DEV Community

Kazu
Kazu

Posted on

Tired of re-recording my README demo GIF by hand, I turned the whole demo environment into code

The demo GIF at the top of an OSS README. That little clip that shows you what a tool does in five seconds, before a single line of prose does. That one.

I used to re-record mine by hand. Every single time.

Fire up the screen recorder, tidy up the terminal, type the command, stop at a good moment, export to GIF. Three minutes if it goes well. It usually doesn't. I fat-finger one character in the command. I hit that final q a beat too early and the recording ends before the screen I wanted to show even appears. Again. This time I notice, after exporting, that the terminal font size is different from last time. Again.

That's how "just one GIF" would casually swallow 30 minutes.

Hand-recording produces a slightly different picture every time

The biggest problem with recording by hand is that the picture changes every time you redo it.

Font, size, color theme, shell prompt, window width. All of it depends on the state of the machine you recorded on, at that moment. My prompt is set up to show the current git branch, and once, after a re-record, the README GIF alone had some unrelated repo name baked into it.

The quieter problem is staleness. You tweak the UI a little. You add a line to the header. You change a keybinding. Every time, the GIF gets a bit more out of date. But re-recording is a pain, so it stays stale. The very top of the README goes on proudly showing a screen that no longer exists in the current version. The first thing a visitor sees is the thing that gets updated least.

Anyone who's shipped a CLI tool as OSS has probably felt this one.

A TUI demo is a whole other level of annoying

pgincident, a tool I maintain, is a TUI for the first response to a Postgres incident. It's the full-screen-in-the-terminal kind.

A TUI demo is hard in a way a single CLI command isn't. If there's no "state" on the screen, the demo means nothing.

For an ls GIF, you just run it in a directory with a few files and it looks fine. But what pgincident wants to show you is a screen of "what's happening in this Postgres right now." A long query is running. A lock wait queue is forming. There's a session that opened a transaction and walked away. Only when all of that is on the screen at once does the viewer go "ah, so that's what this tool is."

Connect to a clean, quiet Postgres where nothing is happening, and all you record is a screen of empty rows that says nothing. That's not a demo.

But manufacturing a real long query, a real lock, and a real idle-in-transaction all at once, conveniently, just for the demo — doing that by hand every time isn't realistic. To create a lock artificially you open two sessions, deliberately make one wait, keep that state alive while you start recording in a third terminal, and... just the choreography is a headache. It's a pain to do once, and the thought of reproducing it every time I change the UI means the GIF stays stale even longer.

The .tape file — writing terminal operations down

This is where vhs comes in. It's a Charm tool, and in one line: you write a script of terminal operations, and it runs them exactly as written and exports a GIF or MP4.

The script goes in a text file with a .tape extension. Type types characters, Enter presses enter, Sleep waits. Set pins down the appearance. This isn't a full reference article, so it's faster to just look at the real thing. Here's pgincident's demo.tape.

# pgincident demo
# Run via: ./scripts/record-demo.sh

Output docs/demo.gif

Set FontSize 14
Set Width 1200
Set Height 700
Set Theme "Dracula"
Set Shell "bash"

# Start pgincident with the dev config
Type "./pgincident --config dev/pgincident-dev.toml"
Sleep 500ms
Enter

# Overview screen — let it render and refresh once
Sleep 4s

# Navigate to Dashboard screen
Type "o"
Sleep 4s

# Move cursor down to a Long-running query row and open SQL detail
Type "j"
Sleep 300ms
Enter
Sleep 3s

# Close detail and quit
Type "q"
Sleep 500ms
Enter fullscreen mode Exit fullscreen mode

You can pretty much read what it does. Font size, window width, height, color theme — all pinned explicitly by the Set lines up top. None of my machine's settings leak in. Type "o" moves to the Dashboard screen, Type "j" moves the cursor down a row, Enter opens the SQL detail — the gaze of a README reader, turned straight into a script.

Even this alone almost entirely kills the "slightly different picture every time" problem. Same script, same picture. No re-shoots for typos. If I mistype something, I fix one character in the .tape and run it again.

But .tape alone is only half of it

So far the operations have become code. But remember the TUI difficulty from earlier. The "state" that shows up on screen still isn't set up anywhere.

A script that launches ./pgincident is useless if the Postgres it connects to is empty — all you record is an empty screen. The script only knows how to operate. It doesn't know what should be on the screen.

So I wrapped the .tape in one more script from the outside. That's scripts/record-demo.sh.

#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "$0")/.."

echo "==> Building pgincident..."
go build -o pgincident ./cmd/pgincident

echo "==> Starting Postgres..."
docker compose up -d postgres

echo "==> Waiting for Postgres to be ready..."
until docker compose exec -T postgres pg_isready -U postgres -q; do
  sleep 1
done

echo "==> Starting load generator..."
docker compose exec -T postgres psql -U postgres < dev/loadgen_setup.sql
go run ./cmd/pgincident-loadgen &
LOADGEN_PID=$!
trap 'kill $LOADGEN_PID 2>/dev/null || true' EXIT

echo "==> Waiting 5s for load to build up..."
sleep 5

echo "==> Recording demo..."
vhs demo.tape

echo "==> Done! -> docs/demo.gif"
Enter fullscreen mode Exit fullscreen mode

Reading top to bottom, here's what it does. First it go builds pgincident. It starts Postgres with Docker. It waits until pg_isready says the connection is up. It pipes in the load-generation SQL and runs the load generator in the background. It waits 5 seconds for the load to build up. Only then does it call vhs demo.tape. When the recording finishes, the trap kills the load generator and cleans up.

So: the .tape records the operations, and this script sets up the state that shows on screen. Build, Postgres startup, load generation, recording, cleanup — all folded into a single script, so the whole demo environment is reproducible. This is the part I most wanted to say. Unless you widen "record the demo" into "assemble the demo environment and record it," you can't fully turn a stateful TUI demo into code.

You type ./scripts/record-demo.sh. After that, with a real long query, a real lock, and a real idle-in-transaction lined up on screen, one GIF gets generated. The manual choreography that used to give me a headache became a single command.

What actually creates that "state"?

Inside record-demo.sh, the line that looks the most unremarkable and does the most work is go run ./cmd/pgincident-loadgen. That's the part that artificially stokes the "real incident" you see on screen.

The prep is dev/loadgen_setup.sql. It's an unremarkable script that just creates two small tables for the load to hit.

CREATE TABLE IF NOT EXISTS loadgen_accounts (
    id         bigint      PRIMARY KEY,
    balance    numeric(12,2) NOT NULL DEFAULT 0,
    touched_at timestamptz   NOT NULL DEFAULT now()
);

-- 10k rows gives cache hit ratio room to move
INSERT INTO loadgen_accounts (id, balance)
SELECT i, (random() * 10000)::numeric(12,2)
FROM   generate_series(1, 10000) AS g(i)
ON CONFLICT DO NOTHING;

-- pgincident_dev only has pg_monitor privileges. Grant DML so the simulator can run
GRANT SELECT, UPDATE ON loadgen_accounts TO pgincident_dev;
Enter fullscreen mode Exit fullscreen mode

Here's where it gets interesting. The load generator itself (Go) deliberately produces, one by one, the three categories that pgincident's Dashboard shows.

  • Long-running queries come from analytical queries with a pg_sleep baked in. It runs short ones that finish in a few seconds and long ones that squat for 5–8 minutes, with staggered start times.
WITH paused AS (SELECT pg_sleep($1))
SELECT a.id, a.balance, b.id, b.balance
FROM   paused, loadgen_accounts a
JOIN   loadgen_accounts b ON b.id != a.id
WHERE  a.id = $2 AND b.id = $3
Enter fullscreen mode Exit fullscreen mode
  • Lock waits come from the classic wait queue: one session grabs a row with FOR UPDATE and holds it, while another goes for the same row and gets stuck waiting.
  • Idle in transaction comes from a BEGIN, one query, and then just sitting there without committing or rolling back. pgincident's threshold is 30 seconds, so it sleeps long enough to cross it.
  • On top of that, a light OLTP load (a mix of SELECT / UPDATE) runs in the background so that TPS and cache hit ratio look "alive" on screen.

And here's the biggest trick for the sake of the recording: each worker is written to start on a staggered schedule. The idle ones so that "at least one is always visibly past the 30-second threshold," the long queries so that "two cycles half-overlap." The point is that the load itself is designed so that whenever vhs demo.tape runs, the Dashboard's three categories are all filled in at once, just right.

"Turning the whole demo environment into code," taken to its conclusion, includes this. Writing operations in a .tape will never produce this "broken just right" state.

The README just embeds this GIF as the top image.

![pgincident demo](docs/demo.gif)
Enter fullscreen mode Exit fullscreen mode

What changed after turning it into code

Honestly, I didn't invent anything dramatic. Neither vhs nor a shell script is a novel technology. Even so, the day-to-day feel clearly changed.

Anyone who runs it gets the same look and the same composition. Font, size, theme, the way load is applied — it's all in the script. It won't match down to which query lands in the top row on any given run, but the look and composition of the picture don't depend on my machine's mood. When a future contributor wants to update the GIF, they can read the .tape and know what's on screen.

It reviews in a diff and lives in git. A GIF is binary, so you can't read the diff of its contents, but .tape and record-demo.sh are just text. "Bumped a Sleep from 3s to 4s," "reordered the operations" — that shows up in the PR diff. Demo changes ride on the same rails as code changes.

Change the UI, regenerate. Added a line to the header. Changed a keybinding. Before, it was "re-recording is a pain, so leave it." Now it's just typing ./scripts/record-demo.sh again. I won't claim staleness is gone — forget to run the regen and it stays stale. But the weight of the "it's a pain" excuse dropped from 30 minutes of manual work to a single command.

Let me be honest about one thing too. Right now record-demo.sh is a local script I run by hand. I'm not auto-regenerating the GIF in CI. When I change the UI, I still have to notice "ah, gotta regenerate the GIF" myself and type it myself. Ideally there's room to get to where any UI-touching change makes CI re-bake the GIF and commit it. That's the next move. What I've got so far is that regeneration is one command.

Gotchas and tips

A few spots that actually took some doing.

Tuning Sleep takes the most work. A TUI refreshes the screen periodically. Right after launch, the data isn't drawn yet. If Sleep is too short, you record an empty or half-drawn screen. The reason demo.tape waits a generous Sleep 4s after launch is to let the Overview screen refresh once and fill in. Too long, though, and the GIF drags. I went back and forth a few times here — "shorten it, record, didn't make it, lengthen it a bit." It's an adjustment a single CLI command never needs; it's a TUI-specific chore.

How well the load is crafted decides how convincing the demo is. If the load the generator throws is too weak, not a single long query crosses the threshold and the screen stays sparse. Too strong and the screen fills up so much you can't tell what to look at. Creating the "broken just right" state is unglamorous but the single biggest thing for demo-ness.

Don't mix in secrets. The script is text and goes into git. And of course the GIF bakes in exactly the characters you typed and the screen you showed. Be careful not to write a production DSN or password into a Type, or to record a screen connected to production. That pgincident's demo uses a dev config (dev/pgincident-dev.toml) and connects to a local Docker Postgres is partly for this reason.

GIF size. Crank up Set Width / Set Height and the file naturally gets heavier. This image sits at the top of the README, so slow loading defeats the purpose. Width 1200 × height 700 was the sweet spot where the text isn't crushed and the file isn't too heavy.

Demos go from something you "record" to something you "write and generate"

Looking back, what changed is the kind of work. From the manual labor of "recording" a demo — moving your hands, failing, re-shooting — to "writing and generating" one: turning the script and the environment into code, and baking it with a command.

And for a stateful TUI demo, writing the operations in a .tape isn't enough. Only when you go all the way to setting up the "state" on screen — a real long query, a real lock — and turn the whole demo environment into code does it become reproducible. That, I think, is the key thing when you turn a TUI demo into code.

If you're re-recording your own OSS README demo GIF by hand every time, start with a single .tape. And if the screen you want to show needs "state," try folding the setup of that state into the script too.

By the way, what pgincident actually is as a tool, I wrote up in another article. It's the story of that first 30 seconds when Postgres slows down at 2 a.m.

Top comments (0)