Demo video (deterministic frames → Pillow compositor → ffmpeg)
A lightweight, reproducible way to make a crisp 60fps ScreenStudio-style demo of a
web app. Two stages, all code, only Python + Pillow + ffmpeg:
capture_template.py → frames/f*.jpg + meta.json (Playwright: 1 step per frame + camera/mouse timeline)
│
compositor.py → demo.mp4 (multiprocess Pillow render — bg + rounded window +
shadow, vector cursor, click pulses, shortcut
keycaps, whole-window zoom/pan — streamed straight
into ffmpeg; 60fps H.264, bt709)
The compositor renders with a worker pool and pipes frames directly into one ffmpeg
process (no intermediate PNGs, no second pass). Default output is the classic panel
size; HD=1 python compositor.py … re-renders the SAME capture at retina resolution
(uses all pixels of a DSF=2 capture) when a crisper deliverable is worth ~3x the
composite time.
Step 0: ask the user for the inputs — don't go discover them
The capture needs facts only the user reliably knows. Hunting for them (grepping configs
for dev-server URLs, guessing test credentials, probing routes) burns time and tokens and
often lands on the wrong instance. Before writing any code, ask for whatever is missing
from this list, in ONE batch:
- Target URL — the running app / dev server to record (e.g.
http://myapp.localhost:8000),
and the specific page or route the demo starts on.
- Login — credentials for a throwaway/test account, or an existing authenticated
session/storage state, or "no login needed".
- What to show — the feature/flow and the beats that matter (what must the viewer see?).
- Fixture policy — is it OK to create and delete throwaway records via the app's
API/UI on this instance? (Never demo against data the user cares about.)
- Output — where to put the mp4, and whether they want the retina
HD=1 render.
Skip questions already answered by the request, project memory, or an obvious fixture
(e.g. a self-contained HTML page needs neither URL nor login). If the user gives a
production URL, confirm before creating any data on it.
The core idea: deterministic frames = real 60fps
Do not rely on real-time screen recording or Playwright's built-in video (framerate
is machine-dependent and usually <60fps). Instead the capture script drives the UI one
small step per frame and screenshots each frame. Capture can take a minute of
wall-clock; playback is still a perfect 60fps because the frames are assembled at a fixed
rate. Motion smoothness is a function of step size, not capture speed.
Two kinds of pause, and picking the right one is both a speed and a correctness call:
still(n) takes ONE screenshot that stands for n frames (repeat in meta.json) — use
it whenever the page is static and only the virtual camera is settling or lingering; it
cuts capture time dramatically since settles/lingers dominate a storyline. hold(n)
captures n live frames — required whenever the APP itself is animating (post-click
transitions, drop animations, spinners), or the animation would freeze in the video.
The camera lives in the compositor, not the app
The capture records a focus point + zoom per frame into meta.json; the compositor
scales the recorded pixels to that virtual camera and eases toward it (EMA). So
re-framing / re-pacing / re-zooming is a compositor re-run, not a re-shoot — you almost
never need to re-capture just to change the camera. Never zoom the app's own canvas
(Ctrl+=/wheel); it's fragile and forces re-captures.
Zooming scales the whole window — frame, rounded corners and drop shadow included —
against a fixed background, the way a real camera push-in looks. The window is not a
fixed cut-out that content zooms inside of: past ~1.1x it grows beyond the frame and
bleeds off the edges, so the gradient background is visible on the wide beats and the UI
fills the frame on the close ones. The pan is clamped so the window always covers the
content area, which means a settled cam(1.0, …) lands back in exactly the classic
framing — that's why openings and endings sit at z=1.
Cinematography principles (the parts that took iteration to get right)
- Decouple the camera from the cursor. Panning to follow the pointer looks erratic.
Set
cam(z, focus) to a stable region and leave it; the action moves, not the camera.
- Zoom in ONCE per shot, then hold steady through the action. Use
fit_zoom([...]) to
frame the working area, zoom to it, and don't touch the camera during the drags/clicks.
- Don't over-zoom. Tight zoom loses context, feels claustrophobic, and blurs (see
headroom below). Keep surrounding UI visible: a hold-zoom around ~1.5–1.9× on a
working region usually reads best.
fit_zoom(..., hi=1.75) caps it. When in doubt, zoom less.
- Move the camera between shots, but let it SETTLE. After
cam(z, focus) add a short
hold(~20) so the EMA reaches the new framing before the action starts. With slow easing
and many shots the camera lags forever — ZOOM_EMA≈0.11 / PAN_EMA≈0.13 reach targets
within a ~20-frame hold while staying smooth.
- Close with a zoom-out + linger, never a hard cut. End with
cam(1.0, FOCUS) then a
hold; the compositor's END_EXTRA renders extra tail frames on the final still so the
closing zoom fully settles and holds a beat.
- Slow enough to read. Drags ~1.2–1.5s (
move_to(..., ~72)), a beat before/after each
action, longer holds on the moment that matters (e.g. an indicator you want seen).
- Capture the COMPLETE interface (full viewport) so it reads as "the real app"; the
camera zooms into the action while keeping editor chrome visible for context.
Determinism: seed and clean up your own fixture
A run that mutates shared state (autosaved drafts, existing docs) drifts between takes. The
reliable pattern for app demos: create a throwaway fixture via the app's API in a try,
point START_URL at it, and delete it in finally. Same input → identical frames → the
compositor is a pure function of the capture.
Long waits: capture dense, compress in post
When the app makes you wait (an AI build, a long job, a spinner), do NOT bake a
timelapse into the capture by screenshotting at a slow interval — the discarded
frames are unrecoverable and the pacing can never be slowed later. Instead shoot
as fast as screenshots allow (~40ms between shots) and append a speed region to
meta.json over that span: {"from": startEntry, "to": endEntry, "mult": N} with
N sized so the DEFAULT playback matches the pacing you want. compositor.py
resolves speed/trim/speedRamp from the meta natively (shared resolve.py),
and the editor shows the region as an editable clip — the compression becomes a
decision you can revisit, with eased transitions at the boundaries for free.
The multi-case "tile" pattern (showcasing several behaviors)
To demo N related behaviors in one video, build ONE fixture page laid out as a grid of
labeled tiles, each a minimal example of one case, sized so they ALL fit in the
viewport — the compositor can only zoom into pixels that were captured, so off-screen
content can't be revealed. Then one beat per tile: frame_tile(sel) → hold to settle
→ perform the real interaction → hold, then move on. Give each tile a text label so the
case is self-explanatory, and linger on the finale. example-multicase.py is a complete
working instance (a canvas block-reorder demo: 5 layout tiles + a center-drop finale).
Gotchas learned the hard way
- Drag-and-drop: read destination coords LIVE, after pickup. Picking a block up can
reflow siblings, so coords read before
mouse.down() may miss. Read the target's
bounding_box() after the drag starts (always safe).
- Cross the drag threshold explicitly. Most engines need a few px of movement after
mouse.down() before a drag "starts" — nudge (move_to(x+9, y, 6)) before moving to the
target, or the press is treated as a click.
- One element per breakpoint. If the app renders multiple responsive canvases, select
the element for the breakpoint you mean (
[data-block-id="X"][data-breakpoint="desktop"]);
the same block exists once per visible canvas.
- Zoom headroom = capture resolution. To zoom to
z and stay sharp, the source crop
(≈ viewport*dsf / z) must be ≥ the panel width. Get more pixels via a bigger VIEWPORT
or higher DSF (2 = crisp) — not by upscaling in the compositor, and not by over-zooming.
- The cursor is drawn by the compositor, not the page. Capture records
mx,my per
frame; the compositor composites a vector arrow at final resolution, so it stays crisp
at any zoom (a DOM-injected cursor blurs when the camera zooms — don't reintroduce one).
- A keycap hint is a claim, not proof.
press() records the combo you sent; if focus
was in the wrong element the app ignores it and the video shows keycaps with nothing
happening. Check the frames right after the press for the effect you expected.
- still() vs hold() is a correctness call: a
still() during an app animation freezes
it in the video. When unsure whether something is still animating, use hold().
- ffprobe the output — the compositor prints the exact check line; confirm
r_frame_rate=60/1. Colors are tagged bt709/limited-range explicitly so players don't
wash them out. JPEG source (quality≈92) keeps ~1000-frame captures fast; the final
H.264 pass (crf 18) is what matters for quality.
Steps
- Gather inputs (Step 0 above): target URL, login, flow, fixture policy, output prefs —
ask the user in one batch rather than discovering them.
- Copy the scripts next to a scratch workdir:
capture_template.py, compositor.py
(and example-multicase.py if doing a tile demo).
- Edit
capture_template.py CONFIG — BASE, START_URL, optional LOGIN, VIEWPORT
(bigger = more zoom headroom), DSF (2). For app demos, seed a fixture (see above).
- Write the STORYLINE with the harness verbs (below), pointing at a live dev server.
- Capture:
VIDEO_DIR=/abs/workdir python capture_template.py (long captures: run in the
background, poll frames/ count).
- Composite + encode:
python compositor.py /abs/workdir → demo.mp4 directly
(multiprocess render streamed into ffmpeg; HD=1 for retina output on demand). To
re-frame/re-zoom without re-capturing, edit the z values in meta.json (or the
compositor knobs) and re-run this step only.
- Verify: run the ffprobe line the compositor prints (
r_frame_rate=60/1) and
spot-check a zoomed frame + the last frame (extract with ffmpeg -ss … -frames:v 1).
Harness verbs (in capture_template.py)
cap() — screenshot the current frame + log camera/mouse state.
hold(n) — n LIVE frames (app animating). still(n) — n frames from ONE screenshot
(page static: camera settles, lingers, endings) — much faster to capture.
cam(z, (fx,fy)) — set the camera target (zoom + focus in page px); compositor eases to it.
move_to(x, y, n) — ease the mouse to (x,y) over n captured frames (the main "action" verb).
jump(x,y) — teleport without capturing.
click(x, y) — click + record a black pulse ring at that point.
press("Meta+Enter") — press a shortcut and show it as keycaps at the bottom of the
frame (⌘ + Enter) for ~1s. Modifier names are mapped to glyphs; pass a second arg
to override the label. Use it for anything a viewer can't otherwise see (shortcuts,
paste, undo) — plain typing needs no hint.
type_text(s, per_char=3) — type one character every few frames, so it reads as live typing.
center(sel) / box(sel) — element center / bounding box (Playwright selector).
fit_zoom([box,...], margin_px, lo, hi) → (zoom, focus) framing those elements (cap hi
to avoid over-zoom).
- Real actions use raw Playwright between frames:
pg.mouse.down()/up()/click(),
pg.keyboard.press(...), pg.locator(...).
Tuning knobs (top of compositor.py)
HD=1 env (or PANEL_SCALE=2) — retina output: 2x panel from the DSF=2 capture pixels.
Default (scale 1) is the classic size and composites ~3x faster; same capture serves both.
PANEL_BASE_W, MARGIN, RAD — panel size, background border, corner radius (the
latter two auto-scale with PANEL_SCALE).
GRAD — background gradient stops (soft indigo→violet default; swap for brand/dark).
ZOOM_EMA / PAN_EMA — camera easing (0.11 / 0.13: reaches per-shot framing within a short
hold; lower = gentler but laggier).
END_EXTRA — tail frames so the closing zoom-out settles + lingers.
KEY_H / KEY_INSET / KEY_N — keycap size, gap from the bottom edge, how long a
shortcut hint stays up. KEY_FONTS — font candidates (needs ⌘/⇧/⌥ glyphs; macOS SF NS
first, DejaVu Sans as the Linux fallback).
CRF / PRESET — libx264 quality/speed (18 / fast). WORKERS — render pool size
(default auto-sizes to physical RAM ÷ per-worker footprint; low-RAM machines also
get trimmed x264 lookahead/refs at the same CRF).
CURSOR_CSS_H — drawn cursor size in page px (26 matches the old baked cursor).
Manual recording (browser extension)
For takes a human should drive by hand, extension/ is an unpacked Chrome
extension that records the current tab while an injected logger stamps mouse
moves, clicks and shortcut presses; a floating REC pill (its own tiny window,
never captured) shows elapsed time and stops the take. Two cursor modes:
the default "drawn" captures via CDP screencast (chrome.debugger) whose
pixels carry NO cursor — timestamped JPEGs stream to the server during
recording, and meta gets mx/my so the compositor draws its editable vector
cursor (cursor-hide works); "as recorded" captures a tabCapture webm whose
pixels have the OS cursor baked in (no overlay drawn — it would double).
ingest.py converts either into a normal capture bundle: frames on the 60fps
grid, still runs collapsed into repeat entries (identical frames hardlink
to one file; screencast idle gaps make stills nearly free), and click/key
frames forced onto their own entry so pulses/keycaps land exactly. The camera
gets auto-framing from the click log per the cinematography rules above: one
steady shot per click cluster (push in a beat before the first click, hold
through the action, release after; short wide gaps carry straight to the next
shot), wide elsewhere — then direct the rest in the editor as normal camera
blocks. Everything downstream is unchanged.
Setup and flow: see extension/README.md. Run the server with --port (e.g.
8787) so the extension's stored server URL survives restarts; the workdir may
start empty. The rail polls /api/segments every 3s, so an imported take
appears without a reload. python3 ingest.py <dir> re-converts a segment dir
holding rec.webm + recording.json by hand if needed. Real-time capture can
drop frames on a slow machine — scripted Playwright capture stays the tool for
perfectly repeatable takes.
Editor (interactive fine-tuning)
python3 editor/server.py --detach [--port N] <workdir> starts a local web editor (stdlib
http.server on a free port unless --port pins one), prints the URL + pid, and returns; the server runs in its
own session with its output in <workdir>/editor.log, so it outlives the agent shell
(a plain foreground or run_in_background launch gets reaped by the harness's task
manager). Stop it later with kill <pid>. It serves the Vue app from editor/ui/dist;
the dist is not checked in, so after a fresh clone run cd editor/ui && yarn && yarn build
once (the server errors with that hint if the dist is missing). To hack on the UI: cd editor/ui && yarn dev
with EDITOR_API=http://127.0.0.1:<server port> (vite proxies /api), then yarn build
to refresh the dist the server ships. The chrome is Vue 3 + frappe-ui, monochrome and
light by default with a top-bar light/dark toggle (persisted in localStorage); it styles
everything with the frappe-ui semantic tokens (surface/ink/outline), and the canvas
engines resolve their chrome colors from those CSS vars at draw time so both themes
work. The preview renderer and virtualized timeline stay canvas engines in
editor/ui/src/editor/engine.js; only the preview's video content (gradient, panel,
cursor) is hardcoded, since it must match the rendered mp4.
<workdir> is a segment dir (frames/ + meta.json) or a dir of segment dirs.
Screen-Studio-style UI: segment rail (thumbnail + name + duration, drag-to-reorder =
concat order; double-click the name or right-click → Rename to give a segment a display
label, stored as label in the edited meta and shown in the rail, top bar, and render
output rows — the directory name stays the id in every API; right-click → Delete removes
the segment dir from disk after a confirmation), live preview canvas that
replicates the compositor exactly (gradient,
rounded window + shadow, EMA camera, cursor, pulses, keycaps), and one integrated
timeline: a transport cluster (play/pause + current/total time) and Camera / Holds /
Events track names in a left gutter, then a single ruler + playhead shared by all
tracks. The ruler ticks in playback seconds (so ticks compress through sped-up
sections), click/drag on it scrubs, and all durations shown anywhere are seconds; the
canonical length everywhere is the body without END_EXTRA, whose hold tail draws as a
hatched extension past the end marker. Tracks: camera blocks as chips with the zoom
label (click to select, then click the preview to set focus / scroll to zoom, drag chip
edges to move transitions), stretchable hold pills (drag the right edge to retime),
non-destructive speed regions as hatched overlays whose edges feather over the ramp
width (drag across live frames, stored as speed:[{from,to,mult}]), draggable click
dots / keycap chips, caption regions on the Events lane (stored as
captions:[{from,to,text}], rendered as a rounded ink bar with wrapped text at the
bottom of the frame that fades in/out, at the top via each caption's right-click
Position submenu, or anywhere: with the caption selected, click/drag the preview to
place the bar freely (stored as frame-fraction x/y on the region; Bottom/Top presets
clear it). A Caption size preset lives in the inspector. Drag the band to move it,
drag its edges to retime, right-click to add/edit/delete, or the + button on the
Events gutter to add one at the playhead), and trim handles. Speed changes ease in and out instead of jumping
at region boundaries: multipliers are smoothed in log space with a raised-cosine kernel
spanning speedRamp seconds (saved in the edited meta, default 0.6, 0 = hard cuts,
editable in Advanced), identically in the server resolver and the JS preview so the
preview's duration always equals the rendered frame count.
Cursor-hidden ranges on the Events lane draw a small slashed-cursor glyph at the range
start (and centered when wide) over a subtle dashed line, in the theme's ink colors.
Right-click anything on the timeline for a context menu with monochrome lucide icons
(zoom stops, split/merge camera blocks via a cut flag on frames, cut a section out
of the video entirely — stored as cuts:[{from,to}], drawn as a hatched span, and
restorable from the same menu — hold presets in seconds, speed up a section, event
add/edit/delete, trim/jump). Every edit goes through
per-segment undo/redo (drags and slider scrubs coalesce into one entry).
Keyboard shortcuts (registered via frappe-ui's useShortcut; ⌘? or the top-bar "?" opens
the built-in KeyboardShortcutsModal cheat sheet): Space play/pause, ←/→ step one frame,
⇧←/⇧→ jump one second, I/O set trim in/out at the playhead, S split the camera block
under the playhead, Delete/Backspace delete the selection (event marker or speed
region; a camera block merges into its neighbor, a still drops its hold), ⌘Z/⇧⌘Z
undo/redo, ⌘S save — all silent while an input or dialog has focus. The inspector is preset-first:
background swatches, corner/shadow/camera-feel/ending-hold/cursor/keycap segments, and
quality/resolution for export; speed is per clip only (Holds-track drag or a camera
block's "Set speed" submenu — no master speed control; a legacy pace in old edited
metas is still honored at resolve time). A selected camera
block gets Wide/Slight/Medium/Close zoom stops, and other selections edit in seconds.
Every raw knob (exact hexes, px values, EMAs, CRF, numeric z/cx/cy, trim entries) lives
in the collapsed Advanced accordion, two-way synced: presets write the raw values,
hand-edited raw values flip the matching preset to a "Custom" chip.
Save writes meta.edited.json + knobs.json into the segment dir; the original
meta.json is never touched. Render runs this skill's compositor.py per segment with
META_FILE (a baked meta.render.json: trim + speed resolved via repeat
counts with the eased multipliers, event indices remapped) and KNOBS_JSON (constant overrides); "Render all + concat" then
ffmpeg-concats (-c copy) in rail order to <workdir>/edited-full.mp4. Without those
env vars the compositor behaves exactly as before. Keep one panel scale across segments
when concatenating.
1---2name: demo-video3description: Produce a polished, ScreenStudio-style 60fps product demo video of a web app with a tiny, dependency-light pipeline — Playwright frame capture + a multiprocess Pillow compositor streaming into ffmpeg (no Node/Remotion). Drives the app one step per frame so playback is true 60fps regardless of capture speed, then renders a gradient background, a rounded window with a soft shadow, a crisp vector cursor, click pulses, keyboard-shortcut keycaps, and a cinematic push-in that scales the whole window and eases out at the end; HD=1 re-renders the same capture at retina resolution. Also ingests hand-driven takes recorded with the bundled Chrome extension (tab capture + input-event log) into the same pipeline and editor. Use whenever asked to create/record/improve a demo video, walkthrough, feature showcase, or screencast of a web UI.4---56# Demo video (deterministic frames → Pillow compositor → ffmpeg)78A lightweight, reproducible way to make a **crisp 60fps ScreenStudio-style** demo of a9web app. Two stages, all code, only Python + Pillow + ffmpeg:1011```12capture_template.py → frames/f*.jpg + meta.json (Playwright: 1 step per frame + camera/mouse timeline)13 │14compositor.py → demo.mp4 (multiprocess Pillow render — bg + rounded window +15 shadow, vector cursor, click pulses, shortcut16 keycaps, whole-window zoom/pan — streamed straight17 into ffmpeg; 60fps H.264, bt709)18```1920The compositor renders with a worker pool and pipes frames directly into one ffmpeg21process (no intermediate PNGs, no second pass). Default output is the classic panel22size; `HD=1 python compositor.py …` re-renders the SAME capture at retina resolution23(uses all pixels of a DSF=2 capture) when a crisper deliverable is worth ~3x the24composite time.2526## Step 0: ask the user for the inputs — don't go discover them2728The capture needs facts only the user reliably knows. Hunting for them (grepping configs29for dev-server URLs, guessing test credentials, probing routes) burns time and tokens and30often lands on the wrong instance. Before writing any code, ask for whatever is missing31from this list, in ONE batch:32331. **Target URL** — the running app / dev server to record (e.g. `http://myapp.localhost:8000`),34 and the specific page or route the demo starts on.352. **Login** — credentials for a throwaway/test account, or an existing authenticated36 session/storage state, or "no login needed".373. **What to show** — the feature/flow and the beats that matter (what must the viewer see?).384. **Fixture policy** — is it OK to create and delete throwaway records via the app's39 API/UI on this instance? (Never demo against data the user cares about.)405. **Output** — where to put the mp4, and whether they want the retina `HD=1` render.4142Skip questions already answered by the request, project memory, or an obvious fixture43(e.g. a self-contained HTML page needs neither URL nor login). If the user gives a44production URL, confirm before creating any data on it.4546## The core idea: deterministic frames = real 60fps4748Do **not** rely on real-time screen recording or Playwright's built-in video (framerate49is machine-dependent and usually <60fps). Instead the capture script drives the UI **one50small step per frame and screenshots each frame**. Capture can take a minute of51wall-clock; playback is still a perfect 60fps because the frames are assembled at a fixed52rate. Motion smoothness is a function of *step size*, not capture speed.5354Two kinds of pause, and picking the right one is both a speed and a correctness call:55**`still(n)`** takes ONE screenshot that stands for n frames (`repeat` in meta.json) — use56it whenever the page is static and only the virtual camera is settling or lingering; it57cuts capture time dramatically since settles/lingers dominate a storyline. **`hold(n)`**58captures n live frames — required whenever the APP itself is animating (post-click59transitions, drop animations, spinners), or the animation would freeze in the video.6061## The camera lives in the compositor, not the app6263The capture records a **focus point + zoom per frame** into `meta.json`; the compositor64scales the recorded pixels to that virtual camera and eases toward it (EMA). So65**re-framing / re-pacing / re-zooming is a compositor re-run, not a re-shoot** — you almost66never need to re-capture just to change the camera. Never zoom the app's own canvas67(`Ctrl+=`/wheel); it's fragile and forces re-captures.6869Zooming scales the **whole window** — frame, rounded corners and drop shadow included —70against a fixed background, the way a real camera push-in looks. The window is not a71fixed cut-out that content zooms inside of: past ~1.1x it grows beyond the frame and72bleeds off the edges, so the gradient background is visible on the wide beats and the UI73fills the frame on the close ones. The pan is clamped so the window always covers the74content area, which means a settled `cam(1.0, …)` lands back in exactly the classic75framing — that's why openings and endings sit at z=1.7677## Cinematography principles (the parts that took iteration to get right)78791. **Decouple the camera from the cursor.** Panning to follow the pointer looks *erratic*.80 Set `cam(z, focus)` to a **stable region** and leave it; the action moves, not the camera.812. **Zoom in ONCE per shot, then hold steady through the action.** Use `fit_zoom([...])` to82 frame the working area, zoom to it, and don't touch the camera during the drags/clicks.833. **Don't over-zoom.** Tight zoom loses context, feels claustrophobic, and blurs (see84 headroom below). Keep surrounding UI visible: a hold-zoom around **~1.5–1.9×** on a85 working region usually reads best. `fit_zoom(..., hi=1.75)` caps it. When in doubt, zoom *less*.864. **Move the camera between shots, but let it SETTLE.** After `cam(z, focus)` add a short87 `hold(~20)` so the EMA reaches the new framing before the action starts. With slow easing88 and many shots the camera lags forever — `ZOOM_EMA≈0.11 / PAN_EMA≈0.13` reach targets89 within a ~20-frame hold while staying smooth.905. **Close with a zoom-out + linger, never a hard cut.** End with `cam(1.0, FOCUS)` then a91 hold; the compositor's `END_EXTRA` renders extra tail frames on the final still so the92 closing zoom fully settles and holds a beat.936. **Slow enough to read.** Drags ~1.2–1.5s (`move_to(..., ~72)`), a beat before/after each94 action, longer holds on the moment that matters (e.g. an indicator you want seen).957. **Capture the COMPLETE interface** (full viewport) so it reads as "the real app"; the96 camera zooms into the action while keeping editor chrome visible for context.9798## Determinism: seed and clean up your own fixture99100A run that mutates shared state (autosaved drafts, existing docs) drifts between takes. The101reliable pattern for app demos: **create a throwaway fixture via the app's API in a `try`,102point `START_URL` at it, and delete it in `finally`.** Same input → identical frames → the103compositor is a pure function of the capture.104105## Long waits: capture dense, compress in post106107When the app makes you wait (an AI build, a long job, a spinner), do NOT bake a108timelapse into the capture by screenshotting at a slow interval — the discarded109frames are unrecoverable and the pacing can never be slowed later. Instead shoot110as fast as screenshots allow (~40ms between shots) and append a `speed` region to111meta.json over that span: `{"from": startEntry, "to": endEntry, "mult": N}` with112N sized so the DEFAULT playback matches the pacing you want. `compositor.py`113resolves `speed`/`trim`/`speedRamp` from the meta natively (shared `resolve.py`),114and the editor shows the region as an editable clip — the compression becomes a115decision you can revisit, with eased transitions at the boundaries for free.116117## The multi-case "tile" pattern (showcasing several behaviors)118119To demo N related behaviors in one video, build ONE fixture page laid out as a grid of120labeled **tiles**, each a minimal example of one case, sized so they ALL fit in the121viewport — the compositor can only zoom into pixels that were captured, so off-screen122content can't be revealed. Then one **beat per tile**: `frame_tile(sel)` → `hold` to settle123→ perform the real interaction → `hold`, then move on. Give each tile a text label so the124case is self-explanatory, and linger on the finale. `example-multicase.py` is a complete125working instance (a canvas block-reorder demo: 5 layout tiles + a center-drop finale).126127## Gotchas learned the hard way128129- **Drag-and-drop: read destination coords LIVE, after pickup.** Picking a block up can130 reflow siblings, so coords read *before* `mouse.down()` may miss. Read the target's131 `bounding_box()` *after* the drag starts (always safe).132- **Cross the drag threshold explicitly.** Most engines need a few px of movement after133 `mouse.down()` before a drag "starts" — nudge (`move_to(x+9, y, 6)`) before moving to the134 target, or the press is treated as a click.135- **One element per breakpoint.** If the app renders multiple responsive canvases, select136 the element for the breakpoint you mean (`[data-block-id="X"][data-breakpoint="desktop"]`);137 the same block exists once per visible canvas.138- **Zoom headroom = capture resolution.** To zoom to `z` and stay sharp, the source crop139 (≈ `viewport*dsf / z`) must be ≥ the panel width. Get more pixels via a bigger `VIEWPORT`140 or higher `DSF` (2 = crisp) — not by upscaling in the compositor, and not by over-zooming.141- **The cursor is drawn by the compositor, not the page.** Capture records `mx,my` per142 frame; the compositor composites a vector arrow at final resolution, so it stays crisp143 at any zoom (a DOM-injected cursor blurs when the camera zooms — don't reintroduce one).144- **A keycap hint is a claim, not proof.** `press()` records the combo you sent; if focus145 was in the wrong element the app ignores it and the video shows keycaps with nothing146 happening. Check the frames right after the press for the effect you expected.147- **still() vs hold() is a correctness call**: a `still()` during an app animation freezes148 it in the video. When unsure whether something is still animating, use `hold()`.149- **ffprobe the output** — the compositor prints the exact check line; confirm150 `r_frame_rate=60/1`. Colors are tagged bt709/limited-range explicitly so players don't151 wash them out. JPEG source (`quality≈92`) keeps ~1000-frame captures fast; the final152 H.264 pass (`crf 18`) is what matters for quality.153154## Steps1551560. **Gather inputs** (Step 0 above): target URL, login, flow, fixture policy, output prefs —157 ask the user in one batch rather than discovering them.1581. **Copy the scripts** next to a scratch workdir: `capture_template.py`, `compositor.py`159 (and `example-multicase.py` if doing a tile demo).1602. **Edit `capture_template.py` CONFIG** — `BASE`, `START_URL`, optional `LOGIN`, `VIEWPORT`161 (bigger = more zoom headroom), `DSF` (2). For app demos, seed a fixture (see above).1623. **Write the STORYLINE** with the harness verbs (below), pointing at a **live dev server**.1634. **Capture:** `VIDEO_DIR=/abs/workdir python capture_template.py` (long captures: run in the164 background, poll `frames/` count).1655. **Composite + encode:** `python compositor.py /abs/workdir` → `demo.mp4` directly166 (multiprocess render streamed into ffmpeg; `HD=1` for retina output on demand). To167 re-frame/re-zoom without re-capturing, edit the `z` values in `meta.json` (or the168 compositor knobs) and re-run this step only.1696. **Verify:** run the ffprobe line the compositor prints (`r_frame_rate=60/1`) and170 spot-check a zoomed frame + the last frame (extract with `ffmpeg -ss … -frames:v 1`).171172## Harness verbs (in `capture_template.py`)173174- `cap()` — screenshot the current frame + log camera/mouse state.175- `hold(n)` — n LIVE frames (app animating). `still(n)` — n frames from ONE screenshot176 (page static: camera settles, lingers, endings) — much faster to capture.177- `cam(z, (fx,fy))` — set the camera **target** (zoom + focus in page px); compositor eases to it.178- `move_to(x, y, n)` — ease the mouse to (x,y) over n captured frames (the main "action" verb).179 `jump(x,y)` — teleport without capturing.180- `click(x, y)` — click + record a black pulse ring at that point.181- `press("Meta+Enter")` — press a shortcut and show it as **keycaps at the bottom of the182 frame** (`⌘` + `Enter`) for ~1s. Modifier names are mapped to glyphs; pass a second arg183 to override the label. Use it for anything a viewer can't otherwise see (shortcuts,184 paste, undo) — plain typing needs no hint.185- `type_text(s, per_char=3)` — type one character every few frames, so it reads as live typing.186- `center(sel)` / `box(sel)` — element center / bounding box (Playwright selector).187- `fit_zoom([box,...], margin_px, lo, hi)` → `(zoom, focus)` framing those elements (cap `hi`188 to avoid over-zoom).189- Real actions use raw Playwright between frames: `pg.mouse.down()/up()/click()`,190 `pg.keyboard.press(...)`, `pg.locator(...)`.191192## Tuning knobs (top of `compositor.py`)193194- `HD=1` env (or `PANEL_SCALE=2`) — retina output: 2x panel from the DSF=2 capture pixels.195 Default (scale 1) is the classic size and composites ~3x faster; same capture serves both.196- `PANEL_BASE_W`, `MARGIN`, `RAD` — panel size, background border, corner radius (the197 latter two auto-scale with `PANEL_SCALE`).198- `GRAD` — background gradient stops (soft indigo→violet default; swap for brand/dark).199- `ZOOM_EMA` / `PAN_EMA` — camera easing (0.11 / 0.13: reaches per-shot framing within a short200 hold; lower = gentler but laggier).201- `END_EXTRA` — tail frames so the closing zoom-out settles + lingers.202- `KEY_H` / `KEY_INSET` / `KEY_N` — keycap size, gap from the bottom edge, how long a203 shortcut hint stays up. `KEY_FONTS` — font candidates (needs ⌘/⇧/⌥ glyphs; macOS SF NS204 first, DejaVu Sans as the Linux fallback).205- `CRF` / `PRESET` — libx264 quality/speed (18 / fast). `WORKERS` — render pool size206 (default auto-sizes to physical RAM ÷ per-worker footprint; low-RAM machines also207 get trimmed x264 lookahead/refs at the same CRF).208- `CURSOR_CSS_H` — drawn cursor size in page px (26 matches the old baked cursor).209210## Manual recording (browser extension)211212For takes a human should drive by hand, `extension/` is an unpacked Chrome213extension that records the current tab while an injected logger stamps mouse214moves, clicks and shortcut presses; a floating REC pill (its own tiny window,215never captured) shows elapsed time and stops the take. Two cursor modes:216the default "drawn" captures via CDP screencast (`chrome.debugger`) whose217pixels carry NO cursor — timestamped JPEGs stream to the server during218recording, and meta gets mx/my so the compositor draws its editable vector219cursor (cursor-hide works); "as recorded" captures a tabCapture webm whose220pixels have the OS cursor baked in (no overlay drawn — it would double).221`ingest.py` converts either into a normal capture bundle: frames on the 60fps222grid, still runs collapsed into `repeat` entries (identical frames hardlink223to one file; screencast idle gaps make stills nearly free), and click/key224frames forced onto their own entry so pulses/keycaps land exactly. The camera225gets auto-framing from the click log per the cinematography rules above: one226steady shot per click cluster (push in a beat before the first click, hold227through the action, release after; short wide gaps carry straight to the next228shot), wide elsewhere — then direct the rest in the editor as normal camera229blocks. Everything downstream is unchanged.230231Setup and flow: see `extension/README.md`. Run the server with `--port` (e.g.2328787) so the extension's stored server URL survives restarts; the workdir may233start empty. The rail polls `/api/segments` every 3s, so an imported take234appears without a reload. `python3 ingest.py <dir>` re-converts a segment dir235holding `rec.webm` + `recording.json` by hand if needed. Real-time capture can236drop frames on a slow machine — scripted Playwright capture stays the tool for237perfectly repeatable takes.238239## Editor (interactive fine-tuning)240241`python3 editor/server.py --detach [--port N] <workdir>` starts a local web editor (stdlib242http.server on a free port unless `--port` pins one), prints the URL + pid, and returns; the server runs in its243own session with its output in `<workdir>/editor.log`, so it outlives the agent shell244(a plain foreground or `run_in_background` launch gets reaped by the harness's task245manager). Stop it later with `kill <pid>`. It serves the Vue app from `editor/ui/dist`;246the dist is not checked in, so after a fresh clone run `cd editor/ui && yarn && yarn build`247once (the server errors with that hint if the dist is missing). To hack on the UI: `cd editor/ui && yarn dev`248with `EDITOR_API=http://127.0.0.1:<server port>` (vite proxies `/api`), then `yarn build`249to refresh the dist the server ships. The chrome is Vue 3 + frappe-ui, monochrome and250light by default with a top-bar light/dark toggle (persisted in localStorage); it styles251everything with the frappe-ui semantic tokens (surface/ink/outline), and the canvas252engines resolve their chrome colors from those CSS vars at draw time so both themes253work. The preview renderer and virtualized timeline stay canvas engines in254`editor/ui/src/editor/engine.js`; only the preview's video content (gradient, panel,255cursor) is hardcoded, since it must match the rendered mp4.256257`<workdir>` is a segment dir (frames/ + meta.json) or a dir of segment dirs.258Screen-Studio-style UI: segment rail (thumbnail + name + duration, drag-to-reorder =259concat order; double-click the name or right-click → Rename to give a segment a display260label, stored as `label` in the edited meta and shown in the rail, top bar, and render261output rows — the directory name stays the id in every API; right-click → Delete removes262the segment dir from disk after a confirmation), live preview canvas that263replicates the compositor exactly (gradient,264rounded window + shadow, EMA camera, cursor, pulses, keycaps), and one integrated265timeline: a transport cluster (play/pause + current/total time) and Camera / Holds /266Events track names in a left gutter, then a single ruler + playhead shared by all267tracks. The ruler ticks in playback seconds (so ticks compress through sped-up268sections), click/drag on it scrubs, and all durations shown anywhere are seconds; the269canonical length everywhere is the body without END_EXTRA, whose hold tail draws as a270hatched extension past the end marker. Tracks: camera blocks as chips with the zoom271label (click to select, then click the preview to set focus / scroll to zoom, drag chip272edges to move transitions), stretchable hold pills (drag the right edge to retime),273non-destructive speed regions as hatched overlays whose edges feather over the ramp274width (drag across live frames, stored as `speed:[{from,to,mult}]`), draggable click275dots / keycap chips, caption regions on the Events lane (stored as276`captions:[{from,to,text}]`, rendered as a rounded ink bar with wrapped text at the277bottom of the frame that fades in/out, at the top via each caption's right-click278Position submenu, or anywhere: with the caption selected, click/drag the preview to279place the bar freely (stored as frame-fraction x/y on the region; Bottom/Top presets280clear it). A Caption size preset lives in the inspector. Drag the band to move it,281drag its edges to retime, right-click to add/edit/delete, or the + button on the282Events gutter to add one at the playhead), and trim handles. Speed changes ease in and out instead of jumping283at region boundaries: multipliers are smoothed in log space with a raised-cosine kernel284spanning `speedRamp` seconds (saved in the edited meta, default 0.6, 0 = hard cuts,285editable in Advanced), identically in the server resolver and the JS preview so the286preview's duration always equals the rendered frame count.287Cursor-hidden ranges on the Events lane draw a small slashed-cursor glyph at the range288start (and centered when wide) over a subtle dashed line, in the theme's ink colors.289Right-click anything on the timeline for a context menu with monochrome lucide icons290(zoom stops, split/merge camera blocks via a `cut` flag on frames, cut a section out291of the video entirely — stored as `cuts:[{from,to}]`, drawn as a hatched span, and292restorable from the same menu — hold presets in seconds, speed up a section, event293add/edit/delete, trim/jump). Every edit goes through294per-segment undo/redo (drags and slider scrubs coalesce into one entry).295Keyboard shortcuts (registered via frappe-ui's useShortcut; ⌘? or the top-bar "?" opens296the built-in KeyboardShortcutsModal cheat sheet): Space play/pause, ←/→ step one frame,297⇧←/⇧→ jump one second, I/O set trim in/out at the playhead, S split the camera block298under the playhead, Delete/Backspace delete the selection (event marker or speed299region; a camera block merges into its neighbor, a still drops its hold), ⌘Z/⇧⌘Z300undo/redo, ⌘S save — all silent while an input or dialog has focus. The inspector is preset-first:301background swatches, corner/shadow/camera-feel/ending-hold/cursor/keycap segments, and302quality/resolution for export; speed is per clip only (Holds-track drag or a camera303block's "Set speed" submenu — no master speed control; a legacy `pace` in old edited304metas is still honored at resolve time). A selected camera305block gets Wide/Slight/Medium/Close zoom stops, and other selections edit in seconds.306Every raw knob (exact hexes, px values, EMAs, CRF, numeric z/cx/cy, trim entries) lives307in the collapsed Advanced accordion, two-way synced: presets write the raw values,308hand-edited raw values flip the matching preset to a "Custom" chip.309310Save writes `meta.edited.json` + `knobs.json` into the segment dir; the original311`meta.json` is never touched. Render runs this skill's `compositor.py` per segment with312`META_FILE` (a baked `meta.render.json`: trim + speed resolved via `repeat`313counts with the eased multipliers, event indices remapped) and `KNOBS_JSON` (constant overrides); "Render all + concat" then314ffmpeg-concats (`-c copy`) in rail order to `<workdir>/edited-full.mp4`. Without those315env vars the compositor behaves exactly as before. Keep one panel scale across segments316when concatenating.