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。
This commit is contained in:
opendance
2026-09-17 00:21:09 +08:00
parent 9eae6f3e43
commit aad60cbd2e
25 changed files with 1158 additions and 89 deletions
+101
View File
@@ -0,0 +1,101 @@
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);
}
});
});