name: demo-video-creator description: Record a polished 4K screencast/demo video of Daintree by driving the in-app demo engine (window.electron.demo — animated cursor, spotlight, captions) from a Playwright spec. Use whenever the user wants to make a demo video, screencast, product/marketing clip, or feature walkthrough of Daintree — either from a standard fixture or from a specific existing project folder they point at ("create a video from this folder").
Demo Video Creator
This skill produces a real recorded video (.webm master + .mp4) of Daintree in action. It does NOT mock anything — it launches the actual app with the demo engine enabled and choreographs a fake cursor, spotlight, and captions over a real project while MediaRecorder captures the frame to disk.
How it works (architecture)
Two halves:
- The in-app demo engine (already built, demo-mode only):
src/components/Demo/DemoCursor.tsx— animated cursor: auto-generated Bézier arcs, Fitts's-law timing, ballistic→settle, click ripples. You give it destinations; the realism is automatic.src/components/Demo/DemoOverlay.tsx— spotlight (blur + dim, masked cutout, 300ms fades) and captions (placement presets, size tiers, 200ms fades).src/components/Demo/DemoCaptureBridge.tsx—getDisplayMedia+ MediaRecorder → streams chunks to the main process.electron/ipc/handlers/demo.ts+electron/preload.cts— thewindow.electron.demoAPI.
- The Playwright driver (the "scene"):
e2e/screenshots/demo-reel.spec.tsis the canonical template. It boots a project with--demo-mode, then awaitswindow.electron.demo.*calls wrapped instartCapture/stopCapture.
Read e2e/screenshots/demo-reel.spec.ts first — copy/adapt it for each new video rather than starting from scratch.
Two modes
Mode A — standard fixture. Reuse the marketing fixtures in e2e/helpers/screenshotFixtures.ts (createBrushCmsRepo = worktree dashboard, createSurgeCheckoutRepo = agent at work, createOrbitalSyncRepo = multi-agent, etc.). Each returns { dir, cleanup }. This is the default — no setup, deterministic, no API key needed for the non-agent fixtures.
Mode B — a specific project folder. The user sets up a real project folder ("I created a whole project, now make a video from this folder") and gives you its absolute path. Open THAT path instead of a fixture: skip createBrushCmsRepo() and pass the user's path as the dir to mockOpenDialog. First inspect the folder (its worktrees, files, what's interesting) and choreograph the scene around its actual content — generic captions on unknown content look hollow.
The boot flow is identical for both; only the folder dir differs.
Workflow
- Build the e2e bundle (demo mode is stripped from prod but present in the test build):
npm run build:e2e - Author the scene — copy
demo-reel.spec.tstoe2e/screenshots/<name>.spec.ts(or edit in place for iteration) and write the beats (see API below). Wrap the visible beats instartCapture(...)/stopCapture(), inside atry/finallyso capture always finalizes; make each beat best-effort (safe()wrapper) so one missing selector doesn't abort the recording. - Record:
npx playwright test e2e/screenshots/<name>.spec.ts --project=screenshots --reporter=line(ThescreenshotsPlaywright project has a 30-min timeout. A transient macOS launch flake auto-retries.) - Post-process (output lands in
artifacts/demo/, which is gitignored):- Fix the WebM duration (MediaRecorder omits it during live muxing):
ffmpeg -i in.webm -c copy fixed.webm - Optional QuickTime-friendly copy:
ffmpeg -i fixed.webm -c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p -movflags +faststart out.mp4
- Fix the WebM duration (MediaRecorder omits it during live muxing):
- Verify before claiming done — extract frames with
ffmpeg -ss <t> -i out.webm -frames:v 1 frame.pngand actually look at them (a tiled montage-vf "fps=15,scale=300:-1,tile=7x3"shows fades ramping). Confirm the feature is on screen, the cursor moves, and captions are legible.
The window.electron.demo API (call via page.evaluate)
All calls return promises that resolve when the command's renderer round-trip completes, so await them. The cursor/overlay components must be mounted first — gate on await page.waitForFunction(() => !!window.electron?.demo).
- Cursor:
moveTo(xPct, yPct, durationMs?)(x/y are 0–100 % of viewport),moveToSelector(selector, durationMs?, offsetX?, offsetY?),click()(clicks at the cursor's current spot — move first),drag(fromSel, toSel, durationMs?),scroll(selector),pressKey(key, code?, modifiers?, selector?),type(selector, text, cps?). - Terminals:
typeInTerminal(selector, text, cps?)types text into an xterm.js terminal panel char-by-char with humanized timing, andsendKeyToTerminal(selector, key)sends a named special key. These route through the PTY (window.electron.terminal.write), unliketype/pressKeywhich target CodeMirror/HTML inputs and do not work on terminals. Theselectorresolves to (or to any element inside) a terminal panel's[data-panel-id]. Supported keys:up,down,left,right,enter,tab,escape,backspace,ctrl-c,ctrl-d,ctrl-u,home,end,pageup,pagedown(arrow keys auto-honor the terminal's application-cursor mode). Example:await d.typeInTerminal('[data-panel-id="…"]', 'echo hi'); await d.sendKeyToTerminal('[data-panel-id="…"]', 'enter');. - Spotlight:
spotlight(selector, padding?)(blur + dim everything else, 300ms fade-in),dismissSpotlight(). - Captions:
annotate(selector, text, placement?, size?, id?)→ returns{ id }.dismissAnnotation(id?)(omit id = clear all; 200ms fades both ways).- placement: element-anchored
top|bottom|left|right; viewportscreen-bottom(subtitle — the default for narration),screen-top,screen-center,lower-third-left|right,top-left|top-right|bottom-left|bottom-right; cursorabove-cursor|below-cursor. For viewport/cursor placements pass""as the selector. - size:
sm|md|lg|xl(resolved as % of frame height so it scales 1080p↔4K). Defaultmd; subtitles read well atlg.
- placement: element-anchored
- Timing/util:
sleep(ms),waitForSelector(selector, timeoutMs?),waitForIdle(settleMs?, timeoutMs?),pause()/resume(),screenshot().waitForIdlesettles only once all activity channels are simultaneously quiet forsettleMs— CSS/WAAPI animations, DOM mutations, xterm terminal output (subscribed via each live terminal'sonWriteParsed), and<video>playback (a playing video blocks idle until it pauses/ends). It returns early attimeoutMsregardless. Safe to use right aftertypeInTerminalto wait for a command's output to drain. The main-process watchdog is command-aware: it derives its budget from each command's owndurationMs/timeoutMs(or text length fortype/typeInTerminal) plus a buffer, so a longsleep,waitFor*, ortypebeat above 30s is honored rather than silently timed out. Unbounded commands (click,scroll,pressKey, …) keep a 30s default. - Capture:
startCapture({ outputPath, fps?, videoBitsPerSecond?, width?, height? }),stopCapture()→{ outputPath, frameCount },getCaptureStatus().outputPathis a main-process filesystem path; the main process mkdirs its dirname.
Quality / resolution knobs (env, read by the spec)
Defaults give a 4K master at the app's true desktop density (the industry sweet spot — Screen Studio / Stripe / Linear all target logical 1920×1080 @ 2× → 4K). Do NOT zoom the interface by default: zooming reduces the effective layout and hides panels, and setZoomFactor literally magnifies fonts so little fits.
| Env | Default | Effect |
|---|---|---|
DAINTREE_DEMO_RESOLUTION |
4k |
1080p / 1440p / 4k output |
DAINTREE_DEMO_ZOOM |
1.0 |
UI magnification; >1 fits less, <1 fits more. Leave at 1.0 unless asked. |
DAINTREE_DEMO_FPS |
60 |
frame rate |
DAINTREE_DEMO_BITRATE_MBPS |
50 (4k) |
encode ceiling (VBR — static dark UI stays small) |
Scene-authoring lessons (hard-won — follow these)
- Tell one story. Intro subtitle → 2–4 focused beats → outro subtitle. Don't narrate every pixel.
- Captions are optional and most users want few of them. When used: keep ≤2 lines, sentence case, and hold them long enough to read — ~15–17 characters/second (a 36-char line ≈ 2.2s minimum on screen).
- Show the feature within the first ~0.5s — open on the real UI, not a title card.
- Cursor + click reads as intent.
moveToSelectorthenclick()plays the press/ripple and (for worktree cards etc.) performs the real action — capture the resulting state change. - Spotlight to focus, then dismiss before moving on. On Daintree's dark theme the blur (not the dim) is what makes the focus read.
- Reading-friendly pacing:
sleepgenerously between beats; IPC round-trips already add latency, so the recording runs a touch longer than the sum of sleeps.
Gotchas / why things are the way they are
- Demo mode must reach the renderer. It's gated on the renderer's
process.argv;--demo-modeis forwarded intoadditionalArgumentsinelectron/window/createWindow.tsandProjectViewManager.ts. Launch vialaunchApp({ extraArgs: ["--demo-mode"], windowSize, screenshotScale }). getDisplayMedianeeds a relaxed Permissions-Policy.display-captureis denied by default;electron/setup/protocols.tsrelaxes it to(self)only in demo mode (gated on!app.isPackaged).- Page handle goes stale on open. The project view reloads once during hydration. Re-acquire with
refreshActiveWindowafter opening the folder (the template does this twice) before driving the demo. - Capture file is small even at 4K — that's efficient VBR on a static dark UI, not low quality. Judge sharpness from a native-res crop, not the byte count.
- Animated camera zoom was removed (
webFrame.setZoomFactorthrashed xterm's WebGL atlas every frame). Z-axis "camera moves" belong in post (e.g. a CSS transform in Remotion), not at capture time.
Output
Videos go to artifacts/demo/ (gitignored). Commit only the code (engine + spec changes), never the rendered media.