Files
opendance-web/web-server/test/motion-preview.spec.ts
T
opendance aad60cbd2e feat(motions): 动作预览——骨架缩略图 + 管理端 3D 回放(契约 data-models 0.6.0)
按《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。
2026-09-17 00:21:09 +08:00

102 lines
4.3 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 { 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);
}
});
});