- CANDIDATES 由服务端表生成写入(与 model-inspector.ts 逐字一致,parity 测试断言) - normalize() 保留 CJK/假名并把全角 ASCII 折半角,修复 MMD 骨名归一化成空串导致 humanoid 覆盖率恒为 0/22 的问题 - contracts/ 同步至 0.5.0 / 0.2.0
203 lines
12 KiB
Markdown
203 lines
12 KiB
Markdown
# contracts/model-formats.md - 模型与动作格式契约 v0.5.0
|
|
|
|
## 1. 角色模型(models)
|
|
|
|
**v0.5.0 起模型主力格式为 VRM**: **所有上传的模型都转换为 VRM**(`MODEL_CONVERT_FORMAT`
|
|
默认 `vrm`);GLB 退为**转换中间产物**与 **humanoid 不达标时的回退交付**。
|
|
两个客户端(Godot `godot-vrm` / 管理端 `@pixiv/three-vrm`)都原生加载 VRM。
|
|
|
|
| 格式 | 扩展名 | 支持级别 | 服务端行为 |
|
|
|--|--|--|--|
|
|
| VRM | `.vrm` | **主力(P0)** | **直接导入**; = GLB + VRM 扩展(humanoid / 表情 / springBone / meta) |
|
|
| glTF Binary | `.glb` | 输入(P0) | 导入后**包装为 VRM**(含 `VRMC_vrm`);humanoid 不齐时保留 GLB 并标记 `usable=false` |
|
|
| glTF | `.gltf` (+ `.bin`, 贴图) | P1 | 直接导入(分离文件在 zip 内可用;单文件上传会缺.bin) |
|
|
| MMD 压缩包 | `.zip` | P1 | **自动搜索包内可用模型**:优先直接导入 `.vrm`/`.glb`/`.gltf`,否则转换 `.pmx`/`.fbx`,见 1.1 |
|
|
| MMD 模型 | `.pmx` | P2(输入) | 服务端转换:`assimp -> GLB -> VRM`;单文件上传无贴图 |
|
|
| FBX | `.fbx` | P2(输入) | 服务端转换:`assimp -> GLB -> VRM` |
|
|
|
|
### 1.1 上传与转换策略
|
|
|
|
**v0.5.0 起的一律转换链路**:
|
|
|
|
```
|
|
.vrm -> 直接导入(已是 VRM)
|
|
.glb / .gltf -> 包装为 VRM 1.0(写 VRMC_vrm)
|
|
.pmx / .fbx -> assimp -> GLB -> 包装为 VRM 1.0
|
|
.zip -> 自动搜索包内模型,按上面三种处理
|
|
```
|
|
|
|
**humanoid 门槛(硬性)**: VRM 1.0 要求 humanoid 必需骨骼齐全。服务端判定规则:
|
|
|
|
| 情况 | 交付 | `models.usable` | 是否进入客户端目录 |
|
|
|--|--|--|--|
|
|
| humanoid 必需骨齐全 | **VRM**(`format=vrm`, `vrmVersion=1.0`) | `true` | ✅ 可发布、可选用 |
|
|
| humanoid 不齐(如 MMD 日文骨名未重绑定) | 回退交付 **GLB**, `vrmVersion=null`, `conversionNote` 注明原因 | `false` | ❌ 不进入 `catalog/export` |
|
|
|
|
即:**不达标模型仍可入库与审核,但标记为不可用、不进入已发布目录**;
|
|
待骨骼按 `skeleton-topology.md` 的标准重绑定成功后重新转换即变为可用。
|
|
|
|
**`.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 预览
|