Spec reference
One YAML file describes the whole video. It is validated by a Zod schema in strict mode, so an unknown field is an error rather than a silently ignored knob — a misspelled option should never be indistinguishable from a feature that works badly.
Shape of a spec#
playhead: 1 # required, the format version
title: Onboard a teammate # required, the title card
subtitle: Account live in a minute
app:
url: http://localhost:3000
viewport: 1280x720
output:
kind: walkthrough
aspect: "16:9"
audio: { narration: tts, provider: kokoro, voice: heart }
theme:
accent: "#6d7cff"
captions: bottom
masking:
- target: 'testid=env-banner'
setup: # runs before recording, never filmed
- click: 'role=button[name="Accept cookies"]'
optional: true
scenes:
- id: invite
title: Add a teammate
steps:
- click: 'role=button[name="Add user"]'
caption: Add a user
narration: Adding a teammate takes one form.app — what to film#
| Field | Meaning |
|---|---|
url | Required. Where capture starts. |
viewport | Capture viewport, e.g. 1280x720. Omit it and the viewport is derived from output.aspect, so a portrait video is captured at a portrait viewport and the app reflows natively instead of being cropped. |
storageState | A session file from playhead login, for authed screens. |
assertLoggedIn | A locator that must match after the initial load. Stale auth aborts the capture instead of confidently filming your login page. |
compareUrl | A second app to capture the same flow against — the “before” in a before-after video. |
dialogs | accept (default) or dismiss for window.confirm and alert. Use dismiss to film the cancel branch of a confirm-guarded flow. |
dismiss | Auto-dismiss cookie banners and chat widgets before filming. Default true — set false when the messenger is what you are filming. |
navigation | load (default) or domcontentloaded. Ad-heavy pages may never fire load; the looser setting plus the settle heuristic makes them filmable instead of timing out. |
settle | { idleMs, capMs } — settle tuning for slow backends and chatty apps. |
app.environment — determinism#
Same spec, same pixels, regardless of where or when it runs. This is what makes --expect-sha a meaningful assertion.
app:
environment:
timezone: UTC
locale: en-US
colorScheme: dark
fixedTime: "2026-01-01T12:00:00Z" # "3 minutes ago" stops drifting
reducedMotion: trueapp.network — request interception#
app:
network:
block: ["*.googletagmanager.com", "*/analytics/*"]
stub:
- url: "*/api/usage"
status: 200
contentType: application/json
body: '{"seats": 42}'
har: fixtures/session.harBlocking third-party noise is usually the single biggest improvement to a flaky capture.
output — how it is presented#
| Field | Values | Notes |
|---|---|---|
kind | walkthrough · demo · bug-repro · before-after · release · social | Sets the whole presentation posture. Explained here → |
aspect | 16:9 · 9:16 · 1:1 | Default 16:9 → 1920×1080. Portrait is 1080×1920, square 1080×1080. |
resolution | 1920x1080 | Optional override; derived from the aspect otherwise. |
fps | 24–60 | Default 30. |
pacing | relaxed · normal · brisk | How long steps hold and how hard idle time is trimmed. |
dwellScale | 0.5–4 | Multiplier on per-step dwell. Raise it for teaching walkthroughs where each screen should linger. |
camera | wide · normal · tight | Framing appetite. wide keeps the whole screen in view and waives the close-up legibility floor. |
endCard | { title, subtitle } | Compose defaults to title + host when omitted, so a video never ends on a freeze-frame. |
audio | { narration, provider, … } | Narration & voices → |
theme#
accent is a #rrggbb hex colour and captions is bottom (default), top or off. There is deliberately no name field: exactly one theme exists today, and a knob that is accepted but ignored is a lie.
masking — keeping secrets off film#
masking:
- target: 'testid=env-banner'
style: solid # or blurRules apply for the whole capture. Each overlay box is parented into the target’s own subtree, which is what lets it cover a field inside a <dialog> that paints in the browser top layer above any document-level overlay. For a single typed value, use mask: true on the step instead — the text is redacted to bullets before it ever enters the event log, and the real value exists only in your spec file.
setup — unfilmed groundwork#
Same step grammar as scenes, but it runs before recording starts and never appears on film: dismiss a consent banner, close a first-run tour, prime app state. Combine with optional: true for anything that might not be there.
scenes and steps#
A scene has a lowercase kebab-case id, an optional title that becomes an on-screen chapter, and at least one step. Ids must be unique — they form the stepRef (scene/index) used in verdicts, the step map and --from-scene.
The thirteen verbs#
| Verb | Form | Notes |
|---|---|---|
goto | goto: /settings | Navigate mid-flow. |
click | click: 'role=button[name="Save"]' | The workhorse. |
dblclick | dblclick: 'text="Rename"' | Double click. |
hover | hover: 'text="Total revenue"' | Tooltips, previews, hover menus. |
type | type: { target, text, mask?, clear? } | clear: true selects all and overwrites — without it, editing a pre-filled field produces “Janenew value”. |
press | press: Enter | Any key or chord, e.g. Control+K. |
rightclick | rightclick: 'testid=row-3' | Context menus. |
upload | upload: { target, file } | Sets the file input directly; no native dialog is involved or filmable. |
drag | drag: { target, to } | Kanban cards, list reorder, sliders. |
select | select: { target, value } | Native select elements. |
scroll | scroll: { by: { y: 400 } } | Or scroll: { target } to bring an element into view. |
expect | expect: { target, visible|text|count|value|url|disabled|checked } | An assertion the verifier will prove. Needs a target, a url, or both. |
wait | wait: { for: <locator>, state: visible|hidden } | The structured form. wait: 2000 is a blind sleep and is discouraged; exactly one of the two is allowed. |
Extras available on every step#
| Key | What it does |
|---|---|
caption | The on-screen card. Numbered, laid out and overflow-checked at compose time. |
narration | The spoken line, free of the caption card’s size limit. Falls back to the caption, then to an auto line. |
focus | target, wide, or a locator naming where the payoff appears — the act-then-react shot. |
shot | cut forces this step into its own shot; continue merges it into the previous one. Camera cuts stay decoupled from scene structure. |
timeout | Per-step budget in ms for finding the target or meeting the expectation. |
optional | A failing step is skipped with a warning instead of killing the capture — for cookie banners and A/B'd tooltips. |
mask | On a type step: redact the typed value. |
Reuse: vars and extends#
extends: ./base.yaml # merged underneath this file, max 4 deep
vars:
tenant: acme
token: ${env.DEMO_TOKEN}
region: ${env.REGION:-eu-west-1}
app:
url: https://{{tenant}}.example.com{{name}} is interpolated into every string in the spec, and an unknown variable is an error rather than an empty string. Values may read the environment as ${env.NAME} with an optional :-default. Base scenes and masking rules are appended first, so scene ids must stay unique across both files.
txet:.