1011 lines
32 KiB
JavaScript
1011 lines
32 KiB
JavaScript
// 全局变量
|
||
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 = '';
|
||
}
|
||
}
|