1
This commit is contained in:
@ -7,8 +7,11 @@ let expressionDurations = {};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
init();
|
||||
autoDetectApiUrl();
|
||||
initCollapseStates();
|
||||
initPanelCollapses();
|
||||
initExpressionList();
|
||||
loadCsvFileList();
|
||||
});
|
||||
|
||||
function init() {
|
||||
@ -113,43 +116,6 @@ async function warmupWithLocalData() {
|
||||
console.log('✓ 本地数据预热完成');
|
||||
}
|
||||
|
||||
// 偷偷调接口预热 - 完全走一遍生成动画流程
|
||||
async function warmupWithApi() {
|
||||
console.log('=== warmupWithApi 开始 ===');
|
||||
const apiUrl = document.getElementById('apiUrl').value;
|
||||
console.log('apiUrl:', apiUrl);
|
||||
|
||||
try {
|
||||
// 完全走一遍 generateAnimationBatch 流程
|
||||
console.log('调用 generateAnimationBatch...');
|
||||
await generateAnimationBatch('你', apiUrl);
|
||||
console.log('generateAnimationBatch 完成, frames:', animator.animationFrames.length);
|
||||
|
||||
// 播放动画
|
||||
console.log('调用 playAnimation...');
|
||||
animator.playAnimation();
|
||||
|
||||
// 等待播放完成
|
||||
await new Promise(resolve => {
|
||||
const checkDone = () => {
|
||||
if (!animator.isPlaying) {
|
||||
resolve();
|
||||
} else {
|
||||
requestAnimationFrame(checkDone);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(checkDone);
|
||||
});
|
||||
|
||||
// 清空
|
||||
animator.loadAnimationFrames([]);
|
||||
morphAdapter.resetAll();
|
||||
|
||||
console.log('✓ 预热完成');
|
||||
} catch (e) {
|
||||
console.warn('预热失败:', e.message, e);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAnimation() {
|
||||
const text = document.getElementById('textInput').value.trim();
|
||||
@ -172,7 +138,7 @@ async function warmupWithApi() {
|
||||
await generateAnimationBatch(text, apiUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus("错误: " + err.message, "error");
|
||||
showStatus("错误: " + err.message + ",请确认接口地址可从当前设备访问", "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
@ -386,10 +352,6 @@ function updateFps(value) {
|
||||
document.getElementById('fpsValue').textContent = value;
|
||||
}
|
||||
|
||||
function updatePlaybackSpeed(value) {
|
||||
animator.updateConfig('playbackSpeed', parseFloat(value));
|
||||
document.getElementById('speedValue').textContent = value + 'x';
|
||||
}
|
||||
|
||||
function testBlendShape() {
|
||||
if (morphAdapter.getCacheSize() === 0) {
|
||||
@ -532,6 +494,79 @@ function initCollapseStates() {
|
||||
});
|
||||
}
|
||||
|
||||
function autoDetectApiUrl() {
|
||||
const input = document.getElementById('apiUrl');
|
||||
if (!input) return;
|
||||
|
||||
const current = (input.value || '').trim();
|
||||
const pageHost = window.location.hostname;
|
||||
const isPageLocal = pageHost === 'localhost' || pageHost === '127.0.0.1' || pageHost === '';
|
||||
const pageProtocol = window.location.protocol.startsWith('http') ? window.location.protocol : 'http:';
|
||||
|
||||
let parsedUrl = null;
|
||||
try {
|
||||
parsedUrl = new URL(current, `${pageProtocol}//${pageHost || 'localhost'}`);
|
||||
} catch (e) {
|
||||
// ignore parse error, will fallback
|
||||
}
|
||||
|
||||
const isLocalhostInput = !current ||
|
||||
(parsedUrl && (parsedUrl.hostname === 'localhost' || parsedUrl.hostname === '127.0.0.1'));
|
||||
|
||||
const resolveBase = () => {
|
||||
if (pageHost) {
|
||||
return `${pageProtocol}//${pageHost}`;
|
||||
}
|
||||
return 'http://localhost';
|
||||
};
|
||||
|
||||
if (!current) {
|
||||
const defaultPort = parsedUrl?.port ? `:${parsedUrl.port}` : '';
|
||||
input.value = `${resolveBase()}${defaultPort}/text-to-blendshapes`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPageLocal && isLocalhostInput) {
|
||||
const port = parsedUrl?.port ? `:${parsedUrl.port}` : '';
|
||||
const path = parsedUrl?.pathname && parsedUrl.pathname !== '/'
|
||||
? parsedUrl.pathname
|
||||
: '/text-to-blendshapes';
|
||||
input.value = `${resolveBase()}${port}${path}`;
|
||||
showStatus('已切换接口到当前域名,避免移动端访问 localhost 失败', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function setPanelCollapsed(panelId, collapsed) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
|
||||
const body = panel.querySelector('.panel-body');
|
||||
const toggle = panel.querySelector('.panel-toggle');
|
||||
|
||||
if (!body || !toggle) return;
|
||||
|
||||
body.classList.toggle('collapsed', collapsed);
|
||||
panel.classList.toggle('panel-collapsed', collapsed);
|
||||
toggle.textContent = collapsed ? '展开' : '收起';
|
||||
}
|
||||
|
||||
function togglePanel(panelId) {
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return;
|
||||
|
||||
const body = panel.querySelector('.panel-body');
|
||||
if (!body) return;
|
||||
|
||||
setPanelCollapsed(panelId, !body.classList.contains('collapsed'));
|
||||
}
|
||||
|
||||
function initPanelCollapses() {
|
||||
const isMobile = window.matchMedia('(max-width: 900px)').matches;
|
||||
['mainControls', 'idleControls'].forEach(id => {
|
||||
setPanelCollapsed(id, isMobile);
|
||||
});
|
||||
}
|
||||
|
||||
function initExpressionList() {
|
||||
if (!window.ExpressionLibrary) {
|
||||
setTimeout(initExpressionList, 100);
|
||||
@ -598,3 +633,378 @@ function showStatus(message, type) {
|
||||
status.textContent = message;
|
||||
status.className = 'status show ' + type;
|
||||
}
|
||||
|
||||
// CSV文件列表
|
||||
const csvFiles = [];
|
||||
|
||||
// 加载CSV文件列表
|
||||
async function loadCsvFileList() {
|
||||
const csvDir = './csv/';
|
||||
const fileNames = [
|
||||
'01.csv', '02.csv', '03.csv', '03-1.csv', '04.csv', '05.csv',
|
||||
'06-1.csv', '06-2.csv', '07.csv', '08.csv', '09.csv', '10.csv',
|
||||
'11.csv', '12.csv', '13.csv', '14.csv', '15.csv', '16.csv',
|
||||
'17.csv', '18.csv', '19.csv', '20.csv', '21.csv', '22.csv',
|
||||
'23.csv', '24.csv', '25.csv', '26.csv', '27.csv', '28.csv',
|
||||
'29.csv', '30.csv', '31.csv', '32.csv', '33.csv', '34.csv',
|
||||
'35.csv', '36.csv', '37.csv', '38.csv', '39.csv', '40.csv',
|
||||
'41.csv', '42.csv', '43.csv', '44.csv', '45.csv'
|
||||
];
|
||||
|
||||
const select = document.getElementById('csvSelect');
|
||||
if (!select) return;
|
||||
|
||||
fileNames.forEach(name => {
|
||||
const option = document.createElement('option');
|
||||
option.value = csvDir + name;
|
||||
option.textContent = name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
// 解析时间码为秒数 (格式: HH:MM:SS:frame.subframe)
|
||||
// 例如 "16:18:37:36.009755" 表示 16时18分37秒,第36帧,子帧0.009755
|
||||
function parseTimecode(timecode) {
|
||||
const parts = timecode.split(':');
|
||||
if (parts.length < 4) return 0;
|
||||
|
||||
const hours = parseInt(parts[0]) || 0;
|
||||
const minutes = parseInt(parts[1]) || 0;
|
||||
const seconds = parseInt(parts[2]) || 0;
|
||||
const frameAndSubframe = parseFloat(parts[3]) || 0;
|
||||
|
||||
// frameAndSubframe 格式是 "帧号.子帧",例如 "36.009755"
|
||||
// 帧号是该秒内的帧序号(0-59,约60fps)
|
||||
// 子帧是更精确的时间偏移
|
||||
const frameInSecond = Math.floor(frameAndSubframe);
|
||||
const subframe = frameAndSubframe - frameInSecond;
|
||||
|
||||
// 每帧约 1/60 秒 ≈ 16.67ms
|
||||
// 子帧部分直接作为帧内的小数时间
|
||||
const frameTime = (frameInSecond + subframe) / 60;
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds + frameTime;
|
||||
}
|
||||
|
||||
// 解析CSV数据为动画帧
|
||||
function parseCsvToFrames(csvText) {
|
||||
const lines = csvText.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
// 解析表头
|
||||
const headers = lines[0].split(',');
|
||||
const rawFrames = [];
|
||||
|
||||
// blendshape名称映射(CSV列名 -> 小写名称)
|
||||
const blendShapeColumns = {};
|
||||
headers.forEach((header, index) => {
|
||||
if (index >= 2 && index < 54) {
|
||||
blendShapeColumns[index] = header.toLowerCase();
|
||||
}
|
||||
});
|
||||
|
||||
// 解析每一行数据,使用实际时间码
|
||||
let firstTimestamp = null;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const values = lines[i].split(',');
|
||||
if (values.length < 54) continue;
|
||||
|
||||
const timecode = values[0];
|
||||
const timestamp = parseTimecode(timecode);
|
||||
|
||||
if (firstTimestamp === null) {
|
||||
firstTimestamp = timestamp;
|
||||
}
|
||||
|
||||
const blendShapes = {};
|
||||
for (const [index, name] of Object.entries(blendShapeColumns)) {
|
||||
const value = parseFloat(values[index]) || 0;
|
||||
if (value !== 0) {
|
||||
blendShapes[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
rawFrames.push({
|
||||
time: timestamp - firstTimestamp, // 相对时间(秒)
|
||||
blendShapes
|
||||
});
|
||||
}
|
||||
|
||||
if (rawFrames.length === 0) return [];
|
||||
|
||||
// 按时间排序,确保顺序正确
|
||||
rawFrames.sort((a, b) => a.time - b.time);
|
||||
|
||||
const targetFps = 30;
|
||||
const targetFrameDuration = 1 / targetFps;
|
||||
const totalDuration = rawFrames[rawFrames.length - 1].time;
|
||||
|
||||
const frames = [];
|
||||
|
||||
// 二分查找:找到时间 t 对应的源帧索引(返回 time <= t 的最大索引)
|
||||
function findFrameIndex(t) {
|
||||
let left = 0;
|
||||
let right = rawFrames.length - 1;
|
||||
while (left < right) {
|
||||
const mid = Math.ceil((left + right + 1) / 2);
|
||||
if (rawFrames[mid].time <= t) {
|
||||
left = mid;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
for (let t = 0; t < totalDuration; t += targetFrameDuration) {
|
||||
// 使用二分查找找到当前时间对应的源帧
|
||||
const rawIndex = findFrameIndex(t);
|
||||
|
||||
const currentFrame = rawFrames[rawIndex];
|
||||
const nextFrame = rawFrames[Math.min(rawIndex + 1, rawFrames.length - 1)];
|
||||
|
||||
// 计算插值进度
|
||||
const timeDiff = nextFrame.time - currentFrame.time;
|
||||
const frameProgress = timeDiff > 0.0001 ? (t - currentFrame.time) / timeDiff : 0;
|
||||
const clampedProgress = Math.max(0, Math.min(1, frameProgress));
|
||||
|
||||
// 插值计算blendshapes
|
||||
const interpolatedBlendShapes = {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(currentFrame.blendShapes),
|
||||
...Object.keys(nextFrame.blendShapes)
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const currentValue = currentFrame.blendShapes[key] || 0;
|
||||
const nextValue = nextFrame.blendShapes[key] || 0;
|
||||
const interpolatedValue = currentValue + (nextValue - currentValue) * clampedProgress;
|
||||
if (Math.abs(interpolatedValue) > 0.001) {
|
||||
interpolatedBlendShapes[key] = interpolatedValue;
|
||||
}
|
||||
}
|
||||
|
||||
frames.push({
|
||||
timeCode: t,
|
||||
blendShapes: interpolatedBlendShapes
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`CSV解析: 原始${rawFrames.length}帧, 插值到${frames.length}帧 (${targetFps}fps), 时长${totalDuration.toFixed(2)}秒`);
|
||||
return frames;
|
||||
}
|
||||
|
||||
// 所有CSV文件路径
|
||||
let allCsvFiles = [];
|
||||
let currentCsvIndex = 0;
|
||||
let isPlayingAllCsv = false;
|
||||
|
||||
// 加载并播放单个CSV动画
|
||||
async function loadAndPlayCsv() {
|
||||
const select = document.getElementById('csvSelect');
|
||||
if (!select || !select.value) {
|
||||
showStatus("请选择CSV文件", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
showStatus("加载CSV中...", "info");
|
||||
|
||||
try {
|
||||
const response = await fetch(select.value);
|
||||
if (!response.ok) {
|
||||
throw new Error('无法加载文件: ' + select.value);
|
||||
}
|
||||
|
||||
const csvText = await response.text();
|
||||
const frames = parseCsvToFrames(csvText);
|
||||
|
||||
if (frames.length === 0) {
|
||||
showStatus("CSV文件为空或格式错误", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
animator.loadAnimationFrames(frames);
|
||||
animator.playAnimation();
|
||||
showStatus(`CSV加载成功,共 ${frames.length} 帧`, "success");
|
||||
} catch (err) {
|
||||
showStatus("加载失败: " + err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// 按顺序播放所有CSV文件
|
||||
async function playAllCsv() {
|
||||
if (isPlayingAllCsv) {
|
||||
stopAllCsv();
|
||||
return;
|
||||
}
|
||||
|
||||
const csvDir = './csv/';
|
||||
allCsvFiles = [
|
||||
'01.csv', '02.csv', '03.csv', '03-1.csv', '04.csv', '05.csv',
|
||||
'06-1.csv', '06-2.csv', '07.csv', '08.csv', '09.csv', '10.csv',
|
||||
'11.csv', '12.csv', '13.csv', '14.csv', '15.csv', '16.csv',
|
||||
'17.csv', '18.csv', '19.csv', '20.csv', '21.csv', '22.csv',
|
||||
'23.csv', '24.csv', '25.csv', '26.csv', '27.csv', '28.csv',
|
||||
'29.csv', '30.csv', '31.csv', '32.csv', '33.csv', '34.csv',
|
||||
'35.csv', '36.csv', '37.csv', '38.csv', '39.csv', '40.csv',
|
||||
'41.csv', '42.csv', '43.csv', '44.csv', '45.csv'
|
||||
].map(name => csvDir + name);
|
||||
|
||||
currentCsvIndex = 0;
|
||||
isPlayingAllCsv = true;
|
||||
|
||||
document.getElementById('playAllCsvBtn').textContent = '停止播放';
|
||||
await playNextCsv();
|
||||
}
|
||||
|
||||
// 播放下一个CSV
|
||||
async function playNextCsv() {
|
||||
if (!isPlayingAllCsv || currentCsvIndex >= allCsvFiles.length) {
|
||||
stopAllCsv();
|
||||
showStatus("全部CSV播放完成", "success");
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = allCsvFiles[currentCsvIndex];
|
||||
const fileName = filePath.split('/').pop();
|
||||
showStatus(`播放 ${fileName} (${currentCsvIndex + 1}/${allCsvFiles.length})`, "info");
|
||||
|
||||
// 显示当前播放的文件名
|
||||
const nameDiv = document.getElementById('currentCsvName');
|
||||
if (nameDiv) {
|
||||
nameDiv.textContent = `正在播放: ${fileName} (${currentCsvIndex + 1}/${allCsvFiles.length})`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(filePath);
|
||||
if (!response.ok) {
|
||||
throw new Error('无法加载: ' + fileName);
|
||||
}
|
||||
|
||||
const csvText = await response.text();
|
||||
const frames = parseCsvToFrames(csvText);
|
||||
|
||||
if (frames.length === 0) {
|
||||
currentCsvIndex++;
|
||||
await playNextCsv();
|
||||
return;
|
||||
}
|
||||
|
||||
// 快速过渡(6帧 = 约0.2秒过渡时间,让动画衔接更紧凑)
|
||||
const transitionFrameCount = 6;
|
||||
const transitionFrames = createTransitionFrames(frames[0], transitionFrameCount);
|
||||
|
||||
// 停止当前动画但不重置blendshape
|
||||
animator.isPlaying = false;
|
||||
|
||||
// 计算过渡时间偏移量
|
||||
const transitionDuration = transitionFrameCount / 30;
|
||||
|
||||
// 调整后续帧的timeCode,使其在过渡帧之后
|
||||
const adjustedFrames = frames.map(frame => ({
|
||||
...frame,
|
||||
timeCode: frame.timeCode + transitionDuration
|
||||
}));
|
||||
|
||||
const allFrames = [...transitionFrames, ...adjustedFrames];
|
||||
animator.loadAnimationFrames(allFrames);
|
||||
animator.playAnimation();
|
||||
|
||||
// 监听播放完成
|
||||
waitForAnimationEnd().then(() => {
|
||||
if (isPlayingAllCsv) {
|
||||
currentCsvIndex++;
|
||||
playNextCsv();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('跳过文件:', err.message);
|
||||
currentCsvIndex++;
|
||||
await playNextCsv();
|
||||
}
|
||||
}
|
||||
|
||||
// 创建过渡帧(从当前状态平滑过渡到目标帧)
|
||||
function createTransitionFrames(targetFrame, frameCount) {
|
||||
const frames = [];
|
||||
const currentBlendShapes = {};
|
||||
|
||||
// 从animator的lastFrameBlendShapes获取当前状态(更准确)
|
||||
if (animator && animator.lastFrameBlendShapes) {
|
||||
for (const [name, value] of Object.entries(animator.lastFrameBlendShapes)) {
|
||||
if (Math.abs(value) > 0.001) {
|
||||
currentBlendShapes[name.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有上一帧数据,从morphAdapter获取
|
||||
if (Object.keys(currentBlendShapes).length === 0 && morphAdapter && morphAdapter.cache) {
|
||||
for (const name of Object.keys(morphAdapter.cache)) {
|
||||
const value = morphAdapter.getInfluence(name) || 0;
|
||||
if (Math.abs(value) > 0.001) {
|
||||
currentBlendShapes[name.toLowerCase()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const targetBlendShapes = {};
|
||||
for (const [name, value] of Object.entries(targetFrame.blendShapes || {})) {
|
||||
targetBlendShapes[name.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
// 合并所有需要过渡的blendshape名称
|
||||
const allNames = new Set([
|
||||
...Object.keys(currentBlendShapes),
|
||||
...Object.keys(targetBlendShapes)
|
||||
]);
|
||||
|
||||
// 生成过渡帧
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const progress = (i + 1) / frameCount;
|
||||
// 使用easeOutQuad缓动(快速到达目标,让过渡更即时)
|
||||
const eased = 1 - (1 - progress) * (1 - progress);
|
||||
|
||||
const blendShapes = {};
|
||||
for (const name of allNames) {
|
||||
const from = currentBlendShapes[name] || 0;
|
||||
const to = targetBlendShapes[name] || 0;
|
||||
const value = from + (to - from) * eased;
|
||||
if (Math.abs(value) > 0.0001) {
|
||||
blendShapes[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
frames.push({
|
||||
timeCode: i / 30,
|
||||
blendShapes: blendShapes
|
||||
});
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
// 等待动画播放完成
|
||||
function waitForAnimationEnd() {
|
||||
return new Promise(resolve => {
|
||||
const check = () => {
|
||||
if (!animator.isPlaying) {
|
||||
resolve();
|
||||
} else {
|
||||
requestAnimationFrame(check);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(check);
|
||||
});
|
||||
}
|
||||
|
||||
// 停止播放所有CSV
|
||||
function stopAllCsv() {
|
||||
isPlayingAllCsv = false;
|
||||
animator.stopAnimation();
|
||||
document.getElementById('playAllCsvBtn').textContent = '播放全部CSV';
|
||||
const nameDiv = document.getElementById('currentCsvName');
|
||||
if (nameDiv) {
|
||||
nameDiv.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user