根因:normalizeBoneName() 只保留 a-z0-9,**中日文字符被整个丢掉**: '上半身'/'左ひじ'/'首' 归一化后都是空串,MMD 模型的骨名必然失配, humanoid 覆盖率恒为 0/22 -> 不可驱动 -> 只能回退交付 GLB 并标记不可用。 修复: 1. 归一化保留 CJK/平假名/片假名/长音符/々,并把全角 ASCII(足IK)折成半角; 2. 候选表补齐四类命名(22 根 x 8-13 个候选):VRoid / Mixamo / MMD 标准骨架(下半身/上半身/上半身2/首/頭/左肩/左腕/左ひじ/左手首/ 左足/左ひざ/左足首/左つま先)/ 常见中文名(臀部/头/脖子/左大臂/左大腿…); 精确匹配保证「左足IK」不会抢占「左足」; 3. Windows 客户端 humanoid_map.gd 的 CANDIDATES 由服务端表**生成写入**, normalize() 同步改造,parity 测试断言键/候选顺序完全一致; 4. 契约 skeleton-topology -> 0.2.0:新增 §6「内部动作标准与模型骨骼重绑定」 (三层标准、22/16 根骨、归一化与匹配规则、实测记录); 5. 新增 4 项测试;规模 17 文件 / 229 项。 实测:奥黛塔 PMX 0/22 -> 21/22、初音未来 PMX 0/22 -> 19/22,均 drivable=true 且产出 VRM。 版本 0.4.0。
206 lines
13 KiB
Markdown
206 lines
13 KiB
Markdown
# 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 软件光栅化 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`)。
|
|
|
|
```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 # 17 个文件 / 229 项通过
|
|
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` 两个字段,用于回填 / 文件替换后刷新 / 缩略图渲染器升级后重出图。
|
|
|
|
```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,并重建 512x512 缩略图。
|
|
文件在存储中丢失时返回 **409** 并说明原因,不会用全 0 结果覆盖既有分析数据。
|
|
另外 `StorageService.persistBuffer` 在 sha256 去重命中但文件不存在时会**自愈补写**,
|
|
避免换过 `STORAGE_DIR` 或只恢复数据库后出现"静默全 0 分析"。
|