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:
@@ -1,7 +1,8 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
|
||||
import { encodePngRgb } from '../common/png';
|
||||
import { dirname, resolve as resolvePath } from 'node:path';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
|
||||
/**
|
||||
* 服务端模型缩略图生成(纯 TypeScript 软件光栅化)。
|
||||
@@ -1355,67 +1356,6 @@ function decodePng(buffer: Buffer): DecodedImage | null {
|
||||
return { width, height, rgba: out };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PNG 编码(8 位 RGB、无隔行、filter=0;不引第三方库)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
function crc32(buffer: Buffer): number {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8);
|
||||
}
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
const typeBuffer = Buffer.from(type, 'ascii');
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length, 0);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
|
||||
return Buffer.concat([length, typeBuffer, data, crc]);
|
||||
}
|
||||
|
||||
/** 把 RGB 像素编码为 PNG(每行前置 filter 字节 0,整块 deflate) */
|
||||
function encodePng(rgb: Buffer, width: number, height: number): Buffer {
|
||||
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 2; // color type: truecolor RGB
|
||||
ihdr[10] = 0; // compression
|
||||
ihdr[11] = 0; // filter method
|
||||
ihdr[12] = 0; // interlace: none
|
||||
|
||||
const stride = width * 3;
|
||||
const raw = Buffer.alloc(height * (stride + 1));
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
raw[y * (stride + 1)] = 0; // filter type 0
|
||||
rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
||||
}
|
||||
|
||||
return Buffer.concat([
|
||||
signature,
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('IDAT', deflateSync(raw)),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 对外入口
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1476,7 +1416,7 @@ export async function renderModelThumbnail(
|
||||
if (output.drawn === 0) return null;
|
||||
|
||||
return {
|
||||
png: encodePng(output.pixels, size, size),
|
||||
png: encodePngRgb(output.pixels, size, size),
|
||||
width: size,
|
||||
height: size,
|
||||
trianglesDrawn: output.drawn,
|
||||
|
||||
Reference in New Issue
Block a user