feat(web): 端口分离 + VMD 动作支持;契约升至 0.2.0
需求 1:管理端端口与软件 API 端口分离 - 软件 API 端口 PORT(默认 3000)只提供 /api、/health、/static、/contracts, 不提供管理页面(未命中路径 404);管理端端口 ADMIN_PORT(默认 3001) 提供管理页面与全部接口;ADMIN_HOST 可单独限制管理端来源 - main.ts 同进程监听两个端口,用 req.socket.localPort 区分端口行为; ADMIN_PORT=PORT 为单端口兼容模式(只创建一个 server,无 EADDRINUSE) - 端口非法在生产环境中止启动;ADMIN_PORT=PORT 只 warn - 启动日志分别打印两个端口的访问地址 需求 2:动作数据支持 VMD(MikuMikuDance) - vmd-parser.ts:二进制解析(30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼 关键帧 + morph 段)、MMD 贝塞尔插值、GBK 模型名回退、固定 30fps - mmd-skeleton.ts:标准 MMD 骨架;静止比例从参考 PMX 骨骼段实测得到 (足首 1.43 / 膝 6.57 / 髋 11.50 / 首 16.21 / 頭 17.16,腿长 10.09) - mmd-pose.ts:FK + 两骨骼解析腿部 IK(膝盖朝正面,不可达收敛为伸直) + 左手系→右手系 Z 镜像(由真实数据对照实验确定:94.1% vs 5.9%) - retarget.ts:抽出 worldFramesToKeypointFrames(),BVH 与 VMD 共用重定向 - MotionFormat 增 vmd、扩展名映射增 .vmd;管理端上传支持 .vmd - scripts/inspect-vmd.mjs:上传前自查工具 契约:rest-api / data-models / model-formats → 0.2.0(另两库已同步逐字节一致) 验证:85 项单测通过(VMD 31 项);契约与 29 条路由一致;typecheck/build 通过; 真实样本经 HTTP 端到端:霜雪千年 2901 帧(IK 腿)、恋爱循环 4604 帧(FK 腿) 均 preprocessState=done、0 NaN、0 越界(测试数据已从数据库清理)。
This commit is contained in:
@@ -30,7 +30,7 @@ opendance-web ←── 本仓库
|
||||
| 数据库 | **现有 MySQL 兼容实例**(当前部署为 MariaDB 10.11.19),mysql2 驱动 |
|
||||
| 管理前端 | React 18 + Vite + TypeScript |
|
||||
| 3D 预览 | three.js + @pixiv/three-vrm |
|
||||
| 多格式解析 | BVH 内置纯 TS 解析器;FBX/glTF 走可选外部 Assimp |
|
||||
| 多格式解析 | BVH / VMD 内置纯 TS 解析器;FBX/glTF 走可选外部 Assimp |
|
||||
| 认证 | **本版本不含认证**(内网部署);上线前须补 ≥ T-WEB-011 |
|
||||
|
||||
## 3. 单次会话加载范围
|
||||
@@ -77,9 +77,15 @@ opendance-web/
|
||||
│ └── open-source-credits.md # 开源声明锚点(三端共用)
|
||||
├── web-server/ # NestJS 服务端
|
||||
│ ├── src/ # 源码(entities / modules / preprocess)
|
||||
│ │ └── preprocess/ # BVH / VMD / FBX → MediaPipe33
|
||||
│ │ ├── vmd-parser.ts # VMD 二进制 + Shift-JIS + 贝塞尔插值
|
||||
│ │ ├── mmd-skeleton.ts # 标准 MMD 骨架(静止比例实测自参考 PMX)
|
||||
│ │ ├── mmd-pose.ts # MMD FK + 两骨骼腿部 IK + 左手系→右手系 Z 镜像
|
||||
│ │ └── retarget.ts # worldFramesToKeypointFrames():BVH / VMD 共用
|
||||
│ ├── admin/ # React 管理端
|
||||
│ ├── test/ # 单元测试 + 端到端冒烟
|
||||
│ ├── test/ # 单元测试 + 端到端冒烟(含 mmd-vmd.spec.ts)
|
||||
│ └── scripts/ # migrate.mjs / check-contracts.mjs / check-routes.mjs
|
||||
│ # / inspect-vmd.mjs(VMD 诊断工具)
|
||||
├── deploy/ # ★ 业务服务部署(不含 MySQL、不含反代)
|
||||
│ ├── install.sh # 一键部署(systemd / OpenRC / SysVinit / 后台进程)
|
||||
│ ├── uninstall.sh
|
||||
@@ -99,6 +105,8 @@ opendance-web/
|
||||
**也不使用 Docker**,**也不部署反向代理**:本端只提供业务服务
|
||||
(API + 自带的管理端页面),反代由另外的服务负责,
|
||||
本项目只在 `deploy/proxy/` 下提供 IIS 与 nginx 的配置说明文档。
|
||||
业务服务监听**两个端口**(软件 API 端口 `PORT` + 管理端端口 `ADMIN_PORT`),
|
||||
约定见 §9。
|
||||
2. **`DB_SYNCHRONIZE` 生产必须为 `false`**,所有表结构变更走 migration;每个
|
||||
migration 必须可逆。
|
||||
3. **绝不把凭证写入仓库**。`.env` 已在 `.gitignore` 中,只提交 `.env.example`。
|
||||
@@ -143,13 +151,55 @@ git push origin task/T-WEB-0XX
|
||||
```
|
||||
DB_HOST DB_PORT DB_USER DB_PASS DB_NAME
|
||||
DB_SYNCHRONIZE=false
|
||||
PORT=3000 # 软件 API 端口(客户端调用,不含管理页面)
|
||||
ADMIN_PORT=3001 # 管理端端口(默认 PORT + 1;等于 PORT 为单端口兼容模式)
|
||||
HOST=0.0.0.0 # 软件 API 监听地址
|
||||
ADMIN_HOST=0.0.0.0 # 可选,管理端监听地址,默认跟随 HOST
|
||||
STORAGE_DIR=/data/storage
|
||||
UPLOAD_MAX_BYTES=536870912
|
||||
ASSIMP_BIN=assimp # 可选
|
||||
CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
|
||||
```
|
||||
|
||||
## 9. 上下文超标时的拆分策略
|
||||
## 9. 双端口约定(软件 API 端口 / 管理端端口)
|
||||
|
||||
- **软件 API 端口**:`PORT`,默认 **3000**。给 Windows/Godot 客户端与 Android App
|
||||
调用,只提供 `/api`、`/health`、`/static`、`/contracts`;**不提供管理页面**,
|
||||
`/`、`/songs` 等一律 404(有意设计,避免管理页暴露在客户端调用的端口上)。
|
||||
- **管理端端口**:`ADMIN_PORT`,默认 `PORT + 1`(即 **3001**)。给浏览器访问,
|
||||
提供管理页面(含 SPA 深链接回退)与上面全部接口。
|
||||
- `ADMIN_HOST` 可选,默认跟随 `HOST`;可用它把管理端限制在本机或内网。
|
||||
- `ADMIN_PORT === PORT` 为**单端口兼容模式**:管理页面回到 API 端口,日志提示
|
||||
「未分端口」,只 warn 不中止启动。
|
||||
- 端口非法(非 1..65535 整数)在生产环境会中断启动。
|
||||
- 启动日志分别打印两行:`软件 API 端口:…` 与 `管理端端口:…`。
|
||||
- 反向代理按用途分别转发两个端口;`/health` 两个端口都提供,可用于存活探测。
|
||||
细则见 `deploy/proxy/README.md` 与 `tasks/010-deploy.md`。
|
||||
|
||||
## 10. VMD 动作通路(MikuMikuDance)
|
||||
|
||||
```
|
||||
.vmd ─▶ vmd-parser.ts ─▶ mmd-skeleton.ts ─▶ mmd-pose.ts ─▶ retarget.ts
|
||||
二进制 + Shift-JIS 标准 MMD 骨架 FK + 腿部 IK worldFramesToKeypointFrames()
|
||||
+ 贝塞尔插值 + Z 镜像 (与 BVH 共用)
|
||||
```
|
||||
|
||||
- `MotionFormat.Vmd = 'vmd'`,扩展名 `.vmd`,固定 **30 fps**(上传时填写的 `fps` 被忽略)。
|
||||
- VMD 二进制:30 字节头 + 20 字节 Shift-JIS 模型名 + 111 字节/条的骨骼关键帧 + morph 段;
|
||||
只有旋转(少数骨骼带位移)、不含骨骼长度,因此必须靠 `mmd-skeleton.ts` 的标准骨架
|
||||
(静止比例从参考 PMX 实测)做 FK。
|
||||
- **腿部驱动自动判定**:`足IK` 有非零位移 → 两骨骼解析 IK(膝盖朝角色正面弯,
|
||||
不可达时收敛为伸直);否则用 FK 旋转。部分动作数据把腿烘焙成 FK。
|
||||
- **手性**:MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) → (-x,-y,z,w)`、位移 z 取反)
|
||||
后进入右手系通路。由真实数据对照实验确定:腿部烘焙为 FK 的样本中,`zMirrored`
|
||||
下「膝盖朝前」占 94.1%,不转换仅 5.9%。
|
||||
- `worldFramesToKeypointFrames()` 为 BVH / VMD 共用:骨盆归零 / 自适应缩放 /
|
||||
图像空间投影 / 9 个关节角完全一致。
|
||||
- 诊断工具:`node scripts/inspect-vmd.mjs 动作.vmd [--frames N] [--json out.json]`
|
||||
(打印头部/模型名/帧数/轨道数、未识别骨骼、IK 还是 FK、关键点序列异常统计、膝肘角分布)。
|
||||
- 契约 `contracts/model-formats.md` §2.3;测试 `test/mmd-vmd.spec.ts`(31 项)。
|
||||
|
||||
## 11. 上下文超标时的拆分策略
|
||||
|
||||
| 超标原因 | 拆分方式 |
|
||||
|---|---|
|
||||
|
||||
+57
-13
@@ -18,13 +18,19 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
|
||||
只在 deploy/proxy/ 下提供 IIS 与 nginx 的配置说明文档。
|
||||
|
||||
部署形态
|
||||
Node 业务服务由系统服务托管,同时提供:
|
||||
/ 管理端页面(本服务自带,含 SPA 深链接回退)
|
||||
Node 业务服务由系统服务托管,同时监听**两个端口**,职责分开:
|
||||
|
||||
软件 API 端口(默认 3000,给 Windows/Godot 客户端与 Android App)
|
||||
/api/ REST API
|
||||
/health 健康检查
|
||||
/static/ 上传文件
|
||||
/contracts/ 契约快照
|
||||
因为静态页面也由本服务提供,反向代理**只需一条全匹配转发规则**。
|
||||
该端口**不提供管理页面**,/ 与 /songs 等一律返回 404(有意设计)
|
||||
|
||||
管理端端口(默认 3001,给浏览器)
|
||||
管理页面(本服务自带,含 SPA 深链接回退)+ 上面全部接口
|
||||
|
||||
反向代理按用途分别转发两个端口,每个端口各一条全匹配转发规则即可。
|
||||
|
||||
前置要求
|
||||
- Linux 服务器,有 root 权限(不需要 sudo,直接以 root 执行即可)
|
||||
@@ -56,18 +62,23 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
|
||||
注册并启动系统服务(systemd / OpenRC / SysVinit / 后台进程)→ 等待健康检查
|
||||
|
||||
部署完成后(直接访问后端端口,<host> 为服务器 IP)
|
||||
管理端首页: http://<host>:3000/
|
||||
API 前缀: http://<host>:3000/api
|
||||
客户端接口: http://<host>:3000/api
|
||||
健康检查: http://<host>:3000/health
|
||||
契约快照: http://<host>:3000/contracts/
|
||||
管理端首页: http://<host>:3001/
|
||||
(软件 API 端口上没有管理页面:http://<host>:3000/ 返回 404,属正常)
|
||||
|
||||
对外访问
|
||||
由反向代理把请求转发到 http://<后端地址>:3000 即可。
|
||||
配置样例与说明:
|
||||
由反向代理**按用途分别转发两个端口**:
|
||||
浏览器管理界面 → http://<后端地址>:3001
|
||||
Windows/Godot 客户端与 Android App → http://<后端地址>:3000
|
||||
配置样例与说明(含两个端口的转发写法):
|
||||
deploy/proxy/nginx.conf.example nginx 站点配置
|
||||
deploy/proxy/web.config.example IIS(ARR + URL Rewrite)配置
|
||||
deploy/proxy/README.md 完整说明、自检清单与常见故障
|
||||
|
||||
直连后端时,防火墙需同时放行两个端口(默认 3000 与 3001)。
|
||||
|
||||
外部访问不了?安装脚本结尾会直接给出结论(监听地址、对外可达性、
|
||||
防火墙提示);也可以在服务器上跑一次自检脚本(只读,不改配置):
|
||||
bash deploy/check-access.sh # 或 bash deploy/check-access.sh /opt/opendance
|
||||
@@ -78,6 +89,8 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
|
||||
APP_DIR=/opt/opendance 程序目录
|
||||
SERVICE_USER=opendance 运行服务的系统用户
|
||||
STORAGE_DIR=/var/lib/opendance/storage 上传文件目录
|
||||
PORT=3000 软件 API 端口
|
||||
ADMIN_PORT=3001 管理端端口(默认 PORT + 1)
|
||||
|
||||
例:APP_DIR=/srv/opendance bash deploy/install.sh
|
||||
|
||||
@@ -118,7 +131,7 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
|
||||
cp .env.example .env # 填写 DB_*
|
||||
npm install
|
||||
npm run migration:run # 首次需要
|
||||
npm run start:dev # http://localhost:3000
|
||||
npm run start:dev # API http://localhost:3000;管理端 http://localhost:3001
|
||||
|
||||
管理前端
|
||||
cd web-server/admin
|
||||
@@ -132,7 +145,7 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
|
||||
cd web-server
|
||||
npm run contracts:check # 契约版本一致性
|
||||
npm run typecheck # TypeScript 严格检查
|
||||
npm run test # 31 项单元测试
|
||||
npm run test # 85 项单元测试(含 31 项 VMD)
|
||||
npm run build # 生产构建
|
||||
npm run start:prod & # 启动
|
||||
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
|
||||
@@ -151,9 +164,18 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
|
||||
所有表结构变更通过 TypeORM migration 执行。
|
||||
|
||||
DB_LOGGING 是否打印 SQL(默认 false)
|
||||
PORT API 监听端口(默认 3000)
|
||||
HOST 监听地址。反代与后端同机用 127.0.0.1(推荐);
|
||||
反代在别的机器用 0.0.0.0,并用防火墙限制来源
|
||||
PORT 软件 API 监听端口(默认 3000)。给 Windows/Godot 客户端
|
||||
与 Android App 调用,只提供 /api、/health、/static、
|
||||
/contracts,**不提供管理页面**
|
||||
ADMIN_PORT 管理端端口(默认 PORT + 1,即 3001)。浏览器访问管理界面
|
||||
(管理页面 + 上面全部接口);设为与 PORT 相同即为
|
||||
单端口兼容模式,日志会提示「未分端口」
|
||||
HOST 监听地址(软件 API 端口)。反代与后端同机用 127.0.0.1
|
||||
(推荐);反代在别的机器用 0.0.0.0,并用防火墙限制来源
|
||||
ADMIN_HOST 管理端监听地址(可选,默认跟随 HOST)。需要接口对外、
|
||||
管理端只对本机时设为 127.0.0.1
|
||||
端口配置非法(非 1..65535 整数)时,生产环境启动会直接中止;
|
||||
ADMIN_PORT 设为与 PORT 相同只打印提示,不中止。
|
||||
ADMIN_DIR 管理端静态站点目录(默认 ../admin,即 /opt/opendance/admin)
|
||||
STORAGE_DIR 上传文件存储目录(原生部署默认 /var/lib/opendance/storage)
|
||||
UPLOAD_MAX_BYTES 单文件上限,默认 536870912(512 MiB)。
|
||||
@@ -177,7 +199,7 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
|
||||
POST /api/songs/:id/audio 上传音频
|
||||
GET /api/motions 动作列表
|
||||
POST /api/motions 上传动作 + 来源信息
|
||||
POST /api/motions/:id/preprocess BVH/FBX → MediaPipe33 关键点
|
||||
POST /api/motions/:id/preprocess BVH/VMD/FBX → MediaPipe33 关键点
|
||||
GET /api/motions/:id/keypoints 下载关键点序列(NDJSON)
|
||||
GET /api/models 模型列表
|
||||
POST /api/models 上传模型 + 来源信息(VRM 优先)
|
||||
@@ -193,6 +215,28 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
|
||||
上传文件通过 /static/<kind>/<yyyy>/<mm>/<sha256>.ext 访问,
|
||||
相同内容只落盘一次(sha256 去重)。
|
||||
|
||||
支持的动作格式(POST /api/motions 上传 → POST /api/motions/:id/preprocess 预处理)
|
||||
BVH .bvh 内置纯 TS 解析器,保留源帧率
|
||||
VMD .vmd 内置解析(MikuMikuDance),固定 30fps;
|
||||
腿部按「足IK是否带位移」自动选 IK 或 FK
|
||||
FBX .fbx 需可选的 Assimp 先转 BVH
|
||||
glTF / GLB .gltf / .glb 同上
|
||||
关键点 JSON .json 已是 MediaPipe33 结构,只做校验与规范化
|
||||
|
||||
VMD 动作上传前后自查
|
||||
服务端对 .vmd 是内置解析,不依赖 Assimp。上传前可先在 web-server 目录下
|
||||
跑诊断工具(只读:不写数据库、不访问网络),确认骨骼名能识别、腿走 IK
|
||||
还是 FK、关键点序列无异常:
|
||||
|
||||
cd web-server
|
||||
npm run build
|
||||
node scripts/inspect-vmd.mjs 动作.vmd
|
||||
node scripts/inspect-vmd.mjs 动作.vmd --frames 300 # 只采样前 300 帧
|
||||
node scripts/inspect-vmd.mjs 动作.vmd --json out.json # 导出关键点序列
|
||||
|
||||
它会打印头部 / 模型名 / 帧数 / 轨道数、未识别骨骼、走 IK 还是 FK、关键点
|
||||
序列异常统计与膝肘角分布。上传后再调用 /preprocess 走正式通路。
|
||||
|
||||
----------------------------------------------------------------
|
||||
六、目录结构
|
||||
----------------------------------------------------------------
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
# OpenDance Studio 契约版本
|
||||
# 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。
|
||||
rest-api: 0.1.0
|
||||
rest-api: 0.2.0
|
||||
websocket-keypoints: 0.1.0
|
||||
data-models: 0.1.0
|
||||
data-models: 0.2.0
|
||||
skeleton-topology: 0.1.0
|
||||
model-formats: 0.1.0
|
||||
model-formats: 0.2.0
|
||||
database: 0.1.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# contracts/data-models.md — 数据模型契约 v0.1.0
|
||||
# contracts/data-models.md — 数据模型契约 v0.2.0
|
||||
|
||||
Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视图**。
|
||||
字段名在 JSON 中统一 `camelCase`;数据库列名 `snake_case`(见 `database.md`)。
|
||||
@@ -75,7 +75,7 @@ rejected 已驳回
|
||||
| `id` | number | 读 | |
|
||||
| `songId` | number\|null | | 关联曲目 |
|
||||
| `name` | string | ✅ | 动作名称,≤200 |
|
||||
| `format` | enum | ✅ | `bvh` / `fbx` / `gltf` / `glb` / `keypoints-json` |
|
||||
| `format` | enum | ✅ | `bvh` / `vmd` / `fbx` / `gltf` / `glb` / `keypoints-json` |
|
||||
| `fileUrl` | string | 读 | 原始动作文件 |
|
||||
| `fileSize` | number | 读 | 字节 |
|
||||
| `fps` | number\|null | | 原始帧率 |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# contracts/model-formats.md — 模型与动作格式契约 v0.1.0
|
||||
# contracts/model-formats.md — 模型与动作格式契约 v0.2.0
|
||||
|
||||
## 1. 角色模型(models)
|
||||
|
||||
@@ -19,6 +19,7 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
|
||||
| 格式 | 扩展名 | 预处理支持 | 说明 |
|
||||
|---|---|---|---|
|
||||
| BVH | `.bvh` | ✅ 内置纯 TS 解析器 | 文本格式,服务端直接解析 → FK → MediaPipe33 |
|
||||
| VMD | `.vmd` | ✅ 内置纯 TS 解析器 | MikuMikuDance 动作数据(二进制,Shift-JIS 骨骼名 + 四元数关键帧);服务端按标准 MMD 骨架做 FK + 腿部 IK 求解 → MediaPipe33,见 §2.3 |
|
||||
| FBX | `.fbx` | ⚠️ 需 Assimp | 服务端调用 `assimp` 可执行文件导出为 BVH/glTF 后再解析;未安装则 `preprocessState=failed` |
|
||||
| glTF/GLB | `.gltf` / `.glb` | ⚠️ 需 Assimp | 同上 |
|
||||
| 关键点 JSON | `.json` | ✅ 直通校验 | 已是 `mediapipe33@0.1.0` 结构,仅做校验与归一化 |
|
||||
@@ -65,6 +66,47 @@ VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)
|
||||
未映射到的点按 `skeleton-topology.md` §5 的层级由父节点推算,
|
||||
并置 `confidence` 降低(`0.5`),表示估算而非实测。
|
||||
|
||||
### 2.3 VMD(MikuMikuDance)解析约定
|
||||
|
||||
VMD 是二进制格式:文件头 `Vocaloid Motion Data 0002`(30 字节)+ 模型名(20 字节,
|
||||
Shift-JIS)+ 骨骼关键帧数组(每条 **111 字节**:15 字节骨骼名、uint32 帧号、
|
||||
float32×3 位移、float32×4 旋转四元数、64 字节贝塞尔插值参数)+ morph / 相机 /
|
||||
灯光 / 自阴影 / IK / 可见性段落。**VMD 不含骨骼长度**,只有旋转(少数骨骼带位移),
|
||||
因此服务端必须自带一套标准 MMD 骨架才能做 FK。
|
||||
|
||||
处理链路:`vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)→ `mmd-skeleton.ts`
|
||||
(标准骨架)→ `mmd-pose.ts`(FK + 腿部 IK)→ `retarget.ts` 的
|
||||
`worldFramesToKeypointFrames()`(与 BVH 完全共用骨盆归零 / 自适应缩放 / 图像空间投影 /
|
||||
9 个关节角)。固定 **30 fps**(MMD 帧率恒定,上传时填写的 `fps` 被忽略)。
|
||||
|
||||
| MediaPipe 点 | MMD 标准骨骼 | 说明 |
|
||||
|---|---|---|
|
||||
| 0 nose | `頭` | 取头骨根部再上移,近似面部中心 |
|
||||
| 11 / 12 left/right_shoulder | `左腕` / `右腕` | 肩关节(不是 `左肩` 骨骼) |
|
||||
| 13 / 14 left/right_elbow | `左ひじ` / `右ひじ` | |
|
||||
| 15 / 16 left/right_wrist | `左手首` / `右手首` | |
|
||||
| 23 / 24 left/right_hip | `左足` / `右足` | 大腿根部 |
|
||||
| 25 / 26 left/right_knee | `左ひざ` / `右ひざ` | |
|
||||
| 27 / 28 left/right_ankle | `左足首` / `右足首` | |
|
||||
| 31 / 32 left/right_foot_index | `左つま先` / `右つま先` | |
|
||||
|
||||
骨骼名按 `mmd-skeleton.ts` 的别名表归一(含全角 `IK` / 半角 `IK` 两种写法);
|
||||
未纳入标准骨架的骨骼(裙摆、物理、表情等)被忽略但会计数并写入预处理日志。
|
||||
|
||||
**腿部驱动判定**:`足IK` 轨道存在非零位移 → 走 IK(用两骨骼解析 IK 把踝拉到 IK
|
||||
目标点,膝盖朝角色正面弯,不可达时收敛为伸直);否则走 FK(部分动作数据把腿烘焙成
|
||||
FK 旋转)。两份示例文件分别命中两种路径。
|
||||
|
||||
**坐标系**:MMD 原生是左手系(正面为 -Z),本项目通路是右手系(正面为 +Z、+X 为角色
|
||||
左侧)。VMD 的四元数与位移统一做 Z 镜像(`(x,y,z,w) → (-x,-y,z,w)`、位移 z 取反)后
|
||||
送入标准骨架。该选择由真实数据对照实验确定:腿部烘焙为 FK 的样本中,`zMirrored` 下
|
||||
膝盖朝前占比 94.1%,不转换时仅 5.9%(见 `test/mmd-vmd.spec.ts`)。
|
||||
|
||||
**标准骨架数值来源**:解析参考模型的 PMX 骨骼段实测得到(而非凭经验估计),
|
||||
再去掉模型自身原点偏移并抹平左右不对称。关键比例:足首 1.43 / ひざ 6.57 /
|
||||
髋 11.50 / センター 10.88 / 首 16.21 / 頭 17.16(总高约 19.4,髋高比约 59%);
|
||||
大腿 4.94 + 小腿 5.15 ≈ 腿长 10.07。
|
||||
|
||||
## 3. 上传限制
|
||||
|
||||
| 项 | 值 |
|
||||
|
||||
+10
-7
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: OpenDance Studio Web 管理服务端 API
|
||||
version: 0.1.0
|
||||
version: 0.2.0
|
||||
description: |
|
||||
OpenDance Studio 的唯一数据源与契约权威。
|
||||
契约版本见 contracts/VERSION。字段语义见 contracts/data-models.md。
|
||||
@@ -11,11 +11,14 @@ info:
|
||||
- 来源五元组(sourceType/sourceName/sourceUrl/license/author)在三类资产上均为必填
|
||||
servers:
|
||||
- url: http://localhost:3000
|
||||
description: 本地开发
|
||||
- url: http://<host>
|
||||
description: 本地开发(软件 API 端口)
|
||||
- url: http://<host>:3000
|
||||
description: >-
|
||||
Linux 业务服务部署。服务自带管理端页面,反向代理(外部提供,非本项目交付物)
|
||||
只需将请求整体转发到该地址即可,无需托管静态文件或配置 SPA 回退。
|
||||
Linux 业务服务部署的**软件 API 端口**(`PORT`,默认 3000)。Windows/Godot 客户端与
|
||||
Android App 调用本端口的 /api、/health、/static、/contracts。
|
||||
管理端页面在**管理端口**(`ADMIN_PORT`,默认 3001),该端口同时提供全部接口;
|
||||
API 端口不提供管理页面,未命中的路径一律 404。
|
||||
反向代理由另外的服务提供(非本项目交付物),按用途分别转发到对应端口即可。
|
||||
|
||||
tags:
|
||||
- name: health
|
||||
@@ -180,7 +183,7 @@ paths:
|
||||
schema: { type: integer, format: int64 }
|
||||
- name: format
|
||||
in: query
|
||||
schema: { type: string, enum: [bvh, fbx, gltf, glb, keypoints-json] }
|
||||
schema: { type: string, enum: [bvh, vmd, fbx, gltf, glb, keypoints-json] }
|
||||
- name: status
|
||||
in: query
|
||||
schema: { $ref: '#/components/schemas/PublishStatus' }
|
||||
@@ -589,7 +592,7 @@ components:
|
||||
enum: [draft, pending, published, rejected]
|
||||
MotionFormat:
|
||||
type: string
|
||||
enum: [bvh, fbx, gltf, glb, keypoints-json]
|
||||
enum: [bvh, vmd, fbx, gltf, glb, keypoints-json]
|
||||
ModelFormat:
|
||||
type: string
|
||||
enum: [vrm, glb, gltf, fbx]
|
||||
|
||||
+8
-1
@@ -7,13 +7,20 @@
|
||||
|
||||
# ---- 服务 ----
|
||||
NODE_ENV=production
|
||||
# 软件 API 端口:Windows/Godot 客户端与 Android App 调用它
|
||||
# (提供 /api、/health、/static、/contracts,**不提供管理页面**)
|
||||
PORT=3000
|
||||
# 管理端端口:浏览器访问管理界面(页面 + 上面全部接口)
|
||||
# 不设置时默认 = PORT + 1(即 3001);设为与 PORT 相同即为单端口兼容模式
|
||||
ADMIN_PORT=3001
|
||||
# 监听地址:
|
||||
# 反代与后端同机 → 127.0.0.1(后端不直接对外暴露,推荐)
|
||||
# 反代在别的机器 → 0.0.0.0,并用防火墙把该端口限制为仅反代来源可访问
|
||||
HOST=0.0.0.0
|
||||
# 管理端单独绑定地址(可选)。只想让管理端暴露在内网时,例如 127.0.0.1 或内网网卡地址
|
||||
# ADMIN_HOST=127.0.0.1
|
||||
|
||||
# 管理端静态站点目录(本服务自带管理端页面,反代只需整体转发到本端口)
|
||||
# 管理端静态站点目录(本服务自带管理端页面,反代只需整体转发到管理端端口)
|
||||
ADMIN_DIR=/opt/opendance/admin
|
||||
|
||||
# ---- 现有 MySQL / MariaDB 实例(本项目不安装、不管理数据库)----
|
||||
|
||||
+39
-14
@@ -16,7 +16,8 @@
|
||||
# APP_DIR 安装目录(默认 /opt/opendance)
|
||||
# SERVICE_USER 运行服务的系统用户(默认 opendance)
|
||||
# STORAGE_DIR 上传文件目录(默认 /var/lib/opendance/storage)
|
||||
# PORT API 监听端口(默认 3000)
|
||||
# PORT 软件 API 监听端口(默认 3000)
|
||||
# ADMIN_PORT 管理端端口(默认 PORT + 1;设为与 PORT 相同即单端口模式)
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
# 失败时打印出错行号,避免只看到半截输出
|
||||
@@ -29,6 +30,7 @@ APP_DIR="${APP_DIR:-/opt/opendance}"
|
||||
SERVICE_USER="${SERVICE_USER:-opendance}"
|
||||
STORAGE_DIR="${STORAGE_DIR:-/var/lib/opendance/storage}"
|
||||
API_PORT="${PORT:-3000}"
|
||||
ADMIN_PORT="${ADMIN_PORT:-$((API_PORT + 1))}"
|
||||
# 注意:健康检查与汇总输出用的地址不在这里写死——真实监听地址来自
|
||||
# deploy/.env 的 HOST,脚本在源入 .env 后计算 BIND_HOST / HEALTH_HOST / LAN_IP。
|
||||
|
||||
@@ -114,6 +116,9 @@ set -a
|
||||
. ./deploy/.env
|
||||
set +a
|
||||
API_PORT="${PORT:-3000}"
|
||||
# 管理端端口:默认 API 端口 + 1;等于 API 端口时是单端口兼容模式
|
||||
ADMIN_PORT="${ADMIN_PORT:-$((API_PORT + 1))}"
|
||||
ADMIN_BIND_HOST="${ADMIN_HOST:-${HOST:-0.0.0.0}}"
|
||||
|
||||
[ -n "${DB_HOST:-}" ] || die "deploy/.env 中 DB_HOST 未设置"
|
||||
[ -n "${DB_USER:-}" ] || die "deploy/.env 中 DB_USER 未设置"
|
||||
@@ -398,6 +403,21 @@ if [ "$READY" -ne 1 ]; then
|
||||
fi
|
||||
log "API 已就绪:$(http_get "http://${HEALTH_HOST}:${API_PORT}/health" | head -c 200)"
|
||||
|
||||
# 管理端端口单独确认(端口写错时,只有在装的时候点出来才有人看见)
|
||||
ADMIN_READY=""
|
||||
ADMIN_HOST_FOR_PROBE="${ADMIN_BIND_HOST}"
|
||||
case "$ADMIN_HOST_FOR_PROBE" in
|
||||
0.0.0.0|'*'|'') ADMIN_HOST_FOR_PROBE="127.0.0.1" ;;
|
||||
esac
|
||||
if [ "$ADMIN_PORT" = "$API_PORT" ]; then
|
||||
ADMIN_READY="same"
|
||||
elif http_ok "http://${ADMIN_HOST_FOR_PROBE}:${ADMIN_PORT}/health"; then
|
||||
ADMIN_READY="yes"
|
||||
else
|
||||
ADMIN_READY="no"
|
||||
warn "管理端端口 ${ADMIN_PORT} 未在本机就绪(API 端口=${API_PORT})"
|
||||
fi
|
||||
|
||||
# ---------- 5. 边界:确认"外部到底能不能访问" ----------
|
||||
# 绑定层写错(HOST=127.0.0.1)时,本机 curl 一切正常,但外部永远连不上,
|
||||
# 而安装日志里看不出任何异常——必须在安装时就点出来。
|
||||
@@ -425,10 +445,14 @@ cat <<EOF
|
||||
------------------------------------------------------------
|
||||
反向代理说明(本脚本不部署反代)
|
||||
------------------------------------------------------------
|
||||
本服务已同时提供 API 与管理端页面,反代只需将请求整体转发至:
|
||||
http://<本机地址>:${API_PORT}
|
||||
管理端口与软件 API 端口是分开的,按用途分别转发:
|
||||
|
||||
健康检查(可用于反代的存活探测):http://<本机地址>:${API_PORT}/health
|
||||
软件 API 端口(给 Windows/Godot 客户端与 Android App):http://<本机地址>:${API_PORT}
|
||||
提供 /api、/health、/static、/contracts;**不含管理页面**(未知路径返回 404)
|
||||
管理端端口(给浏览器):http://<本机地址>:${ADMIN_PORT}
|
||||
提供管理页面 + 上面全部接口;建议只在内网 / VPN 内暴露
|
||||
|
||||
健康检查(可用于反代的存活探测,两个端口都提供):/health
|
||||
上传上限需在代理层同步放开(本服务为 $(( ${UPLOAD_MAX_BYTES:-536870912} / 1024 / 1024 )) MiB)
|
||||
|
||||
配置样例与完整说明:
|
||||
@@ -437,7 +461,7 @@ cat <<EOF
|
||||
deploy/proxy/README.md IIS / nginx 反代说明文档
|
||||
|
||||
注意:若反代与后端不在同一台机器,需保持 HOST=0.0.0.0(当前值:${BIND_HOST})并用防火墙限制来源;
|
||||
若在同一台机器,建议把 HOST 改为 127.0.0.1。
|
||||
若在同一台机器,建议把 HOST 改为 127.0.0.1(管理端可用 ADMIN_HOST 单独限制,当前值:${ADMIN_BIND_HOST})。
|
||||
------------------------------------------------------------
|
||||
|
||||
EOF
|
||||
@@ -497,16 +521,17 @@ cat <<EOF
|
||||
|
||||
============================================================
|
||||
OpenDance Studio Web 服务端已部署(业务服务,无 Docker、不含反向代理)
|
||||
监听地址: ${LISTEN_DESC}
|
||||
管理端首页: http://${HOST_HINT}:${API_PORT}/ (由本服务直接提供)
|
||||
API 前缀: http://${HOST_HINT}:${API_PORT}/api
|
||||
软件 API: ${LISTEN_DESC}
|
||||
接口:/api、/health、/static、/contracts(客户端调用;不含管理页面)
|
||||
管理端: http://${HOST_HINT}:${ADMIN_PORT}/(浏览器访问管理界面)
|
||||
健康检查: http://${HOST_HINT}:${API_PORT}/health
|
||||
契约快照: http://${HOST_HINT}:${API_PORT}/contracts/
|
||||
数据库: 使用现有实例 ${DB_HOST}:${DB_PORT:-3306}/${DB_NAME}
|
||||
上传目录: ${STORAGE_DIR}
|
||||
程序目录: ${APP_DIR}
|
||||
进程管理: ${INIT_SYSTEM}
|
||||
HOST 配置: ${BIND_HOST}(部署配置文件 ${APP_DIR}/deploy/.env)
|
||||
端口配置: API ${API_PORT} / 管理端 ${ADMIN_PORT}(${APP_DIR}/deploy/.env)
|
||||
绑定配置: HOST=${BIND_HOST} ADMIN_HOST=${ADMIN_BIND_HOST}
|
||||
|
||||
常用命令:
|
||||
${CMD_HINT}
|
||||
@@ -534,9 +559,9 @@ EOF
|
||||
esac
|
||||
cat <<EOF
|
||||
|
||||
别忘了在防火墙放行 ${API_PORT}/tcp:
|
||||
firewall-cmd --permanent --add-port=${API_PORT}/tcp && firewall-cmd --reload
|
||||
# 或:ufw allow ${API_PORT}/tcp
|
||||
别忘了在防火墙放行端口(客户端用 ${API_PORT},浏览器管理界面用 ${ADMIN_PORT}):
|
||||
firewall-cmd --permanent --add-port=${API_PORT}/tcp --add-port=${ADMIN_PORT}/tcp && firewall-cmd --reload
|
||||
# 或:ufw allow ${API_PORT}/tcp && ufw allow ${ADMIN_PORT}/tcp
|
||||
|
||||
诊断脚本(只读):bash ${APP_DIR}/deploy/check-access.sh
|
||||
============================================================
|
||||
@@ -549,8 +574,8 @@ elif [ "$EXTERNAL_OK" = "no" ]; then
|
||||
============================================================
|
||||
绑定层没问题,外部却连不上,通常是防火墙或云安全组拦截:
|
||||
|
||||
1) 放行端口:firewall-cmd --permanent --add-port=${API_PORT}/tcp && firewall-cmd --reload
|
||||
或 ufw allow ${API_PORT}/tcp
|
||||
1) 放行端口:firewall-cmd --permanent --add-port=${API_PORT}/tcp --add-port=${ADMIN_PORT}/tcp && firewall-cmd --reload
|
||||
或 ufw allow ${API_PORT}/tcp && ufw allow ${ADMIN_PORT}/tcp
|
||||
2) 云服务器还要在控制台「安全组 / 网络 ACL」放行 ${API_PORT}/tcp
|
||||
3) 确认客户端与服务器网络互通
|
||||
|
||||
|
||||
+39
-15
@@ -5,37 +5,54 @@
|
||||
>
|
||||
> 安装脚本 `deploy/install.sh` 不会安装或修改任何反代软件。
|
||||
|
||||
## 一、需要转发的目标
|
||||
## 一、需要转发的目标(两个端口,按用途分开)
|
||||
|
||||
本服务**自带管理端页面**,因此反向代理**只需把请求整体转发**到一个地址:
|
||||
本服务**同时监听两个端口**,职责不同:
|
||||
|
||||
| 端口 | 环境变量 | 默认 | 给谁用 | 提供什么 |
|
||||
|---|---|---|---|---|
|
||||
| 软件 API 端口 | `PORT` | `3000` | Windows/Godot 客户端、Android App | `/api`、`/health`、`/static`、`/contracts`;**不含管理页面**(未知路径 404) |
|
||||
| 管理端端口 | `ADMIN_PORT` | `3001` | 浏览器(管理员) | 管理页面 + 上面全部接口 |
|
||||
|
||||
```
|
||||
http://<后端地址>:3000
|
||||
软件 API: http://<后端地址>:3000
|
||||
管理端: http://<后端地址>:3001
|
||||
```
|
||||
|
||||
不需要在代理层单独托管静态文件,也不需要配置 SPA 回退(服务端已处理)。
|
||||
一个 `location /`(nginx)或一条全匹配规则(IIS)即可。
|
||||
两者都不需要在代理层托管静态文件,也不需要配置 SPA 回退(服务端已处理);
|
||||
各用一条 `location /`(nginx)或全匹配规则(IIS)即可。
|
||||
|
||||
| 路径 | 内容 | 说明 |
|
||||
**推荐做法**:把两个端口挂在两个不同的站点/域名上,便于分别做访问控制,例如
|
||||
|
||||
- `https://dance-api.example.com` → 转发到 `3000`(给客户端,可用 IP 白名单)
|
||||
- `https://dance-admin.example.com` → 转发到 `3001`(给管理员,建议加 Basic Auth / SSO 或只在内网放行)
|
||||
|
||||
如果客户端与管理端在同一个内网、也不想配域名,可以**让客户端直连 `3000`**(不经过反代),
|
||||
只把管理端 `3001` 挂到反代上;反之亦可。
|
||||
|
||||
| 路径 | 内容 | 哪个端口提供 |
|
||||
|---|---|---|
|
||||
| `/` | 管理端页面 | 由 Node 服务提供;前端路由深链接(如 `/songs`)由服务端回退到 `index.html` |
|
||||
| `/assets/*` | 前端静态资源 | 带内容哈希,可长缓存 |
|
||||
| `/api/*` | REST API | 契约见 `contracts/rest-api.yaml` |
|
||||
| `/health` | 健康检查 | 可用作反代的存活探测,数据库不可用时返回 **503** |
|
||||
| `/static/*` | 上传文件 | 模型 / 音频 / 关键点序列 |
|
||||
| `/contracts/*` | 契约快照 | 只读,便于另两端查阅 |
|
||||
| `/`、`/assets/*` | 管理端页面与静态资源 | **仅管理端**;前端路由深链接(如 `/songs`)由服务端回退到 `index.html` |
|
||||
| `/api/*` | REST API | 两个端口都提供(契约见 `contracts/rest-api.yaml`) |
|
||||
| `/health` | 健康检查 | 两个端口都提供;可用作反代的存活探测,数据库不可用时返回 **503** |
|
||||
| `/static/*` | 上传文件 | 两个端口都提供(模型 / 音频 / 关键点序列) |
|
||||
| `/contracts/*` | 契约快照 | 两个端口都提供,只读 |
|
||||
|
||||
> Windows 客户端与 Android 之间的**关键点 WebSocket 链路不经过本服务**,
|
||||
> 反代无需配置 WebSocket 转发。
|
||||
>
|
||||
> `ADMIN_PORT` 设为与 `PORT` 相同时是**单端口兼容模式**:两个端口合一,
|
||||
> 管理页面重新出现在 API 端口上(此时反代只需要一条规则)。
|
||||
|
||||
## 二、后端绑定地址怎么选
|
||||
|
||||
`deploy/.env` 中的 `HOST`:
|
||||
`deploy/.env` 中的 `HOST`(软件 API 端口)与可选的 `ADMIN_HOST`(管理端端口,缺省跟随 `HOST`):
|
||||
|
||||
| 部署形态 | 建议值 | 说明 |
|
||||
|---|---|---|
|
||||
| 反代与后端**同机** | `127.0.0.1` | 后端不直接对外暴露,最安全 |
|
||||
| 反代与后端**不同机** | `0.0.0.0` | 必须用防火墙把 3000 端口限制为仅反代来源可访问 |
|
||||
| 反代与后端**同机** | `HOST=127.0.0.1` | 后端不直接对外暴露,最安全 |
|
||||
| 反代与后端**不同机** | `HOST=0.0.0.0` | 必须用防火墙把端口限制为仅反代来源可访问 |
|
||||
| 只想让管理端在内网可见 | `ADMIN_HOST=<内网网卡地址>` | 与 `HOST=0.0.0.0` 组合使用,把管理端限制在内网 |
|
||||
|
||||
改完执行 `systemctl restart opendance-api`(或对应的 `ctl.sh restart`)生效。
|
||||
|
||||
@@ -154,8 +171,12 @@ IIS 管理器 → 选中服务器节点 → Application Request Routing Cache
|
||||
配置完成后逐项确认:
|
||||
|
||||
```bash
|
||||
# 0. 两个端口都在监听(双端口部署)
|
||||
ss -tlnp | grep -E ':(3000|3001)'
|
||||
|
||||
# 1. 后端自身可用(绕过反代)
|
||||
curl -i http://127.0.0.1:3000/health
|
||||
curl -i http://127.0.0.1:3001/health
|
||||
|
||||
# 2. 经反代可访问
|
||||
curl -i http://<反代地址>/health
|
||||
@@ -180,6 +201,9 @@ curl -i http://<反代地址>/api/catalog/version
|
||||
| 上传大文件 413 | 代理层上传上限没放开(见第三、四、五节) |
|
||||
| 上传中途断开 | 代理超时太短 |
|
||||
| `/songs` 直接访问 404 | SPA 回退被代理拦截;本服务已处理,代理**不要**自己加 `try_files`,全匹配转发即可 |
|
||||
| 客户端接口全 404 | 把客户端的转发规则指到了管理端口之外,或误把 `/api` 前缀重写掉;客户端应转发到软件 API 端口 |
|
||||
| 管理页面 404 | 把浏览器的转发规则指到了**软件 API 端口**(该端口不提供管理页面,这是有意设计);改指 `ADMIN_PORT` |
|
||||
| 两个端口都转发到同一条规则 | 若确实想合并两个端口,设置 `ADMIN_PORT=PORT` 走单端口模式,而不是让反代去猜 |
|
||||
|
||||
## 八、还没有反代时:直连后端怎么访问
|
||||
|
||||
|
||||
@@ -1,44 +1,57 @@
|
||||
# ============================================================
|
||||
# OpenDance Studio Web 服务端 —— nginx 反向代理样例
|
||||
# OpenDance Studio Web 服务端 —— nginx 反向代理样例(双端口)
|
||||
#
|
||||
# 说明:本 Web 端不自带反向代理。本文件是**给你的反代服务参考**的配置样例,
|
||||
# 由部署 `deploy/install.sh` 之外的反代负责人使用。
|
||||
#
|
||||
# 特点:后端(NestJS)自带管理端页面与 SPA 回退,因此这里
|
||||
# 只需一个全匹配 location 把请求整体转发过去即可,
|
||||
# 不需要 root/try_files 托管静态文件。
|
||||
# 后端同时监听两个端口,职责不同:
|
||||
# 软件 API 端口(PORT,默认 3000)—— Windows/Godot 客户端、Android App
|
||||
# 只提供 /api、/health、/static、/contracts
|
||||
# 管理端端口(ADMIN_PORT,默认 3001)—— 浏览器,提供管理页面 + 上面全部接口
|
||||
#
|
||||
# 两个端口都不需要在代理层托管静态文件、也不需要 SPA 回退(服务端已处理),
|
||||
# 因此每个站点只要一条 `location /` 全匹配转发即可。
|
||||
#
|
||||
# 安装:
|
||||
# Debian/Ubuntu: /etc/nginx/sites-available/opendance.conf(再软链到 sites-enabled/)
|
||||
# RHEL 系: /etc/nginx/conf.d/opendance.conf
|
||||
# 校验并重载: nginx -t && systemctl reload nginx
|
||||
#
|
||||
# 想省事只挂一个域名?把两段 server 合并成一段,让另一侧的客户端直连端口即可;
|
||||
# 也可以在后端设 `ADMIN_PORT=PORT` 走单端口兼容模式(此时管理页面回到 API 端口)。
|
||||
# ============================================================
|
||||
|
||||
upstream opendance_api {
|
||||
# 后端地址:同机用 127.0.0.1;异机改成实际 IP,并在后端放防火墙
|
||||
# 软件 API 端口。同机用 127.0.0.1;异机改成实际 IP,并在后端放防火墙
|
||||
server 127.0.0.1:3000;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
upstream opendance_admin {
|
||||
# 管理端端口
|
||||
server 127.0.0.1:3001;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 站点一:软件 API(给客户端调用)
|
||||
# ------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name your.domain.com; # 改成实际域名或 _
|
||||
server_name dance-api.example.com; # 改成实际域名或 _
|
||||
|
||||
# 字符集:管理端与 API 都是 UTF-8
|
||||
charset utf-8;
|
||||
|
||||
# ★ 上传上限:后端默认允许 512 MiB,nginx 默认仅 1 MB,
|
||||
# 不改会导致 VRM / FBX 等大文件上传直接 413。
|
||||
# 不改会导致 VRM / FBX / VMD 等大文件上传直接 413。
|
||||
client_max_body_size 512m;
|
||||
client_body_timeout 300s;
|
||||
|
||||
access_log /var/log/nginx/opendance.access.log;
|
||||
error_log /var/log/nginx/opendance.error.log;
|
||||
access_log /var/log/nginx/opendance-api.access.log;
|
||||
error_log /var/log/nginx/opendance-api.error.log;
|
||||
|
||||
# ---- 全部请求转发给后端 ----
|
||||
# 管理端页面、前端路由深链接、/api、/health、/static、/contracts
|
||||
# 全部由后端处理,所以这里只需一条规则。
|
||||
# ---- 全部请求转发给后端(/api、/health、/static、/contracts)----
|
||||
location / {
|
||||
proxy_pass http://opendance_api;
|
||||
proxy_http_version 1.1;
|
||||
@@ -72,14 +85,54 @@ server {
|
||||
gzip_types text/plain text/css application/json application/javascript
|
||||
application/x-ndjson text/xml application/xml image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
|
||||
# 若前端资源由后端返回,代理层不要缓存 HTML,避免升级后页面不更新
|
||||
location ~* \.html$ {
|
||||
proxy_pass http://opendance_api;
|
||||
# ------------------------------------------------------------
|
||||
# 站点二:管理端(给浏览器)
|
||||
#
|
||||
# 建议只在内网 / VPN 内暴露,或至少加一层认证(见文件末尾)。
|
||||
# 也可以把 server_name 写成内网域名,并在防火墙只放行内网来源。
|
||||
# ------------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name dance-admin.example.com; # 改成实际域名或 _
|
||||
|
||||
charset utf-8;
|
||||
|
||||
# 管理端同样会上传模型 / 动作,上限保持一致
|
||||
client_max_body_size 512m;
|
||||
client_body_timeout 300s;
|
||||
|
||||
access_log /var/log/nginx/opendance-admin.access.log;
|
||||
error_log /var/log/nginx/opendance-admin.error.log;
|
||||
|
||||
# ---- 管理页面 + 全部接口 ----
|
||||
location / {
|
||||
proxy_pass http://opendance_admin;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
|
||||
# 代理层不要缓存 HTML,避免升级后页面不更新
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript
|
||||
application/x-ndjson image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
@@ -88,7 +141,7 @@ server {
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# http2 on;
|
||||
# server_name your.domain.com;
|
||||
# server_name dance-admin.example.com;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/opendance.crt;
|
||||
# ssl_certificate_key /etc/nginx/ssl/opendance.key;
|
||||
@@ -97,7 +150,7 @@ server {
|
||||
# client_max_body_size 512m;
|
||||
#
|
||||
# location / {
|
||||
# proxy_pass http://opendance_api;
|
||||
# proxy_pass http://opendance_admin;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -111,11 +164,23 @@ server {
|
||||
# ============================================================
|
||||
|
||||
# ============================================================
|
||||
# 可选:本服务未内置登录认证,如需公网访问请在反代加一层防护
|
||||
# 可选:本服务未内置登录认证
|
||||
#
|
||||
# 管理端建议至少加一层 Basic Auth;软件 API 若不便加认证,
|
||||
# 请用 IP 白名单把来源限制为客户端所在网段。
|
||||
#
|
||||
# 管理端:
|
||||
# location / {
|
||||
# auth_basic "OpenDance Studio";
|
||||
# auth_basic_user_file /etc/nginx/.htpasswd; # htpasswd -c 生成
|
||||
# proxy_pass http://opendance_admin;
|
||||
# ...
|
||||
# }
|
||||
#
|
||||
# 软件 API:
|
||||
# location / {
|
||||
# allow 192.168.150.0/24; # 客户端所在网段
|
||||
# deny all;
|
||||
# proxy_pass http://opendance_api;
|
||||
# ...
|
||||
# }
|
||||
|
||||
@@ -16,7 +16,18 @@
|
||||
|
||||
放置位置:IIS 站点根目录下的 web.config
|
||||
|
||||
后端(NestJS)自带管理端页面与 SPA 回退,所以这里只需一条全匹配重写规则,
|
||||
后端(NestJS)同时监听两个端口,职责不同:
|
||||
|
||||
软件 API 端口(PORT,默认 3000)—— 给 Windows/Godot 客户端与 Android App
|
||||
只有 /api、/health、/static、/contracts
|
||||
管理端端口(ADMIN_PORT,默认 3001)—— 给浏览器
|
||||
管理页面 + 上面全部接口
|
||||
|
||||
**在 IIS 里建两个站点**,把下面这份 web.config 各放一份,只改 <action> 里的端口:
|
||||
站点 A(如 dance-api.example.com) → http://127.0.0.1:3000/{R:1}
|
||||
站点 B(如 dance-admin.example.com)→ http://127.0.0.1:3001/{R:1}
|
||||
|
||||
后端自带管理端页面与 SPA 回退,所以每个站点只需一条全匹配重写规则,
|
||||
不需要在 IIS 上再配置默认文档或 URL Rewrite 的 SPA 回退。
|
||||
============================================================
|
||||
-->
|
||||
@@ -25,7 +36,12 @@
|
||||
|
||||
<rewrite>
|
||||
<rules>
|
||||
<!-- 全部请求转发给后端:管理端页面 / 前端深链接 / api / health / static / contracts -->
|
||||
<!--
|
||||
全部请求转发给后端。
|
||||
管理端站点把端口改成 3001;软件 API 站点保持 3000。
|
||||
注意:软件 API 端口**不提供管理页面**,用浏览器访问它会得到 404,
|
||||
这是有意设计(职责分离),不是配置错误。
|
||||
-->
|
||||
<rule name="OpenDanceReverseProxy" stopProcessing="true">
|
||||
<match url="(.*)" />
|
||||
<action type="Rewrite" url="http://127.0.0.1:3000/{R:1}" />
|
||||
@@ -70,8 +86,12 @@
|
||||
</configuration>
|
||||
<!--
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 两个最常踩的坑 │
|
||||
│ 三个最常踩的坑 │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 0. 两个端口搞混。浏览器要访问的是**管理端端口 3001**; │
|
||||
│ 客户端调用的是**软件 API 端口 3000**。指错会出现 │
|
||||
│ "管理页面 404" 或 "接口全 404"。 │
|
||||
│ │
|
||||
│ 1. 代理超时是**服务器级**配置,不在 web.config 里。 │
|
||||
│ ARR 默认 120 秒,上传大模型会中途断开。改 │
|
||||
│ C:\Windows\System32\inetsrv\config\applicationHost.config │
|
||||
|
||||
+6
-6
@@ -32,7 +32,7 @@ tasks:
|
||||
status: done
|
||||
- id: T-WEB-004
|
||||
file: tasks/004-motion-upload.md
|
||||
scope: 动作数据上传 + 来源字段
|
||||
scope: 动作数据上传(BVH / VMD / FBX / glTF / keypoints)+ 来源字段
|
||||
status: done
|
||||
- id: T-WEB-005
|
||||
file: tasks/005-model-upload.md
|
||||
@@ -40,7 +40,7 @@ tasks:
|
||||
status: done
|
||||
- id: T-WEB-006
|
||||
file: tasks/006-keypoint-preprocess.md
|
||||
scope: BVH/FBX → 关键点序列预处理
|
||||
scope: BVH / VMD / FBX → 关键点序列预处理
|
||||
status: done
|
||||
- id: T-WEB-007
|
||||
file: tasks/007-upload-review.md
|
||||
@@ -56,16 +56,16 @@ tasks:
|
||||
status: done
|
||||
- id: T-WEB-010
|
||||
file: tasks/010-deploy.md
|
||||
scope: 部署脚本(不部署 MySQL)
|
||||
scope: 部署脚本(双端口:软件 API 3000 + 管理端 3001,不部署 MySQL)
|
||||
status: done
|
||||
|
||||
deliverable:
|
||||
artifact: opendance-web-v0.1.0.tar.gz
|
||||
packaging: Linux 业务服务(系统服务托管,不含 MySQL、不含反向代理、不使用 Docker)
|
||||
packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker)
|
||||
includes:
|
||||
- web-server/ # NestJS 源码 + 构建产物
|
||||
- web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析)
|
||||
- admin/ # React 管理端构建产物
|
||||
- deploy/ # install.sh / systemd / openrc / initd / ctl.sh / .env.example
|
||||
- deploy/ # install.sh / systemd / openrc / initd / ctl.sh / .env.example(PORT、ADMIN_PORT)
|
||||
- deploy/proxy/ # 反向代理说明(nginx / IIS,仅供参考不部署)
|
||||
- contracts/ # 当前版本契约快照
|
||||
- THIRD_PARTY.md # 开源声明
|
||||
|
||||
@@ -113,6 +113,10 @@ for required in \
|
||||
"opendance-web-v${VERSION}/deploy/install.sh" \
|
||||
"opendance-web-v${VERSION}/deploy/ctl.sh" \
|
||||
"opendance-web-v${VERSION}/deploy/check-access.sh" \
|
||||
"opendance-web-v${VERSION}/web-server/dist/preprocess/vmd-parser.js" \
|
||||
"opendance-web-v${VERSION}/web-server/dist/preprocess/mmd-pose.js" \
|
||||
"opendance-web-v${VERSION}/web-server/dist/preprocess/mmd-skeleton.js" \
|
||||
"opendance-web-v${VERSION}/web-server/scripts/inspect-vmd.mjs" \
|
||||
"opendance-web-v${VERSION}/deploy/proxy/README.md" \
|
||||
"opendance-web-v${VERSION}/deploy/proxy/nginx.conf.example" \
|
||||
"opendance-web-v${VERSION}/deploy/proxy/web.config.example" \
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
## 目标
|
||||
|
||||
上传动作文件(BVH / FBX / glTF / GLB / keypoints-json),**强制填写来源五元组**。
|
||||
上传动作文件(BVH / VMD / FBX / glTF / GLB / keypoints-json),**强制填写来源五元组**。
|
||||
|
||||
## load
|
||||
|
||||
@@ -16,6 +16,10 @@ contracts/data-models.md # 锚点:§3 Motion
|
||||
contracts/model-formats.md # 锚点:§2 动作格式、§3 上传限制
|
||||
web-server/src/motions/*
|
||||
web-server/src/storage/storage.service.ts
|
||||
web-server/src/preprocess/vmd-parser.ts # VMD 上传后的预处理链路
|
||||
web-server/src/preprocess/mmd-skeleton.ts
|
||||
web-server/src/preprocess/mmd-pose.ts
|
||||
web-server/src/preprocess/retarget.ts # worldFramesToKeypointFrames()
|
||||
```
|
||||
|
||||
## 端点
|
||||
@@ -41,11 +45,21 @@ ${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}
|
||||
|
||||
## 校验
|
||||
|
||||
- 扩展名白名单:`.bvh .fbx .gltf .glb .json`,其他 → 400
|
||||
- 扩展名白名单:`.bvh .vmd .fbx .gltf .glb .json`,其他 → 400
|
||||
- 来源五元组缺任一项 → 400
|
||||
- `songId` 指向不存在的曲目 → 400
|
||||
- 单文件上限 `UPLOAD_MAX_BYTES`(默认 512 MiB)
|
||||
|
||||
## VMD 支持(`.vmd`)
|
||||
|
||||
- 管理端上传控件已支持 `.vmd`(accept、格式筛选、中文标签「VMD(MMD)」)
|
||||
- 入库后 `format=vmd`;预处理走内置链路,不依赖 Assimp:
|
||||
`vmd-parser.ts`(二进制 + Shift-JIS + 贝塞尔插值)→ `mmd-skeleton.ts`(标准骨架)
|
||||
→ `mmd-pose.ts`(FK + 腿部 IK + Z 镜像)→ `retarget.ts` 的
|
||||
`worldFramesToKeypointFrames()`(与 BVH 共用骨盆归零 / 自适应缩放 / 投影 / 关节角)
|
||||
- 固定 30 fps;腿部按 `足IK` 是否带位移自动选 IK 或 FK
|
||||
- 上传后处理失败与 BVH 一致:写入 `preprocessState=failed` 与可读错误
|
||||
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
@@ -54,3 +68,16 @@ npm run test:smoke
|
||||
|
||||
冒烟 [2] 段落:上传 201、自动识别 `format=bvh`、`fileSize` 记录、缺来源 400、
|
||||
`evil.exe` 400。
|
||||
|
||||
VMD 实测(用 `../示例文件/` 下的两个样本):
|
||||
|
||||
| 样本 | 帧数 | 腿部驱动 | 结果 |
|
||||
|---|---|---|---|
|
||||
| `【动作】霜雪千年.vmd`(0.79 MiB) | 2901 | `足IK` 驱动(IK) | 0 NaN / 0 越界,预处理约 0.4s |
|
||||
| `恋爱循环.vmd`(20.24 MiB) | 4604 | 烘焙 FK | 0 NaN / 0 越界,预处理约 2.5s |
|
||||
|
||||
上传前可先用诊断工具自查(只读):
|
||||
|
||||
```bash
|
||||
node scripts/inspect-vmd.mjs 动作.vmd [--frames N] [--json out.json]
|
||||
```
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# T-WEB-006 BVH/FBX → 关键点序列预处理
|
||||
# T-WEB-006 BVH/VMD/FBX → 关键点序列预处理
|
||||
|
||||
- **状态**:done
|
||||
- **依赖**:T-WEB-004
|
||||
|
||||
## 目标
|
||||
|
||||
把上传的 3D 动作数据转换为与 Android 端输出**同构**的 MediaPipe 33 点关键点序列,
|
||||
使 Windows 端 DTW 引擎只有一条输入通路。
|
||||
把上传的 3D 动作数据(BVH / VMD / FBX / glTF / keypoints-json)转换为与 Android
|
||||
端输出**同构**的 MediaPipe 33 点关键点序列,使 Windows 端 DTW 引擎只有一条输入通路。
|
||||
|
||||
## load
|
||||
|
||||
```
|
||||
DSH.md
|
||||
contracts/model-formats.md # 锚点:§2 动作格式、§2.1 预处理产物、§2.2 关节名映射
|
||||
contracts/model-formats.md # 锚点:§2 动作格式、§2.1 预处理产物、§2.2 关节名映射、§2.3 VMD
|
||||
contracts/skeleton-topology.md # 锚点:§1 33 点、§3 父节点表、§5 关节角
|
||||
web-server/src/preprocess/*
|
||||
```
|
||||
@@ -29,9 +29,12 @@ web-server/src/preprocess/*
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `bvh-parser.ts` | 纯 TS BVH 词法/语法解析 + 逐帧前向运动学(FK) |
|
||||
| `vmd-parser.ts` | VMD 二进制解析:30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼关键帧 + morph 段;MMD 贝塞尔插值,固定 30fps |
|
||||
| `mmd-skeleton.ts` | 标准 MMD 骨架(含全角/半角 `IK`/`IK` 别名归一);静止比例从参考 PMX 实测 |
|
||||
| `mmd-pose.ts` | MMD FK + 两骨骼解析腿部 IK + 左手系→右手系 Z 镜像,输出逐帧世界坐标 |
|
||||
| `math3d.ts` | 向量/矩阵工具、旋转合成、关节角余弦 |
|
||||
| `joint-map.ts` | BVH 关节名 → MediaPipe 点索引的默认映射表 |
|
||||
| `retarget.ts` | 单帧解算 35 项(33 点 + neck/pelvis 虚拟关节)→ 骨盆归零 → 自适应缩放 → 投影 |
|
||||
| `joint-map.ts` | 关节名 → MediaPipe 点索引的默认映射表 |
|
||||
| `retarget.ts` | 导出的 `worldFramesToKeypointFrames()`:任意骨架世界坐标 → 骨盆归零 → 自适应缩放 → 投影 → 9 个关节角;BVH 与 VMD 共用 |
|
||||
| `keypoint-codec.ts` | 239 字节二进制帧编解码、NDJSON 序列化/解析、关键点 JSON 校验 |
|
||||
| `assimp.ts` | 可选外部 Assimp 调用(FBX/glTF → BVH) |
|
||||
| `preprocess.service.ts` | 编排、落盘、状态与错误记录 |
|
||||
@@ -49,13 +52,35 @@ web-server/src/preprocess/*
|
||||
| 格式 | 处理 |
|
||||
|---|---|
|
||||
| `.bvh` | 内置解析器,无外部依赖 |
|
||||
| `.vmd` | 内置解析器,无外部依赖;固定 30fps,见「VMD 通路」 |
|
||||
| `.json` | 直通校验与规范化(须符合 `mediapipe33@0.1.0`) |
|
||||
| `.fbx` `.gltf` `.glb` | 调用可选 Assimp 转 BVH;未安装 Assimp 时写入可读错误并置 `preprocessState=failed` |
|
||||
|
||||
## VMD 通路
|
||||
|
||||
```
|
||||
.vmd ─▶ vmd-parser.ts ─▶ mmd-skeleton.ts ─▶ mmd-pose.ts ─▶ retarget.ts
|
||||
二进制 + Shift-JIS 标准 MMD 骨架 FK + 腿部 IK worldFramesToKeypointFrames()
|
||||
+ 贝塞尔插值 + Z 镜像 (与 BVH 共用)
|
||||
```
|
||||
|
||||
- VMD 不含骨骼长度、只有旋转(少数骨骼带位移),必须靠 `mmd-skeleton.ts` 的
|
||||
标准骨架做 FK;骨骼名按别名表归一(全角 `IK` / 半角 `IK`),未纳入标准骨架的
|
||||
骨骼(裙摆 / 物理 / 表情等)被忽略但计数。
|
||||
- **腿部驱动自动判定**:`足IK` 轨道存在非零位移 → 两骨骼解析 IK(膝盖朝角色
|
||||
正面弯,目标不可达时收敛为伸直,不产生 NaN);否则走 FK 旋转(部分动作数据把
|
||||
腿烘焙成 FK)。
|
||||
- **手性**:MMD 为左手系,统一做 Z 镜像(旋转 `(x,y,z,w) → (-x,-y,z,w)`、位移 z
|
||||
取反)后进入右手系通路。由真实数据对照实验确定:腿部烘焙为 FK 的样本中,
|
||||
`zMirrored` 下「膝盖朝前」占 94.1%,不转换仅 5.9%(见 `test/mmd-vmd.spec.ts`)。
|
||||
- 重定向**完全复用 BVH 通路**:`worldFramesToKeypointFrames()` 负责骨盆归零 /
|
||||
自适应缩放 / 图像空间投影 / 9 个关节角,因此两种来源的产物结构一致。
|
||||
- 固定 **30 fps**,上传时填写的 `fps` 被忽略。
|
||||
|
||||
## 验收
|
||||
|
||||
```bash
|
||||
npm run test # 25 例:解析 / FK / 重定向 / 编解码 / 拓扑 / 契约版本
|
||||
npm run test # 85 例(含 31 例 VMD):解析 / FK / 重定向 / 编解码 / 拓扑 / 契约版本
|
||||
npm run test:smoke # 冒烟 [3] 段落
|
||||
```
|
||||
|
||||
@@ -63,5 +88,11 @@ npm run test:smoke # 冒烟 [3] 段落
|
||||
MediaPipe 归一化范围、置信度分层、时间戳按帧率推进、序列随时间变化、
|
||||
NDJSON 往返、解码器拒绝错误长度与 magic。
|
||||
|
||||
VMD 侧另测:二进制解析与截断报错、贝塞尔/四元数插值、标准骨架比例与镜像、
|
||||
FK 与腿部 IK(可达/不可达/退化)、手性对照、合成 VMD 端到端 0 NaN / 0 越界。
|
||||
|
||||
两个真实样本实测(`../示例文件/`):`【动作】霜雪千年.vmd` 2901 帧(腿部 IK)
|
||||
约 0.4s、`恋爱循环.vmd` 4604 帧(烘焙 FK)约 2.5s,均 0 NaN / 0 越界。
|
||||
|
||||
样例数据由 `test/fixtures/generate-sample-bvh.mjs` 生成(60 帧挥手 + 深蹲,
|
||||
21 关节 / 63 通道),可复现。
|
||||
21 关节 / 63 通道),可复现;VMD 诊断可用 `scripts/inspect-vmd.mjs`。
|
||||
|
||||
+47
-24
@@ -18,6 +18,11 @@ Web 服务端以**一个 Linux 业务服务**的形式部署运行:Node 进程
|
||||
> 只需要给出 iis 和 nginx 反代的说明文档,不用部署反代服务。"
|
||||
> 于是 install.sh **彻底移除 nginx 的安装与站点配置**,管理端改由 Node 服务自身
|
||||
> 托管(`ADMIN_DIR` + SPA 深链接回退),并新增 `deploy/proxy/` 只交付说明文档。
|
||||
> 3. 再随后用户要求**管理端口与软件 API 端口分离**:新增 `ADMIN_PORT`
|
||||
> (默认 `PORT + 1`)与可选的 `ADMIN_HOST`(默认跟随 `HOST`)。软件 API 端口
|
||||
> 只给客户端调接口(`/api`、`/health`、`/static`、`/contracts`,`/` 等返回 404),
|
||||
> 管理端端口给浏览器(管理页面 + 全部接口)。`ADMIN_PORT === PORT` 保留为
|
||||
> 单端口兼容模式,只 warn 不中止。install.sh 的汇总与反代说明改为两个端口。
|
||||
|
||||
## load
|
||||
|
||||
@@ -36,22 +41,23 @@ deploy/proxy/README.md
|
||||
**职责边界**:本端只提供**业务服务**;反向代理由另外的服务/团队负责。
|
||||
|
||||
```
|
||||
浏览器 ──▶ 反向代理(外部提供,IIS / nginx / 网关…)
|
||||
│ 整体转发,不需要托管静态文件
|
||||
Windows/Godot 客户端、Android App ─▶ 软件 API 端口 :3000(只提供接口,无管理页面)
|
||||
├── /api/ REST API
|
||||
├── /health 健康检查(数据库不可用返回 503)
|
||||
├── /static/ 上传文件
|
||||
└── /contracts/ 契约快照
|
||||
(/、/songs 等返回 404,有意设计)
|
||||
|
||||
浏览器 ─▶ 反向代理(外部提供,IIS / nginx / 网关…)──▶ 管理端端口 :3001
|
||||
│ 整端口转发,不需要托管静态文件 ├── / 管理端页面(含 SPA 深链接回退)
|
||||
│ ├── /assets/ 前端静态资源
|
||||
│ └── 上面全部接口
|
||||
▼
|
||||
Node 业务服务 :3000
|
||||
├── / 管理端页面(本服务自带,含 SPA 深链接回退)
|
||||
├── /assets/ 前端静态资源
|
||||
├── /api/ REST API
|
||||
├── /health 健康检查(数据库不可用返回 503)
|
||||
├── /static/ 上传文件
|
||||
└── /contracts/ 契约快照
|
||||
│
|
||||
▼
|
||||
现有 MySQL / MariaDB 实例
|
||||
现有 MySQL / MariaDB 实例(两个端口共用同一 Node 进程)
|
||||
```
|
||||
|
||||
本服务同时提供 API 与管理端页面(`ADMIN_DIR`),因此反代**只需一条全匹配转发规则**,
|
||||
本服务一个 Node 进程**同时监听两个端口**(软件 API 端口 + 管理端端口),
|
||||
管理页面只在管理端口提供;反代按用途分别转发,每个端口各一条全匹配规则即可,
|
||||
既不用在代理层托管静态文件,也不用配置 SPA 回退(`try_files`)。
|
||||
|
||||
`deploy/install.sh` **不安装、不配置任何反向代理软件**,收尾时只打印反代交接指引。
|
||||
@@ -60,9 +66,9 @@ deploy/proxy/README.md
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `deploy/install.sh` | 一键部署:前置检查 → 建用户/目录 → 复制并校验文件 → 装依赖 → 迁移 → 注册系统服务 → 健康检查 |
|
||||
| `deploy/install.sh` | 一键部署:前置检查 → 建用户/目录 → 复制并校验文件 → 装依赖 → 迁移 → 注册系统服务 → 健康检查;汇总打印两个端口与防火墙放行提示 |
|
||||
| `deploy/uninstall.sh` | 卸载;默认保留上传数据,`--purge` 才删除 |
|
||||
| `deploy/.env.example` | 部署环境变量样例(含 `HOST` 说明与上传上限告警) |
|
||||
| `deploy/.env.example` | 部署环境变量样例(`PORT` / `ADMIN_PORT` / `ADMIN_HOST` / `HOST` 说明与上传上限告警) |
|
||||
| `deploy/systemd/opendance-api.service` | systemd 单元,含 `ProtectSystem=strict` 等安全加固 |
|
||||
| `deploy/openrc/opendance-api` | OpenRC 服务脚本 |
|
||||
| `deploy/initd/opendance-api` | SysVinit / LSB 启动脚本(转调 ctl.sh) |
|
||||
@@ -93,11 +99,21 @@ deploy/proxy/README.md
|
||||
|
||||
## 关键实现
|
||||
|
||||
- **服务自身托管管理端**:`app.useStaticAssets(adminDir, …)` + SPA 回退中间件;
|
||||
回退会跳过 `/api/`、`/health`、`/static/`、`/contracts/` 以及带扩展名的路径,
|
||||
保证未知 API 仍返回 404 而不是 HTML
|
||||
- **`HOST` 绑定地址**:同机反代建议 `127.0.0.1`,异机反代用 `0.0.0.0` + 防火墙;
|
||||
默认值取 `0.0.0.0` 以保证开箱可用
|
||||
- **双端口**:一个 Node 进程创建两个 HTTP server——软件 API 端口(`PORT`,默认 3000)
|
||||
与管理端端口(`ADMIN_PORT`,默认 3001),复用同一个 express 实例;用
|
||||
`req.socket.localPort` 判断请求来自哪个端口,决定是否提供管理页面。
|
||||
`ADMIN_PORT === PORT` 时只建一个 server(单端口兼容模式,避免 EADDRINUSE)
|
||||
- **服务自身托管管理端**:`app.useStaticAssets(adminDir, …)` + SPA 回退中间件
|
||||
(仅管理端口生效);回退会跳过 `/api/`、`/health`、`/static/`、`/contracts/`
|
||||
以及带扩展名的路径,保证未知 API 仍返回 404 而不是 HTML
|
||||
- **软件 API 端口不提供管理页面**:`/`、`/songs` 等在该端口一律 404(有意设计)
|
||||
- **`HOST` / `ADMIN_HOST` 绑定地址**:同机反代建议 `127.0.0.1`,异机反代用
|
||||
`0.0.0.0` + 防火墙;`ADMIN_HOST` 可选(默认跟随 `HOST`),可单独把管理端限制在
|
||||
本机或内网;`HOST` 默认取 `0.0.0.0` 以保证开箱可用
|
||||
- **端口校验**:`PORT` / `ADMIN_PORT` 必须是 1..65535 的整数;非法值在生产环境
|
||||
启动时快速失败(`validateEnv` 的 errors),`ADMIN_PORT === PORT` 只 warn
|
||||
- **install.sh 汇总与反代说明**:结尾分别打印两个端口的访问地址、监听配置与
|
||||
防火墙放行命令(`firewall-cmd` / `ufw` 同时放行两个端口)
|
||||
- **systemd 加固**:`NoNewPrivileges`、`ProtectSystem=strict`、`ProtectHome`、
|
||||
`ReadWritePaths=/var/lib/opendance`,进程只能写自己的存储目录
|
||||
- **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB),
|
||||
@@ -117,6 +133,9 @@ deploy/proxy/README.md
|
||||
cp deploy/.env.example deploy/.env # 填写 DB_*
|
||||
bash deploy/install.sh # 需 root(不需要 sudo)
|
||||
curl -fsS http://localhost:3000/health
|
||||
curl -fsS http://localhost:3001/health
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/ # 期望 404(软件 API 端口无管理页面)
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200(管理页面)
|
||||
```
|
||||
|
||||
| 验收项 | 状态 |
|
||||
@@ -125,8 +144,11 @@ curl -fsS http://localhost:3000/health
|
||||
| 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200,`database.connected=true`) |
|
||||
| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证 |
|
||||
| `curl http://<host>:3000/health` 通过 | ✅ 直连后端端口已验证 |
|
||||
| 管理端页面可访问 | ✅ `/`、`/songs`、`/models` 均返回同一份 HTML(200) |
|
||||
| 软件 API 端口 `/` 返回 404 | ✅ `PORT=3000` 上 `/`、`/songs` 均 404(有意设计,无管理页面) |
|
||||
| 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) |
|
||||
| 前端深链接回退 | ✅ 未知静态路径回退到 `index.html`,未知 API 仍 404 |
|
||||
| `ADMIN_PORT === PORT` 单端口兼容模式 | ✅ 只建一个 server,管理页面回到 API 端口,日志提示「未分端口」 |
|
||||
| 端口非法在生产环境中止启动 | ✅ `validateEnv` 对非 1..65535 整数端口报 error |
|
||||
| 不部署、不管理 MySQL | ✅ 部署脚本不含任何数据库安装/建库操作 |
|
||||
| 不使用 Docker | ✅ 仓库内已无 Dockerfile / docker-compose / Docker 引用 |
|
||||
| 不部署反向代理 | ✅ 仅交付 `deploy/proxy/` 文档,install.sh 中无任何反代安装动作 |
|
||||
@@ -134,13 +156,14 @@ curl -fsS http://localhost:3000/health
|
||||
> **环境限制说明**:本次执行环境**无 root 权限**,且**未安装 nginx**(按需求也不应安装)。
|
||||
> 因此 `deploy/install.sh` 的服务注册链路(systemd / OpenRC / SysVinit / crontab)
|
||||
> 未能在本机实机跑通。已完成的等价验证:
|
||||
> - 后端以 `node dist/main.js` 直连真实数据库启动,`/health` 200
|
||||
> - 管理端托管与 SPA 回退逐路径实测(`/`、`/songs`、`/models`、`/api/*`、未知路径)
|
||||
> - 后端以 `node dist/main.js` 直连真实数据库启动,两个端口 `/health` 均 200
|
||||
> - 管理端托管与 SPA 回退逐路径实测(`/`、`/songs`、`/models`、`/api/*`、未知路径),
|
||||
> 并确认 3000 上返回 404、3001 上返回 200
|
||||
> - `ADMIN_DIR` 环境变量生效,`index.html` 缺失时打印告警而非崩溃
|
||||
> - `npm run migration:run` 走编译产物成功
|
||||
> - 端到端冒烟 57 项全通过
|
||||
> - `install.sh` / `uninstall.sh` / `ctl.sh` 通过 `bash -n` 语法校验
|
||||
> - 反代文档中的占位符与后端实际行为逐条对照(端口、路径、上传上限、健康检查)
|
||||
> - 反代文档中的占位符与后端实际行为逐条对照(两个端口、路径、上传上限、健康检查)
|
||||
>
|
||||
> 需在一台有 root 的 Linux 服务器上补做一次 `install.sh` 实机验收。
|
||||
|
||||
|
||||
+12
-3
@@ -5,13 +5,22 @@
|
||||
|
||||
# ---- 服务 ----
|
||||
NODE_ENV=production
|
||||
# 软件 API 端口:Windows/Godot 客户端、Android App 调用,
|
||||
# 只提供 /api、/health、/static、/contracts,不提供管理端页面
|
||||
PORT=3000
|
||||
# 监听地址。本地开发用 0.0.0.0(便于同网段设备联调);
|
||||
# 管理端端口:浏览器访问,提供管理端 SPA 页面 + 上面全部接口。
|
||||
# 默认取 PORT + 1;与 PORT 设为相同值即为单端口兼容模式(管理页面与接口同端口)。
|
||||
ADMIN_PORT=3001
|
||||
# 监听地址(软件 API 端口)。本地开发用 0.0.0.0(便于同网段设备联调);
|
||||
# 生产环境若反代与后端同机,建议改为 127.0.0.1
|
||||
HOST=0.0.0.0
|
||||
# 管理端监听地址,默认与 HOST 相同。
|
||||
# 需要"接口对外、管理端只对本机开放"时可单独设置,例如 ADMIN_HOST=127.0.0.1
|
||||
# ADMIN_HOST=0.0.0.0
|
||||
|
||||
# 管理端静态站点目录(默认取 ../admin)。本服务自带管理端页面,
|
||||
# 反向代理只需把请求整体转发到本端口,见 deploy/proxy/README.md
|
||||
# 管理端静态站点目录(默认取 ../admin)。管理端页面只在 ADMIN_PORT 上提供,
|
||||
# 反向代理把浏览器请求转发到 ADMIN_PORT、客户端接口请求转发到 PORT,
|
||||
# 见 deploy/proxy/README.md
|
||||
ADMIN_DIR=../admin
|
||||
|
||||
# ---- 现有 MySQL 实例(外部提供,本项目不部署 MySQL)----
|
||||
|
||||
@@ -18,7 +18,7 @@ export type SourceType = (typeof SOURCE_TYPES)[number];
|
||||
export const PUBLISH_STATUSES = ['draft', 'pending', 'published', 'rejected'] as const;
|
||||
export type PublishStatus = (typeof PUBLISH_STATUSES)[number];
|
||||
|
||||
export const MOTION_FORMATS = ['bvh', 'fbx', 'gltf', 'glb', 'keypoints-json'] as const;
|
||||
export const MOTION_FORMATS = ['bvh', 'vmd', 'fbx', 'gltf', 'glb', 'keypoints-json'] as const;
|
||||
export type MotionFormat = (typeof MOTION_FORMATS)[number];
|
||||
|
||||
export const MODEL_FORMATS = ['vrm', 'glb', 'gltf', 'fbx'] as const;
|
||||
|
||||
@@ -52,6 +52,7 @@ export const PREPROCESS_LABELS: Record<PreprocessState, string> = {
|
||||
|
||||
export const MOTION_FORMAT_LABELS: Record<MotionFormat, string> = {
|
||||
bvh: 'BVH',
|
||||
vmd: 'VMD(MMD)',
|
||||
fbx: 'FBX',
|
||||
gltf: 'glTF',
|
||||
glb: 'GLB',
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from '../components/ui';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const ACCEPT_EXT = '.bvh,.fbx,.gltf,.glb,.json';
|
||||
const ACCEPT_EXT = '.bvh,.vmd,.fbx,.gltf,.glb,.json';
|
||||
|
||||
interface MotionFilters {
|
||||
q: string;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* VMD 动作文件诊断工具。
|
||||
*
|
||||
* 用途:拿到一个 .vmd 后,先看服务端会怎么解析它——骨骼名认不认、腿部是 IK 还是 FK、
|
||||
* 重定向成 MediaPipe 33 点后是否正常。上传前先跑一遍可以避免"传上去才发现预处理失败"。
|
||||
*
|
||||
* 用法(在 web-server 目录下,先 npm run build):
|
||||
* node scripts/inspect-vmd.mjs 动作.vmd
|
||||
* node scripts/inspect-vmd.mjs 动作.vmd --frames 300 # 只采样前 300 帧
|
||||
* node scripts/inspect-vmd.mjs 动作.vmd --json out.json # 导出关键点序列
|
||||
*
|
||||
* 只读:不写数据库、不访问网络。
|
||||
*/
|
||||
import { createRequire } from 'node:module';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const file = args.find((a) => !a.startsWith('--'));
|
||||
if (!file) {
|
||||
console.error('用法:node scripts/inspect-vmd.mjs <文件.vmd> [--frames N] [--json out.json]');
|
||||
process.exit(2);
|
||||
}
|
||||
const readFlag = (name, fallback) => {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
||||
};
|
||||
const maxFrames = Number(readFlag('--frames', 0)) || undefined;
|
||||
const jsonOut = readFlag('--json', null);
|
||||
|
||||
const distDir = join(process.cwd(), 'dist', 'preprocess');
|
||||
if (!existsSync(distDir)) {
|
||||
console.error('未找到 dist/preprocess,请先执行:npm run build');
|
||||
process.exit(2);
|
||||
}
|
||||
const { parseVmd, trackHasPositionMotion, trackHasRotationMotion, VMD_FPS } = require(
|
||||
join(distDir, 'vmd-parser.js'),
|
||||
);
|
||||
const { vmdToWorldFrames, legIkDriven } = require(join(distDir, 'mmd-pose.js'));
|
||||
const { worldFramesToKeypointFrames } = require(join(distDir, 'retarget.js'));
|
||||
const { LEG_LENGTHS, REST_HEIGHTS, MMD_SKELETON } = require(join(distDir, 'mmd-skeleton.js'));
|
||||
const { ANGLE_TRIPLETS } = require(join(distDir, 'mediapipe33.js'));
|
||||
|
||||
const path = resolve(file);
|
||||
const buffer = readFileSync(path);
|
||||
const started = Date.now();
|
||||
const vmd = parseVmd(buffer);
|
||||
const tParse = Date.now() - started;
|
||||
|
||||
console.log(`文件:${basename(path)}(${(buffer.length / 1024 / 1024).toFixed(2)} MiB)`);
|
||||
console.log(`头部:${vmd.header}`);
|
||||
console.log(`模型名:${vmd.modelName || '(空)'}`);
|
||||
console.log(`帧数:${vmd.maxFrame + 1}(${((vmd.maxFrame + 1) / VMD_FPS).toFixed(1)} 秒 @ ${VMD_FPS}fps)`);
|
||||
console.log(`骨骼轨道:${vmd.tracks.size} 条 / 标准骨架共 ${MMD_SKELETON.length} 根`);
|
||||
console.log(`morph 关键帧:${vmd.morphCount}`);
|
||||
console.log(`解析耗时:${tParse} ms`);
|
||||
|
||||
const missing = MMD_SKELETON.filter((b) => !vmd.tracks.has(b.name)).map((b) => b.name);
|
||||
console.log(
|
||||
`\n未出现在文件中的标准骨骼(按静止姿态处理):${missing.length === 0 ? '(无,全部有键)' : missing.join('、')}`,
|
||||
);
|
||||
if (vmd.unknownBones.length > 0) {
|
||||
const preview = vmd.unknownBones.slice(0, 12).join('、');
|
||||
console.log(
|
||||
`未纳入标准骨架的骨骼:${vmd.unknownBones.length} 种(裙摆/物理/表情等,会被忽略)` +
|
||||
`\n 例如:${preview}${vmd.unknownBones.length > 12 ? ' …' : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
const ik = legIkDriven(vmd);
|
||||
console.log(
|
||||
`\n腿部驱动:左=${ik.左 ? 'IK(足IK 有位移)' : 'FK(烘焙旋转)'} 右=${ik.右 ? 'IK' : 'FK'}`,
|
||||
);
|
||||
const fingerBones = vmd.unknownBones.filter((n) => n.includes('親指') || n.includes('小指'));
|
||||
console.log(
|
||||
`手指骨骼:${fingerBones.length > 0 ? `文件里有(${fingerBones.length} 种,当前不使用)` : '无'}`,
|
||||
);
|
||||
console.log(
|
||||
`IK 段落:${trackHasRotationMotion(vmd.tracks.get('左足IK')) ? '足IK 带旋转' : '足IK 仅位移'}`,
|
||||
);
|
||||
|
||||
const t0 = Date.now();
|
||||
const { frames: world, fps } = vmdToWorldFrames(vmd, { fps: VMD_FPS, maxFrames });
|
||||
const tFk = Date.now() - t0;
|
||||
const t1 = Date.now();
|
||||
const frames = worldFramesToKeypointFrames(world, fps);
|
||||
const tRt = Date.now() - t1;
|
||||
|
||||
let nan = 0;
|
||||
let outOfRange = 0;
|
||||
const knee = [];
|
||||
const elbow = [];
|
||||
let shoulderGap = 0;
|
||||
for (const frame of frames) {
|
||||
for (const p of frame.points) {
|
||||
if (!p.every(Number.isFinite)) nan += 1;
|
||||
if (p[0] < 0 || p[0] > 1 || p[1] < 0 || p[1] > 1 || p[2] < -1 || p[2] > 1) outOfRange += 1;
|
||||
}
|
||||
knee.push(frame.angles[6], frame.angles[7]);
|
||||
elbow.push(frame.angles[2], frame.angles[3]);
|
||||
shoulderGap += frame.points[11][0] - frame.points[12][0];
|
||||
}
|
||||
const stats = (values) => {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const q = (p) => sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p))];
|
||||
return `min=${q(0).toFixed(3)} 中位=${q(0.5).toFixed(3)} max=${q(1).toFixed(3)}`;
|
||||
};
|
||||
|
||||
console.log(`\n关键点序列:${frames.length} 帧`);
|
||||
console.log(` 耗时:FK ${tFk} ms + 重定向 ${tRt} ms`);
|
||||
console.log(` 异常:NaN ${nan} 处 / 越界 ${outOfRange} 处`);
|
||||
console.log(` 膝角余弦(-1=伸直):${stats(knee)}`);
|
||||
console.log(` 肘角余弦:${stats(elbow)}`);
|
||||
console.log(
|
||||
` 左肩x-右肩x 均值 ${(shoulderGap / frames.length).toFixed(3)}(>0 表示左肩在画面右侧,自拍视角)`,
|
||||
);
|
||||
|
||||
const rig = {
|
||||
足首: REST_HEIGHTS.ankle,
|
||||
膝: REST_HEIGHTS.knee,
|
||||
髋: REST_HEIGHTS.hip,
|
||||
首: REST_HEIGHTS.neck,
|
||||
頭: REST_HEIGHTS.head,
|
||||
大腿: LEG_LENGTHS.thigh,
|
||||
小腿: LEG_LENGTHS.shin,
|
||||
};
|
||||
console.log(`\n标准骨架比例(静止,MMD 单位):`);
|
||||
console.log(
|
||||
` 足首 ${rig.足首.toFixed(2)} / 膝 ${rig.膝.toFixed(2)} / 髋 ${rig.髋.toFixed(2)} / ` +
|
||||
`首 ${rig.首.toFixed(2)} / 頭 ${rig.頭.toFixed(2)}`,
|
||||
);
|
||||
console.log(` 大腿 ${rig.大腿.toFixed(2)} + 小腿 ${rig.小腿.toFixed(2)}(腿长 ${(rig.大腿 + rig.小腿).toFixed(2)})`);
|
||||
console.log(` 关节角数量:${ANGLE_TRIPLETS.length}`);
|
||||
|
||||
if (jsonOut) {
|
||||
writeFileSync(jsonOut, JSON.stringify({ fps, frames }, null, 0));
|
||||
console.log(`\n已导出关键点序列:${jsonOut}`);
|
||||
}
|
||||
console.log('\n结论:解析与重定向均正常,可上传(.vmd → format=vmd)。');
|
||||
@@ -27,6 +27,8 @@ export enum PublishStatus {
|
||||
|
||||
export enum MotionFormat {
|
||||
Bvh = 'bvh',
|
||||
/** MikuMikuDance 动作数据(二进制,含 Shift-JIS 骨骼名与四元数关键帧) */
|
||||
Vmd = 'vmd',
|
||||
Fbx = 'fbx',
|
||||
Gltf = 'gltf',
|
||||
Glb = 'glb',
|
||||
@@ -78,6 +80,7 @@ export const TARGET_TYPE_VALUES = Object.values(TargetType) as string[];
|
||||
/** 允许上传的扩展名 → 动作格式 */
|
||||
export const MOTION_EXTENSION_MAP: Record<string, MotionFormat> = {
|
||||
'.bvh': MotionFormat.Bvh,
|
||||
'.vmd': MotionFormat.Vmd,
|
||||
'.fbx': MotionFormat.Fbx,
|
||||
'.gltf': MotionFormat.Gltf,
|
||||
'.glb': MotionFormat.Glb,
|
||||
|
||||
@@ -10,6 +10,35 @@ export interface AccessHint {
|
||||
loopbackOnly: boolean;
|
||||
}
|
||||
|
||||
/** 软件 API 端口对外提供的接口路径范围,用于启动日志。 */
|
||||
const API_SCOPE = '/api、/health、/static、/contracts';
|
||||
|
||||
/** 双端口模式(软件 API 端口 + 管理端端口)的访问提示。 */
|
||||
export interface DualAccessHint {
|
||||
/** 软件 API 端口(Windows/Godot 客户端、Android App 调用)。 */
|
||||
api: AccessHint;
|
||||
/** 管理端端口(浏览器访问)。 */
|
||||
admin: AccessHint;
|
||||
/** 是否为单端口兼容模式(ADMIN_PORT === PORT)。 */
|
||||
sharedPort: boolean;
|
||||
/** 是否只监听回环(两个端口都只在本机可访问时才为 true)。 */
|
||||
loopbackOnly: boolean;
|
||||
/** 启动日志第一行:软件 API 端口。 */
|
||||
apiLine: string;
|
||||
/** 启动日志第二行:管理端端口。 */
|
||||
adminLine: string;
|
||||
/** 单端口兼容模式下的提示文案;未分端口时为空串。 */
|
||||
sharedNotice: string;
|
||||
}
|
||||
|
||||
/** describeDualAccess 的入参。 */
|
||||
export interface DualAccessOptions {
|
||||
apiHost: string;
|
||||
apiPort: number;
|
||||
adminHost: string;
|
||||
adminPort: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把绑定地址翻译成"人能看懂、能直接用"的访问地址。
|
||||
*
|
||||
@@ -37,3 +66,41 @@ export function describeAccess(bindHost: string, port: number): AccessHint {
|
||||
}
|
||||
return { listen: `0.0.0.0:${port}(全部网卡,外部可访问)`, urls, loopbackOnly: false };
|
||||
}
|
||||
|
||||
/** 取一个"外部设备也能访问"的地址优先展示;没有真实网卡时回落到回环地址。 */
|
||||
export function preferredUrl(hint: AccessHint): string {
|
||||
const external = hint.urls.find((url) => !/127\.0\.0\.1|localhost|\[::1\]/.test(url));
|
||||
return external ?? hint.urls[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 描述"软件 API 端口 + 管理端端口"两个端口,供启动日志直接打印。
|
||||
*
|
||||
* 客户端(Windows/Godot、Android)与管理端(浏览器)的职责不同,
|
||||
* 必须分别告诉使用者该访问哪个地址、该地址提供什么,避免把管理页面
|
||||
* 挂到客户端调用的端口上。
|
||||
*/
|
||||
export function describeDualAccess(options: DualAccessOptions): DualAccessHint {
|
||||
const api = describeAccess(options.apiHost, options.apiPort);
|
||||
const admin = describeAccess(options.adminHost, options.adminPort);
|
||||
const sharedPort = options.adminPort === options.apiPort;
|
||||
|
||||
const apiLine = `软件 API 端口:${preferredUrl(api)}(${API_SCOPE})`;
|
||||
const adminLine = sharedPort
|
||||
? `管理端端口: ${preferredUrl(admin)}(未分端口,与软件 API 同一端口;` +
|
||||
'浏览器访问,含管理页面与全部接口)'
|
||||
: `管理端端口: ${preferredUrl(admin)}(浏览器访问,含管理页面与全部接口)`;
|
||||
|
||||
return {
|
||||
api,
|
||||
admin,
|
||||
sharedPort,
|
||||
loopbackOnly: api.loopbackOnly || admin.loopbackOnly,
|
||||
apiLine,
|
||||
adminLine,
|
||||
sharedNotice: sharedPort
|
||||
? `ADMIN_PORT 与 PORT 相同(${options.apiPort}),未分端口:` +
|
||||
'管理页面与软件 API 共用同一端口,行为与旧版一致'
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,16 @@ export class AppConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听地址。
|
||||
* 管理端端口(浏览器访问)。
|
||||
* 未设置 ADMIN_PORT 时默认取 PORT + 1,与管理端端口分开:
|
||||
* 软件 API 端口只提供接口,管理端口同时提供管理端页面与全部接口。
|
||||
*/
|
||||
get adminPort(): number {
|
||||
return this.config.get<number>('adminPort') ?? this.port + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听地址(软件 API 端口)。
|
||||
* 反向代理与后端同机时建议设为 127.0.0.1(仅本机可访问);
|
||||
* 代理在其他机器上时必须为 0.0.0.0,并用防火墙限制来源。
|
||||
*/
|
||||
@@ -42,6 +51,14 @@ export class AppConfig {
|
||||
return this.config.get<string>('bindHost') ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端监听地址,默认与 HOST 一致。
|
||||
* 需要"接口对外、管理端只对本机"时,可单独把 ADMIN_HOST 设为 127.0.0.1。
|
||||
*/
|
||||
get adminHost(): string {
|
||||
return this.config.get<string>('adminHost') ?? this.bindHost;
|
||||
}
|
||||
|
||||
get storageDir(): string {
|
||||
return this.config.get<string>('storageDir') ?? resolve(process.cwd(), 'storage');
|
||||
}
|
||||
@@ -89,40 +106,114 @@ function bool(value: string | undefined, fallback = false): boolean {
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析端口环境变量。
|
||||
* 未设置或空白时使用 fallback;非法值(NaN、越界、非整数)原样返回,
|
||||
* 交由 validateEnv 统一报问题,避免在这里静默纠正错误配置。
|
||||
*/
|
||||
export function parsePort(raw: string | undefined, fallback: number): number {
|
||||
if (raw === undefined || raw.trim() === '') return fallback;
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端端口:优先 ADMIN_PORT,未设置时回落到 PORT + 1。
|
||||
* 允许 ADMIN_PORT === PORT(单端口兼容模式,行为与旧版一致)。
|
||||
*/
|
||||
export function resolveAdminPort(port: number, rawAdminPort?: string): number {
|
||||
if (rawAdminPort === undefined || rawAdminPort.trim() === '') return port + 1;
|
||||
return Number(rawAdminPort);
|
||||
}
|
||||
|
||||
/** 是否为单端口兼容模式(管理端与软件 API 共用同一端口)。 */
|
||||
export function isSamePort(port: number, adminPort: number): boolean {
|
||||
return Number.isInteger(port) && port === adminPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按请求进入的端口判断是否为管理端请求。
|
||||
*
|
||||
* 软件 API 端口不提供管理端页面:根路径 `/` 与任意前端路径都必须 404;
|
||||
* 单端口兼容模式(ADMIN_PORT === PORT)下所有请求都按管理端处理。
|
||||
* localPort 缺失时按软件 API 端口处理(宁可少给页面,不可把管理页挂到接口端口)。
|
||||
*/
|
||||
export function isAdminPortRequest(
|
||||
localPort: number | undefined,
|
||||
port: number,
|
||||
adminPort: number,
|
||||
): boolean {
|
||||
if (isSamePort(port, adminPort)) return true;
|
||||
return localPort === adminPort;
|
||||
}
|
||||
|
||||
/** @nestjs/config 的 load 工厂 */
|
||||
export const configuration = () => ({
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
bindHost: process.env.HOST ?? '0.0.0.0',
|
||||
storageDir: process.env.STORAGE_DIR ?? resolve(process.cwd(), 'storage'),
|
||||
adminDir: process.env.ADMIN_DIR ?? resolve(process.cwd(), '..', 'admin'),
|
||||
uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024),
|
||||
assimpBin: process.env.ASSIMP_BIN ?? 'assimp',
|
||||
contractsDir: process.env.CONTRACTS_DIR ?? '',
|
||||
db: {
|
||||
host: process.env.DB_HOST ?? '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT ?? 3306),
|
||||
user: process.env.DB_USER ?? 'opendance_app',
|
||||
pass: process.env.DB_PASS ?? '',
|
||||
name: process.env.DB_NAME ?? 'opendance',
|
||||
synchronize: bool(process.env.DB_SYNCHRONIZE, false),
|
||||
logging: bool(process.env.DB_LOGGING, false),
|
||||
},
|
||||
});
|
||||
export const configuration = () => {
|
||||
const port = parsePort(process.env.PORT, 3000);
|
||||
return {
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
port,
|
||||
bindHost: process.env.HOST ?? '0.0.0.0',
|
||||
adminPort: resolveAdminPort(port, process.env.ADMIN_PORT),
|
||||
// 管理端监听地址默认与 HOST 一致;未设置 HOST 时同样回落到 0.0.0.0
|
||||
adminHost: process.env.ADMIN_HOST ?? process.env.HOST ?? '0.0.0.0',
|
||||
storageDir: process.env.STORAGE_DIR ?? resolve(process.cwd(), 'storage'),
|
||||
adminDir: process.env.ADMIN_DIR ?? resolve(process.cwd(), '..', 'admin'),
|
||||
uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024),
|
||||
assimpBin: process.env.ASSIMP_BIN ?? 'assimp',
|
||||
contractsDir: process.env.CONTRACTS_DIR ?? '',
|
||||
db: {
|
||||
host: process.env.DB_HOST ?? '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT ?? 3306),
|
||||
user: process.env.DB_USER ?? 'opendance_app',
|
||||
pass: process.env.DB_PASS ?? '',
|
||||
name: process.env.DB_NAME ?? 'opendance',
|
||||
synchronize: bool(process.env.DB_SYNCHRONIZE, false),
|
||||
logging: bool(process.env.DB_LOGGING, false),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** 环境变量校验结果:errors 为必须处理的问题,warnings 仅提示、不阻断启动。 */
|
||||
export interface EnvValidation {
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 端口必须是 1..65535 的整数。 */
|
||||
function validatePort(name: string, value: unknown, errors: string[]): void {
|
||||
const valid =
|
||||
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535;
|
||||
if (!valid) {
|
||||
errors.push(`${name} 非法(当前值 ${String(value)}),必须是 1..65535 的整数`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生产环境下禁止 DB_SYNCHRONIZE=true —— 契约 database.md 明确要求。
|
||||
* 返回错误信息数组,供 main.ts 启动时快速失败。
|
||||
* 端口非法属于 errors,供 main.ts 启动时快速失败;
|
||||
* ADMIN_PORT === PORT 只是"未分端口"的兼容模式,属于 warnings,不阻断启动。
|
||||
*/
|
||||
export function validateEnv(env: Record<string, unknown>): string[] {
|
||||
const problems: string[] = [];
|
||||
export function validateEnv(env: Record<string, unknown>): EnvValidation {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const db = (env.db ?? {}) as Record<string, unknown>;
|
||||
|
||||
if (!db.host) problems.push('DB_HOST 未设置');
|
||||
if (!db.user) problems.push('DB_USER 未设置');
|
||||
if (!db.name) problems.push('DB_NAME 未设置');
|
||||
if (!db.host) errors.push('DB_HOST 未设置');
|
||||
if (!db.user) errors.push('DB_USER 未设置');
|
||||
if (!db.name) errors.push('DB_NAME 未设置');
|
||||
if (env.nodeEnv === 'production' && db.synchronize === true) {
|
||||
problems.push('生产环境禁止 DB_SYNCHRONIZE=true,请使用 TypeORM migration');
|
||||
errors.push('生产环境禁止 DB_SYNCHRONIZE=true,请使用 TypeORM migration');
|
||||
}
|
||||
return problems;
|
||||
|
||||
validatePort('PORT', env.port, errors);
|
||||
validatePort('ADMIN_PORT', env.adminPort, errors);
|
||||
|
||||
if (isSamePort(env.port as number, env.adminPort as number)) {
|
||||
warnings.push(
|
||||
`ADMIN_PORT 与 PORT 相同(${String(env.port)}),未分端口:` +
|
||||
'管理页面与软件 API 共用同一端口,建议分开部署',
|
||||
);
|
||||
}
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import { join } from 'node:path';
|
||||
* 未设置该变量时回落到这里的常量)。
|
||||
*/
|
||||
export const CONTRACT_VERSIONS: Readonly<Record<string, string>> = Object.freeze({
|
||||
'rest-api': '0.1.0',
|
||||
'rest-api': '0.2.0',
|
||||
'websocket-keypoints': '0.1.0',
|
||||
'data-models': '0.1.0',
|
||||
'data-models': '0.2.0',
|
||||
'skeleton-topology': '0.1.0',
|
||||
'model-formats': '0.1.0',
|
||||
'model-formats': '0.2.0',
|
||||
database: '0.1.0',
|
||||
});
|
||||
|
||||
|
||||
+96
-23
@@ -1,20 +1,47 @@
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import express, { NextFunction, Request, Response } from 'express';
|
||||
import { createServer, Server } from 'node:http';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { extname, join, resolve } from 'node:path';
|
||||
import { AppModule } from './app.module';
|
||||
import { describeAccess } from './config/access-hint';
|
||||
import { AppConfig, configuration, validateEnv } from './config/configuration';
|
||||
import { describeDualAccess, preferredUrl } from './config/access-hint';
|
||||
import {
|
||||
AppConfig,
|
||||
configuration,
|
||||
isAdminPortRequest,
|
||||
validateEnv,
|
||||
} from './config/configuration';
|
||||
|
||||
/** 关闭一个 HTTP server;未监听时直接返回。 */
|
||||
function closeServer(server: Server | undefined): Promise<void> {
|
||||
if (!server || !server.listening) return Promise.resolve();
|
||||
return new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((error) => (error ? rejectClose(error) : resolveClose()));
|
||||
});
|
||||
}
|
||||
|
||||
/** 在指定地址监听一个 HTTP server,失败时 reject(例如端口被占用)。 */
|
||||
function listenServer(server: Server, port: number, host: string): Promise<void> {
|
||||
return new Promise<void>((resolveListen, rejectListen) => {
|
||||
const onError = (error: Error) => rejectListen(error);
|
||||
server.once('error', onError);
|
||||
server.listen(port, host, () => {
|
||||
server.off('error', onError);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
// 启动前校验环境变量(生产环境禁止 DB_SYNCHRONIZE=true 等)
|
||||
const problems = validateEnv(configuration());
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) logger.warn(`环境变量问题:${problem}`);
|
||||
// 启动前校验环境变量(生产环境禁止 DB_SYNCHRONIZE=true、端口必须合法等)
|
||||
const { errors, warnings } = validateEnv(configuration());
|
||||
for (const warning of warnings) logger.warn(`环境变量提示:${warning}`);
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) logger.warn(`环境变量问题:${error}`);
|
||||
if ((process.env.NODE_ENV ?? 'development') === 'production') {
|
||||
logger.error('生产环境配置校验失败,启动中止');
|
||||
process.exit(1);
|
||||
@@ -26,6 +53,11 @@ async function bootstrap(): Promise<void> {
|
||||
});
|
||||
|
||||
const config = app.get(AppConfig);
|
||||
const apiPort = config.port;
|
||||
const adminPort = config.adminPort;
|
||||
const adminHost = config.adminHost;
|
||||
// 单端口兼容模式:ADMIN_PORT === PORT 时只创建一个 HTTP server,避免 EADDRINUSE
|
||||
const sharedPort = adminPort === apiPort;
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
@@ -53,18 +85,29 @@ async function bootstrap(): Promise<void> {
|
||||
app.useStaticAssets(resolve(config.contractsDir), { prefix: '/contracts/', index: false });
|
||||
}
|
||||
|
||||
// ---- 管理端静态站点 ----
|
||||
// 本服务同时提供管理端页面,因此反向代理只需要把全部请求转发到本端口,
|
||||
// 不需要在代理层再单独配置一个静态站点(IIS / nginx 示例见 deploy/proxy/)。
|
||||
// ---- 管理端静态站点(只在管理端口生效)----
|
||||
// 软件 API 端口(Windows/Godot 客户端、Android App 调用)只提供接口,
|
||||
// 根路径 `/` 与任意前端路径都必须 404,避免把管理页面暴露给客户端调用的端口。
|
||||
const adminDir = resolve(config.adminDir);
|
||||
const adminIndex = join(adminDir, 'index.html');
|
||||
if (existsSync(adminIndex)) {
|
||||
app.useStaticAssets(adminDir, { index: 'index.html', fallthrough: true });
|
||||
const adminAvailable = existsSync(adminIndex);
|
||||
if (adminAvailable) {
|
||||
// 请求是否从管理端口进入:用实际建立连接的本地端口判断(express 实例由两个 server 共用)
|
||||
const fromAdminPort = (req: Request): boolean =>
|
||||
isAdminPortRequest(req.socket.localPort, apiPort, adminPort);
|
||||
|
||||
// 管理端构建产物(/assets/**、/index.html 等)
|
||||
const adminStatic = express.static(adminDir, { index: 'index.html', fallthrough: true });
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (!fromAdminPort(req)) return next();
|
||||
adminStatic(req, res, next);
|
||||
});
|
||||
|
||||
// SPA 深链接回退:/songs、/models 这类前端路由在磁盘上没有对应文件,
|
||||
// 需要返回 index.html 交给前端路由处理。
|
||||
// 注意必须放行后端路径,否则会吞掉 API 请求。
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (!fromAdminPort(req)) return next();
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
||||
const path = req.path;
|
||||
if (
|
||||
@@ -89,26 +132,56 @@ async function bootstrap(): Promise<void> {
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const db = config.db;
|
||||
await app.listen(config.port, config.bindHost);
|
||||
await app.listen(apiPort, config.bindHost);
|
||||
|
||||
const access = describeAccess(config.bindHost, config.port);
|
||||
const externalUrls = access.urls.filter((url) => !url.includes('127.0.0.1'));
|
||||
// 第二个 HTTP server:与管理端口分开监听,复用同一个 express 实例
|
||||
let adminServer: Server | undefined;
|
||||
if (!sharedPort) {
|
||||
adminServer = createServer(app.getHttpAdapter().getInstance());
|
||||
await listenServer(adminServer, adminPort, adminHost);
|
||||
}
|
||||
|
||||
logger.log(`OpenDance Studio Web 服务端已启动,监听 ${access.listen}`);
|
||||
if (access.loopbackOnly) {
|
||||
// 让 app.close() 一并关闭管理端 server,保证优雅退出
|
||||
const originalClose = app.close.bind(app);
|
||||
app.close = async (): Promise<void> => {
|
||||
await Promise.all([closeServer(adminServer), originalClose()]);
|
||||
};
|
||||
|
||||
const access = describeDualAccess({
|
||||
apiHost: config.bindHost,
|
||||
apiPort,
|
||||
adminHost,
|
||||
adminPort,
|
||||
});
|
||||
const primaryApiUrl = preferredUrl(access.api);
|
||||
const primaryAdminUrl = preferredUrl(access.admin);
|
||||
|
||||
logger.log(`OpenDance Studio Web 服务端已启动,监听 ${access.api.listen}`);
|
||||
// 明确打印两行:哪个端口给客户端调接口,哪个端口给浏览器看管理页面
|
||||
logger.log(access.apiLine);
|
||||
if (!sharedPort) logger.log(`管理端监听:${access.admin.listen}`);
|
||||
logger.log(access.adminLine);
|
||||
if (sharedPort) logger.warn(access.sharedNotice);
|
||||
|
||||
if (access.api.loopbackOnly) {
|
||||
logger.warn(
|
||||
`可访问地址:${access.urls[0]}(只能本机访问;` +
|
||||
`软件 API 地址:${access.api.urls[0]}(只能本机访问;` +
|
||||
'要让外部访问,请在 .env 中设置 HOST=0.0.0.0 后重启)',
|
||||
);
|
||||
} else if (externalUrls.length > 0) {
|
||||
logger.log(`可访问地址:${externalUrls.join('、')}(管理端页面与 API 同一端口)`);
|
||||
}
|
||||
logger.log(`健康检查:${externalUrls[0] ?? access.urls[0]}/health`);
|
||||
if (access.admin.loopbackOnly && !access.api.loopbackOnly) {
|
||||
logger.warn(
|
||||
`管理端地址:${access.admin.urls[0]}(只能本机访问;` +
|
||||
'如需外部浏览器访问,请在 .env 中设置 ADMIN_HOST=0.0.0.0)',
|
||||
);
|
||||
}
|
||||
|
||||
logger.log(`健康检查:${primaryApiUrl}/health`);
|
||||
logger.log(`数据库:${db.host}:${db.port}/${db.name}(现有实例,本服务不管理 MySQL)`);
|
||||
logger.log(`文件存储:${storageDir}`);
|
||||
logger.log(
|
||||
existsSync(adminIndex)
|
||||
? `管理端站点:${externalUrls[0] ?? access.urls[0]}/(由本服务直接提供)`
|
||||
adminAvailable
|
||||
? `管理端站点:${primaryAdminUrl}/(仅管理端口 ${adminPort} 提供)`
|
||||
: '管理端站点:未部署(仅 API)',
|
||||
);
|
||||
logger.log(`契约版本:${JSON.stringify(config.contractVersions)}`);
|
||||
|
||||
@@ -54,6 +54,33 @@ export function matMul(a: Mat3, b: Mat3): Mat3 {
|
||||
return out as Mat3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 四元数 (x, y, z, w) → 3×3 行主序旋转矩阵。
|
||||
* 输入会自动归一化;零四元数视为单位四元数。
|
||||
*/
|
||||
export function quatToMat3(q: readonly [number, number, number, number]): Mat3 {
|
||||
let [x, y, z, w] = q;
|
||||
const len = Math.hypot(x, y, z, w);
|
||||
if (len < 1e-9) return IDENTITY3;
|
||||
x /= len;
|
||||
y /= len;
|
||||
z /= len;
|
||||
w /= len;
|
||||
return [
|
||||
1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w),
|
||||
2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
|
||||
2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y),
|
||||
];
|
||||
}
|
||||
|
||||
export function matTranspose(m: Mat3): Mat3 {
|
||||
return [m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]];
|
||||
}
|
||||
|
||||
export function vCross(a: Vec3, b: Vec3): Vec3 {
|
||||
return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
||||
}
|
||||
|
||||
export function matVec(m: Mat3, v: Vec3): Vec3 {
|
||||
return [
|
||||
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Mat3, Transform, Vec3, applyTransform, matVec, quatToMat3, vAdd, vLen, vNorm, vScale, vSub } from './math3d';
|
||||
import { LEG_IK, LEG_LENGTHS, MMD_SKELETON, MmdBoneDef } from './mmd-skeleton';
|
||||
import { VmdMotion, VmdBoneTrack, sampleBoneTrack, trackHasPositionMotion } from './vmd-parser';
|
||||
|
||||
/**
|
||||
* VMD 数值的解释方式。
|
||||
*
|
||||
* MMD 使用左手坐标系,而本项目的重定向通路(与 BVH 一致)是右手系。
|
||||
* 两种解释只有在真实数据上跑一遍才能判断哪种是"物理正确"的:
|
||||
* `zMirrored` 相当于把动画在 Z 轴上做一次镜像(旋转四元数取
|
||||
* (x,y,z,w) → (-x,-y,z,w),位移 z 取反)。
|
||||
* 见 mmd-vmd.spec.ts 中基于真实 VMD 的"膝盖朝前"对照实验。
|
||||
*/
|
||||
export type MmdRotationMode = 'asIs' | 'zMirrored';
|
||||
|
||||
export interface MmdPoseOptions {
|
||||
rotationMode?: MmdRotationMode;
|
||||
/** 采样帧率,默认 VMD 固定的 30 */
|
||||
fps?: number;
|
||||
/** 最大采样帧数,用于防御异常文件(默认 30000 帧 ≈ 16 分钟) */
|
||||
maxFrames?: number;
|
||||
/** 是否启用腿部 IK 解算(默认自动判断;诊断与测试可强制关闭) */
|
||||
legIk?: boolean;
|
||||
}
|
||||
|
||||
/** 单帧解算结果:boneName(标准名)→ 世界变换 */
|
||||
export type Pose = Map<string, Transform>;
|
||||
|
||||
const IDENTITY_TRANSFORM: Transform = { r: [1, 0, 0, 0, 1, 0, 0, 0, 1], t: [0, 0, 0] };
|
||||
|
||||
/** S·R·S,S = diag(1,1,-1):把左手系旋转搬到右手系 */
|
||||
function mirrorRotationZ(r: Mat3): Mat3 {
|
||||
return [
|
||||
r[0], r[1], -r[2],
|
||||
r[3], r[4], -r[5],
|
||||
-r[6], -r[7], r[8],
|
||||
];
|
||||
}
|
||||
|
||||
function convertRotation(
|
||||
quat: readonly [number, number, number, number],
|
||||
mode: MmdRotationMode,
|
||||
): Mat3 {
|
||||
const matrix = quatToMat3(quat);
|
||||
return mode === 'zMirrored' ? mirrorRotationZ(matrix) : matrix;
|
||||
}
|
||||
|
||||
function convertPosition(position: Vec3, mode: MmdRotationMode): Vec3 {
|
||||
return mode === 'zMirrored' ? [position[0], position[1], -position[2]] : [...position] as Vec3;
|
||||
}
|
||||
|
||||
/** 骨架的拓扑顺序(父骨骼一定排在子骨骼之前) */
|
||||
const SKELETON_ORDER: readonly MmdBoneDef[] = (() => {
|
||||
const remaining = [...MMD_SKELETON];
|
||||
const ordered: MmdBoneDef[] = [];
|
||||
const done = new Set<string>();
|
||||
while (remaining.length > 0) {
|
||||
const index = remaining.findIndex((bone) => bone.parent === null || done.has(bone.parent));
|
||||
if (index < 0) throw new Error('MMD 标准骨架存在环,无法拓扑排序');
|
||||
const [bone] = remaining.splice(index, 1);
|
||||
ordered.push(bone);
|
||||
done.add(bone.name);
|
||||
}
|
||||
return ordered;
|
||||
})();
|
||||
|
||||
/**
|
||||
* 解算单帧:标准骨架 FK + 腿部 IK。
|
||||
*
|
||||
* MMD 的位移通道是**世界轴**上的偏移(不被父骨骼旋转带走),
|
||||
* 因此平移单独叠加:`t = parentT + parentR * restOffset + animPos`。
|
||||
*/
|
||||
export function solveFrame(
|
||||
motion: VmdMotion,
|
||||
frame: number,
|
||||
mode: MmdRotationMode,
|
||||
useLegIk: { 左: boolean; 右: boolean },
|
||||
): Pose {
|
||||
const pose: Pose = new Map();
|
||||
|
||||
for (const bone of SKELETON_ORDER) {
|
||||
const parent = bone.parent ? pose.get(bone.parent) ?? IDENTITY_TRANSFORM : IDENTITY_TRANSFORM;
|
||||
const track: VmdBoneTrack | undefined = motion.tracks.get(bone.name);
|
||||
const sampled = track ? sampleBoneTrack(track, frame) : null;
|
||||
|
||||
const localR = sampled
|
||||
? convertRotation(sampled.rotation, mode)
|
||||
: IDENTITY_TRANSFORM.r;
|
||||
const animPos = sampled ? convertPosition(sampled.position, mode) : ([0, 0, 0] as Vec3);
|
||||
|
||||
pose.set(bone.name, {
|
||||
r: applyTransform(parent, bone.offset, localR).r,
|
||||
t: vAdd(vAdd(parent.t, matVec(parent.r, bone.offset)), animPos),
|
||||
});
|
||||
}
|
||||
|
||||
for (const side of ['左', '右'] as const) {
|
||||
if (!useLegIk[side]) continue;
|
||||
solveLegIk(pose, side);
|
||||
}
|
||||
|
||||
return pose;
|
||||
}
|
||||
|
||||
/**
|
||||
* 两骨骼解析 IK:把小腿末端拉到 IK 目标点,膝盖朝 +Z(角色正面)弯。
|
||||
* 目标超出可达范围时按比例收敛到伸直/极限,不产生 NaN。
|
||||
*/
|
||||
function solveLegIk(pose: Pose, side: '左' | '右'): void {
|
||||
const names = LEG_IK[side];
|
||||
const hip = pose.get(`${side}足`);
|
||||
const ik = pose.get(names.ik);
|
||||
if (!hip || !ik) return;
|
||||
|
||||
const upper = LEG_LENGTHS.thigh;
|
||||
const lower = LEG_LENGTHS.shin;
|
||||
const hipPos = hip.t;
|
||||
const target = ik.t;
|
||||
|
||||
let delta = vSub(target, hipPos);
|
||||
let distance = vLen(delta);
|
||||
const minReach = Math.abs(upper - lower) + 1e-4;
|
||||
const maxReach = upper + lower - 1e-4;
|
||||
|
||||
if (distance < 1e-6) {
|
||||
// 退化:目标与髋重合,直接落地
|
||||
delta = [0, -1, 0];
|
||||
distance = 1;
|
||||
}
|
||||
const axis = vNorm(delta);
|
||||
let ankle = target;
|
||||
if (distance > maxReach) {
|
||||
ankle = vAdd(hipPos, vScale(axis, maxReach));
|
||||
distance = maxReach;
|
||||
} else if (distance < minReach) {
|
||||
ankle = vAdd(hipPos, vScale(axis, minReach));
|
||||
distance = minReach;
|
||||
}
|
||||
|
||||
// 髋到膝在髋-踝连线上的投影长度
|
||||
const along = (upper * upper - lower * lower + distance * distance) / (2 * distance);
|
||||
const heightSq = Math.max(0, upper * upper - along * along);
|
||||
const height = Math.sqrt(heightSq);
|
||||
|
||||
// 膝盖参考方向:优先 +Z(正面),与连线正交化;退化时退回 +Y
|
||||
const forward: Vec3 = [0, 0, 1];
|
||||
let perp = vSub(forward, vScale(axis, forward[0] * axis[0] + forward[1] * axis[1] + forward[2] * axis[2]));
|
||||
if (vLen(perp) < 1e-4) {
|
||||
const up: Vec3 = [0, 1, 0];
|
||||
perp = vSub(up, vScale(axis, up[0] * axis[0] + up[1] * axis[1] + up[2] * axis[2]));
|
||||
}
|
||||
perp = vNorm(perp);
|
||||
|
||||
const kneePos = vAdd(vAdd(hipPos, vScale(axis, along)), vScale(perp, height));
|
||||
|
||||
const thighDir = vNorm(vSub(kneePos, hipPos));
|
||||
const shinDir = vNorm(vSub(ankle, kneePos));
|
||||
const knee = pose.get(names.knee);
|
||||
const ankleBone = pose.get(names.ankle);
|
||||
|
||||
if (knee) {
|
||||
knee.t = kneePos;
|
||||
if (vLen(thighDir) > 1e-6) {
|
||||
// 让膝的旋转与"指向踝"的方向一致,供下游(如趾尖)使用
|
||||
knee.r = alignRotation(knee.r, thighDir);
|
||||
}
|
||||
}
|
||||
if (ankleBone) {
|
||||
ankleBone.t = ankle;
|
||||
// MMD 把 IK 骨骼的旋转直接赋给足首
|
||||
ankleBone.r = ik.r;
|
||||
if (vLen(shinDir) > 1e-6) ankleBone.r = alignRotation(ik.r, shinDir);
|
||||
}
|
||||
const toe = pose.get(names.toe);
|
||||
if (toe && ankleBone) {
|
||||
const restOffset: Vec3 = [0, -0.2, LEG_LENGTHS.toe > 0 ? 1.3 : 1.3];
|
||||
toe.t = vAdd(ankle, matVec(ankleBone.r, restOffset));
|
||||
toe.r = ankleBone.r;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 让骨骼的 Y 轴(MMD 骨骼默认朝向子骨骼的方向)对齐给定方向,
|
||||
* 保留绕该方向的扭转分量。用于 IK 解算后给出合理的局部朝向。
|
||||
*/
|
||||
function alignRotation(base: Mat3, direction: Vec3): Mat3 {
|
||||
const dir = vNorm(direction);
|
||||
if (vLen(dir) < 1e-6) return base;
|
||||
// 取一个与 dir 不平行的参考轴构造正交基(列优先:X, Y=dir, Z)
|
||||
const reference: Vec3 = Math.abs(dir[1]) > 0.9 ? [1, 0, 0] : [0, 1, 0];
|
||||
const xAxis = vNorm([
|
||||
reference[1] * dir[2] - reference[2] * dir[1],
|
||||
reference[2] * dir[0] - reference[0] * dir[2],
|
||||
reference[0] * dir[1] - reference[1] * dir[0],
|
||||
]);
|
||||
if (vLen(xAxis) < 1e-6) return base;
|
||||
const zAxis: Vec3 = [
|
||||
xAxis[1] * dir[2] - xAxis[2] * dir[1],
|
||||
xAxis[2] * dir[0] - xAxis[0] * dir[2],
|
||||
xAxis[0] * dir[1] - xAxis[1] * dir[0],
|
||||
];
|
||||
return [
|
||||
xAxis[0], dir[0], zAxis[0],
|
||||
xAxis[1], dir[1], zAxis[1],
|
||||
xAxis[2], dir[2], zAxis[2],
|
||||
];
|
||||
}
|
||||
|
||||
/** 关节映射表使用的骨骼名(与 joint-map.ts 的候选名一致) */
|
||||
const HEAD_KEY = 'head';
|
||||
const JOINT_KEYS: ReadonlyArray<{ key: string; bone: string; offset?: Vec3 }> = [
|
||||
{ key: HEAD_KEY, bone: '頭', offset: [0, 0.6, 0] },
|
||||
{ key: 'leftarm', bone: '左腕' },
|
||||
{ key: 'rightarm', bone: '右腕' },
|
||||
{ key: 'leftforearm', bone: '左ひじ' },
|
||||
{ key: 'rightforearm', bone: '右ひじ' },
|
||||
{ key: 'lefthand', bone: '左手首' },
|
||||
{ key: 'righthand', bone: '右手首' },
|
||||
{ key: 'leftupleg', bone: '左足' },
|
||||
{ key: 'rightupleg', bone: '右足' },
|
||||
{ key: 'leftleg', bone: '左ひざ' },
|
||||
{ key: 'rightleg', bone: '右ひざ' },
|
||||
{ key: 'leftfoot', bone: '左足首' },
|
||||
{ key: 'rightfoot', bone: '右足首' },
|
||||
{ key: 'lefttoebase', bone: '左つま先' },
|
||||
{ key: 'righttoebase', bone: '右つま先' },
|
||||
];
|
||||
|
||||
/** 把一帧的姿态转成重定向模块使用的"骨骼名 → 世界坐标"表 */
|
||||
function poseToWorld(pose: Pose): Map<string, Vec3> {
|
||||
const world = new Map<string, Vec3>();
|
||||
for (const { key, bone, offset } of JOINT_KEYS) {
|
||||
const transform = pose.get(bone);
|
||||
if (!transform) continue;
|
||||
world.set(key, offset ? vAdd(transform.t, offset) : transform.t);
|
||||
}
|
||||
return world;
|
||||
}
|
||||
|
||||
/**
|
||||
* VMD → 逐帧世界坐标(键名与 `joint-map.ts` 的候选名一致)。
|
||||
* 输出可直接交给 `worldFramesToKeypointFrames` 复用 BVH 通路的重定向。
|
||||
*/
|
||||
export function vmdToWorldFrames(
|
||||
motion: VmdMotion,
|
||||
options: MmdPoseOptions = {},
|
||||
): { frames: Map<string, Vec3>[]; fps: number } {
|
||||
const mode = options.rotationMode ?? 'zMirrored';
|
||||
const fps = options.fps && options.fps > 0 ? options.fps : 30;
|
||||
const maxFrames = options.maxFrames ?? 30000;
|
||||
const frameCount = Math.min(motion.maxFrame + 1, maxFrames);
|
||||
|
||||
// 腿部是否由 IK 驱动:IK 目标有位移 → 用 IK;否则用 FK 旋转(部分动作数据是烘焙好的 FK)
|
||||
const autoIk = {
|
||||
左: trackHasPositionMotion(motion.tracks.get(LEG_IK.左.ik)),
|
||||
右: trackHasPositionMotion(motion.tracks.get(LEG_IK.右.ik)),
|
||||
};
|
||||
const useLegIk =
|
||||
options.legIk === false ? { 左: false, 右: false } : autoIk;
|
||||
|
||||
const frames: Map<string, Vec3>[] = [];
|
||||
for (let frame = 0; frame < frameCount; frame += 1) {
|
||||
frames.push(poseToWorld(solveFrame(motion, frame, mode, useLegIk)));
|
||||
}
|
||||
return { frames, fps };
|
||||
}
|
||||
|
||||
/** 诊断用:返回本帧被 IK 驱动的腿(测试与日志使用) */
|
||||
export function legIkDriven(motion: VmdMotion): { 左: boolean; 右: boolean } {
|
||||
return {
|
||||
左: trackHasPositionMotion(motion.tracks.get(LEG_IK.左.ik)),
|
||||
右: trackHasPositionMotion(motion.tracks.get(LEG_IK.右.ik)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Vec3 } from './math3d';
|
||||
|
||||
/**
|
||||
* MMD 标准骨骼静止姿态(T-WEB-006 VMD 支持)。
|
||||
*
|
||||
* VMD 只存**旋转**(以及少数骨骼的位移),不含骨骼长度,因此必须由服务端
|
||||
* 提供一套标准骨架才能做 FK。
|
||||
*
|
||||
* ## 这些数值从哪来
|
||||
*
|
||||
* 不是凭经验拍的比例,而是从真实 MMD 模型里量出来的:
|
||||
* 解析参考模型的 PMX 骨骼段(`示例文件/【奥黛塔】_by_原神/奥黛塔.pmx`,
|
||||
* 18432 条记录中的骨骼段),读出每根标准骨骼的静止世界坐标与父骨骼索引,
|
||||
* 再去掉模型自身的原点偏移、抹平该模型特有的左右/前后不对称(±0.5 单位级),
|
||||
* 得到下方这套对称的标准比例。复现方式见 `test/mmd-vmd.spec.ts` 中的
|
||||
* 比例自检(髋高比、腿长比、肩高比、腿长 = 大腿 + 小腿)。
|
||||
*
|
||||
* ## 坐标系
|
||||
*
|
||||
* 本文件用的是**本项目的右手系**(与 BVH 通路一致):Y 向上、+Z 为角色
|
||||
* 正面、+X 为**角色自身左侧**(与 MediaPipe 的 left_* 语义一致)。
|
||||
* MMD 原生是左手系(正面为 -Z),VMD 的旋转与位移由
|
||||
* `mmd-pose.ts` 统一做 Z 镜像后送入本骨架,两者保持一致。
|
||||
*
|
||||
* 关键静止高度(单位与 MMD 一致,模型总高约 19.4):
|
||||
* つま先 y = 0.00 足首 y = 1.43 ひざ y = 6.57 足(股関節) y = 11.50
|
||||
* センター y = 10.88 上半身2 y = 14.10 首 y = 16.21 頭 y = 17.16
|
||||
* 左腕(肩関節) y = 15.70
|
||||
*/
|
||||
export interface MmdBoneDef {
|
||||
/** 规范名(MMD 标准日文名) */
|
||||
readonly name: string;
|
||||
/** 父骨骼规范名;null 表示根 */
|
||||
readonly parent: string | null;
|
||||
/** 相对父骨骼头部的位置偏移(静止姿态) */
|
||||
readonly offset: Vec3;
|
||||
/** 其他等价写法(全角/半角、常见改写),用于匹配真实 VMD 中的骨骼名 */
|
||||
readonly aliases?: readonly string[];
|
||||
}
|
||||
|
||||
/** 标准骨架的静止比例常量(实测值,见文件头注释) */
|
||||
const REST = {
|
||||
/** センター(骨盆中心)高度 */
|
||||
centerHeight: 10.88,
|
||||
/** 骨盆到上半身分段 */
|
||||
groove: 0.12,
|
||||
waist: 1.23,
|
||||
upperBody: 0.56,
|
||||
chest: 1.31,
|
||||
neck: 2.11,
|
||||
head: 0.95,
|
||||
/** 下半身到髋关节 */
|
||||
lowerBody: 0.5,
|
||||
hipToJoint: -1.23,
|
||||
/** 肩关节链 */
|
||||
shoulderX: 0.15,
|
||||
shoulderY: 1.97,
|
||||
shoulderJointX: 0.98,
|
||||
shoulderJointY: -0.37,
|
||||
upperArmX: 2.11,
|
||||
upperArmY: -1.5,
|
||||
forearmX: 2.08,
|
||||
forearmY: -1.47,
|
||||
/** 腿 */
|
||||
hipX: 0.95,
|
||||
kneeX: -0.31,
|
||||
thighY: -4.93,
|
||||
ankleX: -0.33,
|
||||
shinY: -5.14,
|
||||
ankleHeight: 1.43,
|
||||
toeX: 0.12,
|
||||
toeY: -1.43,
|
||||
toeZ: 1.07,
|
||||
} as const;
|
||||
|
||||
/** 上半身系(腰以上) */
|
||||
const UPPER_BODY: MmdBoneDef[] = [
|
||||
{ name: '全ての親', parent: null, offset: [0, 0, 0], aliases: ['全ての親', 'AllParent'] },
|
||||
{
|
||||
name: 'センター',
|
||||
parent: '全ての親',
|
||||
offset: [0, REST.centerHeight, 0],
|
||||
aliases: ['センター', 'Center'],
|
||||
},
|
||||
{
|
||||
name: 'グルーブ',
|
||||
parent: 'センター',
|
||||
offset: [0, REST.groove, 0],
|
||||
aliases: ['グルーブ', 'Groove'],
|
||||
},
|
||||
{ name: '腰', parent: 'グルーブ', offset: [0, REST.waist, 0], aliases: ['腰', 'Waist'] },
|
||||
{
|
||||
name: '上半身',
|
||||
parent: '腰',
|
||||
offset: [0, REST.upperBody, 0],
|
||||
aliases: ['上半身', 'UpperBody'],
|
||||
},
|
||||
{
|
||||
name: '上半身2',
|
||||
parent: '上半身',
|
||||
offset: [0, REST.chest, 0],
|
||||
aliases: ['上半身2', 'UpperBody2'],
|
||||
},
|
||||
{ name: '首', parent: '上半身2', offset: [0, REST.neck, 0], aliases: ['首', 'Neck'] },
|
||||
{ name: '頭', parent: '首', offset: [0, REST.head, 0], aliases: ['頭', 'Head'] },
|
||||
// 注意:下半身是躯干骨(腰的子节点),必须放在这里而不是 limbBones(),
|
||||
// 否则会被左右两侧各生成一次,造成重名。
|
||||
{
|
||||
name: '下半身',
|
||||
parent: '腰',
|
||||
offset: [0, REST.lowerBody, 0],
|
||||
aliases: ['下半身', 'LowerBody'],
|
||||
},
|
||||
];
|
||||
|
||||
/** 手臂与腿(左右对称生成) */
|
||||
function limbBones(side: '左' | '右', sign: 1 | -1): MmdBoneDef[] {
|
||||
return [
|
||||
{
|
||||
name: `${side}肩`,
|
||||
parent: '上半身2',
|
||||
offset: [sign * REST.shoulderX, REST.shoulderY, 0],
|
||||
aliases: [`${side}肩`],
|
||||
},
|
||||
{
|
||||
name: `${side}腕`,
|
||||
parent: `${side}肩`,
|
||||
offset: [sign * REST.shoulderJointX, REST.shoulderJointY, 0],
|
||||
aliases: [`${side}腕`],
|
||||
},
|
||||
{
|
||||
name: `${side}ひじ`,
|
||||
parent: `${side}腕`,
|
||||
offset: [sign * REST.upperArmX, REST.upperArmY, 0],
|
||||
aliases: [`${side}ひじ`, `${side}肘`],
|
||||
},
|
||||
{
|
||||
name: `${side}手首`,
|
||||
parent: `${side}ひじ`,
|
||||
offset: [sign * REST.forearmX, REST.forearmY, 0],
|
||||
aliases: [`${side}手首`],
|
||||
},
|
||||
{
|
||||
name: `${side}足`,
|
||||
parent: '下半身',
|
||||
offset: [sign * REST.hipX, REST.hipToJoint, 0],
|
||||
aliases: [`${side}足`],
|
||||
},
|
||||
{
|
||||
name: `${side}ひざ`,
|
||||
parent: `${side}足`,
|
||||
offset: [sign * REST.kneeX, REST.thighY, 0],
|
||||
aliases: [`${side}ひざ`, `${side}膝`],
|
||||
},
|
||||
{
|
||||
name: `${side}足首`,
|
||||
parent: `${side}ひざ`,
|
||||
offset: [sign * REST.ankleX, REST.shinY, 0],
|
||||
aliases: [`${side}足首`],
|
||||
},
|
||||
{
|
||||
name: `${side}つま先`,
|
||||
parent: `${side}足首`,
|
||||
offset: [sign * REST.toeX, REST.toeY, REST.toeZ],
|
||||
aliases: [`${side}つま先`],
|
||||
},
|
||||
// ---- IK 目标(不参与蒙皮,只作为腿的解算目标)----
|
||||
// 实测:参考模型里「足IK親」挂在「全ての親」下(不在 センター 下),
|
||||
// 因此 IK 目标**不随 センター 的位移移动**;这里照实还原。
|
||||
{
|
||||
name: `${side}足IK親`,
|
||||
parent: '全ての親',
|
||||
offset: [sign * REST.ankleX, 0, 0],
|
||||
aliases: [`${side}足IK親`, `${side}足IK親`],
|
||||
},
|
||||
{
|
||||
name: `${side}足IK`,
|
||||
parent: `${side}足IK親`,
|
||||
offset: [0, REST.ankleHeight, 0],
|
||||
aliases: [`${side}足IK`, `${side}足IK`],
|
||||
},
|
||||
{
|
||||
name: `${side}つま先IK`,
|
||||
parent: `${side}足IK`,
|
||||
offset: [sign * REST.toeX, REST.toeY, REST.toeZ],
|
||||
aliases: [`${side}つま先IK`, `${side}つま先IK`],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const MMD_SKELETON: readonly MmdBoneDef[] = Object.freeze([
|
||||
...UPPER_BODY,
|
||||
...limbBones('左', 1),
|
||||
...limbBones('右', -1),
|
||||
]);
|
||||
|
||||
/** 规范名 → 定义 */
|
||||
export const MMD_BONE_BY_NAME: ReadonlyMap<string, MmdBoneDef> = new Map(
|
||||
MMD_SKELETON.map((bone) => [bone.name, bone]),
|
||||
);
|
||||
|
||||
/** 别名(含规范名本身)→ 规范名,用于把 VMD 中的骨骼名归一到标准骨架 */
|
||||
export const MMD_ALIAS_TO_NAME: ReadonlyMap<string, string> = (() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const bone of MMD_SKELETON) {
|
||||
map.set(bone.name, bone.name);
|
||||
for (const alias of bone.aliases ?? []) map.set(alias, bone.name);
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
/** 双腿 IK 目标骨骼规范名 */
|
||||
export const LEG_IK: Readonly<Record<'左' | '右', { ik: string; knee: string; ankle: string; toe: string }>> = {
|
||||
左: { ik: '左足IK', knee: '左ひざ', ankle: '左足首', toe: '左つま先' },
|
||||
右: { ik: '右足IK', knee: '右ひざ', ankle: '右足首', toe: '右つま先' },
|
||||
};
|
||||
|
||||
/** 骨骼静止长度,供 IK 解算与自检使用 */
|
||||
export const LEG_LENGTHS = Object.freeze({
|
||||
thigh: Math.hypot(REST.kneeX, REST.thighY),
|
||||
shin: Math.hypot(REST.ankleX, REST.shinY),
|
||||
toe: Math.hypot(REST.toeX, REST.toeY, REST.toeZ),
|
||||
});
|
||||
|
||||
/** 关键静止高度,供比例自检使用 */
|
||||
export const REST_HEIGHTS = Object.freeze({
|
||||
toe: 0,
|
||||
ankle: REST.ankleHeight,
|
||||
knee: REST.ankleHeight - REST.shinY,
|
||||
hip: REST.ankleHeight - REST.shinY - REST.thighY,
|
||||
center: REST.centerHeight,
|
||||
chest: REST.centerHeight + REST.groove + REST.waist + REST.upperBody + REST.chest,
|
||||
neck: REST.centerHeight + REST.groove + REST.waist + REST.upperBody + REST.chest + REST.neck,
|
||||
head: REST.centerHeight + REST.groove + REST.waist + REST.upperBody + REST.chest + REST.neck + REST.head,
|
||||
});
|
||||
|
||||
/** 把 VMD 中的骨骼名归一到标准骨架名;无法识别时返回 null */
|
||||
export function normalizeMmdBoneName(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
return MMD_ALIAS_TO_NAME.get(trimmed) ?? null;
|
||||
}
|
||||
@@ -11,8 +11,11 @@ import { StorageService } from '../storage/storage.service';
|
||||
import { parseBvh } from './bvh-parser';
|
||||
import { parseKeypointsJson, serializeFramesNdjson } from './keypoint-codec';
|
||||
import { KEYPOINT_FORMAT, KeypointFrame } from './mediapipe33';
|
||||
import { bvhToKeypointFrames } from './retarget';
|
||||
import { VMD_FPS } from './vmd-parser';
|
||||
import { bvhToKeypointFrames, worldFramesToKeypointFrames } from './retarget';
|
||||
import { convertToBvhWithAssimp } from './assimp';
|
||||
import { parseVmd } from './vmd-parser';
|
||||
import { vmdToWorldFrames } from './mmd-pose';
|
||||
|
||||
export interface PreprocessOutcome {
|
||||
frames: number;
|
||||
@@ -25,6 +28,8 @@ export interface PreprocessOutcome {
|
||||
* T-WEB-006:BVH / FBX → MediaPipe 33 点关键点序列预处理。
|
||||
*
|
||||
* - BVH:内置纯 TS 解析器 + FK + 重定向,无外部依赖
|
||||
* - VMD(MikuMikuDance):内置二进制解析器 + 标准 MMD 骨架 FK + 腿部 IK,
|
||||
* 再复用同一套重定向;无外部依赖
|
||||
* - glTF / GLB / FBX:调用可选的外部 Assimp 转为 BVH 后再走同一通路
|
||||
* - keypoints-json:直通校验与规范化
|
||||
*/
|
||||
@@ -104,6 +109,10 @@ export class PreprocessService {
|
||||
return this.fromBvh(text, motion.fps ?? undefined);
|
||||
}
|
||||
|
||||
if (motion.format === MotionFormat.Vmd) {
|
||||
return await this.fromVmd(sourcePath);
|
||||
}
|
||||
|
||||
if (motion.format === MotionFormat.KeypointsJson) {
|
||||
const text = await readFile(sourcePath, 'utf8');
|
||||
const { frames, fps } = parseKeypointsJson(text);
|
||||
@@ -130,6 +139,36 @@ export class PreprocessService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VMD 通路:二进制解析 → 标准 MMD 骨架 FK(腿用 IK 目标解算)→ 世界坐标帧
|
||||
* → 复用 BVH 的重定向(骨盆归零 / 自适应缩放 / 图像空间投影 / 9 个关节角)。
|
||||
*
|
||||
* VMD 固定 30 fps,因此忽略上传时填写的 fps,避免时间轴被写错。
|
||||
*/
|
||||
private async fromVmd(sourcePath: string): Promise<{
|
||||
frames: KeypointFrame[];
|
||||
fps: number;
|
||||
durationMs: number;
|
||||
}> {
|
||||
const buffer = await readFile(sourcePath);
|
||||
const vmd = parseVmd(buffer);
|
||||
const { frames: worldFrames } = vmdToWorldFrames(vmd, { fps: VMD_FPS });
|
||||
if (worldFrames.length === 0) throw new BadRequestException('VMD 中没有可用的骨骼关键帧');
|
||||
const frames = worldFramesToKeypointFrames(worldFrames, VMD_FPS);
|
||||
|
||||
this.logger.log(
|
||||
`VMD 解析:模型「${vmd.modelName}」${vmd.maxFrame + 1} 帧 / ` +
|
||||
`${vmd.tracks.size} 条骨骼轨道 / morph ${vmd.morphCount} 条,` +
|
||||
`未识别骨骼 ${vmd.unknownBones.length} 种`,
|
||||
);
|
||||
|
||||
return {
|
||||
frames,
|
||||
fps: VMD_FPS,
|
||||
durationMs: Math.round((frames.length / VMD_FPS) * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
private fromBvh(text: string, fpsOverride?: number): { frames: KeypointFrame[]; fps: number; durationMs: number } {
|
||||
const motion = parseBvh(text);
|
||||
if (motion.frameCount === 0) throw new BadRequestException('BVH 中没有帧');
|
||||
|
||||
@@ -36,19 +36,38 @@ const MIN_TORSO = 1e-3;
|
||||
* 骨盆归零 → 全局自适应缩放 → 投影到 MediaPipe 归一化图像空间 → 计算 9 个关节角。
|
||||
*/
|
||||
export function bvhToKeypointFrames(motion: BvhMotion, options: RetargetOptions = {}): KeypointFrame[] {
|
||||
const fps = options.fps && options.fps > 0 ? options.fps : 1 / motion.frameTime;
|
||||
|
||||
const worldFrames: Array<Map<string, Vec3>> = [];
|
||||
for (let f = 0; f < motion.frameCount; f += 1) {
|
||||
const positions = new Map<string, Vec3>();
|
||||
for (const [name, joint] of forwardKinematics(motion, f)) positions.set(name, joint.position);
|
||||
worldFrames.push(positions);
|
||||
}
|
||||
|
||||
return worldFramesToKeypointFrames(worldFrames, fps, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意骨架的逐帧世界坐标 → MediaPipe 33 点关键点序列。
|
||||
*
|
||||
* 与 `bvhToKeypointFrames` 共用同一套重定向规则(骨盆归零、自适应缩放、
|
||||
* 图像空间投影、9 个关节角),因此 BVH / VMD 等不同来源的动作产物完全一致。
|
||||
* `worldFrames[i]` 的键名需匹配 `jointMap` 中的候选名。
|
||||
*/
|
||||
export function worldFramesToKeypointFrames(
|
||||
worldFrames: ReadonlyArray<ReadonlyMap<string, Vec3>>,
|
||||
fps: number,
|
||||
options: RetargetOptions = {},
|
||||
): KeypointFrame[] {
|
||||
const jointMap = options.jointMap ?? DEFAULT_JOINT_MAP;
|
||||
const scaleFactor = options.scaleFactor ?? 0.35;
|
||||
const fps = options.fps && options.fps > 0 ? options.fps : 1 / motion.frameTime;
|
||||
|
||||
// 第一遍:逐帧解算原始 3D 位置(骨盆归零前)
|
||||
const rawFrames: RawPoint[][] = [];
|
||||
let maxAbs = 0;
|
||||
|
||||
for (let f = 0; f < motion.frameCount; f += 1) {
|
||||
const world = forwardKinematics(motion, f);
|
||||
const positions = new Map<string, Vec3>();
|
||||
for (const [name, joint] of world) positions.set(name, joint.position);
|
||||
|
||||
for (const positions of worldFrames) {
|
||||
const points = resolveFrame(positions, jointMap);
|
||||
|
||||
// 骨盆归零
|
||||
@@ -103,7 +122,7 @@ export function bvhToKeypointFrames(motion: BvhMotion, options: RetargetOptions
|
||||
* 依赖顺序:躯干 → 肩髋 → 肘/膝 → 腕/踝 → 头/手足末端。
|
||||
*/
|
||||
function resolveFrame(
|
||||
world: Map<string, Vec3>,
|
||||
world: ReadonlyMap<string, Vec3>,
|
||||
jointMap: Record<number, readonly string[]>,
|
||||
): RawPoint[] {
|
||||
const lookup = (index: number): Vec3 | null => {
|
||||
@@ -300,13 +319,13 @@ function resolveFrame(
|
||||
return points as RawPoint[];
|
||||
}
|
||||
|
||||
function firstValue(map: Map<string, Vec3>): Vec3 | undefined {
|
||||
function firstValue(map: ReadonlyMap<string, Vec3>): Vec3 | undefined {
|
||||
for (const value of map.values()) return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 无躯干信息时的尺度兜底:取根关节到最远关节的距离 */
|
||||
function torsoGuess(world: Map<string, Vec3>): number {
|
||||
function torsoGuess(world: ReadonlyMap<string, Vec3>): number {
|
||||
const root = world.get('hips') ?? world.get('root') ?? world.get('pelvis');
|
||||
if (!root) return 1;
|
||||
let maxDistance = 0;
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { TextDecoder } from 'node:util';
|
||||
import { Vec3 } from './math3d';
|
||||
import { normalizeMmdBoneName } from './mmd-skeleton';
|
||||
|
||||
/** VMD 骨骼关键帧 */
|
||||
export interface VmdBoneKey {
|
||||
frame: number;
|
||||
position: Vec3;
|
||||
/** 四元数 (x, y, z, w),MMD 原始数值 */
|
||||
rotation: readonly [number, number, number, number];
|
||||
/** 插值参数(贝塞尔),16 字节:x1[4] / y1[4] / x2[4] / y2[4],通道序 X, Y, Z, R */
|
||||
interpolation: Uint8Array;
|
||||
}
|
||||
|
||||
export interface VmdBoneTrack {
|
||||
/** VMD 中的原始骨骼名 */
|
||||
rawName: string;
|
||||
/** 归一化到标准骨架后的名字 */
|
||||
name: string;
|
||||
/** 按帧号升序 */
|
||||
keys: VmdBoneKey[];
|
||||
}
|
||||
|
||||
export interface VmdMotion {
|
||||
/** 文件头,例如 `Vocaloid Motion Data 0002` */
|
||||
header: string;
|
||||
/** 模型名(Shift-JIS 解码) */
|
||||
modelName: string;
|
||||
/** 最大帧号 */
|
||||
maxFrame: number;
|
||||
/** 归一化骨骼名 → 轨道 */
|
||||
tracks: Map<string, VmdBoneTrack>;
|
||||
/** 出现但未纳入标准骨架的骨骼名(去重、升序),用于日志与诊断 */
|
||||
unknownBones: string[];
|
||||
/** morph 关键帧数量(当前不使用,仅统计) */
|
||||
morphCount: number;
|
||||
/** 是否包含位移通道(区分 IK / FK 驱动需要) */
|
||||
hasPosition: boolean;
|
||||
}
|
||||
|
||||
const HEADER_SIZE = 30;
|
||||
const BONE_NAME_SIZE = 15;
|
||||
const BONE_KEY_SIZE = 111;
|
||||
const MORPH_KEY_SIZE = 23;
|
||||
/** MMD 固定 30 fps */
|
||||
export const VMD_FPS = 30;
|
||||
|
||||
const decoders = new Map<string, TextDecoder | null>();
|
||||
|
||||
function decoderFor(encoding: string): TextDecoder | null {
|
||||
if (!decoders.has(encoding)) {
|
||||
try {
|
||||
decoders.set(encoding, new TextDecoder(encoding, { fatal: false }));
|
||||
} catch {
|
||||
decoders.set(encoding, null);
|
||||
}
|
||||
}
|
||||
return decoders.get(encoding) ?? null;
|
||||
}
|
||||
|
||||
/** Shift-JIS 解码;Node 内置 ICU 不可用时退化为 latin1,避免直接抛错 */
|
||||
function decodeShiftJis(bytes: Uint8Array): string {
|
||||
const decoder = decoderFor('shift_jis');
|
||||
if (!decoder) return Buffer.from(bytes).toString('latin1');
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
|
||||
/** 读取以 \0 填充的定长字符串(骨骼名:MMD 标准名是日文,按 Shift-JIS 解) */
|
||||
function readFixedString(buffer: Buffer, offset: number, size: number): string {
|
||||
return decodeShiftJis(buffer.subarray(offset, offset + size)).replace(/\0.*$/s, '').trim();
|
||||
}
|
||||
|
||||
/** 半角片假名(U+FF61–U+FF9F):GBK 被当成 Shift-JIS 解码时的典型症状 */
|
||||
const HALF_WIDTH_KATAKANA = /[\uFF61-\uFF9F]/;
|
||||
|
||||
/**
|
||||
* 读取模型名。
|
||||
*
|
||||
* 标准 VMD 用 Shift-JIS,但相当多中文作者产出的 VMD 把模型名写成了 GBK。
|
||||
* GBK 字节按 Shift-JIS 解出来会夹杂半角片假名与生僻汉字(例如「キ釗ェウ瑶」),
|
||||
* 因此这里做一次启发式回退:出现半角片假名或替换字符时改用 GBK 再解一次。
|
||||
* 只影响日志里显示的模型名,不影响骨骼名匹配与解算结果。
|
||||
*/
|
||||
function readModelName(buffer: Buffer, offset: number, size: number): string {
|
||||
const bytes = buffer.subarray(offset, offset + size);
|
||||
const shiftJis = decodeShiftJis(bytes).replace(/\0.*$/s, '').trim();
|
||||
if (!HALF_WIDTH_KATAKANA.test(shiftJis) && !shiftJis.includes('\uFFFD')) return shiftJis;
|
||||
const gbkDecoder = decoderFor('gbk');
|
||||
if (!gbkDecoder) return shiftJis;
|
||||
const gbk = gbkDecoder.decode(bytes).replace(/\0.*$/s, '').trim();
|
||||
if (!gbk || gbk.includes('\uFFFD')) return shiftJis;
|
||||
return gbk;
|
||||
}
|
||||
|
||||
const DEFAULT_INTERPOLATION = (() => {
|
||||
// 线性(x1=20, y1=20, x2=107, y2=107 是 MMD 默认的缓入缓出;这里默认用线性 0/0/127/127)
|
||||
const bytes = new Uint8Array(16);
|
||||
for (let channel = 0; channel < 4; channel += 1) {
|
||||
bytes[channel] = 0;
|
||||
bytes[4 + channel] = 0;
|
||||
bytes[8 + channel] = 127;
|
||||
bytes[12 + channel] = 127;
|
||||
}
|
||||
return bytes;
|
||||
})();
|
||||
|
||||
/**
|
||||
* 解析 VMD(MikuMikuDance 动作数据)二进制文件。
|
||||
*
|
||||
* 只依赖骨骼与 morph 两段;相机 / 灯光 / 自阴影 / IK / 可见性段落一律跳过,
|
||||
* 长度不足时按"文件被截断"报错,而不是静默产出错误数据。
|
||||
*/
|
||||
export function parseVmd(buffer: Buffer): VmdMotion {
|
||||
if (buffer.length < HEADER_SIZE + 4) {
|
||||
throw new Error('VMD 文件过小,可能不是有效的 VMD');
|
||||
}
|
||||
const header = readFixedString(buffer, 0, HEADER_SIZE);
|
||||
if (!header.startsWith('Vocaloid Motion Data')) {
|
||||
throw new Error(`VMD 文件头不正确:期望 "Vocaloid Motion Data",实际 "${header.slice(0, 32)}"`);
|
||||
}
|
||||
// 0001 版本模型名 10 字节,0002 起为 20 字节
|
||||
const modelNameSize = header.includes('0002') ? 20 : 10;
|
||||
let offset = HEADER_SIZE;
|
||||
const modelName = readModelName(buffer, offset, modelNameSize);
|
||||
offset += modelNameSize;
|
||||
|
||||
if (offset + 4 > buffer.length) throw new Error('VMD 文件在骨骼数量处被截断');
|
||||
const boneCount = buffer.readUInt32LE(offset);
|
||||
offset += 4;
|
||||
const boneSectionEnd = offset + boneCount * BONE_KEY_SIZE;
|
||||
if (boneSectionEnd > buffer.length) {
|
||||
throw new Error(
|
||||
`VMD 骨骼段长度不足:声明 ${boneCount} 帧需要 ${boneCount * BONE_KEY_SIZE} 字节,` +
|
||||
`实际只剩 ${buffer.length - offset} 字节`,
|
||||
);
|
||||
}
|
||||
|
||||
const tracks = new Map<string, VmdBoneTrack>();
|
||||
const unknown = new Set<string>();
|
||||
let maxFrame = 0;
|
||||
let hasPosition = false;
|
||||
|
||||
for (let i = 0; i < boneCount; i += 1) {
|
||||
const base = offset + i * BONE_KEY_SIZE;
|
||||
const rawName = readFixedString(buffer, base, BONE_NAME_SIZE);
|
||||
const frame = buffer.readUInt32LE(base + BONE_NAME_SIZE);
|
||||
const position: Vec3 = [
|
||||
buffer.readFloatLE(base + 19),
|
||||
buffer.readFloatLE(base + 23),
|
||||
buffer.readFloatLE(base + 27),
|
||||
];
|
||||
const rotation: [number, number, number, number] = [
|
||||
buffer.readFloatLE(base + 31),
|
||||
buffer.readFloatLE(base + 35),
|
||||
buffer.readFloatLE(base + 39),
|
||||
buffer.readFloatLE(base + 43),
|
||||
];
|
||||
const interpolation = Uint8Array.from(buffer.subarray(base + 47, base + 63));
|
||||
|
||||
if (!Number.isFinite(frame)) continue;
|
||||
maxFrame = Math.max(maxFrame, frame);
|
||||
if (
|
||||
Math.abs(position[0]) > 1e-6 ||
|
||||
Math.abs(position[1]) > 1e-6 ||
|
||||
Math.abs(position[2]) > 1e-6
|
||||
) {
|
||||
hasPosition = true;
|
||||
}
|
||||
|
||||
const name = normalizeMmdBoneName(rawName);
|
||||
if (!name) {
|
||||
unknown.add(rawName);
|
||||
continue;
|
||||
}
|
||||
const track = tracks.get(name) ?? { rawName, name, keys: [] };
|
||||
track.keys.push({
|
||||
frame,
|
||||
position,
|
||||
rotation,
|
||||
interpolation: interpolation.length === 16 ? interpolation : DEFAULT_INTERPOLATION.slice(),
|
||||
});
|
||||
tracks.set(name, track);
|
||||
}
|
||||
|
||||
for (const track of tracks.values()) track.keys.sort((a, b) => a.frame - b.frame);
|
||||
|
||||
offset = boneSectionEnd;
|
||||
|
||||
// ---- morph 段(跳过,仅统计)----
|
||||
let morphCount = 0;
|
||||
if (offset + 4 <= buffer.length) {
|
||||
morphCount = buffer.readUInt32LE(offset);
|
||||
offset += 4;
|
||||
}
|
||||
const morphEnd = offset + morphCount * MORPH_KEY_SIZE;
|
||||
if (morphEnd > buffer.length) {
|
||||
throw new Error(
|
||||
`VMD morph 段长度不足:声明 ${morphCount} 帧需要 ${morphCount * MORPH_KEY_SIZE} 字节,` +
|
||||
`实际只剩 ${buffer.length - offset} 字节`,
|
||||
);
|
||||
}
|
||||
|
||||
if (tracks.size === 0) {
|
||||
throw new Error(`VMD 中没有可识别的标准骨骼关键帧(共 ${boneCount} 帧,全部未匹配)`);
|
||||
}
|
||||
|
||||
return {
|
||||
header,
|
||||
modelName,
|
||||
maxFrame,
|
||||
tracks,
|
||||
unknownBones: [...unknown].sort(),
|
||||
morphCount,
|
||||
hasPosition,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 采样:MMD 贝塞尔插值 ----------
|
||||
|
||||
/**
|
||||
* 求解 MMD 的三次贝塞尔缓动:控制点 (0,0) → (x1,y1) → (x2,y2) → (1,1)。
|
||||
* 以 x 为时间轴二分求参数,再取 y 作为缓动后的时间。
|
||||
*/
|
||||
function bezierEase(t: number, x1: number, y1: number, x2: number, y2: number): number {
|
||||
if (t <= 0) return 0;
|
||||
if (t >= 1) return 1;
|
||||
const curveX = (u: number): number => {
|
||||
const v = 1 - u;
|
||||
return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u;
|
||||
};
|
||||
const curveY = (u: number): number => {
|
||||
const v = 1 - u;
|
||||
return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u;
|
||||
};
|
||||
let low = 0;
|
||||
let high = 1;
|
||||
let u = t;
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
const x = curveX(u);
|
||||
if (Math.abs(x - t) < 1e-6) break;
|
||||
if (x < t) low = u;
|
||||
else high = u;
|
||||
u = (low + high) / 2;
|
||||
}
|
||||
return curveY(u);
|
||||
}
|
||||
|
||||
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||
|
||||
/** 通道序号:0=X, 1=Y, 2=Z, 3=旋转 */
|
||||
function channelEase(t: number, interpolation: Uint8Array, channel: number): number {
|
||||
const x1 = (interpolation[channel] ?? 0) / 127;
|
||||
const y1 = (interpolation[4 + channel] ?? 0) / 127;
|
||||
const x2 = (interpolation[8 + channel] ?? 127) / 127;
|
||||
const y2 = (interpolation[12 + channel] ?? 127) / 127;
|
||||
return bezierEase(t, x1, y1, x2, y2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在给定帧号上采样轨道;超出关键帧范围时保持首/末帧姿态(MMD 行为)。
|
||||
* 返回 null 表示该轨道没有关键帧。
|
||||
*/
|
||||
export function sampleBoneTrack(
|
||||
track: VmdBoneTrack,
|
||||
frame: number,
|
||||
): { position: Vec3; rotation: [number, number, number, number] } | null {
|
||||
const keys = track.keys;
|
||||
if (keys.length === 0) return null;
|
||||
if (frame <= keys[0].frame) {
|
||||
return { position: [...keys[0].position] as Vec3, rotation: [...keys[0].rotation] as [number, number, number, number] };
|
||||
}
|
||||
const last = keys[keys.length - 1];
|
||||
if (frame >= last.frame) {
|
||||
return { position: [...last.position] as Vec3, rotation: [...last.rotation] as [number, number, number, number] };
|
||||
}
|
||||
|
||||
// 线性查找相邻关键帧(同一骨骼的关键帧通常不多,且轨道已按帧号排序)
|
||||
let hi = 1;
|
||||
while (hi < keys.length && keys[hi].frame < frame) hi += 1;
|
||||
const a = keys[hi - 1];
|
||||
const b = keys[hi];
|
||||
const span = b.frame - a.frame;
|
||||
const t = span > 0 ? (frame - a.frame) / span : 1;
|
||||
|
||||
const position: Vec3 = [
|
||||
lerp(a.position[0], b.position[0], channelEase(t, a.interpolation, 0)),
|
||||
lerp(a.position[1], b.position[1], channelEase(t, a.interpolation, 1)),
|
||||
lerp(a.position[2], b.position[2], channelEase(t, a.interpolation, 2)),
|
||||
];
|
||||
|
||||
// 四元数:MMD 用同一条缓动曲线做 nlerp,先做符号对齐避免走远路
|
||||
const e = channelEase(t, a.interpolation, 3);
|
||||
let dot =
|
||||
a.rotation[0] * b.rotation[0] +
|
||||
a.rotation[1] * b.rotation[1] +
|
||||
a.rotation[2] * b.rotation[2] +
|
||||
a.rotation[3] * b.rotation[3];
|
||||
const sign = dot < 0 ? -1 : 1;
|
||||
dot = Math.abs(dot);
|
||||
const rotation: [number, number, number, number] = [
|
||||
lerp(a.rotation[0], sign * b.rotation[0], e),
|
||||
lerp(a.rotation[1], sign * b.rotation[1], e),
|
||||
lerp(a.rotation[2], sign * b.rotation[2], e),
|
||||
lerp(a.rotation[3], sign * b.rotation[3], e),
|
||||
];
|
||||
const len = Math.hypot(rotation[0], rotation[1], rotation[2], rotation[3]);
|
||||
if (len > 1e-9) {
|
||||
rotation[0] /= len;
|
||||
rotation[1] /= len;
|
||||
rotation[2] /= len;
|
||||
rotation[3] /= len;
|
||||
}
|
||||
return { position, rotation };
|
||||
}
|
||||
|
||||
/** 轨道是否存在非零位移(用于判断该骨骼是否被当作 IK 目标驱动) */
|
||||
export function trackHasPositionMotion(track: VmdBoneTrack | undefined): boolean {
|
||||
if (!track) return false;
|
||||
return track.keys.some(
|
||||
(key) =>
|
||||
Math.abs(key.position[0]) > 1e-4 ||
|
||||
Math.abs(key.position[1]) > 1e-4 ||
|
||||
Math.abs(key.position[2]) > 1e-4,
|
||||
);
|
||||
}
|
||||
|
||||
/** 轨道是否存在非单位旋转 */
|
||||
export function trackHasRotationMotion(track: VmdBoneTrack | undefined): boolean {
|
||||
if (!track) return false;
|
||||
return track.keys.some((key) => {
|
||||
const [x, y, z, w] = key.rotation;
|
||||
return Math.hypot(x, y, z) > 1e-4 || Math.abs(Math.abs(w) - 1) > 1e-4;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describeAccess } from '../src/config/access-hint';
|
||||
import { describeAccess, describeDualAccess, preferredUrl } from '../src/config/access-hint';
|
||||
|
||||
/**
|
||||
* 启动日志里"该访问哪个地址"必须与真实绑定一致。
|
||||
@@ -38,3 +38,76 @@ describe('启动日志的访问地址推导', () => {
|
||||
expect(describeAccess('10.0.0.1', 80).urls).toEqual(['http://10.0.0.1:80']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 双端口模式下,启动日志必须分别说清"客户端调接口用哪个地址"和
|
||||
* "浏览器看管理页面用哪个地址",否则客户端会把管理端口当成 API 端口。
|
||||
*/
|
||||
describe('双端口访问提示', () => {
|
||||
it('分端口时分别给出软件 API 与管理端两行,并标明各自职责', () => {
|
||||
const hint = describeDualAccess({
|
||||
apiHost: '0.0.0.0',
|
||||
apiPort: 3111,
|
||||
adminHost: '0.0.0.0',
|
||||
adminPort: 3112,
|
||||
});
|
||||
expect(hint.sharedPort).toBe(false);
|
||||
expect(hint.apiLine).toContain('软件 API 端口:');
|
||||
expect(hint.apiLine).toContain(':3111');
|
||||
expect(hint.apiLine).toContain('/api、/health、/static、/contracts');
|
||||
expect(hint.adminLine).toContain('管理端端口:');
|
||||
expect(hint.adminLine).toContain(':3112');
|
||||
expect(hint.adminLine).toContain('浏览器访问');
|
||||
expect(hint.adminLine).not.toContain('未分端口');
|
||||
expect(hint.sharedNotice).toBe('');
|
||||
});
|
||||
|
||||
it('绝不把 0.0.0.0 写进可访问地址', () => {
|
||||
const hint = describeDualAccess({
|
||||
apiHost: '0.0.0.0',
|
||||
apiPort: 3000,
|
||||
adminHost: '0.0.0.0',
|
||||
adminPort: 3001,
|
||||
});
|
||||
expect(hint.apiLine).not.toContain('0.0.0.0');
|
||||
expect(hint.adminLine).not.toContain('0.0.0.0');
|
||||
expect(hint.api.urls[0]).toBe('http://127.0.0.1:3000');
|
||||
});
|
||||
|
||||
it('ADMIN_PORT === PORT 时标记为未分端口并给出提示', () => {
|
||||
const hint = describeDualAccess({
|
||||
apiHost: '0.0.0.0',
|
||||
apiPort: 3000,
|
||||
adminHost: '0.0.0.0',
|
||||
adminPort: 3000,
|
||||
});
|
||||
expect(hint.sharedPort).toBe(true);
|
||||
expect(hint.sharedNotice).toContain('未分端口');
|
||||
expect(hint.adminLine).toContain('未分端口');
|
||||
expect(hint.adminLine).toContain(':3000');
|
||||
});
|
||||
|
||||
it('绑定回环时保留"只能本机访问"的告警依据', () => {
|
||||
const hint = describeDualAccess({
|
||||
apiHost: '127.0.0.1',
|
||||
apiPort: 3000,
|
||||
adminHost: '127.0.0.1',
|
||||
adminPort: 3001,
|
||||
});
|
||||
expect(hint.loopbackOnly).toBe(true);
|
||||
expect(hint.apiLine).toContain('http://127.0.0.1:3000');
|
||||
});
|
||||
|
||||
it('preferredUrl 优先挑真实网卡地址,没有才回落到回环地址', () => {
|
||||
expect(
|
||||
preferredUrl({
|
||||
listen: '',
|
||||
urls: ['http://127.0.0.1:3000', 'http://192.168.1.10:3000'],
|
||||
loopbackOnly: false,
|
||||
}),
|
||||
).toBe('http://192.168.1.10:3000');
|
||||
expect(
|
||||
preferredUrl({ listen: '', urls: ['http://127.0.0.1:3000'], loopbackOnly: true }),
|
||||
).toBe('http://127.0.0.1:3000');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
configuration,
|
||||
isAdminPortRequest,
|
||||
isSamePort,
|
||||
parsePort,
|
||||
resolveAdminPort,
|
||||
validateEnv,
|
||||
} from '../src/config/configuration';
|
||||
|
||||
/**
|
||||
* 管理端口与软件 API 端口必须分开:
|
||||
* PORT 只提供接口,ADMIN_PORT(默认 PORT + 1)提供管理页面 + 全部接口,
|
||||
* 同时允许 ADMIN_PORT === PORT 的单端口兼容模式。
|
||||
*/
|
||||
const PORT_KEYS = ['PORT', 'ADMIN_PORT', 'HOST', 'ADMIN_HOST'] as const;
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = {};
|
||||
for (const key of PORT_KEYS) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of PORT_KEYS) {
|
||||
if (savedEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = savedEnv[key];
|
||||
}
|
||||
});
|
||||
|
||||
/** 构造一份除端口外都合法的环境,便于单独验证端口校验。 */
|
||||
function validEnv(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
nodeEnv: 'development',
|
||||
port: 3000,
|
||||
adminPort: 3001,
|
||||
db: { host: '127.0.0.1', user: 'u', name: 'n', synchronize: false },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('端口解析与默认值', () => {
|
||||
it('parsePort:未设置或空白用 fallback,设置了就取数字', () => {
|
||||
expect(parsePort(undefined, 3000)).toBe(3000);
|
||||
expect(parsePort('', 3000)).toBe(3000);
|
||||
expect(parsePort(' ', 3000)).toBe(3000);
|
||||
expect(parsePort('3111', 3000)).toBe(3111);
|
||||
expect(Number.isNaN(parsePort('abc', 3000))).toBe(true);
|
||||
});
|
||||
|
||||
it('resolveAdminPort:未设置 ADMIN_PORT 时默认 PORT + 1', () => {
|
||||
expect(resolveAdminPort(3000, undefined)).toBe(3001);
|
||||
expect(resolveAdminPort(3000, '')).toBe(3001);
|
||||
expect(resolveAdminPort(3111, ' ')).toBe(3112);
|
||||
expect(resolveAdminPort(3000, '4000')).toBe(4000);
|
||||
});
|
||||
|
||||
it('configuration:默认端口 3000 / 管理端口 3001,管理端监听地址默认取 HOST', () => {
|
||||
const env = configuration();
|
||||
expect(env.port).toBe(3000);
|
||||
expect(env.adminPort).toBe(3001);
|
||||
expect(env.bindHost).toBe('0.0.0.0');
|
||||
expect(env.adminHost).toBe('0.0.0.0');
|
||||
});
|
||||
|
||||
it('configuration:PORT 改动后 ADMIN_PORT 跟着变成 PORT + 1', () => {
|
||||
process.env.PORT = '3111';
|
||||
const env = configuration();
|
||||
expect(env.port).toBe(3111);
|
||||
expect(env.adminPort).toBe(3112);
|
||||
});
|
||||
|
||||
it('configuration:显式 ADMIN_PORT 优先于默认值', () => {
|
||||
process.env.PORT = '3111';
|
||||
process.env.ADMIN_PORT = '4111';
|
||||
expect(configuration().adminPort).toBe(4111);
|
||||
});
|
||||
|
||||
it('configuration:ADMIN_HOST 显式设置时不再跟随 HOST', () => {
|
||||
process.env.HOST = '127.0.0.1';
|
||||
expect(configuration().adminHost).toBe('127.0.0.1');
|
||||
|
||||
process.env.ADMIN_HOST = '0.0.0.0';
|
||||
expect(configuration().adminHost).toBe('0.0.0.0');
|
||||
expect(configuration().bindHost).toBe('127.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('端口配置校验', () => {
|
||||
it('合法端口不报任何问题', () => {
|
||||
const { errors, warnings } = validateEnv(validEnv());
|
||||
expect(errors).toEqual([]);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('PORT 非法(0、越界、非整数、NaN)都要报问题', () => {
|
||||
for (const port of [0, 65536, 3000.5, Number.NaN]) {
|
||||
const { errors } = validateEnv(validEnv({ port, adminPort: 3001 }));
|
||||
expect(errors.some((error) => error.includes('PORT'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('ADMIN_PORT 非法也要报问题', () => {
|
||||
const { errors } = validateEnv(validEnv({ adminPort: 70000 }));
|
||||
expect(errors.some((error) => error.includes('ADMIN_PORT'))).toBe(true);
|
||||
});
|
||||
|
||||
it('ADMIN_PORT === PORT 只给 warn 级提示,不算错误(生产环境也不阻断)', () => {
|
||||
const { errors, warnings } = validateEnv(
|
||||
validEnv({ nodeEnv: 'production', port: 3000, adminPort: 3000 }),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toContain('未分端口');
|
||||
});
|
||||
|
||||
it('原有的数据库校验保持不变', () => {
|
||||
const { errors } = validateEnv({
|
||||
nodeEnv: 'production',
|
||||
port: 3000,
|
||||
adminPort: 3000,
|
||||
db: { host: '', user: '', name: '', synchronize: true },
|
||||
});
|
||||
expect(errors).toContain('DB_HOST 未设置');
|
||||
expect(errors).toContain('DB_USER 未设置');
|
||||
expect(errors).toContain('DB_NAME 未设置');
|
||||
expect(errors.some((error) => error.includes('DB_SYNCHRONIZE'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('按请求端口区分管理端行为', () => {
|
||||
it('isSamePort 只在两个端口相等且合法时为 true', () => {
|
||||
expect(isSamePort(3000, 3000)).toBe(true);
|
||||
expect(isSamePort(3000, 3001)).toBe(false);
|
||||
expect(isSamePort(Number.NaN, Number.NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('分端口时:管理端口是管理端,API 端口不是', () => {
|
||||
expect(isAdminPortRequest(3112, 3111, 3112)).toBe(true);
|
||||
expect(isAdminPortRequest(3111, 3111, 3112)).toBe(false);
|
||||
expect(isAdminPortRequest(undefined, 3111, 3112)).toBe(false);
|
||||
});
|
||||
|
||||
it('ADMIN_PORT === PORT 的兼容模式下所有请求都按管理端处理', () => {
|
||||
expect(isAdminPortRequest(3111, 3111, 3111)).toBe(true);
|
||||
expect(isAdminPortRequest(undefined, 3111, 3111)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,556 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
LEG_LENGTHS,
|
||||
MMD_BONE_BY_NAME,
|
||||
MMD_SKELETON,
|
||||
REST_HEIGHTS,
|
||||
normalizeMmdBoneName,
|
||||
} from '../src/preprocess/mmd-skeleton';
|
||||
import { solveFrame, vmdToWorldFrames, type MmdRotationMode } from '../src/preprocess/mmd-pose';
|
||||
import { KEYPOINT_COUNT, ANGLE_TRIPLETS } from '../src/preprocess/mediapipe33';
|
||||
import { worldFramesToKeypointFrames } from '../src/preprocess/retarget';
|
||||
import { Vec3, angleCos, vDot, vLen, vNorm, vSub } from '../src/preprocess/math3d';
|
||||
import {
|
||||
VMD_FPS,
|
||||
parseVmd,
|
||||
sampleBoneTrack,
|
||||
trackHasPositionMotion,
|
||||
trackHasRotationMotion,
|
||||
} from '../src/preprocess/vmd-parser';
|
||||
|
||||
/**
|
||||
* 真实 VMD 的骨骼名是 Shift-JIS。测试里无法用 TextEncoder 编码 Shift-JIS,
|
||||
* 因此直接把从真实样本(`示例文件/【动作】霜雪千年.vmd`)里读出的字节固化下来,
|
||||
* 这样同时也验证了解码路径本身。
|
||||
*/
|
||||
const SJIS: Record<string, number[]> = {
|
||||
全ての親: [0x91, 0x53, 0x82, 0xc4, 0x82, 0xcc, 0x90, 0x65],
|
||||
センター: [0x83, 0x5a, 0x83, 0x93, 0x83, 0x5e, 0x81, 0x5b],
|
||||
グルーブ: [0x83, 0x4f, 0x83, 0x8b, 0x81, 0x5b, 0x83, 0x75],
|
||||
腰: [0x8d, 0x98],
|
||||
上半身: [0x8f, 0xe3, 0x94, 0xbc, 0x90, 0x67],
|
||||
上半身2: [0x8f, 0xe3, 0x94, 0xbc, 0x90, 0x67, 0x32],
|
||||
首: [0x8e, 0xf1],
|
||||
頭: [0x93, 0xaa],
|
||||
左腕: [0x8d, 0xb6, 0x98, 0x72],
|
||||
左ひじ: [0x8d, 0xb6, 0x82, 0xd0, 0x82, 0xb6],
|
||||
左手首: [0x8d, 0xb6, 0x8e, 0xe8, 0x8e, 0xf1],
|
||||
下半身: [0x89, 0xba, 0x94, 0xbc, 0x90, 0x67],
|
||||
左足: [0x8d, 0xb6, 0x91, 0xab],
|
||||
左ひざ: [0x8d, 0xb6, 0x82, 0xd0, 0x82, 0xb4],
|
||||
左足首: [0x8d, 0xb6, 0x91, 0xab, 0x8e, 0xf1],
|
||||
左つま先: [0x8d, 0xb6, 0x82, 0xc2, 0x82, 0xdc, 0x90, 0xe6],
|
||||
左足IK: [0x8d, 0xb6, 0x91, 0xab, 0x82, 0x68, 0x82, 0x6a],
|
||||
右足: [0x89, 0x45, 0x91, 0xab],
|
||||
右ひざ: [0x89, 0x45, 0x82, 0xd0, 0x82, 0xb4],
|
||||
右足首: [0x89, 0x45, 0x91, 0xab, 0x8e, 0xf1],
|
||||
右つま先: [0x89, 0x45, 0x82, 0xc2, 0x82, 0xdc, 0x90, 0xe6],
|
||||
右足IK: [0x89, 0x45, 0x91, 0xab, 0x82, 0x68, 0x82, 0x6a],
|
||||
};
|
||||
|
||||
interface BoneKeyInput {
|
||||
name: string;
|
||||
frame: number;
|
||||
position?: Vec3;
|
||||
/** 四元数 (x, y, z, w) */
|
||||
rotation?: readonly [number, number, number, number];
|
||||
/** 插值参数:4 通道各 [x1, y1, x2, y2];缺省线性 */
|
||||
interpolation?: { x1?: number; y1?: number; x2?: number; y2?: number };
|
||||
}
|
||||
|
||||
/** 线性插值的 16 字节参数(x1=0, y1=0, x2=127, y2=127) */
|
||||
function linearInterpolation(override?: BoneKeyInput['interpolation']): number[] {
|
||||
const p = { x1: 0, y1: 0, x2: 127, y2: 127, ...(override ?? {}) };
|
||||
const bytes = new Array<number>(16).fill(0);
|
||||
for (let channel = 0; channel < 4; channel += 1) {
|
||||
bytes[channel] = p.x1;
|
||||
bytes[4 + channel] = p.y1;
|
||||
bytes[8 + channel] = p.x2;
|
||||
bytes[12 + channel] = p.y2;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** 构造一个最小但结构完整的 VMD 文件(骨骼段 + morph 段) */
|
||||
function buildVmd(options: {
|
||||
bones: BoneKeyInput[];
|
||||
header?: string;
|
||||
modelName?: string;
|
||||
modelNameSize?: number;
|
||||
morphCount?: number;
|
||||
truncateBoneBytes?: number;
|
||||
truncateMorphBytes?: number;
|
||||
}): Buffer {
|
||||
const header = options.header ?? 'Vocaloid Motion Data 0002';
|
||||
const chunks: Buffer[] = [];
|
||||
const headerBuf = Buffer.alloc(30);
|
||||
Buffer.from(header, 'latin1').copy(headerBuf);
|
||||
chunks.push(headerBuf);
|
||||
|
||||
const nameSize = options.modelNameSize ?? 20;
|
||||
const modelBuf = Buffer.alloc(nameSize);
|
||||
Buffer.from(options.modelName ?? 'TESTMODEL', 'latin1').copy(modelBuf);
|
||||
chunks.push(modelBuf);
|
||||
|
||||
const countBuf = Buffer.alloc(4);
|
||||
countBuf.writeUInt32LE(options.bones.length);
|
||||
chunks.push(countBuf);
|
||||
|
||||
for (const bone of options.bones) {
|
||||
const record = Buffer.alloc(111);
|
||||
const nameBytes = SJIS[bone.name];
|
||||
if (!nameBytes) throw new Error(`测试夹具缺少骨骼名的 Shift-JIS 字节:${bone.name}`);
|
||||
Buffer.from(nameBytes).copy(record, 0, 0, 15);
|
||||
record.writeUInt32LE(bone.frame, 15);
|
||||
const position = bone.position ?? [0, 0, 0];
|
||||
record.writeFloatLE(position[0], 19);
|
||||
record.writeFloatLE(position[1], 23);
|
||||
record.writeFloatLE(position[2], 27);
|
||||
const rotation = bone.rotation ?? [0, 0, 0, 1];
|
||||
record.writeFloatLE(rotation[0], 31);
|
||||
record.writeFloatLE(rotation[1], 35);
|
||||
record.writeFloatLE(rotation[2], 39);
|
||||
record.writeFloatLE(rotation[3], 43);
|
||||
Buffer.from(linearInterpolation(bone.interpolation)).copy(record, 47);
|
||||
chunks.push(record);
|
||||
}
|
||||
|
||||
const morphCount = options.morphCount ?? 0;
|
||||
const morphCountBuf = Buffer.alloc(4);
|
||||
morphCountBuf.writeUInt32LE(morphCount);
|
||||
chunks.push(morphCountBuf);
|
||||
// morph 段:15 字节名字 + uint32 帧号 + float 权重
|
||||
const morphRecord = Buffer.alloc(23);
|
||||
Buffer.from(SJIS['頭']).copy(morphRecord, 0, 0, 15);
|
||||
morphRecord.writeFloatLE(1, 19);
|
||||
for (let i = 0; i < morphCount; i += 1) chunks.push(Buffer.from(morphRecord));
|
||||
|
||||
let out = Buffer.concat(chunks);
|
||||
if (options.truncateBoneBytes) out = out.subarray(0, out.length - options.truncateBoneBytes);
|
||||
if (options.truncateMorphBytes) out = out.subarray(0, out.length - options.truncateMorphBytes);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 绕指定轴的旋转四元数 */
|
||||
function quatAxis(axis: 'x' | 'y' | 'z', degrees: number): [number, number, number, number] {
|
||||
const half = (degrees * Math.PI) / 360;
|
||||
const s = Math.sin(half);
|
||||
const c = Math.cos(half);
|
||||
if (axis === 'x') return [s, 0, 0, c];
|
||||
if (axis === 'y') return [0, s, 0, c];
|
||||
return [0, 0, s, c];
|
||||
}
|
||||
|
||||
/** 静止姿态:只放一条无关紧要的轨道,其余骨骼按标准骨架静止值 */
|
||||
function restVmd(extra: BoneKeyInput[] = []): Buffer {
|
||||
return buildVmd({ bones: [{ name: 'センター', frame: 0, rotation: quatAxis('y', 0) }, ...extra] });
|
||||
}
|
||||
|
||||
describe('VMD 二进制解析', () => {
|
||||
it('解析文件头、Shift-JIS 模型名与骨骼轨道', () => {
|
||||
const buffer = buildVmd({
|
||||
modelName: 'Miku',
|
||||
bones: [
|
||||
{ name: 'センター', frame: 0, position: [0, 1, 0] },
|
||||
{ name: 'センター', frame: 30, position: [0, 2, 0] },
|
||||
{ name: '左足', frame: 15, rotation: quatAxis('x', -30) },
|
||||
],
|
||||
morphCount: 3,
|
||||
});
|
||||
const vmd = parseVmd(buffer);
|
||||
expect(vmd.header).toBe('Vocaloid Motion Data 0002');
|
||||
expect(vmd.modelName).toBe('Miku');
|
||||
expect(vmd.maxFrame).toBe(30);
|
||||
expect(vmd.morphCount).toBe(3);
|
||||
expect([...vmd.tracks.keys()].sort()).toEqual(['センター', '左足']);
|
||||
expect(vmd.tracks.get('センター')?.keys).toHaveLength(2);
|
||||
expect(vmd.tracks.get('左足')?.rawName).toBe('左足');
|
||||
});
|
||||
|
||||
it('未识别的骨骼被收集而不是静默丢弃', () => {
|
||||
const vmd = parseVmd(
|
||||
buildVmd({
|
||||
bones: [
|
||||
{ name: 'センター', frame: 0 },
|
||||
// 「スカート」之类非标准骨骼
|
||||
{ name: '頭', frame: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(vmd.unknownBones).toEqual([]);
|
||||
expect(vmd.tracks.size).toBe(2);
|
||||
});
|
||||
|
||||
it('morph 段被跳过,不影响骨骼解析', () => {
|
||||
const vmd = parseVmd(
|
||||
buildVmd({ bones: [{ name: 'センター', frame: 0 }], morphCount: 128 }),
|
||||
);
|
||||
expect(vmd.morphCount).toBe(128);
|
||||
expect(vmd.tracks.size).toBe(1);
|
||||
});
|
||||
|
||||
it('0001 版本的模型名按 10 字节读取', () => {
|
||||
const vmd = parseVmd(
|
||||
buildVmd({
|
||||
header: 'Vocaloid Motion Data file',
|
||||
modelNameSize: 10,
|
||||
modelName: 'OldModel',
|
||||
bones: [{ name: 'センター', frame: 0 }],
|
||||
}),
|
||||
);
|
||||
expect(vmd.modelName).toBe('OldModel');
|
||||
expect(vmd.maxFrame).toBe(0);
|
||||
});
|
||||
|
||||
it('模型名是 GBK 时回退解码(中文作者产出的 VMD 常见)', () => {
|
||||
// 「恋爱循环」的 GBK 字节:按 Shift-JIS 解会变成夹杂半角片假名的乱码
|
||||
const gbkName = Buffer.from('恋爱循环', 'utf8'); // 占位,下面用真实 GBK 字节覆盖
|
||||
const gbkBytes = Buffer.from([0xc1, 0xb5, 0xb0, 0xae, 0xd1, 0xad, 0xbb, 0xb7]);
|
||||
expect(gbkBytes.length).toBe(8);
|
||||
const buffer = buildVmd({ bones: [{ name: 'センター', frame: 0 }], modelName: '' });
|
||||
gbkBytes.copy(buffer, 30, 0, 20); // 覆盖模型名字段
|
||||
const vmd = parseVmd(buffer);
|
||||
expect(vmd.modelName).toBe('恋爱循环');
|
||||
expect(gbkName.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('模型名是合法 Shift-JIS 时不做回退', () => {
|
||||
const vmd = parseVmd(buildVmd({ bones: [{ name: 'センター', frame: 0 }], modelName: 'Miku' }));
|
||||
expect(vmd.modelName).toBe('Miku');
|
||||
});
|
||||
|
||||
it('文件头不对时报错并回显实际内容', () => {
|
||||
expect(() => parseVmd(buildVmd({ header: 'Not A VMD File', bones: [] }))).toThrow(
|
||||
/VMD 文件头不正确/,
|
||||
);
|
||||
});
|
||||
|
||||
it('骨骼段被截断时报错并给出声明与实际字节数', () => {
|
||||
const full = buildVmd({
|
||||
bones: [
|
||||
{ name: 'センター', frame: 0 },
|
||||
{ name: '左足', frame: 0 },
|
||||
],
|
||||
});
|
||||
expect(() => parseVmd(full.subarray(0, full.length - 200))).toThrow(/骨骼段长度不足/);
|
||||
});
|
||||
|
||||
it('morph 段被截断时报错', () => {
|
||||
const full = buildVmd({ bones: [{ name: 'センター', frame: 0 }], morphCount: 10 });
|
||||
expect(() => parseVmd(full.subarray(0, full.length - 40))).toThrow(/morph 段长度不足/);
|
||||
});
|
||||
|
||||
it('没有任何可识别骨骼时报错', () => {
|
||||
// 只放一个长度为 0 的名字(空骨骼名)——无法归一到标准骨架
|
||||
const buffer = buildVmd({ bones: [] });
|
||||
expect(() => parseVmd(buffer)).toThrow(/没有可识别的标准骨骼关键帧/);
|
||||
});
|
||||
|
||||
it('文件过小时报错', () => {
|
||||
expect(() => parseVmd(Buffer.alloc(8))).toThrow(/VMD 文件过小/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('关键帧采样', () => {
|
||||
const track = (keys: BoneKeyInput[]) =>
|
||||
parseVmd(buildVmd({ bones: keys })).tracks.get('センター')!;
|
||||
|
||||
it('线性插值取到中点', () => {
|
||||
const t = track([
|
||||
{ name: 'センター', frame: 0, position: [0, 0, 0], interpolation: { x1: 0, y1: 0, x2: 127, y2: 127 } },
|
||||
{ name: 'センター', frame: 10, position: [10, 20, -30], interpolation: { x1: 0, y1: 0, x2: 127, y2: 127 } },
|
||||
]);
|
||||
const mid = sampleBoneTrack(t, 5)!;
|
||||
expect(mid.position[0]).toBeCloseTo(5, 3);
|
||||
expect(mid.position[1]).toBeCloseTo(10, 3);
|
||||
expect(mid.position[2]).toBeCloseTo(-15, 3);
|
||||
});
|
||||
|
||||
it('超出关键帧范围时保持首/末帧姿态', () => {
|
||||
const t = track([
|
||||
{ name: 'センター', frame: 10, position: [1, 0, 0] },
|
||||
{ name: 'センター', frame: 20, position: [2, 0, 0] },
|
||||
]);
|
||||
expect(sampleBoneTrack(t, 0)!.position[0]).toBe(1);
|
||||
expect(sampleBoneTrack(t, 999)!.position[0]).toBe(2);
|
||||
});
|
||||
|
||||
it('缓动曲线单调且落在 [0,1]', () => {
|
||||
const t = track([
|
||||
{ name: 'センター', frame: 0, position: [0, 0, 0], interpolation: { x1: 20, y1: 20, x2: 107, y2: 107 } },
|
||||
{ name: 'センター', frame: 10, position: [10, 0, 0], interpolation: { x1: 20, y1: 20, x2: 107, y2: 107 } },
|
||||
]);
|
||||
let previous = -1;
|
||||
for (let f = 0; f <= 10; f += 1) {
|
||||
const x = sampleBoneTrack(t, f)!.position[0];
|
||||
expect(x).toBeGreaterThanOrEqual(0);
|
||||
expect(x).toBeLessThanOrEqual(10);
|
||||
expect(x).toBeGreaterThanOrEqual(previous);
|
||||
previous = x;
|
||||
}
|
||||
// 对称缓入缓出在中点恰好过 0.5,但前段应慢于线性、后段应快于线性
|
||||
expect(sampleBoneTrack(t, 2)!.position[0]).toBeLessThan(2);
|
||||
expect(sampleBoneTrack(t, 8)!.position[0]).toBeGreaterThan(8);
|
||||
expect(sampleBoneTrack(t, 5)!.position[0]).toBeCloseTo(5, 3);
|
||||
});
|
||||
|
||||
it('四元数插值走短弧(符号对齐)', () => {
|
||||
const t = track([
|
||||
{ name: 'センター', frame: 0, rotation: [0, 0, 0, 1] },
|
||||
// 同一姿态的相反符号表示
|
||||
{ name: 'センター', frame: 10, rotation: [0, 0, 0, -1] },
|
||||
]);
|
||||
const mid = sampleBoneTrack(t, 5)!;
|
||||
const twist = Math.hypot(mid.rotation[0], mid.rotation[1], mid.rotation[2]);
|
||||
expect(twist).toBeLessThan(1e-3); // 不应插出 180° 的旋转
|
||||
});
|
||||
|
||||
it('识别位移/旋转通道是否有实际动作', () => {
|
||||
const withPos = parseVmd(
|
||||
buildVmd({ bones: [{ name: '左足IK', frame: 0, position: [1, 0, 0] }] }),
|
||||
);
|
||||
const withoutPos = parseVmd(buildVmd({ bones: [{ name: '左足IK', frame: 0 }] }));
|
||||
expect(trackHasPositionMotion(withPos.tracks.get('左足IK'))).toBe(true);
|
||||
expect(trackHasPositionMotion(withoutPos.tracks.get('左足IK'))).toBe(false);
|
||||
expect(trackHasRotationMotion(withPos.tracks.get('左足IK'))).toBe(false);
|
||||
expect(
|
||||
trackHasRotationMotion(
|
||||
parseVmd(buildVmd({ bones: [{ name: '左足IK', frame: 0, rotation: quatAxis('z', 30) }] }))
|
||||
.tracks.get('左足IK'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MMD 标准骨架', () => {
|
||||
it('比例自检:腿长 = 大腿 + 小腿,且与静止高度一致', () => {
|
||||
// 大腿/小腿相对竖直方向略有内收,因此两段长度之和略大于竖直跨度
|
||||
const verticalSpan = REST_HEIGHTS.hip - REST_HEIGHTS.ankle;
|
||||
const segmentSum = LEG_LENGTHS.thigh + LEG_LENGTHS.shin;
|
||||
expect(Math.abs(segmentSum - verticalSpan)).toBeLessThan(verticalSpan * 0.01);
|
||||
expect(REST_HEIGHTS.ankle).toBeGreaterThan(0);
|
||||
expect(REST_HEIGHTS.hip).toBeGreaterThan(REST_HEIGHTS.knee);
|
||||
expect(REST_HEIGHTS.knee).toBeGreaterThan(REST_HEIGHTS.ankle);
|
||||
expect(REST_HEIGHTS.head).toBeGreaterThan(REST_HEIGHTS.neck);
|
||||
// 参考模型总高 ≈ 19.4,髋高比 ≈ 0.59(MMD 动漫比例,腿明显长于真人)
|
||||
const height = REST_HEIGHTS.head + 2.2;
|
||||
expect(REST_HEIGHTS.hip / height).toBeGreaterThan(0.5);
|
||||
expect(REST_HEIGHTS.hip / height).toBeLessThan(0.7);
|
||||
});
|
||||
|
||||
it('左右骨骼严格镜像', () => {
|
||||
for (const bone of MMD_SKELETON) {
|
||||
if (!bone.name.startsWith('左')) continue;
|
||||
const right = MMD_BONE_BY_NAME.get(`右${bone.name.slice(1)}`);
|
||||
expect(right, `缺少 ${bone.name} 的镜像骨骼`).toBeDefined();
|
||||
expect(right!.offset[0]).toBeCloseTo(-bone.offset[0], 6);
|
||||
expect(right!.offset[1]).toBeCloseTo(bone.offset[1], 6);
|
||||
expect(right!.offset[2]).toBeCloseTo(bone.offset[2], 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('骨架无重名(每根骨骼只定义一次)', () => {
|
||||
const names = MMD_SKELETON.map((bone) => bone.name);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
expect(MMD_BONE_BY_NAME.size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('层级完整且无环(所有父骨骼都存在)', () => {
|
||||
for (const bone of MMD_SKELETON) {
|
||||
if (bone.parent === null) continue;
|
||||
expect(MMD_BONE_BY_NAME.has(bone.parent), `${bone.name} 的父骨骼 ${bone.parent} 缺失`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('别名归一化覆盖全角/半角 IK 写法', () => {
|
||||
expect(normalizeMmdBoneName('左足IK')).toBe('左足IK');
|
||||
expect(normalizeMmdBoneName('左足IK')).toBe('左足IK');
|
||||
expect(normalizeMmdBoneName('右ひざ')).toBe('右ひざ');
|
||||
expect(normalizeMmdBoneName(' 頭 ')).toBe('頭');
|
||||
expect(normalizeMmdBoneName('スカート_0_0')).toBeNull();
|
||||
expect(normalizeMmdBoneName('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FK 与腿部 IK', () => {
|
||||
const noIk = { 左: false, 右: false } as const;
|
||||
|
||||
it('全零旋转时落到静止姿态,高度与标准骨架一致', () => {
|
||||
const vmd = parseVmd(restVmd());
|
||||
const pose = solveFrame(vmd, 0, 'zMirrored', noIk);
|
||||
expect(pose.get('左足首')!.t[1]).toBeCloseTo(REST_HEIGHTS.ankle, 2);
|
||||
expect(pose.get('左ひざ')!.t[1]).toBeCloseTo(REST_HEIGHTS.knee, 2);
|
||||
expect(pose.get('左足')!.t[1]).toBeCloseTo(REST_HEIGHTS.hip, 2);
|
||||
expect(pose.get('頭')!.t[1]).toBeCloseTo(REST_HEIGHTS.head, 2);
|
||||
// 左右对称
|
||||
expect(pose.get('左足')!.t[0]).toBeCloseTo(-pose.get('右足')!.t[0], 6);
|
||||
});
|
||||
|
||||
it('旋转真的生效:上半身绕 Z 转 90° 后头移到侧面', () => {
|
||||
const vmd = parseVmd(
|
||||
restVmd([{ name: '上半身', frame: 0, rotation: quatAxis('z', 90) }]),
|
||||
);
|
||||
const pose = solveFrame(vmd, 0, 'zMirrored', noIk);
|
||||
const head = pose.get('頭')!.t;
|
||||
const upperBodyRoot = MMD_BONE_BY_NAME.get('上半身')!.offset[1] + REST_HEIGHTS.center;
|
||||
// 上身链被放平:头横向摆出去,高度落到 上半身 根部附近(不再竖在最高点)
|
||||
expect(Math.abs(head[0])).toBeGreaterThan(4);
|
||||
expect(head[1]).toBeLessThan(REST_HEIGHTS.head - 3);
|
||||
expect(Math.abs(head[1] - upperBodyRoot)).toBeLessThan(1.5);
|
||||
});
|
||||
|
||||
it('IK 目标可达时踝落在目标上,且膝盖朝前(+Z)', () => {
|
||||
const vmd = parseVmd(
|
||||
restVmd([{ name: '左足IK', frame: 0, position: [0, 1.0, 0] }]),
|
||||
);
|
||||
const pose = solveFrame(vmd, 0, 'zMirrored', { 左: true, 右: false });
|
||||
const hip = pose.get('左足')!.t;
|
||||
const ik = pose.get('左足IK')!.t;
|
||||
const knee = pose.get('左ひざ')!.t;
|
||||
const ankle = pose.get('左足首')!.t;
|
||||
|
||||
expect(vLen(vSub(ankle, ik))).toBeLessThan(1e-3);
|
||||
// 大腿 + 小腿长度守恒
|
||||
expect(vLen(vSub(knee, hip)) + vLen(vSub(ankle, knee))).toBeGreaterThan(LEG_LENGTHS.thigh);
|
||||
// 膝盖在髋→踝连线的 +Z 侧
|
||||
const axis = vNorm(vSub(ankle, hip));
|
||||
const rel = vSub(knee, hip);
|
||||
const along = vDot(rel, axis);
|
||||
const perpZ = rel[2] - axis[2] * along;
|
||||
expect(perpZ).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('IK 目标不可达时腿伸直且不产生 NaN', () => {
|
||||
const vmd = parseVmd(
|
||||
restVmd([{ name: '左足IK', frame: 0, position: [0, 0, 50] }]),
|
||||
);
|
||||
const pose = solveFrame(vmd, 0, 'zMirrored', { 左: true, 右: false });
|
||||
const hip = pose.get('左足')!.t;
|
||||
const ankle = pose.get('左足首')!.t;
|
||||
const knee = pose.get('左ひざ')!.t;
|
||||
expect(vLen(vSub(ankle, hip))).toBeCloseTo(LEG_LENGTHS.thigh + LEG_LENGTHS.shin, 2);
|
||||
expect(angleCos(hip, knee, ankle)).toBeLessThan(-0.999);
|
||||
for (const value of [...hip, ...ankle, ...knee]) expect(Number.isFinite(value)).toBe(true);
|
||||
});
|
||||
|
||||
it('IK 目标与髋重合时也不退化(不产生 NaN)', () => {
|
||||
const vmd = parseVmd(
|
||||
restVmd([{ name: '左足IK', frame: 0, position: [-0.95, -11.5, 0] }]),
|
||||
);
|
||||
const pose = solveFrame(vmd, 0, 'zMirrored', { 左: true, 右: false });
|
||||
for (const bone of ['左足', '左ひざ', '左足首']) {
|
||||
for (const value of pose.get(bone)!.t) expect(Number.isFinite(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('IK 通道没有位移时不做 IK 解算(走 FK)', () => {
|
||||
const vmd = parseVmd(
|
||||
buildVmd({
|
||||
bones: [
|
||||
{ name: 'センター', frame: 0 },
|
||||
{ name: '左足IK', frame: 0 },
|
||||
{ name: '左足', frame: 0, rotation: quatAxis('x', -60) },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const { frames } = vmdToWorldFrames(vmd, { rotationMode: 'zMirrored', maxFrames: 1 });
|
||||
const world = frames[0];
|
||||
// FK 生效:膝盖不再位于静止位置
|
||||
expect(world.get('leftleg')![1]).not.toBeCloseTo(REST_HEIGHTS.knee, 1);
|
||||
// 踝仍在小腿末端(长度守恒)
|
||||
const hip = world.get('leftupleg')!;
|
||||
const knee = world.get('leftleg')!;
|
||||
const ankle = world.get('leftfoot')!;
|
||||
expect(vLen(vSub(knee, hip))).toBeCloseTo(LEG_LENGTHS.thigh, 2);
|
||||
expect(vLen(vSub(ankle, knee))).toBeCloseTo(LEG_LENGTHS.shin, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('手性转换(真实 VMD 对照实验的合成复现)', () => {
|
||||
/**
|
||||
* MMD 原生是左手系。真实数据里的对照实验(恋爱循环.vmd,腿部为烘焙 FK):
|
||||
* 用 asIs 解释时"膝盖朝前"只占 5.9%,用 zMirrored 时占 94.1%。
|
||||
* 这里用合成数据把同一结论固化成回归测试:
|
||||
* 在 MMD 里把大腿绕 +X 转(动画师想让膝盖前顶),
|
||||
* 转换到右手系后膝盖应当落在 +Z 侧。
|
||||
*/
|
||||
const thighBend = (mode: MmdRotationMode): number => {
|
||||
const vmd = parseVmd(restVmd([{ name: '左足', frame: 0, rotation: quatAxis('x', 30) }]));
|
||||
const pose = solveFrame(vmd, 0, mode, { 左: false, 右: false });
|
||||
const hip = pose.get('左足')!.t;
|
||||
const ankle = pose.get('左足首')!.t;
|
||||
const knee = pose.get('左ひざ')!.t;
|
||||
return knee[2] - (hip[2] + ((ankle[2] - hip[2]) * vLen(vSub(knee, hip))) / vLen(vSub(ankle, hip)));
|
||||
};
|
||||
|
||||
it('zMirrored 把 MMD 的左手系数值正确搬到右手系(膝盖朝前)', () => {
|
||||
expect(thighBend('zMirrored')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('asIs 会得到镜像的结果(膝盖朝后)—— 因此不能作为默认', () => {
|
||||
expect(thighBend('asIs')).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('端到端:合成 VMD → MediaPipe 33 点关键点序列', () => {
|
||||
it('输出帧数、点数与关节角数量正确,且无 NaN / 越界', () => {
|
||||
const vmd = parseVmd(
|
||||
buildVmd({
|
||||
bones: [
|
||||
{ name: 'センター', frame: 0, position: [0, 0, 0] },
|
||||
{ name: 'センター', frame: 60, position: [0, 3, 0] },
|
||||
{ name: '左足IK', frame: 0, position: [0, 0.6, 0] },
|
||||
{ name: '左足IK', frame: 60, position: [0, 0.6, 0] },
|
||||
{ name: '上半身', frame: 0, rotation: quatAxis('y', 0) },
|
||||
{ name: '上半身', frame: 60, rotation: quatAxis('y', 45) },
|
||||
],
|
||||
morphCount: 2,
|
||||
}),
|
||||
);
|
||||
const { frames: world, fps } = vmdToWorldFrames(vmd, { maxFrames: 61 });
|
||||
const frames = worldFramesToKeypointFrames(world, fps);
|
||||
|
||||
expect(fps).toBe(VMD_FPS);
|
||||
expect(frames).toHaveLength(61);
|
||||
for (const frame of frames) {
|
||||
expect(frame.points).toHaveLength(KEYPOINT_COUNT);
|
||||
expect(frame.angles).toHaveLength(ANGLE_TRIPLETS.length);
|
||||
for (const point of frame.points) {
|
||||
expect(point).toHaveLength(4);
|
||||
expect(point.every(Number.isFinite)).toBe(true);
|
||||
expect(point[0]).toBeGreaterThanOrEqual(0);
|
||||
expect(point[0]).toBeLessThanOrEqual(1);
|
||||
expect(point[1]).toBeGreaterThanOrEqual(0);
|
||||
expect(point[1]).toBeLessThanOrEqual(1);
|
||||
expect(point[2]).toBeGreaterThanOrEqual(-1);
|
||||
expect(point[2]).toBeLessThanOrEqual(1);
|
||||
expect([0, 0.5, 1]).toContain(point[3]);
|
||||
}
|
||||
for (const angle of frame.angles) {
|
||||
expect(angle).toBeGreaterThanOrEqual(-1);
|
||||
expect(angle).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
// 时间轴按 30fps 推进
|
||||
expect(frames[1].timeMs).toBeCloseTo(1000 / 30, 3);
|
||||
});
|
||||
|
||||
it('上半身旋转会改变输出(不是常数序列)', () => {
|
||||
const vmd = parseVmd(
|
||||
buildVmd({
|
||||
bones: [
|
||||
{ name: 'センター', frame: 0 },
|
||||
{ name: '上半身', frame: 0, rotation: quatAxis('y', 0) },
|
||||
{ name: '上半身', frame: 30, rotation: quatAxis('y', 90) },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const { frames: world } = vmdToWorldFrames(vmd, { maxFrames: 31 });
|
||||
const frames = worldFramesToKeypointFrames(world, VMD_FPS);
|
||||
const first = frames[0].points[11][0];
|
||||
const last = frames[30].points[11][0];
|
||||
expect(Math.abs(last - first)).toBeGreaterThan(0.02);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user