Files
opendance-web/web-server/test/model-thumbnail.spec.ts
T
zijunchiang 53bf182476 feat(models): 发布 v0.2.0 —— GLB/VRM 主力格式、自动缩略图与分析、模型审核与骨骼预览
需求 1:系统确定以 GLB / VRM 为模型主力格式
- 服务端新增支持库:
  - model-inspector.ts:解析 GLB/VRM 的面数、顶点、材质、贴图、形变目标、
    **骨骼树(名称/层级/是否蒙皮/humanoid 映射/子节点数)**、humanoid 覆盖率与可驱动性
  - model-thumbnail.ts:**纯 TS 软件光栅化**(z-buffer + Lambert + 完整蒙皮矩阵)
    生成 512×512 PNG 缩略图,零原生依赖、不装 headless-gl
  - vrm-writer.ts:GLB → VRM 1.0(写入 VRMC_vrm 扩展与 humanoid 映射)
  - gltf-packer.ts:glTF(.gltf + .bin + 贴图)→ 单文件 GLB
- humanoid 候选表与 Godot 客户端 humanoid_map.gd **逐字对齐**,并有单测断言两边一致
  (服务端判定的"可驱动" = 客户端实际能否驱动)

需求 2:模型管理
- .glb / .vrm / .gltf **直接导入**(不重编码,GLB 含 VRM 扩展会自动识别为 VRM)
- .pmx / .fbx 服务端转换:assimp → .glb →(失败)→ .gltf 后自行打包成 GLB
  →(MODEL_CONVERT_FORMAT=vrm 时)补 VRMC_vrm 产出 VRM 1.0;每步 assimp info 回读校验
- .zip **自动搜索包内可用模型**:.vrm → .glb → .gltf(直接导入)优先,
  其次 .pmx → .fbx(转换);同优先级内按"指名字 → 目录最浅 → 文件最大"
- 入库后自动分析(models.stats)与生成缩略图(models.thumbnail_asset_id),
  失败均不阻断入库;管理端列表显示小缩略图、预览放开到 vrm/glb/gltf

需求 3:审核功能
- 上传的模型状态**一律 pending**(不再自动发布、不再自动设默认)
- 审核界面新增模型面板:体积、面数、顶点、材质、贴图、骨骼、形变目标等指标,
  humanoid 覆盖进度、可驱动性结论(缺哪些必需骨骼)、warnings,以及
  **骨骼详细预览**(层级缩进、蒙皮/Humanoid 徽章、子节点数、搜索保留祖先链、
  仅看蒙皮、限高滚动 + 只渲染前 400 行)
- 审核通过可带 setPrimary=true 一并设为默认模型(此时才写源码种子);
  POST /api/models/:id/primary 仅允许 published(否则 409)
- scripts/import-default-model.mjs 改为"上传 → 审核通过 + 设默认"流程

其它:
- models 新增 stats 列(迁移 AddModelStats1789460000000,已在真实库执行)
- 契约升至 rest-api/data-models/model-formats 0.4.0、database 0.3.0(另两库已同步)
- 版本号 0.1.0 → 0.2.0(package.json ×2 + lockfile + project.yaml)
- 文档:README.txt / DSH.md / project.yaml / tasks 005·007·010 全面更新;
  顶层开发书更新为 v1.4

验证:168 项单测通过(10 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实端到端:MMD 压缩包 → GLB + 分析(面数 9071/骨骼 238/humanoid 0-22) + 缩略图(15KB PNG);
GLB 直接导入 sha256 与源文件一致;审核通过后 defaultModelId 由 VRM 变为该模型
2026-09-15 20:37:53 +08:00

413 lines
16 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 { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { inflateSync } from 'node:zlib';
import { describe, expect, it } from 'vitest';
import { renderModelThumbnail } from '../src/models/model-thumbnail';
/**
* 缩略图生成测试。
* 所有夹具都在代码里构造,不联网、不依赖仓库外的文件。
*/
const dir = mkdtempSync(join(tmpdir(), 'opendance-thumb-'));
function write(name: string, content: Buffer): string {
const path = join(dir, name);
writeFileSync(path, content);
return path;
}
/** 构造 GLB:12 字节头 + JSON chunk(空格补齐)+ 可选 BIN chunk(0 补齐) */
function buildGlb(json: unknown, bin?: Buffer): Buffer {
const jsonRaw = Buffer.from(JSON.stringify(json), 'utf8');
const jsonPad = (4 - (jsonRaw.length % 4)) % 4;
const jsonChunk = Buffer.concat([jsonRaw, Buffer.alloc(jsonPad, 0x20)]);
const chunks: Buffer[] = [];
const header = Buffer.alloc(12);
header.write('glTF', 0, 'ascii');
header.writeUInt32LE(2, 4);
chunks.push(header);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(jsonChunk.length, 0);
jsonHeader.writeUInt32LE(0x4e4f534a, 4);
chunks.push(jsonHeader, jsonChunk);
if (bin) {
const binPad = (4 - (bin.length % 4)) % 4;
const binChunk = Buffer.concat([bin, Buffer.alloc(binPad)]);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binChunk.length, 0);
binHeader.writeUInt32LE(0x004e4942, 4);
chunks.push(binHeader, binChunk);
}
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const out = Buffer.concat(chunks, total);
out.writeUInt32LE(total, 8);
return out;
}
function float32Buffer(values: number[]): Buffer {
const buffer = Buffer.alloc(values.length * 4);
values.forEach((value, index) => buffer.writeFloatLE(value, index * 4));
return buffer;
}
function uint16Buffer(values: number[]): Buffer {
const buffer = Buffer.alloc(values.length * 2);
values.forEach((value, index) => buffer.writeUInt16LE(value, index * 2));
return buffer;
}
/** 单个三角形、无索引的最小 GLB */
function triangleGlb(): Buffer {
const bin = float32Buffer([0, 0, 0, 1, 0, 0, 0, 1, 0]);
const json = {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.9, 0.4, 0.3, 1] } }],
accessors: [{ bufferView: 0, componentType: 5126, count: 3, type: 'VEC3' }],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: bin.length }],
buffers: [{ byteLength: bin.length }],
};
return buildGlb(json, bin);
}
/** 8 顶点 12 三角形的立方体(每个面朝外 CCW),scale 控制大小 */
function cubeGlb(scale: number): Buffer {
const s = scale;
const positions = [
-s, -s, -s, s, -s, -s, s, s, -s, -s, s, -s,
-s, -s, s, s, -s, s, s, s, s, -s, s, s,
];
const indices = [
4, 5, 6, 4, 6, 7, // +z
1, 0, 3, 1, 3, 2, // -z
5, 1, 2, 5, 2, 6, // +x
0, 4, 7, 0, 7, 3, // -x
7, 6, 2, 7, 2, 3, // +y
0, 1, 5, 0, 5, 4, // -y
];
const posBin = float32Buffer(positions);
const idxBin = uint16Buffer(indices);
const bin = Buffer.concat([posBin, idxBin]);
const json = {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }],
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.85, 0.85, 0.85, 1] } }],
accessors: [
{ bufferView: 0, componentType: 5126, count: 8, type: 'VEC3' },
{ bufferView: 1, componentType: 5123, count: 12 * 3, type: 'SCALAR' },
],
bufferViews: [
{ buffer: 0, byteOffset: 0, byteLength: posBin.length },
{ buffer: 0, byteOffset: posBin.length, byteLength: idxBin.length },
],
buffers: [{ byteLength: bin.length }],
};
return buildGlb(json, bin);
}
/** 5000 个三角形的波浪网格(50x50 个格子,每格 2 个三角形) */
function gridGlb(segments: number): Buffer {
const n = segments;
const positions: number[] = [];
for (let j = 0; j <= n; j += 1) {
for (let i = 0; i <= n; i += 1) {
const x = (i / n) * 2 - 1;
const y = (j / n) * 2 - 1;
const z = 0.3 * Math.sin(x * 3) * Math.cos(y * 3);
positions.push(x, y, z);
}
}
const indices: number[] = [];
for (let j = 0; j < n; j += 1) {
for (let i = 0; i < n; i += 1) {
const a = j * (n + 1) + i;
const b = a + 1;
const c = a + (n + 1);
const d = c + 1;
indices.push(a, b, d, a, d, c);
}
}
const posBin = float32Buffer(positions);
const idxBin = uint16Buffer(indices);
const bin = Buffer.concat([posBin, idxBin]);
const json = {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }],
accessors: [
{ bufferView: 0, componentType: 5126, count: positions.length / 3, type: 'VEC3' },
{ bufferView: 1, componentType: 5123, count: indices.length, type: 'SCALAR' },
],
bufferViews: [
{ buffer: 0, byteOffset: 0, byteLength: posBin.length },
{ buffer: 0, byteOffset: posBin.length, byteLength: idxBin.length },
],
buffers: [{ byteLength: bin.length }],
};
return buildGlb(json, bin);
}
/** 列主序平移矩阵(与 glTF 的 matrix 布局一致) */
function translationMat4(x: number, y: number, z: number): number[] {
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];
}
/**
* 蒙皮四边形:4 顶点 2 三角形(无索引复用),2 个关节。
* - 下半两个顶点绑到 joint0(世界矩阵 = 单位阵,IBM = 单位阵)→ 原地不动
* - 上半两个顶点绑到 joint1(世界矩阵 = translate(0,4,0),IBM = translate(0,-2,0))
* → 蒙皮矩阵 translate(0,2,0),顶点被抬到 y=3
* 因此正确实现蒙皮后,几何应当等价于一个顶点为 y∈[-1,3] 的普通四边形。
*/
function skinnedQuadGlb(): Buffer {
const positions = float32Buffer([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]);
const joints = Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
const weights = float32Buffer([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0]);
const inverseBind = float32Buffer([...translationMat4(0, 0, 0), ...translationMat4(0, -2, 0)]);
const indices = uint16Buffer([0, 1, 2, 0, 2, 3]);
const views = [
{ buffer: 0, byteOffset: 0, byteLength: positions.length },
{ buffer: 0, byteOffset: positions.length, byteLength: joints.length },
{ buffer: 0, byteOffset: positions.length + joints.length, byteLength: weights.length },
{ buffer: 0, byteOffset: positions.length + joints.length + weights.length, byteLength: inverseBind.length },
{
buffer: 0,
byteOffset: positions.length + joints.length + weights.length + inverseBind.length,
byteLength: indices.length,
},
];
const bin = Buffer.concat([positions, joints, weights, inverseBind, indices]);
const json = {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0, 3] }],
nodes: [
{ children: [1, 2] }, // 0: 骨架根
{ translation: [0, 0, 0] }, // 1: joint0(绑定姿态即身份)
{ translation: [0, 4, 0] }, // 2: joint1(当前姿态被抬高 4)
{ mesh: 0, skin: 0 }, // 3: 蒙皮网格,节点自身无变换
],
skins: [{ inverseBindMatrices: 3, joints: [1, 2], skeleton: 1 }],
meshes: [
{
primitives: [
{
attributes: { POSITION: 0, JOINTS_0: 1, WEIGHTS_0: 2 },
indices: 4,
material: 0,
},
],
},
],
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.8, 0.8, 0.8, 1] } }],
accessors: [
{ bufferView: 0, componentType: 5126, count: 4, type: 'VEC3' },
{ bufferView: 1, componentType: 5121, count: 4, type: 'VEC4' },
{ bufferView: 2, componentType: 5126, count: 4, type: 'VEC4' },
{ bufferView: 3, componentType: 5126, count: 2, type: 'MAT4' },
{ bufferView: 4, componentType: 5123, count: 6, type: 'SCALAR' },
],
bufferViews: views,
buffers: [{ byteLength: bin.length }],
};
return buildGlb(json, bin);
}
/** 参照物:无蒙皮的四边形,上下边分别位于 y=-1 与 yTop,其余与 skinnedQuadGlb 等价 */
function plainQuadGlb(yTop: number): Buffer {
const positions = float32Buffer([-1, -1, 0, 1, -1, 0, 1, yTop, 0, -1, yTop, 0]);
const indices = uint16Buffer([0, 1, 2, 0, 2, 3]);
const bin = Buffer.concat([positions, indices]);
const json = {
asset: { version: '2.0' },
scene: 0,
scenes: [{ nodes: [0] }],
nodes: [{ mesh: 0 }],
meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }] }],
materials: [{ pbrMetallicRoughness: { baseColorFactor: [0.8, 0.8, 0.8, 1] } }],
accessors: [
{ bufferView: 0, componentType: 5126, count: 4, type: 'VEC3' },
{ bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' },
],
bufferViews: [
{ buffer: 0, byteOffset: 0, byteLength: positions.length },
{ buffer: 0, byteOffset: positions.length, byteLength: indices.length },
],
buffers: [{ byteLength: bin.length }],
};
return buildGlb(json, bin);
}
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
interface DecodedPng {
width: number;
height: number;
pixels: Buffer;
}
/** 手工解 PNG:校验签名 / chunk 结构,用 inflateSync 还原 IDAT */
function decodePng(png: Buffer): DecodedPng {
expect(png.subarray(0, 8)).toEqual(PNG_SIGNATURE);
let offset = 8;
let width = 0;
let height = 0;
let sawIhdr = false;
let sawIend = false;
const idat: Buffer[] = [];
while (offset + 8 <= png.length) {
const length = png.readUInt32BE(offset);
const type = png.toString('ascii', offset + 4, offset + 8);
const data = png.subarray(offset + 8, offset + 8 + length);
expect(offset + 12 + length).toBeLessThanOrEqual(png.length);
if (type === 'IHDR') {
sawIhdr = true;
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
expect(data[8]).toBe(8); // bit depth
expect(data[9]).toBe(2); // color type = RGB
expect(data[12]).toBe(0); // interlace = none
} else if (type === 'IDAT') {
idat.push(Buffer.from(data));
} else if (type === 'IEND') {
sawIend = true;
break;
}
offset += 12 + length;
}
expect(sawIhdr).toBe(true);
expect(sawIend).toBe(true);
const raw = inflateSync(Buffer.concat(idat));
const stride = width * 3;
expect(raw.length).toBe(height * (stride + 1));
const pixels = Buffer.alloc(height * stride);
for (let y = 0; y < height; y += 1) {
expect(raw[y * (stride + 1)]).toBe(0); // filter = 0
raw.copy(pixels, y * stride, y * (stride + 1) + 1, y * (stride + 1) + 1 + stride);
}
return { width, height, pixels };
}
/** 统计与背景色不同的像素数量 */
function countNonBackground(pixels: Buffer, background: [number, number, number]): number {
let count = 0;
for (let i = 0; i < pixels.length; i += 3) {
if (
Math.abs(pixels[i] - background[0]) > 2 ||
Math.abs(pixels[i + 1] - background[1]) > 2 ||
Math.abs(pixels[i + 2] - background[2]) > 2
) {
count += 1;
}
}
return count;
}
describe('renderModelThumbnail', () => {
it('最小三角形 GLB 能出图,PNG 头与 IHDR 尺寸正确', async () => {
const path = write('triangle.glb', triangleGlb());
const result = await renderModelThumbnail(path, { size: 128 });
expect(result).not.toBeNull();
expect(result!.width).toBe(128);
expect(result!.height).toBe(128);
expect(result!.trianglesDrawn).toBeGreaterThanOrEqual(1);
expect(result!.png.subarray(0, 8)).toEqual(PNG_SIGNATURE);
const decoded = decodePng(result!.png);
expect(decoded.width).toBe(128);
expect(decoded.height).toBe(128);
});
it('带索引的立方体能出图,且画面里确实有非背景像素', async () => {
const background: [number, number, number] = [24, 26, 32];
const path = write('cube.glb', cubeGlb(1));
const result = await renderModelThumbnail(path, { size: 256, background });
expect(result).not.toBeNull();
// 12 个三角形,背面剔除后会少画一部分
expect(result!.trianglesDrawn).toBeGreaterThanOrEqual(1);
expect(result!.trianglesDrawn).toBeLessThanOrEqual(12);
const decoded = decodePng(result!.png);
const nonBackground = countNonBackground(decoded.pixels, background);
expect(nonBackground).toBeGreaterThan(0);
});
it('超大(±1000)与超小(±0.001)模型都能出图且不溢出', async () => {
const background: [number, number, number] = [24, 26, 32];
const bigPath = write('big.glb', cubeGlb(1000));
const smallPath = write('small.glb', cubeGlb(0.001));
const big = await renderModelThumbnail(bigPath, { size: 128, background });
const small = await renderModelThumbnail(smallPath, { size: 128, background });
expect(big).not.toBeNull();
expect(small).not.toBeNull();
const bigRatio = countNonBackground(decodePng(big!.png).pixels, background) / (128 * 128);
const smallRatio = countNonBackground(decodePng(small!.png).pixels, background) / (128 * 128);
expect(bigRatio).toBeGreaterThan(0);
expect(smallRatio).toBeGreaterThan(0);
// 构图一致,两者的覆盖率应当接近(都在画面中央占据相似比例)
expect(Math.abs(bigRatio - smallRatio)).toBeLessThan(0.1);
});
it('非法尺寸抛中文 Error', async () => {
const path = write('cube-invalid.glb', cubeGlb(1));
await expect(renderModelThumbnail(path, { size: 8 })).rejects.toThrow(/[\u4e00-\u9fff]/);
await expect(renderModelThumbnail(path, { size: 4096 })).rejects.toThrow(/[\u4e00-\u9fff]/);
});
it('非 glTF 文件(假 fbx)返回 null 且不抛错', async () => {
const path = write('fake.fbx', Buffer.from('Kaydara FBX Binary \u0000\u001a\u0000', 'binary'));
await expect(renderModelThumbnail(path)).resolves.toBeNull();
});
it('截断的 GLB 返回 null 且不抛错', async () => {
const valid = triangleGlb();
const truncated = valid.subarray(0, 20);
const path = write('truncated.glb', truncated);
await expect(renderModelThumbnail(path)).resolves.toBeNull();
});
it('5000 个三角形的模型渲染耗时低于 2000ms', async () => {
const path = write('grid.glb', gridGlb(50));
const started = performance.now();
const result = await renderModelThumbnail(path, { size: 256 });
const elapsed = performance.now() - started;
expect(result).not.toBeNull();
expect(result!.trianglesDrawn).toBeGreaterThan(0);
expect(elapsed).toBeLessThan(2000);
});
it('蒙皮被真正应用:蒙皮结果与等价的无蒙皮几何逐像素一致', async () => {
// 蒙皮后上下边在 y=-1 / y=3;参照物直接用同样的最终顶点位置
const skinned = await renderModelThumbnail(write('skinned.glb', skinnedQuadGlb()), { size: 128 });
const equivalent = await renderModelThumbnail(write('plain-sheared.glb', plainQuadGlb(3)), { size: 128 });
// 绑定姿态(上下边在 y=-1 / y=1)作为反例
const bindPose = await renderModelThumbnail(write('plain-bind.glb', plainQuadGlb(1)), { size: 128 });
expect(skinned).not.toBeNull();
expect(equivalent).not.toBeNull();
expect(bindPose).not.toBeNull();
// 蒙皮矩阵(jointWorld × inverseBindMatrices × 权重)算对了才可能逐字节一致
expect(skinned!.png.equals(equivalent!.png)).toBe(true);
// 并确认它确实不是"忽略蒙皮"画出来的绑定姿态
expect(skinned!.png.equals(bindPose!.png)).toBe(false);
});
});