流式传输
This commit is contained in:
@ -87,6 +87,15 @@ animator.playAnimation();
|
||||
**`loadAnimationFrames(frames)`**
|
||||
加载动画帧数据
|
||||
|
||||
**`appendAnimationFrames(frames)`**
|
||||
追加动画帧数据(流式场景)
|
||||
|
||||
**`startStreaming()`**
|
||||
开启流式模式,允许边接收边播放
|
||||
|
||||
**`endStreaming()`**
|
||||
结束流式模式
|
||||
|
||||
**`playAnimation()`**
|
||||
播放动画
|
||||
|
||||
|
||||
@ -10,6 +10,10 @@ class BlendShapeAnimator {
|
||||
this.idleAnimations = {};
|
||||
this.blendShapeScale = config.blendShapeScale || 1.0;
|
||||
this.dataFps = config.dataFps || 30;
|
||||
this.isStreaming = false;
|
||||
this.streamingComplete = true;
|
||||
this.streamingWaitStart = null;
|
||||
this.streamingStallMs = 0;
|
||||
|
||||
// 空闲动画参数
|
||||
this.blinkParams = config.blinkParams || {
|
||||
@ -36,6 +40,14 @@ class BlendShapeAnimator {
|
||||
this.enabledExpressions = new Set();
|
||||
this.expressionDurations = {};
|
||||
|
||||
// 播放动画时禁用的 blendshape 列表(由空闲动画控制)
|
||||
this.disabledShapesInAnimation = config.disabledShapesInAnimation || [
|
||||
'eyeblinkleft', 'eyeblinkright', // 眨眼
|
||||
'browdownleft', 'browdownright', // 眉毛下
|
||||
'browinnerup', // 眉毛内上
|
||||
'browouterupleft', 'browouterupright' // 眉毛外上
|
||||
];
|
||||
|
||||
// 状态标志
|
||||
this.isBlinkEnabled = false;
|
||||
this.isEyeLookEnabled = false;
|
||||
@ -63,6 +75,43 @@ class BlendShapeAnimator {
|
||||
this.animationShapeNames = this._collectAnimationShapeNames(this.animationFrames);
|
||||
}
|
||||
|
||||
appendAnimationFrames(frames) {
|
||||
if (!Array.isArray(frames) || frames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.animationFrames.push(...frames);
|
||||
const newNames = this._collectAnimationShapeNames(frames);
|
||||
|
||||
if (newNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingNames = new Set(this.animationShapeNames);
|
||||
newNames.forEach(name => {
|
||||
if (!existingNames.has(name)) {
|
||||
existingNames.add(name);
|
||||
this.animationShapeNames.push(name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startStreaming() {
|
||||
console.log('Starting streaming mode');
|
||||
this.isStreaming = true;
|
||||
this.streamingComplete = false;
|
||||
this.streamingWaitStart = null;
|
||||
this.streamingStallMs = 0;
|
||||
}
|
||||
|
||||
endStreaming() {
|
||||
console.log('Ending streaming mode');
|
||||
this.streamingComplete = true;
|
||||
this.isStreaming = false;
|
||||
this.streamingWaitStart = null;
|
||||
this.streamingStallMs = 0;
|
||||
}
|
||||
|
||||
// 播放动画
|
||||
playAnimation() {
|
||||
if (this.animationFrames.length === 0) {
|
||||
@ -75,15 +124,26 @@ class BlendShapeAnimator {
|
||||
return;
|
||||
}
|
||||
|
||||
// 停止随机表情
|
||||
// 停止并立即重置眼球移动
|
||||
if (this.isEyeLookEnabled) {
|
||||
this._stopRandomEyeLook();
|
||||
this._immediateResetEyeLook();
|
||||
}
|
||||
|
||||
// 停止并立即重置随机表情
|
||||
if (this.isExpressionEnabled && window.ExpressionLibrary) {
|
||||
window.ExpressionLibrary.randomPlayer.stop();
|
||||
this._immediateResetExpressions();
|
||||
}
|
||||
|
||||
// 注意:不停止眨眼,让眨眼继续运行
|
||||
|
||||
this.stopAnimation(false);
|
||||
this.isPlaying = true;
|
||||
this.currentFrameIndex = 0;
|
||||
this.animationStartTime = performance.now();
|
||||
this.streamingWaitStart = null;
|
||||
this.streamingStallMs = 0;
|
||||
|
||||
this._animateFrame();
|
||||
this.onStatusChange('info', '播放中...');
|
||||
@ -94,6 +154,11 @@ class BlendShapeAnimator {
|
||||
this.isPlaying = false;
|
||||
this._resetAnimationInfluences();
|
||||
|
||||
// 恢复眼球移动
|
||||
if (resumeExpressions && this.isEyeLookEnabled) {
|
||||
this._startRandomEyeLook();
|
||||
}
|
||||
|
||||
// 恢复随机表情
|
||||
if (resumeExpressions && this.isExpressionEnabled && window.ExpressionLibrary) {
|
||||
window.ExpressionLibrary.randomPlayer.start();
|
||||
@ -130,12 +195,35 @@ class BlendShapeAnimator {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
const now = performance.now();
|
||||
if (this.streamingWaitStart !== null && this.animationFrames.length > this.currentFrameIndex + 1) {
|
||||
this.streamingStallMs += now - this.streamingWaitStart;
|
||||
this.streamingWaitStart = null;
|
||||
}
|
||||
|
||||
const frameDuration = 1000 / this.dataFps;
|
||||
const elapsed = now - this.animationStartTime;
|
||||
const elapsed = now - this.animationStartTime - this.streamingStallMs;
|
||||
const exactFrame = elapsed / frameDuration;
|
||||
const targetFrameIndex = Math.floor(exactFrame);
|
||||
|
||||
if (targetFrameIndex >= this.animationFrames.length) {
|
||||
if (this.isStreaming && !this.streamingComplete) {
|
||||
if (this.streamingWaitStart === null) {
|
||||
this.streamingWaitStart = now;
|
||||
console.log(`Waiting for more frames... (current: ${this.animationFrames.length}, target: ${targetFrameIndex})`);
|
||||
}
|
||||
const waitTime = now - this.streamingWaitStart;
|
||||
if (waitTime > 30000) {
|
||||
console.warn('Streaming timeout after 30s, stopping animation');
|
||||
this.stopAnimation();
|
||||
return;
|
||||
}
|
||||
if (waitTime > 1000 && Math.floor(waitTime / 1000) !== Math.floor((waitTime - 16) / 1000)) {
|
||||
console.log(`Still waiting... ${Math.floor(waitTime / 1000)}s`);
|
||||
}
|
||||
requestAnimationFrame(() => this._animateFrame());
|
||||
return;
|
||||
}
|
||||
console.log(`Animation complete. Total frames: ${this.animationFrames.length}`);
|
||||
this.stopAnimation();
|
||||
return;
|
||||
}
|
||||
@ -152,6 +240,11 @@ class BlendShapeAnimator {
|
||||
: Object.keys(currentBlendShapes);
|
||||
|
||||
for (const key of shapeNames) {
|
||||
// 跳过禁用列表中的 blendshape,让空闲动画继续控制它们
|
||||
if (this.disabledShapesInAnimation.includes(key.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentValue = currentBlendShapes[key] || 0;
|
||||
const nextValue = nextBlendShapes[key] || 0;
|
||||
const interpolatedValue = this._lerp(currentValue, nextValue, smoothProgress);
|
||||
@ -357,6 +450,38 @@ class BlendShapeAnimator {
|
||||
});
|
||||
}
|
||||
|
||||
_immediateResetEyeLook() {
|
||||
const eyeLookShapes = [
|
||||
'eyelookupleft', 'eyelookupright',
|
||||
'eyelookdownleft', 'eyelookdownright',
|
||||
'eyelookinleft', 'eyelookinright',
|
||||
'eyelookoutleft', 'eyelookoutright'
|
||||
];
|
||||
|
||||
if (!this.morphTargetAdapter) return;
|
||||
|
||||
eyeLookShapes.forEach(name => {
|
||||
this.morphTargetAdapter.setInfluence(name, 0);
|
||||
delete this.idleAnimations[name];
|
||||
});
|
||||
}
|
||||
|
||||
_immediateResetExpressions() {
|
||||
if (!this.morphTargetAdapter || !window.ExpressionLibrary) return;
|
||||
|
||||
// 获取所有表情的 blendshape 名称并立即重置
|
||||
const expressions = window.ExpressionLibrary.expressions;
|
||||
for (const exprKey in expressions) {
|
||||
const expr = expressions[exprKey];
|
||||
if (expr.shapes) {
|
||||
for (const shapeName in expr.shapes) {
|
||||
this.morphTargetAdapter.setInfluence(shapeName, 0);
|
||||
delete this.idleAnimations[shapeName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 随机表情控制
|
||||
toggleRandomExpression(enabled) {
|
||||
if (!window.ExpressionLibrary) {
|
||||
|
||||
@ -30,6 +30,13 @@
|
||||
<input type="text" id="apiUrl" value="http://localhost:5001/text-to-blendshapes">
|
||||
</div>
|
||||
|
||||
<div class="input-group toggle-group">
|
||||
<label>
|
||||
<input type="checkbox" id="streamEnabled" checked>
|
||||
启用流式传输
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>形态键强度: <span id="scaleValue">1.0</span></label>
|
||||
<input type="range" id="scaleSlider" min="0" max="2" step="0.1" value="1.0"
|
||||
|
||||
@ -60,6 +60,7 @@ 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");
|
||||
@ -70,22 +71,11 @@ async function generateAnimation() {
|
||||
showStatus("生成中...", "info");
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text, language: 'zh-CN' })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '请求失败');
|
||||
if (streamEnabled) {
|
||||
await generateAnimationStream(text, apiUrl);
|
||||
} else {
|
||||
await generateAnimationBatch(text, apiUrl);
|
||||
}
|
||||
|
||||
animator.loadAnimationFrames(data.frames);
|
||||
console.log("动画数据:", data.frames);
|
||||
showStatus(`动画生成成功!共 ${data.frames.length} 帧`, "success");
|
||||
|
||||
} catch (err) {
|
||||
showStatus("错误: " + err.message, "error");
|
||||
} finally {
|
||||
@ -93,6 +83,190 @@ async function generateAnimation() {
|
||||
}
|
||||
}
|
||||
|
||||
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)));
|
||||
|
||||
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);
|
||||
console.log(`Flushed ${framesToFlush.length} frames, total: ${animator.animationFrames.length}`);
|
||||
if (!started && animator.animationFrames.length >= minStartFrames) {
|
||||
console.log(`Starting animation with ${animator.animationFrames.length} frames (min: ${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);
|
||||
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();
|
||||
}
|
||||
|
||||
@ -59,6 +59,21 @@ body {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.toggle-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.toggle-group input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user