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); }); });