Vhs demo
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.From its SKILL.md
npx -y skills add TitusKirch/skills --skill vhs-demoAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
7.7 KB, ~1.9k tokens by cl100k_base, as published. Nobody here has run it
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
.tapeand 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.tapeanddemo.gifin.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.Dockerfilefromtemplates/vhs.Dockerfile. Keep or drop the Bun layer (see below). - Scaffold the tape (
demo.tapeor<name>.tape) fromtemplates/demo.tapeand 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 24caps 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
Outputpath 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
/tmpinside the hiddenHide … Showblock 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
Sleepholding 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 aHide … Showblock 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 aHide … Showblock, since VHS records nothing there, so those sleeps/typing never reach the GIF. If it's meaningfully shorter than that, frames were dropped → lowerSet Framerateand 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:
Check the first frame (no leftover setup), the final frame (the intended end state), and any edit step.ffmpeg -i .github/assets/demo.gif -vf fps=1 /tmp/demo-frames/f%03d.png
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
- Tape directives, theme list and a worked envprism walkthrough: REFERENCE.md.
- Templates to copy:
templates/vhs.Dockerfile,templates/demo.tape.
What ships with it: 3 files
7.0 KB alongside SKILL.md
templates/
- demo.tape1.8 KB
- vhs.Dockerfile717 B
- REFERENCE.md4.5 KB