Skip to main content

shotbox

Reproducible screenshots of real programs, taken where they can't touch your desktop.

shotbox shoot starts a program on a private X display, with its own D-Bus and a scratch home, waits until it's actually ready, takes the picture, and cleans up. Run it twice and you get the same bytes. Nothing it runs can reach your session: no stray windows on your screen, no settings changed, no profiles or caches written in your home.

shotbox shoot editor.png --window 'Text Editor' -- gnome-text-editor notes.md

Why

Screenshots for a README, a theme preview, or a check that a UI still looks right all want the same things, and every project was growing its own copy:

  • Sealed. The environment is rebuilt from an allowlist, not scrubbed from a denylist, so nothing leaks in by accident. DISPLAY, WAYLAND_DISPLAY and your session bus are gone; HOME and every XDG directory are a scratch dir. The bus has nothing activatable on it, so no gvfs, dconf or portals start behind your back. Settings are in-memory, so apps come up with their own defaults, not yours.
  • Ready, not slept. Waits are for things: a window mapped, a port open, a file written, text on a terminal's screen, or the picture holding still. No sleep 6 and hope.
  • Deterministic. X11 on Xvfb, GTK's software renderer, LANG=C.UTF-8, TZ=UTC, a cursor that doesn't blink (GTK's text caret included, and GTK's animations off), and PNGs with no timestamps in them.
  • Cleaned up. Every program runs in its own process group, and the group is killed on the way out: signalling a wrapper script alone leaves its children running.

Install

Needs Xvfb, xauth, dbus-daemon, ImageMagick and xwininfo; for shotbox term, PyGObject with VTE (gir1.2-vte-2.91). On Debian:

sudo apt install xvfb xauth dbus-daemon imagemagick x11-utils gir1.2-vte-2.91
pipx install --system-site-packages shotbox

--system-site-packages lets it see the system's PyGObject, which shotbox term uses; pip install shotbox works too. Or run it from a checkout: ln -s "$PWD/bin/shotbox" ~/.local/bin/shotbox.

No Python packages: it's the standard library and those tools. Wayland sessions need more; see Wayland.

Commands

shotbox shoot OUT.png [options] -- COMMAND... starts the command in a sealed session, waits, takes the picture and stops everything.

  • --window RE takes that window (its title, a regex matched in full) rather than the whole display, and waits for it to appear. app=RE matches its app id instead: a Wayland window's app_id, or an X window's WM_CLASS, instance or class. Every --window and every window wait takes either; a title that really does start with app= can be written [a]pp=....
  • --wait window:RE|port:N|file:PATH|ready|stable[:SECS] waits for more (repeatable, in order). ready is $SHOTBOX_SCRATCH/ready existing; shotbox term writes it. stable is the --window (or the whole display) looking the same for SECS (0.5): for a program with no other sign it's done drawing.
  • --park moves the pointer out of the way first (see shotbox park).
  • --crop WxH+X+Y, --settle SECS (0.3), --timeout SECS (30), --log FILE for the program's output. If a wait fails or the program dies, it says which, shows the program's last output, and leaves a picture of the screen as it was beside OUT, as OUT-failed.png (--failed FILE for elsewhere).

shotbox run [options] -- COMMAND... runs a command in a sealed session and exits with its status. For scripts that take several pictures: inside, shotbox wait window|port|file|ready|stable ARG and shotbox capture OUT.png [--window RE] [--crop G] [--park] work on the session's display. shotbox wait stable [SECS] [--window RE] is the one to use after a click, in place of a sleep. A wait that gives up takes a picture of the screen with --failed FILE, or wherever $SHOTBOX_FAILED says (--env SHOTBOX_FAILED=...).

Inside, shotbox key CHORD... presses keys (ctrl+comma, Return, alt+shift+Tab), shotbox type TEXT types ASCII text, and shotbox click X Y, shotbox move X Y and shotbox drag X1 Y1 X2 Y2 work the pointer, at a point on the screen or, with --window RE, inside a window (click --button 3, --double). shotbox park moves the pointer to the screen's bottom-right corner, off whatever it was hovering, and waits (up to --settle, 2s) for the repaint; a screen that keeps changing is taken as it is. They speak XTEST to the display directly, so there's still nothing to install.

Both take the session options: --screen WxH (1280x800); --env NAME=VALUE and --pass NAME to set or let through variables; --seed DIR to start the home as a copy of a directory (a config, a profile, sample files); --desktop for real GSettings and the system's D-Bus services, which a desktop shell needs; --system-bus for a stand-in system bus; --keep to keep the scratch dir and say where it is; --wayland and --xwayland for a Wayland session.

shotbox term [options] -- COMMAND... is a terminal to take pictures of: VTE, the engine behind GNOME Terminal, Ptyxis and Tilix, in a plain window named shotbox-term.

  • --scheme FILE colors it: a Tilix scheme as is, or {"foreground", "background", "cursor", "palette": [16 colors]}.
  • --size COLSxROWS, --font "Monospace 11", --title NAME.
  • Steps, in order: --when REGEX waits for the screen's text to match, --type TEXT types (\r is Enter, \e Escape), --sleep SECS. After the last one it writes the ready file. The terminal stays open, its screen as the program left it, after the program exits: no sleep 60 to keep it up.
  • --crop COLSxROWS takes only the top-left cells: a wide terminal, so nothing wraps, and a picture of just the part that matters. It writes the size in pixels to $SHOTBOX_SCRATCH/crop, which shoot crops to when it has no --crop of its own.
  • --shoot OUT.png starts the session and takes the picture too, taking shoot's session options, --park and --failed.
  • --log FILE keeps every byte the program wrote, escape codes and all (via script, whose first line is its own header). Colors are hard to judge in a picture and easy to grep in the log.
shotbox term --shoot irssi.png --scheme dark.json --size 88x24 \
  --when 'End of /NAMES' --type 'hello\r' -- irssi -c 127.0.0.1

which is short for shotbox shoot irssi.png --window shotbox-term --wait ready -- shotbox term ....

shotbox compare A.png B.png [--diff D.png] [--fuzz 1%] [--max N] counts the pixels that differ and fails if more than --max (0) do. With a committed picture, that's a visual regression test.

shotbox compare REFDIR NEWDIR [NAME...] does that for each picture in REFDIR (or each NAME), against the one of the same name in NEWDIR, a line each, and fails if any differs, is missing, or changed size. --diff DIR writes NAME-diff.png there for each that fails. --fuzz and --max take NAME=VALUE for one picture, beside a plain VALUE for the rest:

shotbox compare data/screenshots fresh --fuzz 0.5% --fuzz video=5% --diff fresh

Wayland

--wayland makes a session a Wayland one: the program runs under a headless sway, drawing in software, instead of on Xvfb. --xwayland adds an Xwayland to it, and DISPLAY, so an X client runs the way it does on a Wayland desktop, GTK programs made to with --env GDK_BACKEND=x11.

shotbox shoot editor.png --wayland --window app=org.gnome.TextEditor -- gnome-text-editor

Everything else is the same: windows by title or app=, pictures, waits, failure pictures, keys, text, the pointer, park, term and the Python API (shotbox.Session(wayland=True), or here() inside). Windows come from sway's IPC socket and pictures from grim; keys and the pointer go over sway's virtual keyboard and pointer, from a small client of shotbox's own, as XTEST does on X11.

The pictures aren't X11's: GTK draws its own decorations on Wayland, and under Xwayland, with no XSETTINGS, its text at 96 dpi. A committed picture belongs to one backend.

Needs sway and grim, and Xwayland for --xwayland: sudo apt install sway grim xwayland. Why sway, and how it fits: docs/wayland.md.

Python

A script that takes many pictures can drive the display itself rather than start a shotbox process for every click. Inside shotbox run, shotbox.here() is the session's display; outside, shotbox.Session starts one. Both are a Screen:

import shotbox

with shotbox.Session(size=(1600, 1000), failed="out/chat-failed.png") as s:
    s.spawn(["gtkhx"], log="gtkhx.log")
    s.wait_window("GtkHx.*")
    s.click(560, 660, window="GtkHx.*")
    s.type("/clear\n")
    s.wait_stable(window="GtkHx.*")
    s.capture("out/chat.png", window="GtkHx.*", park=True)
  • Waits: wait_window(re) (a title, or app=RE; returns (id, name, w, h, x, y)), wait_port(n), wait_file(path), wait_ready(), wait_stable(secs=0.5, window=None), and until(what, test) for a test of your own; each takes timeout (30).
  • Input: key(*chords), type(text), click(x, y, window=None, button=1, double=False), move, drag, park(). One X connection, kept.
  • Pictures: capture(out, window=None, crop=None, park=False), and window(re) for where one is.
  • Anything that fails raises SessionError, saying what; with failed set (or $SHOTBOX_FAILED inside shotbox run) it leaves a picture of the screen there first, and says where.
  • A Session also has spawn(cmd, log=None), run(cmd), env and scratch.

Installed, import shotbox just works. With a checkout, put its root on PYTHONPATH (--pass PYTHONPATH to let it into a session).

Node

node/ is a small package for browser checks with Playwright, from the pieces several projects had copied between them:

import { serve, checks, pageErrors, launch, until, gif, sealed,
         dress, film, frames, steady } from 'shotbox';
  • serve(dir, port = 0): a static server on localhost that serves ES modules with the right type; port 0 picks a free one. shotbox-serve [DIR] [PORT] [PAGE] runs it by hand (port 8080) and prints the address of PAGE, for a site whose page isn't at its top: "demo": "shotbox-serve . 8080 demo/" in a project's scripts.

  • checks(): check(cond, what) and skip(what) print ok, FAIL or skip lines; done() prints the tally and returns the exit status.

  • pageErrors(page): every uncaught exception and console.error.

  • launch(cmd, args): a program in its own process group, with kill() that reaches its children and why() that says how it ended and what it printed. until(test, ms) polls.

  • gif(film, out, { width, fps, from, colors, dither }): a Playwright recording, or a directory from frames(), as a GIF, via ffmpeg, with its own palette. from cuts that many seconds off the start, colors caps the palette, and dither is ffmpeg's (none for flat pages with something moving on them).

  • sealed({ pass, env }): an environment for a headless browser that nothing of yours gets into, as a session's but with no display. Otherwise Chromium's fonts come through your ~/.config/fontconfig and ~/.local/share/fonts, and your pictures aren't anybody else's. Give its env to chromium.launch({ env }) and close() it afterwards. pass (a list of names) and env (an object) do what --pass and --env do.

  • dress(page, { hold }): a pointer and captions for a recording, since a browser records neither its cursor nor a key pressed. A dot follows the mouse and shrinks while a button is down; caption(text) shows a caption at the bottom for hold ms (1300). Both come back after a reload; remove() takes them out for a still.

  • film(page): where the loop starts in a recording. Call it right after newPage(), start() when the loop begins, and end() closes the context and resolves to the video, for gif(video, out, { from: reel.from }). Playwright writes a frame only when the page repaints.

  • frames(page, { dir, fps, epoch, seed }): a recording made a frame at a time that comes out the same each run, where recordVideo races the page and never does. The page's time stands still and moves a frame's worth between screenshots: timers, Date, requestAnimationFrame, CSS transitions and animations, and a seeded Math.random. Launch Chromium with args: steady, and call it before the page's first navigation. run(ms) lets page time pass unrecorded, start() starts keeping frames, hold(ms) stands in for waitForTimeout and move(x, y, ms) for mouse.move({ steps }); other input lands between frames. end() says where they are, for gif(dir, out, { fps }). Runs match frame for frame, give or take a few pixels one level apart; what is real-time and not a timer (a fetch, a worker, an iframe loading) is not stood still, though a frame that navigates is let load and paint before time moves on.

const context = await browser.newContext({ recordVideo: { dir } });
const page = await context.newPage();
const reel = film(page);
await page.goto(url);
const dressing = await dress(page);
reel.start();
await page.mouse.move(400, 300, { steps: 24 });
await dressing.caption('Alt  Enter');
await page.keyboard.press('Alt+Enter');
await gif(await reel.end(), 'demo.gif', { from: reel.from });

It has TypeScript declarations, written by hand beside each module and checked by npm run types. dress, film and pageErrors take Playwright's Page, so they want Playwright's own types installed.

npm install --save-dev shotbox. Its package.json is at the top of the repository, since npm installs from the top of a git repository and nowhere else, so "shotbox": "github:mishan/shotbox" in devDependencies works too, or "file:../shotbox" for a checkout beside yours.

Tests

test/run.sh checks the session, the terminal, the pictures, the Python API (test/api.py, which needs GTK 3's PyGObject) and the Node helpers, and takes a few seconds. The helpers that need a browser use Playwright's Chromium (npm install && npx playwright install chromium) and skip without it, and the Wayland sessions need sway, grim and Xwayland and skip without them.

License

MIT. See LICENSE.

Metadata

Release files for shotbox 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for shotbox 0.3.0
File Size Uploaded
shotbox-0.3.0.tar.gz 40.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for shotbox 0.3.0
File Interpreter ABI Platform
shotbox-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 79.3 kB

Release files / shotbox-0.3.0.tar.gz

Download URL shotbox-0.3.0.tar.gz
Size 40.3 kB
Tags Source
SHA-256 checksum
How to use checksums
fa27136ab85390c4027bf36b77332ed718f98f546e3a086a2c6dcd52bd8702dc
BLAKE2b-256 checksum
How to use checksums
8eb48b8dd978bcd363df4beebd7a2caa140831c123ce664031ebe6cf4b431c79
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release files / shotbox-0.3.0-py3-none-any.whl

Download URL shotbox-0.3.0-py3-none-any.whl
Size 39.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
86dab4b038c790c44d97a487acca927539d614973068ff6cff9cc4eebb151599
BLAKE2b-256 checksum
How to use checksums
e3d0e2e1f9e448ad56ec87953d15bfc4c1bcb5fb08ba23eaa71130dd3d51cc2c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 28, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page