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:
2026-09-15 20:37:53 +08:00
parent ccced34dd6
commit 53bf182476
47 changed files with 5610 additions and 628 deletions
+125 -49
View File
@@ -95,24 +95,17 @@ case "$cmd" in
file="\${2:-}"
if [ "\${FAKE_INFO_FAIL:-}" = "fbx" ]; then
case "$file" in
*.fbx)
echo "ERROR: Failed to load file: FBX-DOM no Objects dictionary found" >&2
exit 1
;;
*.fbx) echo "ERROR: Failed to load file: FBX-DOM no Objects dictionary found" >&2; exit 1 ;;
esac
fi
if [ "\${FAKE_INFO_FAIL:-}" = "all" ]; then
echo "ERROR: Failed to load file" >&2; exit 1
fi
exit 0
;;
export)
target="\${3:-}"
case "$target" in
*.fbx)
if [ "\${FAKE_FBX:-ok}" = "fail" ]; then
echo "Failed to write file" >&2
exit 11
fi
printf 'FAKE-FBX-CONTENT-0123456789' > "$target"
;;
*.glb)
if [ "\${FAKE_GLB:-ok}" = "fail" ]; then
echo "Failed to write file" >&2
@@ -121,6 +114,16 @@ case "$cmd" in
printf '%s' "\${FAKE_GLB_B64:-}" | base64 -d > "$target" 2>/dev/null
if [ ! -s "$target" ]; then printf 'FAKE-GLB-CONTENT-0123456789' > "$target"; fi
;;
*.gltf)
if [ "\${FAKE_GLTF:-fail}" = "fail" ]; then
echo "Failed to write file" >&2
exit 11
fi
# 写出 glTF JSON 与配套 .bin(打包器要能把它们合成 GLB)
bin="\${target%.gltf}.bin"
printf '\x01\x02\x03\x04\x05\x06\x07\x08' > "$bin"
printf '%s' "\${FAKE_GLTF_JSON:-}" > "$target"
;;
*) exit 1 ;;
esac
exit 0
@@ -159,6 +162,38 @@ function buildGlbWithExternalImages(): Buffer {
return out;
}
/** 构造一个节点名能映射到 16 根 humanoid 必需骨骼的最小 GLB */
function buildHumanoidGlb(): Buffer {
const names = [
'Hips', 'Spine', 'Neck', 'Head',
'LeftUpperArm', 'LeftLowerArm', 'LeftHand',
'RightUpperArm', 'RightLowerArm', 'RightHand',
'LeftUpperLeg', 'LeftLowerLeg', 'LeftFoot',
'RightUpperLeg', 'RightLowerLeg', 'RightFoot',
];
const json = {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: names.map((name, index) => ({
name,
...(index + 1 < names.length ? { children: [index + 1] } : {}),
})),
};
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
const jsonPadded = Buffer.alloc(jsonBuf.length + ((4 - (jsonBuf.length % 4)) % 4), 0x20);
jsonBuf.copy(jsonPadded);
const total = 12 + 8 + jsonPadded.length;
const out = Buffer.alloc(total);
out.writeUInt32LE(0x46546c67, 0);
out.writeUInt32LE(2, 4);
out.writeUInt32LE(total, 8);
out.writeUInt32LE(jsonPadded.length, 12);
out.writeUInt32LE(0x4e4f534a, 16);
jsonPadded.copy(out, 20);
return out;
}
beforeAll(() => {
workRoot = mkdtempSync(join(tmpdir(), 'opendance-model-test-'));
fakeAssimp = join(workRoot, 'fake-assimp');
@@ -284,22 +319,25 @@ describe('GLB 贴图内嵌', () => {
});
});
describe('压缩包 → 模型转换(用假 Assimp 做对照)', () => {
const archive = () =>
describe('压缩包导入与转换(用假 Assimp 做对照)', () => {
/** 一个含 PMX 的最小模型包 */
const pmxArchive = () =>
buildZip([
{ path: 'Hatsune Miku.pmx', content: Buffer.from('PMX-MODEL-BYTES') },
{ path: 'textures/a.png', content: Buffer.from([1, 2, 3, 4, 5]) },
{ path: 'textures/b.png', content: Buffer.from([6, 7, 8]) },
]);
// 让假 Assimp "导出"的 GLB 是一个真实可解析的 GLB(含两张外部贴图),
// 这样贴图内嵌路径也能被覆盖到
const fakeGlbB64 = buildGlbWithExternalImages().toString('base64');
const runConvert = (env: Record<string, string>, preferFormat: 'fbx' | 'glb' = 'fbx') => {
const runConvert = (
archive: Buffer,
env: Record<string, string> = {},
preferFormat: 'glb' | 'vrm' = 'glb',
) => {
const previous = { ...process.env };
Object.assign(process.env, { FAKE_GLB_B64: fakeGlbB64, ...env });
return convertModelArchive(archive(), {
return convertModelArchive(archive, {
assimpBin: fakeAssimp,
preferFormat,
preferredPmx: 'Hatsune Miku.pmx',
@@ -309,50 +347,88 @@ describe('压缩包 → 模型转换(用假 Assimp 做对照)', () => {
});
};
it('FBX 导出成功且可回读 → 用 FBX', async () => {
const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok' });
expect(result.format).toBe(ModelFormat.Fbx);
it('GLB 直出成功 → 交付 GLB 并内嵌贴图', async () => {
const result = await runConvert(pmxArchive(), { FAKE_GLB: 'ok' });
expect(result.format).toBe(ModelFormat.Glb);
expect(result.direct).toBe(false);
expect(result.sourceModelPath).toBe('Hatsune Miku.pmx');
expect(result.attempts).toHaveLength(1);
expect(result.note).toContain('已内嵌 2 张贴图');
expect(result.attempts[0]).toContain('回读校验通过');
});
it('FBX 导出退出码非 0 → 自动回退 GLB 并记录原因', async () => {
const result = await runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'ok' });
it('GLB 直出失败 → 退回 glTF 导出再自行打包成 GLB', async () => {
const gltfJson = JSON.stringify({
asset: { version: '2.0' },
buffers: [{ byteLength: 8, uri: 'converted.bin' }],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 8 }],
images: [{ uri: 'textures/a.png' }],
meshes: [{ primitives: [{ attributes: {} }] }],
});
const result = await runConvert(pmxArchive(), {
FAKE_GLB: 'fail',
FAKE_GLTF: 'ok',
FAKE_GLTF_JSON: gltfJson,
});
expect(result.format).toBe(ModelFormat.Glb);
expect(result.attempts).toHaveLength(2);
expect(result.attempts[0]).toContain('FBX');
expect(result.attempts[0]).toContain('导出失败');
expect(result.attempts[1]).toContain('GLB');
expect(result.note).toContain('Hatsune Miku.pmx → GLB');
// 贴图从压缩包里被内嵌进 GLB
expect(result.note).toContain('已内嵌 2 张贴图');
expect(result.attempts.some((line) => line.includes('GLB 直出:导出失败'))).toBe(true);
expect(result.attempts.some((line) => line.includes('GLB 打包:由 glTF 打包成功'))).toBe(true);
// 打包产物是合法 GLB(长度自洽)
expect(result.buffer.readUInt32LE(0)).toBe(0x46546c67);
expect(result.buffer.readUInt32LE(8)).toBe(result.buffer.length);
});
it('FBX 退出码为 0 但产物损坏(回读失败)→ 同样回退 GLB', async () => {
// 对照 Assimp 的真实行为:命令"成功"写出了文件,但文件读不回来
const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok', FAKE_INFO_FAIL: 'fbx' });
expect(result.attempts[0]).toContain('无法回读');
expect(result.format).toBe(ModelFormat.Glb);
expect(result.attempts).toHaveLength(2);
});
it('两种格式都失败 → 抛出可读错误并提示安装 Assimp', async () => {
await expect(runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'fail' })).rejects.toThrow(
/模型转换失败[\s\S]*Assimp/,
);
});
it('压缩包里没有 PMX → 明确报错', async () => {
const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]);
it('两种路径都失败 → 抛出可读错误并提示安装 Assimp', async () => {
await expect(
convertModelArchive(zip, { assimpBin: fakeAssimp, timeoutMs: 10_000 }),
).rejects.toThrow(/没有找到 \.pmx/);
runConvert(pmxArchive(), { FAKE_GLB: 'fail', FAKE_GLTF: 'fail' }),
).rejects.toThrow(/模型转换失败[\s\S]*Assimp/);
});
it('preferFormat=vrm 且 humanoid 齐全 → 交付 VRM;不全 → 回退 GLB 并说明', async () => {
// 含 16 根 humanoid 骨骼的 GLB
const humanoidB64 = buildHumanoidGlb().toString('base64');
const wrapped = await runConvert(pmxArchive(), { FAKE_GLB_B64: humanoidB64 }, 'vrm');
expect(wrapped.format).toBe(ModelFormat.Vrm);
expect(wrapped.note).toContain('VRM');
// 骨骼名对不上的 GLB → 回退 GLB
const fallback = await runConvert(pmxArchive(), { FAKE_GLB_B64: fakeGlbB64 }, 'vrm');
expect(fallback.format).toBe(ModelFormat.Glb);
expect(fallback.attempts.some((line) => line.includes('VRM 封装:失败'))).toBe(true);
});
it('包内是 .vrm / .glb 时直接导入,不做重编码', async () => {
const vrmArchive = buildZip([
{ path: 'sub/model.vrm', content: buildHumanoidGlb() },
{ path: 'model.pmx', content: Buffer.from('PMX-BYTES') },
]);
const result = await runConvert(vrmArchive, { FAKE_GLB: 'fail', FAKE_GLTF: 'fail' });
// .vrm 优先级高于 .pmx:即使用 assimp 会失败也应当成功(因为根本不调用它)
expect(result.direct).toBe(true);
expect(result.format).toBe(ModelFormat.Vrm);
expect(result.sourceModelPath).toBe('sub/model.vrm');
expect(result.note).toContain('直接导入');
});
it('包内只有 .glb 时直接导入为 GLB', async () => {
const glbArchive = buildZip([{ path: 'a.glb', content: buildGlbWithExternalImages() }]);
const result = await runConvert(glbArchive);
expect(result.direct).toBe(true);
expect(result.format).toBe(ModelFormat.Glb);
});
it('压缩包里没有可用模型 → 明确报错', async () => {
const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]);
await expect(convertModelArchive(zip, { assimpBin: fakeAssimp })).rejects.toThrow(
/没有找到可用的模型文件/,
);
});
it('assimp 不存在时给出可读错误', async () => {
await expect(
convertModelArchive(archive(), { assimpBin: join(workRoot, 'no-such-assimp'), timeoutMs: 5_000 }),
convertModelArchive(pmxArchive(), {
assimpBin: join(workRoot, 'no-such-assimp'),
timeoutMs: 5_000,
}),
).rejects.toThrow(/模型转换失败/);
});
});