feat(ui-migration): PNG 切分/重组与全量重组校验
切分可逆, 故「重组(切分(原图)) 的像素 === 原图像素」是对全部 1006 帧的完全验证, 成本近零。比较解码后的像素缓冲而非文件字节(编码参数不同会让同像素图字节不同)。 verify-frames CLI 对全部 139 张多帧图跑该校验, 任一失败退出码 1。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PNG } from 'pngjs';
|
||||
import { splitSheet, reassemble, pixelsEqual, verifySheet } from '../ui-migration/frame-io.mjs';
|
||||
|
||||
/** 造一张 w×h 网格的测试图,每格填不同颜色,便于验证切分位置 */
|
||||
function makeSheet(cols, rows, cellW, cellH) {
|
||||
const png = new PNG({ width: cols * cellW, height: rows * cellH });
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = r * cols + c; // 行优先编号
|
||||
for (let y = 0; y < cellH; y++) {
|
||||
for (let x = 0; x < cellW; x++) {
|
||||
const o = ((r * cellH + y) * png.width + (c * cellW + x)) << 2;
|
||||
png.data[o] = (idx * 20) & 0xff; // R 编码帧号,便于断言
|
||||
png.data[o + 1] = 0x40;
|
||||
png.data[o + 2] = 0x80;
|
||||
png.data[o + 3] = 0xff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return PNG.sync.write(png);
|
||||
}
|
||||
|
||||
const IMG = { id: 1, w_all: 60, h_all: 40, w: 3, h: 2, frame_all: 6, bmp: 'test.png', w1: 20, h1: 20 };
|
||||
|
||||
test('splitSheet 切出 frame_all 张,尺寸为单帧尺寸', () => {
|
||||
const frames = splitSheet(makeSheet(3, 2, 20, 20), IMG);
|
||||
assert.equal(frames.length, 6);
|
||||
const first = PNG.sync.read(frames[0]);
|
||||
assert.equal(first.width, 20);
|
||||
assert.equal(first.height, 20);
|
||||
});
|
||||
|
||||
test('splitSheet 按行优先取帧:下标 0..5 的 R 通道依次为 0,20,40,60,80,100', () => {
|
||||
const frames = splitSheet(makeSheet(3, 2, 20, 20), IMG);
|
||||
const reds = frames.map((b) => PNG.sync.read(b).data[0]);
|
||||
assert.deepEqual(reds, [0, 20, 40, 60, 80, 100],
|
||||
'若切成列优先,顺序会变为 0,60,20,80,40,100');
|
||||
});
|
||||
|
||||
test('reassemble(splitSheet(x)) 的像素与 x 完全一致(完全验证的核心不变量)', () => {
|
||||
const sheet = makeSheet(3, 2, 20, 20);
|
||||
const back = reassemble(splitSheet(sheet, IMG), IMG);
|
||||
assert.equal(pixelsEqual(sheet, back), true);
|
||||
});
|
||||
|
||||
test('pixelsEqual 对像素不同的图返回 false', () => {
|
||||
const a = makeSheet(3, 2, 20, 20);
|
||||
const bPng = PNG.sync.read(a);
|
||||
bPng.data[0] = (bPng.data[0] + 1) & 0xff; // 改一个字节
|
||||
assert.equal(pixelsEqual(a, PNG.sync.write(bPng)), false);
|
||||
});
|
||||
|
||||
test('pixelsEqual 忽略编码差异:同像素不同压缩等级仍为 true', () => {
|
||||
const src = PNG.sync.read(makeSheet(3, 2, 20, 20));
|
||||
const enc0 = PNG.sync.write(src, { deflateLevel: 0 });
|
||||
const enc9 = PNG.sync.write(src, { deflateLevel: 9 });
|
||||
assert.notEqual(Buffer.compare(enc0, enc9), 0, '前提:两种编码的文件字节确实不同');
|
||||
assert.equal(pixelsEqual(enc0, enc9), true, '但像素数据必须判为相同');
|
||||
});
|
||||
|
||||
test('verifySheet 对正常图返回 ok', () => {
|
||||
assert.deepEqual(verifySheet(makeSheet(3, 2, 20, 20), IMG), { ok: true });
|
||||
});
|
||||
|
||||
test('verifySheet 对尺寸与网格声明不符的图返回失败原因', () => {
|
||||
const wrong = makeSheet(2, 2, 20, 20); // 实际 40x40,但 IMG 声明 60x40
|
||||
const r = verifySheet(wrong, IMG);
|
||||
assert.equal(r.ok, false);
|
||||
assert.match(r.reason, /尺寸/);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { PNG } from 'pngjs';
|
||||
import { frameRect } from './frames.mjs';
|
||||
|
||||
/**
|
||||
* PNG 切分与重组。见 spec §4.2 ②。
|
||||
* 切分是可逆操作,故「重组(切分(原图)) 的像素 === 原图像素」是对全部帧的完全验证。
|
||||
*/
|
||||
|
||||
/** 切分整图 → 帧 Buffer 数组,下标 0 对应帧 1。 */
|
||||
export function splitSheet(sheetBuffer, image) {
|
||||
const sheet = PNG.sync.read(sheetBuffer);
|
||||
const out = [];
|
||||
for (let i = 1; i <= image.frame_all; i++) {
|
||||
const r = frameRect(image, i);
|
||||
const cell = new PNG({ width: r.width, height: r.height });
|
||||
PNG.bitblt(sheet, cell, r.x, r.y, r.width, r.height, 0, 0);
|
||||
out.push(PNG.sync.write(cell));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 把帧按原网格拼回整图。 */
|
||||
export function reassemble(frameBuffers, image) {
|
||||
const sheet = new PNG({ width: image.w * image.w1, height: image.h * image.h1 });
|
||||
frameBuffers.forEach((buf, i) => {
|
||||
const r = frameRect(image, i + 1);
|
||||
const cell = PNG.sync.read(buf);
|
||||
PNG.bitblt(cell, sheet, 0, 0, r.width, r.height, r.x, r.y);
|
||||
});
|
||||
return PNG.sync.write(sheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两张 PNG 的**解码像素数据**。
|
||||
* 不能比较文件字节——编码参数不同会让同像素的图产生不同字节。
|
||||
*/
|
||||
export function pixelsEqual(bufA, bufB) {
|
||||
const a = PNG.sync.read(bufA);
|
||||
const b = PNG.sync.read(bufB);
|
||||
if (a.width !== b.width || a.height !== b.height) return false;
|
||||
return Buffer.compare(a.data, b.data) === 0;
|
||||
}
|
||||
|
||||
/** 对一张多帧图做重组校验。 */
|
||||
export function verifySheet(sheetBuffer, image) {
|
||||
const sheet = PNG.sync.read(sheetBuffer);
|
||||
const expectW = image.w * image.w1;
|
||||
const expectH = image.h * image.h1;
|
||||
if (sheet.width !== expectW || sheet.height !== expectH) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `尺寸不符: 实际 ${sheet.width}x${sheet.height},按网格应为 ${expectW}x${expectH}`,
|
||||
};
|
||||
}
|
||||
const back = reassemble(splitSheet(sheetBuffer, image), image);
|
||||
if (!pixelsEqual(sheetBuffer, back)) {
|
||||
return { ok: false, reason: '重组后像素与原图不一致(切分逻辑有误)' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { loadSource } from './ui-migration/gameabc-source.mjs';
|
||||
import { verifySheet } from './ui-migration/frame-io.mjs';
|
||||
import { ROOT } from './lib/paths.mjs';
|
||||
|
||||
const DEFAULT_SOURCE = join(ROOT, '..', 'projects', 'Game_Surface_3');
|
||||
|
||||
/** 对全部多帧图跑重组校验。返回 { checked, failures: [{bmp, reason}] }。 */
|
||||
export function verifyAllFrames(projectDir = DEFAULT_SOURCE) {
|
||||
const { images } = loadSource(join(projectDir, 'output'));
|
||||
const failures = [];
|
||||
let checked = 0;
|
||||
for (const image of images.values()) {
|
||||
if (image.frame_all <= 1) continue;
|
||||
const p = join(projectDir, 'assets', 'bmp', image.bmp);
|
||||
if (!existsSync(p)) {
|
||||
failures.push({ bmp: image.bmp, reason: `图片文件不存在: ${p}` });
|
||||
continue;
|
||||
}
|
||||
checked++;
|
||||
const r = verifySheet(readFileSync(p), image);
|
||||
if (!r.ok) failures.push({ bmp: image.bmp, reason: r.reason });
|
||||
}
|
||||
return { checked, failures };
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const { checked, failures } = verifyAllFrames(process.argv[2] ?? DEFAULT_SOURCE);
|
||||
console.log(`重组校验:${checked} 张多帧图`);
|
||||
if (failures.length === 0) {
|
||||
console.log('OK: 全部通过(切分对全部帧完全可逆)。');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(`\n${failures.length} 张失败:`);
|
||||
for (const f of failures) console.error(` ${f.bmp}\n ${f.reason}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user