// 全局变量 let sceneManager; let morphAdapter; let animator; let enabledExpressions = new Set(); let expressionDurations = {}; window.addEventListener('DOMContentLoaded', function () { init(); autoDetectApiUrl(); initCollapseStates(); initPanelCollapses(); initExpressionList(); loadCsvFileList(); }); function init() { // 初始化场景管理器 sceneManager = new BabylonSceneManager('renderCanvas'); sceneManager.init(); // 初始化形态键适配器 morphAdapter = new BabylonMorphTargetAdapter(); // 初始化动画SDK animator = new BlendShapeAnimator({ blendShapeScale: 1.0, dataFps: 30, onStatusChange: showStatus }); animator.setMorphTargetAdapter(morphAdapter); animator.scene = sceneManager.scene; // 把动画更新挂到 Babylon 渲染循环 sceneManager.onBeforeRender = () => animator.tick(); // 导出全局变量供表情库使用 window.animator = animator; window.setIdleAnimation = (name, target, duration, easing) => { animator.setIdleAnimation(name, target, duration, easing); }; window.expressionParams = animator.expressionParams; window.enabledExpressions = enabledExpressions; window.expressionDurations = expressionDurations; updateExpressionInterval(); // 加载3D模型 sceneManager.loadModel('head_a01.glb', async (meshes) => { showStatus("模型加载成功", "success"); const totalTargets = morphAdapter.buildCache(meshes); if (totalTargets === 0) { showStatus("警告: 未找到形态键", "error"); } else { console.log(`✓ 构建缓存完成,共 ${totalTargets} 个形态键`); // 预热:使用写死的预热数据,不请求接口 showStatus("预热中...", "info"); try { await warmupWithLocalData(); console.log('✓ 预热完成'); } catch (e) { console.warn('预热失败:', e); } showStatus("就绪", "success"); } }, (message) => { showStatus("模型加载失败: " + message, "error"); } ); } // 使用本地预热数据进行预热(不请求接口) async function warmupWithLocalData() { console.log('=== warmupWithLocalData 开始 ==='); // 加载预热数据 const response = await fetch('预热数据.json'); const frames = await response.json(); console.log(`加载预热数据: ${frames.length} 帧`); // 保存原始播放速度,设置预热倍速(4倍速快速预热) const originalSpeed = animator.playbackSpeed; animator.playbackSpeed = 4.0; console.log(`预热倍速: ${animator.playbackSpeed}x`); // 加载并播放 animator.loadAnimationFrames(frames); animator.playAnimation(); // 等待播放完成 await new Promise(resolve => { const checkDone = () => { if (!animator.isPlaying) { resolve(); } else { requestAnimationFrame(checkDone); } }; requestAnimationFrame(checkDone); }); // 重置状态 animator.stopAnimation(); animator.loadAnimationFrames([]); morphAdapter.resetAll(); // 恢复原始播放速度 animator.playbackSpeed = originalSpeed; console.log('✓ 本地数据预热完成'); } async function generateAnimation() { const text = document.getElementById('textInput').value.trim(); const apiUrl = document.getElementById('apiUrl').value; const btn = document.getElementById('generateBtn'); const streamEnabled = document.getElementById('streamEnabled')?.checked; if (!text) { showStatus("请输入文字", "error"); return; } btn.disabled = true; showStatus("生成中...", "info"); try { if (streamEnabled) { await generateAnimationStream(text, apiUrl); } else { await generateAnimationBatch(text, apiUrl); } } catch (err) { showStatus("错误: " + err.message + ",请确认接口地址可从当前设备访问", "error"); } finally { btn.disabled = false; } } async function generateAnimationBatch(text, apiUrl) { const url = normalizeApiUrl(apiUrl, false); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text, language: 'zh-CN', first_sentence_split_size: 1 }) }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || '请求失败'); } animator.loadAnimationFrames(data.frames); console.log("动画数据:", data.frames); // 打印可复制的 JSON 格式,过滤掉值为0的字段 const filteredFrames = data.frames.map(frame => { const filtered = { timeCode: frame.timeCode, blendShapes: {} }; for (const [key, value] of Object.entries(frame.blendShapes)) { if (value !== 0) { filtered.blendShapes[key] = value; } } return filtered; }); console.log("========== 复制以下内容作为预热数据 =========="); console.log(JSON.stringify(filteredFrames)); console.log("========== 复制结束 =========="); showStatus(`动画生成成功!共 ${data.frames.length} 帧`, "success"); } async function generateAnimationStream(text, apiUrl) { const url = normalizeApiUrl(apiUrl, true); animator.stopAnimation(); animator.loadAnimationFrames([]); animator.startStreaming(); let started = false; const pendingFrames = []; const streamBufferMs = 1500; const flushBatchMs = 50; const minStartFrames = Math.max(1, Math.round(animator.dataFps * (streamBufferMs / 1000))); const frameBatchSize = Math.max(1, Math.round(animator.dataFps * (flushBatchMs / 1000))); let sentenceTexts = []; const flushFrames = (force = false) => { if (pendingFrames.length === 0) return; const minFlushSize = started ? frameBatchSize : minStartFrames; if (!force && pendingFrames.length < minFlushSize) return; const doFlush = () => { const framesToFlush = pendingFrames.splice(0, pendingFrames.length); animator.appendAnimationFrames(framesToFlush); if (!started && animator.animationFrames.length >= minStartFrames) { console.log(`开始播放,已缓冲 ${animator.animationFrames.length} 帧`); animator.playAnimation(); started = true; } }; if (force || !started) { doFlush(); } else { if (window.requestIdleCallback) { window.requestIdleCallback(doFlush, { timeout: 50 }); } else { setTimeout(doFlush, 0); } } }; const handleMessage = (message) => { if (message.type === 'frame') { pendingFrames.push(message.frame); // 每100帧打印一次进度 if (pendingFrames.length % 100 === 0) { console.log(`[前端] 已接收 ${pendingFrames.length} 帧, sentenceIndex=${message.frame.sentenceIndex}`); } flushFrames(); return; } if (message.type === 'status') { const stageMessage = message.message || 'Streaming'; showStatus(stageMessage, 'info'); console.log('Stream status:', message); if (message.sentence_texts) { sentenceTexts = message.sentence_texts; animator.sentenceTexts = sentenceTexts; console.log('[前端调试] 接收到句子列表:', sentenceTexts); } return; } if (message.type === 'error') { throw new Error(message.message || 'Streaming error'); } if (message.type === 'end') { console.log('Stream ended, flushing remaining frames'); flushFrames(true); animator.endStreaming(); if (!started && animator.animationFrames.length > 0) { animator.playAnimation(); started = true; } const totalFrames = message.frames ?? animator.animationFrames.length; console.log(`Total frames received: ${totalFrames}, in animator: ${animator.animationFrames.length}`); showStatus(`流式动画接收完成,共 ${totalFrames} 帧`, "success"); } }; return new Promise((resolve, reject) => { const worker = new Worker('streamWorker.js'); worker.onmessage = (e) => { const { type, message, error } = e.data; if (type === 'error') { worker.terminate(); animator.endStreaming(); reject(new Error(message || error)); return; } if (type === 'message') { try { handleMessage(message); } catch (err) { worker.terminate(); animator.endStreaming(); reject(err); } return; } if (type === 'complete') { worker.terminate(); flushFrames(true); if (!started && animator.animationFrames.length > 0) { animator.playAnimation(); } animator.endStreaming(); resolve(); } }; worker.onerror = (err) => { worker.terminate(); animator.endStreaming(); reject(new Error('Worker error: ' + err.message)); }; worker.postMessage({ url: url, body: JSON.stringify({ text: text, language: 'zh-CN', first_sentence_split_size: 1 }) }); }); } function normalizeApiUrl(apiUrl, streamEnabled) { if (!apiUrl) { return apiUrl; } const trimmed = apiUrl.replace(/\/+$/, ''); const basePath = '/text-to-blendshapes'; const streamPath = `${basePath}/stream`; if (streamEnabled) { if (trimmed.endsWith(streamPath)) { return trimmed; } if (trimmed.endsWith(basePath)) { return trimmed + '/stream'; } return trimmed + streamPath; } if (trimmed.endsWith(streamPath)) { return trimmed.slice(0, -'/stream'.length); } if (trimmed.endsWith(basePath)) { return trimmed; } return trimmed + basePath; } function playAnimation() { animator.playAnimation(); } function stopAnimation() { animator.stopAnimation(); } function updateScale(value) { animator.updateConfig('blendShapeScale', parseFloat(value)); document.getElementById('scaleValue').textContent = value; } function updateFps(value) { animator.updateConfig('dataFps', parseInt(value, 10)); document.getElementById('fpsValue').textContent = value; } function testBlendShape() { if (morphAdapter.getCacheSize() === 0) { showStatus("模型未加载", "error"); return; } const testShapes = { 'jawopen': 1.0, 'mouthsmileleft': 1.0, 'mouthsmileright': 1.0 }; let successCount = 0; for (const [name, value] of Object.entries(testShapes)) { morphAdapter.setInfluence(name, value); if (morphAdapter.getInfluence(name) > 0) { console.log(`✓ ${name} = ${value}`); successCount++; } else { console.warn(`✗ 未找到: ${name}`); } } showStatus(`测试: ${successCount}/3 成功`, successCount > 0 ? "success" : "error"); } function resetBlendShapes() { morphAdapter.resetAll(); showStatus("已重置", "info"); } function toggleBlink() { const enabled = document.getElementById('blinkEnabled').checked; animator.toggleBlink(enabled); } function toggleEyeLook() { const enabled = document.getElementById('eyeLookEnabled').checked; animator.toggleEyeLook(enabled); } function toggleRandomExpression() { const enabled = document.getElementById('expressionEnabled').checked; animator.toggleRandomExpression(enabled); } // 参数更新函数 function updateBlinkInterval() { const min = parseFloat(document.getElementById('blinkIntervalMin').value) * 1000; const max = parseFloat(document.getElementById('blinkIntervalMax').value) * 1000; animator.blinkParams.intervalMin = min; animator.blinkParams.intervalMax = max; document.getElementById('blinkIntervalValue').textContent = `${min / 1000}-${max / 1000}`; } function updateBlinkDuration(value) { animator.blinkParams.duration = parseInt(value); document.getElementById('blinkDurationValue').textContent = value; } function updateBlinkSpeed(value) { animator.blinkParams.speed = parseInt(value); document.getElementById('blinkSpeedValue').textContent = value; } function updateEyeLookInterval() { const min = parseFloat(document.getElementById('eyeLookIntervalMin').value) * 1000; const max = parseFloat(document.getElementById('eyeLookIntervalMax').value) * 1000; animator.eyeLookParams.intervalMin = min; animator.eyeLookParams.intervalMax = max; document.getElementById('eyeLookIntervalValue').textContent = `${min / 1000}-${max / 1000}`; } function updateEyeLookDuration() { const min = parseInt(document.getElementById('eyeLookDurationMin').value); const max = parseInt(document.getElementById('eyeLookDurationMax').value); animator.eyeLookParams.durationMin = min; animator.eyeLookParams.durationMax = max; document.getElementById('eyeLookDurationValue').textContent = `${min}-${max}`; } function updateEyeLookSpeed(value) { animator.eyeLookParams.speed = parseInt(value); document.getElementById('eyeLookSpeedValue').textContent = value; } function updateExpressionInterval() { const min = parseFloat(document.getElementById('expressionIntervalMin').value) * 1000; const max = parseFloat(document.getElementById('expressionIntervalMax').value) * 1000; animator.expressionParams.intervalMin = min; animator.expressionParams.intervalMax = max; document.getElementById('expressionIntervalValue').textContent = `${min / 1000}-${max / 1000}`; if (window.ExpressionLibrary) { window.ExpressionLibrary.randomPlayer.intervalMin = min; window.ExpressionLibrary.randomPlayer.intervalMax = max; } } function updateExpressionSpeed(value) { animator.expressionParams.speed = parseInt(value); document.getElementById('expressionSpeedValue').textContent = value; } function updateExpressionStrength(value) { animator.expressionParams.strength = parseFloat(value); document.getElementById('expressionStrengthValue').textContent = value; } // 折叠/展开功能 function toggleCollapse(event, paramGroupId) { event.preventDefault(); event.stopPropagation(); const paramGroup = document.getElementById(paramGroupId); const btn = event.target; if (paramGroup.classList.contains('collapsed')) { paramGroup.classList.remove('collapsed'); paramGroup.style.maxHeight = paramGroup.scrollHeight + 'px'; btn.classList.remove('collapsed'); } else { paramGroup.style.maxHeight = paramGroup.scrollHeight + 'px'; setTimeout(() => { paramGroup.classList.add('collapsed'); btn.classList.add('collapsed'); }, 10); } } function initCollapseStates() { const paramGroups = ['blinkParams', 'eyeLookParams', 'expressionParams']; paramGroups.forEach(id => { const paramGroup = document.getElementById(id); const btn = paramGroup.previousElementSibling.querySelector('.collapse-btn'); paramGroup.classList.add('collapsed'); btn.classList.add('collapsed'); }); } 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); return; } const expressionList = document.getElementById('expressionList'); const expressions = window.ExpressionLibrary.getExpressionNames(); expressions.forEach(expr => { enabledExpressions.add(expr.key); const exprData = window.ExpressionLibrary.expressions[expr.key]; expressionDurations[expr.key] = exprData.duration; }); expressions.forEach(expr => { const item = document.createElement('div'); item.className = 'expression-item'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.id = `expr_${expr.key}`; checkbox.checked = true; checkbox.onchange = () => toggleExpression(expr.key, checkbox.checked); const label = document.createElement('label'); label.textContent = expr.name; label.htmlFor = `expr_${expr.key}`; const durationInput = document.createElement('input'); durationInput.type = 'number'; durationInput.min = '500'; durationInput.max = '10000'; durationInput.step = '100'; durationInput.value = expressionDurations[expr.key]; durationInput.onchange = () => updateExpressionDuration(expr.key, durationInput.value); const durationLabel = document.createElement('span'); durationLabel.className = 'duration-label'; durationLabel.textContent = 'ms'; item.appendChild(checkbox); item.appendChild(label); item.appendChild(durationInput); item.appendChild(durationLabel); expressionList.appendChild(item); }); } function updateExpressionDuration(key, value) { expressionDurations[key] = parseInt(value); } function toggleExpression(key, enabled) { if (enabled) { enabledExpressions.add(key); } else { enabledExpressions.delete(key); } } function showStatus(message, type) { const status = document.getElementById('status'); 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 = ''; } }