1
This commit is contained in:
@ -31,82 +31,6 @@ class BabylonMorphTargetAdapter {
|
||||
return totalTargets;
|
||||
}
|
||||
|
||||
async warmupInvisible(scene) {
|
||||
console.log('开始预热...');
|
||||
const startTime = performance.now();
|
||||
|
||||
const allTargets = Object.values(this.morphTargetCache).flat();
|
||||
const totalTargets = allTargets.length;
|
||||
console.log(`预热 ${totalTargets} 个 morph targets`);
|
||||
|
||||
// 多轮预热,用不同值组合
|
||||
const rounds = 10;
|
||||
for (let r = 0; r < rounds; r++) {
|
||||
const val = (r % 2 === 0) ? 1.0 : 0;
|
||||
allTargets.forEach(mt => mt.influence = val);
|
||||
scene.render();
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
}
|
||||
|
||||
// 重置
|
||||
allTargets.forEach(mt => mt.influence = 0);
|
||||
scene.render();
|
||||
|
||||
// 等待几帧让 GPU 完全稳定
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
}
|
||||
|
||||
console.log(`预热完成,耗时 ${(performance.now() - startTime).toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
warmupShaders(scene) {
|
||||
console.log('开始shader预热...');
|
||||
const startTime = performance.now();
|
||||
|
||||
// 强制同步更新所有 morph target managers
|
||||
this.meshes?.forEach(mesh => {
|
||||
const mtm = mesh.morphTargetManager;
|
||||
if (mtm) {
|
||||
mtm.enableNormalMorphing = true;
|
||||
mtm.enableTangentMorphing = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 预热:强制触发着色器编译
|
||||
// 使用多种值组合来触发所有可能的shader变体
|
||||
const testValues = [0, 0.2, 0.4, 0.6, 0.8, 1.0];
|
||||
|
||||
for (let pass = 0; pass < testValues.length; pass++) {
|
||||
const value = testValues[pass];
|
||||
for (const targets of Object.values(this.morphTargetCache)) {
|
||||
targets.forEach(mt => {
|
||||
mt.influence = value;
|
||||
});
|
||||
}
|
||||
|
||||
// 每次设置后都渲染,确保shader编译
|
||||
if (scene) {
|
||||
scene.render();
|
||||
}
|
||||
}
|
||||
|
||||
// 重置所有影响值
|
||||
for (const targets of Object.values(this.morphTargetCache)) {
|
||||
targets.forEach(mt => {
|
||||
mt.influence = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 最后渲染一次确保重置生效
|
||||
if (scene) {
|
||||
scene.render();
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - startTime;
|
||||
console.log(`shader预热完成,耗时 ${elapsed.toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
setInfluence(name, value) {
|
||||
const lowerName = name.toLowerCase();
|
||||
const targets = this.morphTargetCache[lowerName];
|
||||
|
||||
@ -24,11 +24,7 @@ class BlendShapeAnimator {
|
||||
this.deltaThreshold = typeof config.deltaThreshold === 'number'
|
||||
? config.deltaThreshold
|
||||
: 0.002; // Skip re-applying nearly identical values
|
||||
this.prewarmFrameCount = typeof config.prewarmFrameCount === 'number'
|
||||
? config.prewarmFrameCount
|
||||
: 30;
|
||||
this.lastFrameBlendShapes = {};
|
||||
this._hasPrewarmed = false;
|
||||
|
||||
// 空闲动画参数
|
||||
this.blinkParams = config.blinkParams || {
|
||||
@ -97,7 +93,6 @@ class BlendShapeAnimator {
|
||||
this.animationFrames = frames || [];
|
||||
this.animationShapeNames = this._collectAnimationShapeNames(this.animationFrames);
|
||||
this.lastFrameBlendShapes = {};
|
||||
// 不重置 _hasPrewarmed,因为全局预热只需要做一次
|
||||
}
|
||||
|
||||
appendAnimationFrames(frames) {
|
||||
@ -107,7 +102,6 @@ class BlendShapeAnimator {
|
||||
|
||||
this.animationFrames.push(...frames);
|
||||
const newNames = this._collectAnimationShapeNames(frames);
|
||||
// 不重置 _hasPrewarmed
|
||||
|
||||
if (newNames.length === 0) {
|
||||
return;
|
||||
@ -227,49 +221,6 @@ class BlendShapeAnimator {
|
||||
this.lastFrameBlendShapes = {};
|
||||
}
|
||||
|
||||
_prewarmAnimation() {
|
||||
// 同步预热已移除,改用异步预热
|
||||
this._hasPrewarmed = true;
|
||||
}
|
||||
|
||||
// 异步分帧预热 - 预热所有 morph targets,确保 GPU 真正渲染
|
||||
async prewarmAsync(scene) {
|
||||
if (!this.morphTargetAdapter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取所有可用的 morph targets,而不只是当前动画用到的
|
||||
const allShapes = this.morphTargetAdapter.morphTargetCache
|
||||
? Object.keys(this.morphTargetAdapter.morphTargetCache)
|
||||
: this.animationShapeNames;
|
||||
|
||||
const shapes = allShapes.filter(
|
||||
name => !this.disabledShapesInAnimation.includes(name.toLowerCase())
|
||||
);
|
||||
|
||||
if (shapes.length === 0) return;
|
||||
|
||||
console.log(`异步预热 ${shapes.length} 个 morph targets...`);
|
||||
|
||||
// 用不同的值组合预热,触发所有可能的 shader 变体
|
||||
const testValues = [0.2, 0.5, 0.8, 1.0];
|
||||
for (const val of testValues) {
|
||||
shapes.forEach(name => this.morphTargetAdapter.setInfluence(name, val));
|
||||
if (scene) scene.render();
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
}
|
||||
|
||||
// 重置并等待几帧确保稳定
|
||||
shapes.forEach(name => this.morphTargetAdapter.setInfluence(name, 0));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (scene) scene.render();
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
}
|
||||
|
||||
this._hasPrewarmed = true;
|
||||
console.log('异步预热完成');
|
||||
}
|
||||
|
||||
_primeFirstFrame() {
|
||||
if (!this.morphTargetAdapter || this.animationFrames.length === 0) {
|
||||
return;
|
||||
@ -297,24 +248,6 @@ class BlendShapeAnimator {
|
||||
}
|
||||
}
|
||||
|
||||
_touchAnimationShapes() {
|
||||
if (!this.morphTargetAdapter || !Array.isArray(this.animationShapeNames) || this.animationShapeNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touchValue = Math.max(this.minBlendShapeValue * 1.2, 0.05);
|
||||
const touched = [];
|
||||
|
||||
this.animationShapeNames.forEach(name => {
|
||||
this.morphTargetAdapter.setInfluence(name, touchValue);
|
||||
touched.push(name);
|
||||
});
|
||||
|
||||
touched.forEach(name => {
|
||||
this.morphTargetAdapter.setInfluence(name, 0);
|
||||
});
|
||||
}
|
||||
|
||||
_scheduleAnimationStart() {
|
||||
// 只设置开始时间,动画由统一的 RAF 循环驱动
|
||||
this.animationStartTime = performance.now();
|
||||
|
||||
@ -17,54 +17,62 @@
|
||||
<body>
|
||||
<canvas id="renderCanvas"></canvas>
|
||||
|
||||
<div class="controls">
|
||||
<h2>数字人形态键控制</h2>
|
||||
|
||||
<div class="input-group">
|
||||
<label>文字输入</label>
|
||||
<textarea id="textInput" placeholder="输入文字生成动画...">君不见,黄河之水天上来,奔流到海不复回,君不见,高堂明镜悲白发,朝如青丝暮成雪。</textarea>
|
||||
<div class="controls collapsible-panel" id="mainControls">
|
||||
<div class="panel-header">
|
||||
<h2>数字人形态键控制</h2>
|
||||
<button type="button" class="panel-toggle" onclick="togglePanel('mainControls')">收起</button>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>API 地址</label>
|
||||
<input type="text" id="apiUrl" value="http://localhost:5001/text-to-blendshapes">
|
||||
<div class="panel-body">
|
||||
<div class="input-group">
|
||||
<label>文字输入</label>
|
||||
<textarea id="textInput" placeholder="输入文字生成动画...">君不见,黄河之水天上来,奔流到海不复回,君不见,高堂明镜悲白发,朝如青丝暮成雪。</textarea>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>API 地址</label>
|
||||
<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"
|
||||
oninput="updateScale(this.value)"
|
||||
style="width: 100%; cursor: pointer;">
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>动画速度 (FPS): <span id="fpsValue">30</span></label>
|
||||
<input type="range" id="fpsSlider" min="10" max="60" step="1" value="30"
|
||||
oninput="updateFps(this.value)"
|
||||
style="width: 100%; cursor: pointer;">
|
||||
</div>
|
||||
|
||||
<button id="generateBtn" onclick="generateAnimation()">生成动画</button>
|
||||
<button onclick="playAnimation()">播放动画</button>
|
||||
<button onclick="stopAnimation()">停止动画</button>
|
||||
<button onclick="testBlendShape()">测试形态键</button>
|
||||
<button onclick="resetBlendShapes()">重置形态键</button>
|
||||
|
||||
<div class="input-group">
|
||||
<label>CSV动画文件</label>
|
||||
<select id="csvSelect">
|
||||
<option value="">-- 选择CSV文件 --</option>
|
||||
</select>
|
||||
<button onclick="loadAndPlayCsv()">播放单个</button>
|
||||
<button id="playAllCsvBtn" onclick="playAllCsv()">播放全部CSV</button>
|
||||
<div id="currentCsvName" style="margin-top: 8px; font-weight: bold; color: #4CAF50;"></div>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
</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"
|
||||
oninput="updateScale(this.value)"
|
||||
style="width: 100%; cursor: pointer;">
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>动画速度 (FPS): <span id="fpsValue">30</span></label>
|
||||
<input type="range" id="fpsSlider" min="10" max="60" step="1" value="30"
|
||||
oninput="updateFps(this.value)"
|
||||
style="width: 100%; cursor: pointer;">
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>播放倍速: <span id="speedValue">1.0x</span></label>
|
||||
<input type="range" id="speedSlider" min="0.25" max="3" step="0.25" value="1.0"
|
||||
oninput="updatePlaybackSpeed(this.value)"
|
||||
style="width: 100%; cursor: pointer;">
|
||||
</div>
|
||||
|
||||
<button id="generateBtn" onclick="generateAnimation()">生成动画</button>
|
||||
<button onclick="playAnimation()">播放动画</button>
|
||||
<button onclick="stopAnimation()">停止动画</button>
|
||||
<button onclick="testBlendShape()">测试形态键</button>
|
||||
<button onclick="resetBlendShapes()">重置形态键</button>
|
||||
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<!-- 当前播放句子显示(屏幕中央) -->
|
||||
@ -72,11 +80,15 @@
|
||||
<div id="sentenceText"></div>
|
||||
</div>
|
||||
|
||||
<div class="idle-controls">
|
||||
<h2>空闲动画控制</h2>
|
||||
<div class="idle-controls collapsible-panel" id="idleControls">
|
||||
<div class="panel-header">
|
||||
<h2>空闲动画控制</h2>
|
||||
<button type="button" class="panel-toggle" onclick="togglePanel('idleControls')">收起</button>
|
||||
</div>
|
||||
|
||||
<!-- 随机眨眼 -->
|
||||
<div class="checkbox-group">
|
||||
<div class="panel-body">
|
||||
<!-- 随机眨眼 -->
|
||||
<div class="checkbox-group">
|
||||
<label>
|
||||
<button class="collapse-btn" onclick="toggleCollapse(event, 'blinkParams')">▼</button>
|
||||
<input type="checkbox" id="blinkEnabled" onchange="toggleBlink()">
|
||||
|
||||
@ -7,8 +7,11 @@ let expressionDurations = {};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
init();
|
||||
autoDetectApiUrl();
|
||||
initCollapseStates();
|
||||
initPanelCollapses();
|
||||
initExpressionList();
|
||||
loadCsvFileList();
|
||||
});
|
||||
|
||||
function init() {
|
||||
@ -113,43 +116,6 @@ async function warmupWithLocalData() {
|
||||
console.log('✓ 本地数据预热完成');
|
||||
}
|
||||
|
||||
// 偷偷调接口预热 - 完全走一遍生成动画流程
|
||||
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();
|
||||
@ -172,7 +138,7 @@ async function warmupWithApi() {
|
||||
await generateAnimationBatch(text, apiUrl);
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus("错误: " + err.message, "error");
|
||||
showStatus("错误: " + err.message + ",请确认接口地址可从当前设备访问", "error");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
@ -386,10 +352,6 @@ function updateFps(value) {
|
||||
document.getElementById('fpsValue').textContent = value;
|
||||
}
|
||||
|
||||
function updatePlaybackSpeed(value) {
|
||||
animator.updateConfig('playbackSpeed', parseFloat(value));
|
||||
document.getElementById('speedValue').textContent = value + 'x';
|
||||
}
|
||||
|
||||
function testBlendShape() {
|
||||
if (morphAdapter.getCacheSize() === 0) {
|
||||
@ -532,6 +494,79 @@ function initCollapseStates() {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
@ -598,3 +633,378 @@ function showStatus(message, type) {
|
||||
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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,8 +43,55 @@ body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.controls h2 {
|
||||
margin-bottom: 15px;
|
||||
.collapsible-panel {
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
padding-bottom: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.panel-toggle {
|
||||
width: auto;
|
||||
min-width: 70px;
|
||||
padding: 6px 10px;
|
||||
margin-top: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.panel-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
|
||||
.panel-body.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-collapsed {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.controls h2,
|
||||
.idle-controls h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@ -283,3 +330,33 @@ button:disabled {
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.controls,
|
||||
.idle-controls {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
padding: 14px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.controls {
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.idle-controls {
|
||||
top: auto;
|
||||
bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user