refactor: vendor png codec (#609)

* refactor: vendor png codec

* fix: satisfy fallow audit

* fix: bound png inflate output
This commit is contained in:
Michał Pierzchała
2026-05-28 17:54:07 +02:00
committed by GitHub
parent 46784f2e81
commit 0bbb06ebcf
24 changed files with 917 additions and 218 deletions
-2
View File
@@ -204,14 +204,12 @@
"detox"
],
"dependencies": {
"pngjs": "^7.0.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@microsoft/api-extractor": "^7.58.7",
"@rslib/core": "0.20.1",
"@types/node": "^22.0.0",
"@types/pngjs": "^6.0.5",
"@vitest/coverage-v8": "4.1.2",
"fallow": "^2.52.0",
"oxfmt": "^0.42.0",
-19
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
pngjs:
specifier: ^7.0.0
version: 7.0.0
yaml:
specifier: ^2.9.0
version: 2.9.0
@@ -24,9 +21,6 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.19.7
'@types/pngjs':
specifier: ^6.0.5
version: 6.0.5
'@vitest/coverage-v8':
specifier: 4.1.2
version: 4.1.2(vitest@4.1.2(@types/node@22.19.7)(vite@8.0.10(@types/node@22.19.7)(yaml@2.9.0)))
@@ -912,9 +906,6 @@ packages:
'@types/node@22.19.7':
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
'@types/pngjs@6.0.5':
resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
'@types/react@19.2.13':
resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==}
@@ -1684,10 +1675,6 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
postcss@8.5.12:
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
engines: {node: ^10 || ^12 || >=14}
@@ -2864,10 +2851,6 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/pngjs@6.0.5':
dependencies:
'@types/node': 22.19.7
'@types/react@19.2.13':
dependencies:
csstype: 3.2.3
@@ -3940,8 +3923,6 @@ snapshots:
picomatch@4.0.4: {}
pngjs@7.0.0: {}
postcss@8.5.12:
dependencies:
nanoid: 3.3.11
+1 -1
View File
@@ -3,7 +3,7 @@ import os from 'node:os';
import path from 'node:path';
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { PNG } from 'pngjs';
import { PNG } from '../utils/png.ts';
import { tryRunClientBackedCommand } from '../cli/commands/router.ts';
import type {
AgentDeviceClient,
+1 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PNG } from 'pngjs';
import { PNG } from '../utils/png.ts';
import type { DaemonResponse } from '../daemon-client.ts';
import {
runCliCapture as captureCli,
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PNG } from 'pngjs';
import { PNG } from '../utils/png.ts';
import { test } from 'vitest';
import type {
AgentDeviceBackend,
@@ -22,7 +22,7 @@ import { dispatchScreenshotViaRuntime } from '../screenshot-runtime.ts';
import type { SessionState } from '../types.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { attachRefs } from '../../utils/snapshot.ts';
import { PNG } from 'pngjs';
import { PNG } from '../../utils/png.ts';
import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { makeSession as makeBaseSession } from '../../__tests__/test-utils/session-factories.ts';
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PNG } from 'pngjs';
import { PNG } from '../../utils/png.ts';
import { annotateScreenshotWithRefs, buildScreenshotOverlayRefs } from '../screenshot-overlay.ts';
import { makeSnapshotState } from '../../__tests__/test-utils/snapshot-builders.ts';
@@ -2,7 +2,7 @@ import { test, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PNG } from 'pngjs';
import { PNG } from '../../../utils/png.ts';
import { handleSnapshotCommands } from '../snapshot.ts';
import { captureSnapshot } from '../snapshot-capture.ts';
import { SessionStore } from '../../session-store.ts';
+1 -2
View File
@@ -1,5 +1,4 @@
import { promises as fs } from 'node:fs';
import { PNG } from 'pngjs';
import {
centerOfRect,
type Rect,
@@ -7,7 +6,7 @@ import {
type SnapshotNode,
type SnapshotState,
} from '../utils/snapshot.ts';
import { decodePng } from '../utils/png.ts';
import { decodePng, PNG } from '../utils/png.ts';
import { findNearestAncestor, normalizeType } from './snapshot-processing.ts';
import { resolveAndroidOverlaySourceRect } from './screenshot-overlay-android.ts';
import { hasPositiveRect, rectArea, rectContains } from './screenshot-overlay-rects.ts';
+203 -2
View File
@@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PNG } from 'pngjs';
import { resizePngFileToMaxSize } from '../png.ts';
import { deflateSync } from 'node:zlib';
import { PNG, resizePngFileToMaxSize } from '../png.ts';
test('resizePngFileToMaxSize leaves smaller images unchanged', async () => {
const filePath = tmpPngPath('unchanged');
@@ -20,6 +20,153 @@ test('resizePngFileToMaxSize leaves smaller images unchanged', async () => {
assert.deepEqual(readPngPixel(unchanged, 3, 1), [45, 90, 135, 255]);
});
test('PNG sync reader decodes filtered RGB image data', () => {
const png = PNG.sync.read(
encodeTestPng({
width: 2,
height: 1,
bitDepth: 8,
colorType: 2,
rawScanlines: Buffer.from([1, 10, 20, 30, 40, 60, 100]),
}),
);
assert.equal(png.width, 2);
assert.equal(png.height, 1);
assert.deepEqual(readPngPixel(png, 0, 0), [10, 20, 30, 255]);
assert.deepEqual(readPngPixel(png, 1, 0), [50, 80, 130, 255]);
});
test('PNG sync reader decodes indexed color and transparency', () => {
const png = PNG.sync.read(
encodeTestPng({
width: 4,
height: 1,
bitDepth: 2,
colorType: 3,
palette: Buffer.from([255, 0, 0, 0, 255, 0, 0, 0, 255, 20, 30, 40]),
transparency: Buffer.from([255, 200, 80, 255]),
rawScanlines: Buffer.from([0, 0b00011011]),
}),
);
assert.deepEqual(readPngPixel(png, 0, 0), [255, 0, 0, 255]);
assert.deepEqual(readPngPixel(png, 1, 0), [0, 255, 0, 200]);
assert.deepEqual(readPngPixel(png, 2, 0), [0, 0, 255, 80]);
assert.deepEqual(readPngPixel(png, 3, 0), [20, 30, 40, 255]);
});
test('PNG sync reader decodes RGBA alpha', () => {
const png = PNG.sync.read(
encodeTestPng({
width: 1,
height: 1,
bitDepth: 8,
colorType: 6,
rawScanlines: Buffer.from([0, 10, 20, 30, 40]),
}),
);
assert.deepEqual(readPngPixel(png, 0, 0), [10, 20, 30, 40]);
});
test('PNG sync reader scales 16-bit samples to 8-bit output', () => {
const rawScanlines = Buffer.alloc(7);
rawScanlines[0] = 0;
rawScanlines.writeUInt16BE(0x00ff, 1);
rawScanlines.writeUInt16BE(0x0100, 3);
rawScanlines.writeUInt16BE(0xffff, 5);
const png = PNG.sync.read(
encodeTestPng({
width: 1,
height: 1,
bitDepth: 16,
colorType: 2,
rawScanlines,
}),
);
assert.deepEqual(readPngPixel(png, 0, 0), [1, 1, 255, 255]);
});
test('PNG sync reader applies packed grayscale transparency', () => {
const png = PNG.sync.read(
encodeTestPng({
width: 2,
height: 1,
bitDepth: 4,
colorType: 0,
transparency: Buffer.from([0, 2]),
rawScanlines: Buffer.from([0, 0x25]),
}),
);
assert.deepEqual(readPngPixel(png, 0, 0), [34, 34, 34, 0]);
assert.deepEqual(readPngPixel(png, 1, 0), [85, 85, 85, 255]);
});
test('PNG sync reader decodes Adam7 interlaced RGB image data', () => {
const png = PNG.sync.read(
encodeTestPng({
width: 3,
height: 3,
bitDepth: 8,
colorType: 2,
interlace: 1,
rawScanlines: Buffer.from([
0,
...rgb(0, 0),
0,
...rgb(2, 0),
0,
...rgb(0, 2),
...rgb(2, 2),
0,
...rgb(1, 0),
0,
...rgb(1, 2),
0,
...rgb(0, 1),
...rgb(1, 1),
...rgb(2, 1),
]),
}),
);
for (let y = 0; y < 3; y += 1) {
for (let x = 0; x < 3; x += 1) {
assert.deepEqual(readPngPixel(png, x, y), [...rgb(x, y), 255]);
}
}
});
test('PNG sync reader rejects invalid chunk CRCs', () => {
const bytes = encodeTestPng({
width: 1,
height: 1,
bitDepth: 8,
colorType: 2,
rawScanlines: Buffer.from([0, ...rgb(0, 0)]),
});
const lastByte = bytes.length - 1;
bytes[lastByte] = (bytes[lastByte] ?? 0) ^ 0xff;
assert.throws(() => PNG.sync.read(bytes), /Invalid PNG .* chunk CRC/);
});
test('PNG sync reader rejects inflated data larger than IHDR scanlines', () => {
const bytes = encodeTestPng({
width: 1,
height: 1,
bitDepth: 8,
colorType: 6,
rawScanlines: Buffer.from([0, 1, 2, 3, 4, 5]),
});
assert.throws(() => PNG.sync.read(bytes), /PNG pixel data exceeds expected length 5/);
});
function tmpPngPath(prefix: string): string {
return path.join(
fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-png-${prefix}-`)),
@@ -56,3 +203,57 @@ function readPngPixel(png: PNG, x: number, y: number): number[] {
png.data[offset + 3] ?? 0,
];
}
function encodeTestPng(params: {
width: number;
height: number;
bitDepth: number;
colorType: number;
rawScanlines: Buffer;
interlace?: 0 | 1;
palette?: Buffer;
transparency?: Buffer;
}): Buffer {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(params.width, 0);
ihdr.writeUInt32BE(params.height, 4);
ihdr[8] = params.bitDepth;
ihdr[9] = params.colorType;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = params.interlace ?? 0;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
encodeTestChunk('IHDR', ihdr),
...(params.palette ? [encodeTestChunk('PLTE', params.palette)] : []),
...(params.transparency ? [encodeTestChunk('tRNS', params.transparency)] : []),
encodeTestChunk('IDAT', deflateSync(params.rawScanlines)),
encodeTestChunk('IEND', Buffer.alloc(0)),
]);
}
function rgb(x: number, y: number): [number, number, number] {
return [x * 40 + 10, y * 50 + 20, x * 30 + y * 20 + 30];
}
function encodeTestChunk(type: string, data: Buffer): Buffer {
const typeBuffer = Buffer.from(type, 'ascii');
const chunk = Buffer.alloc(8 + data.length + 4);
chunk.writeUInt32BE(data.length, 0);
typeBuffer.copy(chunk, 4);
data.copy(chunk, 8);
chunk.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length);
return chunk;
}
function crc32(buffer: Buffer): number {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
+1 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PNG } from 'pngjs';
import { PNG } from '../png.ts';
import { compareScreenshots } from '../screenshot-diff.ts';
function tmpDir(): string {
+483
View File
@@ -0,0 +1,483 @@
import { deflateSync, inflateSync } from 'node:zlib';
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
type PngColorType = 0 | 2 | 3 | 4 | 6;
const COLOR_CHANNELS: ReadonlyMap<PngColorType, number> = new Map([
[0, 1],
[2, 3],
[3, 1],
[4, 2],
[6, 4],
] as const);
const VALID_BIT_DEPTHS: ReadonlyMap<PngColorType, ReadonlySet<number>> = new Map([
[0, new Set([1, 2, 4, 8, 16])],
[2, new Set([8, 16])],
[3, new Set([1, 2, 4, 8])],
[4, new Set([8, 16])],
[6, new Set([8, 16])],
] as const);
const ADAM7_PASSES = [
{ x: 0, y: 0, dx: 8, dy: 8 },
{ x: 4, y: 0, dx: 8, dy: 8 },
{ x: 0, y: 4, dx: 4, dy: 8 },
{ x: 2, y: 0, dx: 4, dy: 4 },
{ x: 0, y: 2, dx: 2, dy: 4 },
{ x: 1, y: 0, dx: 2, dy: 2 },
{ x: 0, y: 1, dx: 1, dy: 2 },
] as const;
type PngMetadata = {
width: number;
height: number;
bitDepth: number;
colorType: PngColorType;
interlace: 0 | 1;
palette?: Buffer;
transparency?: Buffer;
};
type PngChunk = {
type: string;
data: Buffer;
};
export class PNG {
width: number;
height: number;
data: Buffer;
static sync = {
read: readPng,
write: writePng,
};
constructor(options: { width: number; height: number; data?: Buffer }) {
this.width = validateDimension(options.width, 'width');
this.height = validateDimension(options.height, 'height');
const byteLength = this.width * this.height * 4;
this.data = options.data ? Buffer.from(options.data) : Buffer.alloc(byteLength);
if (this.data.length !== byteLength) {
throw new Error(`PNG data length must be ${byteLength} bytes`);
}
}
}
function readPng(buffer: Buffer): PNG {
const { metadata, idatChunks } = collectPngChunks(buffer);
if (!metadata) throw new Error('PNG is missing IHDR');
if (idatChunks.length === 0) throw new Error('PNG is missing IDAT');
const inflated = inflatePngData(idatChunks, metadata);
return new PNG({
width: metadata.width,
height: metadata.height,
data:
metadata.interlace === 1
? decodeInterlacedPixels(inflated, metadata)
: decodePixels(unfilterPng(inflated, metadata), metadata),
});
}
function collectPngChunks(buffer: Buffer): { metadata?: PngMetadata; idatChunks: Buffer[] } {
let metadata: PngMetadata | undefined;
const idatChunks: Buffer[] = [];
for (const chunk of iteratePngChunks(buffer)) {
if (chunk.type === 'IHDR') metadata = parseIhdr(chunk.data);
else if (chunk.type === 'IDAT') idatChunks.push(Buffer.from(chunk.data));
else metadata = applyMetadataChunk(chunk, metadata);
if (chunk.type === 'IEND') break;
}
return { metadata, idatChunks };
}
function applyMetadataChunk(
chunk: PngChunk,
metadata: PngMetadata | undefined,
): PngMetadata | undefined {
if (chunk.type === 'PLTE') {
if (!metadata) throw new Error('PNG PLTE appeared before IHDR');
metadata.palette = Buffer.from(chunk.data);
} else if (chunk.type === 'tRNS') {
if (!metadata) throw new Error('PNG tRNS appeared before IHDR');
metadata.transparency = Buffer.from(chunk.data);
}
return metadata;
}
function* iteratePngChunks(buffer: Buffer): Generator<PngChunk> {
if (!buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
throw new Error('Invalid PNG signature');
}
let offset = PNG_SIGNATURE.length;
while (offset < buffer.length) {
if (offset + 12 > buffer.length) throw new Error('Truncated PNG chunk');
const length = buffer.readUInt32BE(offset);
const type = buffer.toString('ascii', offset + 4, offset + 8);
const dataStart = offset + 8;
const dataEnd = dataStart + length;
if (dataEnd + 4 > buffer.length) throw new Error(`Truncated PNG ${type} chunk`);
const data = buffer.subarray(dataStart, dataEnd);
const expectedCrc = buffer.readUInt32BE(dataEnd);
const actualCrc = crc32(buffer.subarray(offset + 4, dataEnd));
if (actualCrc !== expectedCrc) throw new Error(`Invalid PNG ${type} chunk CRC`);
offset = dataEnd + 4;
yield { type, data };
}
}
function inflatePngData(idatChunks: Buffer[], metadata: PngMetadata): Buffer {
const expectedLength = inflatedByteLength(metadata);
try {
const inflated = inflateSync(Buffer.concat(idatChunks), { maxOutputLength: expectedLength });
if (inflated.length !== expectedLength) throw new Error('PNG pixel data is truncated');
return inflated;
} catch (error) {
if (isZlibOutputLimitError(error)) {
throw new Error(`PNG pixel data exceeds expected length ${expectedLength}`);
}
throw error;
}
}
function writePng(png: PNG): Buffer {
const scanlineLength = png.width * 4;
const raw = Buffer.alloc((scanlineLength + 1) * png.height);
for (let y = 0; y < png.height; y += 1) {
const rawOffset = y * (scanlineLength + 1);
raw[rawOffset] = 0;
png.data.copy(raw, rawOffset + 1, y * scanlineLength, (y + 1) * scanlineLength);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(png.width, 0);
ihdr.writeUInt32BE(png.height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
return Buffer.concat([
PNG_SIGNATURE,
encodeChunk('IHDR', ihdr),
encodeChunk('IDAT', deflateSync(raw)),
encodeChunk('IEND', Buffer.alloc(0)),
]);
}
function parseIhdr(data: Buffer): PngMetadata {
if (data.length !== 13) throw new Error('Invalid PNG IHDR length');
const width = data.readUInt32BE(0);
const height = data.readUInt32BE(4);
const bitDepth = data[8]!;
const colorType = data[9]!;
const compression = data[10]!;
const filter = data[11]!;
const interlace = data[12]!;
if (!isPngColorType(colorType)) throw new Error(`Unsupported PNG color type ${colorType}`);
const validDepths = VALID_BIT_DEPTHS.get(colorType);
if (!validDepths?.has(bitDepth)) {
throw new Error(`Unsupported PNG color type ${colorType} with bit depth ${bitDepth}`);
}
if (compression !== 0) throw new Error(`Unsupported PNG compression method ${compression}`);
if (filter !== 0) throw new Error(`Unsupported PNG filter method ${filter}`);
if (interlace !== 0 && interlace !== 1)
throw new Error(`Unsupported PNG interlace method ${interlace}`);
return {
width: validateDimension(width, 'width'),
height: validateDimension(height, 'height'),
bitDepth,
colorType,
interlace,
};
}
function isPngColorType(value: number): value is PngColorType {
return COLOR_CHANNELS.has(value as PngColorType);
}
function unfilterPng(inflated: Buffer, metadata: PngMetadata): Buffer {
const scanlineLength = scanlineByteLength(metadata);
const result = unfilterScanlines({
inflated,
offset: 0,
scanlineLength,
height: metadata.height,
bytesPerPixel: filterBytesPerPixel(metadata),
});
return result.raw;
}
function unfilterScanlines(params: {
inflated: Buffer;
offset: number;
scanlineLength: number;
height: number;
bytesPerPixel: number;
}): { raw: Buffer; offset: number } {
const { inflated, offset, scanlineLength, height, bytesPerPixel } = params;
const expectedLength = (scanlineLength + 1) * height;
const endOffset = offset + expectedLength;
if (inflated.length < endOffset) throw new Error('PNG pixel data is truncated');
const output = Buffer.alloc(scanlineLength * height);
for (let y = 0; y < height; y += 1) {
const sourceOffset = offset + y * (scanlineLength + 1);
const targetOffset = y * scanlineLength;
const filter = inflated[sourceOffset]!;
for (let x = 0; x < scanlineLength; x += 1) {
const value = inflated[sourceOffset + 1 + x]!;
const left = x >= bytesPerPixel ? output[targetOffset + x - bytesPerPixel]! : 0;
const up = y > 0 ? output[targetOffset + x - scanlineLength]! : 0;
const upLeft =
y > 0 && x >= bytesPerPixel
? output[targetOffset + x - scanlineLength - bytesPerPixel]!
: 0;
output[targetOffset + x] = unfilterByte(filter, value, left, up, upLeft);
}
}
return { raw: output, offset: endOffset };
}
function unfilterByte(
filter: number,
value: number,
left: number,
up: number,
upLeft: number,
): number {
if (filter === 0) return value;
if (filter === 1) return (value + left) & 0xff;
if (filter === 2) return (value + up) & 0xff;
if (filter === 3) return (value + Math.floor((left + up) / 2)) & 0xff;
if (filter === 4) return (value + paeth(left, up, upLeft)) & 0xff;
throw new Error(`Unsupported PNG filter type ${filter}`);
}
function decodePixels(raw: Buffer, metadata: PngMetadata): Buffer {
const output = Buffer.alloc(metadata.width * metadata.height * 4);
const scanlineLength = scanlineByteLength(metadata);
for (let y = 0; y < metadata.height; y += 1) {
const line = raw.subarray(y * scanlineLength, (y + 1) * scanlineLength);
for (let x = 0; x < metadata.width; x += 1) {
const target = (y * metadata.width + x) * 4;
const [red, green, blue, alpha] = readPixel(line, x, metadata);
output[target] = red;
output[target + 1] = green;
output[target + 2] = blue;
output[target + 3] = alpha;
}
}
return output;
}
function decodeInterlacedPixels(inflated: Buffer, metadata: PngMetadata): Buffer {
const output = Buffer.alloc(metadata.width * metadata.height * 4);
let offset = 0;
for (const pass of ADAM7_PASSES) {
const width = interlacePassSize(metadata.width, pass.x, pass.dx);
const height = interlacePassSize(metadata.height, pass.y, pass.dy);
if (width === 0 || height === 0) continue;
const passMetadata = { ...metadata, width, height, interlace: 0 as const };
const result = unfilterScanlines({
inflated,
offset,
scanlineLength: scanlineByteLength(passMetadata),
height,
bytesPerPixel: filterBytesPerPixel(passMetadata),
});
offset = result.offset;
const scanlineLength = scanlineByteLength(passMetadata);
for (let y = 0; y < height; y += 1) {
const line = result.raw.subarray(y * scanlineLength, (y + 1) * scanlineLength);
for (let x = 0; x < width; x += 1) {
const targetX = pass.x + x * pass.dx;
const targetY = pass.y + y * pass.dy;
const target = (targetY * metadata.width + targetX) * 4;
const [red, green, blue, alpha] = readPixel(line, x, passMetadata);
output[target] = red;
output[target + 1] = green;
output[target + 2] = blue;
output[target + 3] = alpha;
}
}
}
return output;
}
function inflatedByteLength(metadata: PngMetadata): number {
if (metadata.interlace === 0) return filteredScanlineByteLength(metadata, metadata.height);
let byteLength = 0;
for (const pass of ADAM7_PASSES) {
const width = interlacePassSize(metadata.width, pass.x, pass.dx);
const height = interlacePassSize(metadata.height, pass.y, pass.dy);
if (width === 0 || height === 0) continue;
byteLength += filteredScanlineByteLength({ ...metadata, width, height }, height);
}
return byteLength;
}
function filteredScanlineByteLength(metadata: PngMetadata, height: number): number {
return (scanlineByteLength(metadata) + 1) * height;
}
function interlacePassSize(size: number, start: number, step: number): number {
if (size <= start) return 0;
return Math.floor((size - start + step - 1) / step);
}
function readPixel(
line: Buffer,
x: number,
metadata: PngMetadata,
): [number, number, number, number] {
if (metadata.colorType === 3) return readPalettePixel(line, x, metadata);
if (metadata.bitDepth < 8) return readPackedGrayscalePixel(line, x, metadata);
const bytesPerSample = metadata.bitDepth === 16 ? 2 : 1;
const channels = COLOR_CHANNELS.get(metadata.colorType)!;
const offset = x * channels * bytesPerSample;
const rawSample = (channel: number): number =>
metadata.bitDepth === 16
? line.readUInt16BE(offset + channel * 2)
: line[offset + channel * bytesPerSample]!;
const sample = (channel: number): number => scaleSample(rawSample(channel), metadata.bitDepth);
if (metadata.colorType === 0) {
const gray = sample(0);
const transparent = matchesTransparentGray(rawSample(0), metadata);
return [gray, gray, gray, transparent ? 0 : 255];
}
if (metadata.colorType === 2) {
const red = sample(0);
const green = sample(1);
const blue = sample(2);
const transparent = matchesTransparentRgb(rawSample(0), rawSample(1), rawSample(2), metadata);
return [red, green, blue, transparent ? 0 : 255];
}
if (metadata.colorType === 4) {
const gray = sample(0);
return [gray, gray, gray, sample(1)];
}
return [sample(0), sample(1), sample(2), sample(3)];
}
function readPalettePixel(
line: Buffer,
x: number,
metadata: PngMetadata,
): [number, number, number, number] {
if (!metadata.palette) throw new Error('Indexed PNG is missing PLTE');
const index = readPackedSample(line, x, metadata.bitDepth);
const paletteOffset = index * 3;
if (paletteOffset + 2 >= metadata.palette.length)
throw new Error('Indexed PNG palette is invalid');
return [
metadata.palette[paletteOffset]!,
metadata.palette[paletteOffset + 1]!,
metadata.palette[paletteOffset + 2]!,
metadata.transparency?.[index] ?? 255,
];
}
function readPackedGrayscalePixel(
line: Buffer,
x: number,
metadata: PngMetadata,
): [number, number, number, number] {
const sample = readPackedSample(line, x, metadata.bitDepth);
const max = (1 << metadata.bitDepth) - 1;
const gray = Math.round((sample / max) * 255);
const transparent =
metadata.transparency && metadata.transparency.length >= 2
? sample === metadata.transparency.readUInt16BE(0)
: false;
return [gray, gray, gray, transparent ? 0 : 255];
}
function readPackedSample(line: Buffer, x: number, bitDepth: number): number {
const bitOffset = x * bitDepth;
const byte = line[Math.floor(bitOffset / 8)]!;
const shift = 8 - bitDepth - (bitOffset % 8);
return (byte >> shift) & ((1 << bitDepth) - 1);
}
function scanlineByteLength(metadata: PngMetadata): number {
const channels = COLOR_CHANNELS.get(metadata.colorType)!;
return Math.ceil((metadata.width * channels * metadata.bitDepth) / 8);
}
function filterBytesPerPixel(metadata: PngMetadata): number {
const channels = COLOR_CHANNELS.get(metadata.colorType)!;
return Math.max(1, Math.ceil((channels * metadata.bitDepth) / 8));
}
function scaleSample(sample: number, bitDepth: number): number {
if (bitDepth === 16) return Math.round((sample / 0xffff) * 0xff);
return sample;
}
function matchesTransparentGray(sample: number, metadata: PngMetadata): boolean {
if (!metadata.transparency || metadata.transparency.length < 2) return false;
return sample === metadata.transparency.readUInt16BE(0);
}
function matchesTransparentRgb(
red: number,
green: number,
blue: number,
metadata: PngMetadata,
): boolean {
if (!metadata.transparency || metadata.transparency.length < 6) return false;
return (
red === metadata.transparency.readUInt16BE(0) &&
green === metadata.transparency.readUInt16BE(2) &&
blue === metadata.transparency.readUInt16BE(4)
);
}
function encodeChunk(type: string, data: Buffer): Buffer {
const typeBuffer = Buffer.from(type, 'ascii');
const chunk = Buffer.alloc(8 + data.length + 4);
chunk.writeUInt32BE(data.length, 0);
typeBuffer.copy(chunk, 4);
data.copy(chunk, 8);
chunk.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length);
return chunk;
}
function crc32(buffer: Buffer): number {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function paeth(left: number, up: number, upLeft: number): number {
const estimate = left + up - upLeft;
const leftDistance = Math.abs(estimate - left);
const upDistance = Math.abs(estimate - up);
const upLeftDistance = Math.abs(estimate - upLeft);
if (leftDistance <= upDistance && leftDistance <= upLeftDistance) return left;
return upDistance <= upLeftDistance ? up : upLeft;
}
function validateDimension(value: number, label: string): number {
if (!Number.isInteger(value) || value < 1) throw new Error(`PNG ${label} must be positive`);
return value;
}
function isZlibOutputLimitError(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ERR_BUFFER_TOO_LARGE';
}
+3 -1
View File
@@ -1,6 +1,8 @@
import { promises as fs } from 'node:fs';
import { PNG } from 'pngjs';
import { AppError } from './errors.ts';
import { PNG } from './png-codec.ts';
export { PNG };
export function decodePng(buffer: Buffer, label: string): PNG {
try {
+99
View File
@@ -0,0 +1,99 @@
type ConnectedMaskComponentHooks<TComponent> = {
create(pixelIndex: number): TComponent;
visit(component: TComponent, pixelIndex: number): void;
};
const NEIGHBOR_OFFSETS = [
{ x: -1, y: -1 },
{ x: 0, y: -1 },
{ x: 1, y: -1 },
{ x: -1, y: 0 },
{ x: 1, y: 0 },
{ x: -1, y: 1 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
] as const;
export function findConnectedMaskComponents<TComponent>(params: {
mask: Uint8Array;
width: number;
height: number;
hooks: ConnectedMaskComponentHooks<TComponent>;
}): TComponent[] {
const { mask, width, height, hooks } = params;
const visited = new Uint8Array(mask.length);
const queue = new Int32Array(mask.length);
const components: TComponent[] = [];
for (let pixelIndex = 0; pixelIndex < mask.length; pixelIndex += 1) {
if (!isUnvisitedMaskPixel(mask, visited, pixelIndex)) continue;
let queueStart = 0;
let queueEnd = enqueuePixel(queue, visited, 0, pixelIndex);
const component = hooks.create(pixelIndex);
while (queueStart < queueEnd) {
const currentPixelIndex = queue[queueStart]!;
queueStart += 1;
hooks.visit(component, currentPixelIndex);
queueEnd = enqueueMaskNeighbors({
mask,
visited,
queue,
queueEnd,
width,
height,
pixelIndex: currentPixelIndex,
});
}
components.push(component);
}
return components;
}
function enqueueMaskNeighbors(params: {
mask: Uint8Array;
visited: Uint8Array;
queue: Int32Array;
queueEnd: number;
width: number;
height: number;
pixelIndex: number;
}): number {
const { mask, visited, queue, width, height, pixelIndex } = params;
const x = pixelIndex % width;
const y = Math.floor(pixelIndex / width);
let queueEnd = params.queueEnd;
for (const offset of NEIGHBOR_OFFSETS) {
const neighborX = x + offset.x;
const neighborY = y + offset.y;
if (!isInBounds(neighborX, neighborY, width, height)) continue;
const neighborIndex = neighborY * width + neighborX;
if (!isUnvisitedMaskPixel(mask, visited, neighborIndex)) continue;
queueEnd = enqueuePixel(queue, visited, queueEnd, neighborIndex);
}
return queueEnd;
}
function enqueuePixel(
queue: Int32Array,
visited: Uint8Array,
queueEnd: number,
pixelIndex: number,
): number {
visited[pixelIndex] = 1;
queue[queueEnd] = pixelIndex;
return queueEnd + 1;
}
function isUnvisitedMaskPixel(mask: Uint8Array, visited: Uint8Array, pixelIndex: number): boolean {
return mask[pixelIndex] === 1 && visited[pixelIndex] !== 1;
}
function isInBounds(x: number, y: number, width: number, height: number): boolean {
return x >= 0 && x < width && y >= 0 && y < height;
}
+38 -92
View File
@@ -1,6 +1,15 @@
import type { Rect } from './snapshot.ts';
import { findConnectedMaskComponents } from './screenshot-diff-components.ts';
import type { ScreenshotOcrAnalysis, ScreenshotOcrBlock } from './screenshot-diff-ocr.ts';
import type { ScreenshotDiffRegion } from './screenshot-diff-regions.ts';
import {
clamp,
expandRect,
intersectArea,
rectCenter,
squaredDistance,
unionRects,
} from './screenshot-geometry.ts';
export type ScreenshotNonTextDelta = {
index: number;
@@ -118,56 +127,37 @@ function findConnectedComponents(
width: number,
height: number,
): MutableComponent[] {
const visited = new Uint8Array(mask.length);
const queue = new Int32Array(mask.length);
const components: MutableComponent[] = [];
for (let pixelIndex = 0; pixelIndex < mask.length; pixelIndex += 1) {
if (mask[pixelIndex] !== 1 || visited[pixelIndex] === 1) continue;
let queueStart = 0;
let queueEnd = 0;
queue[queueEnd] = pixelIndex;
queueEnd += 1;
visited[pixelIndex] = 1;
return findConnectedMaskComponents({
mask,
width,
height,
hooks: {
create: (pixelIndex) => createComponent(pixelIndex, width),
visit: (component, pixelIndex) => addPixelToComponent(component, pixelIndex, width),
},
});
}
const startX = pixelIndex % width;
const startY = Math.floor(pixelIndex / width);
const component: MutableComponent = {
minX: startX,
minY: startY,
maxX: startX,
maxY: startY,
differentPixels: 0,
};
function createComponent(pixelIndex: number, width: number): MutableComponent {
const startX = pixelIndex % width;
const startY = Math.floor(pixelIndex / width);
return {
minX: startX,
minY: startY,
maxX: startX,
maxY: startY,
differentPixels: 0,
};
}
while (queueStart < queueEnd) {
const currentIndex = queue[queueStart]!;
queueStart += 1;
const x = currentIndex % width;
const y = Math.floor(currentIndex / width);
component.minX = Math.min(component.minX, x);
component.minY = Math.min(component.minY, y);
component.maxX = Math.max(component.maxX, x);
component.maxY = Math.max(component.maxY, y);
component.differentPixels += 1;
for (let yOffset = -1; yOffset <= 1; yOffset += 1) {
const neighborY = y + yOffset;
if (neighborY < 0 || neighborY >= height) continue;
for (let xOffset = -1; xOffset <= 1; xOffset += 1) {
if (xOffset === 0 && yOffset === 0) continue;
const neighborX = x + xOffset;
if (neighborX < 0 || neighborX >= width) continue;
const neighborIndex = neighborY * width + neighborX;
if (mask[neighborIndex] !== 1 || visited[neighborIndex] === 1) continue;
visited[neighborIndex] = 1;
queue[queueEnd] = neighborIndex;
queueEnd += 1;
}
}
}
components.push(component);
}
return components;
function addPixelToComponent(component: MutableComponent, pixelIndex: number, width: number): void {
const x = pixelIndex % width;
const y = Math.floor(pixelIndex / width);
component.minX = Math.min(component.minX, x);
component.minY = Math.min(component.minY, y);
component.maxX = Math.max(component.maxX, x);
component.maxY = Math.max(component.maxY, y);
component.differentPixels += 1;
}
function mergeNearbyComponents(components: MutableComponent[], gapPx: number): MutableComponent[] {
@@ -397,20 +387,6 @@ function findNearestText(
return nearest;
}
function unionRects(rects: Rect[]): Rect {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const rect of rects) {
minX = Math.min(minX, rect.x);
minY = Math.min(minY, rect.y);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
function cleanOcrAnchorText(text: string): string {
return text
.trim()
@@ -436,15 +412,6 @@ function componentToRect(component: MutableComponent): Rect {
};
}
function expandRect(rect: Rect, padding: number): Rect {
return {
x: rect.x - padding,
y: rect.y - padding,
width: rect.width + padding * 2,
height: rect.height + padding * 2,
};
}
function clearRect(mask: Uint8Array, width: number, height: number, rect: Rect): void {
const minX = clamp(Math.floor(rect.x), 0, width - 1);
const minY = clamp(Math.floor(rect.y), 0, height - 1);
@@ -470,30 +437,9 @@ function componentsAreNear(
);
}
function intersectArea(left: Rect, right: Rect): number {
const minX = Math.max(left.x, right.x);
const minY = Math.max(left.y, right.y);
const maxX = Math.min(left.x + left.width, right.x + right.width);
const maxY = Math.min(left.y + left.height, right.y + right.height);
if (maxX <= minX || maxY <= minY) return 0;
return (maxX - minX) * (maxY - minY);
}
function verticalOverlap(left: Rect, right: Rect): number {
return Math.max(
0,
Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y),
);
}
function rectCenter(rect: Rect): { x: number; y: number } {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
}
function squaredDistance(left: { x: number; y: number }, right: { x: number; y: number }): number {
return (left.x - right.x) ** 2 + (left.y - right.y) ** 2;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
+1 -22
View File
@@ -1,5 +1,6 @@
import type { Rect } from './snapshot.ts';
import { runCmd, whichCmd } from './exec.ts';
import { rectCenter, squaredDistance, unionRects } from './screenshot-geometry.ts';
export type ScreenshotOcrBlock = {
text: string;
@@ -335,28 +336,6 @@ function scoreMovementCluster(cluster: ScreenshotOcrMovementCluster): number {
return Math.abs(averageX) * 2 + Math.abs(averageY);
}
function unionRects(rects: Rect[]): Rect {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const rect of rects) {
minX = Math.min(minX, rect.x);
minY = Math.min(minY, rect.y);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
function rectCenter(rect: Rect): { x: number; y: number } {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
}
function squaredDistance(left: { x: number; y: number }, right: { x: number; y: number }): number {
return (left.x - right.x) ** 2 + (left.y - right.y) ** 2;
}
function readTsvString(values: string[], indexByName: Map<string, number>, name: string): string {
const index = indexByName.get(name);
return index === undefined ? '' : (values[index] ?? '');
+1 -9
View File
@@ -3,6 +3,7 @@ import type {
ScreenshotDiffRegionOverlayMatch,
} from './screenshot-diff-regions.ts';
import type { Rect, ScreenshotOverlayRef } from './snapshot.ts';
import { intersectArea } from './screenshot-geometry.ts';
const MAX_MATCHES_PER_REGION = 3;
@@ -52,15 +53,6 @@ function findRegionOverlayMatches(
}));
}
function intersectArea(left: Rect, right: Rect): number {
const minX = Math.max(left.x, right.x);
const minY = Math.max(left.y, right.y);
const maxX = Math.min(left.x + left.width, right.x + right.width);
const maxY = Math.min(left.y + left.height, right.y + right.height);
if (maxX <= minX || maxY <= minY) return 0;
return (maxX - minX) * (maxY - minY);
}
function rectArea(rect: Rect): number {
return rect.width * rect.height;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { PNG } from 'pngjs';
import type { PNG } from './png.ts';
import type { ScreenshotDiffRegion } from './screenshot-diff-regions.ts';
const REGION_BORDER_COLOR = [0, 187, 255, 255] as const;
+1 -1
View File
@@ -1,4 +1,4 @@
import { PNG } from 'pngjs';
import type { PNG } from './png.ts';
import type { MutableDiffRegion } from './screenshot-diff-region-types.ts';
// Region splitting is based on screen-relative heights so it works on phone,
+28 -56
View File
@@ -1,4 +1,5 @@
import { PNG } from 'pngjs';
import type { PNG } from './png.ts';
import { findConnectedMaskComponents } from './screenshot-diff-components.ts';
import { splitLargeDiffRegions } from './screenshot-diff-region-split.ts';
import type { MutableDiffRegion } from './screenshot-diff-region-types.ts';
@@ -89,62 +90,33 @@ function findConnectedDiffRegions(params: {
}): MutableDiffRegion[] {
const { diffMask, baseline, current } = params;
const { width, height } = baseline;
const visited = new Uint8Array(diffMask.length);
const queue = new Int32Array(diffMask.length);
const regions: MutableDiffRegion[] = [];
return findConnectedMaskComponents({
mask: diffMask,
width,
height,
hooks: {
create: (pixelIndex) => createDiffRegion(pixelIndex, width),
visit: (region, pixelIndex) => addPixelToRegion(region, pixelIndex, width, baseline, current),
},
});
}
for (let pixelIndex = 0; pixelIndex < diffMask.length; pixelIndex += 1) {
if (diffMask[pixelIndex] !== 1 || visited[pixelIndex] === 1) continue;
let queueStart = 0;
let queueEnd = 0;
queue[queueEnd] = pixelIndex;
queueEnd += 1;
visited[pixelIndex] = 1;
const startX = pixelIndex % width;
const startY = Math.floor(pixelIndex / width);
const region: MutableDiffRegion = {
minX: startX,
minY: startY,
maxX: startX,
maxY: startY,
differentPixels: 0,
baselineRed: 0,
baselineGreen: 0,
baselineBlue: 0,
currentRed: 0,
currentGreen: 0,
currentBlue: 0,
};
while (queueStart < queueEnd) {
const currentPixelIndex = queue[queueStart]!;
queueStart += 1;
addPixelToRegion(region, currentPixelIndex, width, baseline, current);
const x = currentPixelIndex % width;
const y = Math.floor(currentPixelIndex / width);
for (let yOffset = -1; yOffset <= 1; yOffset += 1) {
const neighborY = y + yOffset;
if (neighborY < 0 || neighborY >= height) continue;
for (let xOffset = -1; xOffset <= 1; xOffset += 1) {
if (xOffset === 0 && yOffset === 0) continue;
const neighborX = x + xOffset;
if (neighborX < 0 || neighborX >= width) continue;
const neighborIndex = neighborY * width + neighborX;
if (diffMask[neighborIndex] !== 1 || visited[neighborIndex] === 1) continue;
visited[neighborIndex] = 1;
queue[queueEnd] = neighborIndex;
queueEnd += 1;
}
}
}
regions.push(region);
}
return regions;
function createDiffRegion(pixelIndex: number, width: number): MutableDiffRegion {
const startX = pixelIndex % width;
const startY = Math.floor(pixelIndex / width);
return {
minX: startX,
minY: startY,
maxX: startX,
maxY: startY,
differentPixels: 0,
baselineRed: 0,
baselineGreen: 0,
baselineBlue: 0,
currentRed: 0,
currentGreen: 0,
currentBlue: 0,
};
}
function addPixelToRegion(
+1 -2
View File
@@ -1,8 +1,7 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { PNG } from 'pngjs';
import { AppError } from '../utils/errors.ts';
import { decodePng } from './png.ts';
import { decodePng, PNG } from './png.ts';
import { annotateDiffRegions } from './screenshot-diff-region-overlay.ts';
import {
summarizeNonTextDiffDeltas,
+48
View File
@@ -0,0 +1,48 @@
import type { Rect } from './snapshot.ts';
export function unionRects(rects: Rect[]): Rect {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const rect of rects) {
minX = Math.min(minX, rect.x);
minY = Math.min(minY, rect.y);
maxX = Math.max(maxX, rect.x + rect.width);
maxY = Math.max(maxY, rect.y + rect.height);
}
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
export function expandRect(rect: Rect, padding: number): Rect {
return {
x: rect.x - padding,
y: rect.y - padding,
width: rect.width + padding * 2,
height: rect.height + padding * 2,
};
}
export function intersectArea(left: Rect, right: Rect): number {
const minX = Math.max(left.x, right.x);
const minY = Math.max(left.y, right.y);
const maxX = Math.min(left.x + left.width, right.x + right.width);
const maxY = Math.min(left.y + left.height, right.y + right.height);
if (maxX <= minX || maxY <= minY) return 0;
return (maxX - minX) * (maxY - minY);
}
export function rectCenter(rect: Rect): { x: number; y: number } {
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
}
export function squaredDistance(
left: { x: number; y: number },
right: { x: number; y: number },
): number {
return (left.x - right.x) ** 2 + (left.y - right.y) ** 2;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { PNG } from 'pngjs';
import { PNG } from '../../../src/utils/png.ts';
import type { ProviderScenarioRpcResult } from './harness.ts';
export function assertCommandCall(calls: readonly string[][], expected: readonly string[]): void {
+1 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { PNG } from 'pngjs';
import { PNG } from '../../src/utils/png.ts';
import { runCmdSync } from '../../src/utils/exec.ts';
const CLI_TIMEOUT_MS = 120_000;