Files
opendance-web/tasks/005-model-upload.md
T

206 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`(默认)|`vrm`;超时 `MODEL_CONVERT_TIMEOUT_MS`(默认 180000) |
| 回读校验 | `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 软件光栅化 512×512 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 与源文件**一致**(不重编码)。
- 缩略图:512×512 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`)。
```bash
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`。
## 验收
```bash
cd web-server
npx vitest run # 10 个文件 / 168 项通过
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` |
| 缩略图生成且不影响入库 | ✅ 512×512 / 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` 两个字段,用于回填 / 文件替换后刷新 / 缩略图渲染器升级后重出图。
```bash
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,并重建 512×512 缩略图。
文件在存储中丢失时返回 **409** 并说明原因,不会用全 0 结果覆盖既有分析数据。
另外 `StorageService.persistBuffer` 在 sha256 去重命中但文件不存在时会**自愈补写**,
避免换过 `STORAGE_DIR` 或只恢复数据库后出现"静默全 0 分析"。