import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; import { HUMANOID_BONES, HUMANOID_CANDIDATES, HUMANOID_REQUIRED, STRIPPABLE_PREFIXES, boneNameVariants, inspectModelFile, normalizeBoneName, } from '../src/models/model-inspector'; import type { ModelStats } from '../src/models/model-inspector'; /** * 模型检查器测试。 * * 全部夹具都在代码里构造(GLB 二进制 / glTF JSON),不依赖外部文件、不联网。 * 另有一条「与客户端一致」用例直接读 `opendance-windows/src/model/humanoid_map.gd` * 的源码做逐键比对 —— 客户端改了名字映射,这里必须立刻失败。 */ const dir = mkdtempSync(join(tmpdir(), 'opendance-inspector-')); afterAll(() => { rmSync(dir, { recursive: true, force: true }); }); function write(name: string, content: Buffer): string { const path = join(dir, name); writeFileSync(path, content); return path; } /** 构造合法 GLB:12 字节头 + JSON chunk(+ 可选 BIN chunk),chunk 按 4 字节对齐 */ function buildGlb(json: unknown, bin?: Buffer): Buffer { const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8'); const jsonChunk = Buffer.concat([jsonBuf, Buffer.alloc((4 - (jsonBuf.length % 4)) % 4, 0x20)]); const chunks: Buffer[] = []; const header = Buffer.alloc(12); header.write('glTF', 0, 'ascii'); header.writeUInt32LE(2, 4); chunks.push(header); const jsonHeader = Buffer.alloc(8); jsonHeader.writeUInt32LE(jsonChunk.length, 0); jsonHeader.writeUInt32LE(0x4e4f534a, 4); // 'JSON' chunks.push(jsonHeader, jsonChunk); if (bin && bin.length > 0) { const binChunk = Buffer.concat([bin, Buffer.alloc((4 - (bin.length % 4)) % 4)]); const binHeader = Buffer.alloc(8); binHeader.writeUInt32LE(binChunk.length, 0); binHeader.writeUInt32LE(0x004e4942, 4); // 'BIN\0' chunks.push(binHeader, binChunk); } const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const out = Buffer.concat(chunks, total); out.writeUInt32LE(total, 8); return out; } /** 所有统计数字必须是有限整数(不允许 NaN / Infinity / 小数) */ function expectIntegerStats(stats: ModelStats): void { const numericKeys: Array = [ 'fileSize', 'meshes', 'primitives', 'vertices', 'triangles', 'materials', 'textures', 'images', 'animations', 'morphTargets', 'nodes', 'bones', 'skins', 'humanoidMapped', 'humanoidTotal', 'maxBoneDepth', ]; for (const key of numericKeys) { const value = stats[key]; expect(Number.isInteger(value), `${String(key)} 必须是有限整数,实际为 ${String(value)}`).toBe(true); } for (const bone of stats.boneTree) { expect(Number.isInteger(bone.index)).toBe(true); expect(Number.isInteger(bone.depth)).toBe(true); expect(Number.isInteger(bone.childCount)).toBe(true); } } /* ------------------------------------------------------- 1. GLB 基础统计 + 骨骼树 */ /** VRoid 风格的最小夹具:1 mesh(POSITION=3 / indices=3)、1 material、1 image、1 skin */ function basicGlb(): Buffer { return buildGlb( { asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }], nodes: [ { name: 'J_Bip_C_Hips', children: [1] }, { name: 'J_Bip_L_UpperArm', children: [2] }, { name: 'J_Bip_L_LowerArm' }, ], skins: [{ joints: [0, 1, 2] }], meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }], accessors: [{ count: 3 }, { count: 3 }], materials: [{}], images: [{}], }, // 顺带覆盖 BIN chunk:解析器应跳过它而不受影响 Buffer.from([1, 2, 3, 4, 5, 6]), ); } describe('GLB 基础统计与骨骼树', () => { it('顶点/面数/材质/skin 与 depth、parentName、childCount 正确', async () => { const glb = basicGlb(); const stats = await inspectModelFile(write('basic.glb', glb)); expect(stats.container).toBe('glb'); expect(stats.vrmVersion).toBeNull(); expect(stats.fileSize).toBe(glb.length); expect(stats.vertices).toBe(3); expect(stats.triangles).toBe(1); expect(stats.materials).toBe(1); expect(stats.images).toBe(1); expect(stats.textures).toBe(0); expect(stats.meshes).toBe(1); expect(stats.primitives).toBe(1); expect(stats.skins).toBe(1); expect(stats.nodes).toBe(3); expect(stats.bones).toBe(3); expect(stats.warnings).toEqual([]); expect(stats.boneTree).toHaveLength(3); expect( stats.boneTree.map((bone) => ({ name: bone.name, depth: bone.depth, parentName: bone.parentName, childCount: bone.childCount, inSkin: bone.inSkin, })), ).toEqual([ { name: 'J_Bip_C_Hips', depth: 0, parentName: null, childCount: 1, inSkin: true }, { name: 'J_Bip_L_UpperArm', depth: 1, parentName: 'J_Bip_C_Hips', childCount: 1, inSkin: true }, { name: 'J_Bip_L_LowerArm', depth: 2, parentName: 'J_Bip_L_UpperArm', childCount: 0, inSkin: true }, ]); expect(stats.maxBoneDepth).toBe(2); // 三根节点都应按名字映射上 humanoid 规范骨 expect(stats.boneTree.map((bone) => bone.humanoid)).toEqual(['hips', 'leftUpperArm', 'leftLowerArm']); expect(stats.humanoidMapped).toBeGreaterThanOrEqual(3); expect(stats.humanoidTotal).toBe(22); expectIntegerStats(stats); }); it('传入 fileSize 时优先采用传入值', async () => { const stats = await inspectModelFile(write('sized.glb', basicGlb()), 123456); expect(stats.fileSize).toBe(123456); }); it('没有 scenes 时以「无父节点」为根,多根各自 depth=0', async () => { const doc = { asset: { version: '2.0' }, nodes: [ { name: 'Hips', children: [1] }, { name: 'Spine', children: [2] }, { name: 'Chest' }, { name: 'Prop' }, ], }; const stats = await inspectModelFile(write('multi-root.gltf', Buffer.from(JSON.stringify(doc)))); expect(stats.container).toBe('gltf'); expect(stats.boneTree.map((bone) => [bone.name, bone.depth])).toEqual([ ['Hips', 0], ['Spine', 1], ['Chest', 2], ['Prop', 0], ]); expect(stats.maxBoneDepth).toBe(2); }); it('同名冲突取先出现的节点(父在前顺序)', async () => { const doc = { asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }], nodes: [{ name: 'Hips', children: [1] }, { name: 'Spine', children: [2] }, { name: 'Hips' }], }; const stats = await inspectModelFile(write('duplicate.gltf', Buffer.from(JSON.stringify(doc)))); const hips = stats.boneTree.find((bone) => bone.humanoid === 'hips'); expect(hips?.index).toBe(0); expect(stats.boneTree[2].humanoid).toBeNull(); }); }); /* ------------------------------------------------------------- 2. VRM 识别 */ describe('VRM 识别', () => { it('VRMC_vrm 扩展 → container=vrm / vrmVersion=1.0', async () => { const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRMC_vrm: { specVersion: '1.0' } } }); const stats = await inspectModelFile(write('vrm1.vrm', glb)); expect(stats.container).toBe('vrm'); expect(stats.vrmVersion).toBe('1.0'); }); it('VRM 0.x 扩展(无 specVersion)→ vrmVersion=0.x', async () => { const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRM: { meta: { title: 't' } } } }); const stats = await inspectModelFile(write('vrm0.vrm', glb)); expect(stats.container).toBe('vrm'); expect(stats.vrmVersion).toBe('0.x'); }); it('缺 meta 的 VRM 会给出提示', async () => { const glb = buildGlb({ asset: { version: '2.0' }, extensions: { VRMC_vrm: { specVersion: '1.0' } } }); const stats = await inspectModelFile(write('vrm1-nometa.vrm', glb)); expect(stats.warnings.join(';')).toContain('meta'); }); }); /* --------------------------------------------- 3. 16 根必需 humanoid 骨 */ /** 用 VRoid 命名覆盖驱动客户端所需的 16 根 */ const REQUIRED_NODE_NAMES = [ 'J_Bip_C_Hips', 'J_Bip_C_Spine', 'J_Bip_C_Neck', 'J_Bip_C_Head', 'J_Bip_L_UpperArm', 'J_Bip_L_LowerArm', 'J_Bip_L_Hand', 'J_Bip_R_UpperArm', 'J_Bip_R_LowerArm', 'J_Bip_R_Hand', 'J_Bip_L_UpperLeg', 'J_Bip_L_LowerLeg', 'J_Bip_L_Foot', 'J_Bip_R_UpperLeg', 'J_Bip_R_LowerLeg', 'J_Bip_R_Foot', ]; /** 串成一条链:所有骨骼都进 skin.joints,父子关系保证 depth 递增 */ function buildHumanoidDocument(names: string[]): unknown { return { asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }], nodes: names.map((name, index) => (index + 1 < names.length ? { name, children: [index + 1] } : { name })), skins: [{ joints: names.map((_, index) => index) }], }; } describe('必需 16 根 humanoid 骨', () => { it('16 根齐全 → drivable=true、missingRequiredBones=[]', async () => { const doc = buildHumanoidDocument(REQUIRED_NODE_NAMES); const path = write('humanoid-complete.gltf', Buffer.from(JSON.stringify(doc))); const stats = await inspectModelFile(path); expect(stats.container).toBe('gltf'); expect(stats.nodes).toBe(16); expect(stats.bones).toBe(16); expect(stats.humanoidMapped).toBe(16); expect(stats.missingRequiredBones).toEqual([]); expect(stats.drivable).toBe(true); expect(stats.boneTree.every((bone) => bone.humanoid !== null)).toBe(true); expect(stats.maxBoneDepth).toBe(15); expectIntegerStats(stats); }); it('缺 leftFoot → drivable=false、missingRequiredBones 只含 leftFoot', async () => { const names = REQUIRED_NODE_NAMES.filter((name) => name !== 'J_Bip_L_Foot'); const doc = buildHumanoidDocument(names); const path = write('humanoid-no-left-foot.gltf', Buffer.from(JSON.stringify(doc))); const stats = await inspectModelFile(path); expect(stats.drivable).toBe(false); expect(stats.missingRequiredBones).toEqual(['leftFoot']); expect(stats.humanoidMapped).toBe(15); }); }); /* --------------------------------------------------- 4. 非 glTF 容器降级 */ describe('非 glTF 容器', () => { it('假 fbx 字节 → container=unknown、有 warnings、不抛错', async () => { const fbx = Buffer.concat([ Buffer.from('Kaydara FBX Binary \x00', 'latin1'), Buffer.from([0x1a, 0x00, 0x00, 0x00, 0x00, 0x00]), ]); const stats = await inspectModelFile(write('fake.fbx', fbx)); expect(stats.container).toBe('unknown'); expect(stats.warnings.length).toBeGreaterThan(0); expect(stats.humanoidMapped).toBe(0); expect(stats.drivable).toBe(false); expect(stats.missingRequiredBones).toEqual([...HUMANOID_REQUIRED]); expectIntegerStats(stats); }); it('假 pmx 字节 → container=unknown、有 warnings', async () => { const pmx = Buffer.concat([Buffer.from('PMX ', 'ascii'), Buffer.from([0x00, 0x00, 0x00, 0x50])]); const stats = await inspectModelFile(write('fake.pmx', pmx)); expect(stats.container).toBe('unknown'); expect(stats.warnings.length).toBeGreaterThan(0); }); it('合法 JSON 但不是 glTF → container=unknown', async () => { const path = write('package.json', Buffer.from(JSON.stringify({ name: 'x', version: '1.0.0' }))); const stats = await inspectModelFile(path); expect(stats.container).toBe('unknown'); expect(stats.warnings.join(';')).toContain('glTF'); }); }); /* ------------------------------------------------------ 5. 截断与健壮性 */ describe('截断与健壮性', () => { it('截断一半的 GLB → 不抛错、container=unknown、warnings 非空', async () => { const full = basicGlb(); const truncated = full.subarray(0, Math.floor(full.length / 2)); const stats = await inspectModelFile(write('truncated.glb', Buffer.from(truncated))); expect(stats.container).toBe('unknown'); expect(stats.warnings.length).toBeGreaterThan(0); expectIntegerStats(stats); }); it('不是 GLB 且 JSON 截断 → 不抛错', async () => { const path = write('broken.gltf', Buffer.from('{"asset":{"version":"2.0"')); const stats = await inspectModelFile(path); expect(stats.container).toBe('unknown'); expect(stats.warnings.length).toBeGreaterThan(0); }); it('文件不存在 → 不抛错,记 warning', async () => { const stats = await inspectModelFile(join(dir, 'no-such-model.glb')); expect(stats.container).toBe('unknown'); expect(stats.warnings[0]).toContain('读取文件失败'); expect(stats.fileSize).toBe(0); expect(stats.bones).toBe(0); }); it('文件不存在但传了 fileSize → 仍返回该大小', async () => { const stats = await inspectModelFile(join(dir, 'no-such-model-2.glb'), 2048); expect(stats.fileSize).toBe(2048); expect(stats.warnings.length).toBeGreaterThan(0); }); it('缺 accessor / 空文档也不产生 NaN', async () => { const doc = { asset: { version: '2.0' }, meshes: [{ primitives: [{ attributes: { POSITION: 99 }, indices: 42 }, {}] }], accessors: [{ count: 'not-a-number' }], }; const stats = await inspectModelFile(write('bad-accessor.gltf', Buffer.from(JSON.stringify(doc)))); expect(stats.vertices).toBe(0); expect(stats.triangles).toBe(0); expect(stats.primitives).toBe(2); expect(stats.warnings.length).toBeGreaterThan(0); expectIntegerStats(stats); }); it('GLB magic 正确但版本不是 2 → 降级 unknown', async () => { const glb = basicGlb(); glb.writeUInt32LE(1, 4); const stats = await inspectModelFile(write('glb-v1.glb', glb)); expect(stats.container).toBe('unknown'); expect(stats.warnings.join(';')).toContain('版本'); }); }); /* -------------------------------------------------------- 7. morphTargets */ describe('morphTargets 统计', () => { it('所有 primitive 的 targets 数量之和', async () => { const glb = buildGlb({ asset: { version: '2.0' }, meshes: [ { primitives: [ { attributes: { POSITION: 0 }, targets: [{ POSITION: 1 }, { POSITION: 2 }] }, { attributes: { POSITION: 0 } }, ], }, ], accessors: [{ count: 6 }, { count: 6 }, { count: 6 }], }); const stats = await inspectModelFile(write('morph.glb', glb)); expect(stats.morphTargets).toBe(2); expect(stats.primitives).toBe(2); expect(stats.vertices).toBe(12); expect(stats.triangles).toBe(4); expectIntegerStats(stats); }); }); /* --------------------- 5.5 MMD / 中文骨名映射(T-WEB-013 骨骼重绑定) */ describe('MMD 与中文骨名映射(T-WEB-013)', () => { async function statsFor(names: string[], file: string): Promise { const doc = buildHumanoidDocument(names); return inspectModelFile(write(file, Buffer.from(JSON.stringify(doc)))); } it('归一化必须保留中日文,并把全角 ASCII 折成半角', () => { expect(normalizeBoneName('上半身')).toBe('上半身'); expect(normalizeBoneName('左ひじ')).toBe('左ひじ'); expect(normalizeBoneName('首')).toBe('首'); // MMD 的「足IK」是全角字母:折成半角后是 足ik,与「左足」精确匹配不上(安全) expect(normalizeBoneName('左足IK')).toBe('左足ik'); expect(normalizeBoneName('J_Bip_C_Hips')).toBe('jbipchips'); }); it('MMD 标准骨架名 → 16 根必需骨齐全、drivable=true(0/22 的修复点)', async () => { const stats = await statsFor( [ '全ての親', 'センター', '下半身', '上半身', '上半身2', '首', '頭', '左肩', '左腕', '左ひじ', '左手首', '右肩', '右腕', '右ひじ', '右手首', '左足', '左ひざ', '左足首', '右足', '右ひざ', '右足首', ], 'mmd-standard.gltf', ); expect(stats.missingRequiredBones).toEqual([]); expect(stats.drivable).toBe(true); expect(stats.humanoidMapped).toBeGreaterThanOrEqual(16); }); it('MMD 的 IK 骨(足IK / つま先IK)不得抢占 humanoid 骨', async () => { const stats = await statsFor( ['下半身', '上半身', '首', '頭', '左足IK', '左つま先IK'], 'mmd-ik.gltf', ); expect(stats.missingRequiredBones).toContain('leftUpperLeg'); expect(stats.missingRequiredBones).toContain('leftFoot'); expect(stats.drivable).toBe(false); }); it('中文骨名也能映射(臀部/头/脖子/左大腿/左脚)', async () => { const stats = await statsFor( [ '臀部', '脊椎', '胸部', '脖子', '头', '左大臂', '左小臂', '左手', '右大臂', '右小臂', '右手', '左大腿', '左小腿', '左脚', '右大腿', '右小腿', '右脚', ], 'chinese-names.gltf', ); expect(stats.missingRequiredBones).toEqual([]); expect(stats.drivable).toBe(true); }); }); /* ------------------------------- 6. 与客户端 humanoid_map.gd 的一致性 */ /** gd 文件在客户端仓库里;按「从 web-server 运行」为主,同时兼容从仓库根运行 */ function findHumanoidMap(): string { const candidates = [ join(__dirname, '..', '..', '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'), resolve(process.cwd(), '..', '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'), resolve(process.cwd(), '..', 'opendance-windows', 'src', 'model', 'humanoid_map.gd'), resolve(process.cwd(), 'opendance-windows', 'src', 'model', 'humanoid_map.gd'), ]; for (const candidate of candidates) { if (existsSync(candidate)) return candidate; } throw new Error(`找不到 opendance-windows/src/model/humanoid_map.gd,候选路径:\n${candidates.join('\n')}`); } const HUMANOID_MAP_PATH = findHumanoidMap(); const GD_SOURCE = readFileSync(HUMANOID_MAP_PATH, 'utf8'); function parseGdStringList(raw: string): string[] { return raw .split(',') .map((item) => item.trim()) .filter((item) => item.length > 0) .map((item) => item.replace(/^"|"$/g, '')); } /** 抽 `const NAME ... = [ "a", "b", ... ]` 形式的字符串数组 */ function extractGdStringList(source: string, constName: string): string[] { const match = new RegExp(`const\\s+${constName}\\s*:[^=]*=\\s*\\[([\\s\\S]*?)\\]`).exec(source); if (!match) throw new Error(`humanoid_map.gd 里找不到 ${constName}`); return parseGdStringList(match[1]); } /** 抽 `const CANDIDATES := { "canonical": ["a", "b"], ... }` */ function extractGdCandidates(source: string): Record { const match = /const\s+CANDIDATES\s*:=\s*\{([\s\S]*?)\n\}/.exec(source); if (!match) throw new Error('humanoid_map.gd 里找不到 CANDIDATES'); const out: Record = {}; const entry = /"([A-Za-z0-9_]+)"\s*:\s*\[([^\]]*)\]/g; let hit: RegExpExecArray | null; while ((hit = entry.exec(match[1])) !== null) { out[hit[1]] = parseGdStringList(hit[2]); } return out; } describe(`与客户端 humanoid_map.gd 一致(${HUMANOID_MAP_PATH})`, () => { it('CANDIDATES 逐键一致(键顺序与候选顺序都要一致)', () => { const gd = extractGdCandidates(GD_SOURCE); expect(Object.keys(gd).length).toBeGreaterThan(0); // 键顺序也要一致:服务端按 HUMANOID_BONES 顺序做映射与提示 expect(Object.keys(HUMANOID_CANDIDATES)).toEqual(Object.keys(gd)); for (const canonical of Object.keys(gd)) { expect(HUMANOID_CANDIDATES[canonical], `候选表 ${canonical} 与客户端不一致`).toEqual(gd[canonical]); } }); it('HUMANOID_BONES 与客户端一致,humanoidTotal 就是它的长度', () => { expect([...HUMANOID_BONES]).toEqual(extractGdStringList(GD_SOURCE, 'HUMANOID_BONES')); expect(HUMANOID_BONES.length).toBe(22); }); it('STRIPPABLE_PREFIXES 与客户端一致', () => { expect([...STRIPPABLE_PREFIXES]).toEqual(extractGdStringList(GD_SOURCE, 'STRIPPABLE_PREFIXES')); }); it('必需 16 根都能在客户端候选表里找到,且互不重复', () => { const gd = extractGdCandidates(GD_SOURCE); expect(HUMANOID_REQUIRED).toHaveLength(16); expect(new Set(HUMANOID_REQUIRED).size).toBe(16); for (const bone of HUMANOID_REQUIRED) { expect(Object.keys(gd)).toContain(bone); } }); }); describe('normalize / variants 行为与客户端一致', () => { it('VRoid / Mixamo / Bip01 三种前缀的展开与缩写', () => { expect(normalizeBoneName('J_Bip_L_UpperArm')).toBe('jbiplupperarm'); expect(boneNameVariants('J_Bip_L_UpperArm')).toEqual(['lupperarm', 'lupperarm', 'leftupperarm']); expect(normalizeBoneName('mixamorig:LeftArm')).toBe('mixamorigleftarm'); expect(boneNameVariants('mixamorig:LeftArm')).toEqual(['leftarm', 'larm', 'leftarm']); expect(boneNameVariants('Bip01 L Thigh')).toEqual(['lthigh', 'lthigh', 'leftthigh']); expect(boneNameVariants('J_Bip_C_Hips')).toEqual(['chips', 'chips', 'chips']); }); it('不剥离 J_Aim_ 等扭转辅助骨前缀(否则会撞上真正的骨骼)', () => { expect(boneNameVariants('J_Aim_L_UpperLeg')).toEqual([ 'jaimlupperleg', 'jaimlupperleg', 'jaimlupperleg', ]); const allCandidates = Object.values(HUMANOID_CANDIDATES).flat(); expect(allCandidates).not.toContain('jaimlupperleg'); }); });