For when text is not enough. Java brews screenshots: a zero-dependency headless-browser harness — point it at a URL, or hand it raw HTML source, and get back real-Chrome screenshots, print-fidelity PDFs, JS evaluation results, and looping GIF recordings. Pure JDK; the only thing it needs from the world is the Chrome you already have installed.
try (BrewShot shot = BrewShot.launch(1280, 900)) {
shot.open("https://example.com"); // an address…
// shot.html("<h1>or direct source</h1>"); // …or raw HTML — no server, no temp file
shot.settle(800);
Object title = shot.eval("document.title");
shot.screenshot(Path.of("page.png"));
}Or from the shell:
brewshot https://example.com -o page.png
brewshot https://example.com -o page.jpg --jpeg-quality 82
cat report.html | brewshot - -o report.png
brewshot ./fx.html --gif 40 --gif-element ".lx-math" -o fx.gif # film an element (jar path)
In the CLI, still-image extensions are truthful and case-insensitive: .png uses PNG;
.jpg/.jpeg use Chrome's JPEG encoder (--jpeg-quality 1..100, default 90);
.pdf selects Page.printToPDF. Other output extensions are rejected rather
than receiving misnamed PNG bytes. Every path-based screenshot, PDF, GIF,
manifest, diff JSON, and heatmap is completed in a sibling temporary file and
then moved into place (atomic move when supported, complete-temp
same-directory replace otherwise), so a failed encode/write cannot leave a
plausible partial target; temporary residue is cleaned best-effort. The
fallback preserves the complete-before-replace rule but cannot guarantee
atomic replacement on a filesystem that does not support ATOMIC_MOVE.
Replacing an existing ordinary POSIX target retains its mode bits. Capture
--json sidecars are the deliberate exception: they are forced to owner-only
0600 even when replacing a permissive file, because opted-in page text can
contain application data. A valid output
symlink remains a symlink and its referent is replaced; broken or cyclic
output symlinks fail before temporary-file creation. Output aliases are
rejected before artifacts are written; when either future target is absent,
Unicode-normalized case-only variants are rejected conservatively, including
on case-sensitive mounts.
When --json accompanies --eval, the manifest's eval field preserves the
JSON value itself — null, boolean, number, string, array, or object — instead
of flattening it through String.valueOf.
Direct stdin HTML is capped at 16 MiB and --eval-file at 1 MiB (byte caps,
UTF-8); inputs at the exact cap are accepted and cap+1 is refused before
decoding.
The library has always retained bounded console() and errors() evidence.
The CLI exposes that same storage only when asked, and never invents a sidecar:
brewshot page.html -o shot.png --json shot.json --page-diagnostics
brewshot page.html -o shot.png --json shot.json --fail-page-errors
brewshot page.html -o shot.png --json shot.json --fail-console-errorsAll three flags require an explicit --json path before Chrome starts.
--page-diagnostics adds the bounded console and errors arrays, counts,
existing drop counters, and completeness to the manifest. The gate-only flags
record counts/status but do not disclose raw page text. --fail-page-errors
means uncaught page exceptions; it deliberately ignores console.error, whose
often-noisy signal is gated only by the separate --fail-console-errors opt-in.
The screenshot and private JSON sidecar are published before a tripped gate returns exit 4. If the shared bounded error stream dropped evidence and no retained entry already proves failure, the gate is inconclusive (exit 5), never clean. Forced raw-diagnostics overflow is likewise exit 5. Drop counters are evidence, not decoration. Console and exception arrays each preserve their own arrival order; V1 claims no total order across those independent CDP streams. Raw strings receive the library's existing entry/UTF-8 byte bounds, not general secret redaction.
In the container image, BrewShot runs as fixed UID 10001. A 0600 sidecar may
therefore be unreadable to a different host UID by design; inspect it through a
deliberate container-side reader (or explicitly copy/change ownership outside
BrewShot) rather than weakening the artifact mode.
The --gif N lane records N frames as a looping GIF instead of a still —
--gif-delay sets the per-frame cadence (capture == playback, default 40 ms) and
--gif-element CSS films just that element's box (composes with --scale).
GIF stores centiseconds: delays round to the nearest 10 ms (half up), with the
existing 20 ms minimum; BrewShot.effectiveGifDelayMs(requested) reports the
exact value, and CLI manifests disclose both requested and encoded delay.
Jar path only: GIF assembly rides ImageIO, which the macOS native binary
doesn't have — the CLI reports this loudly instead of half-working.
Testing a 2,000-line browser-side animation runtime from a pure-Java repo left two bad options: stub a fake DOM (can't prove a real render) or adopt a browser-automation stack (hundreds of MB of toolchain for a repo that prides itself on zero dependencies). Reading the playwright source revealed the third option: under everything, driving Chrome bottoms out in a handful of JSON messages over a WebSocket — and the JDK has shipped a WebSocket client since 11. BrewShot is those messages, wrapped well:
| CDP message | BrewShot surface |
|---|---|
Page.navigate |
open(url) |
Page.setDocumentContent |
html(source) — scripts execute, load fires |
Runtime.evaluate |
eval(js) → null/String/Double/Boolean/Map/List · waitFor(predicate, ms) |
Network + lifecycle |
waitReady() · waitForNetworkIdle(quietMs, timeoutMs) · waitForFontsReady() — render-settled waits instead of a blind settle(ms) |
Runtime.consoleAPICalled / exceptionThrown |
console() / errors() — the page's voice, one-line health asserts |
Page.captureScreenshot |
PNG/JPEG screenshot(path, format, quality) / screenshotClip(x,y,w,h) · screenshotElement("css") |
Page.printToPDF |
pdf(path) / pdf(path, PdfOptions) — the page as a paged, print-fidelity PDF |
Emulation.setEmulatedMedia |
colorScheme("dark"|"light") · media("print"|"screen") · reducedMotion("reduce") |
Emulation.setTimezoneOverride |
timezone("Asia/Tokyo") — applied before navigation and proved from page Intl after load |
Emulation.setDeviceMetricsOverride |
launch(width, height, dpr) — true page-visible DPR, proved after every load |
Emulation.setEmulatedVisionDeficiency |
capture-scoped PNG screenshot(path, VisionDeficiency) / screenshotClip(…, VisionDeficiency) — cleared after the one shot |
Input.dispatchMouseEvent |
mouse(x,y) · click(x,y) / click("css") · hover("css") — real trusted input |
| + JDK ImageIO | recordGif(rect…) · recordGifElement("css", …) · recordGifScroll(…) · recordGifFullPage(…, scale, …) · recordGifRegion(0.5, 1.0, …) |
Target one element by CSS selector. elementBox("css") resolves an element's
page-coordinate box (scroll offset folded in), and screenshotElement/recordGifElement
capture just that element — no hand-computing getBoundingClientRect(). Trigger the
animation first (open/eval), then film it: recordGifElement resolves the box once and
films that fixed region, so motion within the element (glyph jitter, a spinner) is captured
cleanly. Built for exactly this — recording one card's effect out of a page full of them.
All selector-taking surfaces use one JavaScript-string quoting path, including
quotes, backslashes, CR/LF, U+2028, and U+2029; selector data cannot escape
into executable source.
Poke the page with real input. click("css") / hover("css") hit an element's center;
mouse(x, y) / click(x, y) take document coordinates (the same space elementBox speaks —
the scroll offset is handled at the CDP seam). These are trusted browser events: handlers see
event.isTrusted === true and :hover styles actually engage — things a page-side
el.dispatchEvent(new MouseEvent(...)) can never do. Hover something, then screenshot it, and
the capture shows the hovered state, because the mouse genuinely stays there.
Drive a recording frame-by-frame. recordGif/recordGifElement overloads take a
beforeFrame hook (IntConsumer) invoked with the 0-based frame index right before each shot —
trigger the animation at i == 0, advance deterministic state (eval("step()")), or perturb
mid-recording (click(...), hover(...)). The hook runs on the recording thread against the
same instance, so it composes with the whole surface; an exception aborts the recording.
Counts and capture/playback delays are positive contracts and fail before any
capture; clip origins are finite (negative page coordinates remain valid),
while width/height/scale are finite and positive.
Scale is a re-raster, not an upscale. The scale on screenshotClip/screenshotElement
makes Chrome re-render the clip region at that factor — screenshotElement("svg", 3.0)
turns a 360×140 CSS-px box into a genuinely crisp 1080×420 bitmap (vector content, fonts and
hairlines re-rasterized at 3× density), not a blurry blow-up. The arithmetic is pinned by
test: the clip rect is CSS px, the output bitmap is exactly rect × scale. That's the whole
"sharp element PNG" story in one call — no CSS-transform wrappers, no manual rect math.
screenshotElement("css", scale, paddingPx) inflates the box with breathing room first
(CSS px, pre-scale), so tight crops don't need a padding div. The same knobs ride the CLI:
--clip-selector, --scale, --clip-padding — and --scale alone re-rasters the full
page box:
scale does not change window.devicePixelRatio, resolution media queries, responsive-image
selection, or DPR-aware canvas code. Use launch-time DPR for the page environment; use scale for
the resulting raster. They compose: DPR 2 with scale 1.5 yields exactly 3× output dimensions.
brewshot page.html -o card.png --clip-selector "#card" --scale 3 --clip-padding 8Scroll-pan a tall page. recordGifScroll(panFrames, holdFrames, playbackDelayMs, scale, out)
glides the camera from the top of the document to the bottom — one viewport-height window per
frame, smoothstep-eased so it accelerates and settles — turning a long static page (a docs page,
a showcase, a changelog) into a smooth guided tour. holdFrames pauses at top and bottom so the
loop reads. (Unlike recordGifFullPage, which re-shoots the whole page each frame; this pans a
window down it.)
try (BrewShot b = BrewShot.launch(1120, 800)) { // launch height = the pan window
b.open("file:///…/showcase.html");
b.recordGifScroll(46, 8, 90, 0.55, Path.of("scroll.gif")); // 46 pan + 8 hold each end
}The per-frame playback delay is the speed knob, independent of how densely you sample.
Every recorder takes a single frameDelayMs (capture == playback, ≈ real time); the recordGif
and recordGifElement overloads split it into (captureDelayMs, playbackDelayMs) so you can
sample a fast effect densely and replay it slowly: recordGifElement(".fx", 60, 25, 75, s, out)
shoots 60 frames ~25 ms apart; requested 75 ms rounds to an encoded 80 ms/frame.
FPS = 1000 / effectiveGifDelayMs(playbackDelayMs) — so a bigger
effective delay is a lower fps and a slower GIF (a slower scroll, a slower effect).
| requested | encoded | ≈ fps | good for |
|---|---|---|---|
| 33 ms | 30 ms | ~33 | real-time smoothness — UI motion, a spinner, "does it feel right" |
| 50 ms | 50 ms | ~20 | lively but legible — hover/click micro-interactions |
| 75 ms | 80 ms | ~12.5 | catalogue/showcase default — an effect or a scroll you can actually read |
| 100 ms | 100 ms | ~10 | study pace — walk someone through each step |
| 150 ms | 150 ms | ~7 | slow-mo — a fast effect (glitch, a shatter) frame-by-frame; a leisurely scroll |
Chrome's shot time floors real capture cadence at ≈20-30 ms, so captureDelayMs below that just
samples as fast as it can. GIF playback has 10 ms granularity and a 20 ms
minimum; use effectiveGifDelayMs when exact duration/fps matters.
Stream instead of polling — recordGifStream. The poll recorders shoot, wait, shoot; Chrome's
per-shot cost floors that cadence at ≈20-30 ms. recordGifStream(durationMs, playbackDelayMs, out)
opens a CDP screencast instead: Chrome pushes a frame every time the compositor produces one, so
you capture what actually rendered, at the pace it rendered — measured ~9× denser than the poll
path (112 frames vs 12 over the same 1.2 s window on an rAF spinner). The widest overload adds
firstFrameDelayMs (poster-frame hold, as below) and maxWidth (downscale bound; 0 = natural
size), and returns the captured frame count. Two honest limits: frames are viewport-only
(scroll the subject into view first; element/region targeting stays with the poll recorders), and
a page that never composites during the window throws instead of writing an empty GIF — a static
page has nothing to film.
Hold the opening frame. recordGifElement's widest overload takes a firstFrameDelayMs — the
first frame is held that long before the animation runs, so the viewer registers the before state
(an intact equation, a button at rest) then watches it change:
recordGifElement(".fx", 60, 25, 75, 900, s, out) holds frame 0 for 900 ms, then plays the rest at
80 ms (75 ms requested, rounded). The per-frame delay lives in one file — no repeated frames.
Capturing an effect that fires on hover/click has three gotchas, learned the hard way filming the LatteX fx catalogue:
- Trigger after recording starts, not before. If you fire the click then start recording,
you miss the opening (the frames fly by before the first shot). Use the
beforeFramehook to trigger inside the recording — the early frames catch the before state, then the effect runs:recordGifElement(".fx", 60, 25, 75, 1.3, i -> { if (i == 2) shot.click(".fx"); }, out). Pair withfirstFrameDelayMsto hold that intact opening. (The hook'sclick/hoverare trusted input, soisTrusted-checking handlers fire too — the old page-sidesetTimeout(() => el.dispatchEvent(...))workaround is obsolete, and never engaged:hover.) - Sample dense, play slow. Use the
(captureDelayMs, playbackDelayMs)overload: shoot as fast as Chrome allows (~25 ms) and stamp a slower playback (75–110 ms). A fast effect stays smooth but readable — no re-encoding afterward. - Watch for effects that leave the element's box.
recordGifElementfilms the box resolved once at the start. An effect that scatters glyphs, spawns a body overlay, or drifts (an ink diffusion, a page-wide flash) will clip or wander. Fixes:scrollIntoView+ capture a paddedrecordGif(rect…)region instead, or reach for the frame-stream path (seePage.startScreencast, a plannedrecordGifStream) which follows what actually composites.
First proven as the LatteX fx-runtime test harness, where it pinned real rendering bugs (glyph placement, animation lifecycle leaks, hover-state wiring) that no stubbed DOM could catch.
pdf(path) renders the whole document via CDP Page.printToPDF — a paged,
print-fidelity PDF, not a raster. The default is deliberately not the
browser's print dialog (which drops backgrounds and adds margins): US Letter,
portrait, zero margins, backgrounds on, scale 1.0 — the page as a print
artifact. PdfOptions withers tune it per call, and a bad envelope
(non-finite/non-positive paper, non-finite/negative margin, non-finite scale
or scale outside CDP's 0.1–2.0) throws
IllegalArgumentException instead of an opaque Chrome reject:
try (BrewShot shot = BrewShot.launch(1280, 900)) {
shot.open("https://example.com");
shot.waitReady();
shot.pdf(Path.of("page.pdf")); // US Letter, full-bleed
shot.pdf(Path.of("report.pdf"), // A4 landscape,
BrewShot.PdfOptions.a4() // half-inch margins
.landscape(true).margin(0.5)); // all round
}From the shell, a .pdf output path is the whole story — the CLI infers the
mode from the extension:
brewshot https://example.com -o page.pdf(--clip-selector / --scale / --clip-padding are raster-only and don't
apply to .pdf output — PDF is paged, not clipped.)
Unlike GIF recording, PDF rides no ImageIO/AWT — printToPDF hands back
the PDF bytes directly — so the same call works everywhere including the
macOS native binary (the metrics table's GIF caveat doesn't apply here).
A dark-mode-only stylesheet, an @media print layout, or a CSS animation guarded by
@media (prefers-reduced-motion: reduce) can't be captured faithfully by just opening the
page — the browser reports its actual OS-level preferences unless told otherwise.
colorScheme/media/reducedMotion force those media features via CDP
Emulation.setEmulatedMedia, before the capture:
try (BrewShot shot = BrewShot.launch(1280, 900)) {
shot.colorScheme("dark"); // "dark" | "light" | "no-preference"
shot.reducedMotion("reduce"); // "reduce" | "no-preference" — freezes @media
// (prefers-reduced-motion: reduce) { animation: none }
shot.open("https://example.com");
shot.screenshot(Path.of("dark.png"));
shot.media("print"); // "print" | "screen" — @media print rules apply
shot.screenshot(Path.of("print-preview.png"));
}Each setter is chainable (returns this, like navTimeout/commandTimeout) and takes effect
immediately, whether called before or after open/html — and it survives every subsequent
navigation on the same instance without being called again: open/html re-send whatever
override is active before the new document paints, so a second open() on the same shot
never silently reverts to the browser's real preference. The same three knobs ride the CLI:
brewshot page.html -o dark.png --color-scheme dark
brewshot page.html -o preview.png --media print
brewshot page.html -o still.png --reduced-motionDates should not change because a capture moved from a Chicago laptop to a UTC runner.
timezone(IANA_ID) asks Chrome itself to apply the identifier before navigation, re-applies it
for every later open/html, and refuses capture unless the loaded page reports the exact same
identifier through Intl.DateTimeFormat().resolvedOptions().timeZone. Chrome/CDP is the support
authority; BrewShot does not keep a second Java-side IANA allowlist.
try (BrewShot shot = BrewShot.launch()) {
shot.timezone("Asia/Tokyo");
shot.open("https://example.com");
System.out.println(shot.appliedTimezone()); // Asia/Tokyo, page-visible and verified
shot.screenshot(Path.of("tokyo.png"));
}The CLI form is --timezone Asia/Tokyo. With --json, the sidecar adds a timezone receipt with
the requested and verified-applied identifier. When the flag is absent, the JSON byte shape stays
unchanged. Verify manifests accept the same optional capture.timezone; successful job receipts
carry the same requested/applied proof. Blank or Chrome-unsupported identifiers fail before any
capture artifact is written.
Retina-aware pages decide what to render from window.devicePixelRatio, resolution media queries,
responsive images, and canvas backing stores. launch(width, height, dpr) makes those page-visible
signals observe an integer DPR from 1 through 4 while keeping the requested width and height in CSS
pixels:
try (BrewShot shot = BrewShot.launch(1280, 900, 2)) {
shot.open("https://example.com");
System.out.println(shot.appliedDevicePixelRatio()); // 2, witnessed from the loaded page
shot.screenshot(Path.of("retina.png")); // 2560px wide at scale 1
}The target-scoped CDP override is applied once before the first content navigation and witnessed
after every later open/html. It is deliberately not re-applied: if Chrome ever loses the target
state, the post-load witness fails before capture instead of hiding the drift. An explicit DPR launch
omits the otherwise-retained --force-device-scale-factor=1 browser flag, so two mechanisms never
compete for one property.
The CLI form is --dpr N. With --json, the sidecar adds requested and page-verified dpr values;
omitting the flag preserves the legacy JSON shape. Fractional DPR is deferred until its composition
with --scale has a documented pixel-rounding policy. Verify-manifest DPR is likewise intentionally
outside this first slice.
--vision-deficiency asks Chrome to render one PNG preview under a recognized
color-vision-deficiency emulation. It is a visual preview—not an accessibility assessment, WCAG
or contrast verdict, medical claim, diagnosis, or remediation recommendation. The bounded values
are none, achromatopsia, deuteranopia, protanopia, and tritanopia; spelling and case are
strict. Chrome's best-effort blurredVision and reducedContrast modes are intentionally outside
this contract.
brewshot page.html -o deuteranopia.png --vision-deficiency deuteranopia
brewshot page.html -o achromatopsia.png --vision-deficiency achromatopsia \
--json achromatopsia.jsonThe library surface is capture-scoped rather than a sticky setter:
try (BrewShot shot = BrewShot.launch()) {
shot.open("https://example.com");
shot.screenshot(Path.of("preview.png"),
BrewShot.VisionDeficiency.PROTANOPIA);
// The preview was cleared in finally; this is an ordinary PNG again.
shot.screenshot(Path.of("ordinary.png"));
}V1 refuses JPEG, PDF, GIF, and recorder outputs before Chrome or artifact creation. It works with
full-page, scaled, selector, and direct-clip PNG stills, applies immediately before the one capture,
and clears to none in finally, including when capture fails. When --json is supplied, the
receipt records kind: emulated-preview, the requested mode, and that Chrome accepted the command.
It deliberately never says applied: CDP returns no page-visible or per-artifact witness, and a
naturally grayscale subject cannot prove the transform from its own pixels. Omission preserves the
legacy JSON shape.
brewshot diff turns "did this page change?" into a sentence you can paste
straight into a PR review (on private repos a quoted verdict travels where an
image upload won't):
$ brewshot diff before.png after.png --json verdict.json --diff-out heat.png
0.55% of pixels changed (1365 of 250400; 771 anti-aliasing px ignored);
largest cluster at 173,203 (43x17, 20% of the change) — in the body.- Anti-aliasing forgiveness is ON by default — a raw AA diff of two
re-renders is a noise wall (font hinting shifts every glyph edge a pixel).
A luminance-slope and same-color-neighborhood discriminator forgives soft
coverage/hinting changes while keeping coherent opaque edge movement
gate-relevant. Everything it forgives is counted and printed in the
verdict — nothing is silently eaten.
--pixel-exactdisables forgiveness and forces tolerance zero, regardless of flag order, for byte-faithful comparison. - Threshold gate:
--fail-over 0.5(percent) /--fail-pixels 100→ exit 4 with the verdict and artifacts still written — the same evidence-first contract as--fail-js. Percent thresholds are bounded to 0–100, and per-channel tolerance is bounded to 0–254 so even a maximum 255-channel delta remains observable. - Input size is bounded before the decode —
diffreads each input's PNG header first and refuses an oversized one as a usage error (exit 2) without allocating its raster, so a file that declares 40000×40000 costs a header read rather than 6 GB of memory. Two independent ceilings, both inclusive and both raisable when you genuinely have a large image:-Dbrewshot.maxImageDimension(default 16384 px per axis) and-Dbrewshot.maxImagePixels(default 67108864 total). A file that simply isn't a decodable image is unaffected — that stays exit 1. - Mask dynamic regions (
--mask x,y,w,h, repeatable): zero a clock or spinner on both images so the numbers stay stable and citable. Masks require positive extents, are clipped to the image, and a wholly outside mask is an intentional no-op. LibraryOptionsowns deep copies of masks. - Heatmap (
--diff-out diff.png): the base image dimmed, changed pixels magenta — the eyes-artifact companion when someone does want to look. - Localization: connected-component analysis names the largest changed cluster (centroid, box, share) and its page band — header / body / footer — so the verdict says where to look first.
- A size mismatch renders an explicit verdict (never a crash); under any
--fail-*gate it exits 4.
No Chrome involved — diff is pure JDK image work, so it runs anywhere the
jar runs (it rides ImageIO, the same JVM-path caveat as GIF recording; not
the macOS native binary). Library callers get the same engine as
BrewShotDiff.diff(imgA, imgB, options) → a Verdict record.
Options.masks() and Verdict.changedBounds() return defensive copies, so
mutating caller arrays cannot rewrite a later comparison or sidecar.
verify captures every job in a locally-authored manifest, then either checks
the staged captures without changing baselines or replaces the complete staged
baseline set:
brewshot verify --manifest shots.json --check
brewshot verify --manifest shots.json --updateReceipt consumers must treat the manifest-adjacent batch receipt as the
authority envelope. A job receipt is authoritative only when the batch
receipt's top-level state is complete, and its top-level attemptId and
contentDigest match the job receipt. complete means evidence publication
finished, not that the verification passed; read deterministicCore.exit, job
statuses, and failures for the verdict. attemptId is unique to an execution
and deliberately excluded from deterministicCore; contentDigest binds the
manifest, mode, inputs, baselines, and captures and is present in both the
top-level envelope and deterministic core.
The batch receipt is replaced with state: "in-progress" before capture. Its
contentDigestReady is initially false with a null digest, then true before an
update can replace a baseline. An interrupted attempt therefore cannot leave
older job receipts looking current. Update mode is a logical staged commit,
not a power-loss-atomic transaction: powerLossDurable and
baselineCommit.crashAtomic are false, and partial or indeterminate replacement
is reported explicitly. Check mode never writes baseline bytes or metadata.
- Visual pins in JUnit — "this page renders, and no element exploded" as a
build-failing assertion, gated with
assumeTrue(BrewShot.available())so Chrome-less CI skips cleanly. - Reference artifacts — commit screenshots/GIFs beside the pages that produce them; every change carries its own visual receipt.
- Agent eyes — a headless way for an AI to see a rendered page: AGENTS.md has the CLI pattern, a drop-in skill example, and an honest MCP-server recipe (the first two are how this project itself is developed).
- One-off shots — the CLI:
java -jartoday, or the GraalVM native binary (./gradlew nativeImage) for instant no-JVM startup. See the metrics table below — including the one GIF caveat that applies only to the native binary on macOS.
Not a Playwright/Selenium replacement: one browser (local Chrome/Chromium), one
page at a time, no selector engine, no cross-browser patches. Waiting is
opt-in: open() returns on the load event, and you reach for waitReady()
(network-idle + fonts) or waitFor(predicate) when a page settles after load.
eval() is the escape hatch for all of that — dispatch events, read layout,
poll conditions with the full power of page-side JS. ~800 lines of core CDP
client (≈1,300 with the CLI and JSON codec), and it intends to stay lean.
Artifact sizes were reproduced on 2026-07-27 from this source tree on an
Apple-silicon Mac with GraalVM CE 25+37.1 using
./gradlew clean jar nativeImage. The startup/capture timings and
byte-identical PNG observation are the existing same-hardware benchmark, not a
CI performance gate:
brewshot.jar |
build/brewshot (native) |
|
|---|---|---|
| artifact size | 119,754 B (117 KiB) (+ a JVM on the machine) | 36,738,360 B (35.0 MiB), fully self-contained |
CLI startup (--help, warm) |
~20 ms | ~3 ms |
| full shot (launch Chrome → render → PNG) | ~2.0 s | ~1.7 s |
| needs a JVM installed | yes | no |
| GIF recording | yes | not on macOS yet¹ |
The honest read: Chrome launch dominates a full shot (~1.4 s), so the native win there is modest (~0.3 s of skipped JVM warm-up). Where the binary earns its keep is deployment — one file, no JVM, instant startup — which is exactly the shape a pipeline step or an agent tool wants. The jar earns its keep at about 117 KiB with full GIF support. Ship both, pick per context.
First run of a fresh binary pays macOS code-signature verification (~0.3 s, once). PNG outputs are byte-identical across modes.
¹ To be clear about the GIF caveat: GIFs work on every platform when you run
on a JVM — the library in your tests, java -jar from the shell — because
the encoder is the JDK's own ImageIO. The limitation applies only to the
native-compiled executable: GraalVM's native-image doesn't support AWT
(which ImageIO rides on) on macOS yet, so build/brewshot can screenshot but
not assemble GIFs on a Mac. Same code, two execution modes — the caveat lives
entirely in the second one.
The repo ships a self-contained Java 25 image with Chromium and fonts. Its
Temurin stages are pinned by digest and its Chromium/font packages by exact
Alpine version; an unavailable pin fails the build instead of silently
substituting different bytes. It runs as fixed non-root user 10001:10001,
keeps immutable jars under /opt/brewshot, and offers two modes:
- the original one-shot CLI (still the default;
cliis an optional explicit spelling), including URL, stdin, PNG/JPEG/PDF, GIF, diff, and advanced flags; - a long-running
watchworker over/brewshot/inputand/brewshot/output.
Build it and use the old CLI shape unchanged:
docker build -t brewshot .
docker run --rm -v "$PWD:/work" brewshot \
https://example.com -o /work/page.pngFor mounted file input, the input mount can stay read-only while output remains writable:
mkdir -p Input Output
docker run --rm \
--user "$(id -u):$(id -g)" \
--mount type=bind,src="$PWD/Input",dst=/brewshot/input,readonly \
--mount type=bind,src="$PWD/Output",dst=/brewshot/output \
brewshot cli /brewshot/input/page.html \
-o /brewshot/output/page.pngRun a persistent local-HTML worker with writable input and output mounts:
mkdir -p Input Output
docker run -d --name brewshot-worker --restart unless-stopped \
--user "$(id -u):$(id -g)" \
--mount type=bind,src="$PWD/Input",dst=/brewshot/input \
--mount type=bind,src="$PWD/Output",dst=/brewshot/output \
brewshot watchWatch mode accepts complete, visible, regular, direct-child .html and .htm
files only. They should be self-contained: claiming moves the file into a
job-specific processing directory, so sibling asset paths are not a worker
contract. URL manifests and arbitrary option manifests are intentionally not
accepted; use the unchanged CLI for URLs and advanced capture options.
Publish a complete job with a hidden sibling plus atomic rename so the worker never observes a partial upload:
cp page.html Input/.page.html.tmp
mv Input/.page.html.tmp Input/page.htmlFor page.html, success creates Output/page.html.png and moves the original
to Input/finished/page.html:
Input/page.html
-> Input/processing/<job-id>/page.html
-> Input/finished/page.html
A failed capture instead creates a bounded, content-free
Output/page.html.error.txt and moves the source below Input/failed.
Existing outputs, diagnostics, and archives are never overwritten; collisions
are retained under job-id directories. Valid processing claims recover after a
restart, and multiple workers may share the same mounts. Set
BREWSHOT_WATCH_POLL_MS to 10 through 60000 milliseconds (default 500).
Warning
On Linux, map the process to the host UID/GID as above or make the bind
folders writable by the image's 10001:10001 user. Docker Desktop usually
translates ownership automatically; Linux does not. Watch input must be
writable because the worker performs atomic state transitions. Individual
input files only need to be readable: a foreign-owned mode-0444 file is
finalized with owner-agnostic directory/file moves rather than a hard link.
One-shot CLI input can remain read-only. Details:
SLOWSTART Scenario 5.
Warning
What BrewShot writes, you may not be able to read — and whether you can depends on whether the file already existed. Three cases, each measured:
| target | resulting mode | consequence |
|---|---|---|
| does not exist yet | 0600, owned by 10001:10001 in a container |
a different host UID cannot open it |
| exists as an ordinary file | its own mode bits are retained | a pre-existing 0644 output stays world-readable |
--json sidecar |
forced 0600 even over a permissive file |
deliberate, so opted-in page text is not widened |
Measured both ways rather than generalised from one: a fresh capture produced
-rw------- 10001 10001 shot.png and a cat from UID 1001 answered Permission denied, while capturing over a pre-created 0644 file left it -rw-r--r--,
readable by anyone. An earlier draft of this warning said outputs are always
owner-only. That is true only of the first row, and stating it as a blanket rule
would have been wrong in the direction people rely on — "BrewShot outputs are
private" is not a confidentiality guarantee when the target pre-exists permissively.
The first row is the one that costs time, because every symptom points away from
the cause: the file exists, stat works, the size is right, and file(1) says
"regular file, no read permission" rather than anything about the capture. In CI it
surfaces as a step that "produced no usable output" long after the step that
actually produced it.
Two ways through, and pick deliberately:
-
Run as yourself —
--user "$(id -u):$(id -g)". The output lands owned by you and every later step reads it normally. Requires the bind folders to be writable by that UID. -
Read it back through a container, when running as the image's own user is what you want (it is the safer default, and it is what CI does):
docker run --rm --user 0:0 --volume "$PWD/out:/verify:ro" \ --entrypoint /bin/sh brewshot:local -c 'cat /verify/shot.png' > shot.png
This is a property of the writer, so it applies to every artifact BrewShot produces — stills, GIFs, diff heatmaps, verify receipts and JSON sidecars alike.
Rolling your own image: install chromium + fonts (fonts-liberation,
fonts-dejavu-core), set BREWSHOT_CHROME=/usr/bin/chromium and
BREWSHOT_CHROME_ARGS="--no-sandbox --disable-dev-shm-usage" — scope
--no-sandbox to containers rendering YOUR OWN pages; it removes the layer
that contains a malicious page, so don't point a sandboxless browser at
untrusted URLs. (The provided image also runs as a non-root user.) Full walkthrough:
SLOWSTART.md Scenario 5.
BrewShot hands your input to Chromium and reads back bytes + JSON — it never
interprets page content itself, so a hostile page is Chromium's threat model,
not BrewShot's. The one Java-side ingestion point (MiniJson, for eval
results) is depth-capped and fails closed. The real risks are the ordinary
headless-browser ones (SSRF reach, --no-sandbox in containers, injecting
untrusted data into your own eval string). Full threat model + the three
things not to do: SECURITY.md.
- JDK 21+ (bytecode targets 21; CI executes the browser-free lane on Temurin 21 and 25)
- A local Chrome or Chromium (auto-discovered; override with
BREWSHOT_CHROME)
On macOS, the execution context is part of the browser requirement. A normal
Terminal is the supported host-native lane, and the provided Linux container is
the portable control. Unified Chrome 150 is known to abort in an inherited
Codex Seatbelt context (CODEX_SANDBOX=seatbelt) before it exposes DevTools, so
BrewShot refuses that exact combination before it creates a generated profile
or starts Chrome. Run the command from a normal Terminal/container, or
explicitly set BREWSHOT_CHROME to a compatible chrome-headless-shell; the
refusal does not silently fall back to another browser.
For allowed launches, bootstrap listens to three bounded endpoint witnesses:
stdout, stderr, and the generated profile's validated DevToolsActivePort.
They share one deadline and must agree when more than one appears. A failure
names process exit, alive timeout, malformed endpoint, or disagreement and may
include only bounded sanitized stream tails. BrewShot never uses the operator's
default Chrome profile or blanket-terminates unrelated Chrome processes.
The test lanes are explicit:
./gradlew unitTestruns every browser-free method with AWT headless and sets bothBREWSHOT_FORBID_CHROME=1andBREWSHOT_REQUIRE_CHROME=1. Chrome cannot launch, and any skipped or misclassified browser test fails the lane../gradlew chromeTestruns only the methods discovered from BrewShot's one mandatory Chrome gate. The reference CI runs this lane in the pinned Chromium container withBREWSHOT_REQUIRE_CHROME=1, so a green browser lane executed rather than skipped../gradlew testremains the backward-compatible aggregate suite.
The browser-free lane still includes descendant-process teardown fixtures. An
inherited macOS Codex Seatbelt can deny the JDK's ProcessHandle enumeration
through sysctl; use a normal Terminal or a Linux control with a PID-1 reaper
for those fixtures. That refusal occurs without launching Chrome.
The load/navigation wait budget defaults to 15s; raise it for a heavy page with
BREWSHOT_TIMEOUT_MS or per-instance shot.navTimeout(ms).
Resource controls, each a separate axis:
- CDP inbox budgets — every complete DevTools message is measured as exact
UTF-8 while its WebSocket fragments are reassembled
(
-Dbrewshot.maxCdpMessageBytes, default 32 MiB). The undrained queue is independently capped by regular-message count (-Dbrewshot.maxInboxMessages, default 4096) and aggregate exact UTF-8 bytes (-Dbrewshot.maxInboxBytes, default 32 MiB). Dequeue returns byte capacity; these are live retained-content bounds, not lifetime traffic quotas. The message and aggregate byte properties are independently configured: raising one does not implicitly raise the other. One extra physical queue slot remains reserved for typed socket closure. The producer-measured reservation travels with each queued message, including across a split surrogate-pair callback boundary. Operators can retrieve the total and per-cause drop counters withshot.inboxDropped(),shot.inboxCountDropped(), andshot.inboxByteDropped(). - Per-CDP-call budget — how long one DevTools round-trip may take (a
full-page screenshot of a tall document is the motivating case: navigation was
fast, the single capture call wasn't).
BREWSHOT_COMMAND_TIMEOUT_MSorshot.commandTimeout(ms); falls back toBREWSHOT_TIMEOUT_MS, then 15s. - Recording heap budget — the GIF recorders hold every PNG frame in memory
until encoding, so an unbounded recording is an OOM waiting for a long enough
page. Both recorder families (frame-count and screencast) stop at
BREWSHOT_MAX_RECORDING_BYTES(default 256 MiB;shot.recordingHeapBudget(bytes)), write the frames captured so far, and say so on stderr — a truncated GIF that announces itself beats both an OOM and a silently short one. - Still-image dimensions — Chrome's Base64 CDP result and BrewShot's decoded
compressed byte array already exist when BrewShot reads the image header.
-Dbrewshot.maxImageDimension(default 16384/axis) and-Dbrewshot.maxImagePixels(default 64 MP) refuse the image before artifact write or downstream full-raster decode; they do not claim to precede the CDP or Base64 allocations. - GIF decoded-raster accounting — before full frame decode,
-Dbrewshot.gif.maxFrames(default 1000),-Dbrewshot.gif.maxFrameDimension(default 4096/axis), and-Dbrewshot.gif.maxDecodedBytes(default 512 MiB) inspect frame headers and boundΣ width * height * 4. The last value is a raster accounting proxy, not total encoder heap: compressed frames, Java objects, palette/index buffers, and encoder state are additional.
- QUICKSTART.md — the whole API in two minutes
- SLOWSTART.md — walkthroughs by scenario, including the LatteX case study
- AGENTS.md — giving an AI eyes: CLI pattern, skill example, MCP recipe
Apache-2.0 · extracted from the LatteX test harness · design reviewed against playwright's chromium driver
This project is provided under the Apache License 2.0 on an "AS IS" basis, without warranties or conditions of any kind. See the LICENSE file for details.