需求 1:模型上传支持压缩包(MMD 模型包) - common/zip-reader.ts:纯 TS 极简 ZIP 读取器(EOCD/中央目录、STORE+DEFLATE、 ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目) - models/model-convert.ts:解压 → 选主模型(指名字/最浅/最大)→ Assimp 导出 FBX → **assimp info 回读校验**(Assimp 对带蒙皮大骨架模型会写出损坏文件: 实测 714 骨骼 PMX 导出退出码 11、产物回读报 FBX-DOM no Objects dictionary found) → 失败自动回退 GLB;两种都失败则 422 - models/glb-textures.ts:把 Assimp 写成外部 URI 的贴图内嵌进 GLB 的 BIN chunk, 保证模型库里单文件即可加载(否则是白模) - 溯源:压缩包原件存为 model-archive 资产;models 新增 source_asset_id / conversion_note(迁移 1789450000000-AddModelConversionFields,已在真实库上 验证 up/down 可逆) - 默认模型:转换成功且 autoDefault 不为 false → is_primary=1 + published, 并把身份(名称/格式/产物 sha256)写进源码 src/config/default-model.ts; resolveDefaultModelId 增加 DEFAULT_MODEL_ID 与源码种子两步(按 sha256 匹配, 换库仍有效),实测库中有 2 个 VRM 时默认模型仍解析为该 GLB - scripts/import-default-model.mjs:运维 CLI(上传 zip 并写源码 / --write-source-only) - 修复 multipart 中文文件名乱码(upload.util.decodeUploadName) - DTO/契约新增 conversionNote、fileSha256;管理端 accept 增加 .zip 并展示转换来源 需求 2:首次启动 .env 引导 - deploy/init-env.sh:交互式逐项填写 8 个键(回车保留默认、口令不回显不加引号、 端口校验、mysql2 连通性自检且口令只经环境变量传递、只改这 8 个键并保留模板注释、 chmod 600、结尾打码摘要);--force/--check/-y/-i/--skip-db-check 等开关; 非交互(-y、OPENDANCE_NONINTERACTIVE、stdin 关闭)绝不提问、读取带超时 - install.sh 缺 .env 时改为调用引导,失败才 die 需求 3:默认监听 0.0.0.0 - 代码默认值、两份 .env 模板、install.sh、init-env.sh 五处一致,并新增测试守护 (未设置 HOST 时为 0.0.0.0;两份模板都写着 HOST=0.0.0.0) 契约:rest-api / data-models / model-formats → 0.3.0,database → 0.2.0(另两库已同步) 验证:125 项单测通过(7 文件);契约 6 项与 29 条路由一致;typecheck/build 通过; 真实包端到端 HTTP 201 / 0.89s / format=glb / isPrimary=true / published; GLB 1,152,464 字节、1 mesh / 240 nodes / 1 skin(238 joints) / 2 张内嵌贴图; 迁移 revert→run 实测可逆且数据无损恢复
144 lines
8.8 KiB
Markdown
144 lines
8.8 KiB
Markdown
# contracts/model-formats.md — 模型与动作格式契约 v0.3.0
|
||
|
||
## 1. 角色模型(models)
|
||
|
||
| 格式 | 扩展名 | 支持级别 | 说明 |
|
||
|---|---|---|---|
|
||
| VRM | `.vrm` | **首选(P0)** | glTF 2.0 二进制 + VRM 扩展;Windows 端 godot-vrm 直接加载 |
|
||
| glTF Binary | `.glb` | P1 | 需含骨骼(skin);无骨骼则仅作静态展示 |
|
||
| glTF | `.gltf` (+ `.bin`, 贴图) | P2 | 多文件,管理端需打包为 zip 上传 |
|
||
| FBX | `.fbx` | P2 | 需 Assimp 转换,管理端仅归档,不保证重定向质量 |
|
||
| **MMD 压缩包** | `.zip` | P1 | 内含 `.pmx`(+ `textures/`)。服务端自动解压并转换,见 §1.1 |
|
||
|
||
### 1.1 模型压缩包转换(`.zip` → FBX / GLB)
|
||
|
||
上传 `.zip` 时走"解压 → 选主模型 → 转换 → 入库",而不是把压缩包当模型文件存下来。
|
||
|
||
| 步骤 | 规则 |
|
||
|---|---|
|
||
| 选主模型 | 优先 `MODEL_CONVERT_PMX` 指定的文件名(默认 `Hatsune Miku.pmx`,大小写不敏感);否则取目录最浅、其次文件最大者。**只认 `.pmx`**(MMD 的 PMX 模型;PMD/PMX 之外的格式暂不支持) |
|
||
| 转换 | `MODEL_CONVERT_FORMAT`(默认 `fbx`)指定首选格式,调 `ASSIMP_BIN` 导出 |
|
||
| 校验 | **导出后必须回读校验**(`assimp info`)。Assimp 的 FBX 导出器对带蒙皮的大骨架模型会"写出损坏文件"(实测 714 骨骼的 PMX:导出退出码 11,产物回读报 `FBX-DOM no Objects dictionary found`),只信退出码会把坏文件入库 |
|
||
| 回退 | FBX 失败(导出失败或回读失败)→ 自动改用 **GLB**,并把两次尝试写进 `models.conversion_note` |
|
||
| 贴图 | GLB 产物里 Assimp 会把贴图写成外部 URI(还带 `\` 反斜杠),服务端把压缩包内的贴图**内嵌**进 GLB 的二进制块,保证单文件可加载 |
|
||
| 溯源 | 压缩包原件以 `model-archive` 资产留存,写入 `models.source_asset_id` |
|
||
| 默认模型 | 转换成功且 `autoDefault` 不为 false:`is_primary=1` + `status=published`,并把身份写进源码 `web-server/src/config/default-model.ts`,见 `data-models.md` §4.1 |
|
||
| 失败 | 压缩包里没有 `.pmx`、Assimp 不可用、两种格式都失败 → **HTTP 422**,不产生模型记录 |
|
||
|
||
安全:解压前逐条校验路径,拒绝绝对路径与 `..`(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 帧号、
|
||
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. 上传限制
|
||
|
||
| 项 | 值 |
|
||
|---|---|
|
||
| 单文件上限 | 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 预览
|