Files
opendance-web/web-server/scripts/import-default-model.mjs
T
zijunchiang 53bf182476 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 变为该模型
2026-09-15 20:37:53 +08:00

217 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 导入默认模型:把模型压缩包交给服务端导入/转换并入库,
* 再**审核通过并设为全局默认模型**(服务端随之把默认模型身份写进源码
* `src/config/default-model.ts`)。
*
* 注意:服务端上传的模型**一律进入待审核(pending)**,所以本脚本会显式调用
* `POST /api/reviews/model/{id}/approve`(带 `setPrimary=true`)完成"通过 + 设默认"。
* 只想入库不通过审核时加 `--no-approve`。
*
* 为什么要有这个脚本:服务进程在 systemd 加固(ProtectSystem=strict)下**不能**
* 改写程序目录,写源码这一步必须在沙箱外完成,因此由本脚本(以 root/运维身份运行)
* 调 API 之后自己写文件。
*
* 用法(在 web-server 目录下,需先 `npm run build`):
* node scripts/import-default-model.mjs 模型.zip \
* --url http://127.0.0.1:3000 \
* --name "初音未来 Hatsune Miku" \
* --author "Boku Wa Luka" \
* --license "MMD 模型使用条款(见压缩包内说明)" \
* --source-name "模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
* --source-url "本地文件:模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip" \
* --source-type other
*
* # 只根据已入库的模型补写源码(库里的默认模型已知时)
* node scripts/import-default-model.mjs --write-source-only --model-id 16
*
* # 只写源码文件、不调 API(配合 --write-source-only)
* node scripts/import-default-model.mjs --write-source-only --model-id 16 --dry-run
*/
import { createRequire } from 'node:module';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
const require = createRequire(import.meta.url);
const args = process.argv.slice(2);
const flag = (name, fallback = undefined) => {
const index = args.indexOf(name);
return index >= 0 && args[index + 1] && !args[index + 1].startsWith('--')
? args[index + 1]
: index >= 0
? true
: fallback;
};
const has = (name) => args.includes(name);
/** 需要跟一个取值的开关(其余 --x 视为布尔开关) */
const VALUE_FLAGS = new Set([
'--url',
'--name',
'--author',
'--license',
'--source-name',
'--source-url',
'--source-type',
'--model-id',
'--dir',
'--env-file',
]);
/** 第一个"不是开关、也不是开关取值"的参数即输入文件 */
function positionalFile(argv) {
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg.startsWith('--')) {
if (VALUE_FLAGS.has(arg)) i += 1;
continue;
}
return arg;
}
return undefined;
}
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');
const sourceFile = join(sourceDir, 'default-model.ts');
function loadRenderer() {
const distPath = join(process.cwd(), 'dist', 'models', 'default-model-source.js');
if (!existsSync(distPath)) {
console.error('未找到 dist/models/default-model-source.js,请先执行:npm run build');
process.exit(2);
}
return require(distPath);
}
async function api(path, options = {}) {
const response = await fetch(`${url}${path}`, options);
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
if (!response.ok) {
const detail = body && typeof body === 'object' ? JSON.stringify(body) : String(body);
throw new Error(`${options.method ?? 'GET'} ${path} → HTTP ${response.status}:${detail}`);
}
return body;
}
async function fetchModel(id) {
const model = await api(`/api/models/${id}`);
return model;
}
async function main() {
const { renderDefaultModelSource, defaultModelSourcePath } = loadRenderer();
const target = defaultModelSourcePath(process.cwd());
let model;
if (writeSourceOnly) {
const id = Number(flag('--model-id', 0));
if (!Number.isInteger(id) || id <= 0) {
console.error('--write-source-only 需要 --model-id <已入库的模型 id>');
process.exit(2);
}
// 服务端只在 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(
'用法:node scripts/import-default-model.mjs <模型.zip> [--url ...] [--name ...] [--author ...] [--license ...]',
);
process.exit(2);
}
const buffer = readFileSync(resolve(file));
const form = new FormData();
form.append('file', new Blob([buffer]), basename(file));
form.append('name', String(flag('--name', basename(file).replace(/\.[^.]+$/, ''))));
form.append('sourceType', String(flag('--source-type', 'other')));
form.append('sourceName', String(flag('--source-name', basename(file))));
form.append('sourceUrl', String(flag('--source-url', `本地文件:${basename(file)}`)));
form.append('license', String(flag('--license', '待补充:MMD 模型使用条款(见压缩包内说明)')));
form.append('author', String(flag('--author', '待补充')));
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},状态 ${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) {
console.error('该模型没有 fileSha256 / sourceSha256(文件缺失?),无法写入默认模型种子');
process.exit(1);
}
const seed = {
id: model.id,
name: model.name,
format: model.format,
// 主匹配键用上传压缩包的 sha256(稳定);直接上传模型文件时退回产物 sha256
sourceSha256: model.sourceSha256 ?? model.fileSha256,
fileSha256: model.fileSha256 ?? '',
convertedFrom: (model.conversionNote ?? '').split(',')[0] || null,
updatedAt: new Date().toISOString(),
};
console.log(` 种子匹配键:sourceSha256=${String(seed.sourceSha256).slice(0, 16)}…`);
if (model.sourceSha256) {
console.log(' (用的是压缩包 sha256:Assimp 转换产物每次都不一样,只有它稳定)');
}
const content = renderDefaultModelSource(seed);
if (dryRun) {
console.log(`--dry-run:将写入 ${target}\n`);
console.log(content);
return;
}
writeFileSync(target, content, 'utf8');
console.log(`已把默认模型写进源码:${target}`);
console.log('建议接着执行:npm run typecheck && git add src/config/default-model.ts');
}
main().catch((error) => {
console.error(`失败:${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});