Files
opendance-web/web-server/scripts/import-default-model.mjs
T
zijunchiang ccced34dd6 fix(models): 默认模型改用「压缩包 sha256」匹配(转换产物非字节级确定)
实测发现:同一份 zip 连续转换三次得到三个不同的产物 sha256(大小都是 1,152,464
字节,差异集中在 BIN chunk 的十几字节),说明 Assimp 的 GLB 导出不是字节级确定的。
因此原先把「转换产物 sha256」当作默认模型匹配键的设计是错的——换一次转换就失效。

- default-model.ts / default-model-source.ts:种子拆成 sourceSha256(上传压缩包的
  sha256,稳定,主匹配键)与 fileSha256(产物 sha256,仅作记录)
- models.service.ts:resolveDefaultModelId 依次按 压缩包 sha256 → 产物 sha256 →
  名称 匹配,都落空才回落到 VRM 优先规则并告警
- ModelDto 增加 sourceSha256(非压缩包上传为 null)
- scripts/import-default-model.mjs:写种子时用 sourceSha256;顺带修掉位置参数解析
  (文件当第一个参数时识别不出,会打印用法)
- 契约与文档同步:data-models §4.1、model-formats §1.1 记录该确定性结论;
  README / DSH / tasks/005 的「按产物 sha256 匹配」表述已更正

另附:把模型格式能力的实测矩阵写进 Release 说明(原生只支持 glTF 系;
PMX→FBX 失败;VRM→GLB 丢 VRM 扩展;humanoid 骨骼命名覆盖率)。

验证:125 项单测通过;清理掉两个"文件只在本机临时目录"的模型(避免 sha256 去重
导致后续上传不落盘);种子按压缩包 sha256 命中后 defaultModelId 正确解析。
2026-09-15 19:36:37 +08:00

181 lines
6.6 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
/**
* 导入默认模型:把 MMD 模型压缩包(zip 内含 .pmx)交给服务端转换并入库,
* 转换成功后把它设为全局默认模型,并把身份(名称/格式/产物 sha256)
* 写进源码 `src/config/default-model.ts`。
*
* 为什么要有这个脚本:服务进程在 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 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);
}
model = await fetchModel(id);
console.log(`读取模型 #${model.id}:${model.name}(${model.format})`);
} 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', '待补充')));
form.append('autoDefault', 'true');
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 ?? '(无)'}`);
}
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);
});