Files

182 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# contracts/model-formats.md - 模型与动作格式契约 v0.4.0
## 1. 角色模型(models)
**系统以 GLB / VRM 为主力格式**(两个客户端都原生加载),其余格式一律在服务端转换。
| 格式 | 扩展名 | 支持级别 | 服务端行为 |
|--|--|--|--|
| glTF Binary | `.glb` | **主力(P0)** | **直接导入**,不重编码 |
| VRM | `.vrm` | **主力(P0)** | **直接导入**;= GLB + VRM 扩展(humanoid / 表情 / springBone / meta) |
| glTF | `.gltf` (+ `.bin`, 贴图) | P1 | 直接导入(分离文件在 zip 内可用;单文件上传会缺.bin) |
| MMD 压缩包 | `.zip` | P1 | **自动搜索包内可用模型**:优先直接导入 `.vrm`/`.glb`/`.gltf`,否则转换 `.pmx`/`.fbx`,见 1.1 |
| MMD 模型 | `.pmx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM);单文件上传无贴图 |
| FBX | `.fbx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM) |
### 1.1 上传与转换策略
**`.glb` / `.vrm` / `.gltf` 直接导入**(不重编码,零损失);**`.pmx` / `.fbx` 由服务端转换**:
**优先 GLB,其次 VRM**。
#### 单文件上传
| 输入 | 行为 |
|--|--|
| `.glb` / `.vrm` | 直接导入;`.glb` 若含 `VRMC_vrm`/`VRM` 扩展会被识别为 VRM |
| `.gltf` | 直接导入(外部 `.bin` / 贴图不在同一文件里,会缺资源) |
| `.pmx` / `.fbx` | 转换:`ASSIMP_BIN` 导出 -> 回读校验(见下) |
#### 压缩包上传(自动搜索)
不再"只找 `.pmx`",而是**按格式优先级自动搜索包内可用模型**:
```
.vrm ->.glb ->.gltf (直接导入,优先)
.pmx ->.fbx (需要转换)
```
同一优先级内:`MODEL_CONVERT_PMX` 指定的文件名优先,其次目录最浅,最后文件最大。
压缩包原件以 `model-archive` 资产留存并写入 `models.source_asset_id`。
#### 转换与校验(PMX / FBX)
| 步骤 | 规则 |
|--|--|
| 1. GLB 直出 | `assimp export ->.glb`,成功后把 Assimp 写成外部 URI 的贴图**内嵌**进 BIN chunk |
| 2. glTF 打包 | 上一步失败时 `assimp export ->.gltf`,由 `gltf-packer.ts` 把 `.gltf` + `.bin` + 贴图自行合成 GLB |
| 3. VRM 封装 | `MODEL_CONVERT_FORMAT=vrm` 时,在 GLB 之上补 `VRMC_vrm` 扩展(humanoid 骨骼来自骨骼名映射,见 `model-inspector.ts` 的候选表,与 Godot 客户端一致);**humanoid 不全则回退交付 GLB**,缺失骨骼写进 `conversion_note` |
| 回读校验 | 每一步产物都要 `assimp info` 回读。Assimp 会"写出损坏文件"(实测 FBX 导出退出码 11、产物回读报 `FBX-DOM no Objects dictionary found`),只看退出码会把坏文件入库 |
| 说明 | 每次尝试都写进 `models.conversion_note`(例如 `Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图`) |
| 失败 | 包内没有可用模型、Assimp 不可用、两条路径都失败 -> **HTTP 422**,不产生模型记录 |
#### 入库后(自动)
| 项 | 说明 |
|--|--|
| 状态 | **一律 `pending`(待审核)**:模型必须审核通过后才可能被客户端消费,见 `database.md` 状态机 |
| 分析 | `model-inspector.ts` 计算面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,写入 `models.stats`,供审核界面展示 |
| 缩略图 | `model-thumbnail.ts` 纯 TS 光栅化生成 PNG,写入 `models.thumbnail_asset_id`(失败不影响入库) |
| 默认模型 | 只在**审核通过并勾选"同时设为默认"**(或 `POST /api/models/:id/primary`)时设置,并把身份写进源码 `src/config/default-model.ts`,见 `data-models.md` 4.1 |
#### 其它约定
| 项 | 规则 |
|--|--|
| 确定性 | **转换产物不是字节级确定的**:同一份 zip 连续转换 3 次得到 3 个不同 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以**上传压缩包的 sha256** 为匹配键,产物 sha256 只作记录 |
| 安全 | 解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE |
**VRM 优先**的选取顺序见 `data-models.md` 4.1。
VRM 版本识别:读取 GLB 的 JSON chunk,检查 `extensions.VRM`(0.x)或
`extensions.VRMC_vrm`(1.0),写入 `models.vrm_version`。
## 2. 动作数据(motions)
| 格式 | 扩展名 | 预处理支持 | 说明 |
|--|--|--|--|
| 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` 结构,仅做校验与归一化 |
### 2.1 预处理产物(冻结)
- 格式:NDJSON(`application/x-ndjson`),每行一帧,UTF-8 无 BOM
- 一行结构:
```json
{"frame":0,"timeMs":0.0,"points":[[0.5,0.5,0.0,1.0],"...33 项"],"angles":[1.0,"...9 项"]}
```
- `points[i] = [x, y, z, confidence]`,语义与 `websocket-keypoints.md` 解码后完全一致
- `angles` 为 `skeleton-topology.md` 5 定义的 9 个关节角余弦,顺序固定:
`[shoulder_L, shoulder_R, elbow_L, elbow_R, hip_L, hip_R, knee_L, knee_R, spine]`
- 帧率:保留源帧率;`fps` 写入 `motions.fps`,缺省 30
- 坐标系换算:所有 3D 动作数据统一转到 **Y 轴向上、右手系、骨盆为原点**,
再投影到 MediaPipe 归一化图像空间:
`x_img = 0.5 + x_world * scale`,`y_img = 0.5 - y_world * scale`,`z_img = -z_world * scale`,
`scale = 0.35 / max(|x|,|y|,|z|)`(按整段动作的最大范围自适应,保证不出画)
- 缺失关节(BVH 中不存在对应骨骼):`confidence = 0.0`,坐标沿用最近可用父节点
### 2.2 BVH 关节名映射(默认表,可在 `web-server/src/preprocess/joint-map.ts` 扩展)
| MediaPipe 点 | 常见 BVH 关节名(不区分大小写,按序匹配) |
|--|--|
| 0 nose | `Head`, `Neck1`, `Head_Nub` |
| 11 left_shoulder | `LeftShoulder`, `LeftArm`, `LShoulder` |
| 12 right_shoulder | `RightShoulder`, `RightArm`, `RShoulder` |
| 13 left_elbow | `LeftForeArm`, `LeftElbow`, `LElbow` |
| 14 right_elbow | `RightForeArm`, `RightElbow`, `RElbow` |
| 15 left_wrist | `LeftHand`, `LeftWrist`, `LHand` |
| 16 right_wrist | `RightHand`, `RightWrist`, `RHand` |
| 23 left_hip | `LeftUpLeg`, `LeftHip`, `LHipJoint` |
| 24 right_hip | `RightUpLeg`, `RightHip`, `RHipJoint` |
| 25 left_knee | `LeftLeg`, `LeftKnee`, `LKnee` |
| 26 right_knee | `RightLeg`, `RightKnee`, `RKnee` |
| 27 left_ankle | `LeftFoot`, `LeftAnkle`, `LAnkle` |
| 28 right_ankle | `RightFoot`, `RightAnkle`, `RAnkle` |
| 31 left_foot_index | `LeftToeBase`, `LeftToe`, `LToe` |
| 32 right_foot_index | `RightToeBase`, `RightToe`, `RToe` |
未映射到的点按 `skeleton-topology.md` 5 的层级由父节点推算,
并置 `confidence` 降低(`0.5`),表示估算而非实测。
### 2.3 VMD(MikuMikuDance)解析约定
VMD 是二进制格式:文件头 `Vocaloid Motion Data 0002`(30 字节)+ 模型名(20 字节,
Shift-JIS)+ 骨骼关键帧数组(每条 **111 字节**:15 字节骨骼名、uint32 帧号、
float32x3 位移、float32x4 旋转四元数、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. 上传限制
| 项 | 值 |
|--|--|
| 单文件上限 | 512 MiB(`UPLOAD_MAX_BYTES` 可覆盖) |
| 允许扩展名 | 上表全部;其他一律 422 |
| 存储路径 | `${STORAGE_DIR}/{kind}/{yyyy}/{mm}/{sha256前2位}/{sha256}.{ext}` |
| 去重 | 相同 sha256 复用同一 `assets` 记录,不重复落盘 |
| 文件名 | 服务端不使用原始文件名做路径,只存 `assets.original_name` |
## 4. 开源依赖与许可证(引用 `open-source-credits.md`)
- `bvh`(MIT)- BVH 解析参考实现
- `Assimp`(BSD-3-Clause)- FBX/glTF 转换,**可选外部依赖**
- `@pixiv/three-vrm`(MIT)- 管理端 VRM 预览
- `three`(MIT)- 管理端 3D 预览