cdp-toolkit 1.9.1 → 1.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/png.ts ADDED
@@ -0,0 +1,809 @@
1
+ /**
2
+ * png.ts : a lossless, streaming, browser-free stitcher for vertically banded PNG captures.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * ===============
6
+ * Chrome's `Page.captureScreenshot` cannot produce an image larger than 16384 device px on
7
+ * either side. Measured exact to the pixel on Chrome/151: 16384 encodes in ~250ms, 16400 never
8
+ * answers and permanently wedges the tab. It holds on BOTH axes, clipped and unclipped alike,
9
+ * and it is not an area cap (8192x8192 succeeds at 67M px while 16400x1600 hangs at 26M).
10
+ * Output device px is `ceil(css * scale * devicePixelRatio)`, so on a 2x display a full-page
11
+ * capture dies past ~8192 CSS px of page, and at `scale: 4` past ~2048 CSS px : an ordinary
12
+ * long page. The way out is to capture the page in horizontal BANDS, each under the cap, and
13
+ * glue them back together.
14
+ *
15
+ * This module is the glue and ONLY the glue. It opens no socket, imports nothing from
16
+ * client.ts, and knows nothing about CDP; it takes PNG bytes and writes PNG bytes. That split
17
+ * is deliberate: the size limit is a browser fact, but the fix is an image-format fact, and
18
+ * mixing them would make the hard part (the pixel math below) untestable without a browser.
19
+ *
20
+ * WHAT CHROME ACTUALLY EMITS (measured, not assumed)
21
+ * --------------------------------------------------
22
+ * Every IHDR of five real `Page.captureScreenshot` PNGs (800x600, 2560x16384, 8192x8192,
23
+ * 1600x16384, 16384x1600) reads byte-identically past the dimensions:
24
+ *
25
+ * bit depth 8, colour type 2 (truecolour RGB, 3 bytes/px), compression 0, filter 0,
26
+ * interlace 0
27
+ *
28
+ * so interlace is never seen in practice, but is still rejected explicitly below rather than
29
+ * mis-decoded. Their chunk tables are `IHDR, iCCP, IDAT x N, IEND`, with N = 8, 40, 2709,
30
+ * 4421 and 261 respectively: MULTI-IDAT IS THE NORMAL CASE, not an edge case, for every
31
+ * capture large enough to need this module. And Chrome does emit one ancillary chunk, an
32
+ * iCCP ICC colour profile : a stitcher that dropped ancillary chunks wholesale would silently
33
+ * recolour every Chrome capture it touched, which is why the carry policy below exists at all.
34
+ *
35
+ * WHAT IS SUPPORTED
36
+ * -----------------
37
+ * supported : non-interlaced, colour types 0/2/4/6 (grey, RGB, grey+alpha, RGBA) at every
38
+ * bit depth the PNG spec allows for that type (1/2/4/8/16 for grey, 8/16 for the
39
+ * rest). Nothing here is RGB-specific : filtering is defined on BYTES, with
40
+ * `bpp = ceil(bitsPerPixel/8)` and `bpr = ceil(width*bitsPerPixel/8)`, so the
41
+ * same code is correct for all of them.
42
+ * rejected : interlaced (Adam7), with a named error : its seven reduced passes are not
43
+ * scanlines of the final image, so a band-wise concatenation of them is not the
44
+ * concatenated image. Detected from IHDR byte 12, never guessed.
45
+ * rejected : colour type 3 (palette), with a named error. Stitching palettized bands would
46
+ * additionally require every band's PLTE/tRNS to be identical or a full palette
47
+ * remap; Chrome never emits one, so the untested path is refused rather than
48
+ * written.
49
+ *
50
+ * STREAMING, AND WHAT "BOUNDED" MEANS HERE
51
+ * ----------------------------------------
52
+ * The images this exists for do not fit in memory: 2560x140982 RGBA is ~1.4 GB raw. So no step
53
+ * ever holds the whole image. Per band the module inflates that band's IDAT stream in 256 KiB
54
+ * chunks, un-filters ONE scanline at a time, and appends it to a 4 MiB block that is filtered,
55
+ * chosen, and pushed into a single continuous deflate stream whose output is framed straight
56
+ * into IDAT chunks on disk. Live state is: the band's own compressed bytes (the caller's
57
+ * buffer : Chrome hands a whole PNG back per capture, so one compressed band is the floor),
58
+ * zlib's windows, one block plus its two candidate encodings (~12 MiB), and two scanlines.
59
+ *
60
+ * MEASURED with `/usr/bin/time -l`, feeding an async generator that synthesises 1280x2048
61
+ * bands on the fly (so the generator's own working set is inside the number too):
62
+ *
63
+ * bands output rows raw image wall peak RSS
64
+ * 20 40,960 150 MB 1.14s 121.1 MB
65
+ * 60 122,880 471 MB 3.31s 129.9 MB
66
+ * 180 368,640 1415 MB 9.81s 135.3 MB
67
+ *
68
+ * 9.4x the image for +11.7% RSS (23.6 MB of which is bun's own floor), and wall clock linear in
69
+ * pixels. Peak tracks BAND size, not image size, which is the property that matters. The last
70
+ * row's 1280x368640 output is past what the verifying tools can read back : ffmpeg refuses it
71
+ * ("Picture size 1280x368640 is invalid", its ~268 Mpx ceiling) and `sips` reports nil
72
+ * dimensions, so the largest INDEPENDENTLY VERIFIED stitch is the 1280x122880 middle row.
73
+ *
74
+ * THE THING THAT IS EASY TO GET SILENTLY WRONG: prev-row context at a band seam
75
+ * ----------------------------------------------------------------------------
76
+ * A PNG row filter is relative to the row above it, and "the row above" is all zeros only for
77
+ * the first row of an IMAGE. Since every band IS its own PNG, DECODING must reset that context
78
+ * at the top of every band; an implementation that carries the previous band's last row into
79
+ * the first row of the next one silently mis-decodes exactly one row per seam. Nothing about
80
+ * the file is structurally wrong when this happens : it is a valid PNG with a few wrong rows,
81
+ * the kind of bug that survives eyeballing. `test/png.test.ts` pins a prev-DEPENDENT filter
82
+ * (Up/Average/Paeth) onto the first row of bands 2 and 3 for this reason: neither ffmpeg nor
83
+ * Chrome ever emits one there (both fall back to None/Sub when the row above is known-zero),
84
+ * so a fixture built by hand is the only thing that catches it.
85
+ *
86
+ * HOW OUTPUT ROWS ARE RE-FILTERED: measured per block, not predicted
87
+ * ------------------------------------------------------------------
88
+ * The textbook answer is a per-row minimum-sum-of-absolute-differences heuristic. It is wrong
89
+ * here, and so is its opposite. Every strategy below was implemented and run end to end at
90
+ * zlib level 6 over all five real Chrome captures plus three ffmpeg control images (each input
91
+ * stitched to itself, so every seam is exercised); bytes of the stitched output:
92
+ *
93
+ * input (x2 bands, x4 for controls) heuristic all-None THIS (per block)
94
+ * real 800x600 -> 800x1200 56,020 52,191 52,174
95
+ * real 2560x16384 -> 2560x32768 317,509 1,163,677 (!) 317,497
96
+ * real 1600x16384 -> 1600x32768 35,958,755 30,727,639 30,724,184
97
+ * real 8192x8192 -> 8192x16384 22,913,788 18,216,704 18,207,830
98
+ * real16384x1600 ->16384x3200 2,330,190 2,284,030 2,283,366
99
+ * mandelbrot -> 1200x3200 1,995,356 2,182,530 1,995,116
100
+ * testsrc2 -> 1200x3200 375,421 443,609 375,325
101
+ * white noise -> 1200x3200 8,173,014 8,173,014 8,172,766
102
+ *
103
+ * Neither fixed strategy is safe: the heuristic is 15-20% too big on two real captures, and
104
+ * None is 266% too big on another real one. The reason the heuristic loses on screenshots is
105
+ * that it scores one row in isolation and cannot see what actually compresses a screenshot :
106
+ * long literal runs that deflate's LZ77 matches against the rows ABOVE them. Filtering turns
107
+ * those runs into residuals and destroys the matches. The reason None loses on gradients is
108
+ * the mirror image.
109
+ *
110
+ * So the choice is not predicted, it is measured. Rows accumulate into a 4 MiB block; the
111
+ * block is filtered BOTH ways, both encodings are compressed with a throwaway deflate, and
112
+ * whichever is genuinely smaller is what goes into the output stream. That lands at or below
113
+ * the better fixed strategy on all eight images above, adapts down a page whose top is a photo
114
+ * and whose body is text, and costs two extra deflates per block (1.6x the heuristic's wall
115
+ * clock, 3.1x plain None's, on the 1600x32768 stitch).
116
+ *
117
+ * This is also what keeps the ENCODE-side prev-row carry load-bearing: the heuristic candidate
118
+ * filters each block's first row against the last row of the previous block, which at a band
119
+ * seam is the last row of the previous BAND. (Measured, and worth knowing before "fixing" it:
120
+ * zeroing that carry does NOT corrupt output, because the chooser and the filter it picks read
121
+ * the same buffer, so a zeroed one just collapses the choice onto None/Sub, which ignore it.
122
+ * It was worth 2.1% of output size, never correctness. The DECODE-side reset, above, is the
123
+ * one that is a correctness bug.)
124
+ *
125
+ * ANCILLARY CHUNKS
126
+ * ----------------
127
+ * This matters more than it looks, because Chrome DOES emit one (the iCCP measured above), so
128
+ * "drop everything ancillary" would recolour every real capture. Chunks that describe the
129
+ * SAMPLES and stay true under vertical concatenation are carried from the first band and
130
+ * required to match byte-for-byte in every later band (a disagreement throws, naming the
131
+ * chunk): gAMA, cHRM, sRGB, iCCP, pHYs. Everything else is dropped : tEXt/iTXt/zTXt/tIME
132
+ * describe one capture at one instant, and silently re-labelling the whole stitch with band 1's
133
+ * copy is worse than not having it.
134
+ *
135
+ * HONEST FAILURE
136
+ * --------------
137
+ * All output goes to `<outPath>.stitch-partial` and is renamed onto `outPath` only after IEND
138
+ * is written and the IHDR height is patched to the real total. Any throw removes the partial,
139
+ * so a failed stitch never leaves a file at the caller's path : not a truncated one, and not a
140
+ * structurally valid one that is short a few thousand rows.
141
+ *
142
+ * ZERO NEW DEPENDENCIES, per CONTRACT rule 1: `node:zlib` is a Node builtin (this codebase
143
+ * already imports node:fs/promises, node:path, node:async_hooks, node:child_process). CRC32 is
144
+ * hand-written below because chunk framing needs it and "if you need a helper, write it".
145
+ */
146
+ import { once } from "node:events";
147
+ import { mkdir, open, rename, rm } from "node:fs/promises";
148
+ import type { FileHandle } from "node:fs/promises";
149
+ import { dirname } from "node:path";
150
+ import { createDeflate, createInflate, deflateSync } from "node:zlib";
151
+
152
+ /** The 8 bytes every PNG starts with: \x89 P N G \r \n \x1a \n. */
153
+ const PNG_SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
154
+
155
+ /** zlib in/out chunk size. 256 KiB keeps IDAT framing overhead at 12 bytes per 256 KiB (0.005%). */
156
+ const ZLIB_CHUNK = 1 << 18;
157
+
158
+ /**
159
+ * Rows are filtered and emitted a block at a time. 4 MiB of raw scanlines is enough for the
160
+ * two throwaway deflates in `emitBlock` to reflect real compressibility, and small enough that
161
+ * the block plus its two candidate encodings stay a rounding error next to one band.
162
+ */
163
+ const BLOCK_RAW_BYTES = 1 << 22;
164
+
165
+ /** ...but never more rows than this, so a 1-px-wide image cannot buffer a million of them. */
166
+ const MAX_BLOCK_ROWS = 4096;
167
+
168
+ /**
169
+ * Compression level for the throwaway per-block decision deflates: the SAME level the output
170
+ * stream uses, because a cheaper decision is a wrong decision. Deciding at level 1 was tried
171
+ * and measured: it is ~30% faster overall but picks the loser on 2 of the 8 measured images
172
+ * (16384x1600 real capture +2.0%, ffmpeg mandelbrot +9.4%), because level 1's shorter match
173
+ * search under-rates exactly the long cross-row LZ77 matches the decision turns on.
174
+ */
175
+ const DECIDE_LEVEL = 6;
176
+
177
+ /** Ancillary chunks that survive vertical concatenation unchanged. See the header for the policy. */
178
+ const CARRIED_CHUNKS: readonly string[] = ["gAMA", "cHRM", "sRGB", "iCCP", "pHYs"];
179
+
180
+ /** Channels per pixel for each PNG colour type. 3 (palette) is absent on purpose : it is rejected. */
181
+ const CHANNELS: Readonly<Record<number, number>> = { 0: 1, 2: 3, 4: 2, 6: 4 };
182
+
183
+ /** Bit depths the PNG spec allows for each supported colour type. */
184
+ const LEGAL_DEPTHS: Readonly<Record<number, readonly number[]>> = {
185
+ 0: [1, 2, 4, 8, 16],
186
+ 2: [8, 16],
187
+ 4: [8, 16],
188
+ 6: [8, 16],
189
+ };
190
+
191
+ const COLOUR_TYPE_NAMES: Readonly<Record<number, string>> = {
192
+ 0: "greyscale", 2: "truecolour RGB", 3: "palette", 4: "greyscale+alpha", 6: "RGBA",
193
+ };
194
+
195
+ /** Every failure this module raises. Carries a message that names the band and the mismatch. */
196
+ export class PngStitchError extends Error {
197
+ constructor(message: string) {
198
+ super(message);
199
+ this.name = "PngStitchError";
200
+ }
201
+ }
202
+
203
+ /** What a completed stitch reports. `bytes` is the size of the file actually written. */
204
+ export interface PngStitchResult {
205
+ width: number;
206
+ height: number;
207
+ bytes: number;
208
+ bands: number;
209
+ }
210
+
211
+ /** The IHDR fields that must agree across every band (height is the one that may differ). */
212
+ interface BandHeader {
213
+ width: number;
214
+ height: number;
215
+ bitDepth: number;
216
+ colourType: number;
217
+ }
218
+
219
+ interface CarriedChunk {
220
+ type: string;
221
+ data: Uint8Array;
222
+ }
223
+
224
+ interface ParsedBand {
225
+ header: BandHeader;
226
+ /** [start, end) byte ranges of each IDAT payload, in file order. Views, never copies. */
227
+ idat: Array<[number, number]>;
228
+ carried: CarriedChunk[];
229
+ }
230
+
231
+ /* --------------------------------- CRC32 --------------------------------- */
232
+
233
+ const CRC_TABLE = (() => {
234
+ const table = new Uint32Array(256);
235
+ for (let n = 0; n < 256; n++) {
236
+ let c = n;
237
+ for (let k = 0; k < 8; k++) c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
238
+ table[n] = c >>> 0;
239
+ }
240
+ return table;
241
+ })();
242
+
243
+ /** Incremental CRC32 (PNG's, the standard IEEE one). Seed 0xffffffff, xor the result at the end. */
244
+ function crcUpdate(crc: number, bytes: Uint8Array): number {
245
+ let c = crc;
246
+ for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8);
247
+ return c >>> 0;
248
+ }
249
+
250
+ /** CRC32 of one buffer. Exported so a caller framing its own chunks does not need a second copy of this. */
251
+ export function crc32(bytes: Uint8Array): number {
252
+ return (crcUpdate(0xffffffff, bytes) ^ 0xffffffff) >>> 0;
253
+ }
254
+
255
+ /* ------------------------------ byte helpers ------------------------------ */
256
+
257
+ function readU32(b: Uint8Array, off: number): number {
258
+ return ((b[off]! << 24) | (b[off + 1]! << 16) | (b[off + 2]! << 8) | b[off + 3]!) >>> 0;
259
+ }
260
+
261
+ function chunkTypeAt(b: Uint8Array, off: number): string {
262
+ return String.fromCharCode(b[off]!, b[off + 1]!, b[off + 2]!, b[off + 3]!);
263
+ }
264
+
265
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
266
+ if (a.length !== b.length) return false;
267
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
268
+ return true;
269
+ }
270
+
271
+ /* --------------------------- PNG row filtering --------------------------- */
272
+
273
+ /** PNG's Paeth predictor (spec 9.4): pick whichever of left/up/upper-left the linear estimate is nearest. */
274
+ function paeth(a: number, b: number, c: number): number {
275
+ const p = a + b - c;
276
+ const pa = p > a ? p - a : a - p;
277
+ const pb = p > b ? p - b : b - p;
278
+ const pc = p > c ? p - c : c - p;
279
+ if (pa <= pb && pa <= pc) return a;
280
+ return pb <= pc ? b : c;
281
+ }
282
+
283
+ /**
284
+ * Reverse a PNG row filter IN PLACE. `row` arrives filtered and leaves raw; `prev` is the raw
285
+ * row above (all zeros for the first row of an image, which for us means the first row of each
286
+ * BAND, since every band is its own PNG).
287
+ */
288
+ function unfilterRow(type: number, row: Uint8Array, prev: Uint8Array, bpp: number, at: string): void {
289
+ const n = row.length;
290
+ switch (type) {
291
+ case 0:
292
+ return;
293
+ case 1:
294
+ for (let i = bpp; i < n; i++) row[i] = (row[i]! + row[i - bpp]!) & 0xff;
295
+ return;
296
+ case 2:
297
+ for (let i = 0; i < n; i++) row[i] = (row[i]! + prev[i]!) & 0xff;
298
+ return;
299
+ case 3:
300
+ for (let i = 0; i < n; i++) {
301
+ const a = i >= bpp ? row[i - bpp]! : 0;
302
+ row[i] = (row[i]! + ((a + prev[i]!) >> 1)) & 0xff;
303
+ }
304
+ return;
305
+ case 4:
306
+ for (let i = 0; i < n; i++) {
307
+ const a = i >= bpp ? row[i - bpp]! : 0;
308
+ const c = i >= bpp ? prev[i - bpp]! : 0;
309
+ row[i] = (row[i]! + paeth(a, prev[i]!, c)) & 0xff;
310
+ }
311
+ return;
312
+ default:
313
+ throw new PngStitchError(`${at}: unknown row filter type ${type} (PNG defines 0-4)`);
314
+ }
315
+ }
316
+
317
+ /** |byte| read as a signed value: the cost the classic per-row filter heuristic minimises. */
318
+ function absSigned(v: number): number {
319
+ const u = v & 0xff;
320
+ return u < 128 ? u : 256 - u;
321
+ }
322
+
323
+ /**
324
+ * The classic per-row filter heuristic: minimum sum of absolute (signed) residuals over all five
325
+ * candidates, computed in ONE pass because left/up/upper-left are shared across the five
326
+ * estimates. This is only ever a CANDIDATE strategy here : see `emitBlock`, which decides
327
+ * between it and plain None by compressing both, because this heuristic is measurably wrong on
328
+ * screenshot content (header table).
329
+ */
330
+ function chooseFilter(row: Uint8Array, prev: Uint8Array, bpp: number): number {
331
+ const n = row.length;
332
+ let s0 = 0, s1 = 0, s2 = 0, s3 = 0, s4 = 0;
333
+ for (let i = 0; i < n; i++) {
334
+ const x = row[i]!;
335
+ const b = prev[i]!;
336
+ const a = i >= bpp ? row[i - bpp]! : 0;
337
+ const c = i >= bpp ? prev[i - bpp]! : 0;
338
+ s0 += absSigned(x);
339
+ s1 += absSigned(x - a);
340
+ s2 += absSigned(x - b);
341
+ s3 += absSigned(x - ((a + b) >> 1));
342
+ s4 += absSigned(x - paeth(a, b, c));
343
+ }
344
+ let best = 0, bestSum = s0;
345
+ if (s1 < bestSum) { best = 1; bestSum = s1; }
346
+ if (s2 < bestSum) { best = 2; bestSum = s2; }
347
+ if (s3 < bestSum) { best = 3; bestSum = s3; }
348
+ if (s4 < bestSum) { best = 4; }
349
+ return best;
350
+ }
351
+
352
+ /** Apply filter `type` to `row` against `prev`, writing bpr bytes into `out` at `off`. */
353
+ function writeFilteredRow(type: number, row: Uint8Array, prev: Uint8Array, bpp: number, out: Uint8Array, off: number): void {
354
+ const n = row.length;
355
+ switch (type) {
356
+ case 0:
357
+ out.set(row, off);
358
+ return;
359
+ case 1:
360
+ for (let i = 0; i < n; i++) out[off + i] = (row[i]! - (i >= bpp ? row[i - bpp]! : 0)) & 0xff;
361
+ return;
362
+ case 2:
363
+ for (let i = 0; i < n; i++) out[off + i] = (row[i]! - prev[i]!) & 0xff;
364
+ return;
365
+ case 3:
366
+ for (let i = 0; i < n; i++) {
367
+ const a = i >= bpp ? row[i - bpp]! : 0;
368
+ out[off + i] = (row[i]! - ((a + prev[i]!) >> 1)) & 0xff;
369
+ }
370
+ return;
371
+ default:
372
+ for (let i = 0; i < n; i++) {
373
+ const a = i >= bpp ? row[i - bpp]! : 0;
374
+ const c = i >= bpp ? prev[i - bpp]! : 0;
375
+ out[off + i] = (row[i]! - paeth(a, prev[i]!, c)) & 0xff;
376
+ }
377
+ return;
378
+ }
379
+ }
380
+
381
+ /* ------------------------------ band parsing ------------------------------ */
382
+
383
+ /**
384
+ * Bytes per pixel for filtering purposes: PNG rounds sub-byte pixels UP to 1 (spec 9.2). The
385
+ * `max(1, ...)` is the spec's own wording, not a live branch : ceil() of any positive bit count
386
+ * is already >= 1, and removing it changes no output (verified by mutation, no test moved).
387
+ */
388
+ function filterUnitBytes(h: BandHeader): number {
389
+ return Math.max(1, Math.ceil((CHANNELS[h.colourType]! * h.bitDepth) / 8));
390
+ }
391
+
392
+ /** Bytes in one raw scanline, excluding its leading filter byte. */
393
+ function bytesPerRow(h: BandHeader): number {
394
+ return Math.ceil((h.width * CHANNELS[h.colourType]! * h.bitDepth) / 8);
395
+ }
396
+
397
+ function readIhdr(band: Uint8Array, dataStart: number, at: string): BandHeader {
398
+ const width = readU32(band, dataStart);
399
+ const height = readU32(band, dataStart + 4);
400
+ const bitDepth = band[dataStart + 8]!;
401
+ const colourType = band[dataStart + 9]!;
402
+ const compression = band[dataStart + 10]!;
403
+ const filterMethod = band[dataStart + 11]!;
404
+ const interlace = band[dataStart + 12]!;
405
+
406
+ if (width < 1 || height < 1) throw new PngStitchError(`${at}: IHDR declares ${width}x${height}; both dimensions must be >= 1`);
407
+ if (compression !== 0) throw new PngStitchError(`${at}: IHDR compression method ${compression}; PNG defines only 0 (deflate)`);
408
+ if (filterMethod !== 0) throw new PngStitchError(`${at}: IHDR filter method ${filterMethod}; PNG defines only 0`);
409
+ if (interlace === 1) {
410
+ throw new PngStitchError(
411
+ `${at}: interlaced (Adam7) PNG is not supported. Its seven reduced passes are not scanlines of the final image, ` +
412
+ `so bands of them cannot be concatenated; re-capture without interlacing (Chrome's Page.captureScreenshot always does).`,
413
+ );
414
+ }
415
+ if (interlace !== 0) throw new PngStitchError(`${at}: IHDR interlace method ${interlace}; PNG defines only 0 (none) and 1 (Adam7)`);
416
+ if (colourType === 3) {
417
+ throw new PngStitchError(
418
+ `${at}: colour type 3 (palette) is not supported. Concatenating palettized bands requires every band's PLTE/tRNS to ` +
419
+ `be identical or a full palette remap; Chrome emits colour type 2, so this path is refused rather than guessed.`,
420
+ );
421
+ }
422
+ if (CHANNELS[colourType] === undefined) throw new PngStitchError(`${at}: IHDR colour type ${colourType}; PNG defines 0, 2, 3, 4 and 6`);
423
+ if (!LEGAL_DEPTHS[colourType]!.includes(bitDepth)) {
424
+ throw new PngStitchError(
425
+ `${at}: bit depth ${bitDepth} is not legal for colour type ${colourType} (${COLOUR_TYPE_NAMES[colourType]}); ` +
426
+ `allowed: ${LEGAL_DEPTHS[colourType]!.join(", ")}`,
427
+ );
428
+ }
429
+ return { width, height, bitDepth, colourType };
430
+ }
431
+
432
+ /**
433
+ * Walk a band's chunk table. Only headers are read here : IDAT payloads are recorded as [start,
434
+ * end) views and never copied, so parsing a 18 MB capture costs the 278 chunk headers, not the
435
+ * 18 MB. IHDR's CRC is verified (13 bytes, free); IDAT corruption is caught downstream by the
436
+ * zlib stream's own adler32, which covers the concatenated payload as a whole.
437
+ */
438
+ function parseBand(band: Uint8Array, index: number): ParsedBand {
439
+ const at = `band ${index}`;
440
+ if (band.length < 8) throw new PngStitchError(`${at}: ${band.length} bytes is too short to be a PNG`);
441
+ for (let i = 0; i < 8; i++) {
442
+ if (band[i] !== PNG_SIGNATURE[i]) throw new PngStitchError(`${at}: not a PNG (bad 8-byte signature)`);
443
+ }
444
+
445
+ let header: BandHeader | undefined;
446
+ const idat: Array<[number, number]> = [];
447
+ const carried: CarriedChunk[] = [];
448
+ let sawIend = false;
449
+ let off = 8;
450
+
451
+ while (off + 8 <= band.length) {
452
+ const len = readU32(band, off);
453
+ const type = chunkTypeAt(band, off + 4);
454
+ const dataStart = off + 8;
455
+ const dataEnd = dataStart + len;
456
+ if (dataEnd + 4 > band.length) {
457
+ throw new PngStitchError(`${at}: chunk "${type}" at byte ${off} claims ${len} payload bytes but only ${band.length - dataStart} remain (truncated PNG)`);
458
+ }
459
+ if (type === "IHDR") {
460
+ if (off !== 8) throw new PngStitchError(`${at}: IHDR must be the first chunk, found it at byte ${off}`);
461
+ if (len !== 13) throw new PngStitchError(`${at}: IHDR payload is ${len} bytes, must be 13`);
462
+ const want = readU32(band, dataEnd);
463
+ const got = crc32(band.subarray(off + 4, dataEnd));
464
+ if (want !== got) throw new PngStitchError(`${at}: IHDR CRC mismatch (file says 0x${want.toString(16)}, computed 0x${got.toString(16)})`);
465
+ header = readIhdr(band, dataStart, at);
466
+ } else if (!header) {
467
+ throw new PngStitchError(`${at}: first chunk is "${type}", expected IHDR`);
468
+ } else if (type === "IDAT") {
469
+ idat.push([dataStart, dataEnd]);
470
+ } else if (type === "IEND") {
471
+ sawIend = true;
472
+ break;
473
+ } else if (CARRIED_CHUNKS.includes(type)) {
474
+ carried.push({ type, data: band.subarray(dataStart, dataEnd) });
475
+ }
476
+ off = dataEnd + 4;
477
+ }
478
+
479
+ if (!header) throw new PngStitchError(`${at}: no IHDR chunk`);
480
+ if (idat.length === 0) throw new PngStitchError(`${at}: no IDAT chunk`);
481
+ if (!sawIend) throw new PngStitchError(`${at}: no IEND chunk (truncated PNG)`);
482
+ return { header, idat, carried };
483
+ }
484
+
485
+ /** Every way band N can disagree with band 1, each named in its own message. */
486
+ function assertCompatible(first: BandHeader, next: BandHeader, firstCarried: CarriedChunk[], nextCarried: CarriedChunk[], index: number): void {
487
+ const at = `band ${index}`;
488
+ if (next.width !== first.width) throw new PngStitchError(`${at}: width ${next.width} does not match band 1's width ${first.width}`);
489
+ if (next.bitDepth !== first.bitDepth) throw new PngStitchError(`${at}: bit depth ${next.bitDepth} does not match band 1's bit depth ${first.bitDepth}`);
490
+ if (next.colourType !== first.colourType) {
491
+ throw new PngStitchError(
492
+ `${at}: colour type ${next.colourType} (${COLOUR_TYPE_NAMES[next.colourType] ?? "?"}) does not match band 1's ` +
493
+ `colour type ${first.colourType} (${COLOUR_TYPE_NAMES[first.colourType] ?? "?"})`,
494
+ );
495
+ }
496
+ for (const type of CARRIED_CHUNKS) {
497
+ const a = firstCarried.find((c) => c.type === type);
498
+ const b = nextCarried.find((c) => c.type === type);
499
+ if (!a && !b) continue;
500
+ if (!a || !b) throw new PngStitchError(`${at}: ${b ? "has" : "is missing"} a "${type}" chunk that band 1 ${b ? "does not have" : "has"}; the bands disagree about how their samples are interpreted`);
501
+ if (!sameBytes(a.data, b.data)) throw new PngStitchError(`${at}: its "${type}" chunk differs from band 1's; the bands disagree about how their samples are interpreted`);
502
+ }
503
+ }
504
+
505
+ /* ------------------------------ output plumbing ------------------------------ */
506
+
507
+ /** Sequential PNG chunk framing onto a file handle, tracking the byte offset so IHDR can be patched. */
508
+ class ChunkWriter {
509
+ private pos = 0;
510
+ constructor(private readonly fh: FileHandle) {}
511
+
512
+ get bytes(): number {
513
+ return this.pos;
514
+ }
515
+
516
+ async writeRaw(b: Uint8Array): Promise<void> {
517
+ await this.fh.write(b, 0, b.length, this.pos);
518
+ this.pos += b.length;
519
+ }
520
+
521
+ /** One chunk as one writev: [length+type][payload][crc]. Payload is never copied. */
522
+ async writeChunk(type: string, payload: Uint8Array): Promise<void> {
523
+ const head = Buffer.allocUnsafe(8);
524
+ head.writeUInt32BE(payload.length, 0);
525
+ head.write(type, 4, "latin1");
526
+ const crc = (crcUpdate(crcUpdate(0xffffffff, head.subarray(4)), payload) ^ 0xffffffff) >>> 0;
527
+ const tail = Buffer.allocUnsafe(4);
528
+ tail.writeUInt32BE(crc, 0);
529
+ await this.fh.writev([head, payload, tail], this.pos);
530
+ this.pos += 8 + payload.length + 4;
531
+ }
532
+
533
+ async patchAt(position: number, b: Uint8Array): Promise<void> {
534
+ await this.fh.write(b, 0, b.length, position);
535
+ }
536
+ }
537
+
538
+ /** The 25 bytes of a complete IHDR chunk (length + type + 13-byte payload + CRC). */
539
+ function ihdrChunk(h: BandHeader, height: number): Buffer {
540
+ const buf = Buffer.allocUnsafe(25);
541
+ buf.writeUInt32BE(13, 0);
542
+ buf.write("IHDR", 4, "latin1");
543
+ buf.writeUInt32BE(h.width, 8);
544
+ buf.writeUInt32BE(height, 12);
545
+ buf[16] = h.bitDepth;
546
+ buf[17] = h.colourType;
547
+ buf[18] = 0; // compression: deflate
548
+ buf[19] = 0; // filter method: adaptive
549
+ buf[20] = 0; // interlace: none
550
+ buf.writeUInt32BE(crc32(buf.subarray(4, 21)), 21);
551
+ return buf;
552
+ }
553
+
554
+ /**
555
+ * The single continuous deflate stream every band's rows feed into, framed to IDAT chunks as it
556
+ * emits. One stream, not one per band: a per-band stream would reset the deflate window at every
557
+ * seam and would also be illegal PNG (the concatenated IDAT payload is ONE zlib stream).
558
+ */
559
+ class RowEncoder {
560
+ private readonly deflate = createDeflate({ chunkSize: ZLIB_CHUNK });
561
+ private readonly pump: Promise<void>;
562
+ private readonly rowsPerBlock: number;
563
+ private readonly block: Buffer;
564
+ private blockRows = 0;
565
+ private readonly candNone: Buffer;
566
+ private readonly candHeuristic: Buffer;
567
+ /**
568
+ * The last RAW scanline handed to the encoder, the filter context for the next one. Carried
569
+ * across block boundaries AND across band seams : filters are defined on the whole output
570
+ * image, which knows nothing about where the bands were cut.
571
+ */
572
+ private readonly prevRaw: Uint8Array;
573
+
574
+ constructor(private readonly writer: ChunkWriter, private readonly bpr: number, private readonly bpp: number) {
575
+ this.rowsPerBlock = Math.min(MAX_BLOCK_ROWS, Math.max(1, Math.floor(BLOCK_RAW_BYTES / bpr)));
576
+ this.block = Buffer.allocUnsafe(this.rowsPerBlock * bpr);
577
+ this.candNone = Buffer.allocUnsafe(this.rowsPerBlock * (bpr + 1));
578
+ this.candHeuristic = Buffer.allocUnsafe(this.rowsPerBlock * (bpr + 1));
579
+ this.prevRaw = new Uint8Array(bpr);
580
+ this.pump = (async () => {
581
+ for await (const chunk of this.deflate) await this.writer.writeChunk("IDAT", chunk as Buffer);
582
+ })();
583
+ // Rejection is surfaced by finish()/abort(); an unobserved rejection here would kill the process first.
584
+ this.pump.catch(() => {});
585
+ }
586
+
587
+ async addRow(raw: Uint8Array): Promise<void> {
588
+ this.block.set(raw, this.blockRows * this.bpr);
589
+ this.blockRows += 1;
590
+ if (this.blockRows === this.rowsPerBlock) await this.emitBlock();
591
+ }
592
+
593
+ /**
594
+ * Filter one block BOTH ways, compress both, and emit whichever is actually smaller.
595
+ *
596
+ * A per-row heuristic cannot see the thing that decides a screenshot's size (deflate matching
597
+ * a row against the rows above it), and a fixed choice is wrong by 266% on one of the five
598
+ * real captures either way round : see the header table. So the choice is not predicted, it is
599
+ * MEASURED, once per block, which also lets a page whose top is a photo and whose body is text
600
+ * switch strategy on the way down. The extra cost is two throwaway deflates per block.
601
+ */
602
+ private async emitBlock(): Promise<void> {
603
+ const n = this.blockRows;
604
+ if (n === 0) return;
605
+ const { bpr, bpp } = this;
606
+ const stride = bpr + 1;
607
+ let prev: Uint8Array = this.prevRaw;
608
+ for (let r = 0; r < n; r++) {
609
+ const row = this.block.subarray(r * bpr, (r + 1) * bpr);
610
+ const at = r * stride;
611
+ this.candNone[at] = 0;
612
+ this.candNone.set(row, at + 1);
613
+ const filter = chooseFilter(row, prev, bpp);
614
+ this.candHeuristic[at] = filter;
615
+ writeFilteredRow(filter, row, prev, bpp, this.candHeuristic, at + 1);
616
+ prev = row;
617
+ }
618
+ const used = n * stride;
619
+ const a = this.candNone.subarray(0, used);
620
+ const b = this.candHeuristic.subarray(0, used);
621
+ const heuristicWins = deflateSync(b, { level: DECIDE_LEVEL }).length < deflateSync(a, { level: DECIDE_LEVEL }).length;
622
+ this.prevRaw.set(prev);
623
+ this.blockRows = 0;
624
+ // A COPY, because deflate.write() queues the view rather than reading it now: the candidate
625
+ // buffers are reused by the next block and would be rewritten under zlib's feet.
626
+ const out = Buffer.from(heuristicWins ? b : a);
627
+ if (!this.deflate.write(out)) await once(this.deflate, "drain");
628
+ }
629
+
630
+ async finish(): Promise<void> {
631
+ await this.emitBlock();
632
+ this.deflate.end();
633
+ await this.pump;
634
+ }
635
+
636
+ async abort(): Promise<void> {
637
+ this.deflate.destroy();
638
+ await this.pump.catch(() => {});
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Inflate one band's IDAT payloads as a single zlib stream. The payloads are fed as VIEWS into
644
+ * the caller's band buffer (no copy), which is also what makes the multi-IDAT case free: the
645
+ * chunks are simply written back to back into the same inflate.
646
+ */
647
+ async function* inflateBandIdat(band: Uint8Array, slices: ReadonlyArray<readonly [number, number]>, at: string): AsyncGenerator<Buffer> {
648
+ const inf = createInflate({ chunkSize: ZLIB_CHUNK });
649
+ const feed = (async () => {
650
+ for (const [start, end] of slices) {
651
+ const view = Buffer.from(band.buffer as ArrayBuffer, band.byteOffset + start, end - start);
652
+ if (!inf.write(view)) await once(inf, "drain");
653
+ }
654
+ inf.end();
655
+ })();
656
+ feed.catch(() => {}); // the failure surfaces on the read side below
657
+ try {
658
+ for await (const chunk of inf) yield chunk as Buffer;
659
+ } catch (err) {
660
+ throw new PngStitchError(`${at}: IDAT stream would not inflate (${(err as Error).message})`);
661
+ } finally {
662
+ inf.destroy();
663
+ }
664
+ }
665
+
666
+ /* --------------------------------- the stitcher --------------------------------- */
667
+
668
+ function isAsyncIterable(x: unknown): x is AsyncIterable<Uint8Array> {
669
+ return typeof x === "object" && x !== null && Symbol.asyncIterator in x;
670
+ }
671
+
672
+ async function* eachBand(bands: AsyncIterable<Uint8Array> | readonly Uint8Array[]): AsyncGenerator<Uint8Array> {
673
+ if (isAsyncIterable(bands)) {
674
+ for await (const b of bands) yield b;
675
+ } else {
676
+ for (const b of bands) yield b;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Concatenate PNG bands vertically into one PNG at `outPath`, losslessly and without ever
682
+ * holding the whole image.
683
+ *
684
+ * The bands are stacked in the order given, top to bottom. Every band must be a complete,
685
+ * non-interlaced PNG of the same width, bit depth and colour type; heights may differ freely
686
+ * (the last band of a page capture is usually short). Any disagreement, any interlaced or
687
+ * palettized band, any truncated or unreadable band throws `PngStitchError` and leaves no file
688
+ * at `outPath`.
689
+ *
690
+ * Accepts an array or an AsyncIterable, and consumes the iterable one band at a time : a caller
691
+ * that captures band N+1 only when asked for it never holds two bands, and the total height does
692
+ * not need to be known up front (the IHDR is patched with it at the end).
693
+ */
694
+ export async function stitchPngBandsToFile(
695
+ bands: AsyncIterable<Uint8Array> | readonly Uint8Array[],
696
+ outPath: string,
697
+ ): Promise<PngStitchResult> {
698
+ await mkdir(dirname(outPath), { recursive: true });
699
+ const partPath = `${outPath}.stitch-partial`;
700
+ const fh = await open(partPath, "w");
701
+ let done = false;
702
+ try {
703
+ const result = await stitchInto(fh, bands);
704
+ await fh.close();
705
+ await rename(partPath, outPath);
706
+ done = true;
707
+ return result;
708
+ } finally {
709
+ if (!done) {
710
+ await fh.close().catch(() => {});
711
+ await rm(partPath, { force: true }).catch(() => {});
712
+ }
713
+ }
714
+ }
715
+
716
+ async function stitchInto(fh: FileHandle, bands: AsyncIterable<Uint8Array> | readonly Uint8Array[]): Promise<PngStitchResult> {
717
+ const writer = new ChunkWriter(fh);
718
+ let first: BandHeader | undefined;
719
+ let firstCarried: CarriedChunk[] = [];
720
+ let encoder: RowEncoder | undefined;
721
+ /** The raw row above the one being decoded. RESET at the top of every band : see the header. */
722
+ let prevRow = new Uint8Array(0);
723
+ let rowBuf = new Uint8Array(0);
724
+ let bpp = 0;
725
+ let bpr = 0;
726
+ let totalRows = 0;
727
+ let index = 0;
728
+
729
+ try {
730
+ for await (const band of eachBand(bands)) {
731
+ index += 1;
732
+ const parsed = parseBand(band, index);
733
+ const at = `band ${index}`;
734
+
735
+ if (!first) {
736
+ first = parsed.header;
737
+ firstCarried = parsed.carried;
738
+ bpp = filterUnitBytes(first);
739
+ bpr = bytesPerRow(first);
740
+ prevRow = new Uint8Array(bpr);
741
+ rowBuf = new Uint8Array(1 + bpr);
742
+ await writer.writeRaw(PNG_SIGNATURE);
743
+ await writer.writeRaw(ihdrChunk(first, 1)); // placeholder height, patched after the last band
744
+ for (const c of parsed.carried) await writer.writeChunk(c.type, c.data);
745
+ encoder = new RowEncoder(writer, bpr, bpp);
746
+ } else {
747
+ assertCompatible(first, parsed.header, firstCarried, parsed.carried, index);
748
+ }
749
+
750
+ const enc = encoder!;
751
+ const raw = rowBuf.subarray(1);
752
+ // Every band is its own PNG, so its first row filters against a row of zeros, NOT against
753
+ // the last row of the band above it. Getting this wrong mis-decodes one row per seam.
754
+ prevRow.fill(0);
755
+ let rows = 0;
756
+ let filled = 0;
757
+ for await (const chunk of inflateBandIdat(band, parsed.idat, at)) {
758
+ let off = 0;
759
+ while (off < chunk.length) {
760
+ const take = Math.min(rowBuf.length - filled, chunk.length - off);
761
+ rowBuf.set(chunk.subarray(off, off + take), filled);
762
+ filled += take;
763
+ off += take;
764
+ if (filled !== rowBuf.length) continue;
765
+ if (rows >= parsed.header.height) {
766
+ throw new PngStitchError(`${at}: IDAT holds more scanlines than its IHDR height of ${parsed.header.height}`);
767
+ }
768
+ unfilterRow(rowBuf[0]!, raw, prevRow, bpp, at);
769
+ await enc.addRow(raw);
770
+ prevRow.set(raw);
771
+ rows += 1;
772
+ filled = 0;
773
+ }
774
+ }
775
+ if (filled !== 0 || rows !== parsed.header.height) {
776
+ throw new PngStitchError(`${at}: IDAT decoded to ${rows} complete scanlines${filled > 0 ? ` plus ${filled} trailing bytes` : ""}, but its IHDR declares ${parsed.header.height}`);
777
+ }
778
+
779
+ totalRows += parsed.header.height;
780
+ if (totalRows > 0x7fffffff) throw new PngStitchError(`stitched height would be ${totalRows}, past PNG's 2^31-1 limit`);
781
+ }
782
+
783
+ if (!first || !encoder) throw new PngStitchError("no bands to stitch");
784
+
785
+ await encoder.finish();
786
+ await writer.writeChunk("IEND", new Uint8Array(0));
787
+ await writer.patchAt(8, ihdrChunk(first, totalRows));
788
+ return { width: first.width, height: totalRows, bytes: writer.bytes, bands: index };
789
+ } catch (err) {
790
+ await encoder?.abort();
791
+ throw err;
792
+ }
793
+ }
794
+
795
+ /*
796
+ * CDP methods/domains used: NONE. This module is deliberately browser-free : it is the pure half
797
+ * of the >16384px capture story, and the tiling that feeds it lives on the browser side.
798
+ *
799
+ * Support matrix (measured against real Chrome captures, see the header):
800
+ * accepted : non-interlaced colour types 0/2/4/6, bit depth per PNG's own table, any number of
801
+ * IDAT chunks per band, differing band heights, an array or a lazy AsyncIterable.
802
+ * rejected : interlaced (Adam7), colour type 3 (palette), a band whose width/bit depth/colour
803
+ * type or carried colour chunk disagrees with band 1, a truncated or short-scanline
804
+ * band, zero bands. Every one of these throws PngStitchError and leaves no file at
805
+ * outPath.
806
+ * Parity gaps vs anything in chrome-devtools-mcp: not applicable, this is a toolkit-internal
807
+ * image primitive with no MCP tool of its own (hence no entry in index.ts's TOOLS registry,
808
+ * which maps MCP tool names to tool functions : a stitcher is neither).
809
+ */