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.jsonplayhead 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.
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-run | Touches the app? |
|---|---|---|
| A caption, a narration line, the theme, the voice | compose --spec | no |
| Aspect ratio, GIF export | compose --aspects / --gif | no |
| Pacing or camera appetite | compose --spec | no |
| A step, a locator, the flow | render | yes |
| Nothing — you want to re-check a video | verify | no |
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: publishable1: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.
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.