Skip to content

Commit d3a0d39

Browse files
committed
perf(screenshot): read the crop region instead of decoding the whole capture
`screenshot --crop-on` paid for a full PNG decode and an RGBA re-encode of the capture before keeping a frame. One worker job now turns the captured bytes into the cropped bytes: a region reader that reconstructs pixels only down to the box's last row and allocates only the box's pixels, and a truecolor writer that drops the alpha channel when the cropped pixels carry none. The reader claims the 8-bit non-interlaced truecolor layout that iOS simulator and Android emulator captures arrive in, and only for a file whose IHDR and chunk checksums it can verify, whose chunk names it recognises, and whose rows it can unfilter. Everything else — palette, grayscale, interlaced, 16-bit, a checksum that does not match — falls through to the general PNG reader, which keeps owning the canonical decode error and the previous RGBA output. A box covering the whole image reads through that general reader too, so an unchanged answer is only reported for a file that reader accepts. Cropped bytes verify pixel-for-pixel against ImageMagick's own crop across RGB, RGBA, grayscale, palette, 16-bit, interlaced, and translucent sources, on both iOS simulator and Android emulator captures.
1 parent 93a9b14 commit d3a0d39

17 files changed

Lines changed: 1429 additions & 28 deletions
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import zlib from 'node:zlib';
2+
import { predictByte } from './png-predictor.ts';
3+
import { PNG } from './png.ts';
4+
5+
/**
6+
* Test-only PNG writer. `pngjs` picks one filter for a whole image and cannot express the
7+
* per-row mixtures, palettes, transparency, and header variations the crop paths have to
8+
* classify, so fixtures assemble the chunks directly. Every fixture is read back by `pngjs`
9+
* in the test that uses it, which is what proves this writer is honest.
10+
*/
11+
12+
const SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
13+
const FILTER_CYCLE: readonly number[] = [0, 1, 2, 3, 4];
14+
const IHDR_COLOR_TYPE_BYTE = 25;
15+
const IHDR_BIT_DEPTH_BYTE = 24;
16+
17+
export type PngFixture = Readonly<{
18+
pixels: Uint8Array;
19+
width: number;
20+
height: number;
21+
channels: number;
22+
colorType: number;
23+
bitDepth?: number;
24+
interlace?: number;
25+
compressionMethod?: number;
26+
filterMethod?: number;
27+
filterFor?: (row: number) => number;
28+
palette?: Uint8Array;
29+
transparency?: Uint8Array;
30+
ancillary?: Readonly<{ type: string; data: Uint8Array }>;
31+
}>;
32+
33+
export function encodeFixturePng(fixture: PngFixture): Buffer {
34+
const { height, width } = fixture;
35+
const header = Buffer.alloc(13);
36+
header.writeUInt32BE(width, 0);
37+
header.writeUInt32BE(height, 4);
38+
header[8] = fixture.bitDepth ?? 8;
39+
header[9] = fixture.colorType;
40+
header[10] = fixture.compressionMethod ?? 0;
41+
header[11] = fixture.filterMethod ?? 0;
42+
header[12] = fixture.interlace ?? 0;
43+
const chunks: Uint8Array[] = [SIGNATURE, pngChunk('IHDR', header)];
44+
if (fixture.ancillary) chunks.push(pngChunk(fixture.ancillary.type, fixture.ancillary.data));
45+
if (fixture.palette) chunks.push(pngChunk('PLTE', fixture.palette));
46+
if (fixture.transparency) chunks.push(pngChunk('tRNS', fixture.transparency));
47+
chunks.push(pngChunk('IDAT', zlib.deflateSync(filterScanlines(fixture), { level: 6 })));
48+
chunks.push(pngChunk('IEND', new Uint8Array(0)));
49+
return Buffer.concat(chunks);
50+
}
51+
52+
/** A pixel grid where neighbouring pixels differ, so a wrong row or column cannot hide. */
53+
export function rampPixels(
54+
width: number,
55+
height: number,
56+
channels: number,
57+
alpha: (x: number, y: number) => number = () => 255,
58+
): Uint8Array {
59+
const pixels = new Uint8Array(width * height * channels);
60+
for (let y = 0; y < height; y += 1) {
61+
for (let x = 0; x < width; x += 1) {
62+
const offset = (y * width + x) * channels;
63+
pixels[offset] = (x * 7 + y * 3) & 0xff;
64+
if (channels >= 3) {
65+
pixels[offset + 1] = (x * 11 + y * 5) & 0xff;
66+
pixels[offset + 2] = (x * 13 + y * 17) & 0xff;
67+
}
68+
if (channels === 2) pixels[offset + 1] = alpha(x, y) & 0xff;
69+
if (channels === 4) pixels[offset + 3] = alpha(x, y) & 0xff;
70+
}
71+
}
72+
return pixels;
73+
}
74+
75+
/** Reads any encoded PNG back as RGBA through `pngjs`, with the layout bytes it declares. */
76+
export function readPngForTest(buffer: Buffer): {
77+
width: number;
78+
height: number;
79+
colorType: number;
80+
bitDepth: number;
81+
rgba: Uint8Array;
82+
} {
83+
const png = PNG.sync.read(buffer);
84+
return {
85+
width: png.width,
86+
height: png.height,
87+
colorType: buffer.readUInt8(IHDR_COLOR_TYPE_BYTE),
88+
bitDepth: buffer.readUInt8(IHDR_BIT_DEPTH_BYTE),
89+
rgba: png.data,
90+
};
91+
}
92+
93+
function filterScanlines(fixture: PngFixture): Uint8Array {
94+
const { channels, height, width } = fixture;
95+
const stride = width * channels;
96+
const scanlines = new Uint8Array(height * (stride + 1));
97+
for (let row = 0; row < height; row += 1) {
98+
const data = row * (stride + 1) + 1;
99+
const filter = fixture.filterFor?.(row) ?? FILTER_CYCLE[row % FILTER_CYCLE.length]!;
100+
scanlines[row * (stride + 1)] = filter;
101+
const from = row * stride;
102+
for (let offset = 0; offset < stride; offset += 1) {
103+
scanlines[data + offset] =
104+
(fixture.pixels[from + offset]! -
105+
predictByte(
106+
filter,
107+
offset >= channels ? fixture.pixels[from + offset - channels]! : 0,
108+
row > 0 ? fixture.pixels[from - stride + offset]! : 0,
109+
row > 0 && offset >= channels ? fixture.pixels[from - stride + offset - channels]! : 0,
110+
)) &
111+
0xff;
112+
}
113+
}
114+
return scanlines;
115+
}
116+
117+
function pngChunk(type: string, data: Uint8Array): Buffer {
118+
const chunk = Buffer.alloc(12 + data.length);
119+
chunk.writeUInt32BE(data.length, 0);
120+
chunk.write(type, 4, 'ascii');
121+
chunk.set(data, 8);
122+
chunk.writeUInt32BE(zlib.crc32(chunk.subarray(4, 8 + data.length)) >>> 0, 8 + data.length);
123+
return chunk;
124+
}
125+
126+
/** Flips a byte of the named chunk's checksum, so a reader that verifies checksums must notice. */
127+
export function corruptChunkChecksum(buffer: Buffer, type: string): Buffer {
128+
const corrupted = Buffer.from(buffer);
129+
let offset = 8;
130+
while (offset + 12 <= corrupted.length) {
131+
const length = corrupted.readUInt32BE(offset);
132+
if (corrupted.toString('ascii', offset + 4, offset + 8) === type) {
133+
const checksum = offset + 8 + length;
134+
corrupted[checksum] = (corrupted[checksum] ?? 0) ^ 0xff;
135+
return corrupted;
136+
}
137+
offset += 12 + length;
138+
}
139+
throw new Error(`fixture PNG carries no ${type} chunk to corrupt`);
140+
}

0 commit comments

Comments
 (0)