Files
opendance-web/web-server/test/mmd-vmd.spec.ts
T
zijunchiang bba5bcf59e feat(web): 端口分离 + VMD 动作支持;契约升至 0.2.0
需求 1:管理端端口与软件 API 端口分离
- 软件 API 端口 PORT(默认 3000)只提供 /api、/health、/static、/contracts,
  不提供管理页面(未命中路径 404);管理端端口 ADMIN_PORT(默认 3001)
  提供管理页面与全部接口;ADMIN_HOST 可单独限制管理端来源
- main.ts 同进程监听两个端口,用 req.socket.localPort 区分端口行为;
  ADMIN_PORT=PORT 为单端口兼容模式(只创建一个 server,无 EADDRINUSE)
- 端口非法在生产环境中止启动;ADMIN_PORT=PORT 只 warn
- 启动日志分别打印两个端口的访问地址

需求 2:动作数据支持 VMD(MikuMikuDance)
- vmd-parser.ts:二进制解析(30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼
  关键帧 + morph 段)、MMD 贝塞尔插值、GBK 模型名回退、固定 30fps
- mmd-skeleton.ts:标准 MMD 骨架;静止比例从参考 PMX 骨骼段实测得到
  (足首 1.43 / 膝 6.57 / 髋 11.50 / 首 16.21 / 頭 17.16,腿长 10.09)
- mmd-pose.ts:FK + 两骨骼解析腿部 IK(膝盖朝正面,不可达收敛为伸直)
  + 左手系→右手系 Z 镜像(由真实数据对照实验确定:94.1% vs 5.9%)
- retarget.ts:抽出 worldFramesToKeypointFrames(),BVH 与 VMD 共用重定向
- MotionFormat 增 vmd、扩展名映射增 .vmd;管理端上传支持 .vmd
- scripts/inspect-vmd.mjs:上传前自查工具

契约:rest-api / data-models / model-formats → 0.2.0(另两库已同步逐字节一致)

验证:85 项单测通过(VMD 31 项);契约与 29 条路由一致;typecheck/build 通过;
真实样本经 HTTP 端到端:霜雪千年 2901 帧(IK 腿)、恋爱循环 4604 帧(FK 腿)
均 preprocessState=done、0 NaN、0 越界(测试数据已从数据库清理)。
2026-09-15 08:47:37 +08:00

557 lines
22 KiB
TypeScript
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.
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<string, number[]> = {
全ての親: [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<number>(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);
});
});