Files
opendance-web/web-server/scripts/import-default-model.mjs
T
zijunchiang e4351c5062 feat(models): 模型压缩包自动转换 + 默认模型写进源码;首次启动 .env 引导;HOST 默认 0.0.0.0
需求 1:模型上传支持压缩包(MMD 模型包)
- common/zip-reader.ts:纯 TS 极简 ZIP 读取器(EOCD/中央目录、STORE+DEFLATE、
  ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目)
- models/model-convert.ts:解压 → 选主模型(指名字/最浅/最大)→ Assimp 导出 FBX
  → **assimp info 回读校验**(Assimp 对带蒙皮大骨架模型会写出损坏文件:
  实测 714 骨骼 PMX 导出退出码 11、产物回读报 FBX-DOM no Objects dictionary found)
  → 失败自动回退 GLB;两种都失败则 422
- models/glb-textures.ts:把 Assimp 写成外部 URI 的贴图内嵌进 GLB 的 BIN chunk,
  保证模型库里单文件即可加载(否则是白模)
- 溯源:压缩包原件存为 model-archive 资产;models 新增 source_asset_id /
  conversion_note(迁移 1789450000000-AddModelConversionFields,已在真实库上
  验证 up/down 可逆)
- 默认模型:转换成功且 autoDefault 不为 false → is_primary=1 + published,
  并把身份(名称/格式/产物 sha256)写进源码 src/config/default-model.ts;
  resolveDefaultModelId 增加 DEFAULT_MODEL_ID 与源码种子两步(按 sha256 匹配,
  换库仍有效),实测库中有 2 个 VRM 时默认模型仍解析为该 GLB
- scripts/import-default-model.mjs:运维 CLI(上传 zip 并写源码 / --write-source-only)
- 修复 multipart 中文文件名乱码(upload.util.decodeUploadName)
- DTO/契约新增 conversionNote、fileSha256;管理端 accept 增加 .zip 并展示转换来源

需求 2:首次启动 .env 引导
- deploy/init-env.sh:交互式逐项填写 8 个键(回车保留默认、口令不回显不加引号、
  端口校验、mysql2 连通性自检且口令只经环境变量传递、只改这 8 个键并保留模板注释、
  chmod 600、结尾打码摘要);--force/--check/-y/-i/--skip-db-check 等开关;
  非交互(-y、OPENDANCE_NONINTERACTIVE、stdin 关闭)绝不提问、读取带超时
- install.sh 缺 .env 时改为调用引导,失败才 die

需求 3:默认监听 0.0.0.0
- 代码默认值、两份 .env 模板、install.sh、init-env.sh 五处一致,并新增测试守护
  (未设置 HOST 时为 0.0.0.0;两份模板都写着 HOST=0.0.0.0)

契约:rest-api / data-models / model-formats → 0.3.0,database → 0.2.0(另两库已同步)

验证:125 项单测通过(7 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实包端到端 HTTP 201 / 0.89s / format=glb / isPrimary=true / published;
GLB 1,152,464 字节、1 mesh / 240 nodes / 1 skin(238 joints) / 2 张内嵌贴图;
迁移 revert→run 实测可逆且数据无损恢复
2026-09-15 13:55:54 +08:00

150 lines
5.7 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);
const positional = args.find((a) => !a.startsWith('--') && args[args.indexOf(a) - 1]?.startsWith('--') === false);
const file = positional;
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) {
console.error('该模型没有 fileSha256(文件缺失?),无法写入默认模型种子');
process.exit(1);
}
const seed = {
id: model.id,
name: model.name,
format: model.format,
sha256: model.fileSha256,
convertedFrom: (model.conversionNote ?? '').split(',')[0] || null,
updatedAt: new Date().toISOString(),
};
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);
});