import { describe, expect, it } from 'vitest'; import { parseKeypointNdjson, sequenceBounds } from '../admin/src/lib/keypoint-sequence'; import { BONE_SEGMENTS, KEYPOINT_NAMES, PARENT_TABLE } from '../admin/src/lib/skeleton-topology'; import { renderMotionThumbnail } from '../src/motions/motion-thumbnail'; import type { KeypointFrame } from '../src/preprocess/mediapipe33'; /** * 动作预览(T-WEB-014): * * 服务端:关键点序列 -> 骨架缩略图(列表展示); * * 管理端:NDJSON 解析 -> 3D 回放(播放/逐帧/时间轴/变速/循环)。 */ /** 造一帧:所有点可见,坐标沿索引铺开(便于验证解析与绘制) */ function makeFrame(index: number, visibility = 1): KeypointFrame { const points = Array.from({ length: 33 }, (_, point) => [ Math.cos((point / 33) * Math.PI * 2) * 0.5, (point / 33) * 1.6 - 0.8, Math.sin((point / 33) * Math.PI * 2) * 0.2, visibility, ]) as KeypointFrame['points']; return { frame: index, timeMs: index * (1000 / 30), points, angles: new Array(9).fill(0.5) }; } function ndjson(frames: KeypointFrame[]): string { return frames.map((frame) => JSON.stringify(frame)).join('\n') + '\n'; } /** 取 PNG 里偏离背景色的像素数(背景 [24,26,32]) */ function drawnPixels(png: Buffer): number { let count = 0; for (let i = 0; i + 2 < png.length; i += 1) { // 直接扫原始字节(IDAT 已压缩,这里只做粗略计数:非背景三连出现即算) if (png[i] === 96 && png[i + 1] === 165 && png[i + 2] === 250) count += 1; } return count; } describe('动作缩略图(服务端)', () => { it('关键点序列 -> 合法 PNG(含骨架像素)', () => { const png = renderMotionThumbnail([makeFrame(0), makeFrame(1), makeFrame(2)], { size: 128 }); expect(png).not.toBeNull(); // PNG magic expect(png!.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); expect(png!.length).toBeGreaterThan(200); }); it('空序列 / 全部低置信度 -> null(不抛错)', () => { expect(renderMotionThumbnail([])).toBeNull(); expect(renderMotionThumbnail([makeFrame(0, 0.01)])).toBeNull(); }); it('尺寸越界抛中文错误(参数非法与"读不到"区分开)', () => { expect(() => renderMotionThumbnail([makeFrame(0)], { size: Number.NaN })).toThrow(/尺寸非法/); expect(renderMotionThumbnail([makeFrame(0)], { size: 8 })).not.toBeNull(); // 夹到 32 }); }); describe('关键点 NDJSON 解析(管理端)', () => { it('解析帧数与帧间隔;数组尺寸固定为 33*3 / 33', () => { const parsed = parseKeypointNdjson(ndjson([makeFrame(0), makeFrame(1), makeFrame(2)])); expect(parsed.frames.length).toBe(3); expect(parsed.frames[0].positions.length).toBe(33 * 3); expect(parsed.frames[0].visibility.length).toBe(33); // 连续帧:帧间隔 = 1000/30 ms expect(parsed.frameIntervalMs).toBeCloseTo(1000 / 30, 3); expect(parsed.durationMs).toBeGreaterThan(0); }); it('坏行被跳过,好行照常解析(单行损坏不影响整段)', () => { const text = `{"frame":0,"timeMs":0,"points":[[0,0,0,1]],"angles":[]}\n坏行\n${JSON.stringify(makeFrame(1))}\n`; const parsed = parseKeypointNdjson(text); expect(parsed.frames.length).toBe(2); }); it('空文本 -> 0 帧(页面据此提示"先预处理")', () => { expect(parseKeypointNdjson('').frames.length).toBe(0); expect(parseKeypointNdjson('\n\n').frames.length).toBe(0); }); it('包围盒按整段计算(用于自动取景)', () => { const parsed = parseKeypointNdjson(ndjson([makeFrame(0), makeFrame(10)])); const bounds = sequenceBounds(parsed.frames); expect(bounds).not.toBeNull(); expect(bounds!.max[1]).toBeGreaterThan(bounds!.min[1]); }); }); describe('33 点拓扑(管理端绘制用)', () => { it('33 个点、父表长度一致、骨段索引合法', () => { expect(KEYPOINT_NAMES.length).toBe(33); expect(PARENT_TABLE.length).toBe(33); expect(BONE_SEGMENTS.length).toBeGreaterThan(20); for (const [child, parent] of BONE_SEGMENTS) { expect(child).toBeGreaterThanOrEqual(0); expect(child).toBeLessThan(33); expect(parent).toBeGreaterThanOrEqual(0); expect(parent).toBeLessThan(33); } }); });