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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user