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'; /** * 缩略图**贴图采样**测试。 * * 背景(真实故障):缩略图渲染器原先刻意不解码 baseColorTexture,只用 * `baseColorFactor` 上色;而 MMD(PMX 转换)模型的 baseColorFactor 基本全是白色, * 于是缩略图全是"白模"——列表页与审核面板看到的都是白色模型,用户报的正是这个。 * 现在渲染器内置 PNG 解码并逐像素采样贴图,这里锁住行为。 */ const dir = mkdtempSync(join(tmpdir(), 'opendance-thumb-tex-')); function write(name: string, content: Buffer): string { const path = join(dir, name); writeFileSync(path, content); return path; } /** 12 字节头 + JSON chunk + BIN chunk */ function buildGlb(json: unknown, bin: Buffer): Buffer { const jsonRaw = Buffer.from(JSON.stringify(json), 'utf8'); const jsonChunk = Buffer.concat([jsonRaw, Buffer.alloc((4 - (jsonRaw.length % 4)) % 4, 0x20)]); const binChunk = Buffer.concat([bin, Buffer.alloc((4 - (bin.length % 4)) % 4)]); 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); const binHeader = Buffer.alloc(8); binHeader.writeUInt32LE(binChunk.length, 0); binHeader.writeUInt32LE(0x004e4942, 4); chunks.push(binHeader, binChunk); const out = Buffer.concat(chunks); out.writeUInt32LE(out.length, 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; } /* ------------------------------------------------------------ 贴图夹具 */ /** 2x2 纯红(colorType 6,带 alpha=255) */ const RED_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEUlEQVR4nGP4z8DwH4QZYAwAR8oH+WdZbrcAAAAASUVORK5CYII=', 'base64', ); /** 2x2 纯绿(colorType 2,无 alpha) */ const GREEN_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAD0lEQVR4nGNg+M8AQhAKABvyA/1tVLjHAAAAAElFTkSuQmCC', 'base64', ); /** 2x2 全透明(RGBA 的 alpha=0) */ const TRANSPARENT_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEUlEQVR4nGP4z8DAAMIMMAYAKegD/QE6Y34AAAAASUVORK5CYII=', 'base64', ); interface QuadOptions { texture?: Buffer; mimeType?: string; baseColorFactor?: number[]; alphaMode?: string; } /** 一个正对相机(+Z)的方形面片,UV 铺满贴图;baseColorFactor 默认纯白(MMD 的常见情况) */ function texturedQuadGlb(options: QuadOptions = {}): Buffer { const positions = [-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]; const uvs = [0, 1, 1, 1, 1, 0, 0, 0]; const indices = [0, 1, 2, 0, 2, 3]; const posBin = float32Buffer(positions); const uvBin = float32Buffer(uvs); const idxBin = uint16Buffer(indices); const image = options.texture ?? RED_PNG; const bin = Buffer.concat([posBin, uvBin, idxBin, image]); const imageOffset = posBin.length + uvBin.length + idxBin.length; const json = { asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }], nodes: [{ mesh: 0 }], meshes: [{ primitives: [{ attributes: { POSITION: 0, TEXCOORD_0: 1 }, indices: 2, material: 0 }] }], materials: [ { pbrMetallicRoughness: { baseColorFactor: options.baseColorFactor ?? [1, 1, 1, 1], baseColorTexture: { index: 0 }, }, ...(options.alphaMode ? { alphaMode: options.alphaMode, alphaCutoff: 0.5 } : {}), }, ], textures: [{ source: 0 }], images: [{ bufferView: 3, mimeType: options.mimeType ?? 'image/png' }], accessors: [ { bufferView: 0, componentType: 5126, count: 4, type: 'VEC3' }, { bufferView: 1, componentType: 5126, count: 4, type: 'VEC2' }, { bufferView: 2, componentType: 5123, count: 6, type: 'SCALAR' }, ], bufferViews: [ { buffer: 0, byteOffset: 0, byteLength: posBin.length }, { buffer: 0, byteOffset: posBin.length, byteLength: uvBin.length }, { buffer: 0, byteOffset: posBin.length + uvBin.length, byteLength: idxBin.length }, { buffer: 0, byteOffset: imageOffset, byteLength: image.length }, ], buffers: [{ byteLength: bin.length }], }; return buildGlb(json, bin); } /** 解 PNG(我们自己写的编码器:8 位 RGB、filter=0),返回平均 RGB */ function averageColor(png: Buffer): { r: number; g: number; b: number; nonBackground: number } { let offset = 8; let width = 0; let height = 0; 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); if (type === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4); expect(data[8]).toBe(8); expect(data[9]).toBe(2); expect(data[12]).toBe(0); } else if (type === 'IDAT') { idat.push(Buffer.from(data)); } else if (type === 'IEND') { break; } offset += 12 + length; } const raw = inflateSync(Buffer.concat(idat)); let r = 0; let g = 0; let b = 0; let count = 0; let nonBackground = 0; for (let y = 0; y < height; y += 1) { const rowStart = y * (1 + width * 3); expect(raw[rowStart]).toBe(0); // filter=0 for (let x = 0; x < width; x += 1) { const i = rowStart + 1 + x * 3; r += raw[i]; g += raw[i + 1]; b += raw[i + 2]; count += 1; // 默认背景是 [24,26,32](深色);明显偏离即视为主体像素 if (Math.abs(raw[i] - 24) > 12 || Math.abs(raw[i + 1] - 26) > 12 || Math.abs(raw[i + 2] - 32) > 12) { nonBackground += 1; } } } return { r: r / count, g: g / count, b: b / count, nonBackground }; } async function render(glb: Buffer, name: string, extra: Record = {}) { const result = await renderModelThumbnail(write(name, glb), { size: 128, ...extra }); expect(result).not.toBeNull(); return averageColor((result as { png: Buffer }).png); } /* ------------------------------------------------------------------ 测试 */ describe('缩略图贴图采样', () => { it('baseColorFactor 全白时也按贴图上色(回归:不再输出"白模")', async () => { const color = await render(texturedQuadGlb(), 'red.glb'); expect(color.nonBackground).toBeGreaterThan(0); // 贴图是纯红:主体像素整体应明显偏红 expect(color.r).toBeGreaterThan(color.g + 20); expect(color.r).toBeGreaterThan(color.b + 20); }); it('colorType 2(无 alpha 通道)的 PNG 也能解码', async () => { const color = await render(texturedQuadGlb({ texture: GREEN_PNG }), 'green.glb'); expect(color.g).toBeGreaterThan(color.r + 20); expect(color.g).toBeGreaterThan(color.b + 20); }); it('OPAQUE:忽略贴图 alpha(MMD 语义,alpha=0 也照样画出来)', async () => { const color = await render( texturedQuadGlb({ texture: TRANSPARENT_PNG, baseColorFactor: [0.9, 0.1, 0.1, 1] }), 'opaque.glb', ); expect(color.nonBackground).toBeGreaterThan(0); expect(color.r).toBeGreaterThan(color.g + 20); }); it('MASK:alpha 低于阈值的主体像素被丢弃(只剩背景)', async () => { const color = await render( texturedQuadGlb({ texture: TRANSPARENT_PNG, baseColorFactor: [0.9, 0.1, 0.1, 1], alphaMode: 'MASK' }), 'mask.glb', ); expect(color.nonBackground).toBe(0); }); it('贴图解码失败时回退 baseColorFactor 纯色,且不抛错', async () => { const broken = Buffer.from('这不是 PNG 数据', 'utf8'); const color = await render( texturedQuadGlb({ texture: broken, baseColorFactor: [0.1, 0.1, 0.9, 1] }), 'broken.glb', ); expect(color.nonBackground).toBeGreaterThan(0); expect(color.b).toBeGreaterThan(color.r + 20); }); it('默认视角是正面(相机在 +Z):+Z 面颜色明显主导', async () => { // 面片正对 +Z,默认视角下必须看得见(若默认看背面,主体像素会接近 0) const color = await render(texturedQuadGlb(), 'front.glb'); expect(color.nonBackground).toBeGreaterThan(128 * 128 * 0.05); }); });