vhs-demo
Generate a terminal-demo GIF from a Charm VHS tape, rendered headless in Docker. The tape is committed; the GIF is a build artifact regenerated from it.
[!IMPORTANT]
The tape is the source of truth. Always edit the .tape and re-render. Never hand-edit, re-time, or recompress the GIF — it cannot be reproduced and the next render will overwrite it.
Why Docker
VHS normally wants a real TTY (and on macOS, Homebrew + sometimes a screen recorder). The official ghcr.io/charmbracelet/vhs image renders fully headless, so it works in WSL/root environments with no Homebrew, no TTY, no OBS. Everything below assumes Docker is the only host dependency.
Naming convention (enforce exactly)
- Single demo in the repo → files are
demo.tape and demo.gif in .github/assets/. The README references exactly that one .github/assets/demo.gif.
- Multiple demos → one descriptive name per demo, and the tape + its GIF always share the base name:
quickstart.tape/quickstart.gif, ci.tape/ci.gif, etc.
- The Dockerfile stays generic: always
.github/assets/vhs.Dockerfile — never name it after a demo.
Quick start
- Scaffold
.github/assets/vhs.Dockerfile from templates/vhs.Dockerfile. Keep or drop the Bun layer (see below).
- Scaffold the tape (
demo.tape or <name>.tape) from templates/demo.tape and write the choreography for this CLI.
- Build the CLI so the tape runs the built artifact, not the sources: run the repo's build (e.g.
pnpm build / bun run build).
- Build the image (once, or after editing the Dockerfile):
docker build -f .github/assets/vhs.Dockerfile -t <repo>-vhs .
- Render (after every tape change):
docker run --rm -v "$PWD:/vhs" <repo>-vhs .github/assets/demo.tape
- Verify the GIF (dimensions, duration ≈ sum of sleeps, file size) — see Verification.
- Wire the README reference and commit the tape, the Dockerfile, and the regenerated GIF together.
Does the CLI need Bun?
The official VHS image has no Bun. If the CLI is a Bun tool (uses opentui, bun:ffi, or is started with bun …), the render will fail with "bun: command not found". Fix = the derived image in the template that installs Bun (curl | bash from bun.sh, with /root/.bun/bin on PATH).
- Bun CLI → keep the Bun layer in
templates/vhs.Dockerfile.
- Node CLI (plain citty, run with
node …) → delete the Bun layer; the base image already has Node.
Resolution & render quality
Defaults baked into the tape template:
| Setting |
Default (Full HD) |
Lighter alternative |
Set Width |
1920 |
1280 |
Set Height |
1080 |
720 |
Set FontSize |
20 |
16 |
Set Padding |
16 |
16 |
Set Framerate |
24 |
24 |
[!IMPORTANT]
Cap the framerate. At 1080p a busy TUI usually can't repaint fast enough to sustain VHS's default 50fps capture. VHS then drops frames, the GIF plays too fast / janky, and the measured duration is shorter than the sum of your Sleeps. Set Framerate 24 caps the capture rate to something the TUI can actually hold. If verification still shows the duration running short, lower the framerate further (20, then 16) and re-render.
Use the lighter 1280×720 @ FontSize 16 profile when the GIF needs to be smaller/cheaper to load and Full HD detail isn't essential.
Pitfalls — do's & don'ts
Output path MUST be relative (e.g. .github/assets/demo.gif). An absolute path like /vhs/... is rejected by the VHS parser.
- Build the CLI first — run the repo's build (e.g.
pnpm build / bun run build). The tape launches the built artifact (dist/…), not the source.
- Bun CLIs need the Bun image layer — see above.
- Work on a throwaway copy of fixtures if the demo edits/writes files. Copy them to
/tmp inside the hidden Hide … Show block so the working tree stays clean (e.g. cp -r /vhs/examples /tmp/demo).
- Clear pre-filled edit fields before typing. Popovers/inputs seeded with the current value need emptying first:
Backspace@25ms 40. Backspace on an empty field is a no-op, so over-counting is safe.
- Don't end the demo with a quit key. Quitting makes VHS film the shell teardown (scrollback, the setup command reappearing). Instead end on a
Sleep holding the frame you want — VHS kills the process at tape end, giving a clean final frame.
- Hide setup commands. Wrap
cp, the CLI launch, and any other plumbing in a Hide … Show block so only the demo itself is captured.
Verification
After each render, confirm the GIF actually matches the tape. Requires ffprobe/ffmpeg (run them on the host, or via the image: docker run --rm -v "$PWD:/vhs" --entrypoint ffprobe <repo>-vhs …).
- Dimensions match the tape's
Set Width/Set Height:ffprobe -v error -select_streams v:0 -show_entries stream=width,height \
-of csv=p=0 .github/assets/demo.gif
- Duration ≈ sum of the visible
Sleeps (plus visible typing time). Count only actions that are actually captured — exclude anything inside a Hide … Show block, since VHS records nothing there, so those sleeps/typing never reach the GIF. If it's meaningfully shorter than that, frames were dropped → lower Set Framerate and re-render:ffprobe -v error -show_entries format=duration -of csv=p=0 .github/assets/demo.gif
- File size is reasonable for a README asset (rough target: under ~2–3 MB; lighter profile if not).
ls -lh .github/assets/demo.gif.
- (Optional) Eyeball frames — sample one frame per second to PNGs and look at them:
ffmpeg -i .github/assets/demo.gif -vf fps=1 /tmp/demo-frames/f%03d.png
Check the first frame (no leftover setup), the final frame (the intended end state), and any edit step.
README wiring
Reference the GIF with a plain image tag near the top of the README (after the hero/badges):

For multiple demos, embed each under the relevant section with its descriptive name. If the README is being (re)written, the write-readme skill owns layout — this skill only owns the asset and its reference.
Steady-state loop (after the first run)
Quick start covers the first run; afterwards only the cadence differs:
- Image — rebuild only when the Dockerfile changes.
- Every demo change — run the repo's build (e.g.
pnpm build / bun run build) → re-render → verify.
- Commit the tape, Dockerfile, and regenerated GIF together — the tape is the source of truth.
Reference
- Open it while writing or editing the
.tape — when the directive you need is not already in the tape in front of you, when a theme has to be named exactly, or when a first demo needs a worked walkthrough to take its pacing from: REFERENCE.md.
- Copy these on the first run, before the quick start assumes they exist:
templates/vhs.Dockerfile, templates/demo.tape.
1---2name: vhs-demo3description: Creates and maintains a scripted, reproducible terminal-demo GIF for a CLI repo from a Charm VHS tape rendered headless via Docker — the .tape is the committed source of truth, the GIF a regenerated artifact. Use when the user wants to add, record, regenerate or tweak a terminal demo GIF for a kirchDev CLI repo (citty/Bun tools like envprism, forgemap), or asks about VHS tapes, demo.gif, or README terminal previews. Do not use for editing or optimising arbitrary existing GIFs.4---56# vhs-demo78Generate a terminal-demo GIF from a [Charm VHS](https://github.com/charmbracelet/vhs) tape, rendered headless in Docker. The tape is committed; the GIF is a build artifact regenerated from it.910> [!IMPORTANT]11> **The tape is the source of truth.** Always edit the `.tape` and re-render. Never hand-edit, re-time, or recompress the GIF — it cannot be reproduced and the next render will overwrite it.1213## Why Docker1415VHS normally wants a real TTY (and on macOS, Homebrew + sometimes a screen recorder). The official `ghcr.io/charmbracelet/vhs` image renders fully headless, so it works in WSL/root environments with no Homebrew, no TTY, no OBS. Everything below assumes Docker is the only host dependency.1617## Naming convention (enforce exactly)1819- **Single demo in the repo** → files are `demo.tape` and `demo.gif` in `.github/assets/`. The README references exactly that one `.github/assets/demo.gif`.20- **Multiple demos** → one descriptive name per demo, and the tape + its GIF **always share the base name**: `quickstart.tape`/`quickstart.gif`, `ci.tape`/`ci.gif`, etc.21- **The Dockerfile stays generic**: always `.github/assets/vhs.Dockerfile` — never name it after a demo.2223## Quick start24251. **Scaffold** `.github/assets/vhs.Dockerfile` from [`templates/vhs.Dockerfile`](templates/vhs.Dockerfile). Keep or drop the Bun layer (see below).262. **Scaffold** the tape (`demo.tape` or `<name>.tape`) from [`templates/demo.tape`](templates/demo.tape) and write the choreography for this CLI.273. **Build the CLI** so the tape runs the built artifact, not the sources: run the repo's build (e.g. `pnpm build` / `bun run build`).284. **Build the image** (once, or after editing the Dockerfile):29 ```bash30 docker build -f .github/assets/vhs.Dockerfile -t <repo>-vhs .31 ```325. **Render** (after every tape change):33 ```bash34 docker run --rm -v "$PWD:/vhs" <repo>-vhs .github/assets/demo.tape35 ```366. **Verify** the GIF (dimensions, duration ≈ sum of sleeps, file size) — see [Verification](#verification).377. **Wire the README** reference and commit the tape, the Dockerfile, and the regenerated GIF together.3839## Does the CLI need Bun?4041The official VHS image has **no Bun**. If the CLI is a Bun tool (uses `opentui`, `bun:ffi`, or is started with `bun …`), the render will fail with "bun: command not found". Fix = the derived image in the template that installs Bun (`curl | bash` from bun.sh, with `/root/.bun/bin` on `PATH`).4243- **Bun CLI** → keep the Bun layer in `templates/vhs.Dockerfile`.44- **Node CLI** (plain citty, run with `node …`) → delete the Bun layer; the base image already has Node.4546## Resolution & render quality4748Defaults baked into the tape template:4950| Setting | Default (Full HD) | Lighter alternative |51| :-------------- | :---------------- | :------------------ |52| `Set Width` | `1920` | `1280` |53| `Set Height` | `1080` | `720` |54| `Set FontSize` | `20` | `16` |55| `Set Padding` | `16` | `16` |56| `Set Framerate` | `24` | `24` |5758> [!IMPORTANT]59> **Cap the framerate.** At 1080p a busy TUI usually can't repaint fast enough to sustain VHS's default **50fps** capture. VHS then drops frames, the GIF plays too fast / janky, and the measured duration is _shorter_ than the sum of your `Sleep`s. `Set Framerate 24` caps the capture rate to something the TUI can actually hold. If verification still shows the duration running short, lower the framerate further (20, then 16) and re-render.6061Use the lighter 1280×720 @ FontSize 16 profile when the GIF needs to be smaller/cheaper to load and Full HD detail isn't essential.6263## Pitfalls — do's & don'ts6465- **`Output` path MUST be relative** (e.g. `.github/assets/demo.gif`). An absolute path like `/vhs/...` is rejected by the VHS parser.66- **Build the CLI first** — run the repo's build (e.g. `pnpm build` / `bun run build`). The tape launches the built artifact (`dist/…`), not the source.67- **Bun CLIs need the Bun image layer** — see above.68- **Work on a throwaway copy of fixtures** if the demo edits/writes files. Copy them to `/tmp` inside the hidden `Hide … Show` block so the working tree stays clean (e.g. `cp -r /vhs/examples /tmp/demo`).69- **Clear pre-filled edit fields before typing.** Popovers/inputs seeded with the current value need emptying first: `Backspace@25ms 40`. Backspace on an empty field is a no-op, so over-counting is safe.70- **Don't end the demo with a quit key.** Quitting makes VHS film the shell teardown (scrollback, the setup command reappearing). Instead end on a `Sleep` holding the frame you want — VHS kills the process at tape end, giving a clean final frame.71- **Hide setup commands.** Wrap `cp`, the CLI launch, and any other plumbing in a `Hide … Show` block so only the demo itself is captured.7273## Verification7475After each render, confirm the GIF actually matches the tape. Requires `ffprobe`/`ffmpeg` (run them on the host, or via the image: `docker run --rm -v "$PWD:/vhs" --entrypoint ffprobe <repo>-vhs …`).76771. **Dimensions** match the tape's `Set Width`/`Set Height`:78 ```bash79 ffprobe -v error -select_streams v:0 -show_entries stream=width,height \80 -of csv=p=0 .github/assets/demo.gif81 ```822. **Duration ≈ sum of the _visible_ `Sleep`s** (plus visible typing time). Count only actions that are actually captured — exclude anything inside a `Hide … Show` block, since VHS records nothing there, so those sleeps/typing never reach the GIF. If it's meaningfully shorter than that, frames were dropped → lower `Set Framerate` and re-render:83 ```bash84 ffprobe -v error -show_entries format=duration -of csv=p=0 .github/assets/demo.gif85 ```863. **File size** is reasonable for a README asset (rough target: under ~2–3 MB; lighter profile if not). `ls -lh .github/assets/demo.gif`.874. **(Optional) Eyeball frames** — sample one frame per second to PNGs and look at them:88 ```bash89 ffmpeg -i .github/assets/demo.gif -vf fps=1 /tmp/demo-frames/f%03d.png90 ```91 Check the first frame (no leftover setup), the final frame (the intended end state), and any edit step.9293## README wiring9495Reference the GIF with a plain image tag near the top of the README (after the hero/badges):9697```markdown9899```100101For multiple demos, embed each under the relevant section with its descriptive name. If the README is being (re)written, the `write-readme` skill owns layout — this skill only owns the asset and its reference.102103## Steady-state loop (after the first run)104105Quick start covers the first run; afterwards only the cadence differs:106107- **Image** — rebuild only when the Dockerfile changes.108- **Every demo change** — run the repo's build (e.g. `pnpm build` / `bun run build`) → re-render → [verify](#verification).109- **Commit** the tape, Dockerfile, and regenerated GIF together — the tape is the source of truth.110111## Reference112113- **Open it while writing or editing the `.tape`** — when the directive you need is not already in the tape in front of you, when a theme has to be named exactly, or when a first demo needs a worked walkthrough to take its pacing from: [REFERENCE.md](REFERENCE.md).114- **Copy these on the first run, before the quick start assumes they exist**: [`templates/vhs.Dockerfile`](templates/vhs.Dockerfile), [`templates/demo.tape`](templates/demo.tape).