495 lines
15 KiB
JavaScript
495 lines
15 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);
|
|
|
|
// 导出全局变量供表情库使用
|
|
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} 个形态键`);
|
|
}
|
|
},
|
|
(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);
|
|
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 })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `请求失败 (${response.status})`;
|
|
try {
|
|
const data = await response.json();
|
|
if (data?.error) {
|
|
errorMessage = data.error;
|
|
}
|
|
} catch (err) {
|
|
// ignore json parse errors
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
if (!response.body) {
|
|
await generateAnimationBatch(text, apiUrl);
|
|
return;
|
|
}
|
|
|
|
animator.stopAnimation();
|
|
animator.loadAnimationFrames([]);
|
|
animator.startStreaming();
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let started = false;
|
|
const pendingFrames = [];
|
|
const streamBufferMs = 300;
|
|
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;
|
|
}
|
|
if (!force && pendingFrames.length < frameBatchSize) {
|
|
return;
|
|
}
|
|
const framesToFlush = pendingFrames.splice(0, pendingFrames.length);
|
|
animator.appendAnimationFrames(framesToFlush);
|
|
if (!started && animator.animationFrames.length >= minStartFrames) {
|
|
animator.playAnimation();
|
|
started = true;
|
|
}
|
|
};
|
|
|
|
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");
|
|
}
|
|
};
|
|
|
|
let streamError = null;
|
|
try {
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim()) {
|
|
continue;
|
|
}
|
|
|
|
let message;
|
|
try {
|
|
message = JSON.parse(line);
|
|
} catch (err) {
|
|
continue;
|
|
}
|
|
|
|
handleMessage(message);
|
|
}
|
|
}
|
|
if (buffer.trim()) {
|
|
try {
|
|
handleMessage(JSON.parse(buffer));
|
|
} catch (err) {
|
|
// ignore trailing parse errors
|
|
}
|
|
}
|
|
} catch (err) {
|
|
streamError = err;
|
|
throw err;
|
|
} finally {
|
|
if (!streamError) {
|
|
flushFrames(true);
|
|
if (!started && animator.animationFrames.length > 0) {
|
|
animator.playAnimation();
|
|
}
|
|
}
|
|
animator.endStreaming();
|
|
}
|
|
}
|
|
|
|
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 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;
|
|
}
|