init
This commit is contained in:
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pip install:*)",
|
||||||
|
"Bash(python run.py:*)",
|
||||||
|
"Bash(tree:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(dir:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
data/
|
||||||
|
a2f_venv/
|
||||||
|
external/
|
||||||
11
README.md
Normal file
11
README.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
1.本项目需要下载 Audio2Face-3D-Samples
|
||||||
|
https://github.com/NVIDIA/Audio2Face-3D-Samples?tab=readme-ov-file 放到 根目录 external/ 目录下
|
||||||
|
2.虚拟环境创建 a2f_venv
|
||||||
|
python -m venv a2f_venv
|
||||||
|
.\a2f_venv\Scripts\Activate.ps1
|
||||||
|
3.导航到service\a2f_api目录安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
4.运行服务
|
||||||
|
python app.py
|
||||||
|
|
||||||
|
5.打开案例目录 examples\3d\ 控制台输入live-server index.html 即可打开案例
|
||||||
140
examples/3d/SDK_USAGE.md
Normal file
140
examples/3d/SDK_USAGE.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# BlendShape Animator SDK 使用文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
BlendShapeAnimator 是一个引擎无关的形态键动画SDK,可以与任何3D引擎配合使用。
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 形态键适配器 (Morph Target Adapter)
|
||||||
|
|
||||||
|
适配器是连接SDK和3D引擎的桥梁,需要实现以下接口:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
setInfluence(name, value) {
|
||||||
|
// 设置形态键的影响值 (0-1)
|
||||||
|
},
|
||||||
|
getInfluence(name) {
|
||||||
|
// 获取形态键的当前影响值
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 初始化配置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const config = {
|
||||||
|
blendShapeScale: 1.0, // 形态键强度缩放
|
||||||
|
dataFps: 30, // 动画数据帧率
|
||||||
|
onStatusChange: (type, msg) => {
|
||||||
|
// 状态变化回调
|
||||||
|
},
|
||||||
|
blinkParams: { // 眨眼参数
|
||||||
|
intervalMin: 2000,
|
||||||
|
intervalMax: 5000,
|
||||||
|
duration: 150,
|
||||||
|
speed: 100
|
||||||
|
},
|
||||||
|
eyeLookParams: { // 眼球移动参数
|
||||||
|
intervalMin: 2000,
|
||||||
|
intervalMax: 6000,
|
||||||
|
durationMin: 1000,
|
||||||
|
durationMax: 2500,
|
||||||
|
speed: 250
|
||||||
|
},
|
||||||
|
expressionParams: { // 表情参数
|
||||||
|
intervalMin: 3000,
|
||||||
|
intervalMax: 8000,
|
||||||
|
speed: 400
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 1. 创建适配器
|
||||||
|
const adapter = new BabylonMorphTargetAdapter();
|
||||||
|
|
||||||
|
// 2. 初始化SDK
|
||||||
|
const animator = new BlendShapeAnimator({
|
||||||
|
blendShapeScale: 1.0,
|
||||||
|
onStatusChange: (type, msg) => console.log(type, msg)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 设置适配器
|
||||||
|
animator.setMorphTargetAdapter(adapter);
|
||||||
|
|
||||||
|
// 4. 加载模型后构建缓存
|
||||||
|
BABYLON.SceneLoader.ImportMesh("", "./", "model.glb", scene, (meshes) => {
|
||||||
|
adapter.buildCache(meshes);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 加载并播放动画
|
||||||
|
animator.loadAnimationFrames(animationData.frames);
|
||||||
|
animator.playAnimation();
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 参考
|
||||||
|
|
||||||
|
### 核心方法
|
||||||
|
|
||||||
|
**`setMorphTargetAdapter(adapter)`**
|
||||||
|
设置形态键适配器
|
||||||
|
|
||||||
|
**`loadAnimationFrames(frames)`**
|
||||||
|
加载动画帧数据
|
||||||
|
|
||||||
|
**`playAnimation()`**
|
||||||
|
播放动画
|
||||||
|
|
||||||
|
**`stopAnimation()`**
|
||||||
|
停止动画
|
||||||
|
|
||||||
|
**`setIdleAnimation(name, target, duration, easing)`**
|
||||||
|
设置空闲动画
|
||||||
|
- `name`: 形态键名称
|
||||||
|
- `target`: 目标值 (0-1)
|
||||||
|
- `duration`: 持续时间 (ms)
|
||||||
|
- `easing`: 缓动函数名称
|
||||||
|
|
||||||
|
### 空闲动画控制
|
||||||
|
|
||||||
|
**`toggleBlink(enabled)`**
|
||||||
|
开启/关闭随机眨眼
|
||||||
|
|
||||||
|
**`toggleEyeLook(enabled)`**
|
||||||
|
开启/关闭眼球移动
|
||||||
|
|
||||||
|
**`toggleRandomExpression(enabled)`**
|
||||||
|
开启/关闭随机表情
|
||||||
|
|
||||||
|
### 配置更新
|
||||||
|
|
||||||
|
**`updateConfig(key, value)`**
|
||||||
|
更新配置参数
|
||||||
|
|
||||||
|
## 动画数据格式
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
frames: [
|
||||||
|
{
|
||||||
|
blendShapes: {
|
||||||
|
"jawOpen": 0.5,
|
||||||
|
"mouthSmileLeft": 0.3,
|
||||||
|
"mouthSmileRight": 0.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 形态键名称会自动转换为小写进行匹配
|
||||||
|
- SDK使用 `requestAnimationFrame` 进行动画更新
|
||||||
|
- 空闲动画会在主动画播放时自动暂停
|
||||||
|
- 所有形态键值范围为 0-1
|
||||||
76
examples/3d/babylonAdapter.js
Normal file
76
examples/3d/babylonAdapter.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
// Babylon.js 形态键适配器
|
||||||
|
class BabylonMorphTargetAdapter {
|
||||||
|
constructor() {
|
||||||
|
this.morphTargetCache = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
buildCache(meshes) {
|
||||||
|
this.morphTargetCache = {};
|
||||||
|
let totalTargets = 0;
|
||||||
|
|
||||||
|
meshes.forEach(mesh => {
|
||||||
|
const mtm = mesh.morphTargetManager;
|
||||||
|
if (!mtm) return;
|
||||||
|
|
||||||
|
console.log(`网格 ${mesh.name}: ${mtm.numTargets} 个形态键`);
|
||||||
|
|
||||||
|
for (let i = 0; i < mtm.numTargets; i++) {
|
||||||
|
const mt = mtm.getTarget(i);
|
||||||
|
if (!mt?.name) continue;
|
||||||
|
|
||||||
|
const lowerName = mt.name.toLowerCase();
|
||||||
|
|
||||||
|
if (!this.morphTargetCache[lowerName]) {
|
||||||
|
this.morphTargetCache[lowerName] = [];
|
||||||
|
}
|
||||||
|
this.morphTargetCache[lowerName].push(mt);
|
||||||
|
totalTargets++;
|
||||||
|
|
||||||
|
if (i < 3) {
|
||||||
|
console.log(` ${mt.name} -> ${lowerName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`总计: ${totalTargets} 个形态键映射`);
|
||||||
|
return totalTargets;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInfluence(name, value) {
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
const targets = this.morphTargetCache[lowerName];
|
||||||
|
|
||||||
|
if (targets?.length) {
|
||||||
|
targets.forEach(mt => {
|
||||||
|
mt.influence = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfluence(name) {
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
const targets = this.morphTargetCache[lowerName];
|
||||||
|
|
||||||
|
if (targets?.length) {
|
||||||
|
return targets[0].influence;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAll() {
|
||||||
|
for (const targets of Object.values(this.morphTargetCache)) {
|
||||||
|
targets.forEach(mt => {
|
||||||
|
mt.influence = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheSize() {
|
||||||
|
return Object.keys(this.morphTargetCache).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出到全局
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.BabylonMorphTargetAdapter = BabylonMorphTargetAdapter;
|
||||||
|
}
|
||||||
63
examples/3d/babylonScene.js
Normal file
63
examples/3d/babylonScene.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// Babylon.js 场景管理器
|
||||||
|
class BabylonSceneManager {
|
||||||
|
constructor(canvasId) {
|
||||||
|
this.canvas = document.getElementById(canvasId);
|
||||||
|
this.engine = new BABYLON.Engine(this.canvas, true);
|
||||||
|
this.scene = null;
|
||||||
|
this.camera = null;
|
||||||
|
this.onModelLoaded = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.scene = new BABYLON.Scene(this.engine);
|
||||||
|
this.scene.clearColor = new BABYLON.Color3(0.1, 0.1, 0.1);
|
||||||
|
|
||||||
|
this.camera = new BABYLON.ArcRotateCamera(
|
||||||
|
"camera",
|
||||||
|
0,
|
||||||
|
Math.PI / 2,
|
||||||
|
2,
|
||||||
|
new BABYLON.Vector3(0, 1.5, 0),
|
||||||
|
this.scene
|
||||||
|
);
|
||||||
|
this.camera.attachControl(this.canvas, true);
|
||||||
|
this.camera.minZ = 0.01;
|
||||||
|
this.camera.lowerRadiusLimit = 1;
|
||||||
|
this.camera.upperRadiusLimit = 5;
|
||||||
|
|
||||||
|
this.scene.createDefaultEnvironment();
|
||||||
|
|
||||||
|
this.engine.runRenderLoop(() => {
|
||||||
|
this.scene.render();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
this.engine.resize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadModel(modelPath, onSuccess, onError) {
|
||||||
|
BABYLON.SceneLoader.ImportMesh("", "./", modelPath, this.scene,
|
||||||
|
(meshes) => {
|
||||||
|
this.scene.lights.forEach(light => {
|
||||||
|
light.intensity = 50;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(meshes);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
(scene, message) => {
|
||||||
|
if (onError) {
|
||||||
|
onError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出到全局
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.BabylonSceneManager = BabylonSceneManager;
|
||||||
|
}
|
||||||
377
examples/3d/blendshapeAnimator.js
Normal file
377
examples/3d/blendshapeAnimator.js
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
// BlendShape Animation SDK - Engine Agnostic
|
||||||
|
class BlendShapeAnimator {
|
||||||
|
constructor(config = {}) {
|
||||||
|
this.morphTargetAdapter = null;
|
||||||
|
this.animationFrames = [];
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.currentFrameIndex = 0;
|
||||||
|
this.animationStartTime = 0;
|
||||||
|
this.idleAnimations = {};
|
||||||
|
this.blendShapeScale = config.blendShapeScale || 1.0;
|
||||||
|
this.dataFps = config.dataFps || 30;
|
||||||
|
|
||||||
|
// 空闲动画参数
|
||||||
|
this.blinkParams = config.blinkParams || {
|
||||||
|
intervalMin: 2000,
|
||||||
|
intervalMax: 5000,
|
||||||
|
duration: 150,
|
||||||
|
speed: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
this.eyeLookParams = config.eyeLookParams || {
|
||||||
|
intervalMin: 2000,
|
||||||
|
intervalMax: 6000,
|
||||||
|
durationMin: 1000,
|
||||||
|
durationMax: 2500,
|
||||||
|
speed: 250
|
||||||
|
};
|
||||||
|
|
||||||
|
this.expressionParams = config.expressionParams || {
|
||||||
|
intervalMin: 3000,
|
||||||
|
intervalMax: 8000,
|
||||||
|
speed: 400
|
||||||
|
};
|
||||||
|
|
||||||
|
this.enabledExpressions = new Set();
|
||||||
|
this.expressionDurations = {};
|
||||||
|
|
||||||
|
// 状态标志
|
||||||
|
this.isBlinkEnabled = false;
|
||||||
|
this.isEyeLookEnabled = false;
|
||||||
|
this.isExpressionEnabled = false;
|
||||||
|
|
||||||
|
// 定时器
|
||||||
|
this.blinkInterval = null;
|
||||||
|
this.eyeLookInterval = null;
|
||||||
|
|
||||||
|
// 回调
|
||||||
|
this.onStatusChange = config.onStatusChange || (() => {});
|
||||||
|
|
||||||
|
// 启动空闲动画循环
|
||||||
|
this._updateIdleAnimations();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置形态键适配器
|
||||||
|
setMorphTargetAdapter(adapter) {
|
||||||
|
this.morphTargetAdapter = adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载动画帧数据
|
||||||
|
loadAnimationFrames(frames) {
|
||||||
|
this.animationFrames = frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放动画
|
||||||
|
playAnimation() {
|
||||||
|
if (this.animationFrames.length === 0) {
|
||||||
|
this.onStatusChange('error', '请先加载动画数据');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.morphTargetAdapter) {
|
||||||
|
this.onStatusChange('error', '未设置形态键适配器');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止随机表情
|
||||||
|
if (this.isExpressionEnabled && window.ExpressionLibrary) {
|
||||||
|
window.ExpressionLibrary.randomPlayer.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopAnimation();
|
||||||
|
this.isPlaying = true;
|
||||||
|
this.currentFrameIndex = 0;
|
||||||
|
this.animationStartTime = performance.now();
|
||||||
|
|
||||||
|
this._animateFrame();
|
||||||
|
this.onStatusChange('info', '播放中...');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止动画
|
||||||
|
stopAnimation() {
|
||||||
|
this.isPlaying = false;
|
||||||
|
|
||||||
|
// 恢复随机表情
|
||||||
|
if (this.isExpressionEnabled && window.ExpressionLibrary) {
|
||||||
|
window.ExpressionLibrary.randomPlayer.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onStatusChange('info', '已停止');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部动画帧处理
|
||||||
|
_animateFrame() {
|
||||||
|
if (!this.isPlaying) return;
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
const frameDuration = 1000 / this.dataFps;
|
||||||
|
const elapsed = now - this.animationStartTime;
|
||||||
|
const exactFrame = elapsed / frameDuration;
|
||||||
|
const targetFrameIndex = Math.floor(exactFrame);
|
||||||
|
|
||||||
|
if (targetFrameIndex >= this.animationFrames.length) {
|
||||||
|
this.stopAnimation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFrame = this.animationFrames[targetFrameIndex];
|
||||||
|
const nextFrame = this.animationFrames[Math.min(targetFrameIndex + 1, this.animationFrames.length - 1)];
|
||||||
|
const frameProgress = exactFrame - targetFrameIndex;
|
||||||
|
const smoothProgress = this._easeOutQuad(frameProgress);
|
||||||
|
|
||||||
|
for (const key in currentFrame.blendShapes) {
|
||||||
|
const currentValue = currentFrame.blendShapes[key] || 0;
|
||||||
|
const nextValue = nextFrame.blendShapes[key] || 0;
|
||||||
|
const interpolatedValue = this._lerp(currentValue, nextValue, smoothProgress);
|
||||||
|
const scaledValue = interpolatedValue * this.blendShapeScale;
|
||||||
|
|
||||||
|
this.morphTargetAdapter.setInfluence(key, scaledValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentFrameIndex = targetFrameIndex;
|
||||||
|
requestAnimationFrame(() => this._animateFrame());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置空闲动画
|
||||||
|
setIdleAnimation(name, target, duration = 200, easing = 'easeOutQuad') {
|
||||||
|
this.idleAnimations[name] = {
|
||||||
|
target: target,
|
||||||
|
duration: duration,
|
||||||
|
easing: easing,
|
||||||
|
startTime: null,
|
||||||
|
startValue: this.idleAnimations[name]?.target || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新空闲动画
|
||||||
|
_updateIdleAnimations() {
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
for (const name in this.idleAnimations) {
|
||||||
|
const anim = this.idleAnimations[name];
|
||||||
|
|
||||||
|
if (!this.morphTargetAdapter) continue;
|
||||||
|
|
||||||
|
if (!anim.startTime) {
|
||||||
|
anim.startTime = now;
|
||||||
|
anim.startValue = this.morphTargetAdapter.getInfluence(name) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = now - anim.startTime;
|
||||||
|
const duration = anim.duration || 200;
|
||||||
|
let progress = Math.min(elapsed / duration, 1.0);
|
||||||
|
|
||||||
|
progress = this._applyEasing(progress, anim.easing);
|
||||||
|
const currentValue = this._lerp(anim.startValue, anim.target, progress);
|
||||||
|
|
||||||
|
this.morphTargetAdapter.setInfluence(name, currentValue);
|
||||||
|
|
||||||
|
if (progress >= 1.0) {
|
||||||
|
if (anim.target === 0) {
|
||||||
|
delete this.idleAnimations[name];
|
||||||
|
} else {
|
||||||
|
anim.startTime = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => this._updateIdleAnimations());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 眨眼控制
|
||||||
|
toggleBlink(enabled) {
|
||||||
|
this.isBlinkEnabled = enabled;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
this._startRandomBlink();
|
||||||
|
this.onStatusChange('success', '随机眨眼已开启');
|
||||||
|
} else {
|
||||||
|
this._stopRandomBlink();
|
||||||
|
this.onStatusChange('info', '随机眨眼已关闭');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startRandomBlink() {
|
||||||
|
this._stopRandomBlink();
|
||||||
|
|
||||||
|
const scheduleNext = () => {
|
||||||
|
const delay = this.blinkParams.intervalMin + Math.random() * (this.blinkParams.intervalMax - this.blinkParams.intervalMin);
|
||||||
|
this.blinkInterval = setTimeout(() => {
|
||||||
|
if (this.isBlinkEnabled) {
|
||||||
|
this._doBlink();
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopRandomBlink() {
|
||||||
|
if (this.blinkInterval) {
|
||||||
|
clearTimeout(this.blinkInterval);
|
||||||
|
this.blinkInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_doBlink() {
|
||||||
|
const blinkShapes = ['eyeblinkleft', 'eyeblinkright'];
|
||||||
|
|
||||||
|
blinkShapes.forEach(name => {
|
||||||
|
this.setIdleAnimation(name, 1.0, this.blinkParams.speed, 'easeOutQuad');
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
blinkShapes.forEach(name => {
|
||||||
|
this.setIdleAnimation(name, 0, this.blinkParams.speed + 20, 'easeInOutQuad');
|
||||||
|
});
|
||||||
|
}, this.blinkParams.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 眼球移动控制
|
||||||
|
toggleEyeLook(enabled) {
|
||||||
|
this.isEyeLookEnabled = enabled;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
this._startRandomEyeLook();
|
||||||
|
this.onStatusChange('success', '眼球移动已开启');
|
||||||
|
} else {
|
||||||
|
this._stopRandomEyeLook();
|
||||||
|
this._resetEyeLook();
|
||||||
|
this.onStatusChange('info', '眼球移动已关闭');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_startRandomEyeLook() {
|
||||||
|
this._stopRandomEyeLook();
|
||||||
|
|
||||||
|
const scheduleNext = () => {
|
||||||
|
const delay = this.eyeLookParams.intervalMin + Math.random() * (this.eyeLookParams.intervalMax - this.eyeLookParams.intervalMin);
|
||||||
|
this.eyeLookInterval = setTimeout(() => {
|
||||||
|
if (this.isEyeLookEnabled) {
|
||||||
|
this._doRandomEyeLook();
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopRandomEyeLook() {
|
||||||
|
if (this.eyeLookInterval) {
|
||||||
|
clearTimeout(this.eyeLookInterval);
|
||||||
|
this.eyeLookInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_doRandomEyeLook() {
|
||||||
|
const directions = [
|
||||||
|
{ shapes: {} },
|
||||||
|
{ shapes: { 'eyelookupleft': 0.3, 'eyelookupright': 0.3 } },
|
||||||
|
{ shapes: { 'eyelookdownleft': 0.3, 'eyelookdownright': 0.3 } },
|
||||||
|
{ shapes: { 'eyelookinleft': 0.4, 'eyelookoutright': 0.4 } },
|
||||||
|
{ shapes: { 'eyelookoutleft': 0.4, 'eyelookinright': 0.4 } }
|
||||||
|
];
|
||||||
|
|
||||||
|
const direction = directions[Math.floor(Math.random() * directions.length)];
|
||||||
|
const eyeLookShapes = [
|
||||||
|
'eyelookupleft', 'eyelookupright',
|
||||||
|
'eyelookdownleft', 'eyelookdownright',
|
||||||
|
'eyelookinleft', 'eyelookinright',
|
||||||
|
'eyelookoutleft', 'eyelookoutright'
|
||||||
|
];
|
||||||
|
|
||||||
|
eyeLookShapes.forEach(name => {
|
||||||
|
this.setIdleAnimation(name, 0, this.eyeLookParams.speed * 0.8, 'easeInOutQuad');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(direction.shapes)) {
|
||||||
|
this.setIdleAnimation(name, value, this.eyeLookParams.speed, 'easeInOutQuad');
|
||||||
|
}
|
||||||
|
|
||||||
|
const holdDuration = this.eyeLookParams.durationMin + Math.random() * (this.eyeLookParams.durationMax - this.eyeLookParams.durationMin);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.isEyeLookEnabled) {
|
||||||
|
eyeLookShapes.forEach(name => {
|
||||||
|
this.setIdleAnimation(name, 0, this.eyeLookParams.speed * 1.2, 'easeInOutQuad');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, holdDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
_resetEyeLook() {
|
||||||
|
const eyeLookShapes = [
|
||||||
|
'eyelookupleft', 'eyelookupright',
|
||||||
|
'eyelookdownleft', 'eyelookdownright',
|
||||||
|
'eyelookinleft', 'eyelookinright',
|
||||||
|
'eyelookoutleft', 'eyelookoutright'
|
||||||
|
];
|
||||||
|
|
||||||
|
eyeLookShapes.forEach(name => {
|
||||||
|
this.setIdleAnimation(name, 0, this.eyeLookParams.speed, 'easeInOutQuad');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机表情控制
|
||||||
|
toggleRandomExpression(enabled) {
|
||||||
|
if (!window.ExpressionLibrary) {
|
||||||
|
this.onStatusChange('error', '表情库未加载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isExpressionEnabled = enabled;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
window.ExpressionLibrary.randomPlayer.intervalMin = this.expressionParams.intervalMin;
|
||||||
|
window.ExpressionLibrary.randomPlayer.intervalMax = this.expressionParams.intervalMax;
|
||||||
|
|
||||||
|
if (!this.isPlaying) {
|
||||||
|
window.ExpressionLibrary.randomPlayer.start();
|
||||||
|
}
|
||||||
|
this.onStatusChange('success', '随机表情已开启');
|
||||||
|
} else {
|
||||||
|
window.ExpressionLibrary.randomPlayer.stop();
|
||||||
|
this.onStatusChange('info', '随机表情已关闭');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓动函数
|
||||||
|
_easeOutQuad(t) {
|
||||||
|
return t * (2 - t);
|
||||||
|
}
|
||||||
|
|
||||||
|
_lerp(start, end, t) {
|
||||||
|
return start + (end - start) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyEasing(t, type) {
|
||||||
|
switch(type) {
|
||||||
|
case 'easeOutQuad':
|
||||||
|
return t * (2 - t);
|
||||||
|
case 'easeInOutQuad':
|
||||||
|
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||||
|
case 'easeInOutCubic':
|
||||||
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||||
|
case 'easeOutCubic':
|
||||||
|
return 1 - Math.pow(1 - t, 3);
|
||||||
|
case 'linear':
|
||||||
|
default:
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新配置
|
||||||
|
updateConfig(key, value) {
|
||||||
|
if (key === 'blendShapeScale') {
|
||||||
|
this.blendShapeScale = value;
|
||||||
|
} else if (this[key]) {
|
||||||
|
this[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出到全局
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.BlendShapeAnimator = BlendShapeAnimator;
|
||||||
|
}
|
||||||
247
examples/3d/expressionLibrary.js
Normal file
247
examples/3d/expressionLibrary.js
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
// 表情库系统
|
||||||
|
const ExpressionLibrary = {
|
||||||
|
expressions: {
|
||||||
|
// 原有表情
|
||||||
|
smile: {
|
||||||
|
name: '微笑',
|
||||||
|
blendShapes: {
|
||||||
|
mouthsmileleft: 0.4,
|
||||||
|
mouthsmileright: 0.4
|
||||||
|
},
|
||||||
|
duration: 2000
|
||||||
|
},
|
||||||
|
|
||||||
|
disgust: {
|
||||||
|
name: '嫌弃',
|
||||||
|
blendShapes: {
|
||||||
|
mouthupperupright: 0.43,
|
||||||
|
browdownleft: 1,
|
||||||
|
browdownright: 1,
|
||||||
|
nosesneerleft: 1,
|
||||||
|
nosesneerright: 1
|
||||||
|
},
|
||||||
|
duration: 2500
|
||||||
|
},
|
||||||
|
|
||||||
|
smirk: {
|
||||||
|
name: '冷笑',
|
||||||
|
blendShapes: {
|
||||||
|
eyeblinkleft: 0.41,
|
||||||
|
eyeblinkright: 0.41,
|
||||||
|
mouthsmileleft: 0.3
|
||||||
|
},
|
||||||
|
duration: 2000
|
||||||
|
},
|
||||||
|
|
||||||
|
grit: {
|
||||||
|
name: '咬牙切齿',
|
||||||
|
blendShapes: {
|
||||||
|
mouthrolllower: 0.3,
|
||||||
|
mouthrollupper: 0.44,
|
||||||
|
mouthshruglower: 0.42,
|
||||||
|
mouthshrugupper: 0.44,
|
||||||
|
browdownleft: 1,
|
||||||
|
browdownright: 1,
|
||||||
|
nosesneerleft: 1,
|
||||||
|
nosesneerright: 1
|
||||||
|
},
|
||||||
|
duration: 2500
|
||||||
|
},
|
||||||
|
|
||||||
|
// 新增表情
|
||||||
|
surprise: {
|
||||||
|
name: '惊讶',
|
||||||
|
blendShapes: {
|
||||||
|
eyewidenleft: 0.8,
|
||||||
|
eyewidenright: 0.8,
|
||||||
|
browouterupright: 0.7,
|
||||||
|
browouterupleft: 0.7,
|
||||||
|
jawopen: 0.4,
|
||||||
|
mouthfunnel: 0.3
|
||||||
|
},
|
||||||
|
duration: 2000
|
||||||
|
},
|
||||||
|
|
||||||
|
sad: {
|
||||||
|
name: '悲伤',
|
||||||
|
blendShapes: {
|
||||||
|
browinnerup: 0.8,
|
||||||
|
mouthfrownleft: 0.6,
|
||||||
|
mouthfrownright: 0.6,
|
||||||
|
mouthlowerdownleft: 0.4,
|
||||||
|
mouthlowerdownright: 0.4
|
||||||
|
},
|
||||||
|
duration: 3000
|
||||||
|
},
|
||||||
|
|
||||||
|
angry: {
|
||||||
|
name: '生气',
|
||||||
|
blendShapes: {
|
||||||
|
browdownleft: 1,
|
||||||
|
browdownright: 1,
|
||||||
|
eyesquintleft: 0.5,
|
||||||
|
eyesquintright: 0.5,
|
||||||
|
nosesneerleft: 0.7,
|
||||||
|
nosesneerright: 0.7,
|
||||||
|
mouthpressleft: 0.6,
|
||||||
|
mouthpressright: 0.6
|
||||||
|
},
|
||||||
|
duration: 2500
|
||||||
|
},
|
||||||
|
|
||||||
|
thinking: {
|
||||||
|
name: '思考',
|
||||||
|
blendShapes: {
|
||||||
|
eyesquintleft: 0.3,
|
||||||
|
eyesquintright: 0.3,
|
||||||
|
mouthpucker: 0.4,
|
||||||
|
eyelookupleft: 0.3,
|
||||||
|
eyelookupright: 0.3
|
||||||
|
},
|
||||||
|
duration: 3000
|
||||||
|
},
|
||||||
|
|
||||||
|
happy: {
|
||||||
|
name: '开心',
|
||||||
|
blendShapes: {
|
||||||
|
mouthsmileleft: 0.8,
|
||||||
|
mouthsmileright: 0.8,
|
||||||
|
eyesquintleft: 0.4,
|
||||||
|
eyesquintright: 0.4,
|
||||||
|
cheeksquintleft: 0.6,
|
||||||
|
cheeksquintright: 0.6
|
||||||
|
},
|
||||||
|
duration: 2500
|
||||||
|
},
|
||||||
|
|
||||||
|
confused: {
|
||||||
|
name: '困惑',
|
||||||
|
blendShapes: {
|
||||||
|
browouterupleft: 0.6,
|
||||||
|
browdownright: 0.5,
|
||||||
|
mouthfrownleft: 0.3,
|
||||||
|
eyesquintright: 0.3
|
||||||
|
},
|
||||||
|
duration: 2500
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 随机表情播放器
|
||||||
|
randomPlayer: {
|
||||||
|
enabled: false,
|
||||||
|
timeout: null,
|
||||||
|
currentExpression: null,
|
||||||
|
intervalMin: 3000,
|
||||||
|
intervalMax: 8000,
|
||||||
|
|
||||||
|
start: function() {
|
||||||
|
this.enabled = true;
|
||||||
|
this.scheduleNext();
|
||||||
|
},
|
||||||
|
|
||||||
|
stop: function() {
|
||||||
|
this.enabled = false;
|
||||||
|
if (this.timeout) {
|
||||||
|
clearTimeout(this.timeout);
|
||||||
|
this.timeout = null;
|
||||||
|
}
|
||||||
|
this.reset();
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleNext: function() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
const delay = this.intervalMin + Math.random() * (this.intervalMax - this.intervalMin);
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
this.playRandom();
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
|
||||||
|
playRandom: function() {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
// 获取启用的表情列表
|
||||||
|
const enabledKeys = window.enabledExpressions && window.enabledExpressions.size > 0
|
||||||
|
? Array.from(window.enabledExpressions)
|
||||||
|
: Object.keys(ExpressionLibrary.expressions);
|
||||||
|
|
||||||
|
if (enabledKeys.length === 0) {
|
||||||
|
this.scheduleNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomKey = enabledKeys[Math.floor(Math.random() * enabledKeys.length)];
|
||||||
|
const expression = ExpressionLibrary.expressions[randomKey];
|
||||||
|
|
||||||
|
this.play(expression);
|
||||||
|
},
|
||||||
|
|
||||||
|
play: function(expression) {
|
||||||
|
this.currentExpression = expression;
|
||||||
|
|
||||||
|
// 从主页面获取动画速度参数
|
||||||
|
const speed = window.expressionParams?.speed || 400;
|
||||||
|
|
||||||
|
// 应用表情
|
||||||
|
for (const [name, value] of Object.entries(expression.blendShapes)) {
|
||||||
|
if (window.setIdleAnimation) {
|
||||||
|
window.setIdleAnimation(name, value, speed, 'easeInOutCubic');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取表情的key
|
||||||
|
const expressionKey = Object.keys(ExpressionLibrary.expressions).find(
|
||||||
|
key => ExpressionLibrary.expressions[key] === expression
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用用户设置的持续时间,如果没有则使用表情默认时间
|
||||||
|
const duration = (window.expressionDurations && window.expressionDurations[expressionKey])
|
||||||
|
|| expression.duration;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.reset();
|
||||||
|
this.scheduleNext();
|
||||||
|
}, duration);
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: function() {
|
||||||
|
if (!this.currentExpression) return;
|
||||||
|
|
||||||
|
// 从主页面获取参数
|
||||||
|
const speed = window.expressionParams?.speed || 400;
|
||||||
|
|
||||||
|
// 重置表情
|
||||||
|
for (const name of Object.keys(this.currentExpression.blendShapes)) {
|
||||||
|
if (window.setIdleAnimation) {
|
||||||
|
window.setIdleAnimation(name, 0, speed + 100, 'easeInOutCubic');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentExpression = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 手动播放指定表情
|
||||||
|
playExpression: function(expressionName) {
|
||||||
|
const expression = this.expressions[expressionName];
|
||||||
|
if (!expression) {
|
||||||
|
console.warn(`表情 "${expressionName}" 不存在`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.randomPlayer.play(expression);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取所有表情名称
|
||||||
|
getExpressionNames: function() {
|
||||||
|
return Object.keys(this.expressions).map(key => ({
|
||||||
|
key: key,
|
||||||
|
name: this.expressions[key].name
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出到全局
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.ExpressionLibrary = ExpressionLibrary;
|
||||||
|
}
|
||||||
BIN
examples/3d/head_a01.glb
Normal file
BIN
examples/3d/head_a01.glb
Normal file
Binary file not shown.
143
examples/3d/index.html
Normal file
143
examples/3d/index.html
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>3D Digital Human - BlendShapes Demo</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css">
|
||||||
|
<script src="https://cdn.babylonjs.com/babylon.js"></script>
|
||||||
|
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
|
||||||
|
<script src="./blendshapeAnimator.js"></script>
|
||||||
|
<script src="./babylonAdapter.js"></script>
|
||||||
|
<script src="./babylonScene.js"></script>
|
||||||
|
<script src="./expressionLibrary.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<canvas id="renderCanvas"></canvas>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<h2>数字人形态键控制</h2>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="idle-controls">
|
||||||
|
<h2>空闲动画控制</h2>
|
||||||
|
|
||||||
|
<!-- 随机眨眼 -->
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label>
|
||||||
|
<button class="collapse-btn" onclick="toggleCollapse(event, 'blinkParams')">▼</button>
|
||||||
|
<input type="checkbox" id="blinkEnabled" onchange="toggleBlink()">
|
||||||
|
<span>随机眨眼</span>
|
||||||
|
</label>
|
||||||
|
<div class="param-group" id="blinkParams">
|
||||||
|
<div class="param-item">
|
||||||
|
<label>频率间隔 (秒): <span class="param-value" id="blinkIntervalValue">2-5</span></label>
|
||||||
|
<input type="range" id="blinkIntervalMin" min="1" max="5" step="0.5" value="2"
|
||||||
|
oninput="updateBlinkInterval()">
|
||||||
|
<input type="range" id="blinkIntervalMax" min="3" max="10" step="0.5" value="5"
|
||||||
|
oninput="updateBlinkInterval()">
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<label>闭眼时长 (ms): <span class="param-value" id="blinkDurationValue">150</span></label>
|
||||||
|
<input type="range" id="blinkDuration" min="100" max="300" step="10" value="150"
|
||||||
|
oninput="updateBlinkDuration(this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<label>动画速度 (ms): <span class="param-value" id="blinkSpeedValue">100</span></label>
|
||||||
|
<input type="range" id="blinkSpeed" min="50" max="200" step="10" value="100"
|
||||||
|
oninput="updateBlinkSpeed(this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 眼球移动 -->
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label>
|
||||||
|
<button class="collapse-btn" onclick="toggleCollapse(event, 'eyeLookParams')">▼</button>
|
||||||
|
<input type="checkbox" id="eyeLookEnabled" onchange="toggleEyeLook()">
|
||||||
|
<span>眼球移动</span>
|
||||||
|
</label>
|
||||||
|
<div class="param-group" id="eyeLookParams">
|
||||||
|
<div class="param-item">
|
||||||
|
<label>频率间隔 (秒): <span class="param-value" id="eyeLookIntervalValue">2-6</span></label>
|
||||||
|
<input type="range" id="eyeLookIntervalMin" min="1" max="5" step="0.5" value="2"
|
||||||
|
oninput="updateEyeLookInterval()">
|
||||||
|
<input type="range" id="eyeLookIntervalMax" min="3" max="10" step="0.5" value="6"
|
||||||
|
oninput="updateEyeLookInterval()">
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<label>停留时长 (ms): <span class="param-value" id="eyeLookDurationValue">1000-2500</span></label>
|
||||||
|
<input type="range" id="eyeLookDurationMin" min="500" max="2000" step="100" value="1000"
|
||||||
|
oninput="updateEyeLookDuration()">
|
||||||
|
<input type="range" id="eyeLookDurationMax" min="1000" max="4000" step="100" value="2500"
|
||||||
|
oninput="updateEyeLookDuration()">
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<label>移动速度 (ms): <span class="param-value" id="eyeLookSpeedValue">250</span></label>
|
||||||
|
<input type="range" id="eyeLookSpeed" min="100" max="500" step="25" value="250"
|
||||||
|
oninput="updateEyeLookSpeed(this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 随机表情 -->
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label>
|
||||||
|
<button class="collapse-btn" onclick="toggleCollapse(event, 'expressionParams')">▼</button>
|
||||||
|
<input type="checkbox" id="expressionEnabled" onchange="toggleRandomExpression()">
|
||||||
|
<span>随机表情</span>
|
||||||
|
</label>
|
||||||
|
<div class="param-group" id="expressionParams">
|
||||||
|
<div class="param-item">
|
||||||
|
<label>选择表情:</label>
|
||||||
|
<div class="expression-list" id="expressionList">
|
||||||
|
<!-- 动态生成 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<label>频率间隔 (秒): <span class="param-value" id="expressionIntervalValue">3-8</span></label>
|
||||||
|
<input type="range" id="expressionIntervalMin" min="2" max="8" step="0.5" value="3"
|
||||||
|
oninput="updateExpressionInterval()">
|
||||||
|
<input type="range" id="expressionIntervalMax" min="5" max="15" step="0.5" value="8"
|
||||||
|
oninput="updateExpressionInterval()">
|
||||||
|
</div>
|
||||||
|
<div class="param-item">
|
||||||
|
<label>动画速度 (ms): <span class="param-value" id="expressionSpeedValue">400</span></label>
|
||||||
|
<input type="range" id="expressionSpeed" min="200" max="800" step="50" value="400"
|
||||||
|
oninput="updateExpressionSpeed(this.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./main.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
308
examples/3d/main.js
Normal file
308
examples/3d/main.js
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
// 全局变量
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 加载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');
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
showStatus("请输入文字", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
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 || '请求失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
animator.loadAnimationFrames(data.frames);
|
||||||
|
console.log("动画数据:", data.frames);
|
||||||
|
showStatus(`动画生成成功!共 ${data.frames.length} 帧`, "success");
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showStatus("错误: " + err.message, "error");
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAnimation() {
|
||||||
|
animator.playAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAnimation() {
|
||||||
|
animator.stopAnimation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScale(value) {
|
||||||
|
animator.updateConfig('blendShapeScale', parseFloat(value));
|
||||||
|
document.getElementById('scaleValue').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;
|
||||||
|
}
|
||||||
270
examples/3d/styles.css
Normal file
270
examples/3d/styles.css
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderCanvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: block;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
max-width: 350px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
width: 320px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls h2 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background: rgba(76, 175, 80, 0.2);
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: rgba(244, 67, 54, 0.2);
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.info {
|
||||||
|
background: rgba(33, 150, 243, 0.2);
|
||||||
|
color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group > label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label > span {
|
||||||
|
flex: 1;
|
||||||
|
color: white;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-right: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
margin-right: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
color: #aaa;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-group {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-left: 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-group.collapsed {
|
||||||
|
max-height: 0 !important;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-item {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-item label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-item input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-value {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-item input[type="checkbox"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-item label {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-item input[type="number"] {
|
||||||
|
width: 50px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-item .duration-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
flask-cors>=4.0.0
|
||||||
|
gTTS>=2.3.0
|
||||||
41
scripts/run_a2f.py
Normal file
41
scripts/run_a2f.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ========= 基础路径 =========
|
||||||
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
A2F_SCRIPT = BASE_DIR / "external" / "Audio2Face-3D-Samples" / "scripts" / "audio2face_3d_microservices_interaction_app" / "a2f_3d.py"
|
||||||
|
AUDIO_FILE = BASE_DIR / "data" / "input" / "audio" / "Mark_joy.wav"
|
||||||
|
CONFIG_FILE = BASE_DIR / "external" / "Audio2Face-3D-Samples" / "scripts" / "audio2face_3d_microservices_interaction_app" / "config" / "config_james.yml"
|
||||||
|
|
||||||
|
A2F_URL = "192.168.1.39:52000"
|
||||||
|
|
||||||
|
# ========= 调用命令 =========
|
||||||
|
cmd = [
|
||||||
|
sys.executable, # 使用当前 venv 的 python
|
||||||
|
str(A2F_SCRIPT),
|
||||||
|
"run_inference",
|
||||||
|
str(AUDIO_FILE),
|
||||||
|
str(CONFIG_FILE),
|
||||||
|
"--url",
|
||||||
|
A2F_URL
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Running Audio2Face-3D inference...")
|
||||||
|
print("Command:", " ".join(cmd))
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
print("=== A2F OUTPUT ===")
|
||||||
|
print(result.stdout)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("Audio2Face-3D inference failed")
|
||||||
|
|
||||||
|
print("Inference finished successfully.")
|
||||||
64
services/a2f_api/README.md
Normal file
64
services/a2f_api/README.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Text to BlendShapes API
|
||||||
|
|
||||||
|
将文字转换为52个形态键的API服务
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
1. 文字 → 音频 (使用 gTTS)
|
||||||
|
2. 音频 → CSV (使用 Audio2Face)
|
||||||
|
3. CSV → 52个形态键数据
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd a2f_api
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python api.py
|
||||||
|
```
|
||||||
|
|
||||||
|
服务将在 `http://localhost:5001` 启动
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### POST /text-to-blendshapes
|
||||||
|
|
||||||
|
**请求:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "你好世界",
|
||||||
|
"language": "zh-CN"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"timeCode": 0.0,
|
||||||
|
"blendShapes": {
|
||||||
|
"EyeBlinkLeft": 0.0,
|
||||||
|
"EyeLookDownLeft": 0.0,
|
||||||
|
...
|
||||||
|
"TongueOut": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"audio_path": "...",
|
||||||
|
"csv_path": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件说明
|
||||||
|
|
||||||
|
- `tts_service.py` - 文字转音频服务
|
||||||
|
- `a2f_service.py` - Audio2Face包装器
|
||||||
|
- `blend_shape_parser.py` - CSV解析器
|
||||||
|
- `text_to_blendshapes_service.py` - 主服务
|
||||||
|
- `api.py` - Flask API
|
||||||
BIN
services/a2f_api/__pycache__/a2f_service.cpython-311.pyc
Normal file
BIN
services/a2f_api/__pycache__/a2f_service.cpython-311.pyc
Normal file
Binary file not shown.
BIN
services/a2f_api/__pycache__/blend_shape_parser.cpython-311.pyc
Normal file
BIN
services/a2f_api/__pycache__/blend_shape_parser.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
services/a2f_api/__pycache__/tts_service.cpython-311.pyc
Normal file
BIN
services/a2f_api/__pycache__/tts_service.cpython-311.pyc
Normal file
Binary file not shown.
40
services/a2f_api/a2f_service.py
Normal file
40
services/a2f_api/a2f_service.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import glob
|
||||||
|
|
||||||
|
class A2FService:
|
||||||
|
def __init__(self, a2f_url="192.168.1.39:52000"):
|
||||||
|
self.base_dir = Path(__file__).parent.parent.parent
|
||||||
|
self.output_dir = self.base_dir / "data" / "output"
|
||||||
|
self.a2f_script = self.base_dir / "external" / "Audio2Face-3D-Samples" / "scripts" / "audio2face_3d_microservices_interaction_app" / "a2f_3d.py"
|
||||||
|
self.config_file = self.base_dir / "external" / "Audio2Face-3D-Samples" / "scripts" / "audio2face_3d_microservices_interaction_app" / "config" / "config_james.yml"
|
||||||
|
self.a2f_url = a2f_url
|
||||||
|
os.makedirs(self.output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def audio_to_csv(self, audio_path: str) -> str:
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
str(self.a2f_script),
|
||||||
|
"run_inference",
|
||||||
|
audio_path,
|
||||||
|
str(self.config_file),
|
||||||
|
"--url",
|
||||||
|
self.a2f_url
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=str(self.output_dir))
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"A2F inference failed: {result.stdout}")
|
||||||
|
|
||||||
|
output_dirs = sorted(glob.glob(str(self.output_dir / "output_*")))
|
||||||
|
if not output_dirs:
|
||||||
|
raise RuntimeError("No output directory found")
|
||||||
|
|
||||||
|
csv_path = os.path.join(output_dirs[-1], "animation_frames.csv")
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
raise RuntimeError(f"CSV file not found: {csv_path}")
|
||||||
|
|
||||||
|
return csv_path
|
||||||
34
services/a2f_api/api.py
Normal file
34
services/a2f_api/api.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from flask import Flask, request, jsonify
|
||||||
|
from flask_cors import CORS
|
||||||
|
from text_to_blendshapes_service import TextToBlendShapesService
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health():
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
@app.route('/text-to-blendshapes', methods=['POST'])
|
||||||
|
def text_to_blendshapes():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'text' not in data:
|
||||||
|
return jsonify({'success': False, 'error': 'Missing text'}), 400
|
||||||
|
|
||||||
|
text = data['text']
|
||||||
|
language = data.get('language', 'zh-CN')
|
||||||
|
|
||||||
|
service = TextToBlendShapesService(lang=language)
|
||||||
|
result = service.text_to_blend_shapes(text)
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("Text to BlendShapes API: http://localhost:5001")
|
||||||
|
app.run(host='0.0.0.0', port=5001, debug=True)
|
||||||
31
services/a2f_api/blend_shape_parser.py
Normal file
31
services/a2f_api/blend_shape_parser.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import csv
|
||||||
|
|
||||||
|
class BlendShapeParser:
|
||||||
|
BLEND_SHAPE_KEYS = [
|
||||||
|
'EyeBlinkLeft', 'EyeLookDownLeft', 'EyeLookInLeft', 'EyeLookOutLeft', 'EyeLookUpLeft',
|
||||||
|
'EyeSquintLeft', 'EyeWideLeft', 'EyeBlinkRight', 'EyeLookDownRight', 'EyeLookInRight',
|
||||||
|
'EyeLookOutRight', 'EyeLookUpRight', 'EyeSquintRight', 'EyeWideRight', 'JawForward',
|
||||||
|
'JawLeft', 'JawRight', 'JawOpen', 'MouthClose', 'MouthFunnel', 'MouthPucker',
|
||||||
|
'MouthLeft', 'MouthRight', 'MouthSmileLeft', 'MouthSmileRight', 'MouthFrownLeft',
|
||||||
|
'MouthFrownRight', 'MouthDimpleLeft', 'MouthDimpleRight', 'MouthStretchLeft',
|
||||||
|
'MouthStretchRight', 'MouthRollLower', 'MouthRollUpper', 'MouthShrugLower',
|
||||||
|
'MouthShrugUpper', 'MouthPressLeft', 'MouthPressRight', 'MouthLowerDownLeft',
|
||||||
|
'MouthLowerDownRight', 'MouthUpperUpLeft', 'MouthUpperUpRight', 'BrowDownLeft',
|
||||||
|
'BrowDownRight', 'BrowInnerUp', 'BrowOuterUpLeft', 'BrowOuterUpRight', 'CheekPuff',
|
||||||
|
'CheekSquintLeft', 'CheekSquintRight', 'NoseSneerLeft', 'NoseSneerRight', 'TongueOut'
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def csv_to_blend_shapes(csv_path: str):
|
||||||
|
frames = []
|
||||||
|
with open(csv_path, 'r') as f:
|
||||||
|
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for row in reader:
|
||||||
|
frame = {'timeCode': float(row['timeCode']), 'blendShapes': {}}
|
||||||
|
for key in BlendShapeParser.BLEND_SHAPE_KEYS:
|
||||||
|
col_name = f'blendShapes.{key}'
|
||||||
|
if col_name in row:
|
||||||
|
frame['blendShapes'][key] = float(row[col_name])
|
||||||
|
frames.append(frame)
|
||||||
|
return frames
|
||||||
3
services/a2f_api/requirements.txt
Normal file
3
services/a2f_api/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
flask-cors>=4.0.0
|
||||||
|
pyttsx3>=2.90
|
||||||
31
services/a2f_api/text_to_blendshapes_service.py
Normal file
31
services/a2f_api/text_to_blendshapes_service.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
from tts_service import TTSService
|
||||||
|
from a2f_service import A2FService
|
||||||
|
from blend_shape_parser import BlendShapeParser
|
||||||
|
|
||||||
|
class TextToBlendShapesService:
|
||||||
|
def __init__(self, lang='zh-CN', a2f_url="192.168.1.39:52000"):
|
||||||
|
self.tts = TTSService(lang=lang)
|
||||||
|
self.a2f = A2FService(a2f_url=a2f_url)
|
||||||
|
self.parser = BlendShapeParser()
|
||||||
|
|
||||||
|
def text_to_blend_shapes(self, text: str, output_dir: str = None):
|
||||||
|
if output_dir is None:
|
||||||
|
output_dir = tempfile.gettempdir()
|
||||||
|
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
|
audio_path = os.path.join(output_dir, f'tts_{timestamp}.wav')
|
||||||
|
|
||||||
|
self.tts.text_to_audio(text, audio_path)
|
||||||
|
csv_path = self.a2f.audio_to_csv(audio_path)
|
||||||
|
frames = self.parser.csv_to_blend_shapes(csv_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'frames': frames,
|
||||||
|
'audio_path': audio_path,
|
||||||
|
'csv_path': csv_path
|
||||||
|
}
|
||||||
20
services/a2f_api/tts_service.py
Normal file
20
services/a2f_api/tts_service.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import os
|
||||||
|
import pyttsx3
|
||||||
|
|
||||||
|
class TTSService:
|
||||||
|
def __init__(self, lang='zh-CN'):
|
||||||
|
self.lang = lang
|
||||||
|
self.engine = pyttsx3.init()
|
||||||
|
|
||||||
|
if lang == 'zh-CN':
|
||||||
|
voices = self.engine.getProperty('voices')
|
||||||
|
for voice in voices:
|
||||||
|
if 'chinese' in voice.name.lower() or 'zh' in voice.id.lower():
|
||||||
|
self.engine.setProperty('voice', voice.id)
|
||||||
|
break
|
||||||
|
|
||||||
|
def text_to_audio(self, text: str, output_path: str) -> str:
|
||||||
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
|
self.engine.save_to_file(text, output_path)
|
||||||
|
self.engine.runAndWait()
|
||||||
|
return output_path
|
||||||
7
表情数据
Normal file
7
表情数据
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
微笑: mouthSmileLeft:0.4 mouthSmileRight:0.4
|
||||||
|
|
||||||
|
嫌弃:mouthUpperUpRight:0.43 browDownLeft:1 browDownRight:1 noseSneerLeft:1 noseSneerRight:1
|
||||||
|
|
||||||
|
冷笑:eyeBlinkLeft:0.41 eyeBlinkRight:0.41
|
||||||
|
|
||||||
|
咬牙切齿:mouthRollLower:0.3 mouthRollUpper:0.44 mouthShrugLower:0.42 mouthShrugUpper:0.44 browDownLeft:1 browDownRight:1 noseSneerLeft:1 noseSneerRight:1 noseSneerRight:1
|
||||||
Reference in New Issue
Block a user