cdp-toolkit 1.9.0 → 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/CHANGELOG.md +507 -0
- package/CONTRACT.md +62 -5
- package/README.md +74 -10
- package/dist/cli.js +2940 -138
- package/dist/index.js +994 -90
- package/dist/mcp.js +1221 -137
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +228 -33
- package/src/bidi/launch.ts +23 -13
- package/src/bidi-tools.ts +21 -0
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +111 -12
- package/src/driver.ts +254 -3
- package/src/manifest.ts +42 -14
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
- package/src/tools/console.ts +24 -7
- package/src/tools/network.ts +21 -7
- package/src/tools/recorder.ts +110 -10
- package/src/types.ts +8 -6
- package/src/workers.ts +69 -7
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
|
+
*/
|