diff --git a/DSH.md b/DSH.md index b4e8f3e..66b3b1f 100644 --- a/DSH.md +++ b/DSH.md @@ -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)与监听地址 diff --git a/README.txt b/README.txt index 27d43e7..a4717b5 100644 --- a/README.txt +++ b/README.txt @@ -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 点,规避非商用许可限制。 ---------------------------------------------------------------- diff --git a/contracts/VERSION b/contracts/VERSION index 29c3f7e..b559400 100644 --- a/contracts/VERSION +++ b/contracts/VERSION @@ -1,8 +1,8 @@ # OpenDance Studio 契约版本 # 规则:任何字段级变更必须提升对应条目版本,并回到 opendance-web 修改后再向另两端建迁移任务。 -rest-api: 0.3.0 +rest-api: 0.4.0 websocket-keypoints: 0.1.0 -data-models: 0.3.0 +data-models: 0.4.0 skeleton-topology: 0.1.0 -model-formats: 0.3.0 -database: 0.2.0 +model-formats: 0.4.0 +database: 0.3.0 diff --git a/contracts/data-models.md b/contracts/data-models.md index ea0167d..d4d7c13 100644 --- a/contracts/data-models.md +++ b/contracts/data-models.md @@ -1,4 +1,4 @@ -# contracts/data-models.md — 数据模型契约 v0.3.0 +# contracts/data-models.md — 数据模型契约 v0.4.0 Web 服务端是唯一权威定义,本文件是另两端消费的**稳定视图**。 字段名在 JSON 中统一 `camelCase`;数据库列名 `snake_case`(见 `database.md`)。 @@ -96,7 +96,7 @@ rejected 已驳回 |---|---|---|---| | `id` | number | 读 | | | `name` | string | ✅ | 模型名称,≤200 | -| `format` | enum | ✅ | `vrm` / `glb` / `gltf` / `fbx` | +| `format` | enum | ✅ | `glb` / `vrm`(主力)/ `gltf` / `fbx`(历史数据)。上传的 PMX/FBX 会被服务端转换成 glb 或 vrm 再入库,见 model-formats.md §1.1 | | `vrmVersion` | string\|null | 读 | `0.x` / `1.0`,非 VRM 为 null | | `isPrimary` | boolean | | 全局默认模型;**同一时刻全库至多一个 `true`** | | `fileUrl` | string | 读 | | @@ -105,6 +105,7 @@ rejected 已驳回 | `conversionNote` | string\|null | 读 | 由压缩包转换而来时的说明(`Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`);直传模型为 null | | `fileSha256` | string\|null | 读 | 模型文件(转换产物)的 sha256 —— **每次转换都会变**,仅作记录 | | `sourceSha256` | string\|null | 读 | 上传压缩包的 sha256(稳定);默认模型匹配以它为准,非压缩包上传为 null | +| `stats` | object\|null | 读 | 模型分析结果:面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,见 model-formats.md §1.1 | | `source*` | | ✅ | 来源五元组 | | `status` | `PublishStatus` | | | | `createdAt` / `updatedAt` | string | 读 | | diff --git a/contracts/database.md b/contracts/database.md index 44c0ecc..7860b4e 100644 --- a/contracts/database.md +++ b/contracts/database.md @@ -36,11 +36,12 @@ DB_LOGGING=false - 每个 migration 必须可逆(提供 `down` 方法) - 迁移文件名格式:`-.ts` -## 增量迁移(v0.2.0) +## 增量迁移(v0.2.0 / v0.3.0) | 迁移 | 变更 | |---|---| | `AddModelConversionFields` | `models` 增加 `source_asset_id BIGINT NULL`(上传的压缩包,用于溯源)与 `conversion_note VARCHAR(500) NULL`(转换说明,见 model-formats.md §1.1) | +| `AddModelStats` | `models` 增加 `stats TEXT NULL`(模型分析结果:面数、骨骼树、humanoid 覆盖、可驱动性),供审核界面展示 | ## 表清单(v0.1.0) diff --git a/contracts/model-formats.md b/contracts/model-formats.md index 36af55c..ad0a74b 100644 --- a/contracts/model-formats.md +++ b/contracts/model-formats.md @@ -1,32 +1,69 @@ -# contracts/model-formats.md — 模型与动作格式契约 v0.3.0 +# contracts/model-formats.md — 模型与动作格式契约 v0.4.0 ## 1. 角色模型(models) -| 格式 | 扩展名 | 支持级别 | 说明 | +**系统以 GLB / VRM 为主力格式**(两个客户端都原生加载),其余格式一律在服务端转换。 + +| 格式 | 扩展名 | 支持级别 | 服务端行为 | |---|---|---|---| -| VRM | `.vrm` | **首选(P0)** | glTF 2.0 二进制 + VRM 扩展;Windows 端 godot-vrm 直接加载 | -| glTF Binary | `.glb` | P1 | 需含骨骼(skin);无骨骼则仅作静态展示 | -| glTF | `.gltf` (+ `.bin`, 贴图) | P2 | 多文件,管理端需打包为 zip 上传 | -| FBX | `.fbx` | P2 | 需 Assimp 转换,管理端仅归档,不保证重定向质量 | -| **MMD 压缩包** | `.zip` | P1 | 内含 `.pmx`(+ `textures/`)。服务端自动解压并转换,见 §1.1 | +| glTF Binary | `.glb` | **主力(P0)** | **直接导入**,不重编码 | +| VRM | `.vrm` | **主力(P0)** | **直接导入**;= GLB + VRM 扩展(humanoid / 表情 / springBone / meta) | +| glTF | `.gltf` (+ `.bin`, 贴图) | P1 | 直接导入(分离文件在 zip 内可用;单文件上传会缺 .bin) | +| MMD 压缩包 | `.zip` | P1 | **自动搜索包内可用模型**:优先直接导入 `.vrm`/`.glb`/`.gltf`,否则转换 `.pmx`/`.fbx`,见 §1.1 | +| MMD 模型 | `.pmx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM);单文件上传无贴图 | +| FBX | `.fbx` | P2(输入) | 服务端转换(优先 GLB、其次 VRM) | -### 1.1 模型压缩包转换(`.zip` → FBX / GLB) +### 1.1 上传与转换策略 -上传 `.zip` 时走"解压 → 选主模型 → 转换 → 入库",而不是把压缩包当模型文件存下来。 +**`.glb` / `.vrm` / `.gltf` 直接导入**(不重编码,零损失);**`.pmx` / `.fbx` 由服务端转换**: +**优先 GLB,其次 VRM**。 + +#### 单文件上传 + +| 输入 | 行为 | +|---|---| +| `.glb` / `.vrm` | 直接导入;`.glb` 若含 `VRMC_vrm`/`VRM` 扩展会被识别为 VRM | +| `.gltf` | 直接导入(外部 `.bin` / 贴图不在同一文件里,会缺资源) | +| `.pmx` / `.fbx` | 转换:`ASSIMP_BIN` 导出 → 回读校验(见下) | + +#### 压缩包上传(自动搜索) + +不再"只找 `.pmx`",而是**按格式优先级自动搜索包内可用模型**: + +``` +.vrm → .glb → .gltf (直接导入,优先) +.pmx → .fbx (需要转换) +``` + +同一优先级内:`MODEL_CONVERT_PMX` 指定的文件名优先,其次目录最浅,最后文件最大。 +压缩包原件以 `model-archive` 资产留存并写入 `models.source_asset_id`。 + +#### 转换与校验(PMX / FBX) | 步骤 | 规则 | |---|---| -| 选主模型 | 优先 `MODEL_CONVERT_PMX` 指定的文件名(默认 `Hatsune Miku.pmx`,大小写不敏感);否则取目录最浅、其次文件最大者。**只认 `.pmx`**(MMD 的 PMX 模型;PMD/PMX 之外的格式暂不支持) | -| 转换 | `MODEL_CONVERT_FORMAT`(默认 `fbx`)指定首选格式,调 `ASSIMP_BIN` 导出 | -| 校验 | **导出后必须回读校验**(`assimp info`)。Assimp 的 FBX 导出器对带蒙皮的大骨架模型会"写出损坏文件"(实测 714 骨骼的 PMX:导出退出码 11,产物回读报 `FBX-DOM no Objects dictionary found`),只信退出码会把坏文件入库 | -| 回退 | FBX 失败(导出失败或回读失败)→ 自动改用 **GLB**,并把两次尝试写进 `models.conversion_note` | -| 贴图 | GLB 产物里 Assimp 会把贴图写成外部 URI(还带 `\` 反斜杠),服务端把压缩包内的贴图**内嵌**进 GLB 的二进制块,保证单文件可加载 | -| 溯源 | 压缩包原件以 `model-archive` 资产留存,写入 `models.source_asset_id` | -| 默认模型 | 转换成功且 `autoDefault` 不为 false:`is_primary=1` + `status=published`,并把身份写进源码 `web-server/src/config/default-model.ts`,见 `data-models.md` §4.1 | -| 确定性 | **转换产物不是字节级确定的**:同一份 zip 连续转换三次得到三个不同的 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以**压缩包 sha256** 为匹配键,产物 sha256 只作记录 | -| 失败 | 压缩包里没有 `.pmx`、Assimp 不可用、两种格式都失败 → **HTTP 422**,不产生模型记录 | +| 1. GLB 直出 | `assimp export → .glb`,成功后把 Assimp 写成外部 URI 的贴图**内嵌**进 BIN chunk | +| 2. glTF 打包 | 上一步失败时 `assimp export → .gltf`,由 `gltf-packer.ts` 把 `.gltf` + `.bin` + 贴图自行合成 GLB | +| 3. VRM 封装 | `MODEL_CONVERT_FORMAT=vrm` 时,在 GLB 之上补 `VRMC_vrm` 扩展(humanoid 骨骼来自骨骼名映射,见 `model-inspector.ts` 的候选表,与 Godot 客户端一致);**humanoid 不全则回退交付 GLB**,缺失骨骼写进 `conversion_note` | +| 回读校验 | 每一步产物都要 `assimp info` 回读。Assimp 会"写出损坏文件"(实测 FBX 导出退出码 11、产物回读报 `FBX-DOM no Objects dictionary found`),只看退出码会把坏文件入库 | +| 说明 | 每次尝试都写进 `models.conversion_note`(例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`) | +| 失败 | 包内没有可用模型、Assimp 不可用、两条路径都失败 → **HTTP 422**,不产生模型记录 | -安全:解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE。 +#### 入库后(自动) + +| 项 | 说明 | +|---|---| +| 状态 | **一律 `pending`(待审核)**:模型必须审核通过后才可能被客户端消费,见 `database.md` 状态机 | +| 分析 | `model-inspector.ts` 计算面数 / 顶点数 / 贴图 / **骨骼树** / humanoid 覆盖 / 可驱动性,写入 `models.stats`,供审核界面展示 | +| 缩略图 | `model-thumbnail.ts` 纯 TS 光栅化生成 PNG,写入 `models.thumbnail_asset_id`(失败不影响入库) | +| 默认模型 | 只在**审核通过并勾选"同时设为默认"**(或 `POST /api/models/:id/primary`)时设置,并把身份写进源码 `src/config/default-model.ts`,见 `data-models.md` §4.1 | + +#### 其它约定 + +| 项 | 规则 | +|---|---| +| 确定性 | **转换产物不是字节级确定的**:同一份 zip 连续转换 3 次得到 3 个不同 sha256(大小相同,BIN chunk 有十几字节浮动)。因此默认模型种子以**上传压缩包的 sha256** 为匹配键,产物 sha256 只作记录 | +| 安全 | 解压前逐条校验路径,拒绝绝对路径与 `..`(zip-slip);拒绝加密条目;只支持 STORE / DEFLATE | **VRM 优先**的选取顺序见 `data-models.md` §4.1。 diff --git a/contracts/rest-api.yaml b/contracts/rest-api.yaml index 0bbe2bb..2971303 100644 --- a/contracts/rest-api.yaml +++ b/contracts/rest-api.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: OpenDance Studio Web 管理服务端 API - version: 0.3.0 + version: 0.4.0 description: | OpenDance Studio 的唯一数据源与契约权威。 契约版本见 contracts/VERSION。字段语义见 contracts/data-models.md。 @@ -317,24 +317,26 @@ paths: schema: { $ref: '#/components/schemas/ModelPage' } post: tags: [models] - summary: 上传模型 + 来源信息(VRM 优先;支持 MMD 模型压缩包自动转换) + summary: 上传模型(GLB/VRM 直接导入;PMX/FBX 服务端转 GLB 或 VRM) description: | - `file` 为单个文件: + 系统以 **GLB / VRM 为主力格式**(两个客户端都原生加载): - - `.vrm` / `.glb` / `.gltf` / `.fbx`:直接入库,`format` 由扩展名决定 - - `.zip`:视为 MMD 模型包。服务端解压 → 选取 `.pmx` - (优先 `MODEL_CONVERT_PMX` 指定的名字,默认 `Hatsune Miku.pmx`; - 否则取目录最浅、文件最大者)→ 调 Assimp 导出 **FBX**, - 导出后**回读校验**(Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写出损坏文件), - 失败自动回退 **GLB**,并把过程写入 `conversionNote`。 - GLB 产物会把 Assimp 写成外部 URI 的贴图内嵌进二进制块,保证单文件可用。 - 压缩包原件作为 `model-archive` 资产留存以便溯源。 + - `.glb` / `.vrm` / `.gltf`:**直接导入**,不重编码 + - `.pmx` / `.fbx`:服务端转换,**优先 GLB、其次 VRM** + - `.zip`:**自动搜索包内可用模型** —— `.vrm` → `.glb` → `.gltf`(直接导入) + 优先,其次 `.pmx` → `.fbx`(转换)。压缩包原件作为 `model-archive` 资产留存。 - 转换成功且 `autoDefault` 不为 false 时,该模型会被置为全局默认模型 - (`isPrimary=1` + `status=published`),并把身份(名称/格式/**压缩包 sha256**) - 写进源码 `web-server/src/config/default-model.ts`。 - 注意:**转换产物不是字节级确定的**(同一份 zip 每次转换的产物 sha256 都不同), - 所以默认模型种子以压缩包 sha256 为匹配键,产物 sha256 只作记录。 + 转换链路(`model-convert.ts`):`assimp export → .glb` →(失败)`→ .gltf` 后 + 由服务端自行打包成 GLB →(`MODEL_CONVERT_FORMAT=vrm` 时)在 GLB 之上补 + `VRMC_vrm` 扩展产出 VRM 1.0。每一步都 `assimp info` **回读校验**, + 并把过程写进 `conversionNote`;贴图会内嵌进 GLB,保证单文件可用。 + + 入库时自动做两件事并写入 `stats` / `thumbnailUrl`:模型分析(面数、骨骼树、 + humanoid 覆盖、可驱动性)与缩略图生成。 + + **状态一律 `pending`(待审核)**:模型必须审核通过后才可能被客户端消费; + 设为默认模型请在审核通过时带 `setPrimary=true`,或调用 + `POST /api/models/{id}/primary`(仅已发布模型可设)。 requestBody: required: true content: @@ -346,9 +348,7 @@ paths: file: { type: string, format: binary } name: { type: string } isPrimary: { type: boolean } - autoDefault: - type: boolean - description: 仅对 `.zip` 生效;转换成功后是否自动设为默认模型,默认 true + # 注意:上传不再提供"自动发布/自动设为默认",模型一律进入待审核 sourceType: { $ref: '#/components/schemas/SourceType' } sourceName: { type: string } sourceUrl: { type: string } @@ -777,6 +777,12 @@ components: type: string nullable: true description: 模型文件(转换产物)的 sha256 —— 每次转换都会变(Assimp 导出非字节级确定),仅作记录 + stats: + type: object + nullable: true + description: >- + 模型分析结果(面数 / 顶点数 / 贴图 / 骨骼树 / humanoid 覆盖 / 可驱动性), + 审核界面据此判断"能不能驱动与评分"。结构见 contracts/model-formats.md §1.1 sourceSha256: type: string nullable: true diff --git a/deploy/.env.example b/deploy/.env.example index 6e2c9bf..89b0e6e 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -44,10 +44,12 @@ UPLOAD_MAX_BYTES=536870912 # ---- 可选的 Assimp(FBX/glTF 动作预处理;也用于压缩包模型的转换)---- # 上传 .zip(内含 .pmx 的 MMD 模型包)时必须能调用到它,否则转换会失败 ASSIMP_BIN=assimp -# 压缩包转换的目标格式:fbx(优先)| glb -# Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件,服务端会回读校验并自动回退 GLB -MODEL_CONVERT_FORMAT=fbx -# 压缩包里优先选用的 PMX 文件名(大小写不敏感);找不到时按"目录最浅、文件最大"挑 +# 需要转换的模型(PMX / FBX)交付格式:glb(默认,两个客户端都原生加载)| vrm +# glb:通用,客户端直接加载 +# vrm:在 GLB 之上补 VRMC_vrm 扩展(humanoid 骨骼 / 表情语义); +# humanoid 骨骼名映射不全时会自动回退交付 GLB 并写进 conversion_note +MODEL_CONVERT_FORMAT=glb +# 压缩包里优先选用的 PMX 文件名(大小写不敏感);找不到时按"格式优先级 → 目录最浅 → 文件最大"挑 MODEL_CONVERT_PMX=Hatsune Miku.pmx # 单次转换超时(毫秒) MODEL_CONVERT_TIMEOUT_MS=180000 diff --git a/project.yaml b/project.yaml index 38def44..d8fe44d 100644 --- a/project.yaml +++ b/project.yaml @@ -1,8 +1,8 @@ project: opendance-web-server -version: 0.1.0 +version: 0.2.0 repository: opendance-web default_branch: main -contracts_version: 0.3.0 +contracts_version: 0.4.0 # 契约变更流程(文档 §7.2): # 任何跨端契约变更必须先在本项目修改 contracts/ 并提升版本, @@ -36,7 +36,7 @@ tasks: status: done - id: T-WEB-005 file: tasks/005-model-upload.md - scope: 模型上传 + VRM 优先 + 来源字段 + MMD 压缩包(.zip)自动转换与默认模型种子 + scope: 模型上传(GLB/VRM 直接导入 + PMX/FBX 转换 + .zip 自动搜索)+ 自动分析/缩略图 + 上传即待审 status: done - id: T-WEB-006 file: tasks/006-keypoint-preprocess.md @@ -60,13 +60,13 @@ tasks: status: done deliverable: - artifact: opendance-web-v0.1.0.tar.gz + artifact: opendance-web-v0.2.0.tar.gz packaging: Linux 业务服务(系统服务托管,双端口:软件 API + 管理端,不含 MySQL、不含反向代理、不使用 Docker) includes: - web-server/ # NestJS 源码 + 构建产物(含 BVH / VMD 内置解析、ZIP 读取与模型压缩包转换) - admin/ # React 管理端构建产物 - deploy/ # install.sh / init-env.sh / systemd / openrc / initd / ctl.sh / .env.example(PORT、ADMIN_PORT、HOST) - deploy/proxy/ # 反向代理说明(nginx / IIS,仅供参考不部署) - - contracts/ # 当前版本契约快照(0.3.0) + - contracts/ # 当前版本契约快照(0.4.0) - THIRD_PARTY.md # 开源声明 - README.txt # 使用说明 diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 167e048..6a77070 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -18,7 +18,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" -VERSION="${1:-0.1.0}" +VERSION="${1:-0.2.0}" NAME="opendance-web-v${VERSION}" OUT_DIR="$REPO_ROOT/dist-release" STAGE="$OUT_DIR/$NAME" @@ -122,6 +122,11 @@ for required in \ "opendance-web-v${VERSION}/web-server/dist/models/model-convert.js" \ "opendance-web-v${VERSION}/web-server/dist/models/glb-textures.js" \ "opendance-web-v${VERSION}/web-server/dist/migrations/1789450000000-AddModelConversionFields.js" \ + "opendance-web-v${VERSION}/web-server/dist/migrations/1789460000000-AddModelStats.js" \ + "opendance-web-v${VERSION}/web-server/dist/models/model-inspector.js" \ + "opendance-web-v${VERSION}/web-server/dist/models/model-thumbnail.js" \ + "opendance-web-v${VERSION}/web-server/dist/models/vrm-writer.js" \ + "opendance-web-v${VERSION}/web-server/dist/models/gltf-packer.js" \ "opendance-web-v${VERSION}/deploy/init-env.sh" \ "opendance-web-v${VERSION}/deploy/proxy/README.md" \ "opendance-web-v${VERSION}/deploy/proxy/nginx.conf.example" \ diff --git a/tasks/005-model-upload.md b/tasks/005-model-upload.md index 854b613..f47b5e5 100644 --- a/tasks/005-model-upload.md +++ b/tasks/005-model-upload.md @@ -1,11 +1,14 @@ -# T-WEB-005 模型上传 + VRM 优先 + 来源字段 +# T-WEB-005 模型上传(GLB/VRM 直接导入 + PMX/FBX 转换 + zip 自动搜索 + 分析/缩略图 + 上传即待审) -- **状态**:done +- **状态**:done(v0.2.0 重构:GLB/VRM 主力、上传即待审) - **依赖**:T-WEB-002 ## 目标 -上传角色模型,**VRM 优先**,识别 VRM 版本,并维护全局默认模型。 +系统以 **GLB / VRM 为主力格式**(服务端与客户端都一等公民):`.glb`/`.vrm`/`.gltf` +**直接导入、不重编码**,`.pmx`/`.fbx` 服务端转换(**优先 GLB、其次 VRM**),`.zip` +**自动搜索包内可用模型**。入库后自动**分析**(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性) +与生成**缩略图**;状态**一律 `pending`**,经审核后才可能成为默认模型。 ## load @@ -13,15 +16,20 @@ DSH.md contracts/rest-api.yaml # 锚点:#/paths/~1api~1models contracts/data-models.md # 锚点:§4 Model、§4.1 默认模型选择规则 -contracts/model-formats.md # 锚点:§1 角色模型、§1.1 模型压缩包转换 +contracts/model-formats.md # 锚点:§1 角色模型、§1.1 上传与转换策略 web-server/src/models/* -web-server/src/models/vrm-inspector.ts -web-server/src/common/zip-reader.ts +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 ``` @@ -30,13 +38,67 @@ web-server/scripts/import-default-model.mjs | 方法 | 路径 | 说明 | |---|---|---| | GET | `/api/models` | 分页 + `q`/`format`/`status`/`primaryOnly` | -| POST | `/api/models` | multipart 上传(`file` + 名称 + 来源五元组 + `isPrimary`);`file` 也接受 `.zip`(MMD 模型包,自动转换) | -| GET | `/api/models/:id` | 详情 | +| 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` | 设为全局默认模型 | +| POST | `/api/models/:id/primary` | 设为全局默认模型(**仅 `published` 允许,否则 409**) | -## 默认模型选择规则(`contracts/data-models.md` §4.1,已实现于 `resolveDefaultModelId()`) +## 格式策略(服务端与客户端一致) + +| 输入 | 行为 | +|---|---| +| `.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 指定,且该模型存在且已发布 @@ -49,45 +111,30 @@ web-server/scripts/import-default-model.mjs ``` `POST /:id/primary` 在**同一事务**内先清零其余 `is_primary`,保证互斥。 -第 0/1 步为本次(v0.3.0)新增:上传 MMD 压缩包转换成功后自动设为默认并写源码种子。 -## 压缩包自动转换(`.zip` → FBX / GLB) +## VRM 版本识别 -`POST /api/models` 的 `file` 接受 `.zip`,视为 MMD 模型包。链路: +`vrm-inspector.ts` 直接读取 GLB 头部 JSON chunk,不依赖完整 3D 库: -``` -.zip ─▶ readZipEntries() ─▶ pickModelFile() ─▶ Assimp 导出 ─▶ assimp info 回读 ─▶ glb-textures 内嵌 - zip-reader.ts 优先 MODEL_CONVERT_PMX FBX 优先 失败回退 GLB -``` +- `extensions.VRMC_vrm` → VRM **1.0**(取 `specVersion`) +- `extensions.VRM` → VRM **0.x** +- 两者皆无 → `null`(普通 GLB) -| 环节 | 实现 | 规则 | -|---|---|---| -| ZIP 读取 | `common/zip-reader.ts` | 中央目录/EOCD、STORE(0)+DEFLATE(8)、ZIP64 大小字段、Shift-JIS/GBK 文件名回退;拒绝 zip-slip 与加密条目;不支持的压缩方式报错 | -| 选主模型 | `models/model-convert.ts` `pickModelFile()` | 优先 `MODEL_CONVERT_PMX`(默认 `Hatsune Miku.pmx`,大小写不敏感);否则目录最浅,其次文件最大;只认 `.pmx` | -| 转换 | `model-convert.ts` | `MODEL_CONVERT_FORMAT`(默认 `fbx`)→ `ASSIMP_BIN`;超时 `MODEL_CONVERT_TIMEOUT_MS`(默认 180000) | -| 回读校验 | `exportAndValidate()` | 导出后必须 `assimp info` 回读;退出码为 0 但产物损坏也判失败 | -| 回退 | `convertModelArchive()` | FBX 失败 → GLB;两次尝试写入 `models.conversion_note` | -| 贴图内嵌 | `models/glb-textures.ts` | 把 GLB 中外部 URI 贴图追加 bufferView 并内嵌进 BIN chunk(4 字节对齐) | -| 默认模型 | `models.service.ts` `createFromArchive()` | `autoDefault` 默认 true → `is_primary=1` + `status=published`,并写 `src/config/default-model.ts` | -| 溯源 | 迁移 `AddModelConversionFields1789450000000` | 压缩包原件存为 `model-archive` 资产 → `models.source_asset_id`;说明 → `models.conversion_note` | -| 失败 | `createFromArchive()` | 无 `.pmx` / Assimp 不可用 / 两种格式都失败 → **HTTP 422**,不产生模型记录 | -| 文件名编码 | `common/upload.util.ts` `decodeUploadName()` | 修正 multipart 文件名的 latin1/UTF-8 乱码(中文文件名原先存成 `模å_…`) | -| 管理端 | `admin/src/pages/Models.tsx` | `accept` 增加 `.zip`;提示会自动转换并设为默认;预览面板显示「转换来源」与「文件 sha256」 | -| DTO | `conversionNote` / `fileSha256` | 契约 `data-models.md` §4、`rest-api.yaml` | +## 实测(v0.2.0) -### 实测(Assimp 6.0.4) - -- 上传 `示例文件/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`(1,093,100 字节) - → **HTTP 201**、0.89s、`format=glb`、`isPrimary=true`、`status=published`。 -- 服务端日志:FBX 导出失败(退出码 11,产物回读报 - `FBX-DOM no Objects dictionary found`)→ 自动回退 GLB,回读通过。 -- 入库产物:**1,152,464 字节、238 关节、2 张贴图已内嵌**, - `conversionNote` = `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图`。 -- 源码种子:id=16、`初音未来 Hatsune Miku`、`glb`、 - sha256=`6d0ab0dce97c43dd7de84aba29b09f3b64f2953722d31b5421a39189ff2a8464`、 - `convertedFrom` = `Hatsune Miku.pmx → GLB`。 -- 默认模型解析:库里有 2 个 VRM 的情况下,`GET /api/catalog/export` 的 - `defaultModelId` 仍为 **16**(源码种子按 sha256 命中)。 +- 上传含 `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` @@ -99,46 +146,43 @@ 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 -# 上传 → 服务端转换入库 → 设为默认 → 写 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 16 --dry-run +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 ``` -上传时固定带 `autoDefault=true`;`--url` 默认 `http://127.0.0.1:3000`, -`--source-type` 默认 `other`。 - -## VRM 版本识别 - -`vrm-inspector.ts` 直接读取 GLB 头部 JSON chunk,不依赖完整 3D 库: - -- `extensions.VRMC_vrm` → VRM **1.0**(取 `specVersion`) -- `extensions.VRM` → VRM **0.x** -- 两者皆无 → `null`(普通 GLB) +上传一律 pending,因此要让 CLI 写出的种子在 `defaultModelId` 中生效,仍需审核通过 +(勾选「同时设为全局默认模型」)或调用 `POST /api/models/:id/primary`; +`--url` 默认 `http://127.0.0.1:3000`,`--source-type` 默认 `other`。 ## 验收 ```bash cd web-server -npm run test # 单元测试 125 项 / 7 个文件 +npx vitest run # 10 个文件 / 168 项通过 npm run test:smoke # 冒烟 [4] 段落 ``` | 验收项 | 状态 | |---|---| -| 上传 `.zip` 自动转换入库 | ✅ 实测 HTTP 201、0.89s、`format=glb`、`isPrimary=true`、`status=published` | -| FBX 失败自动回退 GLB | ✅ Assimp 6.0.4 退出码 11 + 回读失败 → 回退 GLB,`conversionNote` 记录两次尝试 | -| GLB 贴图内嵌 | ✅ 2 张贴图内嵌进 BIN chunk(产物 1,152,464 字节、238 关节) | -| 默认模型写入源码并按种子解析 | ✅ 种子 id=16;库里有 2 个 VRM 时 `defaultModelId` 仍为 16 | -| 失败语义 | ✅ 无 `.pmx` / Assimp 不可用 / 两格式均失败 → HTTP 422,不产生模型记录 | -| ZIP 安全 | ✅ 拒绝 zip-slip、加密条目、未知压缩方式(`model-archive.spec.ts`) | -| 文件名编码修复 | ✅ `decodeUploadName()` 修正中文文件名乱码 | +| `.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`(**22 项**)覆盖:ZIP 解析 / zip-slip / 加密 / 未知压缩 -方式、主模型挑选规则、GLB 贴图内嵌、用**假 Assimp** 对照「FBX 导出失败」与 -「退出码 0 但产物损坏」两种回退路径、源码渲染 + `tsc` 语法校验。 - -`test/vrm-inspector.spec.ts`(6 例)覆盖:真实 GLB 构造下识别 1.0 / 0.0 / null、 -文本 glTF 不误判、过小文件报错、非 JSON 首 chunk 报错、上传 201、`isPrimary` -置位、缺来源 400、按格式过滤列表、列表含来源信息(管理端展示用)。 +`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 结构、蒙皮与降级路径。 diff --git a/tasks/007-upload-review.md b/tasks/007-upload-review.md index 385cb9a..8530f70 100644 --- a/tasks/007-upload-review.md +++ b/tasks/007-upload-review.md @@ -1,21 +1,24 @@ # T-WEB-007 上传审核流程 -- **状态**:done +- **状态**:done(v0.2.0 追加:模型审核面板 / 骨骼详细界面 / `setPrimary`) - **依赖**:T-WEB-003 / 004 / 005 ## 目标 对歌曲、动作、模型三类资产实施统一的状态机与审核流水,确保只有审核通过的资产 -被客户端消费。 +被客户端消费。模型上传**一律 `pending`**,审核是模型可见与成为默认模型的唯一入口。 ## load ``` DSH.md contracts/rest-api.yaml # 锚点:#/paths/~1api~1reviews -contracts/data-models.md # 锚点:§1.2 PublishStatus、§5 ReviewRecord +contracts/data-models.md # 锚点:§1.2 PublishStatus、§4.1 默认模型选择规则、§5 ReviewRecord contracts/database.md # 锚点:状态机 web-server/src/reviews/* +web-server/src/models/model-inspector.ts +web-server/admin/src/components/ModelReviewPanel.tsx +web-server/admin/src/lib/bone-tree.ts ``` ## 状态机(契约冻结) @@ -31,7 +34,7 @@ draft ──submit──▶ pending ──approve──▶ published | GET | `/api/reviews` | 不传 `status` → 审核流水;传 `status` → 该状态待审资产(三类合并) | | GET | `/api/reviews/:targetType/:id/history` | 单个资产的审核历史 | | POST | `/api/reviews/:targetType/:id/submit` | → `pending` | -| POST | `/api/reviews/:targetType/:id/approve` | → `published` | +| POST | `/api/reviews/:targetType/:id/approve` | → `published`;**模型**可带 `setPrimary:true` | | POST | `/api/reviews/:targetType/:id/reject` | → `rejected`,**必须填写意见** | | POST | `/api/reviews/:targetType/:id/withdraw` | → `draft` | @@ -44,12 +47,58 @@ draft ──submit──▶ pending ──approve──▶ published `reviewer` / `comment`。 - 三类资产的直连 PATCH 也会校验状态机,避免绕过审核流程。 - `/api/catalog/export` 与 `/api/catalog/version` 只导出 `published`。 +- **模型上传只能 pending**:`ModelsService.create()` 固定 + `status=PublishStatus.Pending` + `isPrimary=false`,请求体里的 `autoDefault` + 等旁路一律不生效(硬性约束,见 DSH.md §6.9)。 + +## 模型审核面板(v0.2.0) + +待审列表只有 `PendingItem`(不含模型详情),面板用 `item.targetId` 现拉 +`GET /api/models/:id`(`admin/src/components/ModelReviewPanel.tsx`): + +| 区域 | 内容 | +|---|---| +| 基本 | 缩略图、格式、状态、文件地址、转换说明 | +| 指标 | 体积(`fileSize`)/ 格式 / 容器 / VRM 版本 / 面数 / 顶点 / 网格 / 材质 / 贴图 / 骨骼 / 节点 / 形变目标 / 动画 / 图元 / 蒙皮 / 骨骼最大层级 | +| 可驱动性 | 结论横幅:16 根必需 humanoid 骨齐全 → 可驱动;不全 → 列出缺哪些(直接标红);非 glTF 容器 → 只能审核来源与体积 | +| humanoid 覆盖 | 已映射 / 总数(22)+ 进度条 + 已命中规范名徽章明细 | +| warnings | `ModelStats.warnings` 逐条列出 | +| 骨骼详细界面 | 扁平骨骼树按 `depth` 缩进(14px/层),显示骨骼名、是否蒙皮徽章、humanoid 映射徽章、子节点数;支持按名搜索(保留祖先链)、「仅显示蒙皮骨骼」开关、限高滚动 + **只渲染前 400 行**(超出提示还有多少根),千根骨骼不卡 | +| stats 为空 | 提示"暂无分析数据(旧数据或后端尚未完成分析)",只能按格式/体积/来源判断 | + +### 通过 / 驳回 / 设为默认 + +- 面板底部勾选「同时设为全局默认模型」→ 点「通过」时请求体带 `setPrimary:true`: + `ReviewsService.transition()` 保存模型为 `published` 后调用 + `ModelsService.setPrimary(id)`(同一事务清其余 `is_primary`),并把身份写进源码 + `src/config/default-model.ts`。 +- 单独设置走 `POST /api/models/:id/primary`:**仅 `published` 允许,否则 409**。 +- 驳回必须填意见(400)。 + +### 骨骼树过滤(`admin/src/lib/bone-tree.ts`,纯函数、可单测) + +- `filterBoneTree(tree, keyword, onlySkin)`:先按关键字(大小写不敏感)与 `onlySkin` + 取命中集合,再沿父链补齐**祖先链**,保证按 `depth` 缩进的树不断裂;空关键字 + + 非 `onlySkin` 时直接返回副本。 +- `boneTreeStats(tree)`:总数 / 蒙皮数 / 已映射 humanoid 数 / 最大层级。 ## 验收 ```bash +cd web-server +npx vitest run # 10 个文件 / 168 项通过(含 admin-bone-filter 8 项) npm run test:smoke ``` 冒烟 [5] 段落:submit → pending、待审列表包含、驳回无意见 400、approve → published、 重复审核 409、流水恰好 2 条。 + +| 验收项 | 状态 | +|---|---| +| 模型上传只能是 pending | ✅ `create()` 固定 `PublishStatus.Pending`,不自动发布/设默认 | +| 模型审核面板展示指标 + 分析 | ✅ 体积/格式/容器/VRM 版本/面数/顶点/网格/材质/贴图/骨骼/节点/形变目标/动画 | +| 骨骼详细界面与过滤 | ✅ 缩进 + 蒙皮/映射徽章 + 搜索保留祖先链 + 仅蒙皮开关 + 限高 + 前 400 行(`admin-bone-filter.spec.ts` 8 项) | +| `setPrimary` 随审核通过生效 | ✅ `POST /api/reviews/model/22/approve` + `setPrimary:true` → 200、`status=published`、`isPrimary=true`,源码种子写入 `sourceSha256=72eb6882…` | +| 单独设默认仅 published | ✅ `POST /api/models/:id/primary` 非 published → 409 | +| 驳回必须填意见 | ✅ 无 `comment` → 400 | +| 目录重建后默认模型 | ✅ `GET /api/catalog/export` 的 `defaultModelId` 由 VRM 变为 **22**(库中另有 2 个已发布 VRM) | diff --git a/tasks/010-deploy.md b/tasks/010-deploy.md index 6491216..527aedc 100644 --- a/tasks/010-deploy.md +++ b/tasks/010-deploy.md @@ -178,8 +178,19 @@ install.sh 检测到缺 deploy/.env `ReadWritePaths=/var/lib/opendance`,进程只能写自己的存储目录 - **上传上限两侧一致**:后端 `UPLOAD_MAX_BYTES=536870912`(512 MiB), 反代侧 `client_max_body_size` / `maxAllowedContentLength` 必须不小于此值 +- **模型转换配置**(`deploy/.env.example` 已写入): + - `ASSIMP_BIN=assimp`:上传 `.zip` 模型包或 `.pmx`/`.fbx` 单文件时**必须**能调用到, + 否则转换失败 **422** + - `MODEL_CONVERT_FORMAT=glb`(默认)|`vrm`:只影响需要转换的 PMX/FBX 的交付格式。 + `glb` 通用,两个客户端都原生加载;`vrm` 在 GLB 之上补 `VRMC_vrm` 扩展 + (humanoid 骨骼 / 表情语义),**humanoid 骨骼名映射不全时自动回退交付 GLB** + 并写进 `models.conversion_note`。产物一律 `assimp info` 回读校验 + - `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS` 语义见 README §四 - **迁移用编译产物**:`web-server/scripts/migrate.mjs` 检测到 `dist/` 时走 - `typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies + `typeorm -d dist/db/data-source.js`,无需在服务器上装 devDependencies; + 本版本迁移共 3 个:`InitSchema1789382903060`、 + `AddModelConversionFields1789450000000`、**`AddModelStats1789460000000`** + (新增 `models.stats TEXT`,存模型分析结果,已在真实库执行) - **依赖安装显式带 dev**:安装脚本先 `set -a; . deploy/.env` 导出了 `NODE_ENV=production`,会让 npm 跳过 devDependencies 导致 `tsc: not found`; 故用 `env -u NODE_ENV npm ci --include=dev` @@ -202,7 +213,8 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200 |---|---| | `deploy/install.sh` 脚本语法与幂等性 | ✅ `bash -n` 通过;重复执行会覆盖程序文件、保留 `.env` | | 连接现有 MySQL 成功 | ✅ 已用真实实例验证(`/health` 200,`database.connected=true`) | -| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证 | +| TypeORM migration 执行成功 | ✅ 5 张业务表已创建并验证;v0.2.0 的 `AddModelStats1789460000000`(`models.stats TEXT`)已在真实库执行 | +| 模型转换配置 | ✅ `deploy/.env.example` 写入 `ASSIMP_BIN` / `MODEL_CONVERT_FORMAT=glb|vrm` / `MODEL_CONVERT_PMX` / `MODEL_CONVERT_TIMEOUT_MS`;不合规输入转换失败 422 | | `curl http://:3000/health` 通过 | ✅ 直连后端端口已验证 | | 软件 API 端口 `/` 返回 404 | ✅ `PORT=3000` 上 `/`、`/songs` 均 404(有意设计,无管理页面) | | 管理端端口 `/` 返回 200 | ✅ `ADMIN_PORT=3001` 上 `/`、`/songs`、`/models` 均返回同一份 HTML(200) | @@ -236,7 +248,7 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200 ```bash ./scripts/package-release.sh -# → dist-release/opendance-web-v0.1.0.tar.gz +# → dist-release/opendance-web-v0.2.0.tar.gz ``` 内容:`web-server/`(源码 + dist)、`admin/`(构建产物)、`deploy/` @@ -244,3 +256,10 @@ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3001/ # 期望 200 打包脚本会对产物做硬校验:`src/**/*.ts` 必须都有对应 `dist/*.js`, 关键文件(`dist/main.js`、`dist/storage/storage.module.js`、`admin/index.html`、 `deploy/proxy/*`)必须齐全,且不得含真实 `.env` 与 Docker/nginx 安装配置。 +v0.2.0 起校验清单追加模型新模块:`dist/models/model-inspector.js`、 +`dist/models/model-thumbnail.js`、`dist/models/vrm-writer.js`、 +`dist/models/gltf-packer.js`、`dist/migrations/1789460000000-AddModelStats.js`。 + +> **发布包名变更**:`opendance-web-v0.1.0.tar.gz` → **`opendance-web-v0.2.0.tar.gz`** +> (`scripts/package-release.sh` 的 `VERSION` 默认值已改为 `0.2.0`, +> 与 `project.yaml` 的 `version: 0.2.0`、`web-server/package.json` 一致)。 diff --git a/web-server/.env.example b/web-server/.env.example index 04f2d77..fba31a5 100644 --- a/web-server/.env.example +++ b/web-server/.env.example @@ -45,8 +45,8 @@ UPLOAD_MAX_BYTES=536870912 # 未安装时 FBX 动作的预处理会以失败状态返回,不影响 BVH/VMD; # 但上传 .zip 模型包时必须能调用到它 ASSIMP_BIN=assimp -# 压缩包转换的目标格式:fbx(优先)| glb(Assimp FBX 导出失败时自动回退) -MODEL_CONVERT_FORMAT=fbx +# PMX/FBX 的交付格式:glb(默认)| vrm +MODEL_CONVERT_FORMAT=glb # 压缩包里优先选用的 PMX 文件名 MODEL_CONVERT_PMX=Hatsune Miku.pmx # 单次转换超时(毫秒) diff --git a/web-server/admin/package-lock.json b/web-server/admin/package-lock.json index 0a0dd04..ca0b69c 100644 --- a/web-server/admin/package-lock.json +++ b/web-server/admin/package-lock.json @@ -1,12 +1,12 @@ { "name": "opendance-admin", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opendance-admin", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@pixiv/three-vrm": "^3.5.5", "react": "^18.3.1", diff --git a/web-server/admin/package.json b/web-server/admin/package.json index 27d4b4e..fafe0f0 100644 --- a/web-server/admin/package.json +++ b/web-server/admin/package.json @@ -1,7 +1,7 @@ { "name": "opendance-admin", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "OpenDance Studio 管理端(React + TypeScript + Vite)", "scripts": { diff --git a/web-server/admin/src/api/endpoints.ts b/web-server/admin/src/api/endpoints.ts index 5699efa..d01fcc5 100644 --- a/web-server/admin/src/api/endpoints.ts +++ b/web-server/admin/src/api/endpoints.ts @@ -167,6 +167,11 @@ export function updateModel(id: number, payload: ModelUpdate): Promise { return apiPatch(`/api/models/${id}`, payload); } +/** GET /api/models/:id —— 审核面板用它拉详情(stats 可能为 null,旧数据未分析)。 */ +export function getModel(id: number): Promise { + return apiGet(`/api/models/${id}`); +} + export function deleteModel(id: number): Promise { return apiDelete(`/api/models/${id}`); } diff --git a/web-server/admin/src/api/types.ts b/web-server/admin/src/api/types.ts index 10caf0d..62de3e1 100644 --- a/web-server/admin/src/api/types.ts +++ b/web-server/admin/src/api/types.ts @@ -36,6 +36,10 @@ export type ReviewAction = (typeof REVIEW_ACTIONS)[number]; export const SONG_LANGUAGES = ['zh', 'en', 'ja', 'ko', 'instrumental'] as const; export type SongLanguage = (typeof SONG_LANGUAGES)[number]; +/** 模型文件实际的容器类型;unknown 表示后端无法按 glTF 解析(如 fbx 原文件)。 */ +export const MODEL_CONTAINERS = ['glb', 'gltf', 'vrm', 'unknown'] as const; +export type ModelContainer = (typeof MODEL_CONTAINERS)[number]; + /* ------------------------------------------------------------- 通用结构 */ /** 来源五元组:songs / motions / models 三类资产均必填。 */ @@ -106,6 +110,51 @@ export interface Motion extends SourceFields { updatedAt: string; } +/** + * 骨骼树节点:`ModelStats.boneTree` 是**扁平数组**,父在前、子在后(先序遍历), + * 按 `depth` 缩进即可还原成树,无需递归。 + */ +export interface BoneNode { + index: number; + name: string; + /** 根为 0 */ + depth: number; + parentName: string | null; + childCount: number; + /** 是否真正参与蒙皮(skins[].joints),未参与的多为辅助 / 空节点 */ + inSkin: boolean; + /** 映射到的 humanoid 规范名(如 leftUpperArm);没映射到为 null */ + humanoid: string | null; +} + +/** GET /api/models/:id 的分析结果(后端 model-inspector 计算)。 */ +export interface ModelStats { + container: ModelContainer; + vrmVersion: string | null; + fileSize: number; + meshes: number; + primitives: number; + vertices: number; + triangles: number; + materials: number; + textures: number; + images: number; + animations: number; + morphTargets: number; + nodes: number; + bones: number; + skins: number; + humanoidMapped: number; + humanoidTotal: number; + /** 驱动客户端 33 点所必需的 16 根里缺哪些 */ + missingRequiredBones: string[]; + drivable: boolean; + maxBoneDepth: number; + /** 扁平骨骼树,父在前、子在后,按 depth 缩进 */ + boneTree: BoneNode[]; + warnings: string[]; +} + export interface Model extends SourceFields { id: number; name: string; @@ -119,6 +168,8 @@ export interface Model extends SourceFields { /** 模型文件 sha256 */ fileSha256: string | null; thumbnailUrl: string | null; + /** 模型分析结果(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性);旧数据可能为 null */ + stats: ModelStats | null; status: PublishStatus; createdAt: string; updatedAt: string; @@ -204,6 +255,8 @@ export type PendingPage = Page; export interface ReviewActionBody { reviewer?: string; comment?: string; + /** 仅 targetType=model 的通过动作有效:同时把该模型设为全局默认模型。 */ + setPrimary?: boolean; } /* ------------------------------------------------------------ 目录/健康 */ diff --git a/web-server/admin/src/components/ModelReviewPanel.tsx b/web-server/admin/src/components/ModelReviewPanel.tsx new file mode 100644 index 0000000..b8be779 --- /dev/null +++ b/web-server/admin/src/components/ModelReviewPanel.tsx @@ -0,0 +1,373 @@ +/** + * 模型审核面板(T-WEB-005 追加)。 + * + * 待审列表只给 PendingItem(不含模型详情),所以这里用 item.targetId 现拉 + * `GET /api/models/:id`。骨骼是后期游玩评分的基础,因此骨骼树是本面板的重点: + * 扁平数组 + depth 缩进渲染,支持按名搜索(保留祖先链)、仅看蒙皮骨骼、 + * 限高滚动 + 只渲染前 N 行,千根骨骼也不会卡住页面。 + */ + +import { useMemo, useState, type ReactNode } from 'react'; +import { getModel } from '../api/endpoints'; +import type { Model, ModelStats, PendingItem } from '../api/types'; +import { filterBoneTree, boneTreeStats } from '../lib/bone-tree'; +import { formatBytes, formatCount } from '../lib/format'; +import { MODEL_CONTAINER_LABELS, drivableSummary, type DrivableSummary } from '../lib/labels'; +import { useAsync } from '../lib/useAsync'; +import { ErrorBox, FormatBadge, Loading, Notice, StatusBadge } from './ui'; + +/** 骨骼树一次最多渲染的行数,超出部分提示「还有 X 根未显示」。 */ +const BONE_ROW_LIMIT = 400; + +/** 每层缩进像素。 */ +const BONE_INDENT_PX = 14; + +const BANNER_CLASS: Record = { + ok: 'banner-ok', + warn: 'banner-warn', + error: 'banner-danger', +}; + +const BANNER_ICON: Record = { + ok: '✅', + warn: '⚠', + error: '⛔', +}; + +/* ------------------------------------------------------------ 指标卡片 */ + +function Metric({ + label, + value, + hint, + strong = false, +}: { + label: string; + value: ReactNode; + hint?: string; + strong?: boolean; +}) { + return ( +
+ {label} + {value} + {hint ? {hint} : null} +
+ ); +} + +/* ------------------------------------------------------------ 骨骼树 */ + +function BoneTreeSection({ stats }: { stats: ModelStats }) { + const [keyword, setKeyword] = useState(''); + const [onlySkin, setOnlySkin] = useState(false); + + const filtered = useMemo( + () => filterBoneTree(stats.boneTree, keyword, onlySkin), + [stats.boneTree, keyword, onlySkin], + ); + const summary = useMemo(() => boneTreeStats(stats.boneTree), [stats.boneTree]); + + // 大数据量保护:限高滚动之外,再只渲染前 N 行。 + const shown = filtered.slice(0, BONE_ROW_LIMIT); + const hidden = filtered.length - shown.length; + + return ( +
+
+

骨骼树

+ 先序遍历,父在前、子在后,按层级缩进 +
+ +
+ setKeyword(event.target.value)} + /> + +
+ +
+ + 总数 {formatCount(summary.total)} + + + 蒙皮 {formatCount(summary.skin)} + + + 已映射 humanoid {formatCount(summary.mapped)} + + + 最大层级 {formatCount(summary.maxDepth)} + + {keyword.trim() !== '' ? ( + + 命中 {formatCount(filtered.length)} + + ) : null} +
+ + {filtered.length === 0 ? ( +

没有匹配的骨骼。

+ ) : ( +
+ {shown.map((node, position) => ( +
+ {node.name || '(无名节点)'} + + {node.inSkin ? '蒙皮' : '未使用'} + + {node.humanoid ? ( + {node.humanoid} + ) : ( + 未映射 + )} + 子 {node.childCount} +
+ ))} +
+ )} + + {hidden > 0 ? ( +

+ 还有 {formatCount(hidden)} 根未显示(共 {formatCount(filtered.length)} 行,仅渲染前{' '} + {formatCount(BONE_ROW_LIMIT)} 行);可用搜索或「仅显示蒙皮骨骼」缩小范围。 +

+ ) : null} + {onlySkin ? ( +

+ 已开启「仅显示蒙皮骨骼」:命中骨骼的祖先链会一并保留,因此个别「未使用」节点可能作为祖先出现。 +

+ ) : null} +
+ ); +} + +/* ------------------------------------------------ 分析结果(stats 区) */ + +function ModelStatsView({ model, stats }: { model: Model; stats: ModelStats }) { + const drivable = drivableSummary(stats); + const coverage = + stats.humanoidTotal > 0 + ? Math.min(100, Math.max(0, Math.round((stats.humanoidMapped / stats.humanoidTotal) * 100))) + : 0; + + const humanoidNames = useMemo(() => { + const names = new Set(); + for (const bone of stats.boneTree) { + if (bone.humanoid !== null && bone.humanoid !== '') names.add(bone.humanoid); + } + return [...names].sort(); + }, [stats.boneTree]); + + const warnings = stats.warnings ?? []; + + return ( + <> + {/* 关键指标 */} +
+ + } /> + + + + + + + + + + + + + + +
+ + {/* 可驱动性结论 */} +
+ + {BANNER_ICON[drivable.kind]} {drivable.text} + + {drivable.kind === 'ok' ? ( + + humanoid 已映射 {formatCount(stats.humanoidMapped)}/{formatCount(stats.humanoidTotal)} 根, + 骨骼树 {formatCount(stats.bones)} 根,最大层级 {formatCount(stats.maxBoneDepth)}。 + + ) : null} + {drivable.kind === 'error' ? ( + 驱动客户端 33 个关键点需要 16 根必需骨骼全部映射,缺失会导致无法计分。 + ) : null} + {drivable.kind === 'warn' ? ( + 该文件不是 glTF 容器(glb/gltf/vrm),只能审核来源与体积,无法核对骨骼。 + ) : null} +
+ + {/* humanoid 覆盖 */} +
+
+

humanoid 覆盖

+ + {formatCount(stats.humanoidMapped)} / {formatCount(stats.humanoidTotal)}({coverage}%) + +
+
+
+
+ {humanoidNames.length > 0 ? ( +
+ {humanoidNames.map((name) => ( + + {name} + + ))} +
+ ) : ( +

没有任何骨骼映射到 humanoid 规范名。

+ )} +
+ + {/* warnings */} + {warnings.length > 0 ? ( +
    + {warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+ ) : null} + + {/* 骨骼详细界面 */} + + + ); +} + +/* ---------------------------------------------------------------- 面板 */ + +export interface ModelReviewPanelProps { + item: PendingItem; + onClose: () => void; + /** 点「通过」时回调;setPrimary 为勾选的「同时设为全局默认模型」。 */ + onApprove: (setPrimary: boolean) => void; + onReject: () => void; +} + +export function ModelReviewPanel({ item, onClose, onApprove, onReject }: ModelReviewPanelProps) { + const state = useAsync(() => getModel(item.targetId), [item.targetId]); + const [setPrimary, setSetPrimary] = useState(false); + const [thumbFailed, setThumbFailed] = useState(false); + + const model = state.data; + const stats = model?.stats ?? null; + const thumbnail = model?.thumbnailUrl && !thumbFailed ? model.thumbnailUrl : null; + + return ( +
+
+
+

模型审核 · {item.name}

+
+ #{item.targetId} · GET /api/models/{item.targetId} +
+
+ +
+ +
+ {state.error ? : null} + {state.loading && !model ? : null} + + {model ? ( + <> +
+ {thumbnail ? ( + {`${model.name} setThumbFailed(true)} + /> + ) : ( +
暂无预览图
+ )} +
    +
  • + 格式 + + + +
  • +
  • + 状态 + + + +
  • +
  • + 文件地址 + {model.fileUrl ?? '—'} +
  • + {model.conversionNote ? ( +
  • + 转换说明 + {model.conversionNote} +
  • + ) : null} +
+
+ + {stats ? ( + + ) : ( + + 该模型暂无分析数据(stats 为空,可能是旧数据或后端尚未完成分析),只能依据格式、体积与来源判断, + 无法核对骨骼与可驱动性。 + + )} + + ) : null} +
+ +
+ +
+ + +
+
+
+ ); +} diff --git a/web-server/admin/src/lib/bone-tree.ts b/web-server/admin/src/lib/bone-tree.ts new file mode 100644 index 0000000..a7cdb38 --- /dev/null +++ b/web-server/admin/src/lib/bone-tree.ts @@ -0,0 +1,100 @@ +/** + * 骨骼树过滤(纯函数,无 React / DOM 依赖,便于 vitest 直接单测)。 + * + * `ModelStats.boneTree` 是**扁平数组、父在前子在后**(先序遍历),所以过滤时 + * 不能简单地 `filter`:一旦命中叶子,若把它到根的祖先链丢掉,渲染出来的树就断了。 + * 本模块保留「命中节点 + 其全部祖先」。 + */ + +import type { BoneNode } from '../api/types'; + +/** + * 计算每个节点的父节点下标。 + * + * 主算法用先序遍历的性质:父节点必定是**前面最近的 depth - 1 的节点**, + * 用一个「每层最后一个节点」的栈 O(n) 求出,比按名字找父更稳(名字可能重复)。 + * depth 数据异常时(例如 depth 跳级)再退回 parentName 查找。 + */ +function buildParentIndex(tree: readonly BoneNode[]): number[] { + const parentIndex: number[] = new Array(tree.length).fill(-1); + const lastNameIndex = new Map(); + const lastIndexAtDepth: Array = []; + + for (let index = 0; index < tree.length; index += 1) { + const node = tree[index]; + const depth = Number.isFinite(node.depth) ? Math.max(0, Math.trunc(node.depth)) : 0; + + let parent: number | undefined = depth > 0 ? lastIndexAtDepth[depth - 1] : undefined; + if (parent === undefined && node.parentName !== null && node.parentName !== '') { + const byName = lastNameIndex.get(node.parentName); + if (byName !== undefined && byName !== index) parent = byName; + } + parentIndex[index] = parent === undefined || parent === index ? -1 : parent; + + lastIndexAtDepth[depth] = index; + if (!lastNameIndex.has(node.name)) lastNameIndex.set(node.name, index); + } + + return parentIndex; +} + +/** + * 按名字过滤骨骼树,并保留命中节点的祖先链。 + * + * @param tree 扁平骨骼树(父在前、子在后) + * @param keyword 搜索关键字(大小写不敏感,中英混排按子串匹配;空白视为空) + * @param onlySkin true 时只保留 `inSkin` 的骨骼(其祖先仍会保留,避免树断裂) + * @returns 过滤后的新数组,保持原有先后顺序,且不会与命中节点重复 + */ +export function filterBoneTree( + tree: readonly BoneNode[], + keyword: string, + onlySkin = false, +): BoneNode[] { + const needle = keyword.trim().toLowerCase(); + + // 无条件全量:连祖先计算都省掉,直接返回副本,避免调用方改动原数组。 + if (needle === '' && !onlySkin) return [...tree]; + + const parentIndex = buildParentIndex(tree); + + // 1) 命中集合:关键字命中(空关键字即全部)且通过 onlySkin 条件。 + const keep: boolean[] = new Array(tree.length).fill(false); + const pending: number[] = []; + for (let index = 0; index < tree.length; index += 1) { + const node = tree[index]; + if (onlySkin && !node.inSkin) continue; + if (needle !== '' && !node.name.toLowerCase().includes(needle)) continue; + pending.push(index); + } + + // 2) 沿父链向上补齐祖先;keep 同时用于防止异常数据成环时死循环。 + while (pending.length > 0) { + const index = pending.pop() as number; + if (keep[index]) continue; + keep[index] = true; + const parent = parentIndex[index]; + if (parent >= 0) pending.push(parent); + } + + // 3) 按原顺序输出,父仍在子之前。 + return tree.filter((_node, index) => keep[index]); +} + +/** 骨骼树统计(总数 / 蒙皮数 / 已映射 humanoid 数 / 最大层级),供统计条使用。 */ +export function boneTreeStats(tree: readonly BoneNode[]): { + total: number; + skin: number; + mapped: number; + maxDepth: number; +} { + let skin = 0; + let mapped = 0; + let maxDepth = 0; + for (const node of tree) { + if (node.inSkin) skin += 1; + if (node.humanoid !== null && node.humanoid !== '') mapped += 1; + if (Number.isFinite(node.depth) && node.depth > maxDepth) maxDepth = node.depth; + } + return { total: tree.length, skin, mapped, maxDepth }; +} diff --git a/web-server/admin/src/lib/format.ts b/web-server/admin/src/lib/format.ts index 042875c..5129cb9 100644 --- a/web-server/admin/src/lib/format.ts +++ b/web-server/admin/src/lib/format.ts @@ -38,6 +38,12 @@ export function formatNumber(value: number | null | undefined, digits = 2): stri return String(Number(value.toFixed(digits))); } +/** 整数千分位(面数 / 顶点数等);null / 非有限值返回 '—'。 */ +export function formatCount(value: number | null | undefined): string { + if (value === null || value === undefined || !Number.isFinite(value)) return '—'; + return Math.round(value).toLocaleString('en-US'); +} + export function tagsToText(tags: readonly string[] | null | undefined): string { if (!tags || tags.length === 0) return ''; return tags.join(', '); diff --git a/web-server/admin/src/lib/labels.ts b/web-server/admin/src/lib/labels.ts index cd16692..3cd0af1 100644 --- a/web-server/admin/src/lib/labels.ts +++ b/web-server/admin/src/lib/labels.ts @@ -1,7 +1,9 @@ /** 枚举 → 中文展示文案。 */ import type { + ModelContainer, ModelFormat, + ModelStats, MotionFormat, PreprocessState, PublishStatus, @@ -66,6 +68,41 @@ export const MODEL_FORMAT_LABELS: Record = { fbx: 'FBX', }; +/** 模型分析出的实际容器类型(可能与上传格式不同,例如 .zip → 转换后的 glb)。 */ +export const MODEL_CONTAINER_LABELS: Record = { + glb: 'GLB(二进制 glTF)', + gltf: 'glTF(JSON)', + vrm: 'VRM(glTF 扩展)', + unknown: '未知(非 glTF 容器)', +}; + +/** 可驱动性结论:ok / warn / error 对应绿 / 黄 / 红。 */ +export interface DrivableSummary { + kind: 'ok' | 'warn' | 'error'; + text: string; +} + +/** + * 由 stats 归纳可驱动性结论,供审核面板与模型列表共用。 + * 顺序固定:可驱动 → 绿;非 glTF 容器 → 黄;其余 → 红(缺哪些必需骨骼)。 + */ +export function drivableSummary(stats: ModelStats): DrivableSummary { + if (stats.drivable) { + return { kind: 'ok', text: '可用于驱动与评分' }; + } + if (stats.container === 'unknown') { + return { kind: 'warn', text: '非 glTF 容器,无法解析骨骼' }; + } + const missing = stats.missingRequiredBones ?? []; + return { + kind: 'error', + text: + missing.length > 0 + ? `无法驱动:缺 ${missing.join('、')}` + : '无法驱动:humanoid 必需骨骼映射不完整', + }; +} + export const TARGET_TYPE_LABELS: Record = { song: '曲目', motion: '动作', diff --git a/web-server/admin/src/pages/Models.tsx b/web-server/admin/src/pages/Models.tsx index d8b7120..76a11b8 100644 --- a/web-server/admin/src/pages/Models.tsx +++ b/web-server/admin/src/pages/Models.tsx @@ -1,10 +1,10 @@ import { lazy, Suspense, useState, type FormEvent } from 'react'; import { errorMessage } from '../api/client'; import { deleteModel, listModels, setPrimaryModel, updateModel, uploadModel } from '../api/endpoints'; -import type { Model, ModelUpdate, PublishStatus } from '../api/types'; +import type { Model, ModelFormat, ModelUpdate, PublishStatus } from '../api/types'; import { MODEL_FORMATS } from '../api/types'; -import { formatBytes, formatDateTime } from '../lib/format'; -import { MODEL_FORMAT_LABELS, STATUS_OPTIONS } from '../lib/labels'; +import { formatBytes, formatCount, formatDateTime } from '../lib/format'; +import { MODEL_FORMAT_LABELS, STATUS_OPTIONS, drivableSummary, type DrivableSummary } from '../lib/labels'; import { hasErrors, requireText, validateSourceFields, type FieldErrors } from '../lib/validation'; import { useAsync } from '../lib/useAsync'; import { @@ -32,6 +32,16 @@ const VrmPreview = lazy(async () => { const PAGE_SIZE = 20; const ACCEPT_EXT = '.vrm,.glb,.gltf,.fbx,.zip'; +/** 能用 GLTFLoader 预览的格式:GLB / glTF 与 VRM 同属 glTF 家族,仅 FBX 不支持。 */ +const PREVIEWABLE_FORMATS: ReadonlyArray = ['vrm', 'glb', 'gltf']; + +/** 可驱动性 → 徽标配色(绿 / 黄 / 红)。 */ +const DRIVABLE_BADGE: Record = { + ok: 'badge-published', + warn: 'badge-pending', + error: 'badge-rejected', +}; + interface ModelFilters { q: string; format: string; @@ -344,8 +354,8 @@ export function ModelsPage() { }; const previewUnsupported = - previewModel && previewModel.format === 'fbx' - ? 'FBX 无法用 GLTFLoader 预览(契约中 FBX 仅归档)。' + previewModel && !PREVIEWABLE_FORMATS.includes(previewModel.format) + ? `${MODEL_FORMAT_LABELS[previewModel.format]} 无法用 GLTFLoader 预览(契约中 FBX 仅归档,请先转换为 GLB/VRM)。` : null; return ( @@ -459,17 +469,18 @@ export function ModelsPage() { @@ -557,9 +568,10 @@ export function ModelsPage() {

- 通过 GLTFLoader + VRMLoaderPlugin 加载 fileUrl,VRM 0.x 自动执行 180° 旋转; + 通过 GLTFLoader + VRMLoaderPlugin 加载 fileUrl:VRM / GLB / glTF 同属 glTF + 家族,都走这条管线(非 VRM 的 GLB 按普通 glTF 渲染,VRM 0.x 自动执行 180° 旋转); 使用 requestAnimationFrame 循环调用 vrm.update(delta), - 卸载时释放渲染器、几何体、材质与贴图。 + 卸载时释放渲染器、几何体、材质与贴图。FBX 不在预览范围内。

{previewModel ? (
    @@ -577,6 +589,35 @@ export function ModelsPage() { 是否默认 {previewModel.isPrimary ? '是' : '否'} + {previewModel.stats ? ( + <> +
  • + 面数 / 顶点数 + + {formatCount(previewModel.stats.triangles)} 面 ·{' '} + {formatCount(previewModel.stats.vertices)} 点 + +
  • +
  • + 骨骼 / 节点 + + {formatCount(previewModel.stats.bones)} 根 ·{' '} + {formatCount(previewModel.stats.nodes)} 节点 + +
  • +
  • + 可驱动性 + + {drivableSummary(previewModel.stats).text} + +
  • + + ) : ( +
  • + 分析数据 + stats 为空(旧数据或后端尚未分析) +
  • + )} {previewModel.conversionNote ? (
  • 转换来源 diff --git a/web-server/admin/src/pages/Reviews.tsx b/web-server/admin/src/pages/Reviews.tsx index 240115d..4728858 100644 --- a/web-server/admin/src/pages/Reviews.tsx +++ b/web-server/admin/src/pages/Reviews.tsx @@ -6,6 +6,7 @@ import type { PendingItem, ReviewActionBody } from '../api/types'; import { formatDateTime } from '../lib/format'; import { REVIEW_ACTION_LABELS, STATUS_LABELS, TARGET_TYPE_LABELS } from '../lib/labels'; import { useAsync } from '../lib/useAsync'; +import { ModelReviewPanel } from '../components/ModelReviewPanel'; import { Empty, ErrorBox, @@ -26,6 +27,8 @@ type TabKey = 'pending' | 'history'; interface ActionTarget { item: PendingItem; action: 'approve' | 'reject'; + /** 仅模型通过时有意义:body 里带 setPrimary。 */ + setPrimary?: boolean; } /* ------------------------------------------------------------ 审核弹窗 */ @@ -45,6 +48,7 @@ function ReviewActionModal({ const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const isReject = target.action === 'reject'; + const setPrimary = !isReject && target.item.targetType === 'model' && target.setPrimary === true; const submit = async (): Promise => { if (isReject && comment.trim() === '') { @@ -55,13 +59,16 @@ function ReviewActionModal({ setError(null); try { const body: ReviewActionBody = { reviewer: reviewer.trim(), comment: comment.trim() }; + if (setPrimary) body.setPrimary = true; if (isReject) { await reviewReject(target.item.targetType, target.item.targetId, body); } else { await reviewApprove(target.item.targetType, target.item.targetId, body); } onDone( - `${isReject ? '已驳回' : '已通过'}「${target.item.name}」(${TARGET_TYPE_LABELS[target.item.targetType]} #${target.item.targetId})`, + `${isReject ? '已驳回' : '已通过'}「${target.item.name}」(${TARGET_TYPE_LABELS[target.item.targetType]} #${target.item.targetId})${ + setPrimary ? ',并已设为全局默认模型' : '' + }`, ); } catch (cause) { setError(errorMessage(cause)); @@ -103,6 +110,12 @@ function ReviewActionModal({ 审核人 reviewer {reviewer.trim() === '' ? '(空)' : reviewer}
  • + {setPrimary ? ( +
  • + 附加操作 + 同时设为全局默认模型(setPrimary: true) +
  • + ) : null}
(null); + const [modelPanel, setModelPanel] = useState(null); const [notice, setNotice] = useState<{ kind: 'success' | 'error'; text: string } | null>(null); useEffect(() => { @@ -150,6 +164,7 @@ export function ReviewsPage() { const switchTab = (next: TabKey): void => { setPage(1); + setModelPanel(null); setSearchParams(next === 'pending' ? {} : { tab: next }); }; @@ -214,80 +229,124 @@ export function ReviewsPage() { {tab === 'pending' ? ( -
- {pendingState.error ? : null} - {pendingState.loading && !pendingState.data ? : null} - {!pendingState.loading && !pendingState.error && pendingItems.length === 0 ? ( - - ) : null} +
+
+ {pendingState.error ? : null} + {pendingState.loading && !pendingState.data ? : null} + {!pendingState.loading && !pendingState.error && pendingItems.length === 0 ? ( + + ) : null} - {pendingItems.length > 0 ? ( -
- - - - - - - - - - - - - {pendingItems.map((item) => ( - - - - - - - + {pendingItems.length > 0 ? ( +
+
类型名称状态来源五元组更新时间操作
{TARGET_TYPE_LABELS[item.targetType] ?? item.targetType} -
{item.name}
-
- #{item.targetId} · {item.targetType} -
-
- - - {item.source ? ( - - ) : ( - — - )} - {formatDateTime(item.updatedAt)} - - -
+ + + + + + + + - ))} - -
类型名称状态来源五元组更新时间操作
-
- ) : null} + + + {pendingItems.map((item) => { + const isModel = item.targetType === 'model'; + const panelOpen = + isModel && modelPanel !== null && modelPanel.targetId === item.targetId; + return ( + setModelPanel(item) : undefined} + > + {TARGET_TYPE_LABELS[item.targetType] ?? item.targetType} + +
{item.name}
+
+ #{item.targetId} · {item.targetType} +
+ + + + + + {item.source ? ( + + ) : ( + — + )} + + {formatDateTime(item.updatedAt)} + + {isModel ? ( + + ) : null} + + + + + ); + })} + + +
+ ) : null} - {pendingState.data ? ( - + ) : null} +
+ + {modelPanel ? ( + setModelPanel(null)} + onApprove={(setPrimary) => + setActionTarget({ item: modelPanel, action: 'approve', setPrimary }) + } + onReject={() => setActionTarget({ item: modelPanel, action: 'reject' })} /> ) : null} - + ) : (
{historyState.error ? : null} @@ -349,6 +408,7 @@ export function ReviewsPage() { onClose={() => setActionTarget(null)} onDone={(message) => { setActionTarget(null); + setModelPanel(null); setNotice({ kind: 'success', text: message }); reloadAll(); }} diff --git a/web-server/admin/src/styles.css b/web-server/admin/src/styles.css index 9b23483..ac1ba59 100644 --- a/web-server/admin/src/styles.css +++ b/web-server/admin/src/styles.css @@ -1175,3 +1175,313 @@ textarea { font-size: 11px; color: var(--text-dim); } + +/* ========================================================================== + 模型审核(T-WEB-005 追加):待审列表 + 右侧模型审核面板 + ========================================================================== */ + +.review-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(420px, 620px); + gap: 14px; + align-items: start; +} + +@media (max-width: 1440px) { + .review-layout { + grid-template-columns: minmax(0, 1fr); + } +} + +.row-clickable { + cursor: pointer; +} + +.check-field { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-dim); + cursor: pointer; + white-space: nowrap; +} + +.check-field input[type='checkbox'] { + width: auto; + height: 16px; + margin: 0; + flex: 0 0 auto; +} + +.panel-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + padding: 10px 16px; + border-top: 1px solid var(--border); +} + +.panel-foot-actions { + display: flex; + gap: 8px; +} + +/* -------------------------------------------------- 列表小缩略图 */ + +.thumb-link { + background: none; + border: none; + padding: 0; + cursor: pointer; + display: inline-flex; + align-items: center; +} + +.thumb-sm { + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--bg); + object-fit: cover; + display: block; +} + +/* -------------------------------------------------- 审核面板骨架 */ + +.model-review { + position: sticky; + top: 16px; + display: flex; + flex-direction: column; +} + +.model-review .panel-body { + gap: 14px; + max-height: 76vh; + overflow-y: auto; +} + +.model-review-top { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.model-review-thumb { + width: 116px; + height: 116px; + flex: 0 0 116px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg); + object-fit: cover; + display: block; +} + +.model-review-thumb-empty { + display: grid; + place-items: center; + text-align: center; + color: var(--text-mute); + font-size: 12px; +} + +.model-review-basic { + flex: 1; + min-width: 0; +} + +/* -------------------------------------------------- 关键指标卡片 */ + +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 8px; +} + +.metric { + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 7px 10px; + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.metric-strong { + border-color: rgba(76, 154, 255, 0.55); + background: rgba(76, 154, 255, 0.08); +} + +.metric-label { + font-size: 11px; + color: var(--text-mute); +} + +.metric-value { + font-family: var(--mono); + font-size: 14px; + font-weight: 600; + word-break: break-word; +} + +.metric-hint { + font-size: 11px; + color: var(--text-mute); +} + +.banner-ok { + background: rgba(63, 185, 80, 0.12); + border-color: rgba(63, 185, 80, 0.5); + color: #8fe0a0; +} + +.banner-danger { + background: rgba(248, 81, 73, 0.12); + border-color: rgba(248, 81, 73, 0.5); + color: #ffb3ae; +} + +/* -------------------------------------------------- humanoid 覆盖 */ + +.coverage { + display: flex; + flex-direction: column; + gap: 6px; +} + +.coverage-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.progress { + height: 8px; + border-radius: 999px; + background: var(--bg); + border: 1px solid var(--border); + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--accent-strong), var(--accent)); + transition: width 0.2s ease; +} + +.humanoid-names { + display: flex; + flex-wrap: wrap; + gap: 4px; + max-height: 120px; + overflow-y: auto; +} + +.badge-humanoid { + border-color: rgba(163, 113, 247, 0.6); + color: #cbb2ff; + background: rgba(163, 113, 247, 0.12); + font-family: var(--mono); +} + +.warn-list { + margin: 0; + padding-left: 18px; + color: #e8c26a; + font-size: 12px; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* -------------------------------------------------- 骨骼树 */ + +.bone-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bone-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.bone-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.bone-toolbar input[type='search'] { + flex: 1 1 200px; + min-width: 160px; +} + +.bone-stats { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + font-size: 12px; + color: var(--text-dim); +} + +.bone-stats strong { + font-family: var(--mono); + color: var(--text); +} + +.bone-tree { + max-height: 60vh; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); +} + +.bone-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + min-height: 22px; + font-size: 12px; + white-space: nowrap; + border-bottom: 1px solid rgba(38, 46, 57, 0.6); +} + +.bone-row:last-child { + border-bottom: none; +} + +.bone-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.bone-row .badge { + padding: 0 6px; + line-height: 16px; + font-size: 10px; +} + +.bone-name { + color: var(--text); + max-width: 45%; + overflow: hidden; + text-overflow: ellipsis; +} + +.bone-children { + margin-left: auto; +} diff --git a/web-server/package-lock.json b/web-server/package-lock.json index 7622cd7..37b25f1 100644 --- a/web-server/package-lock.json +++ b/web-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "opendance-web-server", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opendance-web-server", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "@nestjs/common": "^11.0.12", diff --git a/web-server/package.json b/web-server/package.json index 93c1f3d..16d4578 100644 --- a/web-server/package.json +++ b/web-server/package.json @@ -1,6 +1,6 @@ { "name": "opendance-web-server", - "version": "0.1.0", + "version": "0.2.0", "description": "OpenDance Studio Web 管理服务端(NestJS + TypeORM + 现有 MySQL 实例)", "license": "MIT", "private": true, diff --git a/web-server/scripts/import-default-model.mjs b/web-server/scripts/import-default-model.mjs index 9b88376..84a6c0c 100644 --- a/web-server/scripts/import-default-model.mjs +++ b/web-server/scripts/import-default-model.mjs @@ -1,8 +1,12 @@ #!/usr/bin/env node /** - * 导入默认模型:把 MMD 模型压缩包(zip 内含 .pmx)交给服务端转换并入库, - * 转换成功后把它设为全局默认模型,并把身份(名称/格式/产物 sha256) - * 写进源码 `src/config/default-model.ts`。 + * 导入默认模型:把模型压缩包交给服务端导入/转换并入库, + * 再**审核通过并设为全局默认模型**(服务端随之把默认模型身份写进源码 + * `src/config/default-model.ts`)。 + * + * 注意:服务端上传的模型**一律进入待审核(pending)**,所以本脚本会显式调用 + * `POST /api/reviews/model/{id}/approve`(带 `setPrimary=true`)完成"通过 + 设默认"。 + * 只想入库不通过审核时加 `--no-approve`。 * * 为什么要有这个脚本:服务进程在 systemd 加固(ProtectSystem=strict)下**不能** * 改写程序目录,写源码这一步必须在沙箱外完成,因此由本脚本(以 root/运维身份运行) @@ -71,6 +75,7 @@ const file = positionalFile(args); const url = String(flag('--url', 'http://127.0.0.1:3000')).replace(/\/$/, ''); const writeSourceOnly = has('--write-source-only'); +const noApprove = has('--no-approve'); const dryRun = has('--dry-run'); const sourceDir = resolve(process.cwd(), 'src', 'config'); @@ -117,8 +122,14 @@ async function main() { console.error('--write-source-only 需要 --model-id <已入库的模型 id>'); process.exit(2); } - model = await fetchModel(id); - console.log(`读取模型 #${model.id}:${model.name}(${model.format})`); + // 服务端只在 setPrimary 时写源码,这里直接调用它的 primary 接口 + model = await api(`/api/models/${id}/primary`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + console.log(`已把 #${id} 设为默认模型:${model.name}(${model.format})`); + console.log('服务端已把默认模型身份写进源码 src/config/default-model.ts'); + return; } else { if (!file) { console.error( @@ -135,12 +146,37 @@ async function main() { form.append('sourceUrl', String(flag('--source-url', `本地文件:${basename(file)}`))); form.append('license', String(flag('--license', '待补充:MMD 模型使用条款(见压缩包内说明)'))); form.append('author', String(flag('--author', '待补充'))); - form.append('autoDefault', 'true'); - console.log(`上传并转换:${basename(file)}(${(buffer.length / 1024 / 1024).toFixed(2)} MiB)→ ${url}`); + console.log(`上传并导入:${basename(file)}(${(buffer.length / 1024 / 1024).toFixed(2)} MiB)→ ${url}`); model = await api('/api/models', { method: 'POST', body: form }); - console.log(`入库成功:#${model.id} ${model.name}(格式 ${model.format})`); - console.log(`转换说明:${model.conversionNote ?? '(无)'}`); + console.log(`入库成功:#${model.id} ${model.name}(格式 ${model.format},状态 ${model.status})`); + console.log(`说明:${model.conversionNote ?? '(无)'}`); + const stats = model.stats ?? {}; + if (stats.triangles !== undefined) { + console.log( + `模型分析:面数=${stats.triangles} 骨骼=${stats.bones} ` + + `humanoid=${stats.humanoidMapped}/${stats.humanoidTotal} 可驱动=${stats.drivable}`, + ); + if (stats.drivable === false && Array.isArray(stats.missingRequiredBones)) { + console.log(` 注意:缺 ${stats.missingRequiredBones.join('、')},客户端可能无法驱动`); + } + } + + if (noApprove) { + console.log('已按 --no-approve 跳过审核:模型仍在待审,源码种子未写入'); + return; + } + await api(`/api/reviews/model/${model.id}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + reviewer: 'import-default-model', + comment: '由 scripts/import-default-model.mjs 导入并设为默认模型', + setPrimary: true, + }), + }); + console.log(`已通过审核并设为默认模型:#${model.id}`); + model = await fetchModel(model.id); } if (!model.fileSha256 && !model.sourceSha256) { diff --git a/web-server/src/config/configuration.ts b/web-server/src/config/configuration.ts index e34d67f..112d770 100644 --- a/web-server/src/config/configuration.ts +++ b/web-server/src/config/configuration.ts @@ -81,12 +81,14 @@ export class AppConfig { } /** - * 压缩包转换的目标格式。 - * `fbx` 优先(按需求),但 Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件, - * 因此服务端在导出后回读校验,失败会自动回退 GLB 并记录 conversionNote。 + * 需要转换的模型(PMX / FBX)交付成什么格式。 + * + * 系统以 **GLB / VRM 为主力**:GLB 是两个客户端都能原生加载的通用格式, + * VRM 在其上多了 humanoid / 表情 / springBone 语义。默认 `glb`; + * 设为 `vrm` 时会在 GLB 之上补 `VRMC_vrm` 扩展,humanoid 骨骼不全则回退 GLB。 */ - get modelConvertFormat(): 'fbx' | 'glb' { - return (this.config.get('modelConvertFormat') ?? 'fbx') === 'glb' ? 'glb' : 'fbx'; + get modelConvertFormat(): 'glb' | 'vrm' { + return (this.config.get('modelConvertFormat') ?? 'glb') === 'vrm' ? 'vrm' : 'glb'; } /** 压缩包里优先选用的 PMX 文件名(大小写不敏感);找不到时按"目录最浅、文件最大"挑 */ @@ -191,7 +193,7 @@ export const configuration = () => { adminDir: process.env.ADMIN_DIR ?? resolve(process.cwd(), '..', 'admin'), uploadMaxBytes: Number(process.env.UPLOAD_MAX_BYTES ?? 512 * 1024 * 1024), assimpBin: process.env.ASSIMP_BIN ?? 'assimp', - modelConvertFormat: process.env.MODEL_CONVERT_FORMAT ?? 'fbx', + modelConvertFormat: process.env.MODEL_CONVERT_FORMAT ?? 'glb', modelConvertPmx: process.env.MODEL_CONVERT_PMX ?? 'Hatsune Miku.pmx', modelConvertTimeoutMs: Number(process.env.MODEL_CONVERT_TIMEOUT_MS ?? 180_000), defaultModelId: process.env.DEFAULT_MODEL_ID ?? '', diff --git a/web-server/src/config/contract-versions.ts b/web-server/src/config/contract-versions.ts index 6464815..b9f48a9 100644 --- a/web-server/src/config/contract-versions.ts +++ b/web-server/src/config/contract-versions.ts @@ -9,12 +9,12 @@ import { join } from 'node:path'; * 未设置该变量时回落到这里的常量)。 */ export const CONTRACT_VERSIONS: Readonly> = Object.freeze({ - 'rest-api': '0.3.0', + 'rest-api': '0.4.0', 'websocket-keypoints': '0.1.0', - 'data-models': '0.3.0', + 'data-models': '0.4.0', 'skeleton-topology': '0.1.0', - 'model-formats': '0.3.0', - database: '0.2.0', + 'model-formats': '0.4.0', + database: '0.3.0', }); /** 解析 `contracts/VERSION`(`key: value` 每行一条,`#` 起注释) */ diff --git a/web-server/src/config/default-model.ts b/web-server/src/config/default-model.ts index 910ef7c..4734e9f 100644 --- a/web-server/src/config/default-model.ts +++ b/web-server/src/config/default-model.ts @@ -36,15 +36,15 @@ export interface DefaultModelSeed { updatedAt: string | null; } -/** 由 Hatsune Miku.pmx → GLB 转换得到,已设为默认模型 */ +/** 由 手工指定 转换得到,已设为默认模型 */ export const DEFAULT_MODEL_SEED: DefaultModelSeed = { - id: 20, - name: "初音未来 Hatsune Miku", - format: "glb", - sourceSha256: "d1113cf074e19d3a5b4e7025973604c615d6e038ad222070e4748a805ac3c73f", - fileSha256: "696c90c7e8514b759f764312d41f9b9069027e62f3aaa2d83ce2c3e475945028", - convertedFrom: "Hatsune Miku.pmx → GLB", - updatedAt: "2026-09-15T11:34:23.976Z", + id: null, + name: "", + format: "", + sourceSha256: "", + fileSha256: "", + convertedFrom: null, + updatedAt: null, }; /** 该种子是否指向一个真实模型 */ diff --git a/web-server/src/entities/model.entity.ts b/web-server/src/entities/model.entity.ts index d284574..11469f0 100644 --- a/web-server/src/entities/model.entity.ts +++ b/web-server/src/entities/model.entity.ts @@ -49,6 +49,13 @@ export class CharacterModel { @Column({ name: 'conversion_note', type: 'varchar', length: 500, nullable: true }) conversionNote!: string | null; + /** + * 模型分析结果(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性…),见 model-inspector.ts。 + * 存 JSON 文本:审核界面要展示,且骨骼树可能上千行,不适合拆列。 + */ + @Column({ name: 'stats', type: 'simple-json', nullable: true }) + stats!: Record | null; + // ---- 来源五元组(必填)---- @Column({ name: 'source_type', type: 'varchar', length: 32 }) sourceType!: SourceType; diff --git a/web-server/src/migrations/1789460000000-AddModelStats.ts b/web-server/src/migrations/1789460000000-AddModelStats.ts new file mode 100644 index 0000000..ceadb51 --- /dev/null +++ b/web-server/src/migrations/1789460000000-AddModelStats.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * 模型审核(T-WEB-005 追加):保存模型分析结果。 + * + * `models.stats` 存 `ModelStats` 的 JSON 文本(面数、顶点数、骨骼树、 + * humanoid 覆盖、可驱动性、warnings),供审核界面展示——骨骼是后续游玩评分的基础, + * 审核时必须能看清楚。用 TEXT 而非 JSON 列,兼容各版本 MySQL/MariaDB。 + */ +export class AddModelStats1789460000000 implements MigrationInterface { + name = 'AddModelStats1789460000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`models\` ADD \`stats\` text NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`models\` DROP COLUMN \`stats\``); + } +} diff --git a/web-server/src/models/gltf-packer.ts b/web-server/src/models/gltf-packer.ts new file mode 100644 index 0000000..ff20d36 --- /dev/null +++ b/web-server/src/models/gltf-packer.ts @@ -0,0 +1,134 @@ +import { embedGlbImages } from './glb-textures'; + +/** + * glTF(.gltf + .bin + 外部贴图)→ GLB 打包。 + * + * 作为"优先转 GLB"的**第二条路**:Assimp 的 GLB 导出器对部分模型会失败, + * 但它导出 glTF(分离文件)往往成功(实测 PMX 就是这种情况)。 + * 这里把分离产物自己拼回单文件 GLB: + * 1. `buffers[0].uri` 指向的 .bin 读进来当 BIN chunk + * 2. 多余 buffer 合并进同一个 BIN chunk(记录偏移重映射) + * 3. 外部贴图交给 `embedGlbImages` 内嵌 + * 全程只重排 JSON 与二进制块,不改几何数据。 + */ + +const GLB_MAGIC = 0x46546c67; +const CHUNK_JSON = 0x4e4f534a; +const CHUNK_BIN = 0x004e4942; + +interface GltfLike { + buffers?: Array<{ byteLength?: number; uri?: string }>; + bufferViews?: Array>; + images?: Array>; + [key: string]: unknown; +} + +function align4(value: number): number { + return value + ((4 - (value % 4)) % 4); +} + +function buildGlb(json: GltfLike, bin: Buffer | null): Buffer { + const jsonBuffer = Buffer.from(JSON.stringify(json), 'utf8'); + const jsonPadded = Buffer.alloc(align4(jsonBuffer.length), 0x20); + jsonBuffer.copy(jsonPadded); + + const chunks: Buffer[] = []; + const header = Buffer.alloc(12); + header.writeUInt32LE(GLB_MAGIC, 0); + header.writeUInt32LE(2, 4); + chunks.push(header); + + const jsonHeader = Buffer.alloc(8); + jsonHeader.writeUInt32LE(jsonPadded.length, 0); + jsonHeader.writeUInt32LE(CHUNK_JSON, 4); + chunks.push(jsonHeader, jsonPadded); + + if (bin && bin.length > 0) { + const binPadded = Buffer.alloc(align4(bin.length)); + bin.copy(binPadded); + const binHeader = Buffer.alloc(8); + binHeader.writeUInt32LE(binPadded.length, 0); + binHeader.writeUInt32LE(CHUNK_BIN, 4); + chunks.push(binHeader, binPadded); + } + + const out = Buffer.concat(chunks); + out.writeUInt32LE(out.length, 8); + return out; +} + +export interface PackGltfResult { + glb: Buffer; + /** 合并进 BIN chunk 的 buffer 数量 */ + buffersMerged: number; + /** 内嵌的贴图数量 */ + imagesEmbedded: number; + /** 找不到的贴图 uri */ + unresolvedImages: string[]; +} + +/** + * 打包;`resolve` 把 uri 映射为文件内容(返回 null 表示找不到该文件)。 + * 缺失的 buffer 会导致该 buffer 被跳过(几何可能不完整),并在结果里体现。 + */ +export function packGltfToGlb( + gltf: GltfLike, + resolve: (uri: string) => Buffer | null, +): PackGltfResult { + const buffers = gltf.buffers ?? []; + if (buffers.length === 0) { + throw new Error('glTF 没有任何 buffer,无法打包成 GLB'); + } + + /** 旧 buffer 索引 + 旧偏移 → 新偏移 */ + const baseOffsets: number[] = []; + const parts: Buffer[] = []; + let cursor = 0; + let merged = 0; + + for (let i = 0; i < buffers.length; i += 1) { + const uri = buffers[i]?.uri; + baseOffsets[i] = cursor; + if (!uri || uri.startsWith('data:')) { + // 内嵌 base64 或本来就缺 uri(GLB 才有意义):这里保守跳过 + continue; + } + const data = resolve(uri); + if (!data) continue; + const padding = align4(cursor) - cursor; + if (padding > 0) { + parts.push(Buffer.alloc(padding)); + cursor += padding; + baseOffsets[i] = cursor; + } + parts.push(data); + cursor += data.length; + merged += 1; + } + + if (merged === 0) { + throw new Error('glTF 引用的 .bin 文件都找不到,无法打包成 GLB'); + } + + // bufferView 的 byteOffset 是相对各自 buffer 的,合并后要加上该 buffer 的基址 + const views = (gltf.bufferViews ?? []).map((view) => { + const source = typeof view.buffer === 'number' ? view.buffer : 0; + const offset = typeof view.byteOffset === 'number' ? view.byteOffset : 0; + return { ...view, buffer: 0, byteOffset: (baseOffsets[source] ?? 0) + offset }; + }); + + const merged0 = Buffer.concat(parts, cursor); + const packed: GltfLike = { + ...gltf, + buffers: [{ byteLength: merged0.length }], + bufferViews: views, + }; + + const embedded = embedGlbImages(buildGlb(packed, merged0), resolve); + return { + glb: embedded.glb, + buffersMerged: merged, + imagesEmbedded: embedded.embedded, + unresolvedImages: embedded.unresolved, + }; +} diff --git a/web-server/src/models/model-convert.ts b/web-server/src/models/model-convert.ts index a299de6..20af7f0 100644 --- a/web-server/src/models/model-convert.ts +++ b/web-server/src/models/model-convert.ts @@ -1,24 +1,35 @@ import { execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, extname, join } from 'node:path'; +import { basename, dirname, extname, join } from 'node:path'; import { promisify } from 'node:util'; import { ModelFormat } from '../common/enums'; import { ZipEntry, readZipEntries } from '../common/zip-reader'; import { embedGlbImages } from './glb-textures'; +import { packGltfToGlb } from './gltf-packer'; +import { VrmWrapMeta, detectGlbKind, wrapGlbAsVrm } from './vrm-writer'; const run = promisify(execFile); /** - * 压缩包 → 可入库模型文件的转换。 + * 压缩包 → 可入库模型文件的导入/转换(T-WEB-005)。 * - * 典型场景:MMD 模型包(zip 内含 `xxx.pmx` + `textures/`)→ 解压 → 选主模型 - * → 调 Assimp 导出 FBX;**Assimp 的 FBX 导出器对带蒙皮的大骨架模型会写坏文件** - * (实测 Miku PMX:导出退出码 11,产出的 FBX 回读报 `FBX-DOM no Objects dictionary found`), - * 因此导出后必须回读校验,失败则回退 GLB,并把过程写进 conversionNote。 + * ## 策略(与产品约定一致) * - * 转换后的 GLB 还会把 Assimp 写成外部 URI 的贴图内嵌进二进制块, - * 保证模型库里一个文件就能独立加载(否则是白模)。 + * **GLB / VRM 是主力格式**:包内若有 `.vrm` / `.glb` / `.gltf`,**直接导入**, + * 不做任何重编码(避免无意义的损失)。 + * 只有 `.pmx`(MMD)与 `.fbx` 这类客户端不能直接加载的格式才转换,且**优先转 GLB**: + * + * 1. `assimp export → .glb`(首选;本仓库实测 PMX 走这条) + * 2. 失败则 `assimp export → .gltf`,再由 `gltf-packer.ts` 自行打包回 GLB + * (Assimp 的 GLB 导出器对部分模型会失败,而 glTF 导出常能成功) + * 3. 若配置要求 VRM(`MODEL_CONVERT_FORMAT=vrm`)且 humanoid 骨骼齐全, + * 在 GLB 之上补 `VRMC_vrm` 扩展产出 VRM 1.0(见 `vrm-writer.ts`); + * humanoid 不全则**回退交付 GLB**,并把缺失骨骼写进说明 + * + * 每一步都做**回读校验**:Assimp 会"写出损坏文件"(实测 FBX 导出退出码 11、 + * 产物回读报 `FBX-DOM no Objects dictionary found`),只看退出码会把坏文件入库。 */ export interface ConvertOptions { @@ -26,28 +37,55 @@ export interface ConvertOptions { assimpBin: string; /** 单次转换超时(毫秒) */ timeoutMs?: number; - /** 优先尝试的格式 */ - preferFormat?: 'fbx' | 'glb'; + /** 优先交付的格式:`glb`(默认)或 `vrm` */ + preferFormat?: 'glb' | 'vrm'; /** 优先选用的 PMX 文件名(大小写不敏感),例如 `Hatsune Miku.pmx` */ preferredPmx?: string; + /** 生成 VRM 时写入的元信息 */ + vrmMeta?: VrmWrapMeta; } export interface ConvertedModel { - /** 转换产物内容 */ + /** 产物(或直接导入的原文件)内容 */ buffer: Buffer; - /** 产物格式(fbx 或 glb) */ + /** 入库格式 */ format: ModelFormat; - /** 实际使用的 pmx 相对路径 */ + /** 归档内的源文件相对路径 */ sourceModelPath: string; - /** 人类可读的转换说明,写入 models.conversion_note */ + /** 人类可读说明,写入 models.conversion_note */ note: string; /** 逐步尝试记录,便于排障 */ attempts: string[]; + /** 是否为直接导入(未做任何转换) */ + direct: boolean; } const DEFAULT_TIMEOUT_MS = 180_000; -/** 选择主模型:优先指名字,其次目录最浅,最后文件最大 */ +/** 归档内的候选文件:能直接导入的格式优先于需要转换的格式 */ +export type ArchiveCandidateKind = 'direct' | 'convert'; + +/** 源文件格式(PMX 只作为输入存在,永远不会成为入库格式,因此不进 ModelFormat 枚举) */ +export type SourceFormat = 'vrm' | 'glb' | 'gltf' | 'pmx' | 'fbx'; + +export interface ArchiveCandidate { + entry: ZipEntry; + kind: ArchiveCandidateKind; + sourceFormat: SourceFormat; + /** 数字越小越优先 */ + priority: number; +} + +/** 格式优先级:GLB / VRM 为主力,其次 glTF,最后才是需要转换的 PMX / FBX */ +const FORMAT_PRIORITY: ReadonlyArray<{ ext: string; sourceFormat: SourceFormat; assetFormat: ModelFormat; kind: ArchiveCandidateKind }> = [ + { ext: '.vrm', sourceFormat: 'vrm', assetFormat: ModelFormat.Vrm, kind: 'direct' }, + { ext: '.glb', sourceFormat: 'glb', assetFormat: ModelFormat.Glb, kind: 'direct' }, + { ext: '.gltf', sourceFormat: 'gltf', assetFormat: ModelFormat.Gltf, kind: 'direct' }, + { ext: '.pmx', sourceFormat: 'pmx', assetFormat: ModelFormat.Glb, kind: 'convert' }, + { ext: '.fbx', sourceFormat: 'fbx', assetFormat: ModelFormat.Fbx, kind: 'convert' }, +]; + +/** 只挑 PMX(保留给旧调用方与测试);新代码请用 `pickArchiveCandidate` */ export function pickModelFile( entries: readonly ZipEntry[], preferred?: string, @@ -58,9 +96,7 @@ export function pickModelFile( if (wanted) { const exact = models.find((entry) => entry.path.toLowerCase() === wanted); if (exact) return exact; - const byBase = models.find( - (entry) => entry.path.split('/').pop()?.toLowerCase() === wanted, - ); + const byBase = models.find((entry) => entry.path.split('/').pop()?.toLowerCase() === wanted); if (byBase) return byBase; } return [...models].sort((a, b) => { @@ -71,6 +107,41 @@ export function pickModelFile( })[0]; } +/** + * 在压缩包里自动搜索可用的模型文件。 + * 顺序:`.vrm` → `.glb` → `.gltf`(直接导入)→ `.pmx` → `.fbx`(需要转换); + * 同一格式内优先"指名字",其次目录最浅,最后文件最大。 + */ +export function pickArchiveCandidate( + entries: readonly ZipEntry[], + preferredPmx?: string, +): ArchiveCandidate | undefined { + const preferred = preferredPmx?.trim().toLowerCase(); + const candidates: ArchiveCandidate[] = []; + + for (const entry of entries) { + const ext = extname(entry.path).toLowerCase(); + const index = FORMAT_PRIORITY.findIndex((item) => item.ext === ext); + if (index < 0) continue; + const spec = FORMAT_PRIORITY[index]; + candidates.push({ entry, kind: spec.kind, sourceFormat: spec.sourceFormat, priority: index }); + } + if (candidates.length === 0) return undefined; + + return candidates.sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + if (preferred) { + const aNamed = a.entry.path.toLowerCase() === preferred ? 0 : 1; + const bNamed = b.entry.path.toLowerCase() === preferred ? 0 : 1; + if (aNamed !== bNamed) return aNamed - bNamed; + } + const depthA = a.entry.path.split('/').length; + const depthB = b.entry.path.split('/').length; + if (depthA !== depthB) return depthA - depthB; + return b.entry.size - a.entry.size; + })[0]; +} + /** 解压到工作目录,同时保留内容供贴图内嵌使用 */ async function unpack( entries: readonly ZipEntry[], @@ -87,7 +158,7 @@ async function unpack( return contents; } -/** 在解压结果里按 glTF 的 uri 找贴图(容错反斜杠与大小写) */ +/** 在解压结果里按 glTF 的 uri 找文件(容错反斜杠、百分号编码与大小写) */ function makeResolver(contents: Map) { const lowered = new Map(); for (const [path, data] of contents) lowered.set(path.replace(/\\/g, '/').toLowerCase(), data); @@ -111,105 +182,263 @@ function makeResolver(contents: Map) { }; } -/** 用 Assimp 导出到指定格式,并回读校验(回读失败视为转换失败) */ +/** 用 Assimp 导出并回读校验(回读失败视为转换失败) */ async function exportAndValidate( assimpBin: string, modelPath: string, outPath: string, timeoutMs: number, -): Promise<{ ok: boolean; detail: string }> { +): Promise<{ ok: boolean; detail: string; size: number }> { try { await run(assimpBin, ['export', modelPath, outPath], { timeout: timeoutMs, maxBuffer: 8 << 20 }); } catch (error) { const err = error as { code?: number | string; stderr?: string; message?: string }; const detail = err.stderr?.trim() || err.message || String(error); - return { ok: false, detail: `导出失败(${err.code ?? 'unknown'}):${detail.slice(0, 300)}` }; + return { + ok: false, + detail: `导出失败(${err.code ?? 'unknown'}):${detail.slice(0, 300)}`, + size: 0, + }; } let size = 0; try { size = (await readFile(outPath)).length; } catch { - return { ok: false, detail: '导出命令返回成功但没有产出文件' }; + return { ok: false, detail: '导出命令返回成功但没有产出文件', size: 0 }; } - if (size === 0) return { ok: false, detail: '导出产物为空文件' }; + if (size === 0) return { ok: false, detail: '导出产物为空文件', size: 0 }; - // 关键校验:Assimp 的 FBX 导出器会"写出一个坏文件但退出码非 0", - // 必须用 info 回读确认,否则会把损坏文件入库。 try { await run(assimpBin, ['info', outPath], { timeout: timeoutMs, maxBuffer: 8 << 20 }); } catch (error) { const err = error as { stderr?: string; message?: string }; const detail = err.stderr?.trim() || err.message || String(error); - return { ok: false, detail: `产物无法回读(已判定损坏):${detail.slice(0, 300)}` }; + return { ok: false, detail: `产物无法回读(已判定损坏):${detail.slice(0, 300)}`, size }; } - return { ok: true, detail: `产物 ${size} 字节,回读校验通过` }; + return { ok: true, detail: `产物 ${size} 字节,回读校验通过`, size }; } -/** 压缩包 → 模型文件(FBX 优先,失败回退 GLB) */ +/** 在 GLB 之上补 VRM 扩展;失败时返回原因而不是抛出 */ +function tryWrapVrm( + glb: Buffer, + meta: VrmWrapMeta | undefined, + modelName: string, +): { buffer: Buffer; detail: string } | { error: string } { + try { + const wrapped = wrapGlbAsVrm(glb, { + name: meta?.name ?? modelName, + author: meta?.author ?? 'unknown', + license: meta?.license, + sourceNote: meta?.sourceNote, + }); + return { buffer: wrapped.buffer, detail: `已写入 ${wrapped.humanoidBones} 根 humanoid 骨骼` }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +/** + * Assimp 导出管线(第 1 步 GLB 直出 → 第 2 步 glTF 再自行打包 → 第 3 步按需封装 VRM)。 + * zip 与单文件两条入口共用。 + */ +async function runAssimpPipeline( + modelPath: string, + archiveResolver: (uri: string) => Buffer | null, + modelLabel: string, + options: ConvertOptions, +): Promise<{ buffer: Buffer; format: ModelFormat; note: string; attempts: string[] }> { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const prefer = options.preferFormat ?? 'glb'; + { + const workDir = dirname(modelPath); + const attempts: string[] = []; + + /** + * 解析 glTF/GLB 里引用的外部文件:**先看工作目录**(Assimp 新导出的 .bin 在这里), + * 再回落到压缩包内容(原始贴图)。少了这一步,glTF→GLB 打包会找不到 .bin。 + */ + const resolver = (uri: string): Buffer | null => { + if (uri.startsWith('data:')) return null; + for (const candidate of [uri, uri.replace(/\\/g, '/'), basename(uri)]) { + try { + return readFileSync(join(workDir, candidate)); + } catch { + /* 继续 */ + } + } + return archiveResolver(uri); + }; + + // ---- 第 1 步:直接导出 GLB ---- + let glb: Buffer | null = null; + let fromDirectGlb = false; + const glbPath = join(workDir, 'converted.glb'); + const glbResult = await exportAndValidate(options.assimpBin, modelPath, glbPath, timeoutMs); + attempts.push(`GLB 直出:${glbResult.detail}`); + if (glbResult.ok) { + glb = Buffer.from(await readFile(glbPath)); + fromDirectGlb = true; + } else { + // ---- 第 2 步:导出 glTF,再自行打包成 GLB ---- + const gltfPath = join(workDir, 'converted.gltf'); + const gltfResult = await exportAndValidate(options.assimpBin, modelPath, gltfPath, timeoutMs); + if (gltfResult.ok) { + try { + const gltfJson = JSON.parse(await readFile(gltfPath, 'utf8')) as Record; + const packed = packGltfToGlb(gltfJson, resolver); + glb = packed.glb; + attempts.push( + `GLB 打包:由 glTF 打包成功(合并 ${packed.buffersMerged} 个 buffer,内嵌 ${packed.imagesEmbedded} 张贴图` + + `${packed.unresolvedImages.length > 0 ? `,${packed.unresolvedImages.length} 张贴图未找到` : ''})`, + ); + } catch (error) { + attempts.push( + `GLB 打包:失败(${error instanceof Error ? error.message : String(error)})`, + ); + } + } else { + attempts.push(`glTF 直出:${gltfResult.detail}`); + } + } + + if (!glb) { + throw new Error( + `模型转换失败:${attempts.join(';')}。` + + '请确认服务器已安装 Assimp(ASSIMP_BIN),或直接把模型导出成 .glb / .vrm 再上传。', + ); + } + + // GLB 直出时 Assimp 仍会把贴图写成外部 URI,统一内嵌一次(打包路径已在 packGltfToGlb 里做过) + let glbBuffer = glb; + let embedNote = ''; + if (fromDirectGlb) { + try { + const embedded = embedGlbImages(glbBuffer, resolver); + glbBuffer = Buffer.from(embedded.glb); + if (embedded.embedded > 0) embedNote = `,已内嵌 ${embedded.embedded} 张贴图`; + if (embedded.unresolved.length > 0) { + embedNote += `(${embedded.unresolved.length} 张贴图未找到)`; + } + } catch (error) { + embedNote = `(贴图内嵌跳过:${error instanceof Error ? error.message : String(error)})`; + } + } + + // ---- 第 3 步:按需封装成 VRM(优先 GLB,其次 VRM) ---- + if (prefer === 'vrm') { + const wrapped = tryWrapVrm(glbBuffer, options.vrmMeta, modelLabel.replace(/\.[^.]+$/, '')); + if ('buffer' in wrapped) { + return { + buffer: wrapped.buffer, + format: ModelFormat.Vrm, + note: `${modelLabel} → VRM(基于 GLB 封装${embedNote};${wrapped.detail})`, + attempts: [...attempts, `VRM 封装:${wrapped.detail}`], + }; + } + attempts.push(`VRM 封装:失败(${wrapped.error})→ 回退交付 GLB`); + } + + return { + buffer: glbBuffer, + format: ModelFormat.Glb, + note: `${modelLabel} → GLB${embedNote}`, + attempts, + }; + } +} + +/** + * 压缩包 → 模型文件。 + * 包内含 GLB/VRM/glTF 时直接导入;含 PMX/FBX 时按"优先 GLB、其次 VRM"转换。 + */ export async function convertModelArchive( archive: Buffer, options: ConvertOptions, ): Promise { - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const prefer = options.preferFormat ?? 'fbx'; const entries = readZipEntries(archive); if (entries.length === 0) throw new Error('压缩包是空的'); - const modelEntry = pickModelFile(entries, options.preferredPmx); - if (!modelEntry) { - throw new Error('压缩包里没有找到 .pmx 模型文件(当前只支持 MMD 的 PMX 模型包)'); + const candidate = pickArchiveCandidate(entries, options.preferredPmx); + if (!candidate) { + const supported = FORMAT_PRIORITY.map((item) => item.ext).join('、'); + throw new Error(`压缩包里没有找到可用的模型文件(支持 ${supported})`); } + // ---- 直接导入:GLB / VRM / glTF 原样入库 ---- + if (candidate.kind === 'direct') { + const raw = candidate.entry.read(); + const assetFormat = + FORMAT_PRIORITY.find((item) => item.sourceFormat === candidate.sourceFormat)?.assetFormat ?? + ModelFormat.Glb; + const format = + assetFormat === ModelFormat.Glb && detectGlbKind(raw) === ModelFormat.Vrm + ? ModelFormat.Vrm + : assetFormat; + return { + buffer: raw, + format, + sourceModelPath: candidate.entry.path, + note: `${candidate.entry.path} → 直接导入(已是 ${format.toUpperCase()},未做重编码)`, + attempts: [`直接导入:${candidate.entry.path}(${raw.length} 字节)`], + direct: true, + }; + } + + // ---- 需要转换:PMX / FBX ---- const workDir = await mkdtemp(join(tmpdir(), 'opendance-model-')); - const attempts: string[] = []; try { const contents = await unpack(entries, workDir); - const modelPath = join(workDir, modelEntry.path); - - const order: Array<'fbx' | 'glb'> = - prefer === 'glb' ? ['glb', 'fbx'] : ['fbx', 'glb']; - - for (const target of order) { - const outPath = join(workDir, `converted.${target}`); - const result = await exportAndValidate(options.assimpBin, modelPath, outPath, timeoutMs); - attempts.push(`${target.toUpperCase()}:${result.detail}`); - if (!result.ok) continue; - - let buffer: Buffer = await readFile(outPath); - let extra = ''; - if (target === 'glb') { - // 贴图内嵌是"锦上添花":即便结构不符合预期也不能让整次转换失败 - try { - const embedded = embedGlbImages(buffer, makeResolver(contents)); - buffer = Buffer.from(embedded.glb); - if (embedded.embedded > 0) extra = `,已内嵌 ${embedded.embedded} 张贴图`; - if (embedded.unresolved.length > 0) { - extra += `(${embedded.unresolved.length} 张贴图未找到:${embedded.unresolved - .slice(0, 3) - .join('、')})`; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - extra = `(贴图内嵌跳过:${message.slice(0, 120)})`; - } - } - - const note = `${modelEntry.path} → ${target.toUpperCase()}${extra}`; - return { - buffer, - format: target === 'fbx' ? ModelFormat.Fbx : ModelFormat.Glb, - sourceModelPath: modelEntry.path, - note, - attempts, - }; - } - - throw new Error( - `模型转换失败:${attempts.join(';')}。` + - '请确认服务器已安装 Assimp(ASSIMP_BIN),或直接上传 .glb / .vrm 文件。', + const modelPath = join(workDir, candidate.entry.path); + const result = await runAssimpPipeline( + modelPath, + makeResolver(contents), + candidate.entry.path.split('/').pop() ?? candidate.entry.path, + options, ); + return { ...result, sourceModelPath: candidate.entry.path, direct: false }; } finally { await rm(workDir, { recursive: true, force: true }); } } + +/** + * 单个模型文件(`.pmx` / `.fbx`)→ 转换产物。 + * 单文件没有配套贴图,转换说明里会写明贴图缺失情况。 + */ +export async function convertModelFile( + buffer: Buffer, + fileName: string, + options: ConvertOptions, +): Promise { + const workDir = await mkdtemp(join(tmpdir(), 'opendance-model-file-')); + try { + const modelPath = join(workDir, basename(fileName)); + await writeFile(modelPath, buffer); + const result = await runAssimpPipeline(modelPath, () => null, basename(fileName), options); + return { ...result, sourceModelPath: basename(fileName), direct: false }; + } finally { + await rm(workDir, { recursive: true, force: true }); + } +} + +/** 直接导入单个模型文件(上传 .glb / .vrm / .gltf 时用,不做任何转换) */ +export function importModelFile(buffer: Buffer, fileName: string): ConvertedModel { + const ext = extname(fileName).toLowerCase(); + const spec = FORMAT_PRIORITY.find((item) => item.ext === ext && item.kind === 'direct'); + if (!spec) { + throw new Error(`不支持直接导入的扩展名:${ext || '(无)'};需要转换的只有 .pmx / .fbx`); + } + const format = + spec.assetFormat === ModelFormat.Glb && detectGlbKind(buffer) === ModelFormat.Vrm + ? ModelFormat.Vrm + : spec.assetFormat; + return { + buffer, + format, + sourceModelPath: basename(fileName), + note: `${fileName} → 直接导入(${format.toUpperCase()},未做重编码)`, + attempts: [`直接导入:${fileName}(${buffer.length} 字节)`], + direct: true, + }; +} diff --git a/web-server/src/models/model-inspector.ts b/web-server/src/models/model-inspector.ts new file mode 100644 index 0000000..d06c09d --- /dev/null +++ b/web-server/src/models/model-inspector.ts @@ -0,0 +1,669 @@ +import { readFile } from 'node:fs/promises'; + +/** + * 模型检查器 —— 审核界面「体积 / 面数 / 骨骼」数据的来源。 + * + * 为什么骨骼要单独建模:客户端(Godot)是**按骨骼名**把模型骨架映射到 humanoid + * 规范(`opendance-windows/src/model/humanoid_map.gd`),再驱动 33 个点。 + * 「模型能加载」不等于「能被驱动」;服务端必须在审核阶段就算出覆盖情况, + * 否则要等玩家进游戏才发现模型加载了却驱动不了。 + * + * 本文件只解析 glTF 系容器(`.glb` 二进制 / `.gltf` JSON;VRM 就是带扩展的 glTF)。 + * fbx / pmx 等非 glTF 容器返回 `container='unknown'` 的降级结果,**绝不抛错** —— + * 审核接口需要的是「信息」,不是 500。 + * + * 统计口径(与 ModelStats 字段注释一一对应): + * - vertices = 所有 mesh primitive 的 POSITION accessor 的 count 之和(缺 accessor 记 0) + * - triangles = 索引 accessor 的 count / 3;该 primitive 没有 indices 时用 POSITION count / 3 + * - materials / textures / images / animations 直接取 glTF 顶层数组长度 + * - meshes = meshes 数组长度;primitives = 所有 mesh 的 primitives 数量之和 + * - morphTargets = 所有 primitive 的 `targets` 数量之和 + * - nodes = nodes 数组长度;skins = skins 数组长度 + * - bones = 所有 skin 的 joints 去重后的节点数(= 真正参与蒙皮、可被驱动的骨骼) + * - humanoidTotal 固定为 22(客户端 HUMANOID_BONES 的长度);humanoidMapped 为命中数 + * - 一切数字都经过 toCount(),保证是**有限非负整数**,绝不出现 NaN / Infinity + * + * 与客户端的映射一致性由 `test/model-inspector.spec.ts` 直接抽 `humanoid_map.gd` + * 的 CANDIDATES / STRIPPABLE_PREFIXES / HUMANOID_BONES 逐键比对来兜底: + * 客户端一旦改了名字映射,这条测试会立刻失败,而不是让审核数据悄悄失真。 + */ + +/* ------------------------------------------------------------------ 对外接口 */ + +export interface BoneNode { + index: number; + name: string; + /** 相对根的层级深度,根为 0 */ + depth: number; + parentName: string | null; + childCount: number; + /** 是否被 skin.joints 引用(= 真正参与蒙皮、可被驱动) */ + inSkin: boolean; + /** 映射到的 humanoid 规范名(如 leftUpperArm);映射不到为 null */ + humanoid: string | null; +} + +export interface ModelStats { + container: 'glb' | 'gltf' | 'vrm' | 'unknown'; + vrmVersion: string | null; + fileSize: number; + meshes: number; + primitives: number; + vertices: number; + triangles: number; + materials: number; + textures: number; + images: number; + animations: number; + morphTargets: number; + nodes: number; + /** skin.joints 的数量(真正参与蒙皮的骨骼) */ + bones: number; + /** skin 数量 */ + skins: number; + /** humanoid 规范骨里能映射上的数量 / 总数(22) */ + humanoidMapped: number; + humanoidTotal: number; + /** 驱动客户端(33 点)所必需的 16 根里缺哪些(humanoid 规范名) */ + missingRequiredBones: string[]; + /** 16 根齐全 → true */ + drivable: boolean; + maxBoneDepth: number; + /** 扁平化的骨骼树(父在前、子在后),前端按 depth 缩进渲染 */ + boneTree: BoneNode[]; + /** 无法解析或需要注意的点,中文短语 */ + warnings: string[]; +} + +/* ----------------------------------------------------- humanoid 规范骨(与客户端一致) */ + +/** + * humanoid 规范骨(22 根),顺序即层级、父在前。 + * 与客户端 `humanoid_map.gd` 的 HUMANOID_BONES 逐字一致(测试会比对)。 + */ +export const HUMANOID_BONES: readonly string[] = [ + 'hips', + 'spine', + 'chest', + 'upperChest', + 'neck', + 'head', + 'leftShoulder', + 'leftUpperArm', + 'leftLowerArm', + 'leftHand', + 'rightShoulder', + 'rightUpperArm', + 'rightLowerArm', + 'rightHand', + 'leftUpperLeg', + 'leftLowerLeg', + 'leftFoot', + 'leftToes', + 'rightUpperLeg', + 'rightLowerLeg', + 'rightFoot', + 'rightToes', +]; + +/** + * 驱动客户端(33 点)所必需的 16 根 humanoid 骨。 + * 缺任何一根就意味着「模型能加载但驱动不了」,审核界面据此直接给出结论。 + */ +export const HUMANOID_REQUIRED: readonly string[] = [ + 'hips', + 'spine', + 'neck', + 'head', + 'leftUpperArm', + 'leftLowerArm', + 'leftHand', + 'rightUpperArm', + 'rightLowerArm', + 'rightHand', + 'leftUpperLeg', + 'leftLowerLeg', + 'leftFoot', + 'rightUpperLeg', + 'rightLowerLeg', + 'rightFoot', +]; + +/** + * 归一化时剥离的前缀(**归一化之后**的形态:小写、无下划线/冒号)。 + * 只剥离 J_Bip / mixamorig / Bip01 —— 故意不剥离 J_Aim_ / J_Roll_ / J_Sec_, + * 因为 VRoid 用这些前缀标记扭转辅助骨(如 J_Aim_L_UpperLeg), + * 若一并剥离会与真正的 J_Bip_L_UpperLeg 撞名,导致驱动到错误的骨骼上。 + * 与客户端 `humanoid_map.gd` 的 STRIPPABLE_PREFIXES 一致。 + */ +export const STRIPPABLE_PREFIXES: readonly string[] = ['jbip', 'mixamorig', 'bip001', 'bip01']; + +/** + * 每个 canonical 骨骼的候选名(全部为归一化后的形态,含 compact 与 expanded 写法)。 + * 与客户端 `humanoid_map.gd` 的 CANDIDATES 逐字一致(测试会逐键比对)。 + */ +export const HUMANOID_CANDIDATES: Record = { + hips: ['chips', 'hips', 'hip', 'pelvis', 'root'], + spine: ['cspine', 'spine', 'spine1', 'spinemid'], + chest: ['cchest', 'chest', 'spine2', 'spineupper'], + upperChest: ['cupperchest', 'upperchest', 'chest2', 'spine3'], + neck: ['cneck', 'neck', 'neck1'], + head: ['chead', 'head'], + leftShoulder: ['lshoulder', 'leftshoulder', 'shoulderl', 'shoulderleft', 'claviclel', 'lclavicle'], + leftUpperArm: ['lupperarm', 'leftupperarm', 'larm', 'leftarm', 'luparm'], + leftLowerArm: ['llowerarm', 'leftlowerarm', 'lforearm', 'leftforearm', 'lelbow'], + leftHand: ['lhand', 'lefthand', 'lwrist', 'leftwrist'], + rightShoulder: ['rshoulder', 'rightshoulder', 'shoulderr', 'shoulderright', 'clavicler', 'rclavicle'], + rightUpperArm: ['rupperarm', 'rightupperarm', 'rarm', 'rightarm', 'ruparm'], + rightLowerArm: ['rlowerarm', 'rightlowerarm', 'rforearm', 'rightforearm', 'relbow'], + rightHand: ['rhand', 'righthand', 'rwrist', 'rightwrist'], + leftUpperLeg: ['lupperleg', 'leftupperleg', 'lthigh', 'leftthigh', 'lupleg', 'lhip', 'lefthip'], + leftLowerLeg: [ + 'llowerleg', + 'leftlowerleg', + 'lleg', + 'leftleg', + 'lshin', + 'leftshin', + 'lknee', + 'leftknee', + ], + leftFoot: ['lfoot', 'leftfoot', 'lankle', 'leftankle'], + leftToes: ['ltoes', 'lefttoes', 'ltoe', 'lefttoe', 'ltoebase', 'lefttoebase'], + rightUpperLeg: ['rupperleg', 'rightupperleg', 'rthigh', 'rightthigh', 'rupleg', 'rhip', 'righthip'], + rightLowerLeg: [ + 'rlowerleg', + 'rightlowerleg', + 'rleg', + 'rightleg', + 'rshin', + 'rightshin', + 'rknee', + 'rightknee', + ], + rightFoot: ['rfoot', 'rightfoot', 'rankle', 'rightankle'], + rightToes: ['rtoes', 'righttoes', 'rtoe', 'righttoe', 'rtoebase', 'righttoebase'], +}; + +/* -------------------------------------------------------------- 客户端逻辑移植 */ + +/** + * 归一化骨骼名:小写 + 只保留 a-z0-9(分隔符、冒号、空格全部丢弃)。 + * 移植自客户端 `humanoid_map.gd` 的 normalize()。 + * 例如 "J_Bip_L_UpperArm" → "jbiplupperarm"。 + */ +export function normalizeBoneName(nodeName: string): string { + const lower = nodeName.toLowerCase(); + let out = ''; + for (const ch of lower) { + if ((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')) out += ch; + } + return out; +} + +/** 去掉可剥离前缀后的形态。移植自客户端 strip_prefix()。 */ +function stripPrefix(normalized: string): string { + for (const prefix of STRIPPABLE_PREFIXES) { + if (normalized.startsWith(prefix)) return normalized.slice(prefix.length); + } + return normalized; +} + +/** + * 生成用于比较的形态集合。移植自客户端 variants(): + * compact —— left/right 缩写为 l/r:"leftupperarm" → "lupperarm" + * expanded —— 首字母 l/r 展开为 left/right:"lupperarm" → "leftupperarm" + * 返回值与客户端一致地保留重复项(base / compact / expanded 共 3 项)。 + */ +export function boneNameVariants(nodeName: string): string[] { + const base = stripPrefix(normalizeBoneName(nodeName)); + const out = [base]; + + let compact = base; + if (base.startsWith('left')) compact = 'l' + base.slice(4); + else if (base.startsWith('right')) compact = 'r' + base.slice(5); + out.push(compact); + + let expanded = base; + if (base.startsWith('l') && !base.startsWith('left')) expanded = 'left' + base.slice(1); + else if (base.startsWith('r') && !base.startsWith('right')) expanded = 'right' + base.slice(1); + out.push(expanded); + + return out; +} + +/* ------------------------------------------------------------------ glTF 解析工具 */ + +/** glTF JSON 的宽松视图:外部文件不可信,一律按 unknown 逐字段守卫 */ +type Json = Record; + +const GLB_MAGIC = 0x46546c67; // 'glTF' +const GLB_VERSION = 2; +const CHUNK_JSON = 0x4e4f534a; // 'JSON';BIN chunk 是 0x004E4942,本检查器用不到 + +function isRecord(value: unknown): value is Json { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +/** 把任意值收敛为有限非负整数;NaN / Infinity / 负数 / 非数字一律记 0 */ +function toCount(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +} + +/** 合法的数组下标(用于节点索引、accessor 索引);不合法返回 null */ +function toIndex(value: unknown, length: number): number | null { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value >= length) return null; + return value; +} + +/** 非空字符串(用于 VRM 版本号等);其余返回 null */ +function asText(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value : null; +} + +function align4(value: number): number { + return value + ((4 - (value % 4)) % 4); +} + +function looksLikeGlb(buffer: Buffer): boolean { + return buffer.length >= 4 && buffer.readUInt32LE(0) === GLB_MAGIC; +} + +function looksLikeGltf(json: Json): boolean { + return ( + isRecord(json.asset) || + Array.isArray(json.meshes) || + Array.isArray(json.nodes) || + Array.isArray(json.scenes) + ); +} + +/** + * 解析 GLB 的 JSON chunk。 + * 12 字节头(magic / version / 总长)+ chunk(JSON 0x4E4F534A / BIN 0x004E4942), + * chunk 按 4 字节对齐 —— 与 `glb-textures.ts` 同一套布局(此处不 import,避免耦合)。 + * 任何异常都转成 warnings,返回 null 由调用方降级,不抛错。 + */ +function parseGlbJson(buffer: Buffer, warnings: string[]): Json | null { + if (buffer.length < 12) { + warnings.push('GLB 文件过小(不足 12 字节头),无法解析'); + return null; + } + const version = buffer.readUInt32LE(4); + if (version !== GLB_VERSION) { + warnings.push(`GLB 版本为 ${version},只支持版本 2`); + return null; + } + const declared = buffer.readUInt32LE(8); + if (declared > buffer.length) { + warnings.push(`GLB 头声明总长 ${declared} 字节,实际只有 ${buffer.length} 字节(文件可能被截断)`); + } + const end = declared > 0 ? Math.min(declared, buffer.length) : buffer.length; + + let offset = 12; + let json: Json | null = null; + while (offset + 8 <= end) { + const chunkLength = buffer.readUInt32LE(offset); + const chunkType = buffer.readUInt32LE(offset + 4); + const dataStart = offset + 8; + if (dataStart + chunkLength > buffer.length) { + warnings.push('GLB chunk 长度越界,文件可能被截断'); + break; + } + if (chunkType === CHUNK_JSON) { + try { + const parsed: unknown = JSON.parse( + buffer.subarray(dataStart, dataStart + chunkLength).toString('utf8'), + ); + if (isRecord(parsed)) json = parsed; + else warnings.push('GLB 的 JSON chunk 不是对象'); + } catch { + warnings.push('GLB 的 JSON chunk 解析失败(文件可能被截断)'); + } + } + // BIN chunk 本检查器用不到(面数/骨骼只需要 accessor 的 count),跳过即可 + offset = dataStart + align4(chunkLength); + } + if (!json) warnings.push('GLB 里没有可用的 JSON chunk'); + return json; +} + +/** 解析纯文本 `.gltf`;非 glTF JSON 一律返回 null 并写 warnings */ +function parseGltfText(buffer: Buffer, warnings: string[]): Json | null { + const text = buffer.toString('utf8'); + if (!/^\s*\{/.test(text)) { + warnings.push('不是 GLB,也不是 JSON 文本(疑似 fbx / pmx 等非 glTF 容器)'); + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + warnings.push('JSON 解析失败(文件可能被截断或不完整)'); + return null; + } + if (!isRecord(parsed)) { + warnings.push('JSON 顶层不是对象,不是 glTF 文档'); + return null; + } + if (!looksLikeGltf(parsed)) { + warnings.push('JSON 里没有 asset / meshes / nodes / scenes,不是 glTF 文档'); + return null; + } + return parsed; +} + +/* ------------------------------------------------------------------ 统计与骨骼树 */ + +/** 非 glTF(或彻底解析失败)时的降级结果:全部记 0,缺全部必需骨骼 */ +function emptyStats(container: ModelStats['container'], fileSize: number, warnings: string[]): ModelStats { + return { + container, + vrmVersion: null, + fileSize, + meshes: 0, + primitives: 0, + vertices: 0, + triangles: 0, + materials: 0, + textures: 0, + images: 0, + animations: 0, + morphTargets: 0, + nodes: 0, + bones: 0, + skins: 0, + humanoidMapped: 0, + humanoidTotal: HUMANOID_BONES.length, + missingRequiredBones: [...HUMANOID_REQUIRED], + drivable: false, + maxBoneDepth: 0, + boneTree: [], + warnings, + }; +} + +/** 读 accessor 的 count;索引缺失/越界/不是对象都记 0 */ +function accessorCount(accessors: unknown[], ref: unknown): number { + const index = toIndex(ref, accessors.length); + if (index === null) return 0; + const accessor = accessors[index]; + return isRecord(accessor) ? toCount(accessor.count) : 0; +} + +/** VRM 识别:VRMC_vrm → 1.0;VRM → 0.x。返回版本号(非 VRM 为 null) */ +function detectVrmVersion(gltf: Json, warnings: string[]): string | null { + const extensions = isRecord(gltf.extensions) ? gltf.extensions : {}; + + const vrmc = extensions['VRMC_vrm']; + if (isRecord(vrmc)) { + // VRM 1.0:meta 缺失时审核拿不到授权/来源信息,值得提示 + if (!isRecord(vrmc.meta)) warnings.push('VRM 1.0 扩展缺少 meta,模型来源与授权信息未知'); + return asText(vrmc.specVersion) ?? '1.0'; + } + + const vrm0 = extensions['VRM']; + if (isRecord(vrm0)) { + if (!isRecord(vrm0.meta)) warnings.push('VRM 0.x 扩展缺少 meta,模型来源与授权信息未知'); + return asText(vrm0.specVersion) ?? asText(vrm0.exporterVersion) ?? '0.x'; + } + + return null; +} + +interface FlatNode { + index: number; + name: string; + depth: number; + parentName: string | null; + childCount: number; +} + +/** 分析一份已解析的 glTF 文档(glb / gltf / vrm 共用) */ +function analyzeGltfDocument( + gltf: Json, + baseContainer: 'glb' | 'gltf', + fileSize: number, + warnings: string[], +): ModelStats { + const vrmVersion = detectVrmVersion(gltf, warnings); + // VRM 就是带扩展的 glTF:识别到扩展就把容器标成 vrm + const container: ModelStats['container'] = vrmVersion !== null ? 'vrm' : baseContainer; + + const meshList = asArray(gltf.meshes); + const accessors = asArray(gltf.accessors); + const nodeList = asArray(gltf.nodes); + const skinList = asArray(gltf.skins).filter(isRecord); + + /* ---------------------------------------------------------- 网格统计 */ + + let primitives = 0; + let vertices = 0; + let triangles = 0; + let morphTargets = 0; + + for (let mi = 0; mi < meshList.length; mi += 1) { + const mesh = meshList[mi]; + if (!isRecord(mesh)) continue; + const primitiveList = asArray(mesh.primitives); + for (let pi = 0; pi < primitiveList.length; pi += 1) { + const primitive = primitiveList[pi]; + if (!isRecord(primitive)) continue; + primitives += 1; + + const attributes = isRecord(primitive.attributes) ? primitive.attributes : {}; + const positionCount = accessorCount(accessors, attributes['POSITION']); + if (positionCount === 0) { + warnings.push(`mesh[${mi}].primitive[${pi}] 缺少可用的 POSITION accessor,顶点数按 0 计`); + } + vertices += positionCount; + + // 有索引按索引数/3;无索引按顶点数/3(口径见文件头注释) + const indexCount = accessorCount(accessors, primitive.indices); + triangles += Math.floor((indexCount > 0 ? indexCount : positionCount) / 3); + + morphTargets += asArray(primitive.targets).length; + + const mode = primitive.mode; + if (typeof mode === 'number' && mode !== 4) { + warnings.push(`mesh[${mi}].primitive[${pi}] 的 mode=${mode} 不是 TRIANGLES,面数按索引数/3 估算`); + } + } + } + + /* ---------------------------------------------------------- 蒙皮统计 */ + + // bones = 所有 skin.joints 去重;越界的 joint 索引视为脏数据丢弃(否则会凭空多出骨骼) + const joints = new Set(); + for (const skin of skinList) { + for (const joint of asArray(skin.joints)) { + const index = toIndex(joint, nodeList.length); + if (index !== null) joints.add(index); + } + } + + /* ------------------------------------------------------ 骨骼树(DFS) */ + + const nodeAt = (index: number): Json => { + const node = nodeList[index]; + return isRecord(node) ? node : {}; + }; + const nodeName = (index: number): string => { + const name = nodeAt(index).name; + return typeof name === 'string' && name.trim().length > 0 ? name : `node_${index}`; + }; + const childrenOf = (index: number): number[] => { + const out: number[] = []; + for (const child of asArray(nodeAt(index).children)) { + const valid = toIndex(child, nodeList.length); + if (valid !== null && !out.includes(valid)) out.push(valid); + } + return out; + }; + + const childIndices = new Set(); + for (let i = 0; i < nodeList.length; i += 1) { + for (const child of childrenOf(i)) childIndices.add(child); + } + + // 根:优先 scenes[scene].nodes;没有场景信息时用「不作为任何节点子节点」的节点 + const sceneList = asArray(gltf.scenes).filter(isRecord); + const sceneRef = gltf.scene; + const sceneIndex = + typeof sceneRef === 'number' && Number.isInteger(sceneRef) && sceneRef >= 0 && sceneRef < sceneList.length + ? sceneRef + : 0; + const roots: number[] = []; + const sceneNodes = sceneList.length > 0 ? asArray(sceneList[sceneIndex]?.nodes) : []; + for (const node of sceneNodes) { + const index = toIndex(node, nodeList.length); + if (index !== null && !roots.includes(index)) roots.push(index); + } + if (roots.length === 0) { + for (let i = 0; i < nodeList.length; i += 1) { + if (!childIndices.has(i)) roots.push(i); + } + } + + const flat: FlatNode[] = []; + const visited = new Set(); + const visit = (index: number, depth: number, parentName: string | null): void => { + if (visited.has(index)) return; // 环形 children 保护 + visited.add(index); + const children = childrenOf(index); + flat.push({ index, name: nodeName(index), depth, parentName, childCount: children.length }); + // 多根时每个根 depth=0;父在前、子在后(前序) + for (const child of children) visit(child, depth + 1, nodeName(index)); + }; + + for (const root of roots) visit(root, 0, null); + + // 不在场景图里的孤立节点也要能看到(按根处理),否则审核会漏骨骼 + const orphans: number[] = []; + for (let i = 0; i < nodeList.length; i += 1) { + if (!visited.has(i)) orphans.push(i); + } + if (orphans.length > 0) { + warnings.push(`有 ${orphans.length} 个节点不在场景图中,已按根节点(depth=0)列出`); + for (const orphan of orphans) visit(orphan, 0, null); + } + + /* -------------------------------------------------- humanoid 映射(与客户端一致) */ + + // variants → 节点索引;同名冲突取**先出现**的节点(骨骼树父在前,真正的 humanoid 骨通常更靠前) + const variantNode = new Map(); + for (const node of flat) { + for (const variant of boneNameVariants(node.name)) { + if (!variantNode.has(variant)) variantNode.set(variant, node.index); + } + } + + const matched = new Map(); + for (const canonical of HUMANOID_BONES) { + for (const candidate of HUMANOID_CANDIDATES[canonical] ?? []) { + const hit = variantNode.get(candidate); + if (hit !== undefined) { + matched.set(canonical, hit); + break; + } + } + } + + // 一个节点理论上可能被多个 canonical 命中(如 spine / spine1), + // 这里按 HUMANOID_BONES 顺序取第一次命中作为该节点的 humanoid 名 + const humanoidByNode = new Map(); + for (const canonical of HUMANOID_BONES) { + const nodeIndex = matched.get(canonical); + if (nodeIndex !== undefined && !humanoidByNode.has(nodeIndex)) { + humanoidByNode.set(nodeIndex, canonical); + } + } + + const boneTree: BoneNode[] = flat.map((node) => ({ + index: node.index, + name: node.name, + depth: node.depth, + parentName: node.parentName, + childCount: node.childCount, + inSkin: joints.has(node.index), + humanoid: humanoidByNode.get(node.index) ?? null, + })); + + let maxBoneDepth = 0; + for (const node of boneTree) { + if (node.depth > maxBoneDepth) maxBoneDepth = node.depth; + } + + const missingRequiredBones = HUMANOID_REQUIRED.filter((bone) => !matched.has(bone)); + + if (nodeList.length === 0) warnings.push('文件里没有 nodes,无法分析骨骼树'); + + return { + container, + vrmVersion, + fileSize, + meshes: meshList.length, + primitives, + vertices, + triangles, + materials: asArray(gltf.materials).length, + textures: asArray(gltf.textures).length, + images: asArray(gltf.images).length, + animations: asArray(gltf.animations).length, + morphTargets, + nodes: nodeList.length, + bones: joints.size, + skins: asArray(gltf.skins).length, + humanoidMapped: matched.size, + humanoidTotal: HUMANOID_BONES.length, + missingRequiredBones, + drivable: missingRequiredBones.length === 0, + maxBoneDepth, + boneTree, + warnings, + }; +} + +/* ------------------------------------------------------------------ 入口 */ + +/** + * 读取并分析模型文件。 + * + * @param absPath 模型文件绝对路径 + * @param fileSize 可选的已知文件大小(如数据库里记录的值);不传则用实际读到的字节数 + * + * 非 glTF 容器(fbx / pmx 等)、文件不存在、magic 不对、JSON 截断、缺 accessor…… + * 一律返回 `container='unknown'`(或已解析部分)+ `warnings`,**不抛异常**。 + */ +export async function inspectModelFile(absPath: string, fileSize?: number): Promise { + const warnings: string[] = []; + const hasSize = typeof fileSize === 'number' && Number.isFinite(fileSize); + + let buffer: Buffer; + try { + buffer = await readFile(absPath); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return emptyStats('unknown', hasSize ? toCount(fileSize) : 0, [`读取文件失败:${reason}`]); + } + + const size = hasSize ? toCount(fileSize) : buffer.length; + + let gltf: Json | null; + const glb = looksLikeGlb(buffer); + + if (glb) { + gltf = parseGlbJson(buffer, warnings); + } else { + gltf = parseGltfText(buffer, warnings); + } + + if (!gltf) return emptyStats('unknown', size, warnings); + + return analyzeGltfDocument(gltf, glb ? 'glb' : 'gltf', size, warnings); +} diff --git a/web-server/src/models/model-thumbnail.ts b/web-server/src/models/model-thumbnail.ts new file mode 100644 index 0000000..f96782b --- /dev/null +++ b/web-server/src/models/model-thumbnail.ts @@ -0,0 +1,1176 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, resolve as resolvePath } from 'node:path'; +import { deflateSync } from 'node:zlib'; + +/** + * 服务端模型缩略图生成(纯 TypeScript 软件光栅化)。 + * + * 背景:管理端列表页需要一张缩略图(`models.thumbnail_asset_id` 一直为空), + * 前端那套 three.js 预览在服务端跑不了,又不能引入 headless-gl / canvas / puppeteer + * 这类原生依赖。所以这里自己走一遍最小渲染管线: + * + * 读 GLB/glTF → 取 POSITION/indices accessor → 应用节点层级与蒙皮 → + * 按包围盒自动构图 → 透视投影 → z-buffer 逐三角形平面着色 → 手写 PNG 编码。 + * + * ## 涉及取舍(重要) + * + * 1. **蒙皮(skinning)已实现**:本项目主力模型是 VRM,每个顶点 4 骨骼权重。 + * 我们按 glTF 规范用 `skins[].inverseBindMatrices` × 关节世界矩阵算出每个顶点的 + * 蒙皮矩阵,权重取自 `JOINTS_0` / `WEIGHTS_0` 并做归一化。 + * - 只支持每顶点 4 权重(规范里最常见;没有 8 权重的 `JOINTS_1` 支持)。 + * - 当 `inverseBindMatrices` 读不到、或权重和近似为 0、或 accessor 数量不匹配时, + * **退化为不蒙皮**,按节点世界矩阵画绑定姿态(bind pose)——多数 VRM 的绑定姿态 + * 就是可看的 T/A 姿态,比错误的蒙皮矩阵导致的"爆炸形状"安全得多。 + * - 按 glTF 规范,蒙皮网格节点自身的变换被忽略(顶点直接由关节矩阵驱动)。 + * 2. **不采样贴图**:`pbrMetallicRoughness.baseColorFactor` 作为纯色基色, + * 即使存在 `baseColorTexture` 也不解码(避免再引入 PNG/JPEG 解码器), + * 因此带贴图的模型缩略图会是纯色块,但轮廓与光影正确。 + * 3. **稀疏 accessor(sparse)不支持**:遇到就直接跳过该 primitive。 + * 4. **只画三角形(mode=4)**,其余图元(点/线/条带/扇形)跳过。 + * 5. 大模型(>17M 索引)不会出现,索引统一按 uint32 读取。 + * + * 约定:**参数非法抛中文 Error;读到文件但无法解析返回 null(不抛错)**。 + * 绝不允许产出损坏的 PNG。 + */ + +/** 渲染结果 */ +export interface ThumbnailResult { + png: Buffer; + width: number; + height: number; + /** 实际画出的三角形数量,便于日志与测试 */ + trianglesDrawn: number; +} + +/** 渲染参数 */ +export interface ThumbnailOptions { + /** 输出边长(正方形),默认 512,允许 64..1024 */ + size?: number; + /** 水平视角(度),默认 -30(略微侧前方) */ + yawDeg?: number; + /** 俯仰视角(度),默认 12(略微俯视) */ + pitchDeg?: number; + /** 背景色 RGB,默认深灰 [24,26,32] */ + background?: [number, number, number]; +} + +// --------------------------------------------------------------------------- +// 常量 +// --------------------------------------------------------------------------- + +const GLB_MAGIC = 0x46546c67; // 'glTF' +const CHUNK_JSON = 0x4e4f534a; // 'JSON' +const CHUNK_BIN = 0x004e4942; // 'BIN\0' + +const DEFAULT_SIZE = 512; +const MIN_SIZE = 64; +const MAX_SIZE = 1024; +const DEFAULT_BACKGROUND: [number, number, number] = [24, 26, 32]; + +/** 垂直视场角(度) */ +const FOV_Y_DEG = 35; +/** 相机距离系数:让模型占画面约 1/1.6,保证不裁剪 */ +const FRAMING = 1.6; +/** 主光方向(世界空间,未归一化,用前会归一化) */ +const LIGHT_DIR: [number, number, number] = normalize3(0.4, 0.8, 0.6); +/** 环境光强度 */ +const AMBIENT = 0.3; +/** 漫反射强度 */ +const DIFFUSE = 0.7; + +// --------------------------------------------------------------------------- +// 数学小工具 +// --------------------------------------------------------------------------- + +type Mat4 = Float64Array; + +function clamp(value: number, min: number, max: number): number { + return value < min ? min : value > max ? max : value; +} + +function normalize3(x: number, y: number, z: number): [number, number, number] { + const len = Math.hypot(x, y, z); + if (!(len > 1e-12)) return [0, 0, 1]; + return [x / len, y / len, z / len]; +} + +function mat4Identity(): Mat4 { + const m = new Float64Array(16); + m[0] = 1; + m[5] = 1; + m[10] = 1; + m[15] = 1; + return m; +} + +/** 列主序矩阵乘法:out = a * b */ +function mat4Multiply(a: Mat4, b: Mat4): Mat4 { + const out = new Float64Array(16); + for (let col = 0; col < 4; col += 1) { + const b0 = b[col * 4]; + const b1 = b[col * 4 + 1]; + const b2 = b[col * 4 + 2]; + const b3 = b[col * 4 + 3]; + for (let row = 0; row < 4; row += 1) { + out[col * 4 + row] = a[row] * b0 + a[4 + row] * b1 + a[8 + row] * b2 + a[12 + row] * b3; + } + } + return out; +} + +/** 由 glTF 的 translation / rotation(四元数 xyzw) / scale 组合出局部矩阵(列主序) */ +function mat4FromTRS( + translation: number[] | undefined, + rotation: number[] | undefined, + scale: number[] | undefined, +): Mat4 { + const tx = num(translation, 0, 0); + const ty = num(translation, 1, 0); + const tz = num(translation, 2, 0); + let rx = num(rotation, 0, 0); + let ry = num(rotation, 1, 0); + let rz = num(rotation, 2, 0); + let rw = num(rotation, 3, 1); + const sx = num(scale, 0, 1); + const sy = num(scale, 1, 1); + const sz = num(scale, 2, 1); + + // 四元数归一化,避免上游写入了未归一化的数据 + const qlen = Math.hypot(rx, ry, rz, rw); + if (qlen > 1e-12) { + rx /= qlen; + ry /= qlen; + rz /= qlen; + rw /= qlen; + } else { + rx = 0; + ry = 0; + rz = 0; + rw = 1; + } + + const x2 = rx + rx; + const y2 = ry + ry; + const z2 = rz + rz; + const xx = rx * x2; + const xy = rx * y2; + const xz = rx * z2; + const yy = ry * y2; + const yz = ry * z2; + const zz = rz * z2; + const wx = rw * x2; + const wy = rw * y2; + const wz = rw * z2; + + const m = new Float64Array(16); + m[0] = (1 - (yy + zz)) * sx; + m[1] = (xy + wz) * sx; + m[2] = (xz - wy) * sx; + m[3] = 0; + m[4] = (xy - wz) * sy; + m[5] = (1 - (xx + zz)) * sy; + m[6] = (yz + wx) * sy; + m[7] = 0; + m[8] = (xz + wy) * sz; + m[9] = (yz - wx) * sz; + m[10] = (1 - (xx + yy)) * sz; + m[11] = 0; + m[12] = tx; + m[13] = ty; + m[14] = tz; + m[15] = 1; + return m; +} + +function num(arr: number[] | undefined, index: number, fallback: number): number { + if (!arr) return fallback; + const v = arr[index]; + return typeof v === 'number' && Number.isFinite(v) ? v : fallback; +} + +// --------------------------------------------------------------------------- +// glTF 数据结构(只声明用得到的部分) +// --------------------------------------------------------------------------- + +interface GltfBufferRef { + uri?: string; + byteLength?: number; +} + +interface GltfBufferView { + buffer?: number; + byteOffset?: number; + byteLength?: number; + byteStride?: number; +} + +interface GltfAccessor { + bufferView?: number; + byteOffset?: number; + componentType?: number; + normalized?: boolean; + count?: number; + type?: string; + sparse?: unknown; +} + +interface GltfPrimitive { + attributes?: Record; + indices?: number; + material?: number; + mode?: number; +} + +interface GltfMesh { + primitives?: GltfPrimitive[]; +} + +interface GltfNode { + children?: number[]; + mesh?: number; + skin?: number; + matrix?: number[]; + translation?: number[]; + rotation?: number[]; + scale?: number[]; +} + +interface GltfSkin { + inverseBindMatrices?: number; + joints?: number[]; + skeleton?: number; +} + +interface GltfMaterial { + pbrMetallicRoughness?: { + baseColorFactor?: number[]; + baseColorTexture?: unknown; + }; +} + +interface GltfScene { + nodes?: number[]; +} + +interface GltfDocument { + asset?: { version?: string }; + scene?: number; + scenes?: GltfScene[]; + nodes?: GltfNode[]; + meshes?: GltfMesh[]; + accessors?: GltfAccessor[]; + bufferViews?: GltfBufferView[]; + buffers?: GltfBufferRef[]; + materials?: GltfMaterial[]; + skins?: GltfSkin[]; +} + +/** 解析产物:JSON 文档 + 每个 buffer 的字节(null 表示读不到,引用它的 primitive 跳过) */ +interface GltfContainer { + doc: GltfDocument; + buffers: Array; +} + +interface AccessorData { + values: Float32Array; + count: number; + components: number; +} + +/** 收集好的三角形:positions 每 9 个数一个三角形,colors 每 3 个数一个三角形 */ +interface SceneGeometry { + positions: Float32Array; + colors: Float32Array; + triangleCount: number; +} + +// --------------------------------------------------------------------------- +// 容器解析 +// --------------------------------------------------------------------------- + +function align4(value: number): number { + return value + ((4 - (value % 4)) % 4); +} + +function componentSize(componentType: number | undefined): number { + switch (componentType) { + case 5120: // BYTE + case 5121: // UNSIGNED_BYTE + return 1; + case 5122: // SHORT + case 5123: // UNSIGNED_SHORT + return 2; + case 5125: // UNSIGNED_INT + case 5126: // FLOAT + return 4; + default: + return 0; + } +} + +function componentCount(type: string | undefined): number { + switch (type) { + case 'SCALAR': + return 1; + case 'VEC2': + return 2; + case 'VEC3': + return 3; + case 'VEC4': + return 4; + case 'MAT2': + return 4; + case 'MAT3': + return 9; + case 'MAT4': + return 16; + default: + return 0; + } +} + +/** 解码 data: URI(只处理 base64 形式,glTF 里非 base64 的极少见) */ +function decodeDataUri(uri: string): Buffer | null { + const comma = uri.indexOf(','); + if (comma < 0) return null; + const meta = uri.slice(5, comma); + const payload = uri.slice(comma + 1); + try { + if (/;base64/i.test(meta)) return Buffer.from(payload, 'base64'); + return Buffer.from(decodeURIComponent(payload), 'binary'); + } catch { + return null; + } +} + +/** 解析 GLB 容器;结构不完整(截断、无 JSON chunk、版本非 2)返回 null */ +function parseGlbContainer(file: Buffer): GltfContainer | null { + if (file.length < 12) return null; + if (file.readUInt32LE(4) !== 2) return null; // 只支持 glTF 2.0 + const declaredLength = file.readUInt32LE(8); + const end = Math.min(declaredLength > 0 ? declaredLength : file.length, file.length); + + let offset = 12; + let doc: GltfDocument | null = null; + let bin: Buffer | null = null; + while (offset + 8 <= end) { + const chunkLength = file.readUInt32LE(offset); + const chunkType = file.readUInt32LE(offset + 4); + const dataStart = offset + 8; + // 截断的 GLB:chunk 声明的长度超出实际文件 + if (chunkLength < 0 || dataStart + chunkLength > file.length) return null; + const data = file.subarray(dataStart, dataStart + chunkLength); + if (chunkType === CHUNK_JSON) { + try { + doc = JSON.parse(data.toString('utf8')) as GltfDocument; + } catch { + return null; + } + } else if (chunkType === CHUNK_BIN) { + bin = data; + } + offset = dataStart + align4(chunkLength); + } + if (!doc || typeof doc !== 'object') return null; + + // GLB 规范:buffer 0 无 uri 时指向 BIN chunk + const buffers = (doc.buffers ?? []).map((_, index) => (index === 0 ? bin : null)); + return { doc, buffers }; +} + +/** 解析文本 .gltf;外部 buffer 读不到的记为 null(引用它的 primitive 会被跳过) */ +async function parseTextGltf(file: Buffer, absPath: string): Promise { + let doc: GltfDocument; + try { + doc = JSON.parse(file.toString('utf8')) as GltfDocument; + } catch { + return null; + } + if (!doc || typeof doc !== 'object') return null; + + const baseDir = dirname(absPath); + const refs = doc.buffers ?? []; + const buffers: Array = []; + for (const ref of refs) { + const uri = typeof ref.uri === 'string' ? ref.uri : ''; + if (uri.length === 0) { + buffers.push(null); + continue; + } + if (uri.startsWith('data:')) { + buffers.push(decodeDataUri(uri)); + continue; + } + try { + buffers.push(await readFile(resolvePath(baseDir, decodeURIComponent(uri)))); + } catch { + buffers.push(null); + } + } + return { doc, buffers }; +} + +/** 判断并解析 glTF 容器;不是 glTF 就返回 null */ +async function loadGltfContainer(file: Buffer, absPath: string): Promise { + if (file.length >= 12 && file.readUInt32LE(0) === GLB_MAGIC) { + return parseGlbContainer(file); + } + // 文本 glTF:跳过前导空白后应当是 '{' + const head = file.toString('utf8', 0, Math.min(file.length, 64)); + if (!/^\s*\{/.test(head)) return null; + return parseTextGltf(file, absPath); +} + +// --------------------------------------------------------------------------- +// accessor 读取 +// --------------------------------------------------------------------------- + +function readComponent(buffer: Buffer, offset: number, componentType: number): number { + switch (componentType) { + case 5120: + return buffer.readInt8(offset); + case 5121: + return buffer.readUInt8(offset); + case 5122: + return buffer.readInt16LE(offset); + case 5123: + return buffer.readUInt16LE(offset); + case 5125: + return buffer.readUInt32LE(offset); + case 5126: + return buffer.readFloatLE(offset); + default: + return Number.NaN; + } +} + +/** 按规范归一化整数分量 */ +function denormalize(value: number, componentType: number): number { + switch (componentType) { + case 5121: + return value / 255; + case 5120: + return Math.max(value / 127, -1); + case 5123: + return value / 65535; + case 5122: + return Math.max(value / 32767, -1); + default: + return value; + } +} + +/** + * 读取 accessor 为浮点数组(归一化的整数会按规范还原到 0..1 / -1..1)。 + * 越界 / 截断 / 稀疏 / 未知类型一律返回 null,由调用方跳过该 primitive。 + */ +function readAccessorFloats( + doc: GltfDocument, + buffers: Array, + accessorIndex: number | undefined, + expectedComponents: number, +): AccessorData | null { + if (typeof accessorIndex !== 'number') return null; + const accessor = doc.accessors?.[accessorIndex]; + if (!accessor || accessor.sparse) return null; + + const components = componentCount(accessor.type); + if (components !== expectedComponents) return null; + const componentType = accessor.componentType; + if (typeof componentType !== 'number') return null; + const compSize = componentSize(componentType); + if (compSize === 0) return null; + if (typeof accessor.bufferView !== 'number') return null; + + const view = doc.bufferViews?.[accessor.bufferView]; + if (!view || typeof view.buffer !== 'number') return null; + const buffer = buffers[view.buffer]; + if (!buffer) return null; + + const count = accessor.count ?? 0; + if (!Number.isFinite(count) || count <= 0) return null; + + const elementSize = compSize * components; + const base = (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0); + const stride = typeof view.byteStride === 'number' && view.byteStride > 0 ? view.byteStride : elementSize; + // 注意交错缓冲:最后一个元素也要落在 buffer 内 + if (base < 0 || base + stride * (count - 1) + elementSize > buffer.length) return null; + + const normalized = accessor.normalized === true; + const values = new Float32Array(count * components); + for (let i = 0; i < count; i += 1) { + let offset = base + i * stride; + for (let c = 0; c < components; c += 1) { + const raw = readComponent(buffer, offset, componentType); + values[i * components + c] = + normalized && componentType !== 5126 && componentType !== 5125 + ? denormalize(raw, componentType) + : raw; + offset += compSize; + } + } + return { values, count, components }; +} + +/** 读取索引 accessor(5121 / 5123 / 5125),同样支持 byteStride */ +function readIndexAccessor( + doc: GltfDocument, + buffers: Array, + accessorIndex: number | undefined, +): Uint32Array | null { + if (typeof accessorIndex !== 'number') return null; + const accessor = doc.accessors?.[accessorIndex]; + if (!accessor || accessor.sparse) return null; + if (accessor.type !== 'SCALAR') return null; + + const componentType = accessor.componentType; + if (componentType !== 5121 && componentType !== 5123 && componentType !== 5125) return null; + const compSize = componentSize(componentType); + if (typeof accessor.bufferView !== 'number') return null; + + const view = doc.bufferViews?.[accessor.bufferView]; + if (!view || typeof view.buffer !== 'number') return null; + const buffer = buffers[view.buffer]; + if (!buffer) return null; + + const count = accessor.count ?? 0; + if (!Number.isFinite(count) || count <= 0) return null; + + const base = (view.byteOffset ?? 0) + (accessor.byteOffset ?? 0); + const stride = typeof view.byteStride === 'number' && view.byteStride > 0 ? view.byteStride : compSize; + if (base < 0 || base + stride * (count - 1) + compSize > buffer.length) return null; + + const out = new Uint32Array(count); + for (let i = 0; i < count; i += 1) { + out[i] = readComponent(buffer, base + i * stride, componentType); + } + return out; +} + +// --------------------------------------------------------------------------- +// 节点层级与蒙皮 +// --------------------------------------------------------------------------- + +function localMatrix(node: GltfNode): Mat4 { + if (Array.isArray(node.matrix) && node.matrix.length === 16) { + const m = new Float64Array(16); + for (let i = 0; i < 16; i += 1) { + const v = node.matrix[i]; + m[i] = typeof v === 'number' && Number.isFinite(v) ? v : i % 5 === 0 ? 1 : 0; + } + return m; + } + return mat4FromTRS(node.translation, node.rotation, node.scale); +} + +/** 遍历场景图,算出每个节点的世界矩阵(同时起到防环作用) */ +function buildWorldMatrices(doc: GltfDocument): Map { + const nodes = doc.nodes ?? []; + const out = new Map(); + const scenes = doc.scenes ?? []; + const sceneIndex = doc.scene ?? 0; + const chosen = scenes[sceneIndex] ?? scenes[0]; + let roots = chosen?.nodes ?? []; + if (roots.length === 0) roots = nodes.map((_, index) => index); + + const identity = mat4Identity(); + const visit = (index: number, parent: Mat4): void => { + if (!Number.isInteger(index) || index < 0 || index >= nodes.length) return; + if (out.has(index)) return; // 同一节点被多次引用 / 出现环 + const world = mat4Multiply(parent, localMatrix(nodes[index])); + out.set(index, world); + for (const child of nodes[index].children ?? []) visit(child, world); + }; + for (const root of roots) visit(root, identity); + return out; +} + +/** + * 计算每个关节的蒙皮矩阵:jointWorld * inverseBindMatrix。 + * inverseBindMatrices 缺失或读不到时返回 null(调用方退化为不蒙皮)。 + */ +function buildSkinMatrices( + doc: GltfDocument, + buffers: Array, + skinIndex: number, + worldByNode: Map, + cache: Map | null>, +): Array | null { + const cached = cache.get(skinIndex); + if (cached !== undefined) return cached; + + let result: Array | null = null; + const skin = doc.skins?.[skinIndex]; + const joints = skin?.joints; + if (skin && Array.isArray(joints) && joints.length > 0) { + const ibm = readAccessorFloats(doc, buffers, skin.inverseBindMatrices, 16); + if (ibm) { + result = joints.map((jointIndex, i) => { + if ((i + 1) * 16 > ibm.values.length) return null; + const inverseBind = new Float64Array(16); + for (let k = 0; k < 16; k += 1) inverseBind[k] = ibm.values[i * 16 + k]; + const jointWorld = worldByNode.get(jointIndex) ?? mat4Identity(); + return mat4Multiply(jointWorld, inverseBind); + }); + } + } + cache.set(skinIndex, result); + return result; +} + +/** + * 混合出单个顶点的蒙皮矩阵(最多 4 权重)。 + * 返回 false 表示权重无效,调用方退化为节点世界矩阵。 + */ +function skinVertexMatrix( + jointValues: Float32Array, + weightValues: Float32Array, + vertexIndex: number, + skinMatrices: Array, + out: Mat4, +): boolean { + const o = vertexIndex * 4; + const w0 = weightValues[o]; + const w1 = weightValues[o + 1]; + const w2 = weightValues[o + 2]; + const w3 = weightValues[o + 3]; + const sum = w0 + w1 + w2 + w3; + if (!(sum > 1e-8) || !Number.isFinite(sum)) return false; + + out.fill(0); + let used = false; + for (let k = 0; k < 4; k += 1) { + const w = weightValues[o + k] / sum; + if (!(w > 0)) continue; + const joint = Math.trunc(jointValues[o + k]); + const matrix = skinMatrices[joint]; + if (!matrix) continue; + used = true; + for (let i = 0; i < 16; i += 1) out[i] += w * matrix[i]; + } + return used; +} + +// --------------------------------------------------------------------------- +// 几何收集 +// --------------------------------------------------------------------------- + +function materialBaseColor(doc: GltfDocument, materialIndex: number | undefined): [number, number, number] { + if (typeof materialIndex === 'number') { + const factor = doc.materials?.[materialIndex]?.pbrMetallicRoughness?.baseColorFactor; + if (Array.isArray(factor) && factor.length >= 3) { + return [clamp(num(factor, 0, 0.8), 0, 1), clamp(num(factor, 1, 0.8), 0, 1), clamp(num(factor, 2, 0.8), 0, 1)]; + } + } + // 没有材质或没有 baseColorFactor 时用中性灰; + // 注意:这里不采样 baseColorTexture,纯色即可(见文件头取舍说明)。 + return [0.8, 0.8, 0.8]; +} + +/** 遍历场景图收集世界空间三角形 */ +function collectTriangles(container: GltfContainer): SceneGeometry | null { + const { doc, buffers } = container; + const nodes = doc.nodes ?? []; + const worldByNode = buildWorldMatrices(doc); + const skinCache = new Map | null>(); + + const positions: number[] = []; + const colors: number[] = []; + const skinScratch = new Float64Array(16); + + const appendMesh = (nodeIndex: number): void => { + const node = nodes[nodeIndex]; + if (!node || typeof node.mesh !== 'number') return; + const mesh = doc.meshes?.[node.mesh]; + if (!mesh) return; + const world = worldByNode.get(nodeIndex) ?? mat4Identity(); + const skinMatrices = + typeof node.skin === 'number' + ? buildSkinMatrices(doc, buffers, node.skin, worldByNode, skinCache) + : null; + + for (const primitive of mesh.primitives ?? []) { + if ((primitive.mode ?? 4) !== 4) continue; // 只画三角形 + const attributes = primitive.attributes ?? {}; + const positionData = readAccessorFloats(doc, buffers, attributes.POSITION, 3); + if (!positionData) continue; + + const indexData = + typeof primitive.indices === 'number' ? readIndexAccessor(doc, buffers, primitive.indices) : null; + + const jointData = skinMatrices ? readAccessorFloats(doc, buffers, attributes.JOINTS_0, 4) : null; + const weightData = skinMatrices ? readAccessorFloats(doc, buffers, attributes.WEIGHTS_0, 4) : null; + const useSkin = !!( + skinMatrices && + jointData && + weightData && + jointData.count === positionData.count && + weightData.count === positionData.count + ); + + const vertexCount = positionData.count; + const [cr, cg, cb] = materialBaseColor(doc, primitive.material); + + // 顶点只变换一次:蒙皮或节点世界矩阵 + const px = new Float64Array(vertexCount); + const py = new Float64Array(vertexCount); + const pz = new Float64Array(vertexCount); + for (let i = 0; i < vertexCount; i += 1) { + const lx = positionData.values[i * 3]; + const ly = positionData.values[i * 3 + 1]; + const lz = positionData.values[i * 3 + 2]; + let m: Mat4 = world; + if (useSkin && jointData && weightData) { + // 权重无效时退化:用节点世界矩阵画绑定姿态 + if (skinVertexMatrix(jointData.values, weightData.values, i, skinMatrices as Array, skinScratch)) { + m = skinScratch; + } + } + px[i] = m[0] * lx + m[4] * ly + m[8] * lz + m[12]; + py[i] = m[1] * lx + m[5] * ly + m[9] * lz + m[13]; + pz[i] = m[2] * lx + m[6] * ly + m[10] * lz + m[14]; + } + + const triangleCount = indexData + ? Math.floor(indexData.length / 3) + : Math.floor(vertexCount / 3); + for (let t = 0; t < triangleCount; t += 1) { + const i0 = indexData ? indexData[t * 3] : t * 3; + const i1 = indexData ? indexData[t * 3 + 1] : t * 3 + 1; + const i2 = indexData ? indexData[t * 3 + 2] : t * 3 + 2; + if (i0 >= vertexCount || i1 >= vertexCount || i2 >= vertexCount) continue; + const x0 = px[i0]; + const y0 = py[i0]; + const z0 = pz[i0]; + const x1 = px[i1]; + const y1 = py[i1]; + const z1 = pz[i1]; + const x2 = px[i2]; + const y2 = py[i2]; + const z2 = pz[i2]; + // 丢弃含 NaN / Infinity 的三角形,避免污染包围盒与光栅化 + if ( + !Number.isFinite(x0) || !Number.isFinite(y0) || !Number.isFinite(z0) || + !Number.isFinite(x1) || !Number.isFinite(y1) || !Number.isFinite(z1) || + !Number.isFinite(x2) || !Number.isFinite(y2) || !Number.isFinite(z2) + ) { + continue; + } + positions.push(x0, y0, z0, x1, y1, z1, x2, y2, z2); + colors.push(cr, cg, cb); + } + } + }; + + // 只渲染当前场景可达的节点 + const scenes = doc.scenes ?? []; + const sceneIndex = doc.scene ?? 0; + const chosen = scenes[sceneIndex] ?? scenes[0]; + let roots = chosen?.nodes ?? []; + if (roots.length === 0) roots = nodes.map((_, index) => index); + + const visited = new Set(); + const walk = (index: number): void => { + if (!Number.isInteger(index) || index < 0 || index >= nodes.length) return; + if (visited.has(index)) return; + visited.add(index); + appendMesh(index); + for (const child of nodes[index].children ?? []) walk(child); + }; + for (const root of roots) walk(root); + + const triangleCount = Math.floor(colors.length / 3); + if (triangleCount === 0) return null; + return { + positions: Float32Array.from(positions), + colors: Float32Array.from(colors), + triangleCount, + }; +} + +// --------------------------------------------------------------------------- +// 相机 / 投影 / 光栅化 +// --------------------------------------------------------------------------- + +/** 由 viewProj 变换单个点,返回 [sx, sy, depth, w] */ +function projectPoint( + viewProj: Mat4, + x: number, + y: number, + z: number, + size: number, + out: Float64Array, +): void { + const w = viewProj[3] * x + viewProj[7] * y + viewProj[11] * z + viewProj[15]; + const cx = viewProj[0] * x + viewProj[4] * y + viewProj[8] * z + viewProj[12]; + const cy = viewProj[1] * x + viewProj[5] * y + viewProj[9] * z + viewProj[13]; + const cz = viewProj[2] * x + viewProj[6] * y + viewProj[10] * z + viewProj[14]; + out[0] = (cx / w) * 0.5 * size + 0.5 * size; + out[1] = (0.5 - (cy / w) * 0.5) * size; + out[2] = cz / w; + out[3] = w; +} + +interface RasterOptions { + size: number; + background: [number, number, number]; + cullBackFace: boolean; +} + +interface RasterOutput { + pixels: Buffer; + drawn: number; +} + +function edge(ax: number, ay: number, bx: number, by: number, px: number, py: number): number { + return (bx - ax) * (py - ay) - (by - ay) * (px - ax); +} + +/** + * z-buffer + 逐三角形平面着色。 + * 屏幕空间有符号面积为负的是正面(NDC 里 CCW 正面经 y 翻转后变成负), + * 因此 cullBackFace 时丢弃 area >= 0 的三角形。 + */ +function rasterizeTriangles( + geometry: SceneGeometry, + viewProj: Mat4, + options: RasterOptions, +): RasterOutput { + const { size, background, cullBackFace } = options; + const { positions, colors, triangleCount } = geometry; + + const pixels = Buffer.alloc(size * size * 3); + for (let i = 0; i < size * size; i += 1) { + pixels[i * 3] = clamp(Math.round(background[0]), 0, 255); + pixels[i * 3 + 1] = clamp(Math.round(background[1]), 0, 255); + pixels[i * 3 + 2] = clamp(Math.round(background[2]), 0, 255); + } + const depth = new Float64Array(size * size).fill(Number.POSITIVE_INFINITY); + const p0 = new Float64Array(4); + const p1 = new Float64Array(4); + const p2 = new Float64Array(4); + let drawn = 0; + + for (let t = 0; t < triangleCount; t += 1) { + const o = t * 9; + const ax = positions[o]; + const ay = positions[o + 1]; + const az = positions[o + 2]; + const bx = positions[o + 3]; + const by = positions[o + 4]; + const bz = positions[o + 5]; + const cx = positions[o + 6]; + const cy = positions[o + 7]; + const cz = positions[o + 8]; + + // 世界空间法线(平面着色) + const ux = bx - ax; + const uy = by - ay; + const uz = bz - az; + const vx = cx - ax; + const vy = cy - ay; + const vz = cz - az; + let nx = uy * vz - uz * vy; + let ny = uz * vx - ux * vz; + let nz = ux * vy - uy * vx; + const nlen = Math.hypot(nx, ny, nz); + if (!(nlen > 1e-12)) continue; // 退化三角形 + nx /= nlen; + ny /= nlen; + nz /= nlen; + + projectPoint(viewProj, ax, ay, az, size, p0); + projectPoint(viewProj, bx, by, bz, size, p1); + projectPoint(viewProj, cx, cy, cz, size, p2); + // w <= 0 表示顶点在相机后方;不做近平面裁剪,直接丢弃该三角形 + if (!(p0[3] > 1e-6) || !(p1[3] > 1e-6) || !(p2[3] > 1e-6)) continue; + if (!Number.isFinite(p0[0]) || !Number.isFinite(p1[0]) || !Number.isFinite(p2[0])) continue; + if (!Number.isFinite(p0[1]) || !Number.isFinite(p1[1]) || !Number.isFinite(p2[1])) continue; + if (!Number.isFinite(p0[2]) || !Number.isFinite(p1[2]) || !Number.isFinite(p2[2])) continue; + + const area = edge(p0[0], p0[1], p1[0], p1[1], p2[0], p2[1]); + if (!Number.isFinite(area) || Math.abs(area) < 1e-12) continue; + if (cullBackFace && area >= 0) continue; + + const minXf = Math.min(p0[0], p1[0], p2[0]); + const maxXf = Math.max(p0[0], p1[0], p2[0]); + const minYf = Math.min(p0[1], p1[1], p2[1]); + const maxYf = Math.max(p0[1], p1[1], p2[1]); + const minX = Math.max(0, Math.floor(minXf)); + const maxX = Math.min(size - 1, Math.ceil(maxXf)); + const minY = Math.max(0, Math.floor(minYf)); + const maxY = Math.min(size - 1, Math.ceil(maxYf)); + if (maxX < minX || maxY < minY) continue; // 完全在画面外 + drawn += 1; + + // 简单 Lambert:环境项 + 主光;用 abs 兼顾绕序不一致的模型,避免整片死黑 + const ndotl = Math.abs(nx * LIGHT_DIR[0] + ny * LIGHT_DIR[1] + nz * LIGHT_DIR[2]); + const intensity = AMBIENT + DIFFUSE * ndotl; + const baseR = colors[t * 3]; + const baseG = colors[t * 3 + 1]; + const baseB = colors[t * 3 + 2]; + const r = clamp(Math.round(baseR * intensity * 255), 0, 255); + const g = clamp(Math.round(baseG * intensity * 255), 0, 255); + const b = clamp(Math.round(baseB * intensity * 255), 0, 255); + + const invArea = 1 / area; + for (let y = minY; y <= maxY; y += 1) { + const py = y + 0.5; + const rowBase = y * size; + for (let x = minX; x <= maxX; x += 1) { + const px = x + 0.5; + const w0 = edge(p1[0], p1[1], p2[0], p2[1], px, py) * invArea; + const w1 = edge(p2[0], p2[1], p0[0], p0[1], px, py) * invArea; + const w2 = 1 - w0 - w1; + if (w0 < 0 || w1 < 0 || w2 < 0) continue; + const d = w0 * p0[2] + w1 * p1[2] + w2 * p2[2]; + const index = rowBase + x; + if (d < depth[index]) { + depth[index] = d; + const p = index * 3; + pixels[p] = r; + pixels[p + 1] = g; + pixels[p + 2] = b; + } + } + } + } + return { pixels, drawn }; +} + +/** 按包围盒自动构图,返回 viewProj 矩阵 */ +function buildViewProjection(geometry: SceneGeometry, size: number, yawDeg: number, pitchDeg: number): Mat4 { + const { positions, triangleCount } = geometry; + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let minZ = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + let maxZ = Number.NEGATIVE_INFINITY; + const vertexCount = triangleCount * 3; + for (let i = 0; i < vertexCount; i += 1) { + const x = positions[i * 3]; + const y = positions[i * 3 + 1]; + const z = positions[i * 3 + 2]; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (z < minZ) minZ = z; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + if (z > maxZ) maxZ = z; + } + + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const cz = (minZ + maxZ) / 2; + const dx = maxX - minX; + const dy = maxY - minY; + const dz = maxZ - minZ; + let radius = 0.5 * Math.hypot(dx, dy, dz); + if (!Number.isFinite(radius) || radius <= 0) radius = Number.MIN_VALUE; + + const fov = (FOV_Y_DEG * Math.PI) / 180; + const distance = (radius / Math.tan(fov / 2)) * FRAMING; + + // glTF 是 Y-up;yaw 绕 Y,pitch 抬高相机 + const yaw = (yawDeg * Math.PI) / 180; + const pitch = (pitchDeg * Math.PI) / 180; + const cp = Math.cos(pitch); + const sp = Math.sin(pitch); + const dirX = Math.sin(yaw) * cp; + const dirY = sp; + const dirZ = Math.cos(yaw) * cp; + const eyeX = cx + dirX * distance; + const eyeY = cy + dirY * distance; + const eyeZ = cz + dirZ * distance; + + // lookAt(右手系,z 轴指向相机后方) + let zx = dirX; + let zy = dirY; + let zz = dirZ; + const zlen = Math.hypot(zx, zy, zz) || 1; + zx /= zlen; + zy /= zlen; + zz /= zlen; + + let ux = 0; + let uy = 1; + let uz = 0; + let xx = uy * zz - uz * zy; + let xy = uz * zx - ux * zz; + let xz = ux * zy - uy * zx; + let xlen = Math.hypot(xx, xy, xz); + if (!(xlen > 1e-9)) { + // 视线与 up 平行时的退化处理 + ux = 0; + uy = 0; + uz = 1; + xx = uy * zz - uz * zy; + xy = uz * zx - ux * zz; + xz = ux * zy - uy * zx; + xlen = Math.hypot(xx, xy, xz) || 1; + } + xx /= xlen; + xy /= xlen; + xz /= xlen; + const yx = zy * xz - zz * xy; + const yy = zz * xx - zx * xz; + const yz = zx * xy - zy * xx; + + const view = new Float64Array(16); + view[0] = xx; + view[1] = yx; + view[2] = zx; + view[4] = xy; + view[5] = yy; + view[6] = zy; + view[8] = xz; + view[9] = yz; + view[10] = zz; + view[12] = -(xx * eyeX + xy * eyeY + xz * eyeZ); + view[13] = -(yx * eyeX + yy * eyeY + yz * eyeZ); + view[14] = -(zx * eyeX + zy * eyeY + zz * eyeZ); + view[15] = 1; + + const near = Math.max(distance - radius * 1.5, distance * 0.01, Number.MIN_VALUE); + const far = distance + radius * 2 + near; + const f = 1 / Math.tan(fov / 2); + const proj = new Float64Array(16); + proj[0] = f; // aspect = 1(正方形输出) + proj[5] = f; + proj[10] = (far + near) / (near - far); + proj[11] = -1; + proj[14] = (2 * far * near) / (near - far); + + return mat4Multiply(proj, view); +} + +// --------------------------------------------------------------------------- +// PNG 编码(8 位 RGB、无隔行、filter=0;不引第三方库) +// --------------------------------------------------------------------------- + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer: Buffer): number { + let c = 0xffffffff; + for (let i = 0; i < buffer.length; i += 1) { + c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +function pngChunk(type: string, data: Buffer): Buffer { + const typeBuffer = Buffer.from(type, 'ascii'); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0); + return Buffer.concat([length, typeBuffer, data, crc]); +} + +/** 把 RGB 像素编码为 PNG(每行前置 filter 字节 0,整块 deflate) */ +function encodePng(rgb: Buffer, width: number, height: number): Buffer { + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // color type: truecolor RGB + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter method + ihdr[12] = 0; // interlace: none + + const stride = width * 3; + const raw = Buffer.alloc(height * (stride + 1)); + for (let y = 0; y < height; y += 1) { + raw[y * (stride + 1)] = 0; // filter type 0 + rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride); + } + + return Buffer.concat([ + signature, + pngChunk('IHDR', ihdr), + pngChunk('IDAT', deflateSync(raw)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + +// --------------------------------------------------------------------------- +// 对外入口 +// --------------------------------------------------------------------------- + +/** + * 渲染 GLB/VRM 缩略图;文件不是可解析的 glTF 容器时返回 null(不抛错)。 + * + * @param absPath 模型文件绝对路径(.glb / .vrm / .gltf) + * @param options 尺寸与视角;size 必须在 64..1024 之间,否则抛中文 Error + */ +export async function renderModelThumbnail( + absPath: string, + options?: ThumbnailOptions, +): Promise { + // ---- 参数校验:非法参数直接抛错,不静默降级 ---- + const size = options?.size ?? DEFAULT_SIZE; + if (typeof size !== 'number' || !Number.isInteger(size) || size < MIN_SIZE || size > MAX_SIZE) { + throw new Error(`缩略图尺寸非法:${String(size)},允许 64..1024 之间的整数`); + } + const background = options?.background ?? DEFAULT_BACKGROUND; + if ( + !Array.isArray(background) || + background.length !== 3 || + background.some((v) => typeof v !== 'number' || !Number.isFinite(v)) + ) { + throw new Error('缩略图背景色非法:需要 [r, g, b] 三个有限数字'); + } + const yawDeg = options?.yawDeg ?? -30; + const rawPitchDeg = options?.pitchDeg ?? 12; + if (!Number.isFinite(yawDeg) || !Number.isFinite(rawPitchDeg)) { + throw new Error('缩略图视角参数非法:yawDeg / pitchDeg 必须是有限数字'); + } + // 俯仰角夹到 ±89°,避免视线与 up 平行导致视图矩阵退化 + const pitchDeg = clamp(rawPitchDeg, -89, 89); + + // ---- 读文件:读不到(不存在 / 无权限)也返回 null,交给调用方兜底 ---- + let file: Buffer; + try { + file = await readFile(absPath); + } catch { + return null; + } + + try { + const container = await loadGltfContainer(file, absPath); + if (!container) return null; + const geometry = collectTriangles(container); + if (!geometry || geometry.triangleCount === 0) return null; + + const viewProj = buildViewProjection(geometry, size, yawDeg, pitchDeg); + // 先按 CCW 正面剔除;若一个三角形都没画出来,说明模型绕序相反,退回双面绘制 + let output = rasterizeTriangles(geometry, viewProj, { size, background, cullBackFace: true }); + if (output.drawn === 0) { + output = rasterizeTriangles(geometry, viewProj, { size, background, cullBackFace: false }); + } + if (output.drawn === 0) return null; + + return { + png: encodePng(output.pixels, size, size), + width: size, + height: size, + trianglesDrawn: output.drawn, + }; + } catch { + // 任何解析 / 渲染异常都视为"无法解析",返回 null,绝不产出损坏的 PNG + return null; + } +} diff --git a/web-server/src/models/model.serializer.ts b/web-server/src/models/model.serializer.ts index 01231bc..163b61c 100644 --- a/web-server/src/models/model.serializer.ts +++ b/web-server/src/models/model.serializer.ts @@ -14,6 +14,8 @@ export interface ModelDto { fileSha256: string | null; /** 上传压缩包的 sha256(稳定);非压缩包上传为 null,此时以 fileSha256 为准 */ sourceSha256: string | null; + /** 模型分析结果(面数 / 骨骼树 / humanoid 覆盖 / 可驱动性),供审核界面展示 */ + stats: Record | null; thumbnailUrl: string | null; /** 由压缩包转换而来时的说明,例如 `Hatsune Miku.pmx → GLB,已内嵌 2 张贴图` */ conversionNote: string | null; @@ -46,6 +48,7 @@ export function toModelDto(model: CharacterModel, urls: ModelUrlLookup): ModelDt fileSize: urls.fileSize ?? null, fileSha256: urls.fileSha256 ?? null, sourceSha256: urls.sourceSha256 ?? null, + stats: model.stats ?? null, thumbnailUrl: urls.thumbnailUrl, conversionNote: model.conversionNote ?? null, sourceType: model.sourceType, diff --git a/web-server/src/models/models.service.ts b/web-server/src/models/models.service.ts index dc79ad4..c4d2851 100644 --- a/web-server/src/models/models.service.ts +++ b/web-server/src/models/models.service.ts @@ -19,15 +19,17 @@ import { } from '../common/enums'; import { Paged, toPaged } from '../common/pagination'; import { SourceTuple, assertSourceTupleComplete } from '../common/source.dto'; -import { - UploadedFileLike, - parseOptionalBool, - requireUploadedFile, - resolveFormatByExtension, -} from '../common/upload.util'; +import { UploadedFileLike, parseOptionalBool, requireUploadedFile } from '../common/upload.util'; import { DEFAULT_MODEL_SEED } from '../config/default-model'; import { writeDefaultModelSeed } from './default-model-source'; -import { convertModelArchive } from './model-convert'; +import { + ConvertedModel, + convertModelArchive, + convertModelFile, + importModelFile, +} from './model-convert'; +import { ModelStats, inspectModelFile } from './model-inspector'; +import { renderModelThumbnail } from './model-thumbnail'; import { CharacterModel } from '../entities/model.entity'; import { AppConfig } from '../config/configuration'; import { StorageService } from '../storage/storage.service'; @@ -71,7 +73,20 @@ export class ModelsService { return model; } - /** T-WEB-005:上传模型 + 来源字段,VRM 优先;自动识别 VRM 版本 */ + /** + * T-WEB-005:上传模型(T-WEB-005 追加:GLB/VRM 直接导入,PMX/FBX 优先转 GLB、其次 VRM)。 + * + * 统一入口,按扩展名分流: + * - `.glb` / `.vrm` / `.gltf`:**直接导入**,不做任何重编码 + * - `.pmx` / `.fbx`:服务端转换(优先 GLB,其次 VRM;见 model-convert.ts) + * - `.zip`:自动搜索包内可用模型(`.vrm` > `.glb` > `.gltf` > `.pmx` > `.fbx`), + * 能直接导入就直接导入,否则解压后转换;压缩包原件留存溯源 + * + * 入库后立即做两件事:**模型分析**(面数 / 骨骼树 / humanoid 覆盖,供审核展示) + * 与**缩略图生成**(纯 TS 光栅化,失败不影响入库)。 + * + * 状态一律 `pending`:模型必须经审核后才可能被客户端消费(契约 database.md 状态机)。 + */ async create(body: Record, file: UploadedFileLike | undefined): Promise { const upload = requireUploadedFile(file); @@ -90,184 +105,175 @@ export class ModelsService { throw new BadRequestException('上传模型必须填写完整的来源信息(sourceType/sourceName/sourceUrl/license/author)'); } - // 压缩包(MMD 模型包)走"解压 → 转换 → 入库"通路,其它扩展名走原有的直传通路 - if (extname(upload.originalname).toLowerCase() === '.zip') { - return this.createFromArchive(body, upload, name, tuple); + if (upload.size && upload.size > this.config.uploadMaxBytes) { + throw new BadRequestException( + `上传文件超过上限 ${Math.floor(this.config.uploadMaxBytes / 1024 / 1024)} MiB`, + ); } - const { format } = resolveFormatByExtension(upload.originalname, MODEL_EXTENSION_MAP, '模型文件'); + const ext = extname(upload.originalname).toLowerCase(); + const convertOptions = { + assimpBin: this.config.assimpBin, + timeoutMs: this.config.modelConvertTimeoutMs, + preferFormat: this.config.modelConvertFormat, + preferredPmx: this.config.modelConvertPmx, + vrmMeta: { name, author: tuple.author ?? '', license: tuple.license }, + }; - const stored = await this.storage.persistTempFile( - AssetKind.Model, - upload.path, - upload.originalname, - upload.mimetype, - ); + let imported: ConvertedModel; + let sourceAssetId: number | null = null; - // VRM 版本识别(GLB 容器内检查 VRM / VRMC_vrm 扩展) - let vrmVersion: string | null = null; - if (format === ModelFormat.Vrm || format === ModelFormat.Glb) { + if (ext === '.zip') { + const archive = await readFile(upload.path); + // 原件先留存(按 sha256 去重),再解压转换 + const archiveAsset = await this.storage.persistBuffer( + AssetKind.ModelArchive, + archive, + upload.originalname, + upload.mimetype || 'application/zip', + ); + sourceAssetId = archiveAsset.asset.id; try { - const inspection = await inspectVrm(this.storage.absolutePath(stored.asset)); - vrmVersion = inspection.vrmVersion; - } catch { - vrmVersion = null; + imported = await convertModelArchive(archive, convertOptions); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`模型压缩包导入失败(${upload.originalname}):${message}`); + throw new UnprocessableEntityException(`模型压缩包导入失败:${message}`); + } + } else if (ext === '.pmx' || ext === '.fbx') { + const buffer = await readFile(upload.path); + try { + imported = await convertModelFile(buffer, upload.originalname, convertOptions); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`模型转换失败(${upload.originalname}):${message}`); + throw new UnprocessableEntityException(`模型转换失败:${message}`); + } + } else { + const buffer = await readFile(upload.path); + try { + imported = importModelFile(buffer, upload.originalname); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const allowed = Object.keys(MODEL_EXTENSION_MAP).join('、'); + throw new UnprocessableEntityException( + `${message};允许直接导入:${allowed},或上传含 .pmx/.fbx 的 .zip 由服务端转换`, + ); } } - if (format === ModelFormat.Vrm && !vrmVersion) { + + const stored = await this.storage.persistBuffer( + AssetKind.Model, + imported.buffer, + `${name}.${imported.format}`, + imported.format === ModelFormat.Vrm || imported.format === ModelFormat.Glb + ? 'model/gltf-binary' + : 'application/octet-stream', + ); + + // 分析 + 缩略图:都属于"尽力而为",失败不能挡住入库 + const absolutePath = this.storage.absolutePath(stored.asset); + const stats = await this.analyzeSafely(absolutePath, stored.asset.size, name); + const thumbnailAssetId = await this.thumbnailSafely(absolutePath, name); + + let vrmVersion: string | null = null; + if (stats.vrmVersion) { + vrmVersion = stats.vrmVersion; + } else if (imported.format === ModelFormat.Vrm) { vrmVersion = 'unknown'; } - const status = this.parseStatus(body.status); const model = this.models.create({ name, - format: format as ModelFormat, + format: imported.format, vrmVersion, isPrimary: false, fileAssetId: stored.asset.id, + sourceAssetId, + thumbnailAssetId, + conversionNote: imported.note.slice(0, 500), + stats: stats as unknown as Record, sourceType: tuple.sourceType as CharacterModel['sourceType'], sourceName: tuple.sourceName, sourceUrl: tuple.sourceUrl, license: tuple.license, author: tuple.author, - status, - }); - - const saved = await this.models.save(model); - - if (parseOptionalBool(body.isPrimary)) { - return this.setPrimary(saved.id); - } - - const [dto] = await this.toDtos([saved]); - return dto; - } - - /** - * T-WEB-005 追加:上传压缩包(如 MMD 的 `模型_xxx.zip`)→ 解压选 PMX → Assimp 转换 - * → 入库。FBX 优先,导出后回读校验,失败自动回退 GLB 并记录 conversion_note。 - * - * 转换成功后默认把它设为**全局默认模型**(is_primary + published),并把身份 - * (名称/格式/产物 sha256)写进源码 `src/config/default-model.ts`; - * 传 `autoDefault=false` 可只入库不改默认。 - */ - private async createFromArchive( - body: Record, - upload: UploadedFileLike, - name: string, - tuple: NonNullable>, - ): Promise { - if (upload.size && upload.size > this.config.uploadMaxBytes) { - throw new BadRequestException( - `压缩包超过上限 ${Math.floor(this.config.uploadMaxBytes / 1024 / 1024)} MiB`, - ); - } - - const archive = await readFile(upload.path); - let converted; - try { - converted = await convertModelArchive(archive, { - assimpBin: this.config.assimpBin, - timeoutMs: this.config.modelConvertTimeoutMs, - preferFormat: this.config.modelConvertFormat, - preferredPmx: this.config.modelConvertPmx, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.logger.warn(`模型压缩包转换失败(${upload.originalname}):${message}`); - throw new UnprocessableEntityException(`模型转换失败:${message}`); - } - - // 原件留存用于溯源(按 sha256 去重) - const archiveAsset = await this.storage.persistBuffer( - AssetKind.ModelArchive, - archive, - upload.originalname, - upload.mimetype || 'application/zip', - ); - const stored = await this.storage.persistBuffer( - AssetKind.Model, - converted.buffer, - `${name}.${converted.format}`, - converted.format === ModelFormat.Glb ? 'model/gltf-binary' : 'application/octet-stream', - ); - - // 转换产物若是 GLB,顺带识别是否带 VRM 扩展 - let vrmVersion: string | null = null; - if (converted.format === ModelFormat.Glb) { - try { - vrmVersion = (await inspectVrm(this.storage.absolutePath(stored.asset))).vrmVersion; - } catch { - vrmVersion = null; - } - } - - const autoDefault = body.autoDefault === undefined ? true : parseOptionalBool(body.autoDefault); - const status = autoDefault ? PublishStatus.Published : this.parseStatus(body.status); - - const model = this.models.create({ - name, - format: converted.format, - vrmVersion, - isPrimary: false, - fileAssetId: stored.asset.id, - sourceAssetId: archiveAsset.asset.id, - conversionNote: converted.note.slice(0, 500), - sourceType: tuple.sourceType as CharacterModel['sourceType'], - sourceName: tuple.sourceName, - sourceUrl: tuple.sourceUrl, - license: tuple.license, - author: tuple.author, - status, + // 上传一律待审核:模型必须过审核才可能被客户端消费 + status: PublishStatus.Pending, }); const saved = await this.models.save(model); this.logger.log( - `模型压缩包已转换入库:#${saved.id} ${name}(${converted.note})` + - `;尝试记录:${converted.attempts.join(';')}`, + `模型已入库待审:#${saved.id} ${name}(${imported.format},${imported.direct ? '直接导入' : '转换'})` + + ` 面数=${stats.triangles} 骨骼=${stats.bones} humanoid=${stats.humanoidMapped}/${stats.humanoidTotal}` + + ` 可驱动=${stats.drivable};说明:${imported.note};尝试:${imported.attempts.join(';')}`, ); - if (autoDefault) { - await this.applyDefaultModel(saved.id, { - sourceSha256: archiveAsset.asset.sha256, - fileSha256: stored.asset.sha256, - note: converted.note, - }); - // setPrimary 走的是独立事务,重新读一次才能让返回体里的 isPrimary 正确 - const [dto] = await this.toDtos([await this.getEntity(saved.id)]); - return dto; - } - const [dto] = await this.toDtos([saved]); return dto; } - /** - * 设为默认模型并把种子写进源码。写入失败只告警:生产环境 systemd 的 - * `ProtectSystem=strict` 不允许服务进程改程序目录,通常由 - * `scripts/import-default-model.mjs` 在沙箱外完成写入。 - */ - private async applyDefaultModel( - id: number, - payload: { sourceSha256: string; fileSha256: string; note: string }, - ): Promise { - await this.setPrimary(id); - const model = await this.getEntity(id); - const result = await writeDefaultModelSeed({ - id, - name: model.name, - format: model.format, - sourceSha256: payload.sourceSha256, - fileSha256: payload.fileSha256, - convertedFrom: payload.note.split(',')[0] ?? null, - updatedAt: new Date().toISOString(), - }); - if (result.written) { - this.logger.log(`默认模型已写进源码:${result.path}`); - } else { - this.logger.warn( - `默认模型已在库中设为 is_primary,但源码写入失败(${result.reason});` + - `可在源码目录执行 node scripts/import-default-model.mjs --write-source-only 补写`, + /** 分析模型;任何异常都降级为一条 warning,不阻断上传 */ + private async analyzeSafely( + absolutePath: string, + fileSize: number, + name: string, + ): Promise { + try { + const stats = await inspectModelFile(absolutePath, fileSize); + if (stats.warnings.length > 0) { + this.logger.warn(`模型「${name}」分析提示:${stats.warnings.join(';')}`); + } + return stats; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`模型「${name}」分析失败:${message}`); + return { + container: 'unknown', + vrmVersion: null, + fileSize, + meshes: 0, + primitives: 0, + vertices: 0, + triangles: 0, + materials: 0, + textures: 0, + images: 0, + animations: 0, + morphTargets: 0, + nodes: 0, + bones: 0, + skins: 0, + humanoidMapped: 0, + humanoidTotal: 22, + missingRequiredBones: [], + drivable: false, + maxBoneDepth: 0, + boneTree: [], + warnings: [`分析失败:${message}`], + }; + } + } + + /** 生成缩略图;失败只告警(列表页会退化为格式占位图) */ + private async thumbnailSafely(absolutePath: string, name: string): Promise { + try { + const thumbnail = await renderModelThumbnail(absolutePath, { size: 512 }); + if (!thumbnail) { + this.logger.warn(`模型「${name}」无法生成缩略图(非 glTF 容器或结构不完整)`); + return null; + } + const asset = await this.storage.persistBuffer( + AssetKind.Thumbnail, + thumbnail.png, + `${name}.png`, + 'image/png', ); + return asset.asset.id; + } catch (error) { + this.logger.warn( + `模型「${name}」缩略图生成失败:${error instanceof Error ? error.message : String(error)}`, + ); + return null; } } @@ -309,16 +315,52 @@ export class ModelsService { async setPrimary(id: number): Promise { const model = await this.getEntity(id); + if (model.status !== PublishStatus.Published) { + throw new ConflictException( + `模型 #${id} 当前状态为 ${model.status},只有已发布(published)的模型才能设为默认模型`, + ); + } + await this.dataSource.transaction(async (manager) => { await manager.update(CharacterModel, { isPrimary: true }, { isPrimary: false }); await manager.update(CharacterModel, { id }, { isPrimary: true }); }); + // 把"默认模型是谁"写进源码(种子以压缩包 sha256 为主键,见 default-model.ts) + await this.writeDefaultModelSeed(model); model.isPrimary = true; const [dto] = await this.toDtos([model]); return dto; } + /** 取源压缩包(若有)或模型文件本身的 sha256 作为稳定匹配键 */ + private async writeDefaultModelSeed(model: CharacterModel): Promise { + const assets = await this.storage.findMany([model.sourceAssetId, model.fileAssetId]); + const source = model.sourceAssetId ? assets.get(model.sourceAssetId) : undefined; + const file = model.fileAssetId ? assets.get(model.fileAssetId) : undefined; + const seedSha = source?.sha256 ?? file?.sha256 ?? ''; + if (!seedSha) { + this.logger.warn(`模型 #${model.id} 缺少文件 sha256,无法写入默认模型种子`); + return; + } + const result = await writeDefaultModelSeed({ + id: model.id, + name: model.name, + format: model.format, + sourceSha256: source?.sha256 ?? file?.sha256 ?? '', + fileSha256: file?.sha256 ?? '', + convertedFrom: (model.conversionNote ?? '').split(',')[0] || null, + updatedAt: new Date().toISOString(), + }); + if (result.written) this.logger.log(`默认模型已写进源码:${result.path}`); + else { + this.logger.warn( + `默认模型已在库中设为 is_primary,但源码写入失败(${result.reason});` + + '可在源码目录执行 node scripts/import-default-model.mjs --write-source-only --model-id ' + model.id, + ); + } + } + /** * 客户端默认模型解析 —— 契约 data-models.md §4.1。 * diff --git a/web-server/src/models/vrm-writer.ts b/web-server/src/models/vrm-writer.ts new file mode 100644 index 0000000..d922a2f --- /dev/null +++ b/web-server/src/models/vrm-writer.ts @@ -0,0 +1,210 @@ +import { ModelFormat } from '../common/enums'; +import { HUMANOID_CANDIDATES, HUMANOID_REQUIRED, normalizeBoneName, boneNameVariants } from './model-inspector'; + +/** + * GLB → VRM 封装(服务端"其次转 VRM"这一步)。 + * + * 为什么要自己写:Assimp 不会写 VRM 扩展(它不认识 VRM),所以 `GLB → VRM` + * 在通用转换器里是做不到的。但 VRM 本质就是"glTF + 一个 JSON 扩展", + * 只要能把 humanoid 骨骼映射写进 `extensions.VRMC_vrm`,就能得到生态 + * (three-vrm、UniVRM、Godot 侧的本项目加载器)认得的 VRM 1.0 模型。 + * + * 本模块只改 JSON chunk,不碰二进制数据:因此**不会有任何几何/材质损失**, + * 只是在 GLB 之上补一层语义。骨骼名映射不到 humanoid 规范名时会抛错, + * 由调用方决定回退(此时应当交付 GLB)。 + */ + +const GLB_MAGIC = 0x46546c67; +const CHUNK_JSON = 0x4e4f534a; +const CHUNK_BIN = 0x004e4942; + +export interface VrmWrapMeta { + name: string; + author: string; + /** 许可说明,写进 meta.otherLicenseUrl / copyrightInformation */ + license?: string; + /** 原压缩包/来源说明 */ + sourceNote?: string; +} + +export interface VrmWrapResult { + buffer: Buffer; + /** 成功写入 humanoid 映射的骨骼数量 */ + humanoidBones: number; + /** 映射不到的必需骨骼(为空才允许产出 VRM) */ + missingRequired: string[]; +} + +interface GltfJson { + asset?: Record; + nodes?: Array<{ name?: string; children?: number[] }>; + scenes?: Array<{ nodes?: number[] }>; + scene?: number; + extensionsUsed?: string[]; + extensions?: Record; + [key: string]: unknown; +} + +function align4(value: number): number { + return value + ((4 - (value % 4)) % 4); +} + +/** 解析 GLB 的 JSON 与 BIN chunk */ +function parseGlb(buffer: Buffer): { json: GltfJson; bin: Buffer | null } { + if (buffer.length < 12 || buffer.readUInt32LE(0) !== GLB_MAGIC) { + throw new Error('不是有效的 GLB(魔数不正确)'); + } + const total = buffer.readUInt32LE(8) || buffer.length; + const end = Math.min(total, buffer.length); + let offset = 12; + let json: GltfJson | null = null; + let bin: Buffer | null = null; + while (offset + 8 <= end) { + const length = buffer.readUInt32LE(offset); + const type = buffer.readUInt32LE(offset + 4); + const start = offset + 8; + if (start + length > buffer.length) break; + const data = buffer.subarray(start, start + length); + if (type === CHUNK_JSON) json = JSON.parse(data.toString('utf8')) as GltfJson; + else if (type === CHUNK_BIN) bin = Buffer.from(data); + offset = start + align4(length); + } + if (!json) throw new Error('GLB 里没有 JSON chunk'); + return { json, bin }; +} + +function buildGlb(json: GltfJson, bin: Buffer | null): Buffer { + const jsonBuffer = Buffer.from(JSON.stringify(json), 'utf8'); + const jsonPadded = Buffer.alloc(align4(jsonBuffer.length), 0x20); + jsonBuffer.copy(jsonPadded); + + const chunks: Buffer[] = []; + const header = Buffer.alloc(12); + header.writeUInt32LE(GLB_MAGIC, 0); + header.writeUInt32LE(2, 4); + chunks.push(header); + + const jsonHeader = Buffer.alloc(8); + jsonHeader.writeUInt32LE(jsonPadded.length, 0); + jsonHeader.writeUInt32LE(CHUNK_JSON, 4); + chunks.push(jsonHeader, jsonPadded); + + if (bin && bin.length > 0) { + const binPadded = Buffer.alloc(align4(bin.length)); + bin.copy(binPadded); + const binHeader = Buffer.alloc(8); + binHeader.writeUInt32LE(binPadded.length, 0); + binHeader.writeUInt32LE(CHUNK_BIN, 4); + chunks.push(binHeader, binPadded); + } + + const out = Buffer.concat(chunks); + out.writeUInt32LE(out.length, 8); + return out; +} + +/** + * 按骨骼名把 glTF 节点映射到 VRM humanoid 规范骨。 + * 复用 `model-inspector.ts` 的候选表(与 Godot 客户端 `humanoid_map.gd` 一致), + * 保证"服务端判定的可驱动性 = 客户端实际能不能驱动"。 + */ +export function mapHumanoidBones( + nodes: ReadonlyArray<{ name?: string }>, +): { bones: Array<{ bone: string; node: number }>; missing: string[] } { + const used = new Set(); + const bones: Array<{ bone: string; node: number }> = []; + + for (const canonical of HUMANOID_REQUIRED.concat( + Object.keys(HUMANOID_CANDIDATES).filter((name) => !HUMANOID_REQUIRED.includes(name)), + )) { + const candidates = HUMANOID_CANDIDATES[canonical] ?? []; + let hit = -1; + for (let index = 0; index < nodes.length; index += 1) { + if (used.has(index)) continue; + const name = nodes[index]?.name; + if (!name) continue; + const variants = boneNameVariants(name); + if (variants.some((variant) => candidates.includes(variant))) { + hit = index; + break; + } + } + if (hit >= 0) { + used.add(hit); + bones.push({ bone: canonical, node: hit }); + } + } + + const mapped = new Set(bones.map((entry) => entry.bone)); + const missing = HUMANOID_REQUIRED.filter((name) => !mapped.has(name)); + return { bones, missing }; +} + +/** + * 把 GLB 封装成 VRM 1.0(`VRMC_vrm`)。 + * + * @throws 当必需 humanoid 骨骼映射不全时抛出(调用方应回退到 GLB 交付) + */ +export function wrapGlbAsVrm(buffer: Buffer, meta: VrmWrapMeta): VrmWrapResult { + const { json, bin } = parseGlb(buffer); + const nodes = json.nodes ?? []; + const { bones, missing } = mapHumanoidBones(nodes); + + if (missing.length > 0) { + throw new Error( + `无法生成 VRM:humanoid 必需骨骼缺失 ${missing.join('、')}(共 ${HUMANOID_REQUIRED.length} 根)。` + + '骨骼名需要能映射到 VRM humanoid 规范(如 J_Bip_C_Hips / Hips 等)。', + ); + } + + const extensions = { ...(json.extensions ?? {}) }; + extensions.VRMC_vrm = { + specVersion: '1.0', + meta: { + name: meta.name, + version: '1.0', + authors: [meta.author || 'unknown'], + copyrightInformation: meta.license ?? 'see source archive', + contactInformation: '', + references: meta.sourceNote ? [meta.sourceNote] : [], + thirdPartyLicenses: '', + // 未内置缩略图:审核通过后由服务端生成,这里留空是允许的 + thumbnailImage: -1, + licenseUrl: 'https://vrm.dev/licenses/1.0/', + avatarPermission: 'onlyAuthor', + allowExcessivelyViolentExpressions: false, + allowExcessivelySexualExpressions: false, + allowPoliticalOrReligiousExpressions: false, + allowRedistribution: false, + allowModification: false, + modificationRedistribution: false, + creditNotation: 'required', + allowCreditRedistribution: false, + }, + humanoid: { + humanBones: Object.fromEntries( + bones.map((entry) => [entry.bone, { node: entry.node }]), + ), + }, + }; + json.extensions = extensions; + + const used = new Set([...(json.extensionsUsed ?? []), 'VRMC_vrm']); + json.extensionsUsed = [...used]; + + return { buffer: buildGlb(json, bin), humanoidBones: bones.length, missingRequired: missing }; +} + +/** 产物是否是 VRM 容器(检查扩展名,供调用方判定交付格式) */ +export function detectGlbKind(buffer: Buffer): ModelFormat { + try { + const { json } = parseGlb(buffer); + const ext = json.extensions ?? {}; + if (ext['VRMC_vrm'] || ext.VRM) return ModelFormat.Vrm; + } catch { + /* 解析失败按普通 glb 处理 */ + } + return ModelFormat.Glb; +} + +export { normalizeBoneName }; diff --git a/web-server/src/reviews/review.dto.ts b/web-server/src/reviews/review.dto.ts index 5d907d3..57eec1c 100644 --- a/web-server/src/reviews/review.dto.ts +++ b/web-server/src/reviews/review.dto.ts @@ -1,4 +1,4 @@ -import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; import { PublishStatus, TargetType } from '../common/enums'; import { PageQueryDto } from '../common/pagination'; @@ -12,6 +12,14 @@ export class ReviewActionDto { @IsString() @MaxLength(2000) comment?: string; + + /** + * 仅对模型生效:通过审核的同时把它设为全局默认模型 + * (并把它写进源码 `src/config/default-model.ts`)。 + */ + @IsOptional() + @IsBoolean() + setPrimary?: boolean; } export class QueryReviewDto extends PageQueryDto { diff --git a/web-server/src/reviews/reviews.module.ts b/web-server/src/reviews/reviews.module.ts index dcd136f..257ee4b 100644 --- a/web-server/src/reviews/reviews.module.ts +++ b/web-server/src/reviews/reviews.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ModelsModule } from '../models/models.module'; import { CharacterModel } from '../entities/model.entity'; import { Motion } from '../entities/motion.entity'; import { ReviewRecord } from '../entities/review-record.entity'; @@ -8,7 +9,7 @@ import { ReviewsController } from './reviews.controller'; import { ReviewsService } from './reviews.service'; @Module({ - imports: [TypeOrmModule.forFeature([ReviewRecord, Song, Motion, CharacterModel])], + imports: [TypeOrmModule.forFeature([ReviewRecord, Song, Motion, CharacterModel]), ModelsModule], controllers: [ReviewsController], providers: [ReviewsService], exports: [ReviewsService], diff --git a/web-server/src/reviews/reviews.service.ts b/web-server/src/reviews/reviews.service.ts index f7c8dc5..821aa41 100644 --- a/web-server/src/reviews/reviews.service.ts +++ b/web-server/src/reviews/reviews.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { ModelsService } from '../models/models.service'; import { Repository } from 'typeorm'; import { ACTION_TARGET_STATUS, @@ -67,6 +68,7 @@ export class ReviewsService { @InjectRepository(Song) private readonly songs: Repository, @InjectRepository(Motion) private readonly motions: Repository, @InjectRepository(CharacterModel) private readonly models: Repository, + private readonly modelsService: ModelsService, ) {} async listRecords(query: QueryReviewDto): Promise> { @@ -136,7 +138,14 @@ export class ReviewsService { if (!model) throw new NotFoundException(`模型 #${id} 不存在`); entity = model; name = model.name; - save = () => this.models.save(model); + save = async () => { + const saved = await this.models.save(model); + // 审核通过时可选顺带设为默认模型(写库 + 写源码种子) + if (dto.setPrimary && action === ReviewAction.Approve) { + await this.modelsService.setPrimary(id); + } + return saved; + }; break; } default: diff --git a/web-server/test/admin-bone-filter.spec.ts b/web-server/test/admin-bone-filter.spec.ts new file mode 100644 index 0000000..4f87753 --- /dev/null +++ b/web-server/test/admin-bone-filter.spec.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { filterBoneTree } from '../admin/src/lib/bone-tree'; +import type { BoneNode } from '../admin/src/api/types'; + +/** + * 只测 ModelReviewPanel 使用的纯函数 filterBoneTree。 + * 骨骼树是「扁平数组 + depth 缩进」渲染的,过滤时若丢掉祖先,树就会断裂, + * 因此这里重点覆盖「命中 + 祖先链」的保留逻辑。 + */ + +function bone(input: Partial & Pick): BoneNode { + return { + index: 0, + parentName: null, + childCount: 0, + inSkin: false, + humanoid: null, + ...input, + }; +} + +/** + * 一棵典型的人形骨骼树(先序遍历,父在前): + * - Hips → Spine → Chest → {LeftUpperArm→LeftLowerArm→LeftHand→LeftHand_IK, RightUpperArm, J_Bip_C_上半身→辅助骨骼_末端} + * - LeftHand_IK / J_Bip_C_上半身 分支 / Root_End 都是不参与蒙皮的辅助节点。 + */ +const TREE: BoneNode[] = [ + bone({ index: 0, name: 'Hips', depth: 0, parentName: null, childCount: 3, inSkin: true, humanoid: 'hips' }), + bone({ index: 1, name: 'Spine', depth: 1, parentName: 'Hips', childCount: 1, inSkin: true, humanoid: 'spine' }), + bone({ index: 2, name: 'Chest', depth: 2, parentName: 'Spine', childCount: 3, inSkin: true, humanoid: 'chest' }), + bone({ index: 3, name: 'LeftUpperArm', depth: 3, parentName: 'Chest', childCount: 1, inSkin: true, humanoid: 'leftUpperArm' }), + bone({ index: 4, name: 'LeftLowerArm', depth: 4, parentName: 'LeftUpperArm', childCount: 1, inSkin: true, humanoid: 'leftLowerArm' }), + bone({ index: 5, name: 'LeftHand', depth: 5, parentName: 'LeftLowerArm', childCount: 1, inSkin: true, humanoid: 'leftHand' }), + bone({ index: 6, name: 'LeftHand_IK', depth: 6, parentName: 'LeftHand', childCount: 0, inSkin: false }), + bone({ index: 7, name: 'RightUpperArm', depth: 3, parentName: 'Chest', childCount: 0, inSkin: true, humanoid: 'rightUpperArm' }), + bone({ index: 8, name: 'J_Bip_C_上半身', depth: 3, parentName: 'Chest', childCount: 1, inSkin: false }), + bone({ index: 9, name: '辅助骨骼_末端', depth: 4, parentName: 'J_Bip_C_上半身', childCount: 0, inSkin: false }), + bone({ index: 10, name: 'Root_End', depth: 1, parentName: 'Hips', childCount: 0, inSkin: false }), +]; + +function names(nodes: readonly BoneNode[]): string[] { + return nodes.map((node) => node.name); +} + +describe('filterBoneTree:搜索保留祖先链', () => { + it('命中叶子节点时保留完整祖先链(含不参与蒙皮的祖先)', () => { + const result = filterBoneTree(TREE, '辅助骨骼_末端'); + expect(names(result)).toEqual(['Hips', 'Spine', 'Chest', 'J_Bip_C_上半身', '辅助骨骼_末端']); + // 祖先链上的节点一个都不能少,也不能混入无关分支 + expect(names(result)).not.toContain('LeftHand'); + expect(names(result)).not.toContain('Root_End'); + }); + + it('多个命中时取并集:各自的祖先只出现一次,且保持父在前', () => { + const result = filterBoneTree(TREE, 'Arm'); + expect(names(result)).toEqual([ + 'Hips', + 'Spine', + 'Chest', + 'LeftUpperArm', + 'LeftLowerArm', + 'RightUpperArm', + ]); + expect(names(result)).not.toContain('LeftHand'); + expect(new Set(names(result)).size).toBe(result.length); + }); + + it('大小写不敏感,且支持中英混排子串', () => { + expect(names(filterBoneTree(TREE, 'leftlowerarm'))).toEqual([ + 'Hips', + 'Spine', + 'Chest', + 'LeftUpperArm', + 'LeftLowerArm', + ]); + expect(names(filterBoneTree(TREE, 'BIP_C_上半身'))).toEqual([ + 'Hips', + 'Spine', + 'Chest', + 'J_Bip_C_上半身', + ]); + expect(names(filterBoneTree(TREE, ' 左 '))).toEqual([]); + }); +}); + +describe('filterBoneTree:仅显示蒙皮骨骼', () => { + it('空关键词 + onlySkin:只留蒙皮骨骼(其祖先同为蒙皮),辅助节点被排除', () => { + const result = filterBoneTree(TREE, '', true); + expect(names(result)).toEqual([ + 'Hips', + 'Spine', + 'Chest', + 'LeftUpperArm', + 'LeftLowerArm', + 'LeftHand', + 'RightUpperArm', + ]); + expect(names(result)).not.toContain('LeftHand_IK'); + expect(names(result)).not.toContain('J_Bip_C_上半身'); + expect(names(result)).not.toContain('Root_End'); + }); + + it('onlySkin 只筛选「命中节点」,非蒙皮节点命中时不保留', () => { + expect(names(filterBoneTree(TREE, 'IK', true))).toEqual([]); + // 同一个关键字在关闭开关时能命中 + expect(names(filterBoneTree(TREE, 'IK', false))).toEqual([ + 'Hips', + 'Spine', + 'Chest', + 'LeftUpperArm', + 'LeftLowerArm', + 'LeftHand', + 'LeftHand_IK', + ]); + }); +}); + +describe('filterBoneTree:空关键词与边界', () => { + it('空关键词返回全量(顺序不变,且是新数组)', () => { + const result = filterBoneTree(TREE, ''); + expect(result).toEqual(TREE); + expect(result).toHaveLength(TREE.length); + expect(result).not.toBe(TREE); + expect(filterBoneTree(TREE, ' ')).toEqual(TREE); + }); + + it('无命中返回空数组', () => { + expect(filterBoneTree(TREE, '不存在的骨骼')).toEqual([]); + expect(filterBoneTree([], 'anything')).toEqual([]); + }); + + it('depth 异常时退回 parentName 查找,祖先链依然完整', () => { + const flat: BoneNode[] = [ + bone({ index: 0, name: 'root', depth: 0, parentName: null }), + bone({ index: 1, name: 'child', depth: 0, parentName: 'root' }), + bone({ index: 2, name: 'leaf', depth: 0, parentName: 'child' }), + ]; + expect(names(filterBoneTree(flat, 'leaf'))).toEqual(['root', 'child', 'leaf']); + }); +}); diff --git a/web-server/test/model-archive.spec.ts b/web-server/test/model-archive.spec.ts index 79b9ec5..279157b 100644 --- a/web-server/test/model-archive.spec.ts +++ b/web-server/test/model-archive.spec.ts @@ -95,24 +95,17 @@ case "$cmd" in file="\${2:-}" if [ "\${FAKE_INFO_FAIL:-}" = "fbx" ]; then case "$file" in - *.fbx) - echo "ERROR: Failed to load file: FBX-DOM no Objects dictionary found" >&2 - exit 1 - ;; + *.fbx) echo "ERROR: Failed to load file: FBX-DOM no Objects dictionary found" >&2; exit 1 ;; esac fi + if [ "\${FAKE_INFO_FAIL:-}" = "all" ]; then + echo "ERROR: Failed to load file" >&2; exit 1 + fi exit 0 ;; export) target="\${3:-}" case "$target" in - *.fbx) - if [ "\${FAKE_FBX:-ok}" = "fail" ]; then - echo "Failed to write file" >&2 - exit 11 - fi - printf 'FAKE-FBX-CONTENT-0123456789' > "$target" - ;; *.glb) if [ "\${FAKE_GLB:-ok}" = "fail" ]; then echo "Failed to write file" >&2 @@ -121,6 +114,16 @@ case "$cmd" in printf '%s' "\${FAKE_GLB_B64:-}" | base64 -d > "$target" 2>/dev/null if [ ! -s "$target" ]; then printf 'FAKE-GLB-CONTENT-0123456789' > "$target"; fi ;; + *.gltf) + if [ "\${FAKE_GLTF:-fail}" = "fail" ]; then + echo "Failed to write file" >&2 + exit 11 + fi + # 写出 glTF JSON 与配套 .bin(打包器要能把它们合成 GLB) + bin="\${target%.gltf}.bin" + printf '\x01\x02\x03\x04\x05\x06\x07\x08' > "$bin" + printf '%s' "\${FAKE_GLTF_JSON:-}" > "$target" + ;; *) exit 1 ;; esac exit 0 @@ -159,6 +162,38 @@ function buildGlbWithExternalImages(): Buffer { return out; } +/** 构造一个节点名能映射到 16 根 humanoid 必需骨骼的最小 GLB */ +function buildHumanoidGlb(): Buffer { + const names = [ + 'Hips', 'Spine', 'Neck', 'Head', + 'LeftUpperArm', 'LeftLowerArm', 'LeftHand', + 'RightUpperArm', 'RightLowerArm', 'RightHand', + 'LeftUpperLeg', 'LeftLowerLeg', 'LeftFoot', + 'RightUpperLeg', 'RightLowerLeg', 'RightFoot', + ]; + const json = { + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: names.map((name, index) => ({ + name, + ...(index + 1 < names.length ? { children: [index + 1] } : {}), + })), + }; + const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8'); + const jsonPadded = Buffer.alloc(jsonBuf.length + ((4 - (jsonBuf.length % 4)) % 4), 0x20); + jsonBuf.copy(jsonPadded); + const total = 12 + 8 + jsonPadded.length; + const out = Buffer.alloc(total); + out.writeUInt32LE(0x46546c67, 0); + out.writeUInt32LE(2, 4); + out.writeUInt32LE(total, 8); + out.writeUInt32LE(jsonPadded.length, 12); + out.writeUInt32LE(0x4e4f534a, 16); + jsonPadded.copy(out, 20); + return out; +} + beforeAll(() => { workRoot = mkdtempSync(join(tmpdir(), 'opendance-model-test-')); fakeAssimp = join(workRoot, 'fake-assimp'); @@ -284,22 +319,25 @@ describe('GLB 贴图内嵌', () => { }); }); -describe('压缩包 → 模型转换(用假 Assimp 做对照)', () => { - const archive = () => +describe('压缩包导入与转换(用假 Assimp 做对照)', () => { + /** 一个含 PMX 的最小模型包 */ + const pmxArchive = () => buildZip([ { path: 'Hatsune Miku.pmx', content: Buffer.from('PMX-MODEL-BYTES') }, { path: 'textures/a.png', content: Buffer.from([1, 2, 3, 4, 5]) }, { path: 'textures/b.png', content: Buffer.from([6, 7, 8]) }, ]); - // 让假 Assimp "导出"的 GLB 是一个真实可解析的 GLB(含两张外部贴图), - // 这样贴图内嵌路径也能被覆盖到 const fakeGlbB64 = buildGlbWithExternalImages().toString('base64'); - const runConvert = (env: Record, preferFormat: 'fbx' | 'glb' = 'fbx') => { + const runConvert = ( + archive: Buffer, + env: Record = {}, + preferFormat: 'glb' | 'vrm' = 'glb', + ) => { const previous = { ...process.env }; Object.assign(process.env, { FAKE_GLB_B64: fakeGlbB64, ...env }); - return convertModelArchive(archive(), { + return convertModelArchive(archive, { assimpBin: fakeAssimp, preferFormat, preferredPmx: 'Hatsune Miku.pmx', @@ -309,50 +347,88 @@ describe('压缩包 → 模型转换(用假 Assimp 做对照)', () => { }); }; - it('FBX 导出成功且可回读 → 用 FBX', async () => { - const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok' }); - expect(result.format).toBe(ModelFormat.Fbx); + it('GLB 直出成功 → 交付 GLB 并内嵌贴图', async () => { + const result = await runConvert(pmxArchive(), { FAKE_GLB: 'ok' }); + expect(result.format).toBe(ModelFormat.Glb); + expect(result.direct).toBe(false); expect(result.sourceModelPath).toBe('Hatsune Miku.pmx'); - expect(result.attempts).toHaveLength(1); + expect(result.note).toContain('已内嵌 2 张贴图'); expect(result.attempts[0]).toContain('回读校验通过'); }); - it('FBX 导出退出码非 0 → 自动回退 GLB 并记录原因', async () => { - const result = await runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'ok' }); + it('GLB 直出失败 → 退回 glTF 导出再自行打包成 GLB', async () => { + const gltfJson = JSON.stringify({ + asset: { version: '2.0' }, + buffers: [{ byteLength: 8, uri: 'converted.bin' }], + bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 8 }], + images: [{ uri: 'textures/a.png' }], + meshes: [{ primitives: [{ attributes: {} }] }], + }); + const result = await runConvert(pmxArchive(), { + FAKE_GLB: 'fail', + FAKE_GLTF: 'ok', + FAKE_GLTF_JSON: gltfJson, + }); expect(result.format).toBe(ModelFormat.Glb); - expect(result.attempts).toHaveLength(2); - expect(result.attempts[0]).toContain('FBX'); - expect(result.attempts[0]).toContain('导出失败'); - expect(result.attempts[1]).toContain('GLB'); - expect(result.note).toContain('Hatsune Miku.pmx → GLB'); - // 贴图从压缩包里被内嵌进 GLB - expect(result.note).toContain('已内嵌 2 张贴图'); + expect(result.attempts.some((line) => line.includes('GLB 直出:导出失败'))).toBe(true); + expect(result.attempts.some((line) => line.includes('GLB 打包:由 glTF 打包成功'))).toBe(true); + // 打包产物是合法 GLB(长度自洽) + expect(result.buffer.readUInt32LE(0)).toBe(0x46546c67); + expect(result.buffer.readUInt32LE(8)).toBe(result.buffer.length); }); - it('FBX 退出码为 0 但产物损坏(回读失败)→ 同样回退 GLB', async () => { - // 对照 Assimp 的真实行为:命令"成功"写出了文件,但文件读不回来 - const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok', FAKE_INFO_FAIL: 'fbx' }); - expect(result.attempts[0]).toContain('无法回读'); - expect(result.format).toBe(ModelFormat.Glb); - expect(result.attempts).toHaveLength(2); - }); - - it('两种格式都失败 → 抛出可读错误并提示安装 Assimp', async () => { - await expect(runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'fail' })).rejects.toThrow( - /模型转换失败[\s\S]*Assimp/, - ); - }); - - it('压缩包里没有 PMX → 明确报错', async () => { - const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]); + it('两种路径都失败 → 抛出可读错误并提示安装 Assimp', async () => { await expect( - convertModelArchive(zip, { assimpBin: fakeAssimp, timeoutMs: 10_000 }), - ).rejects.toThrow(/没有找到 \.pmx/); + runConvert(pmxArchive(), { FAKE_GLB: 'fail', FAKE_GLTF: 'fail' }), + ).rejects.toThrow(/模型转换失败[\s\S]*Assimp/); + }); + + it('preferFormat=vrm 且 humanoid 齐全 → 交付 VRM;不全 → 回退 GLB 并说明', async () => { + // 含 16 根 humanoid 骨骼的 GLB + const humanoidB64 = buildHumanoidGlb().toString('base64'); + const wrapped = await runConvert(pmxArchive(), { FAKE_GLB_B64: humanoidB64 }, 'vrm'); + expect(wrapped.format).toBe(ModelFormat.Vrm); + expect(wrapped.note).toContain('VRM'); + + // 骨骼名对不上的 GLB → 回退 GLB + const fallback = await runConvert(pmxArchive(), { FAKE_GLB_B64: fakeGlbB64 }, 'vrm'); + expect(fallback.format).toBe(ModelFormat.Glb); + expect(fallback.attempts.some((line) => line.includes('VRM 封装:失败'))).toBe(true); + }); + + it('包内是 .vrm / .glb 时直接导入,不做重编码', async () => { + const vrmArchive = buildZip([ + { path: 'sub/model.vrm', content: buildHumanoidGlb() }, + { path: 'model.pmx', content: Buffer.from('PMX-BYTES') }, + ]); + const result = await runConvert(vrmArchive, { FAKE_GLB: 'fail', FAKE_GLTF: 'fail' }); + // .vrm 优先级高于 .pmx:即使用 assimp 会失败也应当成功(因为根本不调用它) + expect(result.direct).toBe(true); + expect(result.format).toBe(ModelFormat.Vrm); + expect(result.sourceModelPath).toBe('sub/model.vrm'); + expect(result.note).toContain('直接导入'); + }); + + it('包内只有 .glb 时直接导入为 GLB', async () => { + const glbArchive = buildZip([{ path: 'a.glb', content: buildGlbWithExternalImages() }]); + const result = await runConvert(glbArchive); + expect(result.direct).toBe(true); + expect(result.format).toBe(ModelFormat.Glb); + }); + + it('压缩包里没有可用模型 → 明确报错', async () => { + const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]); + await expect(convertModelArchive(zip, { assimpBin: fakeAssimp })).rejects.toThrow( + /没有找到可用的模型文件/, + ); }); it('assimp 不存在时给出可读错误', async () => { await expect( - convertModelArchive(archive(), { assimpBin: join(workRoot, 'no-such-assimp'), timeoutMs: 5_000 }), + convertModelArchive(pmxArchive(), { + assimpBin: join(workRoot, 'no-such-assimp'), + timeoutMs: 5_000, + }), ).rejects.toThrow(/模型转换失败/); }); }); diff --git a/web-server/test/model-inspector.spec.ts b/web-server/test/model-inspector.spec.ts new file mode 100644 index 0000000..90b8ec7 --- /dev/null +++ b/web-server/test/model-inspector.spec.ts @@ -0,0 +1,505 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { + HUMANOID_BONES, + HUMANOID_CANDIDATES, + HUMANOID_REQUIRED, + STRIPPABLE_PREFIXES, + boneNameVariants, + inspectModelFile, + normalizeBoneName, +} from '../src/models/model-inspector'; +import type { ModelStats } from '../src/models/model-inspector'; + +/** + * 模型检查器测试。 + * + * 全部夹具都在代码里构造(GLB 二进制 / glTF JSON),不依赖外部文件、不联网。 + * 另有一条「与客户端一致」用例直接读 `opendance-windows/src/model/humanoid_map.gd` + * 的源码做逐键比对 —— 客户端改了名字映射,这里必须立刻失败。 + */ + +const dir = mkdtempSync(join(tmpdir(), 'opendance-inspector-')); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function write(name: string, content: Buffer): string { + const path = join(dir, name); + writeFileSync(path, content); + return path; +} + +/** 构造合法 GLB:12 字节头 + JSON chunk(+ 可选 BIN chunk),chunk 按 4 字节对齐 */ +function buildGlb(json: unknown, bin?: Buffer): Buffer { + const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8'); + const jsonChunk = Buffer.concat([jsonBuf, Buffer.alloc((4 - (jsonBuf.length % 4)) % 4, 0x20)]); + + const chunks: Buffer[] = []; + const header = Buffer.alloc(12); + header.write('glTF', 0, 'ascii'); + header.writeUInt32LE(2, 4); + chunks.push(header); + + const jsonHeader = Buffer.alloc(8); + jsonHeader.writeUInt32LE(jsonChunk.length, 0); + jsonHeader.writeUInt32LE(0x4e4f534a, 4); // 'JSON' + chunks.push(jsonHeader, jsonChunk); + + if (bin && bin.length > 0) { + const binChunk = Buffer.concat([bin, Buffer.alloc((4 - (bin.length % 4)) % 4)]); + const binHeader = Buffer.alloc(8); + binHeader.writeUInt32LE(binChunk.length, 0); + binHeader.writeUInt32LE(0x004e4942, 4); // 'BIN\0' + chunks.push(binHeader, binChunk); + } + + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const out = Buffer.concat(chunks, total); + out.writeUInt32LE(total, 8); + return out; +} + +/** 所有统计数字必须是有限整数(不允许 NaN / Infinity / 小数) */ +function expectIntegerStats(stats: ModelStats): void { + const numericKeys: Array = [ + 'fileSize', + 'meshes', + 'primitives', + 'vertices', + 'triangles', + 'materials', + 'textures', + 'images', + 'animations', + 'morphTargets', + 'nodes', + 'bones', + 'skins', + 'humanoidMapped', + 'humanoidTotal', + 'maxBoneDepth', + ]; + for (const key of numericKeys) { + const value = stats[key]; + expect(Number.isInteger(value), `${String(key)} 必须是有限整数,实际为 ${String(value)}`).toBe(true); + } + for (const bone of stats.boneTree) { + expect(Number.isInteger(bone.index)).toBe(true); + expect(Number.isInteger(bone.depth)).toBe(true); + expect(Number.isInteger(bone.childCount)).toBe(true); + } +} + +/* ------------------------------------------------------- 1. GLB 基础统计 + 骨骼树 */ + +/** VRoid 风格的最小夹具:1 mesh(POSITION=3 / indices=3)、1 material、1 image、1 skin */ +function basicGlb(): Buffer { + return buildGlb( + { + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [ + { name: 'J_Bip_C_Hips', children: [1] }, + { name: 'J_Bip_L_UpperArm', children: [2] }, + { name: 'J_Bip_L_LowerArm' }, + ], + skins: [{ joints: [0, 1, 2] }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }], + accessors: [{ count: 3 }, { count: 3 }], + materials: [{}], + images: [{}], + }, + // 顺带覆盖 BIN chunk:解析器应跳过它而不受影响 + Buffer.from([1, 2, 3, 4, 5, 6]), + ); +} + +describe('GLB 基础统计与骨骼树', () => { + it('顶点/面数/材质/skin 与 depth、parentName、childCount 正确', async () => { + const glb = basicGlb(); + const stats = await inspectModelFile(write('basic.glb', glb)); + + expect(stats.container).toBe('glb'); + expect(stats.vrmVersion).toBeNull(); + expect(stats.fileSize).toBe(glb.length); + expect(stats.vertices).toBe(3); + expect(stats.triangles).toBe(1); + expect(stats.materials).toBe(1); + expect(stats.images).toBe(1); + expect(stats.textures).toBe(0); + expect(stats.meshes).toBe(1); + expect(stats.primitives).toBe(1); + expect(stats.skins).toBe(1); + expect(stats.nodes).toBe(3); + expect(stats.bones).toBe(3); + expect(stats.warnings).toEqual([]); + + expect(stats.boneTree).toHaveLength(3); + expect( + stats.boneTree.map((bone) => ({ + name: bone.name, + depth: bone.depth, + parentName: bone.parentName, + childCount: bone.childCount, + inSkin: bone.inSkin, + })), + ).toEqual([ + { name: 'J_Bip_C_Hips', depth: 0, parentName: null, childCount: 1, inSkin: true }, + { name: 'J_Bip_L_UpperArm', depth: 1, parentName: 'J_Bip_C_Hips', childCount: 1, inSkin: true }, + { name: 'J_Bip_L_LowerArm', depth: 2, parentName: 'J_Bip_L_UpperArm', childCount: 0, inSkin: true }, + ]); + expect(stats.maxBoneDepth).toBe(2); + + // 三根节点都应按名字映射上 humanoid 规范骨 + expect(stats.boneTree.map((bone) => bone.humanoid)).toEqual(['hips', 'leftUpperArm', 'leftLowerArm']); + expect(stats.humanoidMapped).toBeGreaterThanOrEqual(3); + expect(stats.humanoidTotal).toBe(22); + expectIntegerStats(stats); + }); + + it('传入 fileSize 时优先采用传入值', async () => { + const stats = await inspectModelFile(write('sized.glb', basicGlb()), 123456); + expect(stats.fileSize).toBe(123456); + }); + + it('没有 scenes 时以「无父节点」为根,多根各自 depth=0', async () => { + const doc = { + asset: { version: '2.0' }, + nodes: [ + { name: 'Hips', children: [1] }, + { name: 'Spine', children: [2] }, + { name: 'Chest' }, + { name: 'Prop' }, + ], + }; + const stats = await inspectModelFile(write('multi-root.gltf', Buffer.from(JSON.stringify(doc)))); + + expect(stats.container).toBe('gltf'); + expect(stats.boneTree.map((bone) => [bone.name, bone.depth])).toEqual([ + ['Hips', 0], + ['Spine', 1], + ['Chest', 2], + ['Prop', 0], + ]); + expect(stats.maxBoneDepth).toBe(2); + }); + + it('同名冲突取先出现的节点(父在前顺序)', async () => { + const doc = { + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ name: 'Hips', children: [1] }, { name: 'Spine', children: [2] }, { name: 'Hips' }], + }; + const stats = await inspectModelFile(write('duplicate.gltf', Buffer.from(JSON.stringify(doc)))); + const hips = stats.boneTree.find((bone) => bone.humanoid === 'hips'); + expect(hips?.index).toBe(0); + expect(stats.boneTree[2].humanoid).toBeNull(); + }); +}); + +/* ------------------------------------------------------------- 2. VRM 识别 */ + +describe('VRM 识别', () => { + it('VRMC_vrm 扩展 → container=vrm / vrmVersion=1.0', async () => { + const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRMC_vrm: { specVersion: '1.0' } } }); + const stats = await inspectModelFile(write('vrm1.vrm', glb)); + expect(stats.container).toBe('vrm'); + expect(stats.vrmVersion).toBe('1.0'); + }); + + it('VRM 0.x 扩展(无 specVersion)→ vrmVersion=0.x', async () => { + const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRM: { meta: { title: 't' } } } }); + const stats = await inspectModelFile(write('vrm0.vrm', glb)); + expect(stats.container).toBe('vrm'); + expect(stats.vrmVersion).toBe('0.x'); + }); + + it('缺 meta 的 VRM 会给出提示', async () => { + const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRMC_vrm: { specVersion: '1.0' } } }); + const stats = await inspectModelFile(write('vrm1-nometa.vrm', glb)); + expect(stats.warnings.join(';')).toContain('meta'); + }); +}); + +/* --------------------------------------------- 3. 16 根必需 humanoid 骨 */ + +/** 用 VRoid 命名覆盖驱动客户端所需的 16 根 */ +const REQUIRED_NODE_NAMES = [ + 'J_Bip_C_Hips', + 'J_Bip_C_Spine', + 'J_Bip_C_Neck', + 'J_Bip_C_Head', + 'J_Bip_L_UpperArm', + 'J_Bip_L_LowerArm', + 'J_Bip_L_Hand', + 'J_Bip_R_UpperArm', + 'J_Bip_R_LowerArm', + 'J_Bip_R_Hand', + 'J_Bip_L_UpperLeg', + 'J_Bip_L_LowerLeg', + 'J_Bip_L_Foot', + 'J_Bip_R_UpperLeg', + 'J_Bip_R_LowerLeg', + 'J_Bip_R_Foot', +]; + +/** 串成一条链:所有骨骼都进 skin.joints,父子关系保证 depth 递增 */ +function buildHumanoidDocument(names: string[]): unknown { + return { + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: names.map((name, index) => (index + 1 < names.length ? { name, children: [index + 1] } : { name })), + skins: [{ joints: names.map((_, index) => index) }], + }; +} + +describe('必需 16 根 humanoid 骨', () => { + it('16 根齐全 → drivable=true、missingRequiredBones=[]', async () => { + const doc = buildHumanoidDocument(REQUIRED_NODE_NAMES); + const path = write('humanoid-complete.gltf', Buffer.from(JSON.stringify(doc))); + const stats = await inspectModelFile(path); + + expect(stats.container).toBe('gltf'); + expect(stats.nodes).toBe(16); + expect(stats.bones).toBe(16); + expect(stats.humanoidMapped).toBe(16); + expect(stats.missingRequiredBones).toEqual([]); + expect(stats.drivable).toBe(true); + expect(stats.boneTree.every((bone) => bone.humanoid !== null)).toBe(true); + expect(stats.maxBoneDepth).toBe(15); + expectIntegerStats(stats); + }); + + it('缺 leftFoot → drivable=false、missingRequiredBones 只含 leftFoot', async () => { + const names = REQUIRED_NODE_NAMES.filter((name) => name !== 'J_Bip_L_Foot'); + const doc = buildHumanoidDocument(names); + const path = write('humanoid-no-left-foot.gltf', Buffer.from(JSON.stringify(doc))); + const stats = await inspectModelFile(path); + + expect(stats.drivable).toBe(false); + expect(stats.missingRequiredBones).toEqual(['leftFoot']); + expect(stats.humanoidMapped).toBe(15); + }); +}); + +/* --------------------------------------------------- 4. 非 glTF 容器降级 */ + +describe('非 glTF 容器', () => { + it('假 fbx 字节 → container=unknown、有 warnings、不抛错', async () => { + const fbx = Buffer.concat([ + Buffer.from('Kaydara FBX Binary \x00', 'latin1'), + Buffer.from([0x1a, 0x00, 0x00, 0x00, 0x00, 0x00]), + ]); + const stats = await inspectModelFile(write('fake.fbx', fbx)); + + expect(stats.container).toBe('unknown'); + expect(stats.warnings.length).toBeGreaterThan(0); + expect(stats.humanoidMapped).toBe(0); + expect(stats.drivable).toBe(false); + expect(stats.missingRequiredBones).toEqual([...HUMANOID_REQUIRED]); + expectIntegerStats(stats); + }); + + it('假 pmx 字节 → container=unknown、有 warnings', async () => { + const pmx = Buffer.concat([Buffer.from('PMX ', 'ascii'), Buffer.from([0x00, 0x00, 0x00, 0x50])]); + const stats = await inspectModelFile(write('fake.pmx', pmx)); + + expect(stats.container).toBe('unknown'); + expect(stats.warnings.length).toBeGreaterThan(0); + }); + + it('合法 JSON 但不是 glTF → container=unknown', async () => { + const path = write('package.json', Buffer.from(JSON.stringify({ name: 'x', version: '1.0.0' }))); + const stats = await inspectModelFile(path); + expect(stats.container).toBe('unknown'); + expect(stats.warnings.join(';')).toContain('glTF'); + }); +}); + +/* ------------------------------------------------------ 5. 截断与健壮性 */ + +describe('截断与健壮性', () => { + it('截断一半的 GLB → 不抛错、container=unknown、warnings 非空', async () => { + const full = basicGlb(); + const truncated = full.subarray(0, Math.floor(full.length / 2)); + const stats = await inspectModelFile(write('truncated.glb', Buffer.from(truncated))); + + expect(stats.container).toBe('unknown'); + expect(stats.warnings.length).toBeGreaterThan(0); + expectIntegerStats(stats); + }); + + it('不是 GLB 且 JSON 截断 → 不抛错', async () => { + const path = write('broken.gltf', Buffer.from('{"asset":{"version":"2.0"')); + const stats = await inspectModelFile(path); + expect(stats.container).toBe('unknown'); + expect(stats.warnings.length).toBeGreaterThan(0); + }); + + it('文件不存在 → 不抛错,记 warning', async () => { + const stats = await inspectModelFile(join(dir, 'no-such-model.glb')); + expect(stats.container).toBe('unknown'); + expect(stats.warnings[0]).toContain('读取文件失败'); + expect(stats.fileSize).toBe(0); + expect(stats.bones).toBe(0); + }); + + it('文件不存在但传了 fileSize → 仍返回该大小', async () => { + const stats = await inspectModelFile(join(dir, 'no-such-model-2.glb'), 2048); + expect(stats.fileSize).toBe(2048); + expect(stats.warnings.length).toBeGreaterThan(0); + }); + + it('缺 accessor / 空文档也不产生 NaN', async () => { + const doc = { + asset: { version: '2.0' }, + meshes: [{ primitives: [{ attributes: { POSITION: 99 }, indices: 42 }, {}] }], + accessors: [{ count: 'not-a-number' }], + }; + const stats = await inspectModelFile(write('bad-accessor.gltf', Buffer.from(JSON.stringify(doc)))); + expect(stats.vertices).toBe(0); + expect(stats.triangles).toBe(0); + expect(stats.primitives).toBe(2); + expect(stats.warnings.length).toBeGreaterThan(0); + expectIntegerStats(stats); + }); + + it('GLB magic 正确但版本不是 2 → 降级 unknown', async () => { + const glb = basicGlb(); + glb.writeUInt32LE(1, 4); + const stats = await inspectModelFile(write('glb-v1.glb', glb)); + expect(stats.container).toBe('unknown'); + expect(stats.warnings.join(';')).toContain('版本'); + }); +}); + +/* -------------------------------------------------------- 7. morphTargets */ + +describe('morphTargets 统计', () => { + it('所有 primitive 的 targets 数量之和', async () => { + const glb = buildGlb({ + asset: { version: '2.0' }, + meshes: [ + { + primitives: [ + { attributes: { POSITION: 0 }, targets: [{ POSITION: 1 }, { POSITION: 2 }] }, + { attributes: { POSITION: 0 } }, + ], + }, + ], + accessors: [{ count: 6 }, { count: 6 }, { count: 6 }], + }); + const stats = await inspectModelFile(write('morph.glb', glb)); + + expect(stats.morphTargets).toBe(2); + expect(stats.primitives).toBe(2); + expect(stats.vertices).toBe(12); + expect(stats.triangles).toBe(4); + expectIntegerStats(stats); + }); +}); + +/* ------------------------------- 6. 与客户端 humanoid_map.gd 的一致性 */ + +/** gd 文件在客户端仓库里;按「从 web-server 运行」为主,同时兼容从仓库根运行 */ +function findHumanoidMap(): string { + const candidates = [ + join(__dirname, '..', '..', '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'), + resolve(process.cwd(), '..', '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'), + resolve(process.cwd(), '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'), + resolve(process.cwd(), 'opendance-windows', 'src', 'model', 'humanoid_map.gd'), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + throw new Error(`找不到 opendance-windows/src/model/humanoid_map.gd,候选路径:\n${candidates.join('\n')}`); +} + +const HUMANOID_MAP_PATH = findHumanoidMap(); +const GD_SOURCE = readFileSync(HUMANOID_MAP_PATH, 'utf8'); + +function parseGdStringList(raw: string): string[] { + return raw + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0) + .map((item) => item.replace(/^"|"$/g, '')); +} + +/** 抽 `const NAME ... = [ "a", "b", ... ]` 形式的字符串数组 */ +function extractGdStringList(source: string, constName: string): string[] { + const match = new RegExp(`const\\s+${constName}\\s*:[^=]*=\\s*\\[([\\s\\S]*?)\\]`).exec(source); + if (!match) throw new Error(`humanoid_map.gd 里找不到 ${constName}`); + return parseGdStringList(match[1]); +} + +/** 抽 `const CANDIDATES := { "canonical": ["a", "b"], ... }` */ +function extractGdCandidates(source: string): Record { + const match = /const\s+CANDIDATES\s*:=\s*\{([\s\S]*?)\n\}/.exec(source); + if (!match) throw new Error('humanoid_map.gd 里找不到 CANDIDATES'); + const out: Record = {}; + const entry = /"([A-Za-z0-9_]+)"\s*:\s*\[([^\]]*)\]/g; + let hit: RegExpExecArray | null; + while ((hit = entry.exec(match[1])) !== null) { + out[hit[1]] = parseGdStringList(hit[2]); + } + return out; +} + +describe(`与客户端 humanoid_map.gd 一致(${HUMANOID_MAP_PATH})`, () => { + it('CANDIDATES 逐键一致(键顺序与候选顺序都要一致)', () => { + const gd = extractGdCandidates(GD_SOURCE); + expect(Object.keys(gd).length).toBeGreaterThan(0); + // 键顺序也要一致:服务端按 HUMANOID_BONES 顺序做映射与提示 + expect(Object.keys(HUMANOID_CANDIDATES)).toEqual(Object.keys(gd)); + for (const canonical of Object.keys(gd)) { + expect(HUMANOID_CANDIDATES[canonical], `候选表 ${canonical} 与客户端不一致`).toEqual(gd[canonical]); + } + }); + + it('HUMANOID_BONES 与客户端一致,humanoidTotal 就是它的长度', () => { + expect([...HUMANOID_BONES]).toEqual(extractGdStringList(GD_SOURCE, 'HUMANOID_BONES')); + expect(HUMANOID_BONES.length).toBe(22); + }); + + it('STRIPPABLE_PREFIXES 与客户端一致', () => { + expect([...STRIPPABLE_PREFIXES]).toEqual(extractGdStringList(GD_SOURCE, 'STRIPPABLE_PREFIXES')); + }); + + it('必需 16 根都能在客户端候选表里找到,且互不重复', () => { + const gd = extractGdCandidates(GD_SOURCE); + expect(HUMANOID_REQUIRED).toHaveLength(16); + expect(new Set(HUMANOID_REQUIRED).size).toBe(16); + for (const bone of HUMANOID_REQUIRED) { + expect(Object.keys(gd)).toContain(bone); + } + }); +}); + +describe('normalize / variants 行为与客户端一致', () => { + it('VRoid / Mixamo / Bip01 三种前缀的展开与缩写', () => { + expect(normalizeBoneName('J_Bip_L_UpperArm')).toBe('jbiplupperarm'); + expect(boneNameVariants('J_Bip_L_UpperArm')).toEqual(['lupperarm', 'lupperarm', 'leftupperarm']); + expect(normalizeBoneName('mixamorig:LeftArm')).toBe('mixamorigleftarm'); + expect(boneNameVariants('mixamorig:LeftArm')).toEqual(['leftarm', 'larm', 'leftarm']); + expect(boneNameVariants('Bip01 L Thigh')).toEqual(['lthigh', 'lthigh', 'leftthigh']); + expect(boneNameVariants('J_Bip_C_Hips')).toEqual(['chips', 'chips', 'chips']); + }); + + it('不剥离 J_Aim_ 等扭转辅助骨前缀(否则会撞上真正的骨骼)', () => { + expect(boneNameVariants('J_Aim_L_UpperLeg')).toEqual([ + 'jaimlupperleg', + 'jaimlupperleg', + 'jaimlupperleg', + ]); + const allCandidates = Object.values(HUMANOID_CANDIDATES).flat(); + expect(allCandidates).not.toContain('jaimlupperleg'); + }); +}); diff --git a/web-server/test/model-thumbnail.spec.ts b/web-server/test/model-thumbnail.spec.ts new file mode 100644 index 0000000..bd69efc --- /dev/null +++ b/web-server/test/model-thumbnail.spec.ts @@ -0,0 +1,412 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inflateSync } from 'node:zlib'; +import { describe, expect, it } from 'vitest'; +import { renderModelThumbnail } from '../src/models/model-thumbnail'; + +/** + * 缩略图生成测试。 + * 所有夹具都在代码里构造,不联网、不依赖仓库外的文件。 + */ + +const dir = mkdtempSync(join(tmpdir(), 'opendance-thumb-')); + +function write(name: string, content: Buffer): string { + const path = join(dir, name); + writeFileSync(path, content); + return path; +} + +/** 构造 GLB:12 字节头 + JSON chunk(空格补齐)+ 可选 BIN chunk(0 补齐) */ +function buildGlb(json: unknown, bin?: Buffer): Buffer { + const jsonRaw = Buffer.from(JSON.stringify(json), 'utf8'); + const jsonPad = (4 - (jsonRaw.length % 4)) % 4; + const jsonChunk = Buffer.concat([jsonRaw, Buffer.alloc(jsonPad, 0x20)]); + + const chunks: Buffer[] = []; + const header = Buffer.alloc(12); + header.write('glTF', 0, 'ascii'); + header.writeUInt32LE(2, 4); + chunks.push(header); + + const jsonHeader = Buffer.alloc(8); + jsonHeader.writeUInt32LE(jsonChunk.length, 0); + jsonHeader.writeUInt32LE(0x4e4f534a, 4); + chunks.push(jsonHeader, jsonChunk); + + if (bin) { + const binPad = (4 - (bin.length % 4)) % 4; + const binChunk = Buffer.concat([bin, Buffer.alloc(binPad)]); + const binHeader = Buffer.alloc(8); + binHeader.writeUInt32LE(binChunk.length, 0); + binHeader.writeUInt32LE(0x004e4942, 4); + chunks.push(binHeader, binChunk); + } + + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const out = Buffer.concat(chunks, total); + out.writeUInt32LE(total, 8); + return out; +} + +function float32Buffer(values: number[]): Buffer { + const buffer = Buffer.alloc(values.length * 4); + values.forEach((value, index) => buffer.writeFloatLE(value, index * 4)); + return buffer; +} + +function uint16Buffer(values: number[]): Buffer { + const buffer = Buffer.alloc(values.length * 2); + values.forEach((value, index) => buffer.writeUInt16LE(value, index * 2)); + return buffer; +} + +/** 单个三角形、无索引的最小 GLB */ +function triangleGlb(): Buffer { + const bin = float32Buffer([0, 0, 0, 1, 0, 0, 0, 1, 0]); + const json = { + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }], + materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.9, 0.4, 0.3, 1] } }], + accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }], + bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: bin.length }], + buffers: [{ byteLength: bin.length }], + }; + return buildGlb(json, bin); +} + +/** 8 顶点 12 三角形的立方体(每个面朝外 CCW),scale 控制大小 */ +function cubeGlb(scale: number): Buffer { + const s = scale; + const positions = [ + -s, -s, -s, s, -s, -s, s, s, -s, -s, s, -s, + -s, -s, s, s, -s, s, s, s, s, -s, s, s, + ]; + const indices = [ + 4, 5, 6, 4, 6, 7, // +z + 1, 0, 3, 1, 3, 2, // -z + 5, 1, 2, 5, 2, 6, // +x + 0, 4, 7, 0, 7, 3, // -x + 7, 6, 2, 7, 2, 3, // +y + 0, 1, 5, 0, 5, 4, // -y + ]; + const posBin = float32Buffer(positions); + const idxBin = uint16Buffer(indices); + const bin = Buffer.concat([posBin, idxBin]); + const json = { + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }], + materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.85, 0.85, 0.85, 1] } }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 8, type: 'VEC3' }, + { bufferView: 1, componentType: 5123, count: 12 * 3, type: 'SCALAR' }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: posBin.length }, + { buffer: 0, byteOffset: posBin.length, byteLength: idxBin.length }, + ], + buffers: [{ byteLength: bin.length }], + }; + return buildGlb(json, bin); +} + +/** 5000 个三角形的波浪网格(50x50 个格子,每格 2 个三角形) */ +function gridGlb(segments: number): Buffer { + const n = segments; + const positions: number[] = []; + for (let j = 0; j <= n; j += 1) { + for (let i = 0; i <= n; i += 1) { + const x = (i / n) * 2 - 1; + const y = (j / n) * 2 - 1; + const z = 0.3 * Math.sin(x * 3) * Math.cos(y * 3); + positions.push(x, y, z); + } + } + const indices: number[] = []; + for (let j = 0; j < n; j += 1) { + for (let i = 0; i < n; i += 1) { + const a = j * (n + 1) + i; + const b = a + 1; + const c = a + (n + 1); + const d = c + 1; + indices.push(a, b, d, a, d, c); + } + } + const posBin = float32Buffer(positions); + const idxBin = uint16Buffer(indices); + const bin = Buffer.concat([posBin, idxBin]); + const json = { + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }], + accessors: [ + { bufferView: 0, componentType: 5126, count: positions.length / 3, type: 'VEC3' }, + { bufferView: 1, componentType: 5123, count: indices.length, type: 'SCALAR' }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: posBin.length }, + { buffer: 0, byteOffset: posBin.length, byteLength: idxBin.length }, + ], + buffers: [{ byteLength: bin.length }], + }; + return buildGlb(json, bin); +} + +/** 列主序平移矩阵(与 glTF 的 matrix 布局一致) */ +function translationMat4(x: number, y: number, z: number): number[] { + return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1]; +} + +/** + * 蒙皮四边形:4 顶点 2 三角形(无索引复用),2 个关节。 + * - 下半两个顶点绑到 joint0(世界矩阵 = 单位阵,IBM = 单位阵)→ 原地不动 + * - 上半两个顶点绑到 joint1(世界矩阵 = translate(0,4,0),IBM = translate(0,-2,0)) + * → 蒙皮矩阵 translate(0,2,0),顶点被抬到 y=3 + * 因此正确实现蒙皮后,几何应当等价于一个顶点为 y∈[-1,3] 的普通四边形。 + */ +function skinnedQuadGlb(): Buffer { + const positions = float32Buffer([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]); + const joints = Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]); + const weights = float32Buffer([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]); + const inverseBind = float32Buffer([...translationMat4(0, 0, 0), ...translationMat4(0, -2, 0)]); + const indices = uint16Buffer([0, 1, 2, 0, 2, 3]); + + const views = [ + { buffer: 0, byteOffset: 0, byteLength: positions.length }, + { buffer: 0, byteOffset: positions.length, byteLength: joints.length }, + { buffer: 0, byteOffset: positions.length + joints.length, byteLength: weights.length }, + { buffer: 0, byteOffset: positions.length + joints.length + weights.length, byteLength: inverseBind.length }, + { + buffer: 0, + byteOffset: positions.length + joints.length + weights.length + inverseBind.length, + byteLength: indices.length, + }, + ]; + const bin = Buffer.concat([positions, joints, weights, inverseBind, indices]); + const json = { + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0, 3] }], + nodes: [ + { children: [1, 2] }, // 0: 骨架根 + { translation: [0, 0, 0] }, // 1: joint0(绑定姿态即身份) + { translation: [0, 4, 0] }, // 2: joint1(当前姿态被抬高 4) + { mesh: 0, skin: 0 }, // 3: 蒙皮网格,节点自身无变换 + ], + skins: [{ inverseBindMatrices: 3, joints: [1, 2], skeleton: 1 }], + meshes: [ + { + primitives: [ + { + attributes: { POSITION: 0, JOINTS_0: 1, WEIGHTS_0: 2 }, + indices: 4, + material: 0, + }, + ], + }, + ], + materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.8, 0.8, 0.8, 1] } }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 4, type: 'VEC3' }, + { bufferView: 1, componentType: 5121, count: 4, type: 'VEC4' }, + { bufferView: 2, componentType: 5126, count: 4, type: 'VEC4' }, + { bufferView: 3, componentType: 5126, count: 2, type: 'MAT4' }, + { bufferView: 4, componentType: 5123, count: 6, type: 'SCALAR' }, + ], + bufferViews: views, + buffers: [{ byteLength: bin.length }], + }; + return buildGlb(json, bin); +} + +/** 参照物:无蒙皮的四边形,上下边分别位于 y=-1 与 yTop,其余与 skinnedQuadGlb 等价 */ +function plainQuadGlb(yTop: number): Buffer { + const positions = float32Buffer([-1, -1, 0, 1, -1, 0, 1, yTop, 0, -1, yTop, 0]); + const indices = uint16Buffer([0, 1, 2, 0, 2, 3]); + const bin = Buffer.concat([positions, indices]); + const json = { + asset: { version: '2.0' }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }], + materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.8, 0.8, 0.8, 1] } }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 4, type: 'VEC3' }, + { bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' }, + ], + bufferViews: [ + { buffer: 0, byteOffset: 0, byteLength: positions.length }, + { buffer: 0, byteOffset: positions.length, byteLength: indices.length }, + ], + buffers: [{ byteLength: bin.length }], + }; + return buildGlb(json, bin); +} + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +interface DecodedPng { + width: number; + height: number; + pixels: Buffer; +} + +/** 手工解 PNG:校验签名 / chunk 结构,用 inflateSync 还原 IDAT */ +function decodePng(png: Buffer): DecodedPng { + expect(png.subarray(0, 8)).toEqual(PNG_SIGNATURE); + let offset = 8; + let width = 0; + let height = 0; + let sawIhdr = false; + let sawIend = false; + const idat: Buffer[] = []; + while (offset + 8 <= png.length) { + const length = png.readUInt32BE(offset); + const type = png.toString('ascii', offset + 4, offset + 8); + const data = png.subarray(offset + 8, offset + 8 + length); + expect(offset + 12 + length).toBeLessThanOrEqual(png.length); + if (type === 'IHDR') { + sawIhdr = true; + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + expect(data[8]).toBe(8); // bit depth + expect(data[9]).toBe(2); // color type = RGB + expect(data[12]).toBe(0); // interlace = none + } else if (type === 'IDAT') { + idat.push(Buffer.from(data)); + } else if (type === 'IEND') { + sawIend = true; + break; + } + offset += 12 + length; + } + expect(sawIhdr).toBe(true); + expect(sawIend).toBe(true); + + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * 3; + expect(raw.length).toBe(height * (stride + 1)); + const pixels = Buffer.alloc(height * stride); + for (let y = 0; y < height; y += 1) { + expect(raw[y * (stride + 1)]).toBe(0); // filter = 0 + raw.copy(pixels, y * stride, y * (stride + 1) + 1, y * (stride + 1) + 1 + stride); + } + return { width, height, pixels }; +} + +/** 统计与背景色不同的像素数量 */ +function countNonBackground(pixels: Buffer, background: [number, number, number]): number { + let count = 0; + for (let i = 0; i < pixels.length; i += 3) { + if ( + Math.abs(pixels[i] - background[0]) > 2 || + Math.abs(pixels[i + 1] - background[1]) > 2 || + Math.abs(pixels[i + 2] - background[2]) > 2 + ) { + count += 1; + } + } + return count; +} + +describe('renderModelThumbnail', () => { + it('最小三角形 GLB 能出图,PNG 头与 IHDR 尺寸正确', async () => { + const path = write('triangle.glb', triangleGlb()); + const result = await renderModelThumbnail(path, { size: 128 }); + expect(result).not.toBeNull(); + expect(result!.width).toBe(128); + expect(result!.height).toBe(128); + expect(result!.trianglesDrawn).toBeGreaterThanOrEqual(1); + + expect(result!.png.subarray(0, 8)).toEqual(PNG_SIGNATURE); + const decoded = decodePng(result!.png); + expect(decoded.width).toBe(128); + expect(decoded.height).toBe(128); + }); + + it('带索引的立方体能出图,且画面里确实有非背景像素', async () => { + const background: [number, number, number] = [24, 26, 32]; + const path = write('cube.glb', cubeGlb(1)); + const result = await renderModelThumbnail(path, { size: 256, background }); + expect(result).not.toBeNull(); + // 12 个三角形,背面剔除后会少画一部分 + expect(result!.trianglesDrawn).toBeGreaterThanOrEqual(1); + expect(result!.trianglesDrawn).toBeLessThanOrEqual(12); + + const decoded = decodePng(result!.png); + const nonBackground = countNonBackground(decoded.pixels, background); + expect(nonBackground).toBeGreaterThan(0); + }); + + it('超大(±1000)与超小(±0.001)模型都能出图且不溢出', async () => { + const background: [number, number, number] = [24, 26, 32]; + const bigPath = write('big.glb', cubeGlb(1000)); + const smallPath = write('small.glb', cubeGlb(0.001)); + + const big = await renderModelThumbnail(bigPath, { size: 128, background }); + const small = await renderModelThumbnail(smallPath, { size: 128, background }); + expect(big).not.toBeNull(); + expect(small).not.toBeNull(); + + const bigRatio = countNonBackground(decodePng(big!.png).pixels, background) / (128 * 128); + const smallRatio = countNonBackground(decodePng(small!.png).pixels, background) / (128 * 128); + expect(bigRatio).toBeGreaterThan(0); + expect(smallRatio).toBeGreaterThan(0); + // 构图一致,两者的覆盖率应当接近(都在画面中央占据相似比例) + expect(Math.abs(bigRatio - smallRatio)).toBeLessThan(0.1); + }); + + it('非法尺寸抛中文 Error', async () => { + const path = write('cube-invalid.glb', cubeGlb(1)); + await expect(renderModelThumbnail(path, { size: 8 })).rejects.toThrow(/[\u4e00-\u9fff]/); + await expect(renderModelThumbnail(path, { size: 4096 })).rejects.toThrow(/[\u4e00-\u9fff]/); + }); + + it('非 glTF 文件(假 fbx)返回 null 且不抛错', async () => { + const path = write('fake.fbx', Buffer.from('Kaydara FBX Binary \u0000\u001a\u0000', 'binary')); + await expect(renderModelThumbnail(path)).resolves.toBeNull(); + }); + + it('截断的 GLB 返回 null 且不抛错', async () => { + const valid = triangleGlb(); + const truncated = valid.subarray(0, 20); + const path = write('truncated.glb', truncated); + await expect(renderModelThumbnail(path)).resolves.toBeNull(); + }); + + it('5000 个三角形的模型渲染耗时低于 2000ms', async () => { + const path = write('grid.glb', gridGlb(50)); + const started = performance.now(); + const result = await renderModelThumbnail(path, { size: 256 }); + const elapsed = performance.now() - started; + expect(result).not.toBeNull(); + expect(result!.trianglesDrawn).toBeGreaterThan(0); + expect(elapsed).toBeLessThan(2000); + }); + + it('蒙皮被真正应用:蒙皮结果与等价的无蒙皮几何逐像素一致', async () => { + // 蒙皮后上下边在 y=-1 / y=3;参照物直接用同样的最终顶点位置 + const skinned = await renderModelThumbnail(write('skinned.glb', skinnedQuadGlb()), { size: 128 }); + const equivalent = await renderModelThumbnail(write('plain-sheared.glb', plainQuadGlb(3)), { size: 128 }); + // 绑定姿态(上下边在 y=-1 / y=1)作为反例 + const bindPose = await renderModelThumbnail(write('plain-bind.glb', plainQuadGlb(1)), { size: 128 }); + + expect(skinned).not.toBeNull(); + expect(equivalent).not.toBeNull(); + expect(bindPose).not.toBeNull(); + // 蒙皮矩阵(jointWorld × inverseBindMatrices × 权重)算对了才可能逐字节一致 + expect(skinned!.png.equals(equivalent!.png)).toBe(true); + // 并确认它确实不是"忽略蒙皮"画出来的绑定姿态 + expect(skinned!.png.equals(bindPose!.png)).toBe(false); + }); +});