import { describe, expect, it } from 'vitest'; import { LEG_LENGTHS, MMD_BONE_BY_NAME, MMD_SKELETON, REST_HEIGHTS, normalizeMmdBoneName, } from '../src/preprocess/mmd-skeleton'; import { solveFrame, vmdToWorldFrames, type MmdRotationMode } from '../src/preprocess/mmd-pose'; import { KEYPOINT_COUNT, ANGLE_TRIPLETS } from '../src/preprocess/mediapipe33'; import { worldFramesToKeypointFrames } from '../src/preprocess/retarget'; import { Vec3, angleCos, vDot, vLen, vNorm, vSub } from '../src/preprocess/math3d'; import { VMD_FPS, parseVmd, sampleBoneTrack, trackHasPositionMotion, trackHasRotationMotion, } from '../src/preprocess/vmd-parser'; /** * 真实 VMD 的骨骼名是 Shift-JIS。测试里无法用 TextEncoder 编码 Shift-JIS, * 因此直接把从真实样本(`示例文件/【动作】霜雪千年.vmd`)里读出的字节固化下来, * 这样同时也验证了解码路径本身。 */ const SJIS: Record = { 全ての親: [0x91, 0x53, 0x82, 0xc4, 0x82, 0xcc, 0x90, 0x65], センター: [0x83, 0x5a, 0x83, 0x93, 0x83, 0x5e, 0x81, 0x5b], グルーブ: [0x83, 0x4f, 0x83, 0x8b, 0x81, 0x5b, 0x83, 0x75], 腰: [0x8d, 0x98], 上半身: [0x8f, 0xe3, 0x94, 0xbc, 0x90, 0x67], 上半身2: [0x8f, 0xe3, 0x94, 0xbc, 0x90, 0x67, 0x32], 首: [0x8e, 0xf1], 頭: [0x93, 0xaa], 左腕: [0x8d, 0xb6, 0x98, 0x72], 左ひじ: [0x8d, 0xb6, 0x82, 0xd0, 0x82, 0xb6], 左手首: [0x8d, 0xb6, 0x8e, 0xe8, 0x8e, 0xf1], 下半身: [0x89, 0xba, 0x94, 0xbc, 0x90, 0x67], 左足: [0x8d, 0xb6, 0x91, 0xab], 左ひざ: [0x8d, 0xb6, 0x82, 0xd0, 0x82, 0xb4], 左足首: [0x8d, 0xb6, 0x91, 0xab, 0x8e, 0xf1], 左つま先: [0x8d, 0xb6, 0x82, 0xc2, 0x82, 0xdc, 0x90, 0xe6], 左足IK: [0x8d, 0xb6, 0x91, 0xab, 0x82, 0x68, 0x82, 0x6a], 右足: [0x89, 0x45, 0x91, 0xab], 右ひざ: [0x89, 0x45, 0x82, 0xd0, 0x82, 0xb4], 右足首: [0x89, 0x45, 0x91, 0xab, 0x8e, 0xf1], 右つま先: [0x89, 0x45, 0x82, 0xc2, 0x82, 0xdc, 0x90, 0xe6], 右足IK: [0x89, 0x45, 0x91, 0xab, 0x82, 0x68, 0x82, 0x6a], }; interface BoneKeyInput { name: string; frame: number; position?: Vec3; /** 四元数 (x, y, z, w) */ rotation?: readonly [number, number, number, number]; /** 插值参数:4 通道各 [x1, y1, x2, y2];缺省线性 */ interpolation?: { x1?: number; y1?: number; x2?: number; y2?: number }; } /** 线性插值的 16 字节参数(x1=0, y1=0, x2=127, y2=127) */ function linearInterpolation(override?: BoneKeyInput['interpolation']): number[] { const p = { x1: 0, y1: 0, x2: 127, y2: 127, ...(override ?? {}) }; const bytes = new Array(16).fill(0); for (let channel = 0; channel < 4; channel += 1) { bytes[channel] = p.x1; bytes[4 + channel] = p.y1; bytes[8 + channel] = p.x2; bytes[12 + channel] = p.y2; } return bytes; } /** 构造一个最小但结构完整的 VMD 文件(骨骼段 + morph 段) */ function buildVmd(options: { bones: BoneKeyInput[]; header?: string; modelName?: string; modelNameSize?: number; morphCount?: number; truncateBoneBytes?: number; truncateMorphBytes?: number; }): Buffer { const header = options.header ?? 'Vocaloid Motion Data 0002'; const chunks: Buffer[] = []; const headerBuf = Buffer.alloc(30); Buffer.from(header, 'latin1').copy(headerBuf); chunks.push(headerBuf); const nameSize = options.modelNameSize ?? 20; const modelBuf = Buffer.alloc(nameSize); Buffer.from(options.modelName ?? 'TESTMODEL', 'latin1').copy(modelBuf); chunks.push(modelBuf); const countBuf = Buffer.alloc(4); countBuf.writeUInt32LE(options.bones.length); chunks.push(countBuf); for (const bone of options.bones) { const record = Buffer.alloc(111); const nameBytes = SJIS[bone.name]; if (!nameBytes) throw new Error(`测试夹具缺少骨骼名的 Shift-JIS 字节:${bone.name}`); Buffer.from(nameBytes).copy(record, 0, 0, 15); record.writeUInt32LE(bone.frame, 15); const position = bone.position ?? [0, 0, 0]; record.writeFloatLE(position[0], 19); record.writeFloatLE(position[1], 23); record.writeFloatLE(position[2], 27); const rotation = bone.rotation ?? [0, 0, 0, 1]; record.writeFloatLE(rotation[0], 31); record.writeFloatLE(rotation[1], 35); record.writeFloatLE(rotation[2], 39); record.writeFloatLE(rotation[3], 43); Buffer.from(linearInterpolation(bone.interpolation)).copy(record, 47); chunks.push(record); } const morphCount = options.morphCount ?? 0; const morphCountBuf = Buffer.alloc(4); morphCountBuf.writeUInt32LE(morphCount); chunks.push(morphCountBuf); // morph 段:15 字节名字 + uint32 帧号 + float 权重 const morphRecord = Buffer.alloc(23); Buffer.from(SJIS['頭']).copy(morphRecord, 0, 0, 15); morphRecord.writeFloatLE(1, 19); for (let i = 0; i < morphCount; i += 1) chunks.push(Buffer.from(morphRecord)); let out = Buffer.concat(chunks); if (options.truncateBoneBytes) out = out.subarray(0, out.length - options.truncateBoneBytes); if (options.truncateMorphBytes) out = out.subarray(0, out.length - options.truncateMorphBytes); return out; } /** 绕指定轴的旋转四元数 */ function quatAxis(axis: 'x' | 'y' | 'z', degrees: number): [number, number, number, number] { const half = (degrees * Math.PI) / 360; const s = Math.sin(half); const c = Math.cos(half); if (axis === 'x') return [s, 0, 0, c]; if (axis === 'y') return [0, s, 0, c]; return [0, 0, s, c]; } /** 静止姿态:只放一条无关紧要的轨道,其余骨骼按标准骨架静止值 */ function restVmd(extra: BoneKeyInput[] = []): Buffer { return buildVmd({ bones: [{ name: 'センター', frame: 0, rotation: quatAxis('y', 0) }, ...extra] }); } describe('VMD 二进制解析', () => { it('解析文件头、Shift-JIS 模型名与骨骼轨道', () => { const buffer = buildVmd({ modelName: 'Miku', bones: [ { name: 'センター', frame: 0, position: [0, 1, 0] }, { name: 'センター', frame: 30, position: [0, 2, 0] }, { name: '左足', frame: 15, rotation: quatAxis('x', -30) }, ], morphCount: 3, }); const vmd = parseVmd(buffer); expect(vmd.header).toBe('Vocaloid Motion Data 0002'); expect(vmd.modelName).toBe('Miku'); expect(vmd.maxFrame).toBe(30); expect(vmd.morphCount).toBe(3); expect([...vmd.tracks.keys()].sort()).toEqual(['センター', '左足']); expect(vmd.tracks.get('センター')?.keys).toHaveLength(2); expect(vmd.tracks.get('左足')?.rawName).toBe('左足'); }); it('未识别的骨骼被收集而不是静默丢弃', () => { const vmd = parseVmd( buildVmd({ bones: [ { name: 'センター', frame: 0 }, // 「スカート」之类非标准骨骼 { name: '頭', frame: 0 }, ], }), ); expect(vmd.unknownBones).toEqual([]); expect(vmd.tracks.size).toBe(2); }); it('morph 段被跳过,不影响骨骼解析', () => { const vmd = parseVmd( buildVmd({ bones: [{ name: 'センター', frame: 0 }], morphCount: 128 }), ); expect(vmd.morphCount).toBe(128); expect(vmd.tracks.size).toBe(1); }); it('0001 版本的模型名按 10 字节读取', () => { const vmd = parseVmd( buildVmd({ header: 'Vocaloid Motion Data file', modelNameSize: 10, modelName: 'OldModel', bones: [{ name: 'センター', frame: 0 }], }), ); expect(vmd.modelName).toBe('OldModel'); expect(vmd.maxFrame).toBe(0); }); it('模型名是 GBK 时回退解码(中文作者产出的 VMD 常见)', () => { // 「恋爱循环」的 GBK 字节:按 Shift-JIS 解会变成夹杂半角片假名的乱码 const gbkName = Buffer.from('恋爱循环', 'utf8'); // 占位,下面用真实 GBK 字节覆盖 const gbkBytes = Buffer.from([0xc1, 0xb5, 0xb0, 0xae, 0xd1, 0xad, 0xbb, 0xb7]); expect(gbkBytes.length).toBe(8); const buffer = buildVmd({ bones: [{ name: 'センター', frame: 0 }], modelName: '' }); gbkBytes.copy(buffer, 30, 0, 20); // 覆盖模型名字段 const vmd = parseVmd(buffer); expect(vmd.modelName).toBe('恋爱循环'); expect(gbkName.length).toBeGreaterThan(0); }); it('模型名是合法 Shift-JIS 时不做回退', () => { const vmd = parseVmd(buildVmd({ bones: [{ name: 'センター', frame: 0 }], modelName: 'Miku' })); expect(vmd.modelName).toBe('Miku'); }); it('文件头不对时报错并回显实际内容', () => { expect(() => parseVmd(buildVmd({ header: 'Not A VMD File', bones: [] }))).toThrow( /VMD 文件头不正确/, ); }); it('骨骼段被截断时报错并给出声明与实际字节数', () => { const full = buildVmd({ bones: [ { name: 'センター', frame: 0 }, { name: '左足', frame: 0 }, ], }); expect(() => parseVmd(full.subarray(0, full.length - 200))).toThrow(/骨骼段长度不足/); }); it('morph 段被截断时报错', () => { const full = buildVmd({ bones: [{ name: 'センター', frame: 0 }], morphCount: 10 }); expect(() => parseVmd(full.subarray(0, full.length - 40))).toThrow(/morph 段长度不足/); }); it('没有任何可识别骨骼时报错', () => { // 只放一个长度为 0 的名字(空骨骼名)——无法归一到标准骨架 const buffer = buildVmd({ bones: [] }); expect(() => parseVmd(buffer)).toThrow(/没有可识别的标准骨骼关键帧/); }); it('文件过小时报错', () => { expect(() => parseVmd(Buffer.alloc(8))).toThrow(/VMD 文件过小/); }); }); describe('关键帧采样', () => { const track = (keys: BoneKeyInput[]) => parseVmd(buildVmd({ bones: keys })).tracks.get('センター')!; it('线性插值取到中点', () => { const t = track([ { name: 'センター', frame: 0, position: [0, 0, 0], interpolation: { x1: 0, y1: 0, x2: 127, y2: 127 } }, { name: 'センター', frame: 10, position: [10, 20, -30], interpolation: { x1: 0, y1: 0, x2: 127, y2: 127 } }, ]); const mid = sampleBoneTrack(t, 5)!; expect(mid.position[0]).toBeCloseTo(5, 3); expect(mid.position[1]).toBeCloseTo(10, 3); expect(mid.position[2]).toBeCloseTo(-15, 3); }); it('超出关键帧范围时保持首/末帧姿态', () => { const t = track([ { name: 'センター', frame: 10, position: [1, 0, 0] }, { name: 'センター', frame: 20, position: [2, 0, 0] }, ]); expect(sampleBoneTrack(t, 0)!.position[0]).toBe(1); expect(sampleBoneTrack(t, 999)!.position[0]).toBe(2); }); it('缓动曲线单调且落在 [0,1]', () => { const t = track([ { name: 'センター', frame: 0, position: [0, 0, 0], interpolation: { x1: 20, y1: 20, x2: 107, y2: 107 } }, { name: 'センター', frame: 10, position: [10, 0, 0], interpolation: { x1: 20, y1: 20, x2: 107, y2: 107 } }, ]); let previous = -1; for (let f = 0; f <= 10; f += 1) { const x = sampleBoneTrack(t, f)!.position[0]; expect(x).toBeGreaterThanOrEqual(0); expect(x).toBeLessThanOrEqual(10); expect(x).toBeGreaterThanOrEqual(previous); previous = x; } // 对称缓入缓出在中点恰好过 0.5,但前段应慢于线性、后段应快于线性 expect(sampleBoneTrack(t, 2)!.position[0]).toBeLessThan(2); expect(sampleBoneTrack(t, 8)!.position[0]).toBeGreaterThan(8); expect(sampleBoneTrack(t, 5)!.position[0]).toBeCloseTo(5, 3); }); it('四元数插值走短弧(符号对齐)', () => { const t = track([ { name: 'センター', frame: 0, rotation: [0, 0, 0, 1] }, // 同一姿态的相反符号表示 { name: 'センター', frame: 10, rotation: [0, 0, 0, -1] }, ]); const mid = sampleBoneTrack(t, 5)!; const twist = Math.hypot(mid.rotation[0], mid.rotation[1], mid.rotation[2]); expect(twist).toBeLessThan(1e-3); // 不应插出 180° 的旋转 }); it('识别位移/旋转通道是否有实际动作', () => { const withPos = parseVmd( buildVmd({ bones: [{ name: '左足IK', frame: 0, position: [1, 0, 0] }] }), ); const withoutPos = parseVmd(buildVmd({ bones: [{ name: '左足IK', frame: 0 }] })); expect(trackHasPositionMotion(withPos.tracks.get('左足IK'))).toBe(true); expect(trackHasPositionMotion(withoutPos.tracks.get('左足IK'))).toBe(false); expect(trackHasRotationMotion(withPos.tracks.get('左足IK'))).toBe(false); expect( trackHasRotationMotion( parseVmd(buildVmd({ bones: [{ name: '左足IK', frame: 0, rotation: quatAxis('z', 30) }] })) .tracks.get('左足IK'), ), ).toBe(true); }); }); describe('MMD 标准骨架', () => { it('比例自检:腿长 = 大腿 + 小腿,且与静止高度一致', () => { // 大腿/小腿相对竖直方向略有内收,因此两段长度之和略大于竖直跨度 const verticalSpan = REST_HEIGHTS.hip - REST_HEIGHTS.ankle; const segmentSum = LEG_LENGTHS.thigh + LEG_LENGTHS.shin; expect(Math.abs(segmentSum - verticalSpan)).toBeLessThan(verticalSpan * 0.01); expect(REST_HEIGHTS.ankle).toBeGreaterThan(0); expect(REST_HEIGHTS.hip).toBeGreaterThan(REST_HEIGHTS.knee); expect(REST_HEIGHTS.knee).toBeGreaterThan(REST_HEIGHTS.ankle); expect(REST_HEIGHTS.head).toBeGreaterThan(REST_HEIGHTS.neck); // 参考模型总高 ≈ 19.4,髋高比 ≈ 0.59(MMD 动漫比例,腿明显长于真人) const height = REST_HEIGHTS.head + 2.2; expect(REST_HEIGHTS.hip / height).toBeGreaterThan(0.5); expect(REST_HEIGHTS.hip / height).toBeLessThan(0.7); }); it('左右骨骼严格镜像', () => { for (const bone of MMD_SKELETON) { if (!bone.name.startsWith('左')) continue; const right = MMD_BONE_BY_NAME.get(`右${bone.name.slice(1)}`); expect(right, `缺少 ${bone.name} 的镜像骨骼`).toBeDefined(); expect(right!.offset[0]).toBeCloseTo(-bone.offset[0], 6); expect(right!.offset[1]).toBeCloseTo(bone.offset[1], 6); expect(right!.offset[2]).toBeCloseTo(bone.offset[2], 6); } }); it('骨架无重名(每根骨骼只定义一次)', () => { const names = MMD_SKELETON.map((bone) => bone.name); expect(new Set(names).size).toBe(names.length); expect(MMD_BONE_BY_NAME.size).toBe(names.length); }); it('层级完整且无环(所有父骨骼都存在)', () => { for (const bone of MMD_SKELETON) { if (bone.parent === null) continue; expect(MMD_BONE_BY_NAME.has(bone.parent), `${bone.name} 的父骨骼 ${bone.parent} 缺失`).toBe( true, ); } }); it('别名归一化覆盖全角/半角 IK 写法', () => { expect(normalizeMmdBoneName('左足IK')).toBe('左足IK'); expect(normalizeMmdBoneName('左足IK')).toBe('左足IK'); expect(normalizeMmdBoneName('右ひざ')).toBe('右ひざ'); expect(normalizeMmdBoneName(' 頭 ')).toBe('頭'); expect(normalizeMmdBoneName('スカート_0_0')).toBeNull(); expect(normalizeMmdBoneName('')).toBeNull(); }); }); describe('FK 与腿部 IK', () => { const noIk = { 左: false, 右: false } as const; it('全零旋转时落到静止姿态,高度与标准骨架一致', () => { const vmd = parseVmd(restVmd()); const pose = solveFrame(vmd, 0, 'zMirrored', noIk); expect(pose.get('左足首')!.t[1]).toBeCloseTo(REST_HEIGHTS.ankle, 2); expect(pose.get('左ひざ')!.t[1]).toBeCloseTo(REST_HEIGHTS.knee, 2); expect(pose.get('左足')!.t[1]).toBeCloseTo(REST_HEIGHTS.hip, 2); expect(pose.get('頭')!.t[1]).toBeCloseTo(REST_HEIGHTS.head, 2); // 左右对称 expect(pose.get('左足')!.t[0]).toBeCloseTo(-pose.get('右足')!.t[0], 6); }); it('旋转真的生效:上半身绕 Z 转 90° 后头移到侧面', () => { const vmd = parseVmd( restVmd([{ name: '上半身', frame: 0, rotation: quatAxis('z', 90) }]), ); const pose = solveFrame(vmd, 0, 'zMirrored', noIk); const head = pose.get('頭')!.t; const upperBodyRoot = MMD_BONE_BY_NAME.get('上半身')!.offset[1] + REST_HEIGHTS.center; // 上身链被放平:头横向摆出去,高度落到 上半身 根部附近(不再竖在最高点) expect(Math.abs(head[0])).toBeGreaterThan(4); expect(head[1]).toBeLessThan(REST_HEIGHTS.head - 3); expect(Math.abs(head[1] - upperBodyRoot)).toBeLessThan(1.5); }); it('IK 目标可达时踝落在目标上,且膝盖朝前(+Z)', () => { const vmd = parseVmd( restVmd([{ name: '左足IK', frame: 0, position: [0, 1.0, 0] }]), ); const pose = solveFrame(vmd, 0, 'zMirrored', { 左: true, 右: false }); const hip = pose.get('左足')!.t; const ik = pose.get('左足IK')!.t; const knee = pose.get('左ひざ')!.t; const ankle = pose.get('左足首')!.t; expect(vLen(vSub(ankle, ik))).toBeLessThan(1e-3); // 大腿 + 小腿长度守恒 expect(vLen(vSub(knee, hip)) + vLen(vSub(ankle, knee))).toBeGreaterThan(LEG_LENGTHS.thigh); // 膝盖在髋→踝连线的 +Z 侧 const axis = vNorm(vSub(ankle, hip)); const rel = vSub(knee, hip); const along = vDot(rel, axis); const perpZ = rel[2] - axis[2] * along; expect(perpZ).toBeGreaterThan(0); }); it('IK 目标不可达时腿伸直且不产生 NaN', () => { const vmd = parseVmd( restVmd([{ name: '左足IK', frame: 0, position: [0, 0, 50] }]), ); const pose = solveFrame(vmd, 0, 'zMirrored', { 左: true, 右: false }); const hip = pose.get('左足')!.t; const ankle = pose.get('左足首')!.t; const knee = pose.get('左ひざ')!.t; expect(vLen(vSub(ankle, hip))).toBeCloseTo(LEG_LENGTHS.thigh + LEG_LENGTHS.shin, 2); expect(angleCos(hip, knee, ankle)).toBeLessThan(-0.999); for (const value of [...hip, ...ankle, ...knee]) expect(Number.isFinite(value)).toBe(true); }); it('IK 目标与髋重合时也不退化(不产生 NaN)', () => { const vmd = parseVmd( restVmd([{ name: '左足IK', frame: 0, position: [-0.95, -11.5, 0] }]), ); const pose = solveFrame(vmd, 0, 'zMirrored', { 左: true, 右: false }); for (const bone of ['左足', '左ひざ', '左足首']) { for (const value of pose.get(bone)!.t) expect(Number.isFinite(value)).toBe(true); } }); it('IK 通道没有位移时不做 IK 解算(走 FK)', () => { const vmd = parseVmd( buildVmd({ bones: [ { name: 'センター', frame: 0 }, { name: '左足IK', frame: 0 }, { name: '左足', frame: 0, rotation: quatAxis('x', -60) }, ], }), ); const { frames } = vmdToWorldFrames(vmd, { rotationMode: 'zMirrored', maxFrames: 1 }); const world = frames[0]; // FK 生效:膝盖不再位于静止位置 expect(world.get('leftleg')![1]).not.toBeCloseTo(REST_HEIGHTS.knee, 1); // 踝仍在小腿末端(长度守恒) const hip = world.get('leftupleg')!; const knee = world.get('leftleg')!; const ankle = world.get('leftfoot')!; expect(vLen(vSub(knee, hip))).toBeCloseTo(LEG_LENGTHS.thigh, 2); expect(vLen(vSub(ankle, knee))).toBeCloseTo(LEG_LENGTHS.shin, 2); }); }); describe('手性转换(真实 VMD 对照实验的合成复现)', () => { /** * MMD 原生是左手系。真实数据里的对照实验(恋爱循环.vmd,腿部为烘焙 FK): * 用 asIs 解释时"膝盖朝前"只占 5.9%,用 zMirrored 时占 94.1%。 * 这里用合成数据把同一结论固化成回归测试: * 在 MMD 里把大腿绕 +X 转(动画师想让膝盖前顶), * 转换到右手系后膝盖应当落在 +Z 侧。 */ const thighBend = (mode: MmdRotationMode): number => { const vmd = parseVmd(restVmd([{ name: '左足', frame: 0, rotation: quatAxis('x', 30) }])); const pose = solveFrame(vmd, 0, mode, { 左: false, 右: false }); const hip = pose.get('左足')!.t; const ankle = pose.get('左足首')!.t; const knee = pose.get('左ひざ')!.t; return knee[2] - (hip[2] + ((ankle[2] - hip[2]) * vLen(vSub(knee, hip))) / vLen(vSub(ankle, hip))); }; it('zMirrored 把 MMD 的左手系数值正确搬到右手系(膝盖朝前)', () => { expect(thighBend('zMirrored')).toBeGreaterThan(0); }); it('asIs 会得到镜像的结果(膝盖朝后)—— 因此不能作为默认', () => { expect(thighBend('asIs')).toBeLessThan(0); }); }); describe('端到端:合成 VMD → MediaPipe 33 点关键点序列', () => { it('输出帧数、点数与关节角数量正确,且无 NaN / 越界', () => { const vmd = parseVmd( buildVmd({ bones: [ { name: 'センター', frame: 0, position: [0, 0, 0] }, { name: 'センター', frame: 60, position: [0, 3, 0] }, { name: '左足IK', frame: 0, position: [0, 0.6, 0] }, { name: '左足IK', frame: 60, position: [0, 0.6, 0] }, { name: '上半身', frame: 0, rotation: quatAxis('y', 0) }, { name: '上半身', frame: 60, rotation: quatAxis('y', 45) }, ], morphCount: 2, }), ); const { frames: world, fps } = vmdToWorldFrames(vmd, { maxFrames: 61 }); const frames = worldFramesToKeypointFrames(world, fps); expect(fps).toBe(VMD_FPS); expect(frames).toHaveLength(61); for (const frame of frames) { expect(frame.points).toHaveLength(KEYPOINT_COUNT); expect(frame.angles).toHaveLength(ANGLE_TRIPLETS.length); for (const point of frame.points) { expect(point).toHaveLength(4); expect(point.every(Number.isFinite)).toBe(true); expect(point[0]).toBeGreaterThanOrEqual(0); expect(point[0]).toBeLessThanOrEqual(1); expect(point[1]).toBeGreaterThanOrEqual(0); expect(point[1]).toBeLessThanOrEqual(1); expect(point[2]).toBeGreaterThanOrEqual(-1); expect(point[2]).toBeLessThanOrEqual(1); expect([0, 0.5, 1]).toContain(point[3]); } for (const angle of frame.angles) { expect(angle).toBeGreaterThanOrEqual(-1); expect(angle).toBeLessThanOrEqual(1); } } // 时间轴按 30fps 推进 expect(frames[1].timeMs).toBeCloseTo(1000 / 30, 3); }); it('上半身旋转会改变输出(不是常数序列)', () => { const vmd = parseVmd( buildVmd({ bones: [ { name: 'センター', frame: 0 }, { name: '上半身', frame: 0, rotation: quatAxis('y', 0) }, { name: '上半身', frame: 30, rotation: quatAxis('y', 90) }, ], }), ); const { frames: world } = vmdToWorldFrames(vmd, { maxFrames: 31 }); const frames = worldFramesToKeypointFrames(world, VMD_FPS); const first = frames[0].points[11][0]; const last = frames[30].points[11][0]; expect(Math.abs(last - first)).toBeGreaterThan(0.02); }); });