Files
opendance-web/contracts/model-formats.md
T
zijunchiang ccced34dd6 fix(models): 默认模型改用「压缩包 sha256」匹配(转换产物非字节级确定)
实测发现:同一份 zip 连续转换三次得到三个不同的产物 sha256(大小都是 1,152,464
字节,差异集中在 BIN chunk 的十几字节),说明 Assimp 的 GLB 导出不是字节级确定的。
因此原先把「转换产物 sha256」当作默认模型匹配键的设计是错的——换一次转换就失效。

- default-model.ts / default-model-source.ts:种子拆成 sourceSha256(上传压缩包的
  sha256,稳定,主匹配键)与 fileSha256(产物 sha256,仅作记录)
- models.service.ts:resolveDefaultModelId 依次按 压缩包 sha256 → 产物 sha256 →
  名称 匹配,都落空才回落到 VRM 优先规则并告警
- ModelDto 增加 sourceSha256(非压缩包上传为 null)
- scripts/import-default-model.mjs:写种子时用 sourceSha256;顺带修掉位置参数解析
  (文件当第一个参数时识别不出,会打印用法)
- 契约与文档同步:data-models §4.1、model-formats §1.1 记录该确定性结论;
  README / DSH / tasks/005 的「按产物 sha256 匹配」表述已更正

另附:把模型格式能力的实测矩阵写进 Release 说明(原生只支持 glTF 系;
PMX→FBX 失败;VRM→GLB 丢 VRM 扩展;humanoid 骨骼命名覆盖率)。

验证:125 项单测通过;清理掉两个"文件只在本机临时目录"的模型(避免 sha256 去重
导致后续上传不落盘);种子按压缩包 sha256 命中后 defaultModelId 正确解析。
2026-09-15 19:36:37 +08:00

9.1 KiB
Raw Blame History

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
确定性 转换产物不是字节级确定的:同一份 zip 连续转换三次得到三个不同的 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以压缩包 sha256 为匹配键,产物 sha256 只作记录
失败 压缩包里没有 .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
  • 一行结构:
{"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 预览