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, 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
cat report.html | brewshot - -o report.png
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) β String/Double/Boolean/Map/List Β· waitFor(predicate, ms) |
Runtime.consoleAPICalled / exceptionThrown |
console() / errors() β the page's voice, one-line health asserts |
Page.captureScreenshot |
screenshot(path) / screenshotClip(x,y,w,h) Β· screenshotElement("css") |
| + 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.
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, played back at 75 ms/frame. FPS = 1000 / playbackDelayMs β
so a bigger delay is a lower fps is a slower GIF (a slower scroll, a slower effect).
| playbackDelayMs | β fps | good for |
|---|---|---|
| 33 ms | ~30 | real-time smoothness β UI motion, a spinner, "does it feel right" |
| 50 ms | ~20 | lively but legible β hover/click micro-interactions |
| 75 ms | ~13 | catalogue/showcase default β an effect or a scroll you can actually read |
| 100 ms | ~10 | study pace β walk someone through each step |
| 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; playbackDelayMs has no floor β set it purely for the speed you want.
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
75 ms. (GIF stores a per-frame delay, so this is 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 dispatch the click then start recording,
you miss the opening (the frames fly by before the first shot). Schedule the trigger a beat into
the capture so the early frames catch the before state:
eval("setTimeout(() => el.dispatchEvent(new MouseEvent('click',{bubbles:true})), 150)"), thenrecordGifElementimmediately. Pair withfirstFrameDelayMsto hold that intact opening. - 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.
- 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 auto-waiting, no cross-browser patches.
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.
Measured on an Apple-silicon Mac (GraalVM CE for JDK 25, ./gradlew nativeImage),
same tiny page, identical PNG output from both:
brewshot.jar |
build/brewshot (native) |
|
|---|---|---|
| artifact size | 17 KB (+ a JVM on the machine) | 32 MB, 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 17 KB 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 Dockerfile (jar + Chromium + fonts, self-contained β GIFs
included, since the container runs a JVM):
docker build -t brewshot .
docker run --rm -v "$PWD:/work" brewshot https://example.com -o /work/page.png
(The -v "$PWD:/work" mount is how the PNG reaches your directory β the
container's own filesystem vanishes with --rm. Input files ride the same
mount.)
Warning
On Linux hosts, add --user "$(id -u)" if the output dies with
Permission denied β the image runs non-root by design, and a bind-mounted
directory must be writable by that user. Works-on-my-Mac is not evidence
here: Docker Desktop maps permissions automatically, Linux CI does not.
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+ (built with 25)
- A local Chrome or Chromium (auto-discovered; override with
BREWSHOT_CHROME)
- 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.