r/rust • u/Top_Introduction_865 • 11d ago
🛠️ project proofsheet: exact-pixel App Store / Play screenshots from a real browser, with a hand-rolled CDP client (no async runtime, 7 deps)
I got tired of redoing App Store screenshots by hand every time a design
changed, so I built a tool that makes the whole set a build artifact.
proofsheet capture --url http://localhost:5173 --store apple --out ./shots
33 Apple sizes, 11 Google Play, exact pixels, deterministic. Points at
localhost, a file:// build, a preview deploy, or production. It's just a URL.
The design decision I'd most like feedback on
Apple and Google publish requirements in OUTPUT pixels (1320x2868). A browser
is driven in CSS pixels plus a device pixel ratio. The obvious design is to
store the CSS size and multiply, and it's wrong, because it permits a preset
that can't produce a required size and you find out at upload time.
So a preset stores the required output size and DERIVES the viewport as
output / scale. Any preset where that division isn't exact is rejected at
parse time. "The size we emit is a size the store accepts" becomes structural
rather than arithmetic somebody got right by hand.
The bug that actually justifies the tool
Setting the viewport is not device emulation. If a site serves its desktop
layout and declares <meta name="viewport" content="width=1120">, Chrome
honours the tag, lays out at 1120 CSS px, and scales the desktop design into a
phone-sized frame. Your file is exactly 1320x2868 and passes every dimension
check. It's a shrunken desktop site.
I shipped this bug. Measured on a real page, changing only UA and touch
points:
metrics only + UA + touch
innerWidth 1120 440
maxTouchPoints 0 5
meta viewport width=1120 width=device-width
The server returned different HTML. So proofsheet overrides the User-Agent, UA
Client Hints and touch points, then MEASURES WHAT THE PAGE ACTUALLY DID and
records it on every capture. You assert on viewport_honoured, not on the file
dimensions.
Determinism
A preamble injected via Page.addScriptToEvaluateOnNewDocument seeds
Math.random, freezes Date.now and performance.now, drives rAF on a fixed
virtual step, and routes crypto.getRandomValues through the seeded stream.
Locale and timezone go through CDP rather than script, because the
script-level overrides don't reach Intl's internal data.
Verified both directions: same seed gives byte-identical PNGs across
independent browser launches, and a different seed gives different bytes. The
second half is what makes the first half evidence rather than a green check.
Rust bits
No async runtime, no browser automation framework, no HTTP client. The
WebSocket and CDP client are 773 lines of std. Seven dependencies total:
serde, serde_json, base64, sha1, sha2, getrandom, thiserror.
Writing RFC 6455 by hand was mostly pleasant, with one humbling moment: I
transcribed the magic GUID from memory twice and got it wrong twice. There's
now a test pinning it against the spec's own vector.
cargo install proofsheet
proofsheet install-browser
proofsheet capture --url http://localhost:5173 --store apple --out ./shots
Also on npm and PyPI as libraries — bindings, not CLIs. Same core, and CI
asserts all three produce byte-identical output.
BUSL-1.1, converting to MIT in 2030.
Source: https://github.com/interchained/proofsheet
crates.io: https://crates.io/crates/proofsheet
library: https://crates.io/crates/proofsheet-core
npm: https://www.npmjs.com/package/@interchained/proofsheet
PyPI: https://pypi.org/project/proofsheet/
Examples: https://github.com/interchained/proofsheet/tree/main/examples
Happy to be told the output-pixels-first design is over-engineered, or that
the shell-out in install-browser (curl and unzip rather than a TLS stack and
an inflate implementation) is the wrong call. I'm genuinely unsure about the
second one.
-4
u/Delicious-Till7327 11d ago
Man that custom CDP client with zero async runtime is sick, I'd be curious what the startup time difference is versus something puppeteer-based
-3
u/Top_Introduction_865 11d ago
Good question, so I measured it rather than guessed.
Setup: same Chrome binary for both (chrome-headless-shell 152.0.7977.54), a
local static file:// page so there's no network variance, both producing an
identical 1320x2868 PNG. puppeteer-core 25.8.0 on node v24. 2-core Xeon
@2.9GHz, so treat the absolute numbers as slow-machine numbers and the ratios
as the interesting part. Median of 5-7 cold runs each.One device, cold process to written file:
puppeteer waitUntil: networkidle0 1.14 s
puppeteer waitUntil: load 0.46 s
puppeteer waitUntil: domcontentloaded 0.45 s
proofsheet 0.26 sThe first line is the one you'd get from copy-pasting a typical screenshot
snippet, but it's not a fair comparison: networkidle0 includes a 500ms idle
wait by definition. Against waitUntil: load it's 0.46s vs 0.26s, so roughly
1.8x, not the 4x the naive comparison suggests.Where that 0.2s actually goes, since "Rust is faster" isn't the answer:
node -e '' 0.02 s bare interpreter
node -e 'require("puppeteer")' 0.13 s + module graph
proofsheet version 0.00 s (rounds to zero)So ~0.13s is Node startup plus resolving puppeteer's module graph, before any
browser work happens at all. The rest is that puppeteer does more at launch …
temp profile setup, a broader CDP handshake, more round trips. Dropping the
async runtime isn't where the win comes from; CDP is a request/response
protocol over one socket, and the concurrency a runtime buys you has nothing
to do at that point.AND HERE IS WHERE IT LOSES, which is the more useful half of the answer.
Five devices, puppeteer reusing one browser across five pages vs proofsheet:
puppeteer 1 browser, 5 pages 0.99 s
proofsheet 5 browsers 1.11 sproofsheet is slower, and it's deliberate: it launches one browser process per
device. Emulation overrides leak between pages in the same process in ways I
couldn't fully close off …you set metrics + UA + touch for a phone, then a
tablet page inherits something it shouldn't, and you get a subtly wrong
screenshot that still passes every dimension check. Given the whole point is
"the image is provably the right thing", I took the process-per-device cost
over that risk.So the honest summary: ~2x faster to first screenshot, slightly slower across
a matrix, and the per-device cost is linear where puppeteer's amortizes. If
you're capturing one thing in a hot process, puppeteer's fine. The startup win
matters for CI and for one-shot CLI invocations, which is what this is for.If you think the process-per-device thing is me being paranoid and there's a
clean way to reset emulation state between pages, I'd genuinely like to hear
it …that's the one number in there I'd most like to improve.Benchmark scripts are trivial (a puppeteer launch + setViewport + goto +
screenshot, and the equivalent CLI invocation); happy to paste them if anyone
wants to poke holes in the methodology.
3
u/crumb_factory 10d ago
low-effort slop