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:
2026-09-15 20:37:53 +08:00
parent ccced34dd6
commit 53bf182476
47 changed files with 5610 additions and 628 deletions
+130 -59
View File
@@ -1,5 +1,5 @@
================================================================
OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
OpenDance Studio —— Web 管理服务端 v0.2.0 使用说明
================================================================
本服务端是 OpenDance Studio 三端中的第一端,负责管理歌曲、VRM 模型与
@@ -50,8 +50,8 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明
(本项目不会创建数据库本身,也不需要 GRANT OPTION / FILE / SUPER 等管理权限)
部署步骤
tar -xzf opendance-web-v0.1.0.tar.gz
cd opendance-web-v0.1.0
tar -xzf opendance-web-v0.2.0.tar.gz
cd opendance-web-v0.2.0
bash deploy/init-env.sh # 首次启动引导:逐项问答生成 deploy/.env(见下)
sudo bash deploy/install.sh
@@ -187,8 +187,10 @@ deploy/.env 初始化引导(deploy/init-env.sh)
cd web-server
npm run contracts:check # 契约版本一致性
npm run typecheck # TypeScript 严格检查
npm run test # 125 项单元测试 / 7 个文件(含 VMD 31、模型压缩包 22、
# init-env 16、预处理 25)
npm run test # 168 项单元测试 / 10 个文件(VMD 31、模型压缩包 24、
# 模型检查器 25、缩略图 8、骨骼过滤 8、
# 预处理 25、init-env 16、admin-port 16、
# access-log 9、VRM 检查器 6)
npm run build # 生产构建
npm run start:prod & # 启动
npm run test:smoke # 57 项端到端冒烟(需要数据库可连通)
@@ -228,11 +230,16 @@ deploy/.env 初始化引导(deploy/init-env.sh)
会在代理层被拒(nginx client_max_body_size /
IIS requestLimits),详见 deploy/proxy/README.md
ASSIMP_BIN 可选:Assimp 可执行文件,FBX/glTF 动作预处理需要;
上传 .zip 模型包时**必须**能调用到,否则转换失败(422)
MODEL_CONVERT_FORMAT 模型压缩包首选的目标格式(默认 fbx)。导出后会用
`assimp info` 回读校验,失败自动回退 glb
上传 .zip 模型包、或 .pmx/.fbx 单文件模型时**必须**能调用到,
否则转换失败(422)
MODEL_CONVERT_FORMAT 需要转换的模型(PMX/FBX)的交付格式:
glb(默认,两个客户端都原生加载)| vrm
vrm = 在 GLB 之上补 VRMC_vrm 扩展(humanoid 骨骼 / 表情语义),
humanoid 骨骼映射不全时自动回退交付 GLB 并写进 conversion_note。
产物一律用 `assimp info` 回读校验
MODEL_CONVERT_PMX 压缩包内优先选用的 PMX 文件名(默认 Hatsune Miku.pmx,
大小写不敏感);找不到时取目录最浅、其次文件最大者
大小写不敏感);找不到时按「格式优先级 → 目录最浅 →
文件最大」挑选
MODEL_CONVERT_TIMEOUT_MS
单次转换超时(毫秒,默认 180000)
DEFAULT_MODEL_ID 可选:显式指定默认模型 id;不设置时按源码种子
@@ -256,11 +263,12 @@ deploy/.env 初始化引导(deploy/init-env.sh)
POST /api/motions/:id/preprocess BVH/VMD/FBX → MediaPipe33 关键点
GET /api/motions/:id/keypoints 下载关键点序列(NDJSON)
GET /api/models 模型列表
POST /api/models 上传模型 + 来源信息(VRM/GLB/FBX,
或含 .pmx 的 .zip 模型包)
POST /api/models/:id/primary 设为全局默认模型
POST /api/models 上传模型 + 来源信息(.glb/.vrm/.gltf
直接导入;.pmx/.fbx 服务端转换;
.zip 自动搜索包内模型)
POST /api/models/:id/primary 设为全局默认模型(仅 published 允许,否则 409)
GET /api/reviews 审核流水 / 待审列表
POST /api/reviews/:type/:id/approve 审核通过
POST /api/reviews/:type/:id/approve 审核通过(模型可带 setPrimary:true)
POST /api/reviews/:type/:id/reject 审核驳回(必须填意见)
GET /api/catalog/export 客户端一次性拉取已发布目录
GET /api/catalog/version 契约版本 + 目录指纹
@@ -271,63 +279,113 @@ deploy/.env 初始化引导(deploy/init-env.sh)
相同内容只落盘一次(sha256 去重)。
支持的模型格式(POST /api/models 上传)
VRM .vrm 首选(P0);glTF 2.0 二进制 + VRM 扩展
glTF Binary .glb P1;需含骨骼
glTF .gltf P2;多文件,建议打包后上传
FBX .fbx P2;需 Assimp,管理端仅归档
MMD 压缩包 .zip P1;内含 .pmx(+ textures/),服务端自动解压转换
**系统以 GLB / VRM 为主力格式**:服务端与客户端都把这两种格式作为一等公民
(Godot 客户端用内置 GLTFDocument 加载 glTF/GLB/VRM,见
opendance-windows/src/model/vrm_loader.gd,本次无需改动)。
模型压缩包(.zip)自动转换
上传的 .zip 视为 MMD 模型包,走"解压 → 选主模型 → 转换 → 回读校验 → 入库",
而不是把压缩包当模型文件存下来。
glTF Binary .glb 主力(P0)—— **直接导入**,不重编码
VRM .vrm 主力(P0)—— 直接导入;GLB + VRM 扩展
(humanoid / 表情 / springBone / meta)
glTF .gltf P1 —— 直接导入(分离的 .bin / 贴图需随 .zip 一起上传)
MMD 压缩包 .zip P1 —— **自动搜索包内可用模型**(见下)
MMD 模型 .pmx P2(输入)—— 服务端转换,优先 GLB、其次 VRM
FBX .fbx P2(输入)—— 服务端转换,优先 GLB、其次 VRM
选主模型 优先 MODEL_CONVERT_PMX 指定的文件名(默认 Hatsune Miku.pmx,
大小写不敏感);否则取目录最浅、其次文件最大者。只认 .pmx。
转换 先试 MODEL_CONVERT_FORMAT(默认 fbx),调 ASSIMP_BIN 导出。
校验 导出后**必须**用 `assimp info` 回读校验。Assimp 的 FBX 导出器
对带蒙皮的大骨架模型会"写出损坏文件却返回非 0 退出码",只信
退出码会把坏文件入库。
回退 FBX 失败(导出失败或回读失败)→ 自动改用 GLB,两次尝试写入
models.conversion_note。
贴图 GLB 产物里 Assimp 会把贴图写成外部 URI(还带反斜杠),服务端
把压缩包内的贴图内嵌进 GLB 的二进制块,保证单文件可加载。
默认模型 转换成功且 autoDefault 不为 false(默认 true)→ is_primary=1 +
status=published,并把身份写进源码 src/config/default-model.ts。
入库后**一律 status=pending(待审核)**:上传不再自动发布、不再自动设为默认,
必须经审核通过(模型审核面板可勾选「同时设为全局默认模型」)才对客户端可见。
模型压缩包(.zip)自动搜索与转换
上传的 .zip 不再"只找 .pmx",而是**自动搜索包内可用模型**,走
"解压 → 选主模型 → 直接导入或转换 → 回读校验 → 入库",而不是把压缩包当模型存下来。
选主模型 优先级 .vrm → .glb → .gltf → .pmx → .fbx;同一优先级内按
"MODEL_CONVERT_PMX 指名字 → 目录最浅 → 文件最大"。
命中 .vrm/.glb/.gltf 时**直接导入**(不重编码)。
转换 只有 .pmx/.fbx 需要转换。链路:assimp export → .glb(首选);
失败则 assimp export → .gltf,再由 gltf-packer.ts 自行打包回 GLB;
MODEL_CONVERT_FORMAT=vrm 时在 GLB 之上补 VRMC_vrm 扩展封装成
VRM 1.0(humanoid 骨骼不全则回退交付 GLB,缺失骨骼写进说明)。
校验 每一步产物**必须**用 `assimp info` 回读校验。Assimp 会"写出损坏文件"
(实测 PMX 导出 FBX 退出码 11、回读报 FBX-DOM 错误),只看退出码
会把坏文件入库。
贴图 GLB 产物里 Assimp 会把贴图写成外部 URI(还带反斜杠),服务端把
压缩包内 / .gltf 同目录的贴图内嵌进 GLB 的二进制块,保证单文件可加载。
说明 每次尝试写入 models.conversion_note,例如
`Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`。
溯源 压缩包原件以 model-archive 资产留存,写入 models.source_asset_id;
转换说明写入 models.conversion_note(迁移 AddModelConversionFields)。
失败 压缩包内没有 .pmx、Assimp 不可用、两种格式都失败 → HTTP 422,
迁移 AddModelConversionFields1789450000000。
失败 包内没有可用模型、Assimp 不可用、两条路径都失败 → HTTP 422,
不产生模型记录。
文件名编码 upload.util.ts 的 decodeUploadName() 修正 multipart 文件名的
latin1/UTF-8 乱码(中文文件名原先会存成 模å_…)。
管理端 上传框 accept 增加 .zip,提示会说明"会自动转换并设为默认";
预览面板新增「转换来源」与「文件 sha256」。
新增字段 DTO/契约新增 conversionNote、fileSha256。
管理端 上传框 accept 为 .vrm,.glb,.gltf,.fbx,.zip;预览面板显示「转换来源」、
「文件 sha256」与面数 / 顶点 / 骨骼分析摘要。
新增字段 DTO/契约新增 conversionNote、fileSha256、stats、thumbnailUrl。
实测:上传 示例文件/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip
(1,093,100 字节)→ HTTP 201、0.89s、format=glb、isPrimary=true、
status=published。服务端日志显示 Assimp 6.0.4 导出 FBX 失败(退出码 11),
回退 GLB 后回读通过:1,152,464 字节、238 关节、已内嵌 2 张贴图,
conversionNote = `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`。
实测(Assimp 6.0.4):
- 上传含 `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 与源文件**一致**(不重编码,字节级保留)。
模型分析、缩略图与 3D 预览(入库后自动执行,失败不影响入库)
分析 model-inspector.ts 解析 GLB/VRM:面数 / 顶点 / 网格 / 材质 / 贴图 /
节点 / 形变目标 / 动画 / 蒙皮骨骼数 / **骨骼树** / humanoid 覆盖 /
可驱动性(缺哪些必需骨骼)/ warnings,写入 models.stats
(迁移 AddModelStats1789460000000,新增 models.stats TEXT)。
缩略图 model-thumbnail.ts 用纯 TS 软件光栅化生成 PNG(读 GLB/glTF → 应用节点
层级与蒙皮 → 包围盒自动构图 → 透视投影 → z-buffer 逐三角形平面着色 →
手写 PNG 编码),**零原生依赖**(不需要 canvas / headless-gl / puppeteer)。
默认 512×512,写入 models.thumbnail_asset_id / thumbnailUrl;
失败或非 glTF 容器时留空,列表页退化为格式占位图。
实测:512×512 PNG / 15,043 字节,目视确认为居中的 T-pose 角色图;
真实 10MB VRM(83 关节、5307 顶点)单张耗时 60–121ms(中位 79ms)。
预览 管理端 3D 预览放开到 vrm/glb/gltf(.fbx 仍提示「仅归档,请先转换为
GLB/VRM」);列表页显示 40px 小缩略图,审核面板显示 116px 缩略图。
审核流程(模型)
模型上传后为 pending,在管理端「审核」页的**模型审核面板**里查看并决定:
面板内容 体积 / 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 /
骨骼 / 节点 / 形变目标 / 动画 / humanoid 覆盖(已映射/总数 + 明细)/
**可驱动性结论**(缺哪些必需骨骼)/ warnings。
骨骼预览 骨骼详细界面:扁平骨骼树按 depth 缩进、显示骨骼名、是否参与蒙皮、
humanoid 映射徽章、子节点数;支持按名搜索(保留祖先链)、
「仅显示蒙皮骨骼」开关、限高滚动 + 只渲染前 400 行(千根骨骼不卡)。
通过 勾选「同时设为全局默认模型」→ 请求体带 `setPrimary:true`
(`POST /api/reviews/model/:id/approve`);此时才写库 is_primary
并把默认模型身份写进源码 src/config/default-model.ts。
单独设置 `POST /api/models/:id/primary`(仅 published 允许,否则 409)。
驳回 必须填写审核意见,否则 400。
默认模型是怎么定的
GET /api/catalog/export 的 defaultModelId 按以下顺序解析:
0. 环境变量 DEFAULT_MODEL_ID(且模型存在且已发布)
1. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED——按转换产物
匹配已发布模型(主键是**上传压缩包的 sha256**,用户文件永远稳定;
不能用转换产物的 sha256 —— Assimp 每次导出的字节都略有不同),换库 / 重建库后仍有效
1. 源码种子 src/config/default-model.ts 的 DEFAULT_MODEL_SEED——按**上传压缩包的
sha256** 匹配已发布模型(用户文件永远稳定;不能用转换产物的 sha256 ——
Assimp 每次导出的字节都略有不同),换库 / 重建库后仍有效
2. format='vrm' 且 published 且 is_primary=1
3. 任意 published 的 VRM(id 升序取第一个)
4. id 最小的已发布模型
5. 无可用模型 → 客户端用内置占位模型
当前种子指向 id=16、name=`初音未来 Hatsune Miku`、format=glb、
sha256=`6d0ab0dce97c43dd7de84aba29b09f3b64f2953722d31b5421a39189ff2a8464`。
实测:库里有 2 个 VRM 的情况下,defaultModelId 仍解析为 16。
源码种子 src/config/default-model.ts 当前为**空种子**(占位:id=null、sha256 空串),
此时 defaultModelId 直接从第 2 步的 VRM 优先规则开始解析;按下面的方式设定默认
模型后,服务端会把种子写成真实内容。
实测(v0.2.0):`POST /api/reviews/model/22/approve` 带 `setPrimary:true` → 200,
模型 status=published、isPrimary=true,源码种子写入 sourceSha256=72eb6882…;
重建后 `GET /api/catalog/export` 的 defaultModelId 由 VRM 变为 **22**
(库中另有 2 个已发布 VRM)。
也可手工指定:POST /api/models/:id/primary。
第 1 步的源码写入通常由服务端在转换成功时完成;生产 systemd 加固
(ProtectSystem=strict)下服务进程写不了程序目录,可改用下面的 CLI 补写。
第 1 步的源码写入由服务端在**审核通过并勾选 setPrimary**(或调用 primary 接口)
时完成;生产 systemd 加固(ProtectSystem=strict)下服务进程写不了程序目录,
可改用下面的 CLI 补写。
导入 / 补写默认模型(web-server/scripts/import-default-model.mjs)
需先 `npm run build`(脚本复用编译产物 dist/models/default-model-source.js)。
@@ -340,16 +398,18 @@ deploy/.env 初始化引导(deploy/init-env.sh)
--source-name "模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
--source-url "本地文件:模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
--source-type other
# 上传 → 服务端转换入库 → 设为默认 → 把身份写进 src/config/default-model.ts
# 上传 → 服务端转换入库(pending)→ 把身份写进 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 22
# 只根据已入库模型补写源码,不调上传 API
node scripts/import-default-model.mjs --write-source-only --model-id 16 --dry-run
node scripts/import-default-model.mjs --write-source-only --model-id 22 --dry-run
# 只打印将写入的内容,不落盘
说明:--url 默认 http://127.0.0.1:3000,--source-type 默认 other;
上传时脚本固定带 autoDefault=true。
说明:--url 默认 http://127.0.0.1:3000,--source-type 默认 other。
v0.2.0 起上传一律 pending,因此 CLI 上传路径只负责转换入库与写源码种子;
要让它在 `defaultModelId` 中生效,仍需在管理端**审核通过**(勾选「同时设为全局
默认模型」)或调用 `POST /api/models/:id/primary`。
支持的动作格式(POST /api/motions 上传 → POST /api/motions/:id/preprocess 预处理)
BVH .bvh 内置纯 TS 解析器,保留源帧率
@@ -384,9 +444,18 @@ VMD 动作上传前后自查
web-server/ NestJS 服务端源码
web-server/admin/ React 管理端
web-server/src/common/zip-reader.ts 纯 TS ZIP 读取器
web-server/src/models/model-convert.ts 压缩包 → FBX/GLB 转换
web-server/src/models/model-convert.ts .zip/.pmx/.fbx → GLB/VRM 转换
web-server/src/models/gltf-packer.ts glTF(分离文件)→ GLB 打包
web-server/src/models/glb-textures.ts GLB 贴图内嵌
web-server/src/models/vrm-writer.ts GLB → VRM 1.0 封装(写 VRMC_vrm 扩展)
web-server/src/models/model-inspector.ts 模型检查器(面数/骨骼树/humanoid 覆盖)
web-server/src/models/model-thumbnail.ts 纯 TS 光栅化缩略图(零原生依赖)
web-server/src/config/default-model.ts 默认模型种子(服务端/CLI 写入)
web-server/src/migrations/1789460000000-AddModelStats.ts
models.stats 迁移(审核分析结果)
web-server/admin/src/components/ModelReviewPanel.tsx
模型审核面板(含骨骼详细界面)
web-server/admin/src/lib/bone-tree.ts 骨骼树过滤(搜索保留祖先链)
web-server/scripts/import-default-model.mjs 默认模型导入 / 补写 CLI
deploy/ 业务服务部署(系统服务,不含 MySQL、不含反向代理)
deploy/init-env.sh 首次启动的 .env 交互引导(install.sh 会调用)
@@ -400,8 +469,10 @@ VMD 动作上传前后自查
1. .env 已被 .gitignore 忽略,禁止提交;仓库中只保留 .env.example。
2. 数据库账号使用最小权限,不授予 GRANT OPTION / FILE / SUPER。
3. 本版本未内置管理端登录认证,请部署在内网,或在反向代理层加 Basic Auth / SSO。
4. 上传的模型与动作默认需填写来源五元组并通过审核后才对客户端可见;上传 .zip
模型包且未关闭 autoDefault 时,转换成功会直接设为已发布的默认模型(见 §五)。
4. 上传的模型与动作默认需填写来源五元组并通过审核后才对客户端可见。
**模型上传后一律 pending**:不再自动发布、不再自动设为默认;默认模型只在
审核通过时勾选「同时设为全局默认模型」(`setPrimary:true`)或调用
`POST /api/models/:id/primary`(仅 published)时才设置(见 §五)。
5. 未使用 SMPL 拓扑,统一 MediaPipe 33 点,规避非商用许可限制。
----------------------------------------------------------------