feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览

需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
  - model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
    **骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
  - model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
    生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
  - vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
  - gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
  (服务端判定的"可驱动" = 客户端实际能否驱动)

需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
  →(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
  其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
  失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf

需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
  humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
  **骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
  仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
  POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程

其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
  顶层开发书更新为 v1.4

验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
This commit is contained in:
2026-09-15 20:37:53 +08:00
parent ccced34dd6
commit 53bf182476
47 changed files with 5610 additions and 628 deletions
+45 -9
View File
@@ -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) {