This commit is contained in:
yinsx
2026-02-03 14:16:42 +08:00
parent f98ff21394
commit c10cfa7c33
12 changed files with 603 additions and 283 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ data/
a2f_venv/ a2f_venv/
external/ external/
nul nul
examples

View File

@ -31,82 +31,6 @@ class BabylonMorphTargetAdapter {
return totalTargets; 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];
for (let pass = 0; pass < testValues.length; pass++) {
const value = testValues[pass];
for (const targets of Object.values(this.morphTargetCache)) {
targets.forEach(mt => {
mt.influence = value;
});
}
// 每次设置后都渲染,确保shader编译
if (scene) {
scene.render();
}
}
// 重置所有影响值
for (const targets of Object.values(this.morphTargetCache)) {
targets.forEach(mt => {
mt.influence = 0;
});
}
// 最后渲染一次确保重置生效
if (scene) {
scene.render();
}
const elapsed = performance.now() - startTime;
console.log(`shader预热完成,耗时 ${elapsed.toFixed(2)}ms`);
}
setInfluence(name, value) { setInfluence(name, value) {
const lowerName = name.toLowerCase(); const lowerName = name.toLowerCase();
const targets = this.morphTargetCache[lowerName]; const targets = this.morphTargetCache[lowerName];

View File

@ -24,11 +24,7 @@ class BlendShapeAnimator {
this.deltaThreshold = typeof config.deltaThreshold === 'number' this.deltaThreshold = typeof config.deltaThreshold === 'number'
? config.deltaThreshold ? config.deltaThreshold
: 0.002; // Skip re-applying nearly identical values : 0.002; // Skip re-applying nearly identical values
this.prewarmFrameCount = typeof config.prewarmFrameCount === 'number'
? config.prewarmFrameCount
: 30;
this.lastFrameBlendShapes = {}; this.lastFrameBlendShapes = {};
this._hasPrewarmed = false;
// 空闲动画参数 // 空闲动画参数
this.blinkParams = config.blinkParams || { this.blinkParams = config.blinkParams || {
@ -97,7 +93,6 @@ class BlendShapeAnimator {
this.animationFrames = frames || []; this.animationFrames = frames || [];
this.animationShapeNames = this._collectAnimationShapeNames(this.animationFrames); this.animationShapeNames = this._collectAnimationShapeNames(this.animationFrames);
this.lastFrameBlendShapes = {}; this.lastFrameBlendShapes = {};
// 不重置 _hasPrewarmed因为全局预热只需要做一次
} }
appendAnimationFrames(frames) { appendAnimationFrames(frames) {
@ -107,7 +102,6 @@ class BlendShapeAnimator {
this.animationFrames.push(...frames); this.animationFrames.push(...frames);
const newNames = this._collectAnimationShapeNames(frames); const newNames = this._collectAnimationShapeNames(frames);
// 不重置 _hasPrewarmed
if (newNames.length === 0) { if (newNames.length === 0) {
return; return;
@ -227,49 +221,6 @@ class BlendShapeAnimator {
this.lastFrameBlendShapes = {}; this.lastFrameBlendShapes = {};
} }
_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() { _primeFirstFrame() {
if (!this.morphTargetAdapter || this.animationFrames.length === 0) { if (!this.morphTargetAdapter || this.animationFrames.length === 0) {
return; return;
@ -297,24 +248,6 @@ class BlendShapeAnimator {
} }
} }
_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() { _scheduleAnimationStart() {
// 只设置开始时间,动画由统一的 RAF 循环驱动 // 只设置开始时间,动画由统一的 RAF 循环驱动
this.animationStartTime = performance.now(); this.animationStartTime = performance.now();

View File

@ -17,54 +17,62 @@
<body> <body>
<canvas id="renderCanvas"></canvas> <canvas id="renderCanvas"></canvas>
<div class="controls"> <div class="controls collapsible-panel" id="mainControls">
<h2>数字人形态键控制</h2> <div class="panel-header">
<h2>数字人形态键控制</h2>
<div class="input-group"> <button type="button" class="panel-toggle" onclick="togglePanel('mainControls')">收起</button>
<label>文字输入</label>
<textarea id="textInput" placeholder="输入文字生成动画...">君不见,黄河之水天上来,奔流到海不复回,君不见,高堂明镜悲白发,朝如青丝暮成雪。</textarea>
</div> </div>
<div class="input-group"> <div class="panel-body">
<label>API 地址</label> <div class="input-group">
<input type="text" id="apiUrl" value="http://localhost:5001/text-to-blendshapes"> <label>文字输入</label>
<textarea id="textInput" placeholder="输入文字生成动画...">君不见,黄河之水天上来,奔流到海不复回,君不见,高堂明镜悲白发,朝如青丝暮成雪。</textarea>
</div>
<div class="input-group">
<label>API 地址</label>
<input type="text" id="apiUrl" value="http://localhost:5001/text-to-blendshapes">
</div>
<div class="input-group toggle-group">
<label>
<input type="checkbox" id="streamEnabled" checked>
启用流式传输
</label>
</div>
<div class="input-group">
<label>形态键强度: <span id="scaleValue">1.0</span></label>
<input type="range" id="scaleSlider" min="0" max="2" step="0.1" value="1.0"
oninput="updateScale(this.value)"
style="width: 100%; cursor: pointer;">
</div>
<div class="input-group">
<label>动画速度 (FPS): <span id="fpsValue">30</span></label>
<input type="range" id="fpsSlider" min="10" max="60" step="1" value="30"
oninput="updateFps(this.value)"
style="width: 100%; cursor: pointer;">
</div>
<button id="generateBtn" onclick="generateAnimation()">生成动画</button>
<button onclick="playAnimation()">播放动画</button>
<button onclick="stopAnimation()">停止动画</button>
<button onclick="testBlendShape()">测试形态键</button>
<button onclick="resetBlendShapes()">重置形态键</button>
<div class="input-group">
<label>CSV动画文件</label>
<select id="csvSelect">
<option value="">-- 选择CSV文件 --</option>
</select>
<button onclick="loadAndPlayCsv()">播放单个</button>
<button id="playAllCsvBtn" onclick="playAllCsv()">播放全部CSV</button>
<div id="currentCsvName" style="margin-top: 8px; font-weight: bold; color: #4CAF50;"></div>
</div>
<div class="status" id="status"></div>
</div> </div>
<div class="input-group toggle-group">
<label>
<input type="checkbox" id="streamEnabled" checked>
启用流式传输
</label>
</div>
<div class="input-group">
<label>形态键强度: <span id="scaleValue">1.0</span></label>
<input type="range" id="scaleSlider" min="0" max="2" step="0.1" value="1.0"
oninput="updateScale(this.value)"
style="width: 100%; cursor: pointer;">
</div>
<div class="input-group">
<label>动画速度 (FPS): <span id="fpsValue">30</span></label>
<input type="range" id="fpsSlider" min="10" max="60" step="1" value="30"
oninput="updateFps(this.value)"
style="width: 100%; cursor: pointer;">
</div>
<div class="input-group">
<label>播放倍速: <span id="speedValue">1.0x</span></label>
<input type="range" id="speedSlider" min="0.25" max="3" step="0.25" value="1.0"
oninput="updatePlaybackSpeed(this.value)"
style="width: 100%; cursor: pointer;">
</div>
<button id="generateBtn" onclick="generateAnimation()">生成动画</button>
<button onclick="playAnimation()">播放动画</button>
<button onclick="stopAnimation()">停止动画</button>
<button onclick="testBlendShape()">测试形态键</button>
<button onclick="resetBlendShapes()">重置形态键</button>
<div class="status" id="status"></div>
</div> </div>
<!-- 当前播放句子显示(屏幕中央) --> <!-- 当前播放句子显示(屏幕中央) -->
@ -72,11 +80,15 @@
<div id="sentenceText"></div> <div id="sentenceText"></div>
</div> </div>
<div class="idle-controls"> <div class="idle-controls collapsible-panel" id="idleControls">
<h2>空闲动画控制</h2> <div class="panel-header">
<h2>空闲动画控制</h2>
<button type="button" class="panel-toggle" onclick="togglePanel('idleControls')">收起</button>
</div>
<!-- 随机眨眼 --> <div class="panel-body">
<div class="checkbox-group"> <!-- 随机眨眼 -->
<div class="checkbox-group">
<label> <label>
<button class="collapse-btn" onclick="toggleCollapse(event, 'blinkParams')"></button> <button class="collapse-btn" onclick="toggleCollapse(event, 'blinkParams')"></button>
<input type="checkbox" id="blinkEnabled" onchange="toggleBlink()"> <input type="checkbox" id="blinkEnabled" onchange="toggleBlink()">

View File

@ -7,8 +7,11 @@ let expressionDurations = {};
window.addEventListener('DOMContentLoaded', function () { window.addEventListener('DOMContentLoaded', function () {
init(); init();
autoDetectApiUrl();
initCollapseStates(); initCollapseStates();
initPanelCollapses();
initExpressionList(); initExpressionList();
loadCsvFileList();
}); });
function init() { function init() {
@ -113,43 +116,6 @@ async function warmupWithLocalData() {
console.log('✓ 本地数据预热完成'); 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() { async function generateAnimation() {
const text = document.getElementById('textInput').value.trim(); const text = document.getElementById('textInput').value.trim();
@ -172,7 +138,7 @@ async function warmupWithApi() {
await generateAnimationBatch(text, apiUrl); await generateAnimationBatch(text, apiUrl);
} }
} catch (err) { } catch (err) {
showStatus("错误: " + err.message, "error"); showStatus("错误: " + err.message + ",请确认接口地址可从当前设备访问", "error");
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
@ -386,10 +352,6 @@ function updateFps(value) {
document.getElementById('fpsValue').textContent = value; document.getElementById('fpsValue').textContent = value;
} }
function updatePlaybackSpeed(value) {
animator.updateConfig('playbackSpeed', parseFloat(value));
document.getElementById('speedValue').textContent = value + 'x';
}
function testBlendShape() { function testBlendShape() {
if (morphAdapter.getCacheSize() === 0) { 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() { function initExpressionList() {
if (!window.ExpressionLibrary) { if (!window.ExpressionLibrary) {
setTimeout(initExpressionList, 100); setTimeout(initExpressionList, 100);
@ -598,3 +633,378 @@ function showStatus(message, type) {
status.textContent = message; status.textContent = message;
status.className = 'status show ' + type; 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 = '';
}
}

View File

@ -43,8 +43,55 @@ body {
overflow-y: auto; overflow-y: auto;
} }
.controls h2 { .collapsible-panel {
margin-bottom: 15px; z-index: 900;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
position: sticky;
top: 0;
background: rgba(0, 0, 0, 0.9);
padding-bottom: 10px;
z-index: 1;
}
.panel-toggle {
width: auto;
min-width: 70px;
padding: 6px 10px;
margin-top: 0;
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.panel-toggle:hover {
background: rgba(255, 255, 255, 0.2);
}
.panel-body {
transition: max-height 0.3s ease;
}
.panel-body.collapsed {
display: none;
}
.panel-collapsed {
background: rgba(0, 0, 0, 0.7);
}
.controls h2,
.idle-controls h2 {
margin: 0;
font-size: 18px; font-size: 18px;
} }
@ -283,3 +330,33 @@ button:disabled {
color: #888; color: #888;
white-space: nowrap; white-space: nowrap;
} }
@media (max-width: 900px) {
.controls,
.idle-controls {
position: fixed;
left: 12px;
right: 12px;
width: auto;
max-width: none;
padding: 14px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.controls {
top: 12px;
}
.idle-controls {
top: auto;
bottom: 12px;
}
.panel-header {
position: relative;
}
button {
font-size: 13px;
}
}

6
services/a2f_api/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/node_modules/
/public/
/dist/
nul
/csv/*
examples

View File

@ -8,7 +8,7 @@ import shutil
from datetime import datetime from datetime import datetime
class A2FService: class A2FService:
def __init__(self, a2f_url="192.168.1.39:52000"): def __init__(self, a2f_url="192.168.1.230:52000"):
self.base_dir = Path(__file__).parent.parent.parent self.base_dir = Path(__file__).parent.parent.parent
self.a2f_script = self.base_dir / "external" / "Audio2Face-3D-Samples" / "scripts" / "audio2face_3d_microservices_interaction_app" / "a2f_3d.py" self.a2f_script = self.base_dir / "external" / "Audio2Face-3D-Samples" / "scripts" / "audio2face_3d_microservices_interaction_app" / "a2f_3d.py"
self.config_file = self.base_dir / "external" / "Audio2Face-3D-Samples" / "scripts" / "audio2face_3d_microservices_interaction_app" / "config" / "config_james.yml" self.config_file = self.base_dir / "external" / "Audio2Face-3D-Samples" / "scripts" / "audio2face_3d_microservices_interaction_app" / "config" / "config_james.yml"

View File

@ -13,7 +13,7 @@ from blend_shape_parser import BlendShapeParser
class TextToBlendShapesService: class TextToBlendShapesService:
DEFAULT_SPLIT_PUNCTUATIONS = '。!?;!?;,' DEFAULT_SPLIT_PUNCTUATIONS = '。!?;!?;,'
def __init__(self, lang='zh-CN', a2f_url="192.168.1.39:52000", tts_provider='edge-tts'): def __init__(self, lang='zh-CN', a2f_url="192.168.1.230:52000", tts_provider='edge-tts'):
""" """
初始化服务 初始化服务
:param lang: 语言 :param lang: 语言

View File

@ -1,43 +0,0 @@
# 工作日报 - 2025年12月25日
## 今日完成工作
### 1. 修复句子拆分导致的播放停顿问题
- **问题**原系统将长句子前2-3个字单独拆分导致播放时出现不自然的停顿
- **解决**:移除激进拆分逻辑,实现智能拆分策略
### 2. 实现可配置的智能拆分规则
- **≤8字**:不拆分,整句处理
- **9-12字**拆分为2部分并发处理
- **>12字**拆分为3部分6字+6字+剩余)并发处理
- **效果**:平衡了响应速度和播放流畅性
### 3. 实现流式传输功能
- 支持动画帧数据的实时流式推送
- 边生成边传输,降低首帧延迟
- 使用队列机制保证帧顺序的正确性
### 4. 修复时间码连续性问题
- **问题**:拆分后的片段时间码重置,导致动画不连续
- **解决**:重构时间码调整逻辑,连续片段保持累计时间无缝衔接
### 5. 添加连续片段标记机制
- 在每个动画帧中添加 `isContinuation` 标记
- 为前端提供片段连续性信息,便于后续优化
### 6. 优化并发处理性能
- 使用多线程ThreadPoolExecutor并行生成TTS和A2F数据
- 长句子60字处理速度提升约3倍
### 7. 更新API接口和前端调用
- 添加 `first_sentence_split_size` 参数控制拆分行为
- 前端默认启用拆分优化
### 8. 涉及文件
- 后端:`services/a2f_api/text_to_blendshapes_service.py``api.py`
- 前端:`examples/3d/main.js`
---
**日期**2025年12月25日
**项目**:文本转语音动画服务优化