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 POSIX target retains its mode bits. 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 --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") |
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:
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-motionbrewshot 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 3×3 shifted-edge heuristic forgives those, and everything it forgives is
counted and printed in the verdict — nothing is silently eaten.
--pixel-exactopts out 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. - 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.
- 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.
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).
Two further resource bounds, each a separate axis:
- 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.
- 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.