Files
a2f-service/examples/3d/main.js
2025-12-29 11:22:51 +08:00

523 lines
17 KiB
JavaScript
Raw 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();
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);
// 导出全局变量供表情库使用
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',
(meshes) => {
showStatus("模型加载成功", "success");
const totalTargets = morphAdapter.buildCache(meshes);
if (totalTargets === 0) {
showStatus("警告: 未找到形态键", "error");
} else {
console.log(`✓ 构建缓存完成,共 ${totalTargets} 个形态键`);
// 预热着色器以避免首次动画卡顿
console.log('预热着色器中...');
morphAdapter.warmupShaders(sceneManager.scene);
console.log('✓ 着色器预热完成');
// 播放120帧预热动画4秒
console.log('预热渲染管线中...');
const dummyFrames = [];
const allBlendShapes = Object.keys(morphAdapter.morphTargetCache);
console.log(`使用 ${allBlendShapes.length} 个blendshapes进行预热`);
for (let i = 0; i < 120; i++) {
const blendShapes = {};
const factor = Math.sin(i * 0.12) * 0.5 + 0.5;
allBlendShapes.forEach((name, index) => {
const phase = (i + index * 5) * 0.18;
const value = Math.sin(phase) * 0.5 + 0.5;
if (value > 0.25) {
blendShapes[name] = value * factor * 0.7;
}
});
dummyFrames.push({
timeCode: i / 30,
blendShapes: blendShapes
});
}
animator.loadAnimationFrames(dummyFrames);
animator.playAnimation();
setTimeout(() => {
animator.stopAnimation();
animator.loadAnimationFrames([]);
console.log('✓ 渲染管线预热完成');
}, 4000);
}
},
(message) => {
showStatus("模型加载失败: " + message, "error");
}
);
}
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);
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;
}