Skip to content

perf(screenshot): read the crop region instead of decoding the whole capture - #2504

Open
thymikee wants to merge 1 commit into
mainfrom
t3code/optimize-crop-performance
Open

perf(screenshot): read the crop region instead of decoding the whole capture#2504
thymikee wants to merge 1 commit into
mainfrom
t3code/optimize-crop-performance

Conversation

@thymikee

@thymikee thymikee commented Sep 11, 2026

Copy link
Copy Markdown
Member

Summary

screenshot --crop-on <selector> decoded the whole capture to RGBA, copied the frame's rows, and re-encoded it. One PNG worker job now turns captured bytes into cropped bytes:

  • a region reader that reconstructs pixels only down to the crop box's last row and allocates only the box's pixels;
  • a truecolor writer that drops alpha when the cropped pixels carry none, so an opaque crop is RGB and one with transparency stays RGBA.

The reader claims the 8-bit non-interlaced truecolor layout iOS simulator and Android emulator captures arrive in. Palette, grayscale, interlaced, 16-bit and structurally untrusted files fall through to the general PNG reader, which keeps owning the canonical decode error and the previous RGBA output.

agent-device screenshot card.png --crop-on 'label="Apple Account"'

What the region reader does not claim. A deflate stream cannot be cut short, so the new path inflates the whole compressed capture into a buffer sized for every filtered row, exactly as the old path did; what stops at the box is the pixel work, not the read. And the reader only acts on a file it can vouch for: the IHDR and every chunk checksum are verified, an unrecognised critical chunk name is a decline, and every row's filter byte is read whether or not the crop reaches that row. A box covering the whole image is reported as "your file is already the answer" only after the general reader has decoded the file cleanly. A truncated capture, a bad checksum, an unknown critical chunk or an unreadable filter below the box therefore fails the same way it did before, on both paths.

Why the reader and the writer together, and not one of them. Measured on the same bytes with only one half of the change in place (main thread, median of 7, one 20%-height crop box; full table in a comment below):

pipeline 245 kB iOS UI 3.0 MB iOS photo 1.4 MB Android screencap
old reader + old writer 55.2ms, 93 kB 99.7ms, 1030 kB 67.2ms, 744 kB
old reader + new writer 36.4ms, 30 kB 89.3ms, 441 kB 85.5ms, 317 kB
new reader + old writer 28.1ms, 60 kB 55.2ms, 384 kB 42.8ms, 202 kB
both (shipped) 13.5ms, 30 kB 44.1ms, 441 kB 59.1ms, 317 kB

Each half alone buys one thing. The writer alone buys the artifact (93 kB to 30 kB) and costs time on noisy captures; the reader alone buys the time (55.2ms to 28.1ms) and leaves an RGBA artifact bigger than the one the writer produces. Only together do they get 4.1x faster and 3.1x smaller on the flat capture where --crop-on is actually used. The reader cannot avoid the writer, because an RGBA-only answer gives back part of what the region read won; the writer cannot avoid the reader, because re-encoding from a full RGBA bitmap is the cost the reader removes.

18 files, 1,467 gross lines: 570 production across seven modules (largest 250 lines), 895 mirrored tests and fixtures, one docs line. Asking for an explicit exception to the 1,000-line budget here rather than a split: the 570 production lines are one reader, the writer it feeds, and the format module both of them read, and the 895 remaining lines are their mirrored tests, which by repo convention land with the source. A split would ship a PNG writer with no reader feeding it, or a reader with no writer, and every intermediate layer would be dead code on main.

Validation

Validated at this layer's head ad5be9e8aa, and at the stack head 3b121f7dd5 whose only additions over it are scripts/png-crop-benchmark and its gate registration.

  • pnpm check:affected --run: 368 test files / 2,350 tests pass, including the eager-closure budget suite (590 checks), plus format, oxlint, typecheck, layering, di-seams, check:fallow --base origin/main and command-doc coverage. Two gates fail and both fail identically at origin/main in this worktree: mutation-model, and production-exports (63 findings, 63 at the merge base, none in png-*). One provider-integration scenario timed out under full-suite load and passes in isolation (2.4s of a 5s budget).
  • Cropped bytes vs ImageMagick's crop over a real iOS and a real Android capture, across RGB, RGBA, grayscale, palette, 16-bit, interlaced and translucent sources: max channel delta 0; declared color type is RGB for opaque, RGBA otherwise.
  • iPhone 17 Pro: 370x90 Apple Account crop, 8-bit RGB, correct frame. Android e2e not run — this worktree has no snapshot helper (pnpm build:android), so that path rests on the comparison above.
  • Crop stage, measured at the worker boundary both paths use, on three real captures: 2.6x to 5.6x faster and 1.67x to 2.16x smaller on a 245 kB iOS UI capture, 1.78x to 2.52x faster and 1.04x to 1.31x smaller on a 3 MB iOS photo capture. On a noisy 1.4 MB Android screencap it is a wash on time and the crop can come out ~13% larger, because inflating and reconstructing that much entropy dominates and the None-filter writer cannot beat the general writer's filter search there. The benchmark layer documents how to reproduce that.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://callstack.github.io/agent-device/pr-preview/pr-2504/

Built to branch gh-pages at 2026-09-12 12:11 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.50 MB 4.50 MB +1.6 kB
Package (unpacked) 4.50 MB 4.50 MB +1.6 kB
Package (download) 1.32 MB 1.32 MB +644 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.1 ms 25.5 ms +0.4 ms
CLI --help 69.5 ms 73.0 ms +3.5 ms

@thymikee

Copy link
Copy Markdown
Member Author

Two PNG validation regressions remain at 3ed12d5:

  • cropPngBytes returns early for a full-image box after reading only the IHDR dimensions. A truncated or corrupt PNG can now report crop success and remain on disk; the previous path decoded and rejected it before deciding the crop was a no-op. Validate the image before returning unchanged.
  • Partial crops also bypass canonical validation: the header reader never checks the IHDR checksum, and the image-stream reader ignores unknown critical chunks. An IHDR-CRC-corrupt image produces crop pixels through the new reader while the existing decoder rejects it. Validate these structures or fall back to the existing decoder, with regressions for both paths.

Coverage fails five eager-import budget checks because png-worker-client now statically loads the new crop modules. Restore the lazy boundary. This is related to the change, separate from the reported base mutation-model failure.

The 1,437-line diff also needs a split or an explicit exception to the 1,000-line budget. For the smaller-design review, the current reader still allocates the full inflated scanline buffer; it only stops reconstruction at the crop boundary. Clarify that scope and justify the added reader/writer surface against a smaller change.

@thymikee
thymikee force-pushed the t3code/optimize-crop-performance branch from 3ed12d5 to d3a0d39 Compare September 12, 2026 05:53
@thymikee

Copy link
Copy Markdown
Member Author

The earlier checksum, critical-chunk, full-image and eager-import findings are fixed at d3a0d39. One malformed-input case remains: decodePngRegion checks row filters only through the crop's last row. A 2x3 RGBA PNG with filter byte 9 on row 2 succeeds when cropped to the top-left pixel, while the existing decoder rejects it with Unrecognised filter type - 9. Validate the remaining filter bytes before returning; those rows need no pixel reconstruction. Add a regression with the invalid row below the crop.

The diff is now 1,457 gross lines, so the split or explicit budget exception is still needed. Update the PR's validation section for this head; it currently names the previous stack head. Current-head CI is still running.

@thymikee
thymikee force-pushed the t3code/optimize-crop-performance branch 2 times, most recently from e6749fc to 4d849a3 Compare September 12, 2026 06:11
@thymikee

Copy link
Copy Markdown
Member Author

Fixed at 4d849a3a3a (stack head 4749932304):

  • Full-image box. The no-op is no longer decided from the IHDR. A box covering the image goes through the general reader, and null is returned only after that reader decodes the file cleanly. Regression: a capture truncated inside its image data plus a full-image box reports Failed to decode screenshot as PNG instead of succeeding unchanged.
  • IHDR checksum. readPngHeader verifies the IHDR checksum, so nobody acts on dimensions the file itself does not vouch for. Covered in png-format and png-region-decode.
  • Chunk names. The chunk walk starts at IHDR and refuses any name that claims to be critical without being one of IHDR/PLTE/IDAT/IEND, and any name that is not four ASCII letters. Regression uses a CUty chunk.
  • Row filters below the crop. hasReadableFiltersBelow reads the filter byte of every row under the region without reconstructing it. I reproduced your case first: a 2x3 RGBA PNG with filter byte 9 on row 2, cropped to the top-left pixel, used to return pixels and now declines, and cropPngBytes surfaces Failed to decode screenshot as PNG with reason: 'Unrecognised filter type - 9', the same diagnosis pngjs gives.
  • Eager-import budgets. The crop fallback in png-worker-client is now a dynamic import inside the worker-unavailable branch, so importing the client stops dragging the region reader into every entry that touches it. The eager-closure suite (590 checks) passes.

Scope wording: the reader's module doc says plainly that a deflate stream cannot be cut short — both paths inflate the whole compressed image, and what stops at the box is reconstruction and allocation.

Diff size: 1,466 gross lines is 570 production across seven modules (largest 250), 895 mirrored tests and fixtures, one docs line. I would like an explicit budget exception rather than a split, for the reason in the body: every split point leaves a layer that is dead code on main. Your call.

One slip I found while recounting the diff: a squash dropped the commands.md sentence about the crop being re-encoded and written as RGB when opaque. It is back in this commit.

@thymikee

Copy link
Copy Markdown
Member Author

Commit identity, since it is out of the description now: this layer is 4d849a3a3a; the stack head that was run through the gates is 4749932304, which adds only scripts/png-crop-benchmark and its gate registration.

On the first-row filter finding, I pushed on it and I don't think the restriction is in the format, so I have not added the guard:

  • W3C PNG Specification 6.1: "PNG imposes no restriction on which filter types can be applied to an image", and "For filters that refer to the prior scanline, the entire prior scanline must be treated as being zeroes for the first scanline of an image (or of a pass of an interlaced image)." 6.5 and 6.6 repeat it per algorithm: "On the first scanline of an image … assume Prior(x) = 0 for all x."
  • That is what unfilterFirstRow does: it passes zeroes for the missing row, and Paeth collapses to Sub because the left neighbour wins the tie at PaethPredictor(left, 0, 0). Average becomes filt + (left >> 1)
  • Two decoders agree the files are readable. The repo's own decoder accepts filter 3 and 4 on row 0 (PNG.sync.read returns pixels, no throw). libpng via ImageMagick reconstructs them pixel-identically: a 64x64 RGBA ramp written with None everywhere vs. the same pixels with Average on row 0, and with Paeth on row 0, both compare at AE = 0.
  • The claim "delegation would hide the diagnosis" cannot fire here, because there is nothing for the general reader to reject. A reader that refused Average on row 0 would reject files libpng and pngjs both read, which is the opposite failure mode.
  • The property test already covers the case independently: every crop of a random capture matches the pixels the file declares draws filter plans over 0-4 for every row including row 0 and checks the crop against the general decoder's pixels, 100 runs.

On the constants: PNG_ROW_FILTERS and the PREDICT_* values are already one module, png-predictor.ts lines 9-22, with predictByte underneath them. png-format.ts holds file structure (signature, IHDR, chunk walk), and the filter vocabulary sits with the arithmetic that has to match it, so I'd rather not move it. The validForFirstRow field would encode a rule the format does not have, so I'd rather not add that shape either.

If you have a source that restricts the first scanline, point me at it and I'll re-check, because then this is a real divergence and I want it fixed here rather than in the next PR.

…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 it can vouch for:
the IHDR and every chunk checksum are verified, an unrecognised critical chunk
name is a decline, and every row's filter byte is read whether or not the box
reaches that row. 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.
@thymikee
thymikee force-pushed the t3code/optimize-crop-performance branch from 4d849a3 to ad5be9e Compare September 12, 2026 12:10
@thymikee

Copy link
Copy Markdown
Member Author

Follow-up on the remaining asks from this thread. Heads: this layer is ad5be9e8aa, stack head is 3b121f7dd5; the validation section now names both instead of an earlier push. Current-head CI has finished since your comment — all checks pass on both layers, including the Coverage job that previously reported the eager-import budgets.

Scope, stated where the code is. The reader's module doc and the benchmark README now say the allocation explicitly rather than gesturing at it: a deflate stream cannot be cut short, so the new path inflates the whole compressed capture into a buffer sized for every filtered row, and it is the pixel work that stops at the box. My earlier wording ("only the region's pixels are allocated") under-described the inflate buffer.

Justifying the reader/writer surface against a smaller change. I measured the halves instead of arguing about them: same bytes, one 20%-height crop box, main thread, median of 7, swapping one half of the change at a time.

pipeline 245 kB iOS UI 3.0 MB iOS photo 1.4 MB Android screencap
old reader + old writer 55.2ms, 93 kB 99.7ms, 1030 kB 67.2ms, 744 kB
old reader + new writer 36.4ms, 30 kB 89.3ms, 441 kB 85.5ms, 317 kB
new reader + old writer 28.1ms, 60 kB 55.2ms, 384 kB 42.8ms, 202 kB
both (shipped) 13.5ms, 30 kB 44.1ms, 441 kB 59.1ms, 317 kB

Each single-sided change buys one axis and gives the other back. The writer alone gets the artifact (93 kB to 30 kB) and is slower than today on the noisy capture (67.2ms to 85.5ms). The reader alone gets the time (55.2ms to 28.1ms) and leaves a 60 kB RGBA artifact where the writer's answer is 30 kB. Together: 4.1x faster and 3.1x smaller on the flat capture where --crop-on is used. A smaller change is therefore not a cheaper version of this one, it is one of these two rows, and I would have to pick which half of the win to give up.

That is the same argument for keeping them in one layer, which is also the budget question: 1,467 gross lines is 570 production across seven modules (largest 250), 895 mirrored tests and fixtures, one docs line. I am asking for an explicit exception rather than a split — every split point here puts a writer with no reader, or a reader with no writer, on main. Say the word and I will split it anyway.

Correction on my comment above: I posted that about a first-scanline filter finding that is not in this thread. The first-scanline question is worth its own answer on its own merits (Average and Paeth are legal on row 0; the format treats the prior scanline as zeroes there and both decoders read such files), but you did not raise it and I should not have answered a comment that does not exist.

@thymikee

Copy link
Copy Markdown
Member Author

The below-crop filter fix is correct at ad5be9e. One parser mismatch remains in png-format.ts:100: readPngChunks returns at IEND without checking that it ends the buffer. A partial crop therefore accepts a PNG with trailing bytes that the previous decoder rejects. Decline that input and add a partial-crop regression to preserve the canonical decode error. Current-head checks pass and there are no conflicts. The reader/writer measurements address the smaller-design question; the over-budget diff still needs an explicit exception or split, and live Android crop validation remains unreported.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant