Files
opendance-web/tasks/005-model-upload.md
T
opendance da03777995 fix(models): Assimp 缺失时给出可操作报错,部署脚本自动安装
现象:服务器未安装 Assimp,上传 PMX 压缩包只得到
  GLB 直出:导出失败(ENOENT):spawn assimp ENOENT
  glTF 直出:导出失败(ENOENT):spawn assimp ENOENT
用户看不出缺什么、怎么装,也不知道只传 glb/vrm 其实不需要它。

修复:
1. 新增 src/common/assimp.ts:按 ASSIMP_BIN -> PATH -> /usr/bin、/usr/local/bin
   等常见目录定位可执行文件(只认文件,不认同名目录),转换前先预检;
   找不到就一次性给出各发行版安装命令、查过的路径与替代办法,
   不再打两条 ENOENT。动作预处理(FBX/glTF -> BVH)复用同一文案。
2. 新增 deploy/assimp-lib.sh:install.sh 按发行版自动安装
   (apt-get / dnf / yum / zypper / apk / pacman),装在非常规目录时把绝对
   路径写入 deploy/.env 的 ASSIMP_BIN;装不上只告警不中止,可用
   SKIP_ASSIMP_INSTALL=1 跳过。check-access.sh 自检增加 Assimp 一行。
3. /health 增加 assimp.path;管理端「概览 -> 服务健康」显示 Assimp 状态,
   不可用时给出安装提示。
4. 新增 test/assimp-locate.spec.ts 13 项;强化「assimp 不存在」用例断言安装命令。
   测试规模 12 文件 / 193 项。
5. 版本 0.2.2,README(.md/.txt)/DSH.md 同步(前置要求、FAQ、环境变量说明)。
2026-09-16 02:55:32 +08:00

13 KiB

T-WEB-005 模型上传(GLB/VRM 直接导入 + PMX/FBX 转换 + zip 自动搜索 + 分析/缩略图 + 上传即待审)

  • 状态: done(v0.2.0 重构:GLB/VRM 主力、上传即待审)
  • 依赖: T-WEB-002

目标

系统以 GLB / VRM 为主力格式(服务端与客户端都一等公民):.glb/.vrm/.gltf 直接导入、不重编码, .pmx/.fbx 服务端转换(优先 GLB、其次 VRM),.zip 自动搜索包内可用模型。入库后自动分析(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性) 与生成缩略图;状态一律 pending,经审核后才可能成为默认模型。

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/model-inspector.ts
web-server/src/models/model-thumbnail.ts
web-server/src/models/vrm-writer.ts
web-server/src/models/gltf-packer.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/common/zip-reader.ts
web-server/src/config/default-model.ts
web-server/src/migrations/1789450000000-AddModelConversionFields.ts
web-server/src/migrations/1789460000000-AddModelStats.ts
web-server/src/reviews/review.dto.ts
web-server/scripts/import-default-model.mjs

端点

方法 路径 说明
GET /api/models 分页 + q/format/status/primaryOnly
POST /api/models multipart 上传(file + 名称 + 来源五元组);file 接受 .glb/.vrm/.gltf/.pmx/.fbx/.zip; 入库一律 pending
GET /api/models/:id 详情(含 stats、thumbnailUrl、conversionNote、fileSha256、sourceSha256)
PATCH /api/models/:id 更新
DELETE /api/models/:id 软删除
POST /api/models/:id/primary 设为全局默认模型(仅 published 允许,否则 409)

格式策略(服务端与客户端一致)

输入 行为
.glb / .vrm 直接导入,不重编码;.vrm = GLB + VRM 扩展
.gltf 直接导入(分离的 .bin / 贴图不在同一文件里,会缺资源,建议随 zip 上传)
.pmx / .fbx 服务端转换:优先 GLB、其次 VRM(见下)
.zip 自动搜索包内可用模型(见下)

客户端(Godot)本来就用内置 GLTFDocument 加载 glTF/GLB/VRM (opendance-windows/src/model/vrm_loader.gd), 无需改动。服务端 humanoid 候选表 与客户端 humanoid_map.gd 逐字对齐,由 test/model-inspector.spec.ts 断言两边一致。

压缩包自动搜索与转换

.zip ─▶ readZipEntries() ─▶ 自动搜索主模型 ─┬─.vrm/.glb/.gltf ─▶ 直接导入
        zip-reader.ts.vrm>.glb>       │
                             .gltf>.pmx>.fbx  └─.pmx/.fbx ─▶ Assimp 转换
                                                              ├─ 1) ->.glb
                                                              ├─ 2) ->.gltf -> gltf-packer -> GLB
                                                              ├─ 3) MODEL_CONVERT_FORMAT=vrm -> vrm-writer
                                                              └─ 每步 assimp info 回读
环节 实现 规则
ZIP 读取 common/zip-reader.ts 中央目录/EOCD、STORE(0)+DEFLATE(8)、ZIP64 大小字段、Shift-JIS/GBK 文件名回退;拒绝 zip-slip 与加密条目;不支持的压缩方式报错
选主模型 model-convert.ts 格式优先级 .vrm -> .glb -> .gltf -> .pmx -> .fbx;同一优先级内"MODEL_CONVERT_PMX 指名字(默认 Hatsune Miku.pmx,大小写不敏感)-> 目录最浅 -> 文件最大"
直接导入 importModelFile() .vrm/.glb/.gltf 原样入库;.glb 若含 VRMC_vrm/VRM 扩展会被识别为 VRM
转换 convertModelFile() / convertModelArchive() MODEL_CONVERT_FORMAT=glb(默认)
回读校验 exportAndValidate() 每步产物必须 assimp info 回读;退出码 0 但产物损坏也判失败
GLB 打包 gltf-packer.ts GLB 直出失败时导出 .gltf,把 .bin 与外部贴图自行合成单文件 GLB
VRM 封装 vrm-writer.ts MODEL_CONVERT_FORMAT=vrm 时只改 JSON chunk 补 extensions.VRMC_vrm; humanoid 骨骼不全 -> 回退交付 GLB,缺失骨骼写进说明
贴图内嵌 glb-textures.ts 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐)
溯源 迁移 AddModelConversionFields1789450000000 压缩包原件存为 model-archive 资产 -> models.source_asset_id;说明 -> models.conversion_note
失败 models.service.ts 无可用模型 / Assimp 不可用 / 两条路径都失败 -> HTTP 422,不产生模型记录
文件名编码 common/upload.util.ts decodeUploadName() 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 模å_...)
DTO conversionNote / fileSha256 / sourceSha256 / stats / thumbnailUrl 契约 data-models.md 4、rest-api.yaml

入库后自动:分析 + 缩略图(尽力而为,失败不影响入库)

项 实现 说明
分析 model-inspector.ts -> models.stats 面数 / 顶点 / 网格 / 材质 / 贴图 / 节点 / 形变目标 / 动画 / 蒙皮骨骼数 / 骨骼树 / humanoid 覆盖 / 可驱动性(缺哪些必需骨骼) / warnings;迁移 AddModelStats1789460000000 新增 models.stats TEXT
缩略图 model-thumbnail.ts -> models.thumbnail_asset_id 纯 TS 软件光栅化 512x512 PNG(读 GLB/glTF -> 节点层级与蒙皮 -> 包围盒构图 -> 透视投影 -> z-buffer 平面着色 -> 手写 PNG),零原生依赖;失败或非 glTF 容器留空,前端退化为格式占位图

状态与默认模型(硬性)

  • 上传一律 status=pending(models.service.ts 固定 PublishStatus.Pending), is_primary=false: 不再自动发布、不再自动设为默认;服务端不再识别 autoDefault。
  • 默认模型只在下列时机设置,并把身份写进源码 src/config/default-model.ts:
    1. 审核通过带 setPrimary:true: POST /api/reviews/model/:id/approve;
    2. 显式设置:POST /api/models/:id/primary(仅 published 允许,否则 409)。
  • GET /api/catalog/export 的 defaultModelId 解析顺序(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,保证互斥。

VRM 版本识别

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

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

实测(v0.2.0)

  • 上传含 Hatsune Miku.pmx 的 MMD 压缩包 -> HTTP 201 / 0.86s、format=glb、 status=pending、conversionNote=Hatsune Miku.pmx -> GLB,已内嵌 2 张贴图。 分析结果:vertices=7155 triangles=9071 materials=3 textures=2 images=2 nodes=240 bones=238 skins=1 morphTargets=0 humanoidMapped=0/22 drivable=false maxBoneDepth=23; missingRequiredBones 为全部 16 根(日文 MMD 骨名无法映射到 humanoid)。
  • 直接上传 .glb -> HTTP 201 / 0.36s、conversionNote=直接导入(GLB,未做重编码), 入库文件 sha256 与源文件一致(不重编码)。
  • 缩略图:512x512 PNG / 15,043 字节,服务端纯 TS 光栅化(含蒙皮),目视确认为居中的 T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60-121ms(中位 79ms)。
  • 审核:POST /api/reviews/model/22/approve 带 setPrimary:true -> 200, 模型 status=published、isPrimary=true,源码种子写入 sourceSha256=72eb6882...; 重建后 GET /api/catalog/export 的 defaultModelId 由 VRM 变为 22 (库中另有 2 个已发布 VRM)。

运维 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
# 上传 -> 服务端转换入库(pending)-> 把身份写进 src/config/default-model.ts

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

上传一律 pending,因此要让 CLI 写出的种子在 defaultModelId 中生效,仍需审核通过 (勾选「同时设为全局默认模型」)或调用 POST /api/models/:id/primary; --url 默认 http://127.0.0.1:3000, --source-type 默认 other。

验收

cd web-server
npx vitest run        # 12 个文件 / 193 项通过
npm run test:smoke    # 冒烟 [4] 段落
验收项 状态
.glb/.vrm/.gltf 直接导入不重编码 ✅ 实测 GLB 上传 201、conversionNote=直接导入(GLB,未做重编码)、入库 sha256 与源文件一致
.zip 自动搜索包内模型 ✅ 优先级 .vrm>glb>.gltf>.pmx>.fbx;同优先级按指名字->目录最浅->文件最大
.pmx 转换优先 GLB ✅ 实测 201 / 0.86s、format=glb、2 张贴图内嵌
GLB 失败回退 glTF->GLB 打包 ✅ GLB 直出失败时 gltf-packer.ts 自行打包;每步 assimp info 回读校验
MODEL_CONVERT_FORMAT=vrm 封装 ✅ vrm-writer.ts 补 VRMC_vrm; humanoid 不全回退 GLB
自动分析写入 models.stats ✅ 实测 triangles=9071 bones=238 humanoidMapped=0/22 drivable=false
缩略图生成且不影响入库 ✅ 512x512 / 15,043 B;失败只告警
上传一律 pending、不自动设默认 ✅ create() 固定 PublishStatus.Pending + isPrimary=false
setPrimary 审核通过时生效 ✅ POST /api/reviews/model/22/approve + setPrimary:true -> 200、isPrimary=true
非 published 设默认 ✅ POST /api/models/:id/primary -> 409
失败语义 ✅ 无可用模型 / Assimp 不可用 / 两条路径均失败 -> HTTP 422,不产生模型记录
ZIP 安全 ✅ 拒绝 zip-slip、加密条目、未知压缩方式(model-archive.spec.ts 24 项)
humanoid 与客户端一致 ✅ model-inspector.spec.ts 抽 humanoid_map.gd 逐键比对(25 项)
VRM 版本识别 ✅ test/vrm-inspector.spec.ts(6 例)

test/model-archive.spec.ts(24 项)覆盖 ZIP 解析 / zip-slip / 加密 / 未知压缩方式、 主模型挑选优先级、GLB 贴图内嵌、用假 Assimp 对照多种回退路径、源码渲染 + tsc 校验。 test/model-inspector.spec.ts(25 项)覆盖 GLB 统计与骨骼树、VRM 识别、必需 16 根 humanoid 骨、非 glTF 容器降级、截断与健壮性、与客户端映射表逐字一致。 test/model-thumbnail.spec.ts(8 项)覆盖缩略图 PNG 结构、蒙皮与降级路径。

追加:历史模型回填(POST /api/models/:id/reanalyze)

升级到带 models.stats 的版本后,已有模型还没有分析与缩略图。重新分析接口只改 stats 与 thumbnailAssetId 两个字段,用于回填 / 文件替换后刷新 / 缩略图渲染器升级后重出图。

cd /opt/opendance/web-server
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000          # 回填全部缺 stats 的
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --dry-run # 只列清单

实测(真实 21MB VRM, 290 骨骼):回填出面数 53552 / 顶点 44662 / 骨骼 290 / humanoid 7/22,并重建 512x512 缩略图。 文件在存储中丢失时返回 409 并说明原因,不会用全 0 结果覆盖既有分析数据。 另外 StorageService.persistBuffer 在 sha256 去重命中但文件不存在时会自愈补写, 避免换过 STORAGE_DIR 或只恢复数据库后出现"静默全 0 分析"。