vhs-recording

star 1

Create, debug, and record VHS (charmbracelet/vhs) demo tapes for TUI applications. Use when the user asks to create demo GIFs, fix broken recordings, or troubleshoot VHS tape issues. Covers tmux-based TUI apps, fake command injection, background orchestration, and recording verification.

Ischca By Ischca schedule Updated 2/7/2026

name: vhs-recording description: | Create, debug, and record VHS (charmbracelet/vhs) demo tapes for TUI applications. Use when the user asks to create demo GIFs, fix broken recordings, or troubleshoot VHS tape issues. Covers tmux-based TUI apps, fake command injection, background orchestration, and recording verification. allowed-tools: Read, Write, Edit, Bash, Glob, Grep argument-hint: "[create|debug|record ]"

VHS Recording Skill

Expert knowledge for creating reliable VHS (https://github.com/charmbracelet/vhs) demo recordings, especially for TUI applications that interact with tmux.

Usage

  • /vhs-recording create <name> — Create a new .tape file with best practices
  • /vhs-recording debug <tape-file> — Diagnose why a recording is broken
  • /vhs-recording record <tape-file> — Record and verify the output

Critical Pitfalls & Solutions

1. Background Processes Corrupt TUI Display

The #1 cause of "display is broken" in TUI recordings.

Any background process that writes to stdout/stderr will corrupt the TUI's alternate screen. The TUI renders via escape sequences to the alternate screen buffer; stray output from background processes interleaves with these sequences, producing garbled display.

# WRONG — background output bleeds into TUI rendering
bash ./setup-script.sh &

# CORRECT — silence ALL output at the top of background scripts
exec >/dev/null 2>&1

This applies to any command that produces output: echo, printf, CLI tools that print status messages, etc. If a script runs in the background while a TUI is active, every stdout/stderr write must be suppressed or redirected to a file.

2. GIF Verification

The Read tool in Claude Code only renders one static frame of an animated GIF. A GIF that appears blank may actually contain a full animation. Always verify with frame extraction:

# Extract frames at specific points (frame 150 = ~5s at 30fps)
mkdir -p /tmp/vhs-frames
ffmpeg -y -i output.webm \
  -vf "select='eq(n\,150)+eq(n\,300)+eq(n\,450)+eq(n\,600)+eq(n\,750)'" \
  -vsync vfr /tmp/vhs-frames/frame_%03d.png

# Then Read individual PNGs to inspect content

File size as sanity check:

  • < 100KB for a 30+ second recording → likely empty/broken
  • 200KB–2MB → normal range for a TUI recording
  • 5MB → may need framerate or resolution reduction

3. macOS Config Paths

Rust's dirs::config_dir() returns ~/Library/Application Support/ on macOS, NOT ~/.config/. Many tape files clean up config files in the Hidden section — using the wrong path means state is never cleared.

# WRONG
rm -f ~/.config/myapp/state.json

# CORRECT (macOS)
rm -f "$HOME/Library/Application Support/myapp/state.json"

Always check the actual application source to verify the config directory.

4. tmux PATH Propagation

Set Shell "bash" in VHS only affects the recording shell. tmux sessions created during the recording use the tmux server's default shell (typically the user's login shell — zsh on macOS).

The problem: zsh re-reads ~/.zshrc on startup, which often resets PATH. A fake command placed in PATH via export PATH="$PWD:$PATH" in the recording shell will NOT be found inside tmux sessions.

Solution: Control the tmux server environment directly.

# 1. Create fake command in a known location
mkdir -p /tmp/demo-bin
cp my-fake-command.sh /tmp/demo-bin/mycommand
chmod +x /tmp/demo-bin/mycommand

# 2. Start tmux server with controlled environment
tmux new-session -d -s _setup

# Force all new panes to use bash without profile (prevents PATH override)
tmux set-option -g default-command "bash --norc --noprofile"

# Set PATH for all future sessions
tmux set-environment -g PATH "/tmp/demo-bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"

# _setup session keeps the server alive; don't kill it

Now any session created by the TUI app (via tmux new-session) will inherit the controlled PATH and find the fake command.

5. Atomic File Writes & Race Conditions

If a background script writes a JSON/config file that the TUI reads periodically, std::fs::write() (or similar non-atomic write calls) creates a race window: the file is truncated before new content is written. The TUI may read an empty or partial file, causing state loss or flicker.

Symptoms: Data briefly disappears and reappears; UI flickers; state resets momentarily.

Fix in the writer:

let tmp = path.with_extension("tmp");
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, &path)?;  // Atomic on same filesystem

Fix in the reader (defensive):

// Don't trust an empty file when we know data should exist
if loaded_data.is_empty() && !current_state.is_empty() {
    // Skip this reload cycle — likely a race condition
}

6. Silent Script Failures

set -euo pipefail combined with exec >/dev/null 2>&1 = silent death. The script exits on any error and you never see why.

Debug approach:

# Redirect trace to a FILE, not stdout (which is suppressed)
bash -x ./orchestrator.sh 2>/tmp/orch-debug.log &
sleep 10
cat /tmp/orch-debug.log

7. VHS Timing vs Real Time

VHS Sleep commands control recording time (how long a frame is shown), not process execution. Background scripts run in real time regardless of VHS Sleep values.

Common timing to account for:

  • tmux session creation: ~0.3s
  • Command execution in a pane: ~0.5s
  • Shell startup in new pane: ~0.5-1s
  • TUI refresh cycle: typically 0.25-1s
  • Dynamic content reload: application-specific (often 1-5s)

Always add buffer time after creating sessions before expecting content to appear in the TUI.

8. Cleanup Must Match the App

Different apps store state in different locations. Before recording, kill the exact processes and remove the exact files the app uses:

# Example: full cleanup for a tmux-based app
tmux kill-server 2>/dev/null
rm -f "$HOME/Library/Application Support/myapp/state.json"
rm -f "$HOME/Library/Application Support/myapp/state.json.tmp"

Missing the temp file (.tmp) can cause the app to find stale data on the next rename operation.


VHS Tape Template

# Description of what this demo shows

Output assets/demo.gif
Output assets/demo.webm

Set Shell "bash"
Set FontFamily "Fira Code"
Set FontSize 15
Set Width 1400
Set Height 700
Set Padding 20
Set Framerate 30
Set PlaybackSpeed 0.8
Set TypingSpeed 50ms
Set Theme {
  "name": "Custom",
  "black": "#1a1a2e",
  "red": "#ef4444",
  "green": "#22c55e",
  "yellow": "#f59e0b",
  "blue": "#3b82f6",
  "magenta": "#8b5cf6",
  "cyan": "#22d3ee",
  "white": "#e2e8f0",
  "brightBlack": "#475569",
  "brightRed": "#f87171",
  "brightGreen": "#4ade80",
  "brightYellow": "#fbbf24",
  "brightBlue": "#60a5fa",
  "brightMagenta": "#a78bfa",
  "brightCyan": "#67e8f9",
  "brightWhite": "#f8fafc",
  "background": "#0f0d1a",
  "foreground": "#e2e8f0",
  "selection": "#334155",
  "cursor": "#f59e0b"
}

# ============================================================
# Hidden setup
# ============================================================
Hide

# Clean state (use correct OS-specific path!)
Type "tmux kill-server 2>/dev/null"
Enter
Sleep 500ms

# Background scripts MUST suppress output (exec >/dev/null 2>&1)
Type "bash ./orchestrator.sh &"
Enter
Sleep 500ms

Type "clear"
Enter
Sleep 500ms

Show

# ============================================================
# Demo content — organize into labeled phases
# ============================================================

# Phase 1 (0-5s): Launch the app
Type "myapp"
Enter
Sleep 5s

# Phase 2 (5-10s): First interaction
# ...

Debug Checklist

When a recording is broken, check in this order:

  1. File size — Is the GIF suspiciously small (< 100KB for 30s+)?
  2. Extract frames — Use ffmpeg to check specific timestamps
  3. Config/state path — Is cleanup using the correct OS-specific path?
  4. Background stdout — Are any background processes writing to the terminal?
  5. Script failures — Run orchestrator with bash -x redirected to a log file
  6. tmux sessions — Run tmux list-sessions to verify sessions were created
  7. State file — Check the correct path for the app's state/config file
  8. Timing — Is there enough buffer for the TUI to discover new content?
  9. PATH — Does the tmux session's shell find the expected commands?
  10. Atomic writes — Could a race condition cause empty/partial file reads?

Recording Workflow

# 1. Clean state
tmux kill-server 2>/dev/null

# 2. Record
vhs mytape.tape

# 3. Verify output
ls -lh assets/demo.gif         # Check file size

# 4. Extract frames to inspect
ffmpeg -y -i assets/demo.webm \
  -vf "select='eq(n\,150)+eq(n\,450)+eq(n\,750)'" \
  -vsync vfr /tmp/verify_%03d.png
# Read each PNG to visually inspect

# 5. If broken, debug the background script
bash -x ./orchestrator.sh 2>/tmp/debug.log &
sleep 15
cat /tmp/debug.log
Install via CLI
npx skills add https://github.com/Ischca/vhs-recording-skill --skill vhs-recording
Repository Details
star Stars 1
call_split Forks 1
navigation Branch main
article Path SKILL.md
More from Creator