From 2bd183463d60d7181c1f4ed3e3bb5ba3b2a4b67d Mon Sep 17 00:00:00 2001 From: yinsx Date: Sun, 4 Jan 2026 09:58:26 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E6=8E=A5=E5=8F=A3=E9=A2=84?= =?UTF-8?q?=E7=83=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/3d/.vite/deps/_metadata.json | 8 + examples/3d/.vite/deps/package.json | 3 + examples/3d/SDK_USAGE.md | 5 + examples/3d/babylonAdapter.js | 38 ++++ examples/3d/babylonScene.js | 5 + examples/3d/blendshapeAnimator.js | 215 +++++++++++++----- examples/3d/main.js | 112 ++++++--- examples/3d/performance-test.html | 56 ----- ...ext_to_blendshapes_service.cpython-311.pyc | Bin 15788 -> 19656 bytes .../a2f_api/text_to_blendshapes_service.py | 151 +++++++----- 10 files changed, 386 insertions(+), 207 deletions(-) create mode 100644 examples/3d/.vite/deps/_metadata.json create mode 100644 examples/3d/.vite/deps/package.json delete mode 100644 examples/3d/performance-test.html diff --git a/examples/3d/.vite/deps/_metadata.json b/examples/3d/.vite/deps/_metadata.json new file mode 100644 index 0000000..53188be --- /dev/null +++ b/examples/3d/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "7a1c4e14", + "configHash": "ff0b84f2", + "lockfileHash": "e3b0c442", + "browserHash": "3dd711d0", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/examples/3d/.vite/deps/package.json b/examples/3d/.vite/deps/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/examples/3d/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/examples/3d/SDK_USAGE.md b/examples/3d/SDK_USAGE.md index 42a28a5..53564d0 100644 --- a/examples/3d/SDK_USAGE.md +++ b/examples/3d/SDK_USAGE.md @@ -27,6 +27,9 @@ BlendShapeAnimator 是一个引擎无关的形态键动画SDK,可以与任何3 ```javascript const config = { blendShapeScale: 1.0, // 形态键强度缩放 + minBlendShapeValue: 0.1, // ignore values below this to avoid spikes + deltaThreshold: 0.002, // skip setInfluence when delta is tiny + prewarmFrameCount: 30, // number of frames used for warmup dataFps: 30, // 动画数据帧率 onStatusChange: (type, msg) => { // 状态变化回调 @@ -145,5 +148,7 @@ animator.playAnimation(); - 形态键名称会自动转换为小写进行匹配 - SDK使用 `requestAnimationFrame` 进行动画更新 +- Blendshape values below 0.1 are zeroed to reduce CPU load +- Startup prewarms prewarmFrameCount frames to hide the first-frame hitch - 空闲动画会在主动画播放时自动暂停 - 所有形态键值范围为 0-1 diff --git a/examples/3d/babylonAdapter.js b/examples/3d/babylonAdapter.js index 4951c9b..b2bf58a 100644 --- a/examples/3d/babylonAdapter.js +++ b/examples/3d/babylonAdapter.js @@ -31,10 +31,48 @@ class BabylonMorphTargetAdapter { return totalTargets; } + async warmupInvisible(scene) { + console.log('开始预热...'); + const startTime = performance.now(); + + const allTargets = Object.values(this.morphTargetCache).flat(); + const totalTargets = allTargets.length; + console.log(`预热 ${totalTargets} 个 morph targets`); + + // 多轮预热,用不同值组合 + const rounds = 10; + for (let r = 0; r < rounds; r++) { + const val = (r % 2 === 0) ? 1.0 : 0; + allTargets.forEach(mt => mt.influence = val); + scene.render(); + await new Promise(r => requestAnimationFrame(r)); + } + + // 重置 + allTargets.forEach(mt => mt.influence = 0); + scene.render(); + + // 等待几帧让 GPU 完全稳定 + for (let i = 0; i < 5; i++) { + await new Promise(r => requestAnimationFrame(r)); + } + + console.log(`预热完成,耗时 ${(performance.now() - startTime).toFixed(2)}ms`); + } + warmupShaders(scene) { console.log('开始shader预热...'); const startTime = performance.now(); + // 强制同步更新所有 morph target managers + this.meshes?.forEach(mesh => { + const mtm = mesh.morphTargetManager; + if (mtm) { + mtm.enableNormalMorphing = true; + mtm.enableTangentMorphing = true; + } + }); + // 预热:强制触发着色器编译 // 使用多种值组合来触发所有可能的shader变体 const testValues = [0, 0.2, 0.4, 0.6, 0.8, 1.0]; diff --git a/examples/3d/babylonScene.js b/examples/3d/babylonScene.js index f1a57e8..34fdc10 100644 --- a/examples/3d/babylonScene.js +++ b/examples/3d/babylonScene.js @@ -6,6 +6,7 @@ class BabylonSceneManager { this.scene = null; this.camera = null; this.onModelLoaded = null; + this.onBeforeRender = null; // 渲染前回调 } init() { @@ -28,6 +29,10 @@ class BabylonSceneManager { this.scene.createDefaultEnvironment(); this.engine.runRenderLoop(() => { + // 在渲染前调用动画更新 + if (this.onBeforeRender) { + this.onBeforeRender(); + } this.scene.render(); }); diff --git a/examples/3d/blendshapeAnimator.js b/examples/3d/blendshapeAnimator.js index 44da432..d8daa07 100644 --- a/examples/3d/blendshapeAnimator.js +++ b/examples/3d/blendshapeAnimator.js @@ -2,6 +2,7 @@ class BlendShapeAnimator { constructor(config = {}) { this.morphTargetAdapter = null; + this.scene = null; this.animationFrames = []; this.animationShapeNames = []; this.isPlaying = false; @@ -16,6 +17,17 @@ class BlendShapeAnimator { this.streamingWaitStart = null; this.streamingStallMs = 0; this.sentenceTexts = []; // 句子文本列表 + this.minBlendShapeValue = typeof config.minBlendShapeValue === 'number' + ? config.minBlendShapeValue + : 0.1; // Skip tiny blendshape inputs to avoid stalls + this.deltaThreshold = typeof config.deltaThreshold === 'number' + ? config.deltaThreshold + : 0.002; // Skip re-applying nearly identical values + this.prewarmFrameCount = typeof config.prewarmFrameCount === 'number' + ? config.prewarmFrameCount + : 30; + this.lastFrameBlendShapes = {}; + this._hasPrewarmed = false; // 空闲动画参数 this.blinkParams = config.blinkParams || { @@ -63,8 +75,15 @@ class BlendShapeAnimator { // 回调 this.onStatusChange = config.onStatusChange || (() => {}); - // 启动空闲动画循环 - this._updateIdleAnimations(); + // 不再启动独立的 RAF 循环,由外部渲染循环调用 tick() + } + + // 每帧调用,由外部渲染循环驱动 + tick() { + if (this.isPlaying) { + this._animateFrameOnce(); + } + this._updateIdleAnimationsOnce(); } // 设置形态键适配器 @@ -76,6 +95,8 @@ class BlendShapeAnimator { loadAnimationFrames(frames) { this.animationFrames = frames || []; this.animationShapeNames = this._collectAnimationShapeNames(this.animationFrames); + this.lastFrameBlendShapes = {}; + // 不重置 _hasPrewarmed,因为全局预热只需要做一次 } appendAnimationFrames(frames) { @@ -85,6 +106,7 @@ class BlendShapeAnimator { this.animationFrames.push(...frames); const newNames = this._collectAnimationShapeNames(frames); + // 不重置 _hasPrewarmed if (newNames.length === 0) { return; @@ -116,7 +138,8 @@ class BlendShapeAnimator { } // 播放动画 - playAnimation() { + async playAnimation() { + console.log('=== playAnimation 开始 ==='); if (this.animationFrames.length === 0) { this.onStatusChange('error', '请先加载动画数据'); return; @@ -141,45 +164,24 @@ class BlendShapeAnimator { // 注意:不停止眨眼,让眨眼继续运行 - // 预演前10帧以避免首次播放卡顿 - console.log('预演前10帧...'); - const framesToPreview = Math.min(10, this.animationFrames.length); - for (let i = 0; i < framesToPreview; i++) { - const frame = this.animationFrames[i]; - const blendShapes = frame?.blendShapes || {}; - for (const key in blendShapes) { - if (this.disabledShapesInAnimation.includes(key.toLowerCase())) { - continue; - } - this.morphTargetAdapter.setInfluence(key, blendShapes[key] * this.blendShapeScale); - } - } - // 重置为0 - for (let i = 0; i < framesToPreview; i++) { - const frame = this.animationFrames[i]; - const blendShapes = frame?.blendShapes || {}; - for (const key in blendShapes) { - this.morphTargetAdapter.setInfluence(key, 0); - } - } - console.log('预演完成'); - - this.stopAnimation(false); + this.stopAnimation(false, false); this.isPlaying = true; this.currentFrameIndex = 0; - this.animationStartTime = performance.now(); this.streamingWaitStart = null; this.streamingStallMs = 0; - this._animateFrame(); + this._primeFirstFrame(); + this._scheduleAnimationStart(); this.onStatusChange('info', '播放中...'); } // 停止动画 - stopAnimation(resumeExpressions = true) { + stopAnimation(resumeExpressions = true, resetBlendShapes = true) { this.isPlaying = false; - this._resetAnimationInfluences(); + if (resetBlendShapes) { + this._resetAnimationInfluences(); + } // 恢复眼球移动 if (resumeExpressions && this.isEyeLookEnabled) { @@ -196,12 +198,17 @@ class BlendShapeAnimator { _collectAnimationShapeNames(frames) { const names = new Set(); + const threshold = Math.min(this.minBlendShapeValue, 0.02); frames.forEach(frame => { const blendShapes = frame?.blendShapes; if (!blendShapes) return; - Object.keys(blendShapes).forEach(name => names.add(name)); + Object.entries(blendShapes).forEach(([name, value]) => { + if (Math.abs(value || 0) >= threshold) { + names.add(name); + } + }); }); return Array.from(names); @@ -215,10 +222,107 @@ class BlendShapeAnimator { this.animationShapeNames.forEach(name => { this.morphTargetAdapter.setInfluence(name, 0); }); + + this.lastFrameBlendShapes = {}; } - // 内部动画帧处理 - _animateFrame() { + _prewarmAnimation() { + // 同步预热已移除,改用异步预热 + this._hasPrewarmed = true; + } + + // 异步分帧预热 - 预热所有 morph targets,确保 GPU 真正渲染 + async prewarmAsync(scene) { + if (!this.morphTargetAdapter) { + return; + } + + // 获取所有可用的 morph targets,而不只是当前动画用到的 + const allShapes = this.morphTargetAdapter.morphTargetCache + ? Object.keys(this.morphTargetAdapter.morphTargetCache) + : this.animationShapeNames; + + const shapes = allShapes.filter( + name => !this.disabledShapesInAnimation.includes(name.toLowerCase()) + ); + + if (shapes.length === 0) return; + + console.log(`异步预热 ${shapes.length} 个 morph targets...`); + + // 用不同的值组合预热,触发所有可能的 shader 变体 + const testValues = [0.2, 0.5, 0.8, 1.0]; + for (const val of testValues) { + shapes.forEach(name => this.morphTargetAdapter.setInfluence(name, val)); + if (scene) scene.render(); + await new Promise(r => requestAnimationFrame(r)); + } + + // 重置并等待几帧确保稳定 + shapes.forEach(name => this.morphTargetAdapter.setInfluence(name, 0)); + for (let i = 0; i < 5; i++) { + if (scene) scene.render(); + await new Promise(r => requestAnimationFrame(r)); + } + + this._hasPrewarmed = true; + console.log('异步预热完成'); + } + + _primeFirstFrame() { + if (!this.morphTargetAdapter || this.animationFrames.length === 0) { + return; + } + + const firstFrame = this.animationFrames[0]; + const blendShapes = firstFrame?.blendShapes || {}; + this.lastFrameBlendShapes = {}; + + for (const key in blendShapes) { + const lower = key.toLowerCase(); + if (this.disabledShapesInAnimation.includes(lower)) { + continue; + } + + const scaledValue = (blendShapes[key] || 0) * this.blendShapeScale; + if (Math.abs(scaledValue) < this.minBlendShapeValue) { + this.morphTargetAdapter.setInfluence(key, 0); + this.lastFrameBlendShapes[key] = 0; + continue; + } + + this.morphTargetAdapter.setInfluence(key, scaledValue); + this.lastFrameBlendShapes[key] = scaledValue; + } + } + + _touchAnimationShapes() { + if (!this.morphTargetAdapter || !Array.isArray(this.animationShapeNames) || this.animationShapeNames.length === 0) { + return; + } + + const touchValue = Math.max(this.minBlendShapeValue * 1.2, 0.05); + const touched = []; + + this.animationShapeNames.forEach(name => { + this.morphTargetAdapter.setInfluence(name, touchValue); + touched.push(name); + }); + + touched.forEach(name => { + this.morphTargetAdapter.setInfluence(name, 0); + }); + } + + _scheduleAnimationStart() { + // 只设置开始时间,动画由统一的 RAF 循环驱动 + this.animationStartTime = performance.now(); + this.streamingWaitStart = null; + this.streamingStallMs = 0; + } + + // 内部动画帧处理(单次调用,不递归) + _animateFrameOnce() { if (!this.isPlaying) return; const frameStartTime = performance.now(); @@ -232,6 +336,7 @@ class BlendShapeAnimator { const elapsed = now - this.animationStartTime - this.streamingStallMs; const exactFrame = elapsed / frameDuration; const targetFrameIndex = Math.floor(exactFrame); + const frameProgress = exactFrame - targetFrameIndex; if (targetFrameIndex >= this.animationFrames.length) { if (this.isStreaming && !this.streamingComplete) { @@ -243,12 +348,7 @@ class BlendShapeAnimator { if (waitTime > 30000) { console.warn('Streaming timeout after 30s, stopping animation'); this.stopAnimation(); - return; } - if (waitTime > 1000 && Math.floor(waitTime / 1000) !== Math.floor((waitTime - 16) / 1000)) { - console.log(`Still waiting... ${Math.floor(waitTime / 1000)}s`); - } - requestAnimationFrame(() => this._animateFrame()); return; } console.log(`Animation complete. Total frames: ${this.animationFrames.length}`); @@ -260,7 +360,6 @@ class BlendShapeAnimator { const nextFrame = this.animationFrames[Math.min(targetFrameIndex + 1, this.animationFrames.length - 1)]; const currentBlendShapes = currentFrame?.blendShapes || {}; const nextBlendShapes = nextFrame?.blendShapes || {}; - const frameProgress = exactFrame - targetFrameIndex; const smoothProgress = this._easeOutQuad(frameProgress); const shapeNames = this.animationShapeNames.length > 0 @@ -268,8 +367,6 @@ class BlendShapeAnimator { : Object.keys(currentBlendShapes); let updateCount = 0; - const setInfluenceStart = performance.now(); - for (const key of shapeNames) { // 跳过禁用列表中的 blendshape,让空闲动画继续控制它们 if (this.disabledShapesInAnimation.includes(key.toLowerCase())) { @@ -281,30 +378,36 @@ class BlendShapeAnimator { const interpolatedValue = this._lerp(currentValue, nextValue, smoothProgress); const scaledValue = interpolatedValue * this.blendShapeScale; + const previousValue = this.lastFrameBlendShapes[key] ?? 0; + if (Math.abs(scaledValue) < this.minBlendShapeValue) { + if (Math.abs(previousValue) >= this.minBlendShapeValue) { + this.morphTargetAdapter.setInfluence(key, 0); + this.lastFrameBlendShapes[key] = 0; + updateCount++; + } + continue; + } + + if (Math.abs(scaledValue - previousValue) < this.deltaThreshold) { + continue; + } + this.morphTargetAdapter.setInfluence(key, scaledValue); + this.lastFrameBlendShapes[key] = scaledValue; updateCount++; } - const setInfluenceTime = performance.now() - setInfluenceStart; + this._lastFrameTime = now; this.currentFrameIndex = targetFrameIndex; - // 性能监控前100帧 - if (targetFrameIndex < 100) { - const totalFrameTime = performance.now() - frameStartTime; - if (totalFrameTime > 16.67) { - console.warn(`帧${targetFrameIndex}耗时${totalFrameTime.toFixed(2)}ms (setInfluence: ${setInfluenceTime.toFixed(2)}ms, 调用${updateCount}次)`); - } - } - // 更新当前句子显示 const sentenceIndex = currentFrame?.sentenceIndex ?? -1; if (sentenceIndex !== this.currentSentenceIndex) { this.currentSentenceIndex = sentenceIndex; this._updateCurrentSentenceDisplay(); } - - requestAnimationFrame(() => this._animateFrame()); + // 不再递归调用 RAF,由统一循环驱动 } // 设置空闲动画 @@ -326,8 +429,8 @@ class BlendShapeAnimator { }; } - // 更新空闲动画 - _updateIdleAnimations() { + // 更新空闲动画(单次调用,不递归) + _updateIdleAnimationsOnce() { const now = performance.now(); for (const name in this.idleAnimations) { @@ -359,8 +462,6 @@ class BlendShapeAnimator { } } } - - requestAnimationFrame(() => this._updateIdleAnimations()); } // 眨眼控制 diff --git a/examples/3d/main.js b/examples/3d/main.js index dc0e597..84ced5d 100644 --- a/examples/3d/main.js +++ b/examples/3d/main.js @@ -27,6 +27,10 @@ function init() { }); animator.setMorphTargetAdapter(morphAdapter); + animator.scene = sceneManager.scene; + + // 把动画更新挂到 Babylon 渲染循环 + sceneManager.onBeforeRender = () => animator.tick(); // 导出全局变量供表情库使用 window.animator = animator; @@ -41,7 +45,7 @@ function init() { // 加载3D模型 sceneManager.loadModel('head_a01.glb', - (meshes) => { + async (meshes) => { showStatus("模型加载成功", "success"); const totalTargets = morphAdapter.buildCache(meshes); @@ -50,43 +54,39 @@ function init() { } else { console.log(`✓ 构建缓存完成,共 ${totalTargets} 个形态键`); - // 预热着色器以避免首次动画卡顿 - console.log('预热着色器中...'); - morphAdapter.warmupShaders(sceneManager.scene); - console.log('✓ 着色器预热完成'); - - // 播放120帧预热动画(4秒) - console.log('预热渲染管线中...'); - const dummyFrames = []; - const allBlendShapes = Object.keys(morphAdapter.morphTargetCache); - console.log(`使用 ${allBlendShapes.length} 个blendshapes进行预热`); - - for (let i = 0; i < 120; i++) { - const blendShapes = {}; - const factor = Math.sin(i * 0.12) * 0.5 + 0.5; - - allBlendShapes.forEach((name, index) => { - const phase = (i + index * 5) * 0.18; - const value = Math.sin(phase) * 0.5 + 0.5; - if (value > 0.25) { - blendShapes[name] = value * factor * 0.7; + // 预热:直接调用生成动画流程(和用户点击按钮完全一样) + showStatus("预热中...", "info"); + const apiUrl = document.getElementById('apiUrl').value; + const streamEnabled = document.getElementById('streamEnabled')?.checked; + console.log('预热 apiUrl:', apiUrl, 'stream:', streamEnabled); + if (apiUrl) { + try { + // 和 generateAnimation 走完全一样的路径 + if (streamEnabled) { + await generateAnimationStream('你好', apiUrl); + } else { + await generateAnimationBatch('你好', apiUrl); + playAnimation(); } - }); - - dummyFrames.push({ - timeCode: i / 30, - blendShapes: blendShapes - }); + // 等播放完 + await new Promise(resolve => { + const check = () => animator.isPlaying ? requestAnimationFrame(check) : resolve(); + check(); + }); + // 完全重置状态 + animator.stopAnimation(); + animator.loadAnimationFrames([]); + animator.endStreaming(); + morphAdapter.resetAll(); + console.log('✓ 预热完成'); + } catch (e) { + console.warn('预热失败:', e); + } + } else { + console.warn('预热跳过: apiUrl 为空'); } - animator.loadAnimationFrames(dummyFrames); - animator.playAnimation(); - - setTimeout(() => { - animator.stopAnimation(); - animator.loadAnimationFrames([]); - console.log('✓ 渲染管线预热完成'); - }, 4000); + showStatus("就绪", "success"); } }, (message) => { @@ -95,6 +95,44 @@ function init() { ); } +// 偷偷调接口预热 - 完全走一遍生成动画流程 +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(); const apiUrl = document.getElementById('apiUrl').value; @@ -185,6 +223,10 @@ async function generateAnimationStream(text, apiUrl) { 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; } diff --git a/examples/3d/performance-test.html b/examples/3d/performance-test.html deleted file mode 100644 index 75339eb..0000000 --- a/examples/3d/performance-test.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - 性能测试 - - - -

动画性能监控

-
- - - - diff --git a/services/a2f_api/__pycache__/text_to_blendshapes_service.cpython-311.pyc b/services/a2f_api/__pycache__/text_to_blendshapes_service.cpython-311.pyc index 3e90032e325ef1784eca743a91c398031137f247..cd3751bd2fd669766c67f27a24f9be70a75e75a1 100644 GIT binary patch delta 6855 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 delta 3540 zcmZ`*eN0=|6~FiS+s5A?U}K1l`LJCKEDc|Vkh*{gp-GdHkfD4~lINxnK9Xk>X!;zs zHC>!ETAf~LrD~9_{!77QMaY}hPs{&?Av3gd*YOc!q(5o1un3fvLKWqW{xxq=)YC~BE zI^sCYAQV@`m77$wyyA7l>Ubr=+bxF6#WCahXoQAU#OSwqFWC}I!j?eKNLc-OWn2}r zY*5jx^K%7c&~GVX4euJSV}#y;H~-Y5xH_hPMeQ)aBAntPH1PsnJ*TAu{Je_S@CDZ; zFa+NGgVE)ZAr#lLrnnAXJ!_5|cpcp}$ryoqS0^I0a&PnY_d*UqDqK} z=_I%n_P~fyG(i%$<5u3lTiL1yyQWd266$L3ig z@h0|>2NpJ3quIjRl;Tj`{YA>9NxX20e5SXN&yDuxjR&Qg*MkQFUk@l-!|U1F^?6y; zi+pwDOVdtr#XNw$~fsJ2J)W6?4D47)MTf z{OZl1D@D2;bvStP+YXJ9AOcl7(1#Uqk@|i0*tt(1vw|uJIx}x2>bXw4E zO=&}EZ77GDRj%dlu6nBH`%|9Aw5Jh_YCCWqujrGqG)SY;8&E%Yo&NaVw?&`S}Qy^suteH#ZruzkR4bYm) zn{fv+{%xxj-g)~QN3R{tc=j&@m+_4@!LuLe{q~!x_xAj8PrjGa$ct?`lm{#2*1?2| zE9SceM?FxEk=ZLUEnT0s^sKb>2>TBREj_80zI01p%Gos2IoB}XFuhQpscTr0FUy45 z@J#nyB;~ALb-IPJ14|KrJf)m{X=k4x(%sc9)%NiEWrpq1| zfVjz|e)^WdnnS3tomo=n+dTf+e6#OQ#UB@MlnFyq&^?SpjBqFd1f6NSYYbBl>k{Me?NOrDy8<#4U)#+eQ z%G;CdobjhD6`4RIaJ4DRv_E5U&4oaed?MrVFB}jYEtD(|2xbBQ!z|k~mDTf4T^n9- zq$^ut8iQkI5RQU6H-M43WOHukteZmft>Kz}D10=3z9FjrK7)};C+K!Ai~!^*rR+{C zy9H(Un#G>6mE~j-J%lh1D$0EgqHS=Q{Gl$?)}xoaU)vC_WIm{?z|i@iQXg)TeNfL( zz7bQtsS5ZHtJ@2q`A9GAF)Kc@VZg;-RQ*e;X`uI0BZVgNc6A><{>#8lEN`ZQRoFaF7ht*5VtBFV|uj z+`m`0`SDZax7&Or=cvv!fKS2=QGNg*D<3>{3T|cczin>KeGRUb>{ZxEsD20j+v4}@ zgP3tj7k}5#g=L@fYHp3FnwDMgC!l7vtmZ}LL0zMjF|KCiCorocxOofK5_j{cLeCdK z#WIlH&CXI_jI8NA;hMai(*D8xD#LU}s$U-XHUnI-z z*SuI&B2+dn?OUmA6Dr#>W~Wf#Tr*h`icFC^hm@*-2#K9U=%|zzty!#zPSVu*Xdq*- z3ij%hp*ktc$&k5d)-&_$Yk{kQ6;qX9s=5ou+fbO$akS8KBa4A>2zz6c-wlwJ#$wTd zv(lQ$!81M;*GuL5DIB2CM}FK@heybVT@k#M1a^MWoTOqK1$xx7(qrS}BU#DNSd8;h zp_sWr%6TZzt#joRULd==o%lLA(f#s%4$I*5Qe4v+b270jN7ka8LS&Vw#FbNttQwWN za~hE?K;>0At;mW)^9JtWvl6m@*P+%%Sz!(V%(aWnQm@zuM+e0guGBEo4L9q$+6Iop z 0: - cumulative_time = 0.0 + # 如果不是连续句子,重置累计时间 + if not is_continuation and next_index > 0: + cumulative_time = 0.0 - for frame in frames: - # 调整时间码:从累计时间开始 - frame['timeCode'] = cumulative_time + frame['timeCode'] - frame['sentenceIndex'] = next_index - total_frames += 1 - yield {'type': 'frame', 'frame': frame} + for frame in frames: + # 调整时间码:从累计时间开始 + frame['timeCode'] = cumulative_time + frame['timeCode'] + frame['sentenceIndex'] = next_index + total_frames += 1 + yield {'type': 'frame', 'frame': frame} - # 更新累计时间为当前句子的最后一帧时间 - if frames: - cumulative_time = frames[-1]['timeCode'] + # 更新累计时间为当前句子的最后一帧时间 + if frames: + cumulative_time = frames[-1]['timeCode'] - next_index += 1 + next_index += 1 - print(f"[主线程] 流式传输完成,共 {total_frames} 帧") - yield { - 'type': 'end', - 'frames': total_frames - } + print(f"[主线程] 流式传输完成,共 {total_frames} 帧,处理了 {next_index} 个句子") + yield { + 'type': 'end', + 'frames': total_frames + } + except Exception as e: + import traceback + print(f"[错误] 流式处理异常: {e}") + traceback.print_exc() + yield {'type': 'error', 'message': f'流式处理异常: {str(e)}'} def _process_sentence(self, sentence, output_dir, index): """处理单个句子: TTS -> A2F -> 解析""" @@ -286,6 +303,9 @@ class TextToBlendShapesService: # 12字以上:前6字,再6字,剩下的 parts = [first[:6], first[6:12], first[12:]] + # 过滤空字符串 + parts = [p for p in parts if p.strip()] + # 替换第一句为多个小句 sentences = parts + sentences[1:] # 标记后续部分为连续播放 @@ -293,18 +313,31 @@ class TextToBlendShapesService: print(f"[拆分优化] 第一句({length}字)拆分为{len(parts)}部分: {[len(p) for p in parts]} - 连续播放") if not max_sentence_length or max_sentence_length <= 0: + print(f"[拆分] 最终句子数: {len(sentences)}, is_continuation长度: {len(self.is_continuation)}") return sentences limited = [] - for sentence in sentences: + new_is_continuation = [] + for i, sentence in enumerate(sentences): if len(sentence) <= max_sentence_length: limited.append(sentence) + new_is_continuation.append(self.is_continuation[i] if i < len(self.is_continuation) else False) continue start = 0 + first_part = True while start < len(sentence): limited.append(sentence[start:start + max_sentence_length]) + # 第一部分继承原来的 is_continuation,后续部分标记为连续 + if first_part: + new_is_continuation.append(self.is_continuation[i] if i < len(self.is_continuation) else False) + first_part = False + else: + new_is_continuation.append(True) start += max_sentence_length + + self.is_continuation = new_is_continuation + print(f"[拆分] 最终句子数: {len(limited)}, is_continuation长度: {len(self.is_continuation)}") return limited def _prepare_output_paths(self, output_dir: str = None, suffix: str = None):