Files
opendance-web/tasks/005-model-upload.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

7.7 KiB
Raw Blame History

T-WEB-005 模型上传 + VRM 优先 + 来源字段

  • 状态:done
  • 依赖:T-WEB-002

目标

上传角色模型,VRM 优先,识别 VRM 版本,并维护全局默认模型。

load

DSH.md
contracts/rest-api.yaml          # 锚点:#/paths/~1api~1models
contracts/data-models.md         # 锚点:§4 Model、§4.1 默认模型选择规则
contracts/model-formats.md       # 锚点:§1 角色模型、§1.1 模型压缩包转换
web-server/src/models/*
web-server/src/models/vrm-inspector.ts
web-server/src/common/zip-reader.ts
web-server/src/models/model-convert.ts
web-server/src/models/glb-textures.ts
web-server/src/models/default-model-source.ts
web-server/src/config/default-model.ts
web-server/src/migrations/1789450000000-AddModelConversionFields.ts
web-server/scripts/import-default-model.mjs

端点

方法 路径 说明
GET /api/models 分页 + q/format/status/primaryOnly
POST /api/models multipart 上传(file + 名称 + 来源五元组 + isPrimary);file 也接受 .zip(MMD 模型包,自动转换)
GET /api/models/:id 详情
PATCH /api/models/:id 更新
DELETE /api/models/:id 软删除
POST /api/models/:id/primary 设为全局默认模型

默认模型选择规则(contracts/data-models.md §4.1,已实现于 resolveDefaultModelId())

0. 环境变量 DEFAULT_MODEL_ID 指定,且该模型存在且已发布
1. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED(按**上传压缩包**的 sha256 匹配;
   转换产物 sha256 每次不同,只作记录)
2. format='vrm' 且 published 且 is_primary=1
3. format='vrm' 且 published,按 id 升序取第一个
4. published,按 (format='vrm' DESC, id ASC) 取第一个
5. 无 → 客户端使用内置占位模型

POST /:id/primary 在同一事务内先清零其余 is_primary,保证互斥。 第 0/1 步为本次(v0.3.0)新增:上传 MMD 压缩包转换成功后自动设为默认并写源码种子。

压缩包自动转换(.zip → FBX / GLB)

POST /api/models 的 file 接受 .zip,视为 MMD 模型包。链路:

.zip ─▶ readZipEntries() ─▶ pickModelFile() ─▶ Assimp 导出 ─▶ assimp info 回读 ─▶ glb-textures 内嵌
         zip-reader.ts        优先 MODEL_CONVERT_PMX    FBX 优先      失败回退 GLB
环节 实现 规则
ZIP 读取 common/zip-reader.ts 中央目录/EOCD、STORE(0)+DEFLATE(8)、ZIP64 大小字段、Shift-JIS/GBK 文件名回退;拒绝 zip-slip 与加密条目;不支持的压缩方式报错
选主模型 models/model-convert.ts pickModelFile() 优先 MODEL_CONVERT_PMX(默认 Hatsune Miku.pmx,大小写不敏感);否则目录最浅,其次文件最大;只认 .pmx
转换 model-convert.ts MODEL_CONVERT_FORMAT(默认 fbx)→ ASSIMP_BIN;超时 MODEL_CONVERT_TIMEOUT_MS(默认 180000)
回读校验 exportAndValidate() 导出后必须 assimp info 回读;退出码为 0 但产物损坏也判失败
回退 convertModelArchive() FBX 失败 → GLB;两次尝试写入 models.conversion_note
贴图内嵌 models/glb-textures.ts 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐)
默认模型 models.service.ts createFromArchive() autoDefault 默认 true → is_primary=1 + status=published,并写 src/config/default-model.ts
溯源 迁移 AddModelConversionFields1789450000000 压缩包原件存为 model-archive 资产 → models.source_asset_id;说明 → models.conversion_note
失败 createFromArchive() 无 .pmx / Assimp 不可用 / 两种格式都失败 → HTTP 422,不产生模型记录
文件名编码 common/upload.util.ts decodeUploadName() 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 模å_…)
管理端 admin/src/pages/Models.tsx accept 增加 .zip;提示会自动转换并设为默认;预览面板显示「转换来源」与「文件 sha256」
DTO conversionNote / fileSha256 契约 data-models.md §4、rest-api.yaml

实测(Assimp 6.0.4)

  • 上传 示例文件/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip(1,093,100 字节) → HTTP 201、0.89s、format=glb、isPrimary=true、status=published。
  • 服务端日志:FBX 导出失败(退出码 11,产物回读报 FBX-DOM no Objects dictionary found)→ 自动回退 GLB,回读通过。
  • 入库产物:1,152,464 字节、238 关节、2 张贴图已内嵌, conversionNote = Hatsune Miku.pmx → GLB,已内嵌 2 张贴图。
  • 源码种子:id=16、初音未来 Hatsune Miku、glb、 sha256=6d0ab0dce97c43dd7de84aba29b09f3b64f2953722d31b5421a39189ff2a8464、 convertedFrom = Hatsune Miku.pmx → GLB。
  • 默认模型解析:库里有 2 个 VRM 的情况下,GET /api/catalog/export 的 defaultModelId 仍为 16(源码种子按 sha256 命中)。

运维 CLI:web-server/scripts/import-default-model.mjs

服务进程在 systemd 加固(ProtectSystem=strict)下写不了程序目录,源码种子 通常由本脚本在沙箱外补写。需先 npm run build(复用 dist/models/default-model-source.js)。

cd web-server
node scripts/import-default-model.mjs 模型.zip --url http://127.0.0.1:3000 \
  --name "初音未来 Hatsune Miku" --author "Boku Wa Luka" \
  --license "MMD 模型使用条款(见压缩包内说明)" --source-type other
# 上传 → 服务端转换入库 → 设为默认 → 写 src/config/default-model.ts

node scripts/import-default-model.mjs --write-source-only --model-id 16   # 只补写源码
node scripts/import-default-model.mjs --write-source-only --model-id 16 --dry-run

上传时固定带 autoDefault=true;--url 默认 http://127.0.0.1:3000, --source-type 默认 other。

VRM 版本识别

vrm-inspector.ts 直接读取 GLB 头部 JSON chunk,不依赖完整 3D 库:

  • extensions.VRMC_vrm → VRM 1.0(取 specVersion)
  • extensions.VRM → VRM 0.x
  • 两者皆无 → null(普通 GLB)

验收

cd web-server
npm run test          # 单元测试 125 项 / 7 个文件
npm run test:smoke    # 冒烟 [4] 段落
验收项 状态
上传 .zip 自动转换入库 ✅ 实测 HTTP 201、0.89s、format=glb、isPrimary=true、status=published
FBX 失败自动回退 GLB ✅ Assimp 6.0.4 退出码 11 + 回读失败 → 回退 GLB,conversionNote 记录两次尝试
GLB 贴图内嵌 ✅ 2 张贴图内嵌进 BIN chunk(产物 1,152,464 字节、238 关节)
默认模型写入源码并按种子解析 ✅ 种子 id=16;库里有 2 个 VRM 时 defaultModelId 仍为 16
失败语义 ✅ 无 .pmx / Assimp 不可用 / 两格式均失败 → HTTP 422,不产生模型记录
ZIP 安全 ✅ 拒绝 zip-slip、加密条目、未知压缩方式(model-archive.spec.ts)
文件名编码修复 ✅ decodeUploadName() 修正中文文件名乱码
VRM 版本识别 ✅ test/vrm-inspector.spec.ts(6 例)

test/model-archive.spec.ts(22 项)覆盖:ZIP 解析 / zip-slip / 加密 / 未知压缩 方式、主模型挑选规则、GLB 贴图内嵌、用假 Assimp 对照「FBX 导出失败」与 「退出码 0 但产物损坏」两种回退路径、源码渲染 + tsc 语法校验。

test/vrm-inspector.spec.ts(6 例)覆盖:真实 GLB 构造下识别 1.0 / 0.0 / null、 文本 glTF 不误判、过小文件报错、非 JSON 首 chunk 报错、上传 201、isPrimary 置位、缺来源 400、按格式过滤列表、列表含来源信息(管理端展示用)。