Record demo videos
Write an ad-hoc Playwright storyboard, then run the self-contained engine from any cwd.
Do not add the storyboard to an application repo unless the user asks for that.
Keep the storyboard self-contained; the runner stages that file inside the engine before
Playwright loads it, so do not use storyboard-relative helper imports.
<skill-dir> means the directory where this repository is cloned, for example
~/.claude/skills/record-demo.
Write the storyboard
Import the DSL with this portable import:
import { demo } from "record-demo/storyboard";
The runner rewrites this specifier to the engine's real path when staging, so the
storyboard file can live anywhere. Editor type resolution for
record-demo/storyboard comes from engine/tsconfig.json paths - outside the
repo, map record-demo/storyboard to the absolute
<skill-dir>/engine/lib/storyboard.ts path in your tsconfig paths, or keep the
file in engine/scripts/.
Use engine/scripts/example-site.demo.ts as the reference. Prefer getByRole selectors,
use d.click(locator) instead of locator.click(), pace actions with d.hold(...), and
assert or wait for the final state.
demo("storyboard-title", async (d) => {
await d.seed(async (page) => {
await page.getByRole("heading", { name: "Example Domain" }).waitFor();
});
await d.step("Read the page", async (page) => {
const heading = page.getByRole("heading", { name: "Example Domain" });
await d.zoomTo(heading, { padding: 48 });
await d.hold(2_000);
d.zoomOut();
await heading.waitFor({ state: "visible" });
});
});
The engine overrides the timeline name with --name or the script basename, so the
string passed to demo(...) does not need to match the output slug.
Preflight (before any capture)
When recording against a locally served checkout: git fetch origin in the serving
checkout and assert HEAD == origin/main (or the user-named branch) — a stale checkout
records a stale design. If stale, rebuild/restart the
server first. Print the serving SHA into the take notes so every recording is attributable.
Record
Require Node >= 22.18 (nvm or system), ffmpeg, Xvfb, and xdpyinfo. Install
dependencies once inside engine/ with pnpm install.
node <skill-dir>/engine/record.mjs \
--script /absolute/or/cwd-relative/demo.demo.ts \
--url https://example.org \
[--name output-slug] [--formats 16x9,1x1,9x16] \
[--chrome none|macos|browser] [--storage-state <path>] [--raw]
Use --formats to select one or more composition presets (default 16x9) and
--chrome to choose the rendered window frame (default none). Formats render in
the requested order, and the first is the primary used to encode the GIF.
The 9x16 format is an aggressive center crop (about 44% of the page width is visible in the wide shot), so keep the action centered when authoring for portrait.
Use --raw to stop after the clean capture. Without it, expect these deliverables:
engine/out/<name>-raw.mp4engine/out/<name>-<format>.mp4for every requested formatengine/out/<name>.gifencoded from the primary format
The runner prints absolute deliverable paths when it finishes. Treat live-site reproducibility as the target site's responsibility and never embed real credentials in a storyboard.
Authenticated sites
Record authenticated flows only with staging or test accounts; never use production
credentials. The agent should first log in once with its own tooling, using a headless
Playwright script. If sign-in requires an emailed OTP or magic link, the preferred
route is the agent's own mail access - a connected Gmail connector/MCP on Claude Code,
or a mail CLI on other runtimes - so it reads the code itself; otherwise ask the user
to paste it. After login, save the session with
await context.storageState({ path: storageStatePath }), where storageStatePath
is a one-shot temporary path created as
storageStatePath="$(mktemp -d)/<account>.storage-state.json". Then run the
recording command with --storage-state "$storageStatePath".
Playwright storageState restores cookies and localStorage only. Apps that keep auth
in IndexedDB or sessionStorage will silently record a logged-out screen, so verify the
authenticated page before capturing. The storage-state file is a secret: never print
or commit it. The recorder deletes it immediately after loading it; it is a one-shot
handoff, so re-recording requires a fresh login and nothing auth-related is left on
disk. The one-off login helper script also holds credentials: keep it temporary and
never commit it.
Edit a recorded demo in Remotion Studio
Export a self-contained project after recording, then install and open Studio:
node <skill-dir>/engine/scripts/export-project.mjs <name>
cd <skill-dir>/engine/out/<name>-project
pnpm install
pnpm studio
Studio's timeline panel shows editor-style tracks (Screen filmstrip, one named clip per
caption, spotlight, zoom move, and outro) - drag the playhead to scrub, click a clip's
row to find its event. Use the props panel or fixtures/timeline.json to edit captions,
zoom timing (keep zoom moves at or above the 1650ms floor), spotlight timing, and the
outro title. Edit
remotion/theme.ts for colors, typography, spacing, and motion. Render the finished
16:9 video with pnpm render.
Motion and storyboard rules (what makes a clip postable)
- One camera move per scene, not per field. Zoom once to the section, act inside it. Per-input zooms read as edgy cuts.
- Land wide through page transitions, stay wide on sparse pages.
d.zoomOut()BEFORE a click that navigates; never zoom to a status pill on a near-empty page. - Sections near the page bottom:
d.zoomTo([...locators], { scroll: "center", padding: 48 })- the camera clamp cannot center a page-edge rect; scroll-centering fixes it at the
root.
Locator[]frames a whole section via union bbox.
- the camera clamp cannot center a page-edge rect; scroll-centering fixes it at the
root.
d.spotlight([...locators])= Apple-style focus dim (cutout + ring). GenerousholdMs, end explicitly withd.spotlightOff()+hold(600)before the step ends. Spotlight rects must fit the viewport (enforced); zoom rects may overflow by design.- Seed waits for real data, not just the trigger element - otherwise the video opens on a skeleton. Wait for a known content node + ~600ms.
d.settle(locator)before outcome captions. Views that re-fetch after a submit blank briefly; a plainwaitForresolves on the first paint and the outcome caption fires over a white page.- Captions are imperatives tied to the cursor action; put an outcome caption
(
d.caption(...)late in the step) on closing scenes; trim step captions so imperatives never hang over completed actions. d.outro(title)closes postable clips with a calm end slate after the final scene.- Always assert the end state - without it the pipeline renders a polished video of a broken flow.
- Never ship a clip with a localhost URL, internal hostname, provider name, or real credentials on screen. Mask presentation-only strings in the seed (window.open patch and a MutationObserver input rewrite).
Verify before reporting done
pnpm typecheck && pnpm testinengine/.node scripts/score-motion.mjs out/timeline.json- FAIL blocks; WARN zoom-rate ~0.78/s is the accepted steady state; any discontinuity verdict is a camera bug; dead-air WARNs mean a static stretch >= 3.5s with under 40px of cursor travel - re-pace or caption it.node scripts/prove-composition.mjsafter engine changes.- Watch the actual render:
ffmpeg -i out/<name>-16x9.mp4 -vf fps=1 out/frames/%03d.pngand read the frames. Zoom clipping, caption lag, cutout desync and on-screen dev URLs are invisible in a green test run.
Gotchas
| Symptom | Cause / fix |
|---|---|
Invalid demo viewport ... abort | Geometry drifted - fix launch flags, never relax the assertion. |
| Cursor offset ~174px vertically | Chrome ran tabbed, not --app. |
| Could not find clapperboard frames | Detector tolerance is 64 (external captures average impure); if it still misses, check the capture isn't black (Chrome on wrong DISPLAY). |
| Captions/cursor drift over the clip | Clapperboard sync - don't wall-clock subtract. |
| Spotlight cutout off its target | Rect raced a smooth scroll - measureTarget's stability loop should hold; if it recurs, raise its timeout. |
| Words cut mid-letter at crop edge | zoomTo(locator, { padding: 120 }) for modals. |
| Zoom clips content off-frame | maximumScale taste issue (1.45 works), not a clamp bug. |
Verify engine changes
Run from engine/:
pnpm typecheck && pnpm test
node scripts/prove-composition.mjs