feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览
需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
- model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
**骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
- model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
- vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
- gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
(服务端判定的"可驱动" = 客户端实际能否驱动)
需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
→(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf
需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
**骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程
其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
顶层开发书更新为 v1.4
验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
This commit is contained in:
@@ -31,7 +31,9 @@ opendance-web ←── 本仓库
|
||||
| 管理前端 | React 18 + Vite + TypeScript |
|
||||
| 3D 预览 | three.js + @pixiv/three-vrm |
|
||||
| 多格式解析 | BVH / VMD 内置纯 TS 解析器;FBX/glTF 走可选外部 Assimp |
|
||||
| 模型压缩包 | 纯 TS ZIP 读取器(`common/zip-reader.ts`)+ 外部 Assimp 转换(`models/model-convert.ts`);GLB 贴图内嵌见 `models/glb-textures.ts` |
|
||||
| 模型主力格式 | **GLB / VRM 为一等公民**:`.glb`/`.vrm`/`.gltf` 直接导入(不重编码),`.pmx`/`.fbx` 服务端转换(优先 GLB、其次 VRM) |
|
||||
| 模型检查 / 缩略图 | `models/model-inspector.ts`(面数 / 顶点 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性)、`models/model-thumbnail.ts`(纯 TS 光栅化 PNG,零原生依赖) |
|
||||
| 模型压缩包 | 纯 TS ZIP 读取器(`common/zip-reader.ts`)+ 自动搜索包内模型 + 外部 Assimp 转换(`models/model-convert.ts`);GLB 打包 `models/gltf-packer.ts`、贴图内嵌 `models/glb-textures.ts`、GLB→VRM 封装 `models/vrm-writer.ts` |
|
||||
| 默认模型种子 | `web-server/src/config/default-model.ts`(服务端/CLI 写入,运行期只读) |
|
||||
| 认证 | **本版本不含认证**(内网部署);上线前须补 ≥ T-WEB-011 |
|
||||
|
||||
@@ -80,21 +82,30 @@ opendance-web/
|
||||
├── web-server/ # NestJS 服务端
|
||||
│ ├── src/ # 源码(entities / models / preprocess)
|
||||
│ │ ├── common/zip-reader.ts # 纯 TS ZIP 读取器(STORE/DEFLATE、ZIP64、zip-slip 防护)
|
||||
│ │ ├── models/ # 模型上传 + 压缩包转换 + VRM 识别
|
||||
│ │ │ ├── model-convert.ts # 解压 → 选 PMX → Assimp → 回读校验 → 回退
|
||||
│ │ ├── models/ # 模型上传 + 自动搜索/转换 + 分析 + 缩略图
|
||||
│ │ │ ├── model-convert.ts # 选主模型 → 直接导入或 Assimp 转换 → 回读校验
|
||||
│ │ │ ├── gltf-packer.ts # glTF(.gltf + .bin + 贴图)→ GLB 打包
|
||||
│ │ │ ├── glb-textures.ts # 把外部贴图内嵌进 GLB 的 BIN chunk
|
||||
│ │ │ ├── vrm-writer.ts # GLB → VRM 1.0(写 VRMC_vrm 扩展)
|
||||
│ │ │ ├── model-inspector.ts # 面数 / 顶点 / 骨骼树 / humanoid 覆盖 / 可驱动性
|
||||
│ │ │ ├── model-thumbnail.ts # 纯 TS 光栅化 512×512 PNG(含蒙皮,零原生依赖)
|
||||
│ │ │ ├── default-model-source.ts # 渲染 / 写入 src/config/default-model.ts
|
||||
│ │ │ └── vrm-inspector.ts
|
||||
│ │ ├── config/default-model.ts # 默认模型种子(由服务端/CLI 写入,运行期只读)
|
||||
│ │ ├── migrations/ # TypeORM 迁移(含 AddModelConversionFields)
|
||||
│ │ ├── config/default-model.ts # 默认模型种子(由审核 / CLI 写入,运行期只读)
|
||||
│ │ ├── migrations/ # TypeORM 迁移(AddModelConversionFields / AddModelStats)
|
||||
│ │ └── preprocess/ # BVH / VMD / FBX → MediaPipe33
|
||||
│ │ ├── vmd-parser.ts # VMD 二进制 + Shift-JIS + 贝塞尔插值
|
||||
│ │ ├── mmd-skeleton.ts # 标准 MMD 骨架(静止比例实测自参考 PMX)
|
||||
│ │ ├── mmd-pose.ts # MMD FK + 两骨骼腿部 IK + 左手系→右手系 Z 镜像
|
||||
│ │ └── retarget.ts # worldFramesToKeypointFrames():BVH / VMD 共用
|
||||
│ ├── admin/ # React 管理端
|
||||
│ ├── test/ # 单元测试 + 端到端冒烟
|
||||
│ │ # (mmd-vmd / model-archive / init-env / admin-port …)
|
||||
│ │ ├── src/components/ModelReviewPanel.tsx # 模型审核面板 + 骨骼详细界面
|
||||
│ │ └── src/lib/bone-tree.ts # 骨骼树过滤(搜索保留祖先链)
|
||||
│ ├── test/ # 单元测试 + 端到端冒烟(10 文件 / 168 项)
|
||||
│ │ # mmd-vmd 31 / model-archive 24 / model-inspector 25
|
||||
│ │ # / model-thumbnail 8 / admin-bone-filter 8
|
||||
│ │ # / preprocess 25 / init-env 16 / admin-port 16
|
||||
│ │ # / access-log 9 / vrm-inspector 6
|
||||
│ └── scripts/ # migrate.mjs / check-contracts.mjs / check-routes.mjs
|
||||
│ # / inspect-vmd.mjs(VMD 诊断)
|
||||
│ # / import-default-model.mjs(默认模型导入 / 补写源码)
|
||||
@@ -130,6 +141,14 @@ opendance-web/
|
||||
`npm run contracts:check` 在构建前拦截漂移。
|
||||
6. **不使用 SMPL** 及其衍生拓扑(许可风险),统一 MediaPipe 33 点。
|
||||
7. **上传文件永不使用原始文件名作为路径**,一律 `sha256` 去重后归档。
|
||||
8. **GLB / VRM 是模型主力格式**:服务端与客户端(Godot 内置 `GLTFDocument`)都把
|
||||
这两种格式作为一等公民。`.glb` / `.vrm` / `.gltf` **直接导入、不重编码**;
|
||||
`.pmx` / `.fbx` 才由服务端转换,且**优先 GLB、其次 VRM**。
|
||||
humanoid 候选表必须与客户端 `opendance-windows/src/model/humanoid_map.gd` 逐字对齐,
|
||||
由 `test/model-inspector.spec.ts` 断言兜底。
|
||||
9. **模型上传一律 `pending`(待审核)**:上传不得自动发布、不得自动设为默认模型。
|
||||
默认模型只在审核通过勾选 `setPrimary:true`(`POST /api/reviews/model/:id/approve`)
|
||||
或显式调用 `POST /api/models/:id/primary`(仅 `published`,否则 409)时设置。
|
||||
|
||||
## 7. 每任务的执行步骤
|
||||
|
||||
@@ -171,9 +190,11 @@ HOST=0.0.0.0 # 软件 API 监听地址(未设置时默认即 0.
|
||||
ADMIN_HOST=0.0.0.0 # 可选,管理端监听地址,默认跟随 HOST
|
||||
STORAGE_DIR=/data/storage
|
||||
UPLOAD_MAX_BYTES=536870912
|
||||
ASSIMP_BIN=assimp # 可选;上传 .zip 模型包时必需
|
||||
MODEL_CONVERT_FORMAT=fbx # 压缩包首选格式;导出后回读校验,失败自动回退 glb
|
||||
MODEL_CONVERT_PMX="Hatsune Miku.pmx" # 优先选用的 PMX 文件名(大小写不敏感)
|
||||
ASSIMP_BIN=assimp # 可选;上传 .zip 模型包或 .pmx/.fbx 单文件时必需
|
||||
MODEL_CONVERT_FORMAT=glb # PMX/FBX 交付格式:glb(默认)| vrm
|
||||
# vrm = GLB 之上补 VRMC_vrm 扩展;humanoid 不全则回退 GLB
|
||||
# 产物一律 `assimp info` 回读校验
|
||||
MODEL_CONVERT_PMX="Hatsune Miku.pmx" # 压缩包内优先选用的 PMX 文件名(大小写不敏感)
|
||||
MODEL_CONVERT_TIMEOUT_MS=180000
|
||||
DEFAULT_MODEL_ID=1 # 可选;未设置时按 src/config/default-model.ts 的种子匹配
|
||||
CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
|
||||
@@ -217,43 +238,105 @@ CONTRACTS_DIR=/contracts # 可选,读取实时契约版本
|
||||
(打印头部/模型名/帧数/轨道数、未识别骨骼、IK 还是 FK、关键点序列异常统计、膝肘角分布)。
|
||||
- 契约 `contracts/model-formats.md` §2.3;测试 `test/mmd-vmd.spec.ts`(31 项)。
|
||||
|
||||
## 11. 模型压缩包转换(.zip → FBX / GLB)与默认模型种子
|
||||
## 11. 模型格式策略、转换、分析与审核
|
||||
|
||||
**GLB / VRM 是主力格式**(服务端与客户端都一等公民),其余格式在服务端转换。
|
||||
|
||||
```
|
||||
.zip(MMD 包)─▶ zip-reader.ts ─▶ pickModelFile() ─▶ model-convert.ts ─▶ glb-textures.ts
|
||||
中央目录/EOCD 优先 MODEL_CONVERT_PMX Assimp 导出 GLB 贴图内嵌
|
||||
STORE+DEFLATE 否则目录最浅、文件最大 + info 回读校验
|
||||
拒绝 zip-slip/加密 失败回退 FBX → GLB
|
||||
POST /api/models ─┬─ .glb / .vrm / .gltf ─▶ importModelFile() 直接导入(不重编码)
|
||||
├─ .pmx / .fbx ─────────▶ convertModelFile() Assimp → GLB(首选)
|
||||
└─ .zip ─▶ zip-reader.ts ─▶ 自动搜索包内模型
|
||||
├─ .vrm / .glb / .gltf ─▶ 直接导入
|
||||
└─ .pmx / .fbx ─▶ model-convert.ts
|
||||
├─ 1) assimp export → .glb
|
||||
├─ 2) 失败 → .gltf → gltf-packer.ts 打包回 GLB
|
||||
├─ 3) MODEL_CONVERT_FORMAT=vrm → vrm-writer.ts 补 VRMC_vrm
|
||||
└─ 每步 assimp info 回读校验
|
||||
─▶ 入库(status=pending)
|
||||
─▶ model-inspector.ts 分析 → models.stats
|
||||
─▶ model-thumbnail.ts 缩略图 → models.thumbnail_asset_id
|
||||
```
|
||||
|
||||
- `POST /api/models` 的 `file` 也接受 **`.zip`**,视为 MMD 模型包;只认包内 `.pmx`。
|
||||
- `POST /api/models` 的 `file` 接受 `.glb` / `.vrm` / `.gltf` / `.pmx` / `.fbx` / `.zip`。
|
||||
- 新增模块(均在 `web-server/src/`):
|
||||
- `common/zip-reader.ts`:纯 TS 极简 ZIP 读取器(中央目录/EOCD、STORE+DEFLATE、
|
||||
ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目、
|
||||
不支持的压缩方式报错)。
|
||||
- `models/model-convert.ts`:解压 → 选主模型 → Assimp 转换 → 回读校验 → 回退。
|
||||
- `models/model-convert.ts`:解压 → 自动搜索主模型 → 直接导入或 Assimp 转换 →
|
||||
回读校验 → 回退。
|
||||
- `models/gltf-packer.ts`:把 Assimp 导出的分离 `.gltf` + `.bin` + 贴图自行打包成 GLB。
|
||||
- `models/glb-textures.ts`:把 GLB 里 Assimp 写成外部 URI 的贴图内嵌进 BIN chunk。
|
||||
- `models/vrm-writer.ts`:GLB → VRM 1.0,只改 JSON chunk 写 `extensions.VRMC_vrm`,
|
||||
不做任何几何/材质损失。
|
||||
- `models/model-inspector.ts`:解析 GLB/VRM,算面数 / 顶点 / 贴图 / **骨骼树** /
|
||||
humanoid 覆盖 / 可驱动性(缺哪些必需骨骼)/ warnings。
|
||||
- `models/model-thumbnail.ts`:纯 TS 软件光栅化 512×512 PNG(含蒙皮),零原生依赖。
|
||||
- `models/default-model-source.ts`:渲染 / 写入 `src/config/default-model.ts`。
|
||||
- `config/default-model.ts`:默认模型种子(服务端/CLI 写入,运行期只读)。
|
||||
- **选主模型**:优先 `MODEL_CONVERT_PMX`(默认 `Hatsune Miku.pmx`,大小写不敏感);
|
||||
否则取目录最浅、其次文件最大者。
|
||||
- **转换与回退**:先试 `MODEL_CONVERT_FORMAT`(默认 `fbx`);**导出后必须用
|
||||
`assimp info` 回读校验**,失败自动回退 `glb`,两次尝试写进 `conversion_note`。
|
||||
实测 Assimp 6.0.4 对 714 骨骼的 Miku PMX 导出 FBX 失败(退出码 11,产物回读报
|
||||
`FBX-DOM no Objects dictionary found`),实际入库为 GLB。
|
||||
- **默认模型**:转换成功且 `autoDefault` 不为 false(默认 true)→ `is_primary=1` +
|
||||
`status=published`,并把身份写进源码种子;`GET /api/catalog/export` 的
|
||||
`defaultModelId` 解析顺序为 `DEFAULT_MODEL_ID` → 源码种子(按**上传压缩包**的 sha256 匹配;
|
||||
产物 sha256 每次转换都变,只作记录)
|
||||
- `config/default-model.ts`:默认模型种子(审核 / CLI 写入,运行期只读)。
|
||||
- **压缩包自动搜索**:优先级 `.vrm` → `.glb` → `.gltf` → `.pmx` → `.fbx`;同一优先级内
|
||||
按 `MODEL_CONVERT_PMX` 指名字 → 目录最浅 → 文件最大。命中 glTF 系格式时**直接导入**,
|
||||
只有 `.pmx`/`.fbx` 才转换。压缩包原件存为 `model-archive` 资产。
|
||||
- **转换与回退**:GLB 直出失败 → glTF 再自行打包;`MODEL_CONVERT_FORMAT=vrm` 时在 GLB
|
||||
之上补 `VRMC_vrm`(humanoid 不全则**回退交付 GLB**并把缺失骨骼写进 `conversion_note`)。
|
||||
**每一步产物都必须用 `assimp info` 回读校验**:实测 Assimp 会"写出损坏文件"
|
||||
(FBX 导出退出码 11、回读报 `FBX-DOM no Objects dictionary found`),只看退出码
|
||||
会把坏文件入库。
|
||||
- **入库后自动**:`inspectModelFile()` 分析写入 `models.stats`
|
||||
(迁移 `AddModelStats1789460000000`,新增 `models.stats TEXT`);
|
||||
`renderModelThumbnail()` 写入 `models.thumbnail_asset_id` / `thumbnailUrl`。
|
||||
**两者都是"尽力而为"**:失败只记 warning,不影响入库。
|
||||
- **状态与默认模型(硬性)**:上传**一律 `status=pending`**,`is_primary=false`,
|
||||
不自动发布、不自动设为默认。默认模型只在
|
||||
① 审核通过带 `setPrimary:true`(`POST /api/reviews/model/:id/approve`)或
|
||||
② `POST /api/models/:id/primary`(仅 `published`,否则 **409**)时设置,此时写库
|
||||
`is_primary` 并把身份写进源码种子。
|
||||
`GET /api/catalog/export` 的 `defaultModelId` 解析顺序为 `DEFAULT_MODEL_ID` → 源码种子
|
||||
(按**上传压缩包**的 sha256 匹配;产物 sha256 每次转换都变,只作记录)
|
||||
→ 已发布且 is_primary 的 VRM → 任意已发布 VRM → id 最小的已发布模型。
|
||||
- **溯源**:压缩包原件存为 `model-archive` 资产,记入 `models.source_asset_id`
|
||||
- **溯源**:压缩包原件记入 `models.source_asset_id`
|
||||
(迁移 `AddModelConversionFields1789450000000`);`models.conversion_note` 记转换说明。
|
||||
- **失败**:包内没有 `.pmx`、Assimp 不可用、两种格式都失败 → **HTTP 422**,
|
||||
- **失败**:包内没有可用模型、Assimp 不可用、两条路径都失败 → **HTTP 422**,
|
||||
不产生模型记录。
|
||||
- **文件名编码**:`common/upload.util.ts` 的 `decodeUploadName()` 修正 multipart
|
||||
文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_…`)。
|
||||
- 契约:`contracts/model-formats.md` §1.1、`contracts/data-models.md` §4.1;
|
||||
DTO 新增 `conversionNote` / `fileSha256`;测试 `test/model-archive.spec.ts`(22 项)。
|
||||
DTO 新增 `conversionNote` / `fileSha256` / `stats` / `thumbnailUrl`。
|
||||
|
||||
### 11.1 审核要求(模型)
|
||||
|
||||
管理端「审核」页对 `pending` 模型展示 **模型审核面板**
|
||||
(`admin/src/components/ModelReviewPanel.tsx`):
|
||||
|
||||
- **指标**:体积 / 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 /
|
||||
骨骼 / 节点 / 形变目标 / 动画。
|
||||
- **humanoid 覆盖**:已映射 / 总数(22 根)+ 明细徽章;**可驱动性结论**直接给出
|
||||
缺哪些必需骨骼(16 根必需骨全齐才算可驱动);并列出 warnings。
|
||||
- **骨骼详细界面**:扁平骨骼树按 `depth` 缩进,显示骨骼名、是否参与蒙皮、
|
||||
humanoid 映射徽章、子节点数;支持按名搜索(`admin/src/lib/bone-tree.ts`
|
||||
保留祖先链)、「仅显示蒙皮骨骼」开关、限高滚动 + 只渲染前 400 行。
|
||||
- **通过**:可勾选「同时设为全局默认模型」→ 请求体 `setPrimary:true`。
|
||||
- **驳回**:必须填写审核意见(400)。
|
||||
- 实测:`POST /api/reviews/model/22/approve` 带 `setPrimary:true` → 200、
|
||||
`status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882…`;
|
||||
重建后 `defaultModelId` 由 VRM 变为 **22**。
|
||||
|
||||
### 11.2 实测数据(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 骨名无法映射)。
|
||||
- 直接上传 `.glb`:**HTTP 201 / 0.36s**、`conversionNote=直接导入(GLB,未做重编码)`,
|
||||
入库文件 sha256 与源文件**一致**。
|
||||
- 缩略图:512×512 PNG / 15,043 字节,纯 TS 光栅化(含蒙皮),目视确认为居中的
|
||||
T-pose 角色图;真实 10MB VRM(83 关节、5307 顶点)单张耗时 60–121ms(中位 79ms)。
|
||||
- 管理端 3D 预览放开到 `vrm/glb/gltf`(`fbx` 仍提示"仅归档,请先转换为 GLB/VRM");
|
||||
列表页显示 40px 缩略图。
|
||||
- 测试:`npx vitest run` → **10 个文件 / 168 项通过**
|
||||
(`model-inspector` 25、`model-thumbnail` 8、`admin-bone-filter` 8、
|
||||
`model-archive` 24、`mmd-vmd` 31、`preprocess` 25、`init-env` 16、
|
||||
`admin-port` 16、`access-log` 9、`vrm-inspector` 6)。
|
||||
|
||||
## 12. 首次启动的 .env 引导(deploy/init-env.sh)与监听地址
|
||||
|
||||
|
||||
Reference in New Issue
Block a user