From 75702d803e38e4faa1fc84d12ba36e3599d1651f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Jul 2026 12:34:43 +0200 Subject: [PATCH] refactor: restore pngjs codec --- package.json | 2 + pnpm-lock.yaml | 19 + .../__tests__/screenshot-diff.test.ts | 2 +- src/screenshot-diff/screenshot-diff.ts | 3 +- src/utils/__tests__/png-worker-client.test.ts | 4 +- src/utils/__tests__/png.test.ts | 215 -------- src/utils/png-codec.ts | 483 ------------------ src/utils/png-resize.ts | 2 +- src/utils/png-worker-client.ts | 7 +- src/utils/png-worker.ts | 10 +- src/utils/png.ts | 2 +- tsdown.config.ts | 2 +- 12 files changed, 36 insertions(+), 715 deletions(-) delete mode 100644 src/utils/__tests__/png.test.ts delete mode 100644 src/utils/png-codec.ts diff --git a/package.json b/package.json index f1af197bdd..bd4664003a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb22677668..6ba2e7a1a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/screenshot-diff/__tests__/screenshot-diff.test.ts b/src/screenshot-diff/__tests__/screenshot-diff.test.ts index 9e8ea8c674..7aafcea8cf 100644 --- a/src/screenshot-diff/__tests__/screenshot-diff.test.ts +++ b/src/screenshot-diff/__tests__/screenshot-diff.test.ts @@ -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'), ]); diff --git a/src/screenshot-diff/screenshot-diff.ts b/src/screenshot-diff/screenshot-diff.ts index fdd00c7230..fc37343d0a 100644 --- a/src/screenshot-diff/screenshot-diff.ts +++ b/src/screenshot-diff/screenshot-diff.ts @@ -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)); diff --git a/src/utils/__tests__/png-worker-client.test.ts b/src/utils/__tests__/png-worker-client.test.ts index f8481d0733..4244e304f3 100644 --- a/src/utils/__tests__/png-worker-client.test.ts +++ b/src/utils/__tests__/png-worker-client.test.ts @@ -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; }, ); diff --git a/src/utils/__tests__/png.test.ts b/src/utils/__tests__/png.test.ts deleted file mode 100644 index c318478152..0000000000 --- a/src/utils/__tests__/png.test.ts +++ /dev/null @@ -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; -} diff --git a/src/utils/png-codec.ts b/src/utils/png-codec.ts deleted file mode 100644 index 2f3afb1952..0000000000 --- a/src/utils/png-codec.ts +++ /dev/null @@ -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 = new Map([ - [0, 1], - [2, 3], - [3, 1], - [4, 2], - [6, 4], -] as const); -const VALID_BIT_DEPTHS: ReadonlyMap> = 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 { - 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'; -} diff --git a/src/utils/png-resize.ts b/src/utils/png-resize.ts index a8955f6385..257f54658a 100644 --- a/src/utils/png-resize.ts +++ b/src/utils/png-resize.ts @@ -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'; /** diff --git a/src/utils/png-worker-client.ts b/src/utils/png-worker-client.ts index c2775624dc..932d4b6bb4 100644 --- a/src/utils/png-worker-client.ts +++ b/src/utils/png-worker-client.ts @@ -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 { diff --git a/src/utils/png-worker.ts b/src/utils/png-worker.ts index 86f74209e0..3da1a5dbe8 100644 --- a/src/utils/png-worker.ts +++ b/src/utils/png-worker.ts @@ -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': { diff --git a/src/utils/png.ts b/src/utils/png.ts index d6f8f05a7e..a485e68184 100644 --- a/src/utils/png.ts +++ b/src/utils/png.ts @@ -1,5 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; -import { PNG } from './png-codec.ts'; +import { PNG } from 'pngjs'; export { PNG }; diff --git a/tsdown.config.ts b/tsdown.config.ts index bd3ef58661..245d0b6e96 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -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',