Skip to content

Commit 6ae738d

Browse files
committed
refactor: vendor png codec
1 parent 7a2428e commit 6ae738d

19 files changed

Lines changed: 580 additions & 40 deletions

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,12 @@
205205
],
206206
"dependencies": {
207207
"fast-xml-parser": "^5.7.2",
208-
"pngjs": "^7.0.0",
209208
"yaml": "^2.9.0"
210209
},
211210
"devDependencies": {
212211
"@microsoft/api-extractor": "^7.58.7",
213212
"@rslib/core": "0.20.1",
214213
"@types/node": "^22.0.0",
215-
"@types/pngjs": "^6.0.5",
216214
"@vitest/coverage-v8": "4.1.2",
217215
"fallow": "^2.52.0",
218216
"oxfmt": "^0.42.0",

pnpm-lock.yaml

Lines changed: 0 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/cli-client-commands.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import os from 'node:os';
33
import path from 'node:path';
44
import { test } from 'vitest';
55
import assert from 'node:assert/strict';
6-
import { PNG } from 'pngjs';
6+
import { PNG } from '../utils/png.ts';
77
import { tryRunClientBackedCommand } from '../cli/commands/router.ts';
88
import type {
99
AgentDeviceClient,

src/__tests__/cli-diff.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
33
import fs from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6-
import { PNG } from 'pngjs';
6+
import { PNG } from '../utils/png.ts';
77
import type { DaemonResponse } from '../daemon-client.ts';
88
import {
99
runCliCapture as captureCli,

src/__tests__/runtime-diff-screenshot.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
22
import fs from 'node:fs';
33
import os from 'node:os';
44
import path from 'node:path';
5-
import { PNG } from 'pngjs';
5+
import { PNG } from '../utils/png.ts';
66
import { test } from 'vitest';
77
import type {
88
AgentDeviceBackend,

src/daemon/__tests__/request-router-screenshot.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { dispatchScreenshotViaRuntime } from '../screenshot-runtime.ts';
2222
import type { SessionState } from '../types.ts';
2323
import { LeaseRegistry } from '../lease-registry.ts';
2424
import { attachRefs } from '../../utils/snapshot.ts';
25-
import { PNG } from 'pngjs';
25+
import { PNG } from '../../utils/png.ts';
2626
import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
2727
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
2828
import { makeSession as makeBaseSession } from '../../__tests__/test-utils/session-factories.ts';

src/daemon/__tests__/screenshot-overlay.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
33
import fs from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6-
import { PNG } from 'pngjs';
6+
import { PNG } from '../../utils/png.ts';
77
import { annotateScreenshotWithRefs, buildScreenshotOverlayRefs } from '../screenshot-overlay.ts';
88
import { makeSnapshotState } from '../../__tests__/test-utils/snapshot-builders.ts';
99

src/daemon/handlers/__tests__/snapshot-handler.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { test, expect, vi, beforeEach } from 'vitest';
22
import fs from 'node:fs';
33
import os from 'node:os';
44
import path from 'node:path';
5-
import { PNG } from 'pngjs';
5+
import { PNG } from '../../../utils/png.ts';
66
import { handleSnapshotCommands } from '../snapshot.ts';
77
import { captureSnapshot } from '../snapshot-capture.ts';
88
import { SessionStore } from '../../session-store.ts';

src/daemon/screenshot-overlay.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { promises as fs } from 'node:fs';
2-
import { PNG } from 'pngjs';
32
import {
43
centerOfRect,
54
type Rect,
65
type ScreenshotOverlayRef,
76
type SnapshotNode,
87
type SnapshotState,
98
} from '../utils/snapshot.ts';
10-
import { decodePng } from '../utils/png.ts';
9+
import { decodePng, PNG } from '../utils/png.ts';
1110
import { findNearestAncestor, normalizeType } from './snapshot-processing.ts';
1211
import { resolveAndroidOverlaySourceRect } from './screenshot-overlay-android.ts';
1312
import { hasPositiveRect, rectArea, rectContains } from './screenshot-overlay-rects.ts';

src/utils/__tests__/png.test.ts

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
33
import fs from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6-
import { PNG } from 'pngjs';
7-
import { resizePngFileToMaxSize } from '../png.ts';
6+
import { deflateSync } from 'node:zlib';
7+
import { PNG, resizePngFileToMaxSize } from '../png.ts';
88

99
test('resizePngFileToMaxSize leaves smaller images unchanged', async () => {
1010
const filePath = tmpPngPath('unchanged');
@@ -20,6 +20,91 @@ test('resizePngFileToMaxSize leaves smaller images unchanged', async () => {
2020
assert.deepEqual(readPngPixel(unchanged, 3, 1), [45, 90, 135, 255]);
2121
});
2222

23+
test('PNG sync reader decodes filtered RGB image data', () => {
24+
const png = PNG.sync.read(
25+
encodeTestPng({
26+
width: 2,
27+
height: 1,
28+
bitDepth: 8,
29+
colorType: 2,
30+
rawScanlines: Buffer.from([1, 10, 20, 30, 40, 60, 100]),
31+
}),
32+
);
33+
34+
assert.equal(png.width, 2);
35+
assert.equal(png.height, 1);
36+
assert.deepEqual(readPngPixel(png, 0, 0), [10, 20, 30, 255]);
37+
assert.deepEqual(readPngPixel(png, 1, 0), [50, 80, 130, 255]);
38+
});
39+
40+
test('PNG sync reader decodes indexed color and transparency', () => {
41+
const png = PNG.sync.read(
42+
encodeTestPng({
43+
width: 4,
44+
height: 1,
45+
bitDepth: 2,
46+
colorType: 3,
47+
palette: Buffer.from([255, 0, 0, 0, 255, 0, 0, 0, 255, 20, 30, 40]),
48+
transparency: Buffer.from([255, 200, 80, 255]),
49+
rawScanlines: Buffer.from([0, 0b00011011]),
50+
}),
51+
);
52+
53+
assert.deepEqual(readPngPixel(png, 0, 0), [255, 0, 0, 255]);
54+
assert.deepEqual(readPngPixel(png, 1, 0), [0, 255, 0, 200]);
55+
assert.deepEqual(readPngPixel(png, 2, 0), [0, 0, 255, 80]);
56+
assert.deepEqual(readPngPixel(png, 3, 0), [20, 30, 40, 255]);
57+
});
58+
59+
test('PNG sync reader decodes Adam7 interlaced RGB image data', () => {
60+
const png = PNG.sync.read(
61+
encodeTestPng({
62+
width: 3,
63+
height: 3,
64+
bitDepth: 8,
65+
colorType: 2,
66+
interlace: 1,
67+
rawScanlines: Buffer.from([
68+
0,
69+
...rgb(0, 0),
70+
0,
71+
...rgb(2, 0),
72+
0,
73+
...rgb(0, 2),
74+
...rgb(2, 2),
75+
0,
76+
...rgb(1, 0),
77+
0,
78+
...rgb(1, 2),
79+
0,
80+
...rgb(0, 1),
81+
...rgb(1, 1),
82+
...rgb(2, 1),
83+
]),
84+
}),
85+
);
86+
87+
for (let y = 0; y < 3; y += 1) {
88+
for (let x = 0; x < 3; x += 1) {
89+
assert.deepEqual(readPngPixel(png, x, y), [...rgb(x, y), 255]);
90+
}
91+
}
92+
});
93+
94+
test('PNG sync reader rejects invalid chunk CRCs', () => {
95+
const bytes = encodeTestPng({
96+
width: 1,
97+
height: 1,
98+
bitDepth: 8,
99+
colorType: 2,
100+
rawScanlines: Buffer.from([0, ...rgb(0, 0)]),
101+
});
102+
const lastByte = bytes.length - 1;
103+
bytes[lastByte] = (bytes[lastByte] ?? 0) ^ 0xff;
104+
105+
assert.throws(() => PNG.sync.read(bytes), /Invalid PNG .* chunk CRC/);
106+
});
107+
23108
function tmpPngPath(prefix: string): string {
24109
return path.join(
25110
fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-png-${prefix}-`)),
@@ -56,3 +141,57 @@ function readPngPixel(png: PNG, x: number, y: number): number[] {
56141
png.data[offset + 3] ?? 0,
57142
];
58143
}
144+
145+
function encodeTestPng(params: {
146+
width: number;
147+
height: number;
148+
bitDepth: number;
149+
colorType: number;
150+
rawScanlines: Buffer;
151+
interlace?: 0 | 1;
152+
palette?: Buffer;
153+
transparency?: Buffer;
154+
}): Buffer {
155+
const ihdr = Buffer.alloc(13);
156+
ihdr.writeUInt32BE(params.width, 0);
157+
ihdr.writeUInt32BE(params.height, 4);
158+
ihdr[8] = params.bitDepth;
159+
ihdr[9] = params.colorType;
160+
ihdr[10] = 0;
161+
ihdr[11] = 0;
162+
ihdr[12] = params.interlace ?? 0;
163+
164+
return Buffer.concat([
165+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
166+
encodeTestChunk('IHDR', ihdr),
167+
...(params.palette ? [encodeTestChunk('PLTE', params.palette)] : []),
168+
...(params.transparency ? [encodeTestChunk('tRNS', params.transparency)] : []),
169+
encodeTestChunk('IDAT', deflateSync(params.rawScanlines)),
170+
encodeTestChunk('IEND', Buffer.alloc(0)),
171+
]);
172+
}
173+
174+
function rgb(x: number, y: number): [number, number, number] {
175+
return [x * 40 + 10, y * 50 + 20, x * 30 + y * 20 + 30];
176+
}
177+
178+
function encodeTestChunk(type: string, data: Buffer): Buffer {
179+
const typeBuffer = Buffer.from(type, 'ascii');
180+
const chunk = Buffer.alloc(8 + data.length + 4);
181+
chunk.writeUInt32BE(data.length, 0);
182+
typeBuffer.copy(chunk, 4);
183+
data.copy(chunk, 8);
184+
chunk.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 8 + data.length);
185+
return chunk;
186+
}
187+
188+
function crc32(buffer: Buffer): number {
189+
let crc = 0xffffffff;
190+
for (const byte of buffer) {
191+
crc ^= byte;
192+
for (let bit = 0; bit < 8; bit += 1) {
193+
crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
194+
}
195+
}
196+
return (crc ^ 0xffffffff) >>> 0;
197+
}

0 commit comments

Comments
 (0)