Files
a2f-service/examples/3d/main.js
yinsx c10cfa7c33 1
2026-02-03 14:16:42 +08:00

1011 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 全局变量
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 = '';
}
}