Files
opendance-web/web-server/scripts/reanalyze-models.mjs
T
zijunchiang c342a804a1 feat(models): 新增历史模型回填(reanalyze)并修复存储去重的悬挂文件问题
背景:升级到带 models.stats 的版本后,库里已有模型没有分析与缩略图,
审核界面会全部显示"未分析、无预览图"。E2E 验证时还发现一个真问题:
sha256 去重命中旧资产但文件不存在(换过 STORAGE_DIR、或只恢复数据库)时,
分析会静默产出"全 0 结果"。

- POST /api/models/:id/reanalyze:重新分析(面数/骨骼树/humanoid/可驱动性)
  并重建缩略图,只改 stats 与 thumbnailAssetId;文件在存储中不存在时返回 409,
  避免用空结果覆盖已有分析数据
- scripts/reanalyze-models.mjs:批量回填 CLI(--dry-run / --model-id / --force)
- StorageService.persistBuffer:去重命中但文件不存在时按原路径**自愈补写**
- 契约:rest-api 新增 /api/models/{id}/reanalyze(路由 29 → 30 条)
- 文档:README / tasks/005 / 发布说明补回填章节与升级步骤

实测:真实 21MB VRM(290 骨骼)回填出面数 53552 / 顶点 44662 / humanoid 7/22,
并重建 512×512 缩略图;文件缺失场景返回 409 且不覆盖既有数据。
测试 168 项通过;契约与 30 条路由一致。
2026-09-15 21:18:15 +08:00

105 lines
3.5 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
/**
* 回填 / 刷新已有模型的分析结果与缩略图。
*
* 用途:升级到带 `models.stats` 的版本后,历史模型还没有面数、骨骼树与预览图;
* 这个脚本逐个调用 `POST /api/models/{id}/reanalyze` 把它们补齐。
*
* 用法(在 web-server 目录下,服务需已启动):
* node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 # 全部模型
* node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --model-id 14
* node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --force # 已有 stats 的也重算
* node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --dry-run # 只列出会处理谁
*
* 只读列表 + 调 reanalyze 接口,不直接改数据库。
*/
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 url = String(flag('--url', 'http://127.0.0.1:3000')).replace(/\/$/, '');
const onlyId = Number(flag('--model-id', 0)) || 0;
const force = has('--force');
const dryRun = has('--dry-run');
const pageSize = 50;
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) {
throw new Error(`${options.method ?? 'GET'} ${path} → HTTP ${response.status}:${JSON.stringify(body)}`);
}
return body;
}
/** 拉取全部模型(分页) */
async function listAllModels() {
const all = [];
for (let page = 1; ; page += 1) {
const data = await api(`/api/models?page=${page}&pageSize=${pageSize}`);
all.push(...(data.items ?? []));
if (all.length >= (data.total ?? 0) || (data.items ?? []).length === 0) break;
}
return all;
}
async function main() {
if (onlyId) {
const model = await api(`/api/models/${onlyId}`);
await reanalyze(model);
return;
}
const models = await listAllModels();
const todo = force ? models : models.filter((m) => !m.stats);
console.log(
`模型总数 ${models.length},${force ? '(--force)全部重算' : '需要回填'} ${todo.length} 个`,
);
if (dryRun) {
for (const m of todo) console.log(` #${m.id} ${m.name}(${m.format})`);
return;
}
let ok = 0;
let failed = 0;
for (const model of todo) {
try {
await reanalyze(model);
ok += 1;
} catch (error) {
failed += 1;
console.error(` ✗ #${model.id} ${model.name}:${error instanceof Error ? error.message : error}`);
}
}
console.log(`完成:成功 ${ok} 个,失败 ${failed} 个`);
if (failed > 0) process.exit(1);
}
async function reanalyze(model) {
const result = await api(`/api/models/${model.id}/reanalyze`, { method: 'POST' });
const stats = result.stats ?? {};
console.log(
` ✓ #${result.id} ${result.name}:面数=${stats.triangles ?? '—'} 骨骼=${stats.bones ?? '—'} ` +
`humanoid=${stats.humanoidMapped ?? '—'}/${stats.humanoidTotal ?? '—'} ` +
`可驱动=${stats.drivable ?? '—'} 缩略图=${result.thumbnailUrl ? '已生成' : '无'}`,
);
}
main().catch((error) => {
console.error(`失败:${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});