Your first video

Four stages, four artifacts. Once you know which stage owns what, you know which command to re-run — and it is almost never the one that touches your app.

The pipeline#

spec.yaml
   │  playhead capture      ← the ONLY stage that touches your app
   ▼
out/capture/                  frames on a timestamp index + events.json
   │  playhead compose      ← pure function of (bundle, spec, theme)
   ▼
out/out.mp4 + compose-manifest.json
   │  playhead verify       ← reads the delivered pixels
   ▼
out/verify/verdict.json

playhead render runs all three in order. The others exist so you can re-enter the pipeline at the stage you actually changed.

1 · Capture#

Playwright drives your app behind an in-house driver interface. Playhead records 2× resolution frames on a timestamp index — not a fixed-rate video — plus an event log holding the action kind, the accessible label, the measured bounding box, timings and the cursor path. Masking is applied in the DOM before any pixel is painted.

Frames are sparse by design. The capture source emits a frame when the page paints, so an idle screen produces nothing and the bundle treats that as a hold. A 47-second capture might be 192 frames. That is normal, and compose reconstructs a smooth 30fps output from it.

2 · Compose#

Everything you would call “the edit” happens here, from the bundle alone: pacing (holds, trimmed idle, a minimum step duration), the camera path from measured geometry, the re-synthesized cursor, captions, the title and end cards, and the narration track. No network, no browser — which is exactly why it is deterministic.

3 · Verify#

Ten deterministic checks run against the pixels that were actually delivered, then a verdict and a contact sheet are written. Verification has its own page →

The iteration loop#

This is the part worth internalising. Text, voice, theme, pacing and aspect ratio are compose inputs, so changing them never goes near your app:

playhead capture specs/tour.yaml -o out          # once

playhead compose out/capture --spec tour.yaml --watch
  # edit captions and narration; each save re-composes in seconds
You changed…Re-runTouches the app?
A caption, a narration line, the theme, the voicecompose --specno
Aspect ratio, GIF exportcompose --aspects / --gifno
Pacing or camera appetitecompose --specno
A step, a locator, the flowrenderyes
Nothing — you want to re-check a videoverifyno

While iterating on one scene, capture --from-scene <id> fast-forwards the earlier steps for state only, unfilmed, so you are not waiting on the whole flow.

A real render, annotated#

This is the actual console output of the render that produced the video on the examples page, filmed on saucedemo.com:

$ playhead render saucedemo-checkout.yaml -o out

capturing "saucedemo.com"
  ▸ 23 steps on https://www.saucedemo.com/
  ✓ bundle: 169 frames, 65.0s

synthesizing narration: 23 lines (kokoro, voice heart)
composing 84.9s (2546 frames @ 30fps, 1920x1080)
  ✓ rendered → out/out.mp4

verifying output
    pass  encode: h264 1920x1080 @ 30fps, 84.9s
    pass  target-in-frame: 17/17 targets fully framed, min rendered height 21px
    pass  cursor-on-target: 8/8 clicks land on target; sprite verified on film at 2/2 sampled beat(s)
    pass  action-effect: 13/13 actions visibly effective
    pass  output-pixels: 3 sampled output regions match their planned source
    warn  frozen-blank: frames live, but 17 sampled frame(s) have a blank content region (mid-hydration footage?)
    pass  captions: 23 captions fit at full size
    pass  pacing: 23 steps paced ≥1.2s, total 84.9s
    skip  masks: no masking rules
    pass  audio: 23 lines audible in their windows, aac, -16.1 LUFS
  ✓ verdict: publishable

1:25 of narrated output from 169 captured frames and a 23-step spec, with every check green.

When capture fails#

If your app breaks the flow — a locator stops resolving, an expectation never comes true —render exits 1 and still writes something watchable: a partial bundle and a failure clip that ends on the step that broke, with the reason on screen. That is the artifact to attach to the bug report.

Validate before you render. playhead validate walks the whole spec against the live app in one browser pass and reports every unresolvable locator with suggestions — in seconds, before a single frame is captured. Run it after every spec edit.