按《DSH 开发文档 v1.6》4.11(T-WEB-014,用户标注「重要」): 服务端: - 抽出公共 PNG 编码器 src/common/png.ts(模型缩略图改为复用,零原生依赖不变) - 新增 src/motions/motion-thumbnail.ts:关键点序列 -> 正视图骨架 PNG (复用 33 点 PARENT_TABLE 连线,包围盒按整段计算,低置信度点不画) - motions 增 thumbnail_asset_id(迁移 AddMotionThumbnail)+ AssetKind.MotionThumbnail - /preprocess 生成缩略图并落库,失败只记 warning;MotionDto 增 thumbnailUrl(契约 0.6.0) 管理端: - 新增 MotionPreview:懒加载 NDJSON -> 骨架线框 + 关节实例球体 支持播放/暂停、上一帧/下一帧、时间轴拖动、0.25x-2x 变速、循环 叠加帧号/当前时间/总时长,按整段包围盒自动取景 three.js 单独分包(lazy + Suspense),主包 241 kB 不涨 - 动作列表新增 40px 骨架缩略图列(点击开预览)+ 预览按钮 + 内联预览面板 - 新增 admin/src/lib/keypoint-sequence.ts 与 lib/skeleton-topology.ts 测试:新增 8 项;规模 18 文件 / 237 项。版本 0.5.0。
102 lines
4.3 KiB
TypeScript
102 lines
4.3 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|