Files
a2f-service/examples/3d/main.js
2026-01-04 09:58:26 +08:00

565 lines
18 KiB
JavaScript

// 全局变量
let sceneManager;
let morphAdapter;
let animator;
let enabledExpressions = new Set();
let expressionDurations = {};
window.addEventListener('DOMContentLoaded', function () {
init();
initCollapseStates();
initExpressionList();
});
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");
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();
}
// 等播放完
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 为空');
}
showStatus("就绪", "success");
}
},
(message) => {
showStatus("模型加载失败: " + message, "error");
}
);
}
// 偷偷调接口预热 - 完全走一遍生成动画流程
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;
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);
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 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;
}