refactor: restore pngjs codec (#1498)

This commit is contained in:
Michał Pierzchała
2026-07-30 14:12:50 +02:00
committed by GitHub
parent 47540f1f07
commit 7ef83bc8bc
12 changed files with 36 additions and 715 deletions
+2
View File
@@ -241,6 +241,7 @@
],
"dependencies": {
"@limrun/api": "^0.24.5",
"pngjs": "^7.0.0",
"yaml": "^2.9.0"
},
"devDependencies": {
@@ -249,6 +250,7 @@
"@stryker-mutator/core": "9.6.1",
"@stryker-mutator/vitest-runner": "9.6.1",
"@types/node": "^22.19.21",
"@types/pngjs": "^6.0.5",
"@vitest/coverage-v8": "4.1.8",
"fallow": "^2.95.0",
"fast-check": "^4.9.0",
+19
View File
@@ -17,6 +17,9 @@ importers:
'@limrun/api':
specifier: ^0.24.5
version: 0.24.5(supports-color@7.2.0)
pngjs:
specifier: ^7.0.0
version: 7.0.0
yaml:
specifier: ^2.9.0
version: 2.9.0
@@ -36,6 +39,9 @@ importers:
'@types/node':
specifier: ^22.19.21
version: 22.19.21
'@types/pngjs':
specifier: ^6.0.5
version: 6.0.5
'@vitest/coverage-v8':
specifier: 4.1.8
version: 4.1.8(vitest@4.1.8)
@@ -1288,6 +1294,9 @@ packages:
'@types/node@22.19.21':
resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==}
'@types/pngjs@6.0.5':
resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
'@types/react@19.2.13':
resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==}
@@ -2518,6 +2527,10 @@ packages:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
postcss@8.5.18:
resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==}
engines: {node: ^10 || ^12 || >=14}
@@ -4215,6 +4228,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/pngjs@6.0.5':
dependencies:
'@types/node': 22.19.21
'@types/react@19.2.13':
dependencies:
csstype: 3.2.3
@@ -5621,6 +5638,8 @@ snapshots:
picomatch@4.0.5: {}
pngjs@7.0.0: {}
postcss@8.5.18:
dependencies:
nanoid: 3.3.12
@@ -6,7 +6,7 @@ import path from 'node:path';
vi.mock('../../utils/png-worker-client.ts', async () => {
const [{ PNG }, { decodePng }, { computeScreenshotDiffPixels }] = await Promise.all([
import('../../utils/png-codec.ts'),
import('../../utils/png.ts'),
import('../../utils/png.ts'),
import('../../utils/screenshot-diff-pixels.ts'),
]);
+2 -1
View File
@@ -112,7 +112,8 @@ export async function compareScreenshots(
: [];
if (differentPixels > 0 && diffOutputPath) {
const diff = new PNG({ width: baseline.width, height: baseline.height, data: diffData });
const diff = new PNG({ width: baseline.width, height: baseline.height });
diff.data = diffData;
annotateDiffRegions(diff, regions);
await fs.mkdir(path.dirname(diffOutputPath), { recursive: true });
await fs.writeFile(diffOutputPath, await encodePngAsync(diff));
@@ -1,7 +1,7 @@
import { afterAll, test } from 'vitest';
import assert from 'node:assert/strict';
import { AppError } from '@agent-device/kernel/errors';
import { PNG } from '../png-codec.ts';
import { PNG } from '../png.ts';
import {
computePngRgbDifferenceAsync,
computeScreenshotDiffPixelsAsync,
@@ -101,7 +101,7 @@ test('decodePngAsync rejects invalid PNG data with the canonical decode AppError
assert.equal(error.code, 'COMMAND_FAILED');
assert.match(error.message, /Failed to decode fixture as PNG/);
assert.equal(error.details?.label, 'fixture');
assert.match(String(error.details?.reason), /Invalid PNG signature/);
assert.ok(String(error.details?.reason).length > 0);
return true;
},
);
-215
View File
@@ -1,215 +0,0 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { deflateSync } from 'node:zlib';
import { PNG } from '../png.ts';
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 readPngPixel(png: PNG, x: number, y: number): number[] {
const offset = (y * png.width + x) * 4;
return [
png.data[offset] ?? 0,
png.data[offset + 1] ?? 0,
png.data[offset + 2] ?? 0,
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;
}
-483
View File
@@ -1,483 +0,0 @@
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';
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { promises as fs } from 'node:fs';
import { AppError } from '@agent-device/kernel/errors';
import { PNG } from './png-codec.ts';
import { PNG } from './png.ts';
import { decodePngAsync, encodePngAsync } from './png-worker-client.ts';
/**
+4 -3
View File
@@ -2,8 +2,7 @@ import { Worker } from 'node:worker_threads';
import { emitDiagnostic } from './diagnostics.ts';
import { AppError, toAppErrorCode } from '@agent-device/kernel/errors';
import { resolveInternalEntryModulePath } from './internal-entry.ts';
import { PNG } from './png-codec.ts';
import { decodePng } from './png.ts';
import { decodePng, PNG } from './png.ts';
import {
computeScreenshotDiffPixels,
type ScreenshotDiffPixelsJob,
@@ -201,7 +200,9 @@ export async function decodePngAsync(buffer: Buffer, label: string): Promise<PNG
const png = decodePng(buffer, label);
return { kind: 'decode', width: png.width, height: png.height, data: png.data };
});
return new PNG({ width: result.width, height: result.height, data: toBuffer(result.data) });
const png = new PNG({ width: result.width, height: result.height });
png.data = toBuffer(result.data);
return png;
}
export async function encodePngAsync(png: PNG): Promise<Buffer> {
+3 -7
View File
@@ -1,7 +1,6 @@
import { parentPort } from 'node:worker_threads';
import { normalizeError } from '@agent-device/kernel/errors';
import { PNG } from './png-codec.ts';
import { decodePng } from './png.ts';
import { decodePng, PNG } from './png.ts';
import { computeScreenshotDiffPixels } from './screenshot-diff-pixels.ts';
import { computePngRgbDifference } from './png-rgb-difference.ts';
import {
@@ -24,11 +23,8 @@ function runJob(request: PngWorkerRequest): PngWorkerJobResult {
return { kind: 'decode', width: png.width, height: png.height, data: png.data };
}
case 'encode': {
const png = new PNG({
width: request.width,
height: request.height,
data: toBuffer(request.data),
});
const png = new PNG({ width: request.width, height: request.height });
png.data = toBuffer(request.data);
return { kind: 'encode', png: PNG.sync.write(png) };
}
case 'rgb-difference': {
+1 -1
View File
@@ -1,5 +1,5 @@
import { AppError } from '@agent-device/kernel/errors';
import { PNG } from './png-codec.ts';
import { PNG } from 'pngjs';
export { PNG };
+1 -1
View File
@@ -73,7 +73,7 @@ export default defineConfig({
'internal/png-worker': 'src/utils/png-worker.ts',
'internal/update-check-entry': 'src/utils/update-check-entry.ts',
},
noExternal: [/^@agent-device\//],
noExternal: [/^@agent-device\//, 'pngjs'],
format: 'esm',
platform: 'node',
target: 'es2022',