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:
@@ -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(/模型转换失败/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user