Tape reference
This is the canonical reference for Betamax tape files. Use it with Tape files, Outputs, and Examples when authoring tapes. The Feature field guide shows rendered examples of each feature.
Betamax parses a tape before it starts a shell. Syntax errors, unknown commands, invalid regexes, unknown settings, type mismatches, unsupported output extensions, and startup-command ordering errors fail before the PTY is spawned.
Parsing rules
Section titled “Parsing rules”- A tape is line oriented.
- Blank lines are ignored.
- Lines whose trimmed form starts with
#are comments. - Tokens follow shell-like quoting and escaping. An unquoted
/regex/after a wait orSet WaitPatternis the exception: it is kept exactly as written. See Regex patterns. - One physical line may contain more than one command, such as
Type "echo hi" Enter. - A word with quotes or backslash escapes is always text, never a command:
"Enter"is the textEnter. Quoted text where a command should start is an error. - Startup commands belong before runtime commands.
Startup commands are Output, Require, Set, and Env. Runtime commands include typing, keys,
waits, sleeps, captions, screenshots, state checkpoints, Hide, Show, Copy, and Paste.
Startup commands after runtime commands
Section titled “Startup commands after runtime commands”The header is the leading run of Output, Require, Set, and Env commands; the first other
command starts the runtime section. Put startup commands in the header. Betamax accepts them later
for VHS compatibility, with the same effect as VHS:
| Command after runtime commands | Effect | Warning |
|---|---|---|
Set TypingSpeed |
Applies to later Type commands |
No |
Set Shell |
Applies to the whole tape | Yes |
Any other Set |
Ignored | Yes |
Env |
Applies to the whole tape from shell start | Yes |
Require |
Checked before the shell starts | Yes |
Output |
Applies to the whole tape | No |
Warnings go to stderr as warning: <tape>:<line>: <message>. betamax run --quiet hides them.
betamax validate prints them and still succeeds; betamax validate --strict fails when any tape
has warnings.
Outputs
Section titled “Outputs”| Command | Behavior | Notes |
|---|---|---|
Output <path>.gif |
Write an animated GIF | Encoded in process |
Output <path>.webp |
Write a lossless animated WebP | Encoded in process |
Output <path>.png |
Write a final-frame PNG | Encoded in process |
Output <path>.json |
Write final terminal state | See State JSON |
Output <path>.txt |
Write VHS-compatible text snapshots | Also .ascii; see Text outputs |
Output <path>.mp4 |
Write MP4 video | Requires ffmpeg on PATH |
Output <path>.webm |
Write WebM video | Requires ffmpeg on PATH |
Output <dir> |
Write numbered PNG frames into a directory | Extensionless path only |
Screenshot <path>.png |
Write a checkpoint screenshot at this command | Encoded in process |
Screenshot <path>.webp |
Write a lossless WebP checkpoint screenshot | Encoded in process |
State <path>.json |
Write checkpoint terminal state | Only .json is supported |
Betamax reports a missing ffmpeg as a runtime error only when a video output is requested.
Primary Output paths are grouped by output kind when written, not returned in source order.
Checkpoint Screenshot and State commands are side effects at their position in the tape.
Caption <text> renders presentation text onto visual outputs, but does not create files.
Settings
Section titled “Settings”Defaults below describe Default, which currently selects Framed. See Presets for
alternatives.
| Setting | Value | Default | Notes |
|---|---|---|---|
Shell |
string | bash |
Shell argv is split with shell-like rules. |
Theme |
theme name or inline JSON string | Aardvark Ink |
Ghostty or VHS name, any case. |
FontFamily |
string | JetBrains Mono |
Bundled; see Fonts. |
FontSize |
number | 22 |
Pixels. |
LetterSpacing |
number | 0 |
Additional pixels between cells. |
LineHeight |
number | 1 |
Multiplier applied to font size. |
Width |
number | 1200 |
Final output width in pixels. |
Height |
number | 720 |
Final output height in pixels. |
Columns |
number | from Width |
Terminal columns; sizes the output width. |
Rows |
number | from Height |
Terminal rows; sizes the output height. |
Padding |
number | 0 |
Inner padding before terminal cells. |
Framerate |
number | 50 |
Capture cadence in frames per second. |
TypingSpeed |
duration or number | 50ms |
Typing delay; number values are seconds. |
PlaybackSpeed |
number | 1.0 |
Changes output playback, not PTY timing. |
LoopOffset |
percentage or duration | 0 |
Percent of frames, like VHS; or 2s. |
CursorBlink |
bool | true |
Simulates a half-second blink cadence. |
WebpQuality |
number from 0 to 100, or Lossless |
Lossless |
Lossy animated WebP; see WebP. |
| KeyboardOverlay | Off, Keys, Input, or All |
Off |
Shows recent input chips in media. |
| KeyboardOverlayLocation | CaptionRow, TopLeft, TopRight, etc. |
CaptionRow |
Places keyboard overlay chips. |
WaitTimeout |
duration or number | 15s |
Number values are seconds. |
WaitPattern |
regex string | >$ |
Used by Wait without explicit pattern. |
Margin |
number, or string of 1–4 numbers | 32 |
Pixels; string sets sides as in CSS. |
MarginFill |
string | theme gradient | Color, gradient, image, or transparent. |
WindowBar |
Rings, RingsRight, Colorful, or etc. |
Colorful |
Left dots; any non-empty value enables bar. |
WindowBarSize |
number | 30 |
Bar height in pixels when enabled. |
WindowBarColor |
#rrggbb string |
theme surface | Invalid colors fall back to theme bg. |
WindowTitle |
string | empty / disabled | Initial centered window bar title. |
WindowTitleFromTerminal |
bool | true |
Let OSC 0/2 update the window title. |
WindowTitleColor |
auto or #rrggbb string |
auto | Invalid colors use automatic contrast. |
BorderRadius |
number | 11 |
Rounded-corner mask radius in pixels. |
WindowBorder |
number | 1 |
Shell outline width; zero disables. |
WindowBorderColor |
string | auto |
Auto contrast or explicit #rrggbb. |
WindowBorderOpacity |
number | 0.15 |
Outline opacity, clamped to 0–1. |
Preset |
Default, Framed, Bare, Inset, or Default-0.1 |
Default |
Baseline for appearance settings. |
Shadow |
bool | true |
Outer window shadow. |
ShadowBlur |
number | 20 |
CSS blur radius in pixels. |
ShadowOffset |
number | 6 |
Vertical shadow offset in pixels. |
ShadowOpacity |
number | 0.55 |
Shadow opacity, clamped to 0–1. |
Unknown settings and type mismatches are errors. For example, Set Wdith 900 and
Set Width "wide" fail before the shell starts instead of silently falling back to defaults.
Betamax embeds JetBrains Mono 2.304, so the default FontFamily renders the same whether or not
JetBrains Mono is installed; an installed copy is ignored. Other families come from installed fonts.
When the requested family is not installed, Betamax logs
FontFamily "<name>" is not installed; using "JetBrains Mono" and renders with the bundled font.
Glyphs that JetBrains Mono lacks, such as CJK, emoji, and Nerd Font icons, still come from installed
fallback fonts. For matching output, install the same fallback fonts in CI as locally, for example
sudo apt-get install -y fonts-noto-cjk fonts-noto-color-emoji on Ubuntu.
The terminal grid is derived after all settings are applied. Margin, window-bar decoration, and any
presentation row needed for captions or KeyboardOverlayLocation CaptionRow are subtracted from
width and height first, then padding, font size, letter spacing, and line height determine the PTY
columns and rows. Positive terminal dimensions fit at least one row and column. Settings that
leave no terminal canvas are errors; increase dimensions or select Bare for a small image.
Font size, margins, and frame decoration affect the space available to the application. When testing
modal placement, centering, wrapping, or split panes, start with the default FontSize and
Margin.
Increase Width or Height if the application needs more space.
Sizing in cells
Section titled “Sizing in cells”Set Columns and Set Rows ask for an exact terminal grid, like VHS. Betamax grows the output to
fit it: the cells, then padding, margin, window bar, and any caption row, rounded up to an even
pixel count for video encoders. Font size, letter spacing, and line height change the output size,
not the grid.
Set Columns 80Set Rows 24Each axis is sized one way. Set only Rows and the width still comes from Width, the preset, or
the default. Setting Width and Columns, or Height and Rows, is an error. A preset’s size is
a default, so cells replace it without an error. Both values are whole numbers in 1..=65535 and,
like other settings, belong in the header. Each
captured run prints the resolved
grid and output size, for example terminal 80x24 cells, output 1104x622 px.
Set Columns and Set Rows choose the starting grid and size the output around it.
Resize changes the grid mid-tape and keeps the output size fixed: a
smaller grid leaves empty canvas, and a larger one is clipped. Use Set to size the recording,
and Resize to test how an application reacts when its terminal changes size.
Set KeyboardOverlay Input draws input labels on generated PNG, GIF, video,
screenshot, and frame-sequence media. Labels appear when input is queued and linger briefly after
the input is typed, so review GIFs show the action near the terminal change it caused. The overlay
is presentation-only: it does not change PTY input bytes, waits, state JSON, or final output
dimensions. KeyboardOverlayLocation CaptionRow reserves a bottom presentation row before deriving
the terminal grid so chips do not cover terminal content. Corner locations such as BottomRight
draw chips inside the terminal canvas with a small inset from the terminal edge. Caption-row
overlays are inset by Padding when the tape has no margin, or by half of BorderRadius from the
terminal frame edge when it has one; see Caption for details and workarounds.
Keyboard overlay modes are:
Off: no overlay.Keys: explicit key commands only, such asCtrl+P,Down,Enter, andEscape.Input: key commands plus short typed input that reads like user intent.All: every visible input event, including longTypecommands summarized by character count.
For built-in shell names such as bash, zsh, and fish, Betamax starts a clean recording shell
with startup files and history disabled where possible. Those shells use the VHS-style colored >
prompt by default, so captures do not inherit prompts such as bash-5.3$ from the host
environment.
Without Set Shell, tapes run in bash found on PATH, as in VHS, not the host user’s $SHELL.
If bash isn’t installed, the run fails before the shell starts; set an installed shell such as
Set Shell "sh". Set Shell doesn’t expand environment variables, so name the shell (Set Shell "zsh") rather than writing "$SHELL". On macOS without Homebrew bash, this is the system bash
3.2.
Before the first command, Betamax waits for the shell to be ready for typing: for its line editor
to take over the terminal (leave canonical mode), so typed text does not echo ahead of the prompt.
Built-in shells are ready within tens of milliseconds. A program that never takes over, such as
dash or a custom program reading whole lines, starts the tape after 250 ms. Betamax does not
wait for startup output to stop; output after the shell is ready is recorded as it arrives. To
start from a particular screen, begin the tape with Wait+Screen.
Presets
Section titled “Presets”An unconfigured tape uses Default, which currently selects Framed. Set Preset Default
selects the current default explicitly; Set Preset Framed selects that named look directly.
Future default selection can change without redefining the named Framed preset.
Preset names are case-insensitive. When repeated, the last Set Preset selects the baseline.
Ordinary Set values override it regardless of their position relative to Set Preset; repeated
ordinary settings retain their existing last-value-wins behavior.
Set FontSize 26Set Preset FramedSet Theme "GitHub Light Default"Set Shadow falseThis uses the framed baseline, 26px text, a light theme, and no shadow. Explicit colors survive
preset and theme ordering. Set WindowBar "", Set WindowBorder 0, and Set Shadow false disable
those effects. To remove all decoration together, select Bare.
| Setting | Framed | Bare | Inset | Default-0.1 |
|---|---|---|---|---|
| Width × Height | 1200×720 | 1200×720 | 1200×720 | 1200×600 |
| Theme | Aardvark Ink | Aardvark Ink | Aardvark Ink | Aardvark Blue |
| FontSize / LetterSpacing / LineHeight | 22 / 0 / 1 | 22 / 0 / 1 | 22 / 0 / 1 | 22 / 1 / 1 |
| FontFamily | JetBrains Mono | JetBrains Mono | JetBrains Mono | JetBrains Mono |
| Padding | 0 | 0 | 0 | 60 |
| Margin | 32 | 0 | 16 | 0 |
| MarginFill | theme gradient | theme background | theme surface | theme background |
| WindowBar / WindowBarSize | Colorful / 30 | disabled / 30 | disabled / 30 | disabled / 30 |
| WindowBarColor | theme surface | theme background | theme surface | theme background |
| BorderRadius / WindowBorder | 11 / 1 | 0 / 0 | 11 / 1 | 0 / 0 |
| WindowBorderColor / Opacity | auto / 0.15 | auto / 0.15 | auto / 0.15 | auto / 0.15 |
| Shadow / Blur / Offset / Opacity | true / 20 / 6 / 0.55 | false / 28 / 9 / 0.55 | false / 28 / 9 / 0.55 | false / 28 / 9 / 0.55 |
| WindowTitleFromTerminal | true | true | true | false |
All presets start with an empty static title and automatic title contrast. Timing, shell, cursor, keyboard overlays, captions, and output format are independent of preset.
Framed provides a window bar, rounded shell, subtle outline, gradient, and shadow for README
media. Bare provides undecorated terminal content. Inset provides a compact rounded panel on a
solid backdrop, without a bar or shadow. Run
examples/presets-{default,framed,bare,inset,default-0.1}.tape to render the same terminal scene
with each baseline.
The framed backdrop is a gradient in the theme background’s own hue, darker than the window. Its
top is slightly darker than the background with a little more chroma; its bottom is darker still,
at 70% of the background’s OKLCH lightness for dark themes and 0.10 below it for light themes.
Neutral backgrounds stay neutral. The bar and inset backdrop use the
background mixed with 25% black for dark themes and 4% black for light themes. These defaults
follow the final selected theme. An explicit MarginFill or WindowBarColor overrides the
derived surface.
At a displayed width of 800px, the 1200px source’s 22px text appears approximately 16px tall. Choose a smaller font or larger canvas for denser applications; output scaling is independent.
For tapes migrating from Betamax 0.1 to the planned 0.2 defaults, add Set Preset Default-0.1 in
the preamble. This restores the pre-preset 0.1 sizing, typography, theme, and disabled effects. It
does not promise pixel-identical VHS rendering. Bare retains the new typography and sizing
instead. Framed, Bare, and Inset are visual baselines whose values may evolve. Default-0.1
retains the pre-preset 0.1 default settings; renderer fixes may still change pixels. Set individual
values when exact settings matter.
Window border
Section titled “Window border”Set WindowBorder 1 enables a subtle inset outline around the terminal and window bar together.
It follows the continuous corners of BorderRadius, without a divider between the bar and terminal.
The Framed and Inset width is one pixel; Bare and Default-0.1 disable the outline. Fractional widths
truncate to integer pixels. Negative or non-finite widths and non-finite opacity values are errors.
Opacity is clamped to 0–1; zero leaves rendering unchanged.
WindowBorderColor "auto" (the default) selects white or black for contrast with the final theme
background, using linear-sRGB relative luminance with a 0.179 threshold. Explicit #rrggbb colors
survive subsequent theme changes. Invalid color strings are errors; auto resets an override.
The shell expands outward into the margin until its border and rounded corners surround the complete
terminal cell rectangle. They never paint over or clip cells, including with Padding 0. If the
margin cannot hold the shell, startup reports an error: increase Margin or reduce BorderRadius
or WindowBorder. Output dimensions, cell placement, PTY rows/columns, raw frames, and state JSON
do not change when border width or radius changes. Captions and keyboard overlays draw afterward.
Rounded edges blend against the backdrop at the same coordinates, including a shadow when available.
Output remains opaque.
Margin fill
Section titled “Margin fill”MarginFill accepts a single color, Betamax’s linear(...) gradient syntax, or an
image. A color is
#rrggbb, #rrggbbaa with alpha last as in CSS, or transparent:
Set Margin 44Set MarginFill "linear(#232640, #0f101a)"Set MarginFill "linear(to: bottom-right, #232640, #0f101a)"Set MarginFill "linear(to: left, #232640, #0f101a)"Set MarginFill "linear(angle: 135deg, #232640, #0f101a)"Set MarginFill "linear(angle: 135deg, #4d5b9e 0%, #232640 42%, #0f101a 100%)"Margin takes one number for every side, or a string in CSS shorthand order: one value sets all
four sides, two set top/bottom then left/right, three set top, left/right, then bottom, and four set
top, right, bottom, left. Values are whole pixels separated by spaces; anything else is an error.
Columns and Rows add each side once, so the grid size is unchanged:
Set Margin "40 200 120 40"linear(...) takes an optional direction followed by at least two comma-separated color stops.
Without a direction, it runs from top to bottom. A to: direction points from the first stop to
the last, and accepts top, top-right, right, bottom-right, bottom, bottom-left, left,
or top-left. angle: <number>deg is measured clockwise from upward: 90deg is left to right and
135deg is top-left to bottom-right. A stop is a color, optionally followed by <position>%.
When positions are omitted, they are distributed evenly; when one stop has a position, every stop
must have one, in strictly ascending order.
This is Betamax syntax, not a CSS parser: it accepts hex colors, transparent, to: directions,
angles, and linear stops only. Colors interpolate in sRGB, rounding each channel with a small, fixed
ordered dither to soften banding in GIFs. The gradient spans the entire output, including rounded
window corners and the caption row. It does not change the terminal’s background or grid.
With no explicit MarginFill, the backdrop follows the selected preset and final theme. An explicit
color or gradient is preserved regardless of Set Theme ordering. Set MarginFill "" disables the
margin. Invalid colors and malformed gradients are errors that name the expected syntax. betamax validate checks tape syntax, so it does not reject malformed fill strings. Radial
gradients are not supported. See examples/margin-gradient.tape for a complete
example.
Transparent margin
Section titled “Transparent margin”Set MarginFill transparent leaves the backdrop clear so the output sits on whatever page shows it.
Alpha colors tint the page or fade a gradient to clear:
Set Preset FramedSet MarginFill transparentSet MarginFill "#0f101a80"Set MarginFill "linear(to: bottom, #4d5b9e, transparent)"Stops with alpha blend premultiplied, so a fade to transparent keeps its hue as it clears rather
than darkening. With transparent, the margin, caption row, and the area outside rounded corners
are fully transparent. The shadow becomes translucent black, so it darkens any page. The window,
bar, border, and terminal stay opaque. Captions and keyboard labels keep their theme colors, so
check them against the page they sit on.
| Output | Transparency |
|---|---|
PNG, Screenshot |
Full alpha |
| PNG sequence, WebP | Full alpha |
| WebM | Full alpha (VP9 yuva420p) |
| GIF | One bit: partly transparent pixels, including the shadow, are dropped |
| MP4 | Rejected before the shell starts; H.264 has no alpha |
Use PNG or WebP to keep soft shadows and antialiased corners. See
examples/margin-transparent.tape.
Image margin
Section titled “Image margin”MarginFill also takes a picture. A bare path works as in VHS and stretches the image to the
output size. image(<path>) keeps the aspect ratio and takes an optional fit:
Set MarginFill "backdrop.png"Set MarginFill "image(backdrop.png)"Set MarginFill "image(backdrop.png, contain)"| Fit | Behavior |
|---|---|
cover |
Default. Scale to fill the output; crop the overflow equally from both sides. |
contain |
Scale to fit inside the output, centered; the theme background fills the gap. |
tile |
Repeat at native size from the top-left corner. |
stretch |
Scale each axis to the output size, as a bare path does. |
Any value that is not empty, a # color, linear(...), or transparent is an image path.
Relative paths resolve against the working directory first, as in VHS, then against the tape’s
directory, so a tape and its picture can move together. ~/ expands to the home directory. PNG,
JPEG, and WebP files are supported, detected from their contents; each side may be up to 16384
pixels. The image is decoded and resampled with Lanczos3 once per run.
Unlike invalid colors, image problems stop the run before the shell starts: a missing file names
every place it looked, an undecodable file reports the decoder error, and a malformed image(...)
lists the accepted forms. betamax validate does not open images.
The image covers the same area as a gradient, including the caption row and the area outside
rounded corners. The shadow, captions, and keyboard chips draw over it unchanged, so prefer calm,
low-detail pictures behind text. A picture with alpha keeps it and follows the transparency rules
above. GIF output shares one 256-color palette between the picture and the terminal, so detailed
photos band; use MP4, WebM, or WebP for those. See examples/margin-image.tape.
Window shadow
Section titled “Window shadow”Shadow draws a black outer shadow behind the rounded terminal window. ShadowBlur is the CSS blur
radius, so its standard deviation is half the value; Framed uses 20 (sigma 10); the other styles
retain the inactive value 28 (sigma 14). ShadowOffset is a signed vertical displacement, and
ShadowOpacity is clamped to 0..1.
Set Margin 44Set Shadow trueSet ShadowBlur 28Set ShadowOffset 9Set ShadowOpacity 0.55The shadow is clipped to the output frame. Margin 0 or MarginFill "" makes it a no-op, as does
zero opacity. Allow a margin of at least about ShadowBlur + abs(ShadowOffset) to avoid obvious
clipping. Also allow room for shell expansion around cells; use Margin 44 for extra room. Increase
the margin further if the faint tail is still visible at the edge. The shadow shows best on a
gradient or on a backdrop lighter than near-black.
Window corners
Section titled “Window corners”BorderRadius uses continuous corners: the curve eases into the straight window edges instead of
joining them with a circular arc. The same outline shapes the window mask, inset border, and shadow.
The radius clamps to fit small windows; zero keeps square corners. Corner coverage and antialiased
circular window buttons are prepared once per run, without resizing terminal pixels or the output.
Window bar color
Section titled “Window bar color”Set WindowBarColor "#0f101a" fills the window bar with a #rrggbb color. The default is the
preset-derived theme surface. An explicit color takes precedence regardless of whether Set Theme
appears before or after it. Invalid colors, including an empty string, fall back to the active theme
background. The setting has no effect when WindowBar is empty.
Only the bar changes; terminal colors stay in the theme. With BorderRadius, the window’s top
corners are rounded and the boundary between the bar and terminal remains straight.
Set WindowBar RingsRightSet WindowBarColor "#0f101a"Set Theme '{"name":"Night","background":"#151723"}'Set BorderRadius 11Window title
Section titled “Window title”Set WindowBar ColorfulSet WindowTitle "jk — log"Set WindowTitleColor autoWindowTitle defaults to an empty string and is ignored when WindowBar is empty. The
single-line title is centered across the whole bar, with equal space reserved at both ends to avoid
buttons in left and Right modes, with a gap of two button radii. Long titles truncate with …;
extremely narrow bars clip the ellipsis or omit the title when no space remains. Line breaks become
spaces.
The title uses FontFamily at 40% of WindowBarSize (12 pixels for the default 30-pixel bar).
WindowTitleColor defaults to auto; omitted, empty, or invalid values also use automatic color.
Automatic color starts with 65% theme foreground and 35% effective window-bar color, mixing sRGB
channels. It increases the foreground share in one-percent steps until the text color reaches at
least 4.5:1 contrast against the bar. If the theme foreground cannot reach that contrast, it chooses
black or white, whichever contrasts more. This uses an explicit WindowBarColor when configured.
The ratio describes the text color before glyph antialiasing.
An explicit #rrggbb title color bypasses automatic contrast adjustment and survives Set Theme
in either order, even when its contrast is low. Titles affect decorated media only; they do not
change terminal state.
Following app titles
Section titled “Following app titles”Set WindowTitleFromTerminal true lets the recorded program change the bar with OSC 0 or OSC 2. The
default is true (false with Preset Default-0.1). Set it to false to keep a static title.
Custom capture backends follow app titles by default only when they report title-event support; an
explicit true still requires that capability. For example, the app can write
printf '\033]2;title set by app\007'. BEL and ST terminators are supported by Ghostty.
Set WindowBar ColorfulSet WindowTitle "title set by tape"Set WindowTitleFromTerminal true
Type "printf '\033]2;title set by app\007'"EnterSleep 1sWindowTitle "title reset by tape"Sleep 1sThe latest assignment wins: app titles replace the initial tape title, WindowTitle "text"
replaces an app title, and a later OSC can replace it again. Shell startup and prompt OSC titles
count too. An empty title explicitly clears the bar; it does not restore the initial value.
Repeated identical assignments do not create a transition. The title remains hidden with no bar.
Every visible title transition retains its corresponding terminal frame, including several titles
in one output burst. Titles participate in frame coalescing. Before encoding, a transition gets at
least one output video frame and 10ms of hold time if its natural hold is shorter. Very fast bursts
can therefore extend playback. Hidden updates change the current title without replaying hidden
history on Show. Screenshots and final stills use the current title. Title patches are cached
with a bounded 32-entry cache for the run.
State JSON always reports the app’s raw terminal title, even with following disabled. Tape display assignments do not change that state. Custom capture backends must implement title observation to support the opt-in; an unsupported backend returns an error instead of silently losing events.
Command reference
Section titled “Command reference”Output <path>
Section titled “Output <path>”Adds a primary output. The runner classifies it by extension after parsing and before starting the shell; unsupported extensions fail before shell startup.
.gif: animated GIF. After the first frame, each frame stores only the region that changed, and unchanged frames extend the previous frame’s hold..webp: animated WebP with millisecond frame holds. Lossless by default; after the first frame, each frame stores only the region that changed.Set WebpQuality 85makes it lossy and several times smaller for recordings with images; see WebP encoding..png: final-frame PNG..mp4and.webm: video written throughffmpeg..json: final terminal-state JSON..txtor.ascii: VHS-compatible plain-text snapshots of the screen, one after each command (see Text outputs).- no extension: directory of numbered PNG frames.
Runner::run_artifacts returns Output paths in deterministic output-kind order, not source order.
Inline Screenshot and State commands are checkpoint outputs and are not in that list.
Text outputs
Section titled “Text outputs”Output demo.txt and Output demo.ascii write the same format as VHS, so VHS golden files compare
byte for byte. After each command that runs while the tape is shown, Betamax appends the visible
screen: one line per terminal row, trailing whitespace trimmed, blank rows kept. A line of 80 ─
characters follows each snapshot. Styles and the cursor are dropped, wide characters appear once,
and the alternate screen is written while an app has it active.
Hide and the commands after it add nothing until Show, which adds a snapshot. Set, Output,
and Require never add one. Two choices differ from VHS: VHS also writes a snapshot for each
header command after Output, and once output scrolls it dumps the oldest scrollback rows instead
of the screen. Betamax skips the header and always writes the visible screen. Each Output gets
its own file, where VHS keeps only the last text output.
Require <program>
Section titled “Require <program>”Fails early if a program is not present on PATH. This is useful for tapes that call optional
tools such as git, rg, cargo, or ffmpeg. The check runs before any PTY or output side
effects, including for a Require placed after runtime commands, which also warns. It looks for a
file with that name in each PATH directory; executable-bit failures are left to the process that
later runs the program. A missing program leaves a failure bundle with the
report.
Set <name> <value>
Section titled “Set <name> <value>”Applies one setting from the settings table. Unknown settings and type mismatches are
errors. Put Set commands before runtime commands because they affect shell startup, PTY size,
capture timing, rendering, or output decoration. After runtime commands, Set TypingSpeed changes
the delay for later Type commands, Set Shell still applies to the whole tape, and other
settings are ignored with a warning. See
Startup commands after runtime commands.
The shell starts in the directory where betamax was run, not the tape file’s directory.
WindowTitle <text>
Section titled “WindowTitle <text>”Change the displayed window title immediately in tape order. Unlike the startup-only
Set WindowTitle, this command can appear between runtime commands. It accepts one text argument,
including an empty string to clear the bar; use quotes for spaces. It does not accept @duration:
use Sleep for a deliberate hold. Pending PTY output is consumed before the assignment without
waiting for quiet. If that queue exceeds the bounded drain budget, synchronize with the app first.
See Following app titles for precedence and output timing.
Env <key> <value>
Section titled “Env <key> <value>”Adds or overrides an environment variable for the spawned shell. Betamax applies its terminal
defaults first, then tape Env values, so a tape can intentionally override variables such as
TERM, COLORTERM, NO_COLOR, prompt variables, or application-specific flags. Put Env in the
header. The shell is spawned before runtime execution, so an Env after runtime commands still
applies from shell start, with a warning.
Type[@duration] <text>
Section titled “Type[@duration] <text>”Types text into the PTY one Unicode scalar at a time. Type@25ms "hello" overrides the default
typing speed for that command; otherwise the most recent Set TypingSpeed applies. Betamax drains
output and captures frames after each typed character, so text appears progressively. Several
strings are joined with one space, as in VHS: Type "echo" "hi" types echo hi and warns.
Quoted strings that name a key or command are joined too, so Type "press" "q" types press q. Use
Type for printable text and key commands for terminal control keys such as arrows, tab, delete,
and interrupt.
Key commands
Section titled “Key commands”Key commands send terminal key sequences to the PTY. They accept an optional @duration suffix,
which sleeps after each key press, and an optional repeat count after the key:
EnterEnter@250msDown 3Shift+TabCtrl+CAlt+FCtrl+Alt+Shift+XF5Supported named keys are Escape, Backspace, Delete, Insert, Down, Enter, Space,
Tab, Left, Right, Up, PageUp, PageDown, Home, End, and F1 through F25.
Single-character key tokens are accepted for modified combinations such as Ctrl+C. Supported
modifiers are Ctrl, Alt, and Shift, and they come before the key. A token with any other
part, or with more than one key, is an unsupported command rather than a key press. Betamax
routes key events through libghostty-vt’s key encoder, with fallbacks for common terminal control
sequences.
Mouse commands
Section titled “Mouse commands”Mouse coordinates are zero-based terminal cells: column 0, row 0 is the top-left cell.
They exclude font padding, margins, window bars, and captions. The pointer starts at (0, 0).
MouseMove 42 8MouseDown LeftMouseMove 46 8MouseUp LeftShift+MouseScroll Down 3MouseMove <column> <row> moves the pointer; moving with a held button produces a drag.
MouseDown <button> and MouseUp <button> accept Left, Middle, or Right.
MouseScroll <direction> <count> sends a positive number of wheel ticks at the pointer, with
Up, Down, Left, or Right describing wheel direction. These commands accept Ctrl+,
Alt+, and Shift+ prefixes in any order. Modifiers apply to that action only.
The application controls mouse reporting. Betamax uses Ghostty’s encoder to respect disabled, press/release, button-motion, and all-motion reporting and the negotiated X10, UTF-8, SGR, urxvt, or SGR-pixel format. Legacy protocols can suppress coordinates they cannot represent. SGR-pixel reports use the center of the requested cell with the current cell metrics. Wheel commands emit press reports without changing held-button state. Multiple held buttons are supported; motion reports the most recently pressed button still held.
Out-of-bounds coordinates, pressing an already-held button, and releasing a button that is not held
fail the tape. With reporting disabled, input produces no PTY bytes while pointer/button state
still updates. Betamax does not emulate a terminal window’s local selection; use ScrollUp and
ScrollDown to move the viewport through scrollback.
Use an output wait after each action to synchronize with application redraw. Mouse commands do not
accept @duration; use Sleep separately for recording pacing. Mouse input requires a terminal
backend even when the tape requests no media. Custom TerminalSession implementations can opt in
by implementing mouse_input; its default reports unsupported input when a mouse command runs.
ScrollUp[@duration] [count] and ScrollDown[@duration] [count]
Section titled “ScrollUp[@duration] [count] and ScrollDown[@duration] [count]”Move the terminal viewport through scrollback, one row at a time, without sending input to the
program. count defaults to 1, and @duration sets the pause after each row; the frame interval
applies when it is absent. These match VHS’s commands of the same name.
ScrollUp 10ScrollDown@50ms 4Scrolling stops at the oldest scrollback row and at the live screen. While the viewport is scrolled
back, new output does not pull it down; Type, key commands, and Paste return it to the bottom
before sending input, as Ghostty and VHS do. The alternate screen has no scrollback, so these
commands do nothing there. Wait+Screen, AssertText, screenshots, and the viewport fields in
state JSON all read the rows currently shown.
Use MouseScroll instead when the program should handle scrolling, such as a TUI with its own
scroll region.
Resize <columns> <rows>
Section titled “Resize <columns> <rows>”Resize the live terminal grid and PTY in cells. Both dimensions must be integers in 1..=65535.
The child receives its normal resize notification; follow the command with an output wait to
verify redraw rather than assuming a timing delay is sufficient.
Resize 50 16Wait+Screen@5s "narrow layout"State target/narrow.jsonThe media canvas, font metrics, decorations, and padding stay fixed for the entire tape. To size
the recording in cells, use Set Columns and Set Rows instead. The live
grid starts at the same top-left origin as the original grid. Shrinking leaves unused canvas space
in the terminal background color; growing beyond the canvas clips the rendered grid. State JSON
and subsequent mouse bounds use the live grid even for clipped cells. The pointer clamps to the
last valid column and row after shrinking; held buttons remain held.
The terminal backend updates before the PTY notification, so child redraw goes into the new grid.
Either update failing aborts the tape; commands do not continue with inconsistent dimensions.
Optional kernel pixel dimensions use grid size times cell metrics, or zero (unspecified) if they
exceed the PTY’s 16-bit pixel fields. The PTY control handle remains alive for the session and is
released with its reader, writer, and child handles. A custom TerminalSession can implement
resize; the default returns an unsupported-resize error when this command runs.
Sleep <duration>
Section titled “Sleep <duration>”Pauses tape execution while continuing to drain PTY output and capture frames. Durations accept
compact forms such as 500ms, 1s, and 2m, VHS-style split forms such as 500 ms and 1 s,
and bare numbers such as 0.5 as seconds. Negative durations are errors.
In tests, use sleeps only when there is no prompt, status line, or other text to wait for. In demos, add sleeps after waits to give viewers time to read the output.
Wait[@duration] [/regex/|text]
Section titled “Wait[@duration] [/regex/|text]”Waits for terminal text while continuing to drain output and capture frames. A bare Wait checks
the current cursor line.
Waits are assertions and synchronization points. Put Wait, Wait+Line, or Wait+Screen before
any sleep that only exists to make a GIF, video, or review artifact readable.
Pattern forms:
/regex/: regular expression, validated when the tape is parsed. See Regex patterns.text: plain substring match. Quote text that names a command or key, such as"Enter"or"q". Unquoted,Wait Enterwaits for the default pattern and then presses Enter.- omitted: use
Set WaitPattern, which defaults to the VHS-style prompt regex>$.
Wait@5s sets the timeout for that command; otherwise Set WaitTimeout applies, defaulting to
15s. On timeout, the tape fails with the target and pattern in the error message.
On timeout, the tape fails with the target and pattern in the error. If the shell exits before the pattern matches, the wait fails at once with the exit status instead of running out the timeout. Either failure leaves a failure bundle.
Regex patterns
Section titled “Regex patterns”Wait regexes use the Rust regex syntax, which
has no lookaround or backreferences.
Write the regex unquoted. After Wait, Wait+Line, Wait+Screen, or Set WaitPattern, an
unquoted /regex/ is kept exactly as written, as in VHS:
Wait+Screen /v[0-9]+\.[0-9]+/Wait+Screen@10s /it's (ok|done)/Wait+Screen /(?m)^ready *$/- Backslashes are kept, so
\.matches a literal dot. - Spaces and quote characters are part of the pattern. The regex ends at the first
/that ends a word and is not escaped, so/a/b/is the patterna/b. Write\/for a slash followed by a space. - A quoted pattern such as
"/v[0-9]+\.[0-9]+/"is a string first. Single quotes keep it unchanged. Double quotes keep\.but turn\\into\and\"into". Set WaitPatterntreats its value as a regex with or without slashes, so>$and/>$/are the same.
What the regex sees depends on the target:
WaitandWait+Linematch the cursor row with trailing blanks removed, so the default>$matches a prompt that ends in>and a space.Wait+Screenmatches every visible row joined into one string. Each row is padded with spaces to the terminal width and ends in a newline.^and$match only at the start and end of the whole screen, so the default>$never matches and a bareWait+Screentimes out. To anchor to the end of a row, turn on multi-line mode and allow the padding, as in/(?m)^ready *$/. A row-anchored prompt pattern also matches earlier prompts still on screen.
Wait+Line[@duration] [/regex/|text]
Section titled “Wait+Line[@duration] [/regex/|text]”Waits against the current cursor line. This is equivalent to bare Wait with an explicit target,
and it is useful when the tape should make the target obvious.
Wait+Screen[@duration] [/regex/|text]
Section titled “Wait+Screen[@duration] [/regex/|text]”Waits against all visible viewport text. Use this for command output, full-screen TUIs, status lines, or any situation where cursor position should not matter.
Checkpoint assertions
Section titled “Checkpoint assertions”Assertions pass on the first frame where their condition holds, checking after each burst of
output for up to WaitTimeout. They behave like a Wait that checks cells, styles, or state
instead of text, so an assertion right after a keypress does not need a separate wait first. When
the condition never holds, the error describes the last frame checked, and the run takes the full
WaitTimeout to report it.
This is a deliberate choice. Betamax never waits for output to stop, because a program that animates continuously never stops, and waiting would fail or stall the tape. Checking a single frame instead would make every assertion race the program’s redraw, so each one would need its own wait. A frame that matched only briefly still counts: the terminal showed it.
AssertAbsent <text> follows the same rule: it passes on the first frame without the text, which
suits text that an action removes. To check that text never appears, use AssertAbsent@<duration>:
it must hold on every frame in that period, starting one frame after the previous command.
Wait+Screen@5s "ready"AssertText "ready"AssertCells 4 2 '["1", "5", "0", "0"]'AssertStyle 4 2 4 '{"fg":"#7ecf91","bold":true}'AssertAbsent@500ms "error"AssertState baselines/ready.jsonAssertText <text>requires a literal visible-screen substring.AssertCells <column> <row> <JSON>matches an exact horizontal range. Supply one JSON string per physical cell, including spaces for blanks and wide-character continuations. For example,'["界", " ", "é"]'occupies three cells; the combining mark stays withe. Coordinates are zero-based and exclude all presentation decoration. Ranges outside the live grid fail.AssertStyle <column> <row> <width> <JSON>checks every cell in the range.fgandbg, when supplied, match resolved#RRGGBBcolors. Omitted colors are unconstrained. All attributes match exactly: omitted booleans mean false and omittedunderlinemeansnone. Accepted attributes are those in State JSON; unknown fields, invalid colors, and underline names fail validation. The width must be positive.AssertAbsent <text>passes on the first frame without the text.AssertAbsent@<duration> <text>instead observes the screen throughout that positive duration, checking after each PTY chunk and at the end. It fails immediately if the substring appears. This samples terminal state; text emitted and erased within one PTY chunk is not observable. The observation begins at the checkpoint frame, and does not make a claim about later output.AssertState <baseline.json>compares the captured state with an existing explicit baseline. Dimensions, cursor, metadata, text, styles, and compact spans all participate. No fields are ignored, including title, working directory, scrollback, and style-table order. The JSON representation of omitted underline and explicitnoneis treated as equivalent. Capture baselines withState, inspect their text/styles and corresponding screenshot, and approve changes explicitly. Assertions never create or replace baselines automatically.
Failures name the tape line and the expected and actual text, cells, or styles, and leave a
failure bundle with a screenshot and state JSON of the checked screen. No pixel
golden comparison is performed. Custom backends can implement the optional
viewport_cells method for cell/style assertions; text and baseline assertions use existing
methods.
Stops appending frames to animated outputs. The PTY still runs, terminal state still updates, waits
still work, and checkpoint outputs still see the current terminal. Use this for compilation, setup,
or teardown that should not appear in the final GIF/video. Hide controls recording visibility,
not execution isolation: hidden commands can still affect final state.
Resumes frame capture and immediately captures the current terminal. This reveals the prepared state without showing the hidden commands that produced it.
Caption <text>
Section titled “Caption <text>”Sets a caption rendered onto later visual media frames. The text is one token, so quote captions
that contain spaces. Use Caption "" to clear the active caption.
Captions are presentation metadata only. They do not write to the PTY, alter terminal state, affect
wait matching, or change final output dimensions. Active captions appear on GIF, PNG, MP4, WebM,
frame directory, and Screenshot outputs. State JSON does not include captions. When a tape
contains a caption, Betamax reserves a bottom presentation row before deriving the terminal grid so
captions do not cover terminal content.
Caption does not capture a frame or add time to animated output by itself. The new caption appears
on the next visual frame Betamax renders, such as a frame captured during a later Sleep, Wait,
typing, key press, Show, final-frame output, or Screenshot. Add Sleep after a caption-only
change when an animation should dwell on the new caption without changing terminal content.
Betamax renders captions left-aligned below the terminal canvas. Their horizontal inset depends on
whether the tape sets Margin:
- Without a margin, the presentation row spans the full image width. Caption text starts
Paddingpixels from the left edge, in line with the first terminal column, and caption-row keyboard chips keep the same inset from the right edge. WithPadding 0, the inset is half the caption font size so text never touches the image edge. - With a margin, the presentation row sits below the framed terminal. Captions and chips align with
the terminal frame edge and ignore
Padding. Both are inset by half ofBorderRadius, so their edges feel aligned with rounded corners.
To place captions closer to the image edge than the terminal text, add a margin and reduce
Padding by the same amount. For example, replacing Set Padding 20 with Set Margin 8 and
Set Padding 12 keeps terminal text and the grid where they were and moves the caption to 8 pixels
from the edge. The margin and presentation row share the MarginFill backdrop, so the
terminal gains a thin frame in that color. Lowering Padding alone also moves the caption, but
changes the terminal grid size.
Captions are single-line presentation text: if a caption does not fit beside right-aligned keyboard
chips, Betamax truncates it with ... instead of wrapping. Caption glyphs are clipped to their
reserved width as a final guard for font fallback and unusually wide characters. If the caption or
keyboard overlay needs more room, increase the tape height or reduce presentation chrome such as
margin and window bar size. Keyboard chips reserve caption space using their visible width.
Screenshot <path>.png
Section titled “Screenshot <path>.png”Writes a static screenshot at that point in the tape, using the same theme and frame decoration as
primary visual outputs. Accepts .png and .webp, in any case. WebP screenshots are lossless and
preserve transparency. To write a static final WebP, put Screenshot final.webp at the end of the
tape; Output final.webp always writes an animation.
Checkpoint timing
Section titled “Checkpoint timing”Screenshot and State hold for one frame at the Framerate (20 ms at the default 50), then
capture the terminal as it is. Output that arrives within that frame is included; output still
arriving is not waited for, so a program that animates continuously does not block the tape. The
end of the tape works the same way: the final frame and Output state are captured one frame after
the last command.
A frame caught mid-update is captured as it is: a partial redraw, or a half-sent image from a
program that does not use synchronized output. A terminal shows those frames too, and a checkpoint
that hid them would hide tearing bugs. To capture a particular screen, put Wait+Screen before the
checkpoint.
Programs that use synchronized output (DEC private mode 2026) mark where each update starts and ends. A terminal keeps showing the previous frame until the update ends, and recordings do the same. A checkpoint that lands inside an update keeps reading until the update ends, because the previous screen is no longer available to capture. As in Ghostty, an update that never ends stops being held after one second.
MouseMove, MouseDown, MouseUp, MouseScroll, Resize, ScrollUp, and ScrollDown apply
output that has already arrived (for example, a program that just enabled mouse reporting), then
act without waiting. WindowTitle does the same, and so does Show when the window title follows
the terminal, so a title change the program already sent is in place first.
State <path>.json
Section titled “State <path>.json”Writes a terminal-state snapshot, with the same timing as Screenshot. The
state includes viewport text, scrollback text, cursor metadata, a default style, compact style
deltas, and styled text spans. See
State JSON for the schema and snapshot-testing tradeoffs.
Copy <text> and Paste
Section titled “Copy <text> and Paste”Copy stores text in Betamax’s tape-local clipboard. Paste writes that clipboard into the PTY;
before any Copy, the clipboard is empty and Paste writes nothing. Neither touches the host
operating system clipboard, which keeps tapes deterministic and safe for CI.
Source <path>.tape
Section titled “Source <path>.tape”Source other.tape inlines the commands of another tape at that point, as VHS does. Use it to share
setup across tapes:
Output demo.gifSource shared/setup.tapeType "make demo"- A relative path resolves against the directory of the tape that contains the
Sourceline, then against the current directory. VHS resolves only against the current directory. Outputcommands in the sourced tape are dropped with a warning, so they cannot replace the parent’s outputs. Every other command is kept.- Sourced
Set,Env, andRequirecommands follow the usual ordering rule. PutSourcebefore the first runtime command if the sourced tape configures anything. - Sourced tapes can use
Sourcethemselves; a cycle is an error. VHS rejects nestedSource. - Errors inside a sourced tape name that tape and line, then the line that sourced it.
- The path must end in
.tape, and the file must exist and not be empty. Tape::parserejectsSourcebecause it has no file to resolve paths against. Rust callers useTape::parse_fileorTape::parse_with_sources.
Failure bundles
Section titled “Failure bundles”When a tape command fails, Betamax saves what you need to debug it without a rerun: a screenshot and state JSON of the terminal at that moment, plus a report of which command failed, at which tape line, after how long, and what it was waiting for. The error names the line and the bundle:
line 6: `Wait+Screen@1s "Ready"` failed after 1.04s (tape command 5 of 5): timed out waitingfor screen to match text `Ready`failure bundle: target/betamax-failures/24623-1792732654799368000-command-5Bundles go under betamax run --failure-dir <dir>, else BETAMAX_FAILURE_DIR, else
target/betamax-failures in the working directory. Each failure gets a new
<pid>-<timestamp>-command-<n> directory, so reruns and parallel tapes never overwrite each other.
Nothing is written when a tape passes. Library callers choose the root with
Runner::with_failure_dir.
| File | Contents |
|---|---|
report.txt |
Failed command and line, timing, wait pattern, shell status |
report.json |
The same report for scripts |
actual.png |
Screenshot at the failure, decorated like the tape’s images |
actual.json |
State JSON at the failure |
cells.json |
Visible cells with styles, when the backend provides them |
diff.txt |
The error message |
report.txt also lists the recent commands, the visible screen, and the last 20 scrollback lines;
actual.json holds all of the scrollback. A missing Require program fails before the shell
starts, so its bundle has only the reports and diff.txt.
report.json has these fields:
schema:1. It increases when a field changes meaning or is removed.command: the failed command’snumber(counted afterSourceexpansion),location(line 6, orsetup.tape line 2, sourced at demo.tape line 1), andtext.error: the error message.elapsed_ms: how long the failed command ran, ornullif the shell had not started.run_elapsed_ms: time since the shell started, ornull.wait: for a failed wait, itstarget(screenorcurrent line),pattern({"text": …}or{"regex": …}), andtimeout_ms, withWaitPatternandWaitTimeoutdefaults resolved. Otherwisenull.shell:{"status": "running"},{"status": "exited", "code": 3},{"status": "signaled", "signal": …}, or{"status": "not_started"}.recent_commands: up to six commands ending with the failed one, shaped likecommand.files: the files written, keyed byscreenshot,state,cells,error,report_text, andreport_json.artifact_errors: evidence that could not be written. The rest of the bundle is still saved.
CLI commands
Section titled “CLI commands”Betamax currently exposes these CLI commands:
| Command | Behavior |
|---|---|
betamax new <path> |
Write a starter tape with an inline command summary |
betamax run <path> |
Parse, execute, render, and write requested outputs |
betamax run - |
Read a tape from standard input |
betamax run -o out.gif demo.tape |
Append one CLI-provided output to the tape |
betamax run --failure-dir <dir> |
Put failure bundles in <dir> |
betamax validate <path-or-glob> |
Parse tapes without starting shells or writing outputs |
betamax validate --strict <path> |
Also fail when a tape has warnings |
betamax themes |
List theme names available to Set Theme |
betamax themes --json |
Print theme names as JSON |
betamax themes --markdown |
Print theme names as Markdown bullets |
betamax run --publish is accepted so existing VHS scripts get a clear not-implemented error. See
Differences from VHS for VHS commands Betamax omits. Theme
lookup, aliases, and name matching are described in
Themes and layout.