proofsheet
Exact-pixel App Store and Google Play screenshots from a real browser. Deterministic, local-first, no cloud.
Source: github.com/interchained/proofsheet
pip install proofsheet
import proofsheet
report = proofsheet.capture(
"https://your.app",
out_dir="./shots",
store="apple",
)
print(report.summary())
if not report.ok:
raise SystemExit(1)
33 exact, 0 off-size, 0 failed in 12043ms
Point it at anything a browser can open
There is no "local mode" and no "remote mode" — the first argument is a URL. All four of these are ordinary usage:
proofsheet.capture("http://localhost:5173", store="apple", out_dir="./shots") # dev server
proofsheet.capture(f"file://{os.getcwd()}/dist/index.html", store="apple") # static build
proofsheet.capture("https://pr-482.preview.example.com", store="apple") # preview deploy
proofsheet.capture("https://your.app", store="apple") # production
Prefer localhost. Not as a fallback — as the default:
- You capture before you ship. The set is built from the branch you're about to release, so it can gate the release rather than document it afterwards.
- CI needs no deploy and no public URL. Start your dev server, capture, upload.
- It's hermetic. A live domain drags in CDN state, cookie banners, A/B buckets and analytics — all of which move between runs and destroy byte-identical determinism. Localhost doesn't.
- It's faster. Measured on the same page:
366mslocal vs1.6sover the network. Across a 44-device matrix that's ~25 seconds versus a couple of minutes. - It works on apps that aren't public yet, or are behind auth.
A complete pre-release script:
import contextlib
import socket
import subprocess
import time
import proofsheet
@contextlib.contextmanager
def dev_server(cmd, port, timeout=30):
proc = subprocess.Popen(cmd, shell=True)
try:
# Wait for the port to accept connections rather than sleeping a
# hopeful number of seconds.
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket() as s:
s.settimeout(0.5)
if s.connect_ex(("127.0.0.1", port)) == 0:
break
time.sleep(0.25)
else:
raise TimeoutError(f"nothing listening on {port} after {timeout}s")
yield f"http://localhost:{port}"
finally:
proc.terminate()
proc.wait(timeout=10)
with dev_server("npm run preview", 4173) as url:
report = proofsheet.capture(url, store="apple", out_dir="./shots")
print(report.summary())
raise SystemExit(0 if report.ok else 1)
Two things worth knowing before they bite you:
file:// is not a real origin. Fastest path, fine for genuinely static pages, but fetch, service workers, ES module imports and anything CORS-sensitive behave differently there than in production. If your app does real work, run the dev server and point at localhost.
Inside Docker, localhost means the container. If this runs in a container while your dev server runs on the host, use host.docker.internal or --network host.
Why
Store screenshot sets rot. They get taken by hand, at different moments, on different builds — one shot says 3:47 and the next says 9:12, a list reshuffles between frames, "2 hours ago" becomes "3 days ago". Then a redesign lands and somebody spends two days redoing all of them.
proofsheet makes the set a build artifact. Same seed, same bytes. Rerun after a redesign and the only things that changed are the things you changed.
Exact pixels, structurally
Apple and Google publish requirements in output pixels — 1320 × 2868, 1024 × 500. A browser is driven in CSS pixels plus a device pixel ratio. Storing the CSS size and multiplying is the obvious design and it's the wrong one: it permits a preset that cannot produce a required size, and you find out at upload.
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 before a browser starts.
d = proofsheet.devices("apple")[0]
d.output_size # (1320, 2868) <- what the store requires
d.viewport # (440, 956) <- what the browser is driven at
d.scale # 3
d.source # the Apple doc URL this came from
Determinism
A preamble is injected before any page script on every document: seeded PRNG, frozen clock, virtual requestAnimationFrame, seeded crypto.getRandomValues. Locale and timezone are pinned through CDP rather than script, because the script-level overrides don't reach Intl's internal data.
Verified in both directions — the same seed produces byte-identical PNGs across independent browser launches, and a different seed produces different bytes. The second half is what makes the first half evidence.
a = proofsheet.capture(url, device_ids=["apple-iphone-6-9-1320"], seed=42)
b = proofsheet.capture(url, device_ids=["apple-iphone-6-9-1320"], seed=42)
assert a.results[0].capture.sha256 == b.results[0].capture.sha256
Presets
46 presets, every store size read from official documentation on 2026-08-22 and carrying the URL it came from.
- Apple — iPhone 6.9″/6.5″/6.3″/6.1″/5.5″/4.7″, iPad 13″/11″/10.5″/9.7″, Mac, Apple TV, Vision Pro, Apple Watch
- Google Play — feature graphic, phone, 7″/10″ tablet, Wear OS, Automotive, TV
for d in proofsheet.devices("play"):
if d.mandatory:
print(d.id, d.output_size)
Pass presets="my-devices.json" to use your own table. Stores change these numbers without warning, and a requirement you can only fix by cutting a release is a requirement that will be wrong.
report.ok, not failed == 0
if report.ok: ...
ok requires that at least one capture happened and nothing went wrong. A run that captured nothing has an empty problem list, so failed == 0 is true for it — an empty green is the most misleading result a tool can produce.
Browser
You need a Chromium. Point PROOFSHEET_CHROME at one, or pass browser=. Chrome for Testing builds work well and are pinnable.
proofsheet.find_browser() # raises with instructions if none found
Scope, honestly
In scope: anything that renders in a browser engine — web apps, PWAs, and Capacitor / web-view apps, where the web view genuinely is the app.
Out of scope: true native Swift or Kotlin apps. Those need a simulator, and no amount of Chromium gets you there. Better you know that now than after installing.
Also available
- Rust:
cargo install proofsheet(CLI) /proofsheet-core(library) - Node:
npm i @interchained/proofsheet
Same core, same behaviour, one release.
Contact
- Web — interchained.org
- Source — github.com/interchained/proofsheet
- Issues — github.com/interchained/proofsheet/issues
- Email — dev@interchained.org
License
BUSL-1.1, converting to MIT on 2030-08-22.
Property made in part by Interchained LLC Labs.
© 2026 Interchained LLC
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file proofsheet-0.1.4.tar.gz.
File metadata
- Download URL: proofsheet-0.1.4.tar.gz
- Upload date:
- Size: 43.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
maturin/1.14.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ff5826cddda743beba98537b78fedde3ef384a309bf5eaa8401b0e8430a451cb
|
|
| MD5 |
d0cefe09b6b9159320052d48d398079d
|
|
| BLAKE2b-256 |
99b23f8994529675ed201d74eb22614414f4588bb575024def319993cbddb16d
|
File details
Details for the file proofsheet-0.1.4-cp38-abi3-win_amd64.whl.
File metadata
- Download URL: proofsheet-0.1.4-cp38-abi3-win_amd64.whl
- Upload date:
- Size: 350.9 kB
- Tags: CPython 3.8+, Windows x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
maturin/1.14.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
63942312432c8b453b75e1c53c905560c0b3f1420166b92aa00866431e9b7af4
|
|
| MD5 |
8979c8bc8770b7c944bedf0b86be0a97
|
|
| BLAKE2b-256 |
9e2d9d5e770e411173a6d94b2c7d4dcf7fa41a2d0ec8be34e96c20b4b4e20398
|
File details
Details for the file proofsheet-0.1.4-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: proofsheet-0.1.4-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 492.0 kB
- Tags: CPython 3.8+, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
maturin/1.14.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
585ce67867aa16ca653015bc694089a68d389390a6e9b8feecd9698684bfa7ec
|
|
| MD5 |
d03e0b8502d73a1bd3ede5bcd3eaf883
|
|
| BLAKE2b-256 |
3212bfae24b1044b8813bbaa81c5384711a130a8e7c509ad85c1dc9871b0253e
|
File details
Details for the file proofsheet-0.1.4-cp38-abi3-macosx_11_0_arm64.whl.
File metadata
- Download URL: proofsheet-0.1.4-cp38-abi3-macosx_11_0_arm64.whl
- Upload date:
- Size: 425.7 kB
- Tags: CPython 3.8+, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via:
maturin/1.14.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
11313cb448cdc370321bca5333dff31d1fde69cc148759d908a729a8bef47183
|
|
| MD5 |
0772109396a534d259f57c280f4854e3
|
|
| BLAKE2b-256 |
4c4dfa283ad53f306d63289fa14799509aa55b90007cb923fd4af4a8054d46c8
|
File details
Details for the file proofsheet-0.1.4-cp38-abi3-macosx_10_12_x86_64.whl.
File metadata
- Download URL: proofsheet-0.1.4-cp38-abi3-macosx_10_12_x86_64.whl
- Upload date:
- Size: 442.1 kB
- Tags: CPython 3.8+, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
maturin/1.14.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9e4ba937f9ae868195a396687c1b43b4edd643ff00ad2046ab367e9c0266688c
|
|
| MD5 |
7d8ec167b39ff4bf06d6bdf06c9259a7
|
|
| BLAKE2b-256 |
f07bee8d48c18e80bff9ad5f2088e1a65cf2b0834ebd2203405f31d6176b9dd6
|