
Desktop Recorder Skill
- 16 repo stars
- Updated June 27, 2026
- MobAI-App/desktop-recorder-skill
This is a copy of desktop-recorder by MobAI-App - installs and ranking accrue to the original listing.
desktop-recorder-skill is a skill for the build phase that lets an agent drive a desktop or web app and record a reproducible screencast, exporting a clean mp4 with zoom, captions and click highlights.
About
desktop-recorder-skill is a macOS agent skill for recording polished, reproducible screencasts of desktop or web apps. The agent explores the app, writes a recording screenplay, normalizes state, does a dry run, then records and edits, adding zoom, captions, click highlights and speed-ups before exporting an mp4. Because the recording is scripted through the native deskagent CLI, you re-run it whenever the UI changes instead of re-shooting demos by hand. It is aimed at builders who need repeatable product demos, tutorial clips and release GIFs that stay in sync with the app.
- An agent drives the app and records itself, no manual screen capture
- Reproducible: re-run the script when your UI changes and the demo regenerates
- Polished output - zoom, captions, click ripples and variable speed
- Drives desktop and web apps via the native deskagent CLI
- Exports a clean, share-ready mp4
Desktop Recorder Skill by the numbers
- Data as of Jul 12, 2026 (Skillselion catalog sync)
npx skills add https://github.com/MobAI-App/desktop-recorder-skill --skill desktop-recorderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| repo stars | ★ 16 |
|---|---|
| Last updated | June 27, 2026 |
| Repository | MobAI-App/desktop-recorder-skill ↗ |
What it does
An agent drives your desktop or web app and records a polished, reproducible screencast - a clean mp4 with zoom, captions and click highlights you regenerate when the UI changes.
Who is it for?
Builders who need repeatable product demos, tutorial clips or release GIFs that an agent can regenerate whenever the UI changes.
Skip if: Live webinars, real-time screen sharing, or recording on Windows or Linux - it is a scripted, macOS-only capture tool, not a streaming app.
When should I use this skill?
When you need to produce or refresh a demo video, tutorial clip or release GIF of a desktop or web app.
What you get
A scripted screencast you re-run on demand - the polished mp4 regenerates when the app changes, instead of being re-recorded.
- a screenplay.json recording script
- a polished mp4 screencast
By the numbers
- MIT licensed
- macOS 14+ / Apple Silicon
- deskagent CLI: list, inspect, record, control
Files
Desktop Recorder
Built on deskagent (ScreenCaptureKit + AXPress + Vision OCR). The agent never improvises during the final take - exploration is unconstrained, the recording is a deterministic JSON-script replay.
Pre-recording checklist - ASK, don't pick silently
Use AskUserQuestion (or plain text) before recording. State defaults explicitly so the user can shrug and accept.
| Decision | Default if user shrugs |
|---|---|
Background none / dark / light / color:RRGGBB / image:/path | color:1a1a2e (dark navy). dark/light render as cached vertical gradients; image: covers the canvas. |
Layout for multi-source: auto / side-by-side / grid / stack | auto |
| Padding between elements | 60 |
| Composition canvas | display native (or [1400, 1000] for the typical 1.4 AR) |
| Supersample on capture | 1 (device pixels). Bump to 2 for sharper playback when the editor will scale clips up |
| Cursor sprite | macOS system arrow with pointing-hand on click (deskagent cursor-png). Override via screenplay.highlights.cursor.{arrow,pointing,size} |
| Click ripple | Procedural soft expanding white ring. Override color/size/duration via screenplay.highlights.ripple.*, or supply your own .mov/APNG via ripple.sprite |
| Zoom follow_cursor | Enabled - camera tracks the synthetic cursor's eased path (shared with the sprite, no desync) |
| Final export resolution | display native (so QuickTime plays 1:1 on this machine) |
| Final quality | high (HEVC ~200 Mbps target) |
| State-verification level | preflight + fingerprint at dry-run boundary |
| Which apps may be driven | Whichever the user named; never silently click into others |
The 6 rules
1. Explore first
Tools allowed during exploration only:
deskagent list --json→ window pickingdeskagent inspect --window $ID→ element coords (AX + OCR)deskagent assert --window $ID --label X→ cheap yes/no probedeskagent screenshot --window $ID→ visual reference; on macOS 26/Tahoe
hangs or capture failures, use node scripts/record-screenshot.js --window $ID --out /tmp/shot.png
- Trial
deskagent controlruns against the live UI
Collect: click coords (window-relative), per-step waits, demo data, popups to normalize, the recording's window size (pin BEFORE inspecting), start/end states, captions.
2. The screenplay - single source of truth
One screenplay.json describes the demo end-to-end: scenes of actions to execute, plus top-level composition / zoom / speed / captions / trim directives. deskagent control reads only scenes[].actions[]; editor scripts read the top-level directives.
{
"schema_version": 2,
"name": "demo1",
"coordinate_space": "window",
"scenes": [
{ "id": "open_settings", "windowId": 245663,
"actions": [ { "action": "click", "x": 244.5, "y": 54.5 } ] },
{ "id": "wait_load", "windowId": 245663,
"actions": [ { "action": "wait", "ms": 4000 } ] }
],
"composition": {
"canvas": [2560, 1600],
"background": "color:0a0a10",
"layout": "side-by-side", // optional; auto-computes slot rects
"padding": 60, // optional gap (used by layout)
"elements": [
{ "windowId": 245663 } // auto rect from layout
]
},
"zoom": [
{ "scale": 2.0, "follow_cursor": true,
"fromAction": "open_settings/0", "toAction": "wait_load/0" }
],
"speed": [
{ "factor": 2.5,
"fromAction": "wait_load/0", "toAction": "wait_load/0", "endDelayMs": 4000 }
],
"captions": [
{ "text": "Open Settings", "fromAction": "open_settings/0", "durationMs": 1500 },
{ "text": "Loading the panel…", "fromAction": "wait_load/0", "toAction": "wait_load/0", "endDelayMs": 3000 }
],
"trim": { "beforeScene": "open_settings", "afterScene": "wait_load" }
}composition drives scripts/stages/compose.js - required for multi-clip recordings, optional when there's a single clip. zoom, speed, and captions are top-level arrays with global fromAction / toAction refs
- a single entry can span any range of actions across any scenes, with
timing offsets via startDelayMs/endDelayMs. Zoom camera modes: follow_cursor: true tracks the synthetic cursor across click-driven ranges; pan: [...] does cinematic sweeps over no-click ranges (the cursor is hidden during pans). Don't overlap a pan with clicks - use follow_cursor there. See `references/desktop.md` for the full shape.
coordinate_space: "window" keeps screenplays portable across window drags. Per-scene windowId routes that scene's action coords to the correct window in multi-window compositions. No conditionals, no wait_for, no observation loops - every action is deterministic. Recording start/stop is outside the screenplay; the agent owns the lifecycle.
3. Dry-run + state fingerprint
Run the full script with --background against the live UI (no recorder active). Iterate until clean.
Before the final take, prove state matches what the script expects:
1. deskagent screenshot --window $ID → visual record. If screenshot hangs or fails on macOS 26/Tahoe, use the streaming fallback: node scripts/record-screenshot.js --window $ID --out /tmp/shot.png. 2. At least two deskagent assert calls on labels that exist ONLY in the expected sub-state. (Section names usually persist across sub-states - they're too coarse.) 3. Refuse to record if any assert fails. Re-normalize in setup.
Per-step assertions inside recording are NOT supported - the recording is meant to be a deterministic replay, not a probe loop.
4. Pin window info BEFORE recording
Capture id, pid, and x,y for every window during exploration, then drive with --target-pid + --window-frame + --background during the take. No WindowServer lookups during the run, no chance of the wrong window being picked if focus shifted between dry-run and record. Multi-window: collect pid+origin per window in one pre-record list call; route each control invocation via its own flags.
Targeting flag rules are documented in references/deskagent.md. The short form: HID mode needs --target-window (activates the app); background mode needs --target-pid + --window-frame (delivers per-pid, no activation).
5. No live recovery - re-record
Final take failed? Stop, discard, fix the script, re-record. Don't patch live.
6. Don't resize source windows after exploration
Any osascript … set size of window belongs in setup BEFORE exploration, confirmed with the user. Never inside recording. Composition is done in the editor's compose stage; per-clip placement + canvas + background live in the screenplay's composition block, so a mid-take window resize would still mismatch the composition rects.
End-to-end pipeline
1. Explore -> pin id/pid/origin per window, gather click coords.
2. Author -> screenplay.json (scenes + actions, top-level composition/zoom/speed/captions/trim).
3. Normalize state (close tabs, log in, hide chrome, theme).
4. Dry-run -> deskagent control screenplay.json --target-window $ID --timeline /tmp/dry.json
5. Fingerprint state with deskagent assert.
6. Record -> deskagent record ./demo/raw --window $ID [--window $ID2 ...] --no-cursor --pid-file /tmp/rec.pid [--supersample 2] &
(writes per-source ProRes 4444 .mov clips + recording.manifest.json into ./demo/raw/)
7. Drive -> deskagent control screenplay.json --target-pid $PID --window-frame "$ORIGIN" \
--background --no-activate --timeline timeline.json
(run in parallel per target window for multi-window demos)
8. Stop -> kill -INT $(cat /tmp/rec.pid); wait
9. Export -> node scripts/export.js ./demo/raw screenplay.json timeline.json demo.final.mp4 [format] [--quality high]
(one ffmpeg pass: compose -> highlights -> zoom -> captions -> speedups -> encode)
10. Copy -> node scripts/generate_copy.js timeline.json prompt.txt copy.mdEvery editing operation runs in a single ffmpeg invocation off the per-source clips - no intermediate mp4s in the hot path, no generation-loss from per-stage re-encodes. The final encoder choice (HEVC / H.264 / ProRes 422) is the only re-encode in the entire pipeline.
Outputs (one demo folder, default ./demo-out/<name>/)
screenplay.json single source of truth (scenes + composition + directives)
timeline.json execution evidence from `deskagent control`
raw/ directory written by `deskagent record`:
recording.manifest.json per-clip paths + host-time alignment anchors
window-<id>.mov one ProRes 4444 .mov per source (alpha-preserving)
display-<id>.mov
demo.final.mp4 the deliverable (single ffmpeg pass via scripts/export.js)
copy.md upload copyFor per-stage debugging, each editing stage has a CLI that can either emit its filter fragment as JSON (used by the orchestrator) or render it standalone to a ProRes 4444 .mov to inspect just that stage's effect:
node scripts/stages/zoom.js generate <recDir> <screenplay> <timeline>
node scripts/stages/zoom.js generate <recDir> <screenplay> <timeline> --apply input.mov output.movReferences (load on demand)
references/deskagent.md- CLI surface, permissions, recording.manifest.json schemareferences/desktop.md- screenplay schema (scenes + composition + captions + zoom + speed + trim), setup normalization, state-fingerprint recipereferences/timeline.md- timeline event schema (scene_start / action / scene_end)references/editing.md- export orchestrator + per-stage modules (compose / highlights / zoom / captions / speedups); cursor track +cursor.hide/showreferences/web-driver.md- CDP web driver (drive-web.js): drive browser page content focus-free; trajectory/pointer actions; draw on web canvasesremotion-template/- optional Remotion bridge: turn a recording (manifest+timeline+screenplay) into React motion graphics. The bridge (loadRecording+<RecordingCard>/<Cursor>/<ClickRipple>/<Caption>) does the integration; you only write the creative composition. See itsREADME.md.
Authorization rule
deskagent control mutates the user's app state (focus, tabs, open docs). --background minimizes visible impact but still mutates. Confirm before touching any app the user didn't name. Don't silently swap targets or open new tabs / windows / documents.
Quick failure map
| Stage | Cause | Action |
|---|---|---|
inspect returns nothing | Wails/Electron WebView | rely on OCR; AX walk only sees chrome |
| Capture errors at start | Target window onScreen: false | bring it forward; SCK can't always frame-pump occluded windows |
macOS 26/Tahoe: deskagent screenshot hangs or fails while deskagent record --window ID works | screenshot uses a separate ScreenCaptureKit still-image path that can fail independently from streaming capture | Use node scripts/record-screenshot.js --window ID --out /tmp/shot.png for visual fingerprints; keep final takes on deskagent record |
macOS 26/Tahoe: deskagent list reports displays: [] | ScreenCaptureKit display enumeration mismatch | Try deskagent record ... --display 1 directly; display recording can still work even when list output omits displays |
| Web/browser clip frozen, animations stalled, stale content | Window fully occluded - WebView throttles timers/rAF/repaints | keep the window at least partially visible during the take |
screenplay schema_version N not supported | screenplay was authored for a different deskagent build | re-author to current schema (current: 2) |
compose.js errors: "manifest has N clips but screenplay has no \composition\" | multi-clip recording lacks composition | add composition.canvas + composition.elements[] to screenplay |
captions[i] overlaps captions[j] | two caption entries cover the same time range | shorten the first with endDelayMs/durationMs or push the second with startDelayMs |
| Video looks soft in QuickTime | QT renders .mov dimensions as logical points; pixel-doubles on retina | record with --supersample 2, OR export at display-native size (default), OR open the frame as PNG in Preview |
| Composition stretches a window | source pixel size doesn't fit slot AND composition.upscale is on | leave upscale unset; clip will sit at native size centered in the slot (downscale-only) |
| TCC re-prompts every release | Ad-hoc signing - fresh identity per build | re-grant once, or build with a stable self-signed cert |
Chromium-based browsers (Chrome, Edge, Brave, Arc)
For page content, prefer the CDP web driver (scripts/drive-web.js) over deskagent control - it injects input at the renderer, so it needs no focus and no foreground (drive an unfocused window while recording), targets the DOM by selector (no pixel-guessing / mis-clicks), and emits the same timeline.json so record + export are unchanged. It replaces control for browser scenes; full setup (launch flags, actions, coordinates) in `references/web-driver.md`. Use deskagent control only for the browser's own UI (omnibox, tabs, extensions, menus) - CDP can't reach those.
Fallback (no debug Chrome): deskagent control HID mode reaches only the browser chrome via --background; for page content record with --no-cursor and drive with --target-window (HID, cursor moves during the take). OCR beats AX for Chrome element discovery; URL-bar nav (cmd+l → type → return) beats chasing SPA links.
Keep the browser window at least partially visible during the take. When fully occluded, the renderer throttles timers / rAF / repaints (Page Visibility), so the clip captures stalled animations or stale content - even though SCK is still pulling frames. (Launch Chrome with the --disable-*backgrounding* / throttling flags - see web-driver.md - to keep an unfocused window compositing.)
Out of scope
Mobile (use mobile-recorder-skill); Linux/Windows; direct upload; AI voiceover / music; GUI video editor.
{
"schema_version": 2,
"name": "continuous_zoom_demo",
"coordinate_space": "window",
"_comment": "Demonstrates: (1) a single zoom range that spans three scenes, so the camera stays at 1.8x throughout without dipping between scenes; (2) pan waypoints that move the camera across the screen while no clicks are happening - the cinematic 'look here, then look there' shot.",
"scenes": [
{ "id": "intro", "actions": [ { "action": "wait", "ms": 1200 } ] },
{ "id": "explore", "actions": [ { "action": "wait", "ms": 5000 } ] },
{ "id": "click_save", "actions": [
{ "action": "click", "x": 1300, "y": 820 },
{ "action": "wait", "ms": 800 }
] }
],
"captions": [
{ "text": "Settings panel", "fromAction": "intro/0", "durationMs": 1200 },
{ "text": "Review the sidebar, then the editor", "fromAction": "explore/0", "toAction": "click_save/0", "endDelayMs": -100 },
{ "text": "Save", "fromAction": "click_save/0", "durationMs": 800 }
],
"zoom": [
{
"scale": 1.8,
"x": 200, "y": 200,
"fromAction": "intro/0",
"toAction": "click_save/1",
"pan": [
{ "afterMs": 1200, "x": 200, "y": 600, "ease": "in_out" },
{ "afterMs": 3500, "x": 900, "y": 400, "ease": "in_out" },
{ "afterMs": 6000, "x": 1300, "y": 820, "ease": "in_out" }
]
}
],
"trim": { "beforeScene": "intro", "afterScene": "click_save" }
}
{
"schema_version": 2,
"name": "notes_demo",
"coordinate_space": "window",
"_comment": "Window-relative coords pulled from `deskagent inspect --window <id>`. Pipeline: `deskagent record ./out --window $ID --no-cursor &` then `deskagent control --target-pid $PID --window-frame \"$ORIGIN\" --background --timeline timeline.json`. Edit + export with `node scripts/export.js ./out screenplay.json timeline.json demo.mp4 --quality high`.",
"setup": [
{ "action": "shell", "cmd": "open -a 'Notes'" },
{ "action": "wait", "ms": 1500 },
{ "action": "shell", "cmd": "osascript -e 'tell application \"System Events\" to tell process \"Notes\" to set position of window 1 to {80, 80}'" },
{ "action": "shell", "cmd": "osascript -e 'tell application \"System Events\" to tell process \"Notes\" to set size of window 1 to {1440, 900}'" }
],
"preflight": [
{ "assert": "label", "value": "New Note" }
],
"scenes": [
{ "id": "new_note", "actions": [
{ "action": "click", "x": 132, "y": 86 },
{ "action": "wait", "ms": 600 }
] },
{ "id": "title", "actions": [
{ "action": "type", "text": "Launch checklist" },
{ "action": "key", "combo": "return" },
{ "action": "wait", "ms": 300 }
] },
{ "id": "fill_list", "actions": [
{ "action": "type", "text": "- Record launch video\n- Cut Shorts version\n- Write copy\n- Schedule post" },
{ "action": "wait", "ms": 1000 }
] },
{ "id": "save", "actions": [
{ "action": "key", "combo": "cmd+s" },
{ "action": "wait", "ms": 500 }
] }
],
"captions": [
{ "text": "Capture an idea in seconds", "fromAction": "new_note/0", "toAction": "title/0", "endDelayMs": -100 },
{ "text": "Write the launch checklist", "fromAction": "fill_list/0", "toAction": "save/0", "endDelayMs": -100 },
{ "text": "Save", "fromAction": "save/0", "durationMs": 800 }
],
"zoom": [
{ "scale": 1.6, "follow_cursor": true, "fromAction": "new_note/0", "toAction": "title/0" }
],
"speed": [
{ "factor": 2.5, "fromAction": "fill_list/0", "toAction": "save/0" }
],
"validate": [
{ "assert": "label", "value": "Launch checklist" }
],
"trim": { "beforeScene": "new_note", "afterScene": "save" }
}
[
{
"startMs": 0,
"endMs": 2000,
"text": "Start in seconds"
},
{
"startMs": 2000,
"endMs": 4500,
"text": "Create your first project"
},
{
"startMs": 4500,
"endMs": 6500,
"text": "Done"
}
]
Title
{{title}}
Short post
{{short_post}}
YouTube Shorts title
{{shorts_title}}
Thumbnail text
{{thumbnail_text}}
Hashtags (optional)
{{hashtags}}
Notes
- Replace any
{{...}}placeholder before posting. - Keep title under 60 characters where possible.
- Shorts title under 40 characters.
- Thumbnail text 3–5 words, large-text safe.
- Short post should describe the flow shown in the video, not the product overall.
deskagent - CLI surface
Native macOS recorder + deterministic input replayer + AX/OCR inspector. ScreenCaptureKit captures occluded/minimized windows. macOS only.
WebView/browser caveat: capture works while occluded, but apps with web content (Chrome, Safari, Electron, Wails) throttle timers, rAF, and repaints when the window is fully occluded (Page Visibility / occlusion throttling). The clip then shows stalled animations or stale content. Keep such windows at least partially visible during the take. Native AppKit windows are unaffected.
| Subcommand | Purpose |
|---|---|
list | Enumerate displays + windows (id, pid, x/y, title, onScreen). |
inspect <window> | Discover clickable elements via AX + Vision OCR. Bbox + center in CG points. |
assert <window> | Cheap yes/no probe for a label. Optimized for tight loops. |
screenshot <window> | One-shot JPEG (or PNG via --out *.png). Sized for LLM context by default. |
record <out-dir> | Record one ProRes 4444 .mov (alpha-preserving) per source + recording.manifest.json into the output directory. SIGINT-clean. |
control <script> | Replay a deterministic JSON script. --background drives without focus shift. |
doctor | Verify Screen Recording + Accessibility grants. --request-accessibility triggers the prompt. |
text-png / cursor-png | Render typeset text / cursor sprite to a transparent PNG (for overlay tools). |
All commands accept --json. Exit codes: 0 ok, 1 assertion-failed (assert only), 2 runtime/permission, 64 usage.
Permissions
| Need | TCC scope | Triggered by |
|---|---|---|
| Capture screens/windows | Screen Recording | first record or list |
| Synthesize input | Accessibility | first control |
deskagent doctor reports both. --request-accessibility surfaces the prompt without making a control call.
Discovery
deskagent list
deskagent list --jsonEmits { displays: [...], windows: [{id, pid, app, bundleID, title, x, y, width, height, onScreen}] }. Window IDs are per-launch - re-list before each record. Add --all to surface occluded / minimized windows (onScreen: false).
Pick one:
INFO=$(deskagent list --all --json | jq -c '[.windows[] | select(.app=="Safari")] | first')
ID=$(echo "$INFO" | jq -r '.id')
PID=$(echo "$INFO" | jq -r '.pid')
ORIGIN=$(echo "$INFO" | jq -r '"\(.x),\(.y)"')deskagent inspect
deskagent inspect --window <id> \
[--ax|--no-ax] [--ocr|--no-ocr] \
[--label "Submit" ...] [--role AXButton ...] \
[--json]Two complementary sources, both on by default:
--ax- native AppKit; one entry per AX element (role,label,bbox).--ocr- Vision text recognition; works on any pixels (Wails/Electron/Canvas).
Filters (repeatable): --label (case-insensitive substring), --role (exact AX role). When --label matches AX, the OCR pass is skipped.
Single inspect is the most expensive op in this CLI (AX walk + Vision OCR). Cache the result and jq it locally:
SNAPSHOT=$(deskagent inspect --window $ID --json)
echo "$SNAPSHOT" | jq -r '.ax[] | select(.label=="Submit") | .center | @sh'For Wails/Electron, --ax usually returns only the window chrome. Use --ocr for those, or rely on --background's AXPress hit-test which walks the WebKit-bridged AX tree at click time.
deskagent assert
deskagent assert --window <id> \
[--label "X" ...] [--label-any "A,B"] [--role AXButton ...] \
[--absent] [--no-ocr] [--json]Exit codes: 0 found · 1 absent · 2 error. --absent inverts.
--label is AND across flags; --label-any is OR across CSV entries. JSON returns { found, source, label, role, center, bbox } for the first match - pipe straight into a click.
deskagent screenshot
deskagent screenshot --window <id> \
[--region x,y,w,h] [--annotate-bboxes] \
[--quality 1-100] [--max-dim N] \
[--out path.jpg] [--json]JPEG by default - PNG via --out *.png. Defaults: quality=85, max-dim=1568 (Claude's resize threshold), output $TMPDIR/deskagent/<window-id>-<ms>.jpg. JSON emits {path, pixelSize, windowFrameCG, backingScale, format}.
--annotate-bboxes overlays AX (cyan) + OCR (yellow) rectangles - useful for visual verification of which element a label resolves to.
macOS 26/Tahoe screenshot fallback
On macOS 26/Tahoe, deskagent screenshot can hang or fail even when doctor reports Screen Recording as granted and deskagent record succeeds. When that happens, take the visual reference through the streaming path:
node scripts/record-screenshot.js --window <id> --out /tmp/shot.pngThe helper runs a short deskagent record, stops it with SIGINT, and extracts one frame with ffmpeg. Use it for fingerprints and visual checks; keep final takes on the normal deskagent record pipeline.
Recording: deskagent record
record writes one ProRes 4444 .mov per source into an output directory, plus recording.manifest.json. Per-pixel alpha is preserved on every clip, so the editor can composite windows onto any background without the OS's rounded corners or shadows showing through as black.
deskagent record /tmp/demo --window "$ID" \
--fps 60 \
--pid-file /tmp/rec.pid --quiet --json > /tmp/rec.json &
# … drive …
kill -INT "$(cat /tmp/rec.pid)"; wait
# /tmp/demo/ now contains window-<ID>.mov + recording.manifest.jsonNever `kill -9` - the .mov's moov atom won't flush.
Composition / quality / final-encode are an editor concern; see editing.md. record always captures BGRA → ProRes 4444 .mov. The editor's export.js picks the final container, codec, and bitrate.
Source flags
| Flag | Repeatable | Purpose |
|---|---|---|
--window ID | yes | One clip per window. |
--display ID | yes | One clip per display. |
--app NAME | yes | One clip per matched window (name or bundle id; case-insensitive). |
--window-title S | no | When --app is used, restrict to titles containing S. |
Behavior flags
| Flag | Default | Notes |
|---|---|---|
--fps | 60 | 10–120. Same fps applied to every clip. |
--supersample N | 1 | Pixel-density multiplier on top of the display's backing scale (1..4). 1 = device pixels (what's on screen); 2 = 2× supersampled - SCK re-rasterizes AppKit content from vectors so text/UI stay crisp when the export scales clips up or QuickTime pixel-doubles on retina. Costs ~N² bandwidth and disk. |
--no-cursor | (cursor visible) | Hide system cursor in every clip. The editor's highlights stage draws a synthetic cursor that follows clicks. |
--pid-file PATH | – | Write the process pid for kill -INT. |
--quiet / --json | – | Output mode. |
Output
Each source writes to <out-dir>/<kind>-<id>.mov (e.g. window-12345.mov, display-1.mov). <out-dir>/recording.manifest.json is written after all clips finalize.
JSON stdout (--json):
{
"status": "ok",
"directory": "/tmp/demo",
"manifest": "/tmp/demo/recording.manifest.json",
"durationSeconds": 12.3,
"fps": 60,
"clips": [
{ "path": "/tmp/demo/window-12345.mov",
"source": "window:12345",
"frames": 740, "dropped": 0,
"startWallclockMs": 1716480000050 }
]
}Manifest sync anchors
Every clip in recording.manifest.json carries startHostNs and endHostNs. The editor uses them to compute a shared time window:
t0 = max(clip.startHostNs)- latest first-frame across clips.tEnd = min(clip.endHostNs)- earliest last-frame.- Per clip head-trim:
(t0 - clip.startHostNs) / 1e9seconds. - Composited duration:
(tEnd - t0) / 1e9seconds.
Alpha & color
Capture is BGRA via SCStream → ProRes 4444 (yuva444p12le) via AVAssetWriter. Each clip's alpha channel reflects the window's real shape - areas outside the window's content have alpha=0. Compositing in the editor (compose stage) uses ffmpeg's overlay, which respects the source alpha natively.
Desktop control: deskagent control
A deterministic replayer. No screen observation, no retry. Author a screenplay, dry-run, replay.
Screenplay schema
Full reference: `desktop.md`. Minimal shape:
{
"schema_version": 2,
"coordinate_space": "window",
"scenes": [
{
"id": "open_settings",
"caption": "Open Settings",
"actions": [
{ "action": "click", "x": 320, "y": 180 },
{ "action": "wait", "ms": 600 }
]
}
]
}Action kinds:
| Action | Required | Optional |
|---|---|---|
wait | ms | - |
move | x, y (or path) | duration_ms; path (polyline [{x,y}…]) to glide a trajectory |
click | x, y | button (left/right/middle) |
double_click | x, y | button |
drag | x, y, to_x, to_y | duration_ms (default 400); button |
pointer_down | x, y | button - press and hold (held moves then drag) |
pointer_move | x, y (or path) | duration_ms; path - move while held = a drawn stroke |
pointer_up | - | x, y (default: current position); button |
type | text | cpm (chars/minute; overrides default cadence - ~7500 cpm HID, ~3750 cpm per-pid; posts Unicode via keyboardSetUnicodeString) |
key | combo | - (cmd+s, shift+tab, escape, f1-f12, home, end, pageup, pagedown, arrows, …) |
scroll | - | dx, dy (line-based wheel deltas) |
pointer_down → pointer_move(path) → pointer_up is one continuous stroke - draw lines/circles/bezier by sampling the curve into path points. The same actions run in the web driver (CDP) - see `web-driver.md`.
coordinate_space: "window" is the agent default - pair with deskagent inspect's window-relative coords for portability.
deskagent control ignores screenplay's editing-only fields (zoom, speed, trim, setup, preflight, ...). Editing scripts read them directly.
Invocation
deskagent control screenplay.json \
[--target-window ID | --target-pid PID --window-frame "x,y"] \
[--background] [--no-activate] \
[--timeout-ms N] [--prompt-permission] \
[--timeline /tmp/timeline.json] [--mouse-path /tmp/mp.json] [--json]| Flag | Purpose |
|---|---|
--target-window ID | Resolve origin via CGWindowListCopyWindowInfo (and auto-raise unless --no-activate). Forbidden during an active recording - see Rule 4. |
--target-pid + --window-frame | Explicit; makes no WindowServer call. Preferred during recording. |
--background | Drive without focus shift. AXPress for clicks, CGEventPostToPid for keys/scroll. |
--no-activate | Skip auto-raising the target app. |
--timeline | Write the execution event array (see `timeline.md`). |
--mouse-path | Write sampled cursor positions (cadence from sample_mouse_ms). |
HID vs --background
| HID (default) | --background | |
|---|---|---|
| User cursor | Moves to each step | Doesn't move |
| Frontmost app | Switches to target | Stays put |
| Clicks | CGEvent.post(.cghidEventTap) | AXUIElementPerformAction(.AXPress) |
| Keys / scroll | Global HID tap | CGEventPostToPid(pid, event) |
| Works on Wails/Electron | Yes (target frontmost) | Yes (WebKit AX bridge) |
| Works on no-AX apps | Yes | Filtered (no-op) |
cmd+X shortcuts | Reliable | Often dropped (AppKit reads flags from global tap) |
HID is the safe default. --background is for "user keeps working in another app" demos.
--background caveats:
- Modifier shortcuts can drop - briefly activate the target for
essential ones, or use a menu/osascript equivalent.
- Apps with no AX exposure won't accept
--backgroundclicks. Validate
with deskagent inspect --window <id> --ax.
- Inter-character pacing: per-PID
typeadds 8 ms per char (target
drains slower than the global tap). HID has zero delay.
- Wails-occluded repaint lag: when the captured window is fully
occluded, the backing layer can lag the actual UI state by several frames. Clicks still register in app state, but the captured pixels may show stale content. Bring the window forward if pixel-accuracy matters.
Timeline output
Full schema: `timeline.md`. Each scene produces scene_start / action (one per executed action) / scene_end events, each tagged with scene_id / scene_index, and (for actions) canonical action_id = "<scene_id>/<action_index>". Editing scripts join screenplay directives against these IDs.
For overlay alignment use the *WallclockMs fields together with the recording's firstFrameWallclockMs. The startedAtMs field is process-local and not anchored to the video.
type MousePathSample = { tMs: number, x: number, y: number }Recording manifest
deskagent record <out-dir> writes <out-dir>/recording.manifest.json after every clip finalizes (current schema version: 1):
{
"version": 1,
"createdAtWallclockMs": 1778883093800,
"fps": 60,
"anchorHostNs": 123456789012345,
"durationSeconds": 18.4,
"clips": [
{
"path": "window-245663.mov",
"kind": "window",
"id": 245663, "pid": 40115,
"app": "MobAI", "bundleID": "run.mobai.app",
"title": "MobAI - Untitled",
"frameCG": [538, 90, 1190, 831],
"pixelSize": [2380, 1662],
"backingScale": 2.0,
"firstFramePtsNs": 0,
"lastFramePtsNs": 18400000000,
"frameCount": 1104,
"droppedFrames": 0,
"startHostNs": 123456789012345,
"endHostNs": 123475189012345,
"startWallclockMs": 1778883093850,
"endWallclockMs": 1778883112250
}
]
}| Field | Meaning |
|---|---|
clips[].path | Relative to the manifest's directory. Always .mov (ProRes 4444). |
clips[].kind | "window" or "display". |
clips[].pixelSize | Encoded video pixel dimensions of this clip. |
clips[].firstFramePtsNs / lastFramePtsNs | File-time PTS of first/last frame (first is always 0). |
clips[].startHostNs / endHostNs | Host-time anchors. Use to compute the shared time window across clips (t0 = max(startHostNs), tEnd = min(endHostNs)). |
clips[].startWallclockMs / endWallclockMs | Wallclock anchors (also human-readable). |
anchorHostNs | max(clips[].startHostNs) - the composited timeline's t=0 in host time. |
createdAtWallclockMs | Wallclock when start completed; human label. |
scripts/export.js reads the manifest, applies the screenplay's composition block via the compose stage, then chains the other editing stages in one ffmpeg pass.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
record writes 0-byte file | kill -9 | Always SIGINT/SIGTERM; the file finalizes in finishWriting. |
cannot enumerate sources | TCC blocks | deskagent doctor; grant Screen Recording. |
Accessibility permission required | TCC blocks input | deskagent doctor --request-accessibility. |
window id <N> not found | IDs rotate per launch | Re-list before record. |
| Wrong window captured | Multiple windows match --app | Add --window-title <substring> or explicit --window ID. |
| Click off on retina | Coords in logical pixels not CG points | Use the values inspect returns directly; don't multiply. |
--quality pro rejected | ProRes is .mov-only | Use .mov. |
cmd+X no-op under --background | AppKit reads flag state from global tap | Activate briefly, or use a menu / osascript path. |
--background click no-op | App has no AX exposure for that element | Drop --background for that step, or osascript the action. |
type'd text never appears in video | type finished too close to SIGINT; WebView didn't redraw | Add a 1–2 s wait after the last type, and sleep 1 between control completion and the SIGINT. |
| Overlays land at the wrong time in the video | Meta sidecar from scripts/stages/compose.js missing firstFrameWallclockMs | Re-run scripts/stages/compose.js; it derives the value from the manifest's host-time anchors. |
| Editing script errors on multi-window meta | Omitted --target-window | Pass --target-window <id> on every editing stage. |
Screenplay format
The screenplay is the single source of truth for a demo: scenes of actions to execute, plus top-level editing directives (zoom, speed, trim) and per-scene caption. deskagent control reads only scenes[].actions[]; the editing scripts read everything.
See `deskagent.md` for the CLI, `timeline.md` for the execution-event schema, `editing.md` for the editing pipeline.
Top-level shape
{
"schema_version": 2,
"name": "demo1",
"coordinate_space": "window",
"timeout_ms": 30000,
"sample_mouse_ms": 16,
"scenes": [ /* ... */ ],
"composition": { /* canvas + per-clip placement; see below */ },
"zoom": [ /* directive entries; see below */ ],
"speed": [ /* directive entries; see below */ ],
"captions": [ /* directive entries; see below */ ],
"highlights": { /* cursor + ripple overrides; see below */ },
"trim": { "beforeScene": "<sceneId>", "afterScene": "<sceneId>" }
}| Field | Required | Purpose |
|---|---|---|
schema_version | yes | Currently 2. Wrong value - hard error. |
coordinate_space | no (screen) | "window" resolves x/y against the executor's window origin - recommended. |
timeout_ms | no | Total budget for the run; override at CLI with --timeout-ms. |
sample_mouse_ms | no | Mouse-path sampling cadence (HID-mode demos). |
scenes | yes | Ordered execution units. May carry per-scene windowId to route action coords to the correct window in multi-window compositions. |
composition | conditional | Required for multi-clip recordings. Drives scripts/stages/compose.js (canvas size, background, per-clip placement, optional auto-layout). Single-clip recordings may omit it. |
zoom | no | Array of zoom directive entries. Each entry's fromAction/toAction is a global ref - ranges may cross scenes. |
speed | no | Array of speed directive entries. Same global-ref shape; ranges may cross scenes but not overlap each other. |
captions | no | Array of caption directive entries, drawn at the bottom of the canvas in a single strip. Same global-ref shape; entries may not overlap in time. |
highlights | no | Override block for cursor sprites + click-ripple. Without it, defaults apply (macOS system cursor sprites + procedural soft expanding ring). |
trim | no | Scene IDs that bound the final video. Defaults: first scene, last scene + 600 ms pad. |
Action IDs are global
Every action gets a global ID <sceneId>/<actionIndex> in the timeline. The directives reference these IDs directly - they're not scoped to any scene. "open_settings/0" works just as well from a zoom entry as from a speed entry, and a single entry can span from one scene's action to a later scene's action.
Scenes
{
"id": "open_settings", // unique within the screenplay
"caption": "Open Settings", // viewer-facing; spans the whole scene
"note": "verify panel state", // author/debug only; never rendered
"actions": [
{ "action": "click", "x": 244.5, "y": 54.5 },
{ "action": "wait", "ms": 600 }
]
}| Field | Required | Purpose |
|---|---|---|
id | yes | Canonical scene reference. Used in actionId = "<sceneId>/<index>" and trim.*Scene. |
windowId | no | When set, action coords in this scene are mapped to this window's placement on the canvas (compose's pointToCanvasPixel). Required if the recording has multiple windows. |
note | no | Skipped by every consumer. |
actions | yes | One or more Action records executed in order. |
(caption on scenes is no longer consumed - captions are a top-level directive array, see below.)
Scenes no longer carry zoom or speed fields. Camera and tempo are timeline-wide concerns and live at the top level.
Actions (execution-only)
{ "action": "wait", "ms": 500 }
{ "action": "move", "x": 1, "y": 2, "duration_ms": 250 }
{ "action": "click", "x": 10, "y": 20, "button": "left" } // button: left | right | middle
{ "action": "double_click", "x": 30, "y": 40 }
{ "action": "drag", "x": 0, "y": 0, "to_x": 100, "to_y": 200, "button": "left" } // button optional, default left
{ "action": "type", "text": "hello", "cpm": 300 }
{ "action": "key", "combo": "cmd+s" } // keys incl. f1-f12, home, end, pageup, pagedown, arrows
{ "action": "scroll", "dx": 0, "dy": -3 }
// Trajectory move + pointer primitives (draw shapes / compose gestures):
{ "action": "move", "path": [ {"x":110,"y":130}, {"x":400,"y":130} ], "duration_ms": 600 } // glide along a polyline
{ "action": "pointer_down", "x": 110, "y": 130 } // press + hold (button optional, default left)
{ "action": "pointer_move", "path": [ ... ], "duration_ms": 800 } // move while held = a drawn stroke
{ "action": "pointer_up" } // release (defaults to current position)move glides over duration_ms; with a path it traces that polyline (constant speed). pointer_down → pointer_move(path) → pointer_up is one continuous stroke - use it to draw lines/circles/bezier (sample the curve into a path). drag is the straight-line shorthand. click/double_click/drag take button: left/right/middle.
These run identically in native deskagent control (CGEvent) and the web driver (scripts/drive-web.js, CDP). The web driver adds page-only actions (navigate, wait_for, scroll_to, scroll_page, selector/text targets, shape sugar) - see `web-driver.md`.
No intent / caption / zoom fields on actions - those live higher up. Action records are pure execution.
coordinate_space is screenplay-wide ("window" or "screen"); the executor adds the resolved window origin at runtime for "window".
Composition
composition drives scripts/stages/compose.js - the first editing stage. It maps each clip in recording.manifest.json to a rect on a shared canvas. Required when the recording has 2+ clips; optional for single-clip recordings (the clip then fills its native pixel size).
"composition": {
"canvas": [1920, 1080], // [W, H] in pixels; required for 2+ clips
"background": "color:1a1a2e", // optional; "none" | "dark" | "light" | "color:RRGGBB" | "image:/path/to/bg.png"
"layout": "side-by-side", // optional; auto-computes slot rects (see below)
"padding": 60, // optional; canvas-pixel gap when layout is set
"elements": [
{ "windowId": 12345 }, // auto rect from layout
{ "windowId": 67890, "weight": 2 }, // wider slot in side-by-side / taller in stack
{ "displayId": 1, "rect": [0, 0, 1920, 1080] } // explicit rect overrides layout
]
}| Field | Required | Notes |
|---|---|---|
canvas | conditional | Required when composition is present and there are 2+ clips. [W, H] in pixels. |
background | no (default none = black) | none (opaque black) · dark / light (subtle vertical gradients, cached to ~/.cache/deskagent-skill/) · color:RRGGBB (solid hex fill) · image:/path/to/bg.png (scale-and-crop to cover the canvas). |
layout | no | When set, slot rects are auto-computed and you can omit rect per element. See layout modes below. |
padding | no (default 60) | Canvas-pixel gap around and between slots. Only used when layout is set. |
upscale | no (default false) | When false, a clip smaller than its slot sits at native pixel size centered in the slot (no scaling). When true, clips are aspect-fit to fill the slot (may upscale, can look soft). |
elements | yes | Array of clip placements. Each entry references a clip via windowId or displayId (matched against the manifest). An explicit rect always wins over the layout-computed slot. Order matters - slots are filled in element order. |
elements[i].rect | conditional | [x, y, w, h] in canvas pixels. Required if layout is omitted; otherwise optional override. |
elements[i].weight | no (default 1) | Proportional slot size; honored by side-by-side (column widths) and stack (row heights). |
elements[i].upscale | no | Per-element override of composition.upscale. |
Inside whichever slot it lands in, the clip is aspect-fitted (letter-/pillarboxed, centered) - never stretched.
Layout modes
layout | Slots |
|---|---|
auto | 1 clip = full canvas (no padding). 2 = side-by-side. 3+ = grid. |
side-by-side | A row of N slots; element weight controls column widths. |
stack | A column of N slots; element weight controls row heights. |
grid | 2-column grid, ceil(N/2) rows. (3 clips → 2×2 with one empty cell.) |
Mixed mode is fine: set layout: "side-by-side", give one element an explicit rect, and the rest fill the auto-computed slots in element order.
Editing directives
Half-open ranges
Every directive entry takes a fromAction / toAction pair. The range is half-open: it starts at fromAction.tStart and ends at toAction.tStart (toAction is excluded). To extend a range "through" some action, point toAction at the next action after it.
zoom
"zoom": [
{
"scale": 2.0, // > 1; required
"follow_cursor": false, // optional; default false
"x": 244.5, "y": 54.5, // optional center; also the implicit "afterMs=0" waypoint for pan
"coordinate_space": "window", // optional; defaults to top-level
"windowId": 12345, // required for window-space centers in multi-window comps
"fromAction": "open_settings/0", // required, global ref
"toAction": "save/0", // required, half-open
"startDelayMs": 0, // optional; offsets fromAction time
"endDelayMs": 0, // optional; offsets toAction time
"pan": [ // optional; mutually exclusive with follow_cursor
{ "afterMs": 1200, "x": 800, "y": 400, "ease": "in_out" },
{ "afterMs": 4000, "x": 200, "y": 150 }
]
}
]| Field | Required | Notes |
|---|---|---|
scale | yes | Numeric zoom factor, must be > 1. |
fromAction | yes | Action ID "sceneId/index". Resolves globally - need not be in the same scene as toAction. |
toAction | yes | Action ID. Excluded from the range. |
follow_cursor | no | If true, camera centers on the synthetic cursor's piecewise-eased path (the same expression the highlights stage uses for the sprite - no desync). Requires at least one click event inside the range. Mutually exclusive with pan. |
x, y | no | Static center. Also serves as the implicit "afterMs=0" waypoint when pan is used. Without it, the first action with x/y inside the range is used. Pure-wait ranges need an explicit x/y (or follow_cursor: true). |
coordinate_space | no | "window" or "screen". Defaults to the top-level setting. |
windowId | conditional | Names the window a window-space center resolves against. Required when the center is window-space and the comp has >1 window; the whole entry (static/start center and every pan waypoint) resolves in that window's space. A directive is not scene-bound, so the scene's windowId is not consulted - this is the only lever. Omitting it in a multi-window comp is a hard error. follow_cursor and the first-action-in-range fallback are exempt (they resolve real actions). To pan across windows, use coordinate_space: "screen". |
startDelayMs / endDelayMs | no | Signed ms offsets on the start/end. Default 0. |
pan | no | Array of waypoints { afterMs, x, y, ease? }. See below. |
Implementation: per-frame scale=...:eval=frame then bounded crop on the canvas. Linear ramp-in / ramp-out at segment edges (RAMP = 0.2 s, auto-clamped to half the segment length so short segments still get both ramps).
pan waypoints
pan lets the camera move within a single zoom segment. Each waypoint moves the camera to a new center, easing from the previous position.
| Field | Required | Notes |
|---|---|---|
afterMs | yes | Time from the zoom's effective start (fromAction.tStart + startDelayMs), where tStart is the action's time as recorded in `timeline.json` (canvas/video seconds). Absolute, not cumulative. Not relative to deskagent control's per-event ms, which start at 0 inside the script and are offset from video time by the recorder + control startup lead-in (~1.4 s). Compute it from timeline.json event times, never the control clock. |
x, y | yes | Target center coordinates in the entry's coordinate_space. All waypoints share the entry's windowId (window-space pans stay in one window). |
ease | no (default in_out) | linear / in / out / in_out. Easing curve into this waypoint. |
`pan` vs `follow_cursor` - use the right one:
follow_cursoris for ranges where clicks happen: the camera tracks
the synthetic cursor as it moves between click targets.
panis for ranges where no clicks happen: a cinematic "look here,
then there" sweep over static UI. The cursor sprite is hidden inside pan ranges (a parked cursor would otherwise sit off the panned view and distract).
Don't author a pan over a range that contains click actions - use follow_cursor for those. The two are mutually exclusive within one entry, and overlapping a pan range with clicks just hides the cursor for those clicks.
Semantics: from t=segment_start until pan[0].afterMs, the camera holds at the entry's x/y (the implicit "start waypoint"). Between waypoints, the camera eases from the previous position to the current one using the destination's ease. After the last waypoint, the camera holds at that position until the zoom segment ends.
Validation: every afterMs must be >= 0 and strictly less than the range's duration (after offsets). Waypoints must be in strictly increasing afterMs order. pan + follow_cursor: true is a hard error.
Continuity across scenes
Two zoom entries that touch in time (one's toAction is the next one's fromAction) both ramp at the join - A ramps out, B ramps in. For ~2 × RAMP (about 400 ms) the combined zoom amount drops, so the camera visibly unzooms and re-zooms.
To keep one continuous camera across multiple scenes, write one zoom entry whose fromAction and toAction span all of them. Use pan waypoints inside that single entry to change focus, or follow_cursor: true to glide between clicks.
speed
"speed": [
{
"factor": 2.5, // > 0 and != 1; required
"fromAction": "fill_list/0", // required, global ref
"toAction": "fill_list/2", // required, half-open
"startDelayMs": 0, // optional
"endDelayMs": 0 // optional
}
]Factor > 1 plays faster, < 1 slower, 1 is rejected. Entries beyond the speed range play at factor 1. scripts/stages/speedups.js builds a piecewise warp; the resulting timewarp.json is consumed by scripts/export.js for trim math.
Validation: speed segments may not overlap (evaluated on the post-offset envelope). Overlapping entries are a hard error.
captions
"captions": [
{ "text": "Press 7",
"fromAction": "press7/0",
"startDelayMs": 0, // optional, signed
"toAction": "plus/0", // EITHER toAction (+ optional endDelayMs)
"endDelayMs": -100 },
{ "text": "Then plus",
"fromAction": "plus/0",
"durationMs": 800, // OR durationMs (mutually exclusive with toAction)
"y": 0.10, // optional position (see below)
"align": "center" }
]Captions render via deskagent text-png (CoreText, sidesteps the missing drawtext in some ffmpeg builds). Default position is a centered bottom strip; y/align/x move individual entries.
| Field | Required | Notes |
|---|---|---|
text | yes | The caption string. |
fromAction | yes | Action ID "sceneId/index" that anchors the start. |
startDelayMs | no | Signed offset on the start, in ms. Default 0. |
toAction | conditional | Action ID that anchors the end. Mutually exclusive with durationMs. |
endDelayMs | no | Signed offset on the end (only with toAction). Default 0. |
durationMs | conditional | Duration from the start. Mutually exclusive with toAction. |
y | no | Canvas-height fraction 0..1 for the caption's bottom edge. Default 0.88 (bottom strip); ~0.10 = top. |
align | no | center (default) / left / right - horizontal placement with a 5% margin. |
x | no | Canvas-width fraction 0..1 for the caption's horizontal center. Overrides align when set. |
Validation: two captions may not overlap in time at the same position (they'd stack illegibly). Different positions can coexist (e.g. a top label over a bottom subtitle). For a same-position clash, shorten the first (endDelayMs/durationMs), push the second (startDelayMs), or move one (y/align/x).
highlights
Optional override block for the editor's cursor sprite and click-ripple. Not used by deskagent control - purely a render-time concern. Full field reference: `editing.md`.
"highlights": {
"ripple": { /* see editing.md#highlights */ },
"cursor": { /* see editing.md#highlights */ }
}cursor (visibility)
Controls when the synthetic cursor is drawn. By default the cursor follows the pointer track (clicks + moves + pointer events) across the whole video. Use hide/show (action ranges, same startDelayMs/endDelayMs offsets as other directives) to gate it - e.g. hide it during a scroll, then let it reappear before the next click.
"cursor": {
"hide": [ { "fromAction": "tour/0", "toAction": "outro/0", "endDelayMs": 1400 } ],
"show": [ /* whitelist: if present, cursor is visible ONLY in these ranges */ ]
}End a hide range a beat before the next click and the cursor reappears already gliding toward it (the path is continuous; hide only gates visibility). Composes with the automatic pan-range hiding. Render-time only - not read by deskagent control. See `editing.md`.
trim (top-level)
"trim": { "beforeScene": "intro", "afterScene": "outro" }Head trim = beforeScene's tStart. Tail trim = afterScene's tEnd + 600 ms. Both fields default to first/last scene.
Validation (Swift, on load)
schema_version == 2. v1 is rejected with a migration hint.- Scene IDs unique.
- All actions have a valid
actionkind (the eight above).
Editing scripts additionally validate:
- Every
zoom/speed/captionsentry hasfromActionAND (for zoom/speed)toAction. - Every referenced action ID resolves.
- Every referenced scene id resolves.
zoom:follow_cursor: truerequires at least one click event in the range.zoom:pan: [...]waypoints must be in strictly increasingafterMsorder and within the range.zoom:panandfollow_cursor: trueare mutually exclusive.zoom: a window-space center (static or pan) in a multi-window comp must set the entry'swindowId(scenes'windowIdis not consulted for directives).speed: factor> 0and!= 1.speed: no overlapping post-offset envelopes.captions: entries don't overlap in time (single shared bottom strip).captions: each entry has eithertoAction(+ optionalendDelayMs) ORdurationMs.- Multi-window composition needs per-scene
windowIdso click coords resolve to the correct window slot.
setup / preflight / validate (informational)
Authors may add free-form setup, preflight, validate arrays at the top level for their own bookkeeping (open-app shell commands, deskagent assert probes, post-demo verifications). Neither deskagent control nor the editing scripts read them - they're notes the agent re-executes manually outside the screenplay.
"setup": [
{ "action": "shell", "cmd": "open -a 'Notes'" },
{ "action": "wait", "ms": 1500 }
],
"preflight": [
{ "assert": "label", "value": "New Note" }
]Don't put setup actions inside a scene unless they're meant on camera.
Window size vs. format
| Format | Recommended window |
|---|---|
horizontal_16_9 (1920x1080) | 1440x900 @ 1x retina |
square_1_1 (1080x1080) | 1080x1080 centered crop |
vertical_9_16 (1080x1920) | 540x960 |
Pick the window size BEFORE exploration so click coords stay valid.
Worked examples:
- `assets/examples/notes-demo.json` - basic scene-bound zoom and speed.
- `assets/examples/continuous-zoom-demo.json` - cross-scene zoom range with
panwaypoints.
Editing & export
Inputs: the recording directory (containing recording.manifest.json and per-source ProRes 4444 .mov clips) + screenplay.json + timeline.json.
Everything runs in one ffmpeg invocation. scripts/export.js collects a filter fragment from each stage, assembles them into one filter_complex, and runs ffmpeg once. The per-source clips are decoded once, the final mp4 is encoded once - no intermediate files in the hot path, no generation loss from per-stage re-encodes.
Stage order
[compose] → [highlights] → [zoom] → [captions] → [speedups] → [final scale/pad] → encodeEach stage is a module in scripts/stages/ exporting two functions:
generate(ctx, { inputLabel }) -> {
filters: ["[in]...[out]", ...], // ffmpeg filter strings, joined with ';'
inputs: ["[in]"], // upstream labels consumed
outputs: "[afterX]", // single label produced
extraInputs: [{ argv: ["-i", "..."] }], // additional ffmpeg `-i` blocks
sidecars: { captions?, timewarp? }, // out-of-band data
}
apply(ctx, inputMov, outputMov) // debug runner; renders only this stage to a ProRes 4444 .movStages reference their own extra inputs by ${capInput<N>} placeholders; the orchestrator substitutes absolute ffmpeg input indices after counting prior stages' extra inputs.
Orchestrator CLI
node scripts/export.js <recordingDir> <screenplay.json> <timeline.json> <out.mp4> [format] \
[--quality standard|high|h264|pro] default: high (HEVC ~200 Mbps via VideoToolbox)
[--width N --height N] explicit output dims (override format)
[--skip <stage>] (repeatable; e.g. --skip captions)
[--dry-run] print ffmpeg cmd without running
[--debug] print the assembled filtergraphformat is optional. When omitted, the export sizes to the user's main display's native pixel resolution (so QuickTime plays the result 1:1 on this machine).
Named formats:
| Format | Size |
|---|---|
display (default when omitted) | NSScreen.mainScreen × backingScale (e.g., 3456×2234 on a 16" MBP) |
horizontal_16_9 | 1920 × 1080 |
square_1_1 | 1080 × 1080 |
vertical_9_16 | 1080 × 1920 |
hd_720 | 1280 × 720 |
uhd_4k | 3840 × 2160 |
--width N --height N overrides any format choice with exact pixel dims.
Quality presets:
--quality | Codec | Notes |
|---|---|---|
standard | hevc_videotoolbox -b:v 50M | Smaller, content-aware encoder may go well below the cap |
high (default) | hevc_videotoolbox -b:v 200M | Same encoder, much higher ceiling |
h264 | libx264 crf=18 veryfast | Wider-compat fallback; soft on sub-pixel UI |
pro | prores_ks profile 3 (422 HQ) | Master / further-editing output; huge files |
Composition canvas (from screenplay.composition.canvas) is letterboxed into the requested output size with scale=W:H:force_original_aspect_ratio=decrease,pad=W:H:(ow-iw)/2:(oh-ih)/2:color=black.
Per-stage debug CLI
Every stage exposes the same shape:
# Print the stage's filter fragment as JSON (no ffmpeg run).
node scripts/stages/<stage>.js generate <recDir> <screenplay> <timeline>
# Render only this stage's effect to a ProRes 4444 .mov, taking <in.mov> as
# its upstream input. Useful to verify what just one stage does.
node scripts/stages/<stage>.js generate <recDir> <screenplay> <timeline> \
--apply <in.mov> <out.mov>For compose, --apply takes only <out.mov> - the inputs are the per-source clips in the recording dir, not a single video.
Stage descriptions
compose
Reads composition from the screenplay, opens each per-source clip as a separate ffmpeg input, trims the head of each by shared.headTrimsByPath (so every clip's t=0 maps to the shared timeline start), aspect-fits inside its placement rect, overlays them onto the canvas background with alpha preserved.
Never upscales by default. When a clip's source pixels fit inside its slot in both axes, it sits at native pixel size centered in the slot (no scaling, sharp). Only scales down when source > slot. Opt-in via composition.upscale: true (or per-element upscale: true) to force fit-to-slot.
Layout helpers (composition.layout):
| Mode | Slots |
|---|---|
auto | 1 clip = full canvas (no padding). 2 = side-by-side. 3+ = grid. |
side-by-side | A row of N slots; element weight controls column widths. |
stack | A column of N slots; element weight controls row heights. |
grid | 2-column grid, ceil(N/2) rows. |
Output label: [afterCompose] (carries alpha).
highlights
Two synthetic overlays on top of the composed canvas:
1. Cursor sprite. An arrow (via deskagent cursor-png --type arrow) drawn along the pointer track - the unified timeline of every positional action (click, move, drag, pointer_*), via cursorWaypointsInCanvasSeconds. Segment easing depends on the waypoint:
- click → auto pre-arrival glide (cubic ease, ≤0.55 s at ~1400 px/s),
arriving exactly at the click so the ripple lands on a still cursor;
- move → glides over the action's own
duration_ms(author-controlled
speed) - so move is the "point the viewer's eye at X" beat;
- `move`/`pointer_move` with a `path` → the polyline is spread across
the duration and interpolated linearly (constant speed), so shapes/ trajectories trace smoothly. The sprite trace is downsampled to ≤24 points per path (the cursor overlay's x/y is one ffmpeg expression term per waypoint, which has a practical size ceiling); the underlying draw/driver still used the full-resolution path. The cursor path is a flat-sum expression (one ramped term per segment), not nested - so density doesn't blow the parser. A pointing-hand sprite (--type pointing) replaces the arrow for ~220 ms around each click (moves/pointer events don't swap or ripple). 2. Click ripple. A procedural soft expanding-ring sprite (alpha .mov generated once via ffmpeg ... geq) overlay'd at each click position with -itsoffset <click.t> so each click plays its own copy. Clicks only.
User overrides on screenplay.highlights:
"highlights": {
"ripple": {
"sprite": "/path/to/anim.mov", // optional override; alpha .mov / APNG / transparent webm
"color": "FFFFFF", // procedural ring color RRGGBB; default white
"size": 160, // procedural sprite longest edge in canvas px; default 160
"durationMs": 520 // procedural sprite duration; default 520
},
"cursor": {
"arrow": "/path/to/arrow.png", // optional; default = deskagent cursor-png --type arrow
"pointing": "/path/to/pointing.png", // optional; default = --type pointing
"size": 64 // longest edge in canvas px; default 64
}
}| Field | Required | Notes |
|---|---|---|
ripple.sprite | no | Custom animated sprite with alpha. When set, color/size/durationMs are ignored. Each click plays one copy starting from PTS 0 via -itsoffset. |
ripple.color | no | Procedural ring fill color (RRGGBB). Default FFFFFF. |
ripple.size | no | Procedural sprite dimensions in canvas pixels. Default 160. |
ripple.durationMs | no | Procedural sprite length. Default 520. |
cursor.arrow | no | Path to a PNG with alpha. |
cursor.pointing | no | Path to a PNG with alpha; shown for ~220 ms around each click, replacing the arrow so the cursor visibly "presses". |
cursor.size | no | Longest edge in canvas pixels (when rendering defaults via deskagent cursor-png). Default 64. |
cursor.hotspotArrow | no | [x, y] in sprite pixels - the pixel that should land EXACTLY on the click point. Default [0, 0] (matches the default arrow whose tip is top-left). Required for custom PNGs whose tip isn't at the corner. |
cursor.hotspotPointing | no | Same idea for the pointing sprite. Defaults to hotspotArrow. |
Cursor visibility - screenplay.cursor gates the sprite:
cursor.hide: [ {fromAction,toAction,startDelayMs?,endDelayMs?}, … ]- cursor
invisible inside these ranges (e.g. hide it during a scroll).
cursor.show: [ … ]- whitelist; if present the cursor is visible ONLY in
these ranges.
Both compose with the automatic pan-range hiding. End a hide range a beat before the next click and the cursor reappears mid-glide toward it.
Implementation notes:
- Arrow's
enable=is `not(click windows) not(pan ranges) visibility
gates; pointing's is (click windows) * gates`. Same path expression for both, so the swap is seamless.
- The pointer track is shared with
zoom'sfollow_cursor: truevia
lib/cursor-path.js - the camera centers on the sprite's actual position (clicks and moves), no desync during glides.
- Procedural ripple sprite is cached to
~/.cache/deskagent-skill/ripple-{size}-{durSec}-{color}.mov; only the first export of a given configuration pays the geq render cost.
- Pre-record: pass
--no-cursortodeskagent recordso the real OS
cursor doesn't fight the synthetic one.
Output label: [afterHighlights].
zoom
Reads screenplay.zoom[]. Each entry creates one segment whose center comes from one of three sources (mutually exclusive):
- Static center -
x/yon the entry, or the first action with
coords in range.
- Follow cursor -
follow_cursor: true. Camera centers on the
synthetic-cursor pointer track (clicks and moves) in range - shared with the highlights stage, so camera and sprite never desync. Needs ≥1 click or move in the range.
- Pan waypoints -
pan: [...]. Explicit list of waypoints
{ afterMs, x, y, ease? }. Camera holds at the segment's start center (the entry's x/y or first action with coords in range), then eases through each waypoint, then holds at the last waypoint until the segment ends.
Use follow_cursor for click-driven ranges and pan for no-click cinematic ranges - don't mix. The highlights stage hides the cursor sprite inside any pan range (a no-click section has no cursor to show; a parked sprite off the panned view just distracts).
Window for window-space centers. When a zoom/pan center is in coordinate_space: "window" (entry-level coordinate_space, or the screenplay default) and the composition has more than one window, the entry must set windowId to name the target window. The whole entry
- the static/start center and every pan waypoint - resolves in that
one window's coordinate space; to travel a pan across windows, use coordinate_space: "screen" instead. The directive's windowId is the only lever here: unlike action coordinates, a directive is not bound to a scene, so the scene's windowId is not consulted. Omitting it in a multi-window comp is a hard error naming the entry. (Single-window comps resolve automatically.) follow_cursor and the first-action-in-range fallback are exempt - they resolve real recorded actions, which already carry their own window.
`afterMs` is video time, relative to the segment start. A waypoint's afterMs is added to the segment's tStart, where tStart is the anchor action's start as it appears in `timeline.json` (canvas/video seconds). It is not relative to deskagent control's own per-event ms, which start at 0 inside the script and are offset from video time by the recorder + control startup lead-in (~1.4 s). Compute afterMs from timeline.json event times (or as a plain delta after the anchor action begins), never from the control script's internal clock.
The per-frame scale filter (eval=frame) scales the whole canvas by the piecewise zoom factor, then bounded crop re-centers back to canvas dims. Linear ease at each segment's edges (RAMP_SEC = 0.2, clamped to half the segment length so short zooms still get both ramps).
Pan easing modes (per waypoint, default in_out):
ease | curve on u ∈ [0,1] |
|---|---|
linear | u |
in | u² (ease in only) |
out | 1 − (1 − u)² (ease out only) |
in_out | cubic ease-in/out |
Output label: [afterZoom].
captions
Reads screenplay.captions[] (top-level directive array - see `desktop.md`). Each entry's text is rendered to a transparent PNG via deskagent text-png and overlay'd with enable=between(t, ...) at its position: y (canvas-height fraction, default 0.88 = bottom), align (center/left/right) or an explicit x center fraction.
Two captions may overlap in time only if they sit at different positions (e.g. a top label over a bottom subtitle); a same-position time-overlap is a hard error.
Output label: [afterCaptions].
speedups
Reads screenplay.speed[]. Builds a piecewise setpts expression that compresses or expands each range by factor. Emits a sidecars.timewarp that export.js uses to size the final output -t correctly (it caps at last.dstEnd + (sourceDuration - last.srcEnd) so any post-warp tail plays at 1× and contributes to duration).
Output label: [afterSpeedups].
Skipping stages
--skip compose is rejected - compose is the source of inputs. Any other stage can be skipped; its output label short-circuits to the previous stage's output.
Sidecars
The orchestrator passes ctx (loaded once via lib/screenplay.js) and collects each stage's sidecars for cross-stage data (e.g., speedups' timewarp consumed by export's duration math). No on-disk intermediate sidecars in the hot path.
What changed vs. the older five-script pipeline
- Five
add_*.jsCLIs +export_video.jscollapsed into oneexport.js
+ five stages/*.js library modules.
- No intermediate mp4s between stages; one decode → one filter graph →
one encode.
- Per-stage
--applyprovides the same "look at one stage's output"
affordance the old chain gave for free.
- Quality / final container is a flag on
export.jsrather than the
fixed libx264 crf=18 the old pipeline re-encoded between every stage.
- Default output size auto-detects the user's display so QuickTime plays
the result 1:1 on this machine.
- Captions moved from per-scene
captionto a top-level
screenplay.captions[] directive array, matching zoom/speed's shape.
Timeline event schema
deskagent control screenplay.json --timeline timeline.json emits a flat JSON array of three event types, in execution order:
type TimelineEvent =
| { type: "scene_start" } & SceneBoundary
| { type: "scene_end" } & SceneBoundary
| { type: "action" } & ActionEvent
type SceneBoundary = {
scene_id: string
scene_index: number
startedAtMs: number // ms since control run start
endedAtMs: number // === startedAtMs for boundary events
startedAtWallclockMs: number // Unix-epoch ms - anchor for video time
endedAtWallclockMs: number
coordinate_space: "window" | "screen"
}
type ActionEvent = SceneBoundary & {
action_id: string // canonical: "${scene_id}/${action_index}"
action_index: number
action: "click" | "double_click" | "drag"
| "type" | "key" | "scroll"
| "wait" | "move"
| "pointer_down" | "pointer_move" | "pointer_up"
x?: number // CG points in coordinate_space (window/screen)
y?: number
path?: { x: number, y: number }[] // trajectory polyline (move/pointer_move);
// the cursor track follows it (linear)
}Example
[
{ "type": "scene_start", "scene_id": "open_settings", "scene_index": 0,
"startedAtMs": 500, "endedAtMs": 500,
"startedAtWallclockMs": 1778883094300, "endedAtWallclockMs": 1778883094300,
"coordinate_space": "window" },
{ "type": "action", "scene_id": "open_settings", "scene_index": 0,
"action_id": "open_settings/0", "action_index": 0, "action": "click",
"startedAtMs": 752, "endedAtMs": 800,
"startedAtWallclockMs": 1778883094552, "endedAtWallclockMs": 1778883094600,
"x": 244.5, "y": 54.5, "coordinate_space": "window" },
{ "type": "scene_end", "scene_id": "open_settings", "scene_index": 0,
"startedAtMs": 850, "endedAtMs": 850,
"startedAtWallclockMs": 1778883094650, "endedAtWallclockMs": 1778883094650,
"coordinate_space": "window" }
]Mapping to video time
Every editing stage anchors via the recording's recording.manifest.json. The editor picks a canvas t=0 from the shared time window across clips (t0 = max(clip.startHostNs) in host time, with the wallclock-equivalent used to convert timeline events):
t0WallMs = wallclock of the clip whose startHostNs == max(startHostNs)
videoSec = (event.startedAtWallclockMs - t0WallMs) / 1000lib/screenplay.js's loadContext does this once; stages call ctx.actionEvents.get(actionId).tStart to read canvas-second timings. The agent never computes this by hand.
Joining timeline to screenplay
Both files agree on scene_id. Action references in screenplay directives (fromAction, toAction) use the canonical action_id = "${scene_id}/${action_index}". Resolution is hard-failure on the editing side - missing IDs print the available IDs and exit non-zero.
Coordinate space
x / y are CG screen points (not pixels) in the declared coordinate_space. Editing stages map to canvas pixels via ctx.pointToCanvasPixel(e) which finds the action's window placement (via the scene's windowId) and applies the placement's fitted rect:
windowSpace: pixel = fit.ox + point * fit.fitW / frameCG.w
screenSpace: pixel = fit.ox + (point - frameCG.x) * fit.fitW / frameCG.wFor multi-window compositions, scenes must carry windowId so the scene's actions are mapped to the correct window's slot on the canvas.
Invariants
1. Events arrive in execution order; *WallclockMs is monotone non-decreasing. 2. Every scene_start is followed by a matching scene_end with the same scene_id / scene_index. Actions in between carry that scene_id. 3. action_id is unique across the timeline (since scene IDs are unique and action indices are local). 4. record_start / record_stop markers are NOT in the timeline - the screenplay doesn't describe them. The recording's recording.manifest.json carries per-clip host-time + wallclock anchors that the editor uses to compute the shared canvas timeline.
Web driver - scripts/drive-web.js
A CDP-based driver for web page content. It replaces deskagent control for browser scenes: it talks to Chrome over the DevTools Protocol (zero deps, Node's built-in WebSocket) and emits the same `timeline.json` contract as deskagent control, so deskagent record and the whole export.js pipeline are unchanged.
Why it exists
deskagent control posts OS-level events (CGEvent / AX). For a browser that means:
- it can drive the browser chrome (tabs, omnibox, buttons) but **not page
content** reliably - the DOM lives in an out-of-process renderer;
- HID mode requires the window focused/frontmost and targets raw pixels.
CDP injects input at the renderer: no focus, no foreground, the real cursor never moves, and you target the DOM by selector. So Chrome can sit unfocused (even behind other windows) and still be driven while SCK records its window.
Division of labor: use the web driver for page content (scroll, click links, fill forms, draw on a web canvas); use deskagent control for the browser's own UI (omnibox, tabs, extensions, menus) - CDP can't touch those. A mixed demo can do both against the same Chrome (native posts to its pid; CDP attaches to its page target).
Launching the debug Chrome
CDP can't attach to an already-running vanilla Chrome (no debugging port). Launch a dedicated instance:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9222 \
--remote-allow-origins='*' \ # REQUIRED on Chrome 111+ or the CDP WebSocket handshake is rejected (403)
--user-data-dir=/tmp/demo/chrome-profile \ # isolated profile (clean, no extensions)
--disable-background-timer-throttling \ # keep an UNFOCUSED window compositing
--disable-renderer-backgrounding \ # at full rate so scroll/animation
--disable-backgrounding-occluded-windows \ # stay smooth while recording
--window-size=1280,840 --window-position=120,90 \
--no-first-run --no-default-browser-check \
--new-window "https://example.com"- The isolated profile is not logged in - private/authed pages 404. For
authed content, point --user-data-dir at a persistent profile you logged into once.
- Even with the anti-throttle flags an unfocused window caps ~50-55 fps during
scroll (vs a locked 60 if focused). Hands-off background recording's tradeoff.
Workflow
Same as the native pipeline, with drive-web.js in place of control:
# 1. pre-load so the recording's first frames aren't stale (optional but clean)
node scripts/drive-web.js prenav.json --cdp-port 9222 --window-frame "120,90,1280,840" --timeline /dev/null
# 2. record the (unfocused) Chrome window
deskagent record ./demo/raw --window <ID> --no-cursor --pid-file /tmp/rec.pid &
# 3. drive page content over CDP -> timeline.json
node scripts/drive-web.js screenplay.json \
--cdp-port 9222 --window-frame "x,y,w,h" \
--timeline ./demo/raw/timeline.json
# 4. stop + export (unchanged)
kill -INT $(cat /tmp/rec.pid); wait
node scripts/export.js ./demo/raw screenplay.json ./demo/raw/timeline.json demo.mp4 --width W --height HGet <ID> and the --window-frame "x,y,w,h" (CG points) from deskagent list.
CLI
node scripts/drive-web.js <screenplay.json>
--cdp-port 9222 # debug port (default 9222)
--window-frame "x,y,w,h" # REQUIRED; CG points from `deskagent list`
--timeline <out.json> # REQUIRED; the timeline.json to emit
[--url-match <substring>] # pick the page target by URL (default: first page)Coordinates
The driver writes window-space CG points to the timeline (what pointToCanvasPixel expects). It derives the browser chrome height as chromeH = window.h - window.innerHeight and maps viewport CSS px → window points as y_window = y_css + chromeH (x is flush-left, 1:1 at 100% page zoom).
So in a screenplay, raw `x`/`y` and all `path`/`shape` points are window CG points; selector/text targets are resolved to their element centre and mapped for you.
Actions
Targets resolve by selector (CSS) or text (+ optional tag, default h1..h6; matches visible elements only, so GitHub's duplicate mobile/desktop rows don't trip it) or raw window x/y.
| Action | Fields | Notes |
|---|---|---|
wait | ms | Idle. |
navigate | url, timeoutMs? | Page.navigate, waits for load. |
wait_for | selector/text, timeoutMs? | Poll until the element exists. |
scroll_to | selector/text, smooth? | scrollIntoView (block:center); settles by polling the element's own rect, so it works in inner scroll containers (e.g. GitHub blob view). Emits the element centre. |
scroll | dx/dy, settleMs? | Relative scrollBy (smooth). |
scroll_page | dy?, durationMs? | One continuous rAF scroll (easeInOutQuad) over durationMs; omit dy to scroll to the bottom. Use ONE for a smooth page tour (don't chain several - that stutters). |
click | target, scroll?, settleMs? | Real mousePressed/Released. Drives a click event (ripple + pointer-hand in the editor). |
move | target or path/shape, durationMs?, scroll? | Non-clicking cursor waypoint to draw the eye. durationMs = glide time. With path/shape, traces a trajectory (see below). |
pointer_down | target | Press and hold (button state on). |
pointer_move | target or path/shape, durationMs? | Move while held (drag) - compose strokes/gestures. |
pointer_up | target? | Release (defaults to current position). |
key | key | Escape/Enter/Tab/ArrowUp/Down/PageUp/Down. |
type | text, selector?, perCharMs? | Input.insertText per char; focuses selector first. |
Trajectories (path / shape)
move and pointer_move accept a trajectory instead of a single point:
{ "action": "pointer_move", "path": [ {"x":110,"y":130}, {"x":400,"y":130} ], "duration_ms": 600 } // polyline (window pts)
{ "action": "move", "shape": "circle", "cx": 640, "cy": 470, "r": 180, "points": 48, "duration_ms": 2200 }
{ "action": "move", "shape": "line", "x1": 100, "y1": 100, "x2": 400, "y2": 100 }shape is authoring sugar (circle params: cx,cy,r,points?,turns?, startDeg?,ccw?; line: x1,y1,x2,y2) - it compiles to a polyline; the timeline only ever carries points. The cursor traces the polyline at constant speed over duration_ms.
Drawing on a web canvas (e.g. jsPaint, Excalidraw): select the tool, then pointer_down → pointer_move(path/shape) → pointer_up is one continuous stroke. Same vocabulary as native control, so screenplays port across native and web.
node_modules/
out/
# the recording you drop in is not part of the template
public/rec.*
public/recording.manifest.json
public/timeline.json
public/screenplay.json
{
"name": "deskagent-remotion-template",
"version": "0.1.0",
"private": true,
"scripts": {
"studio": "remotion studio",
"render": "remotion render Demo out/video.mp4"
},
"dependencies": {
"@remotion/cli": "4.0.465",
"remotion": "4.0.465",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@types/react": "19.1.0",
"typescript": "5.5.4"
}
}
Put the recording contract here: rec.mp4, recording.manifest.json, timeline.json, screenplay.json
Remotion bridge template
Turn a deskagent recording into a Remotion motion-graphics video. The bridge (src/bridge/) does the integration; you only edit the creative composition (src/Demo.tsx).
This is an optional path, separate from the lean ffmpeg export.js pipeline. It needs Node + Remotion installed (heavier deps, headless Chrome). Use it when you want React-grade motion graphics around the footage.
Use
1. Copy this folder into a working dir and npm install. 2. Feed the recording into public/ (the contract from deskagent record + the web/native driver):
public/rec.mp4- the clip, transcoded to a browser codec:
ffmpeg -i raw/window-<id>.mov -vf scale=<w>:<h> -c:v libx264 -pix_fmt yuv420p -an public/rec.mp4
public/recording.manifest.json- from the recording dirpublic/timeline.json- from the driver (deskagent controlordrive-web.js)public/screenplay.json- optional, for captions
3. Render: npx remotion render Demo out/video.mp4 (first run: npx remotion browser ensure to fetch the headless shell). 4. Iterate in npx remotion studio.
The bridge (src/bridge/)
loadRecording(fps, {speed?, videoFile?})- reads the contract (via
fetch(staticFile(...)), so it runs in calculateMetadata) and returns frame-indexed props: fps, durationInFrames, speed, stageWidth/Height (recording window in CG points), videoSrc, events (clicks/moves as {frame,x,y}), scenes, captions. speed > 1 plays the footage faster - duration and event frames are scaled down to match; pass rec.speed to <RecordingCard playbackRate> so the cursor stays synced with the video.
<RecordingStage width height>- the recording's coordinate space. Put the
card + cursor + ripples inside; the creative comp scales/positions the stage as one unit and everything stays aligned with the footage.
<RecordingCard src width height playbackRate?>- the<OffthreadVideo>, framed.<Cursor events>/<ClickRipple events>- timeline-driven, in stage coords.<Caption captions>- output-canvas captions (y/alignhonored).
Root.tsx wires loadRecording into calculateMetadata, so the composition duration comes from the recording and Demo receives the parsed rec prop. Coordinates: timeline x/y are recording-window CG points = the video's pixel space, so cursor/ripples line up without extra mapping.
import {Config} from '@remotion/cli/config';
Config.setVideoImageFormat('jpeg');
import React from 'react';
import {useCurrentFrame, useVideoConfig, spring, interpolate} from 'remotion';
import {RecCaption} from './loadRecording';
// Output-canvas captions (not inside the stage): positioned by `y` fraction +
// `align`, springing in at startFrame, gone at endFrame.
export const Caption: React.FC<{captions: RecCaption[]; accent?: string}> = ({
captions, accent = '#7c5cff',
}) => {
const frame = useCurrentFrame();
const {fps, height} = useVideoConfig();
return (
<>
{captions
.filter((c) => frame >= c.startFrame && frame < c.endFrame)
.map((c, i) => {
const pop = spring({frame: frame - c.startFrame, fps, config: {damping: 12}});
const justify = c.align === 'left' ? 'flex-start' : c.align === 'right' ? 'flex-end' : 'center';
return (
<div
key={i}
style={{
position: 'absolute', left: 0, right: 0, top: height * c.y,
display: 'flex', justifyContent: justify, padding: '0 6%',
transform: `translateY(${interpolate(pop, [0, 1], [24, 0])}px)`,
opacity: interpolate(pop, [0, 1], [0, 1]),
}}
>
<span style={{
fontSize: 38, fontWeight: 700, color: 'white', fontFamily: 'Inter, system-ui, sans-serif',
background: 'rgba(124,92,255,0.22)', border: `1px solid ${accent}88`,
padding: '12px 30px', borderRadius: 999, backdropFilter: 'blur(8px)',
}}>
{c.text}
</span>
</div>
);
})}
</>
);
};
import React from 'react';
import {useCurrentFrame, useVideoConfig, interpolate} from 'remotion';
import {RecEvent} from './loadRecording';
// Expanding-ring ripple at each click. Place inside <RecordingStage>.
export const ClickRipple: React.FC<{events: RecEvent[]; durationMs?: number; color?: string}> = ({
events, durationMs = 550, color = 'rgba(255,255,255,0.9)',
}) => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const dur = (durationMs / 1000) * fps;
return (
<>
{events
.filter((e) => e.kind === 'click' && frame >= e.frame && frame < e.frame + dur)
.map((e, i) => {
const local = frame - e.frame;
const r = interpolate(local, [0, dur], [6, 70]);
const opacity = interpolate(local, [0, dur], [0.8, 0]);
return (
<div
key={i}
style={{
position: 'absolute', left: e.x - r, top: e.y - r, width: r * 2, height: r * 2,
borderRadius: '50%', border: `3px solid ${color}`, opacity,
}}
/>
);
})}
</>
);
};
import React from 'react';
import {useCurrentFrame} from 'remotion';
import {cursorAt, RecEvent} from './loadRecording';
// Synthetic cursor that follows the pointer track (clicks + moves) in stage
// coords. Place inside <RecordingStage>. Pure JS per frame - none of the
// ffmpeg-expression limits of the built-in pipeline.
export const Cursor: React.FC<{events: RecEvent[]; size?: number}> = ({events, size = 26}) => {
const frame = useCurrentFrame();
const pos = cursorAt(events, frame);
if (!pos) return null;
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
style={{position: 'absolute', left: pos.x, top: pos.y, filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))'}}
>
<path d="M3 2 L3 20 L8 15 L11.5 22 L14 21 L10.5 14 L17 14 Z" fill="white" stroke="black" strokeWidth="1.2" strokeLinejoin="round" />
</svg>
);
};
export {loadRecording, cursorAt} from './loadRecording';
export type {Recording, RecEvent, RecScene, RecCaption} from './loadRecording';
export {RecordingStage} from './RecordingStage';
export {RecordingCard} from './RecordingCard';
export {Cursor} from './Cursor';
export {ClickRipple} from './ClickRipple';
export {Caption} from './Caption';
// The bridge: turn the deskagent recording contract (recording.manifest.json +
// timeline.json + optional screenplay.json) into Remotion-ready, frame-indexed
// props. Runs in `calculateMetadata` (Node at render, browser in Studio), so it
// reads via fetch(staticFile(...)) - put the three JSON files in public/.
//
// Coordinate space: events keep the recording's window CG-point coords (the
// same space the video pixels live in), so a <Cursor>/<ClickRipple> placed
// inside <RecordingStage> lines up with the footage regardless of how the
// creative composition scales/positions the stage.
import {staticFile} from 'remotion';
export type RecEvent = {kind: 'click' | 'move'; frame: number; x: number; y: number};
export type RecScene = {id: string; startFrame: number; endFrame: number};
export type RecCaption = {text: string; startFrame: number; endFrame: number; y: number; align: string};
export type Recording = {
fps: number;
durationInFrames: number;
speed: number; // playback speedup; events + duration are already scaled by it
stageWidth: number; // recording window, CG points
stageHeight: number;
videoSrc: string;
events: RecEvent[];
scenes: RecScene[];
captions: RecCaption[];
};
async function getJSON(file: string): Promise<any | null> {
try {
const res = await fetch(staticFile(file));
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export async function loadRecording(
fps = 30,
opts: {speed?: number; videoFile?: string} = {},
): Promise<Recording> {
const speed = opts.speed ?? 1;
const videoFile = opts.videoFile ?? 'rec.mp4';
const manifest = await getJSON('recording.manifest.json');
const timeline = (await getJSON('timeline.json')) ?? [];
const screenplay = await getJSON('screenplay.json');
if (!manifest) throw new Error('public/recording.manifest.json not found');
const clip = manifest.clips[0];
const t0 = clip.startWallclockMs;
const endWall =
clip.endWallclockMs && clip.endWallclockMs > 0 ? clip.endWallclockMs : t0 + clip.lastFramePtsNs / 1e6;
// speed > 1 plays the footage faster: scale duration + event frames down,
// and the card plays the video at the matching playbackRate so they stay synced.
const durationInFrames = Math.max(1, Math.round((((endWall - t0) / 1000) * fps) / speed));
const [, , stageWidth, stageHeight] = clip.frameCG; // window size in CG points
const toFrame = (wallMs: number) => Math.round((((wallMs - t0) / 1000) * fps) / speed);
const events: RecEvent[] = [];
const scenes: RecScene[] = [];
const sceneStart: Record<string, number> = {};
const actionFrame: Record<string, number> = {};
for (const e of timeline) {
if (e.type === 'scene_start') {
sceneStart[e.scene_id] = toFrame(e.startedAtWallclockMs);
} else if (e.type === 'scene_end') {
scenes.push({id: e.scene_id, startFrame: sceneStart[e.scene_id] ?? 0, endFrame: toFrame(e.endedAtWallclockMs)});
} else if (e.type === 'action') {
const startF = toFrame(e.startedAtWallclockMs);
const endF = toFrame(e.endedAtWallclockMs);
actionFrame[e.action_id] = startF;
const positional = e.action === 'click' || e.action === 'move' || (e.action || '').startsWith('pointer');
if (positional && Array.isArray(e.path) && e.path.length > 0) {
// Expand the trajectory polyline into per-point events spread across
// the action's duration, so the cursor traces it smoothly instead of
// jumping start->end. Full resolution is fine here - it's JS per frame,
// not an ffmpeg expression.
const n = e.path.length;
e.path.forEach((p: any, j: number) => {
const fr = n === 1 ? startF : Math.round(startF + (j / (n - 1)) * (endF - startF));
events.push({kind: 'move', frame: fr, x: p.x, y: p.y});
});
} else if (positional && e.x != null && e.y != null) {
events.push({kind: e.action === 'click' ? 'click' : 'move', frame: startF, x: e.x, y: e.y});
}
}
}
events.sort((a, b) => a.frame - b.frame);
const captions: RecCaption[] = [];
if (screenplay && Array.isArray(screenplay.captions)) {
for (const c of screenplay.captions) {
const from = actionFrame[c.fromAction];
if (from == null) continue;
const start = from + Math.round(((c.startDelayMs || 0) / 1000) * fps);
let end: number;
if (c.toAction != null && actionFrame[c.toAction] != null) {
end = actionFrame[c.toAction] + Math.round(((c.endDelayMs || 0) / 1000) * fps);
} else if (c.durationMs != null) {
end = start + Math.round((c.durationMs / 1000) * fps);
} else {
continue;
}
captions.push({text: c.text, startFrame: start, endFrame: end, y: c.y ?? 0.88, align: c.align ?? 'center'});
}
}
return {fps, durationInFrames, speed, stageWidth, stageHeight, videoSrc: staticFile(videoFile), events, scenes, captions};
}
// Cursor position at a frame: linear interpolation between waypoints (matches
// the editor's pointer track), parked before the first / after the last.
export function cursorAt(events: RecEvent[], frame: number): {x: number; y: number} | null {
if (events.length === 0) return null;
if (frame <= events[0].frame) return {x: events[0].x, y: events[0].y};
const last = events[events.length - 1];
if (frame >= last.frame) return {x: last.x, y: last.y};
for (let i = 0; i < events.length - 1; i++) {
const a = events[i], b = events[i + 1];
if (frame >= a.frame && frame < b.frame) {
const t = (frame - a.frame) / Math.max(1, b.frame - a.frame);
return {x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t};
}
}
return {x: last.x, y: last.y};
}
import React from 'react';
import {OffthreadVideo} from 'remotion';
// The recording itself, filling the stage, framed as a rounded card.
export const RecordingCard: React.FC<{
src: string;
width: number;
height: number;
radius?: number;
playbackRate?: number;
}> = ({src, width, height, radius = 18, playbackRate = 1}) => {
return (
<div
style={{
position: 'absolute',
inset: 0,
borderRadius: radius,
overflow: 'hidden',
boxShadow: '0 50px 120px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.08)',
background: '#0b1020',
}}
>
<OffthreadVideo src={src} playbackRate={playbackRate} style={{width, height, display: 'block'}} />
</div>
);
};
import React from 'react';
// Sets up the recording's coordinate space (window CG points). Place
// <RecordingCard>, <Cursor>, <ClickRipple> inside it - they share these coords,
// so the creative composition can scale/position/animate the stage as one unit
// and everything stays aligned with the footage.
export const RecordingStage: React.FC<{
width: number;
height: number;
children: React.ReactNode;
}> = ({width, height, children}) => {
return <div style={{position: 'relative', width, height}}>{children}</div>;
};
// The creative composition - this is the file the agent edits. All the
// recording plumbing (parsing the contract, frame/coord mapping, cursor,
// ripples, captions) comes from ./bridge; here we only do the look.
import React from 'react';
import {AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring} from 'remotion';
import {Recording, RecordingStage, RecordingCard, Cursor, ClickRipple, Caption} from './bridge';
const ACCENT = '#7c5cff';
const Blob: React.FC<{x: number; y: number; size: number; color: string; speed: number; phase: number}> = ({x, y, size, color, speed, phase}) => {
const f = useCurrentFrame();
return (
<div style={{
position: 'absolute', left: x + Math.sin(f * speed + phase) * 80, top: y + Math.cos(f * speed * 0.8 + phase) * 60,
width: size, height: size, borderRadius: '50%', background: color, filter: 'blur(90px)', opacity: 0.5,
}} />
);
};
export const Demo: React.FC<{rec?: Recording}> = ({rec}) => {
const frame = useCurrentFrame();
const {fps, width, height, durationInFrames} = useVideoConfig();
if (!rec) return null;
const hue = interpolate(frame, [0, durationInFrames], [230, 320]);
const bg = `radial-gradient(circle at 30% 20%, hsl(${hue},45%,18%), hsl(${hue + 40},55%,7%))`;
// Fit the recording into the canvas, then spring it in and let it float/tilt.
const margin = 150;
const fit = Math.min((width - margin * 2) / rec.stageWidth, (height - margin * 2) / rec.stageHeight);
const enter = spring({frame, fps, config: {damping: 14, mass: 0.9}});
const scale = fit * interpolate(enter, [0, 1], [0.7, 1]);
const floatY = Math.sin(frame / 22) * 12;
const tiltY = Math.sin(frame / 40) * 6;
const titleOut = interpolate(frame, [50, 70], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'});
const titleIn = spring({frame: frame - 4, fps, config: {damping: 16}});
return (
<AbsoluteFill style={{background: bg, fontFamily: 'Inter, system-ui, sans-serif', overflow: 'hidden'}}>
<Blob x={-100} y={-120} size={620} color={ACCENT} speed={0.012} phase={0} />
<Blob x={width - 500} y={height - 520} size={680} color="#21d4fd" speed={0.01} phase={2} />
<div style={{
position: 'absolute', top: 64, width: '100%', textAlign: 'center',
opacity: interpolate(titleIn, [0, 1], [0, 1]) * titleOut,
transform: `translateY(${interpolate(titleIn, [0, 1], [40, 0])}px)`,
}}>
<div style={{fontSize: 70, fontWeight: 800, color: 'white', letterSpacing: -2}}>
deskagent <span style={{color: ACCENT}}>×</span> Remotion
</div>
</div>
<AbsoluteFill style={{justifyContent: 'center', alignItems: 'center'}}>
<div style={{perspective: 1600}}>
<div style={{transform: `scale(${scale}) translateY(${floatY}px) rotateY(${tiltY}deg)`}}>
<RecordingStage width={rec.stageWidth} height={rec.stageHeight}>
<RecordingCard src={rec.videoSrc} width={rec.stageWidth} height={rec.stageHeight} />
<ClickRipple events={rec.events} color={ACCENT} />
<Cursor events={rec.events} size={30} />
</RecordingStage>
</div>
</div>
</AbsoluteFill>
<Caption captions={rec.captions} accent={ACCENT} />
</AbsoluteFill>
);
};
import {registerRoot} from 'remotion';
import {RemotionRoot} from './Root';
registerRoot(RemotionRoot);
import {Composition} from 'remotion';
import {Demo} from './Demo';
import {loadRecording} from './bridge';
// calculateMetadata runs the bridge loader (Node at render / browser in Studio)
// and feeds the parsed recording in as props; it also sets the duration from
// the recording length, so the agent never hand-counts frames.
export const RemotionRoot = () => (
<Composition
id="Demo"
component={Demo}
fps={30}
width={1920}
height={1080}
durationInFrames={300}
defaultProps={{}}
calculateMetadata={async () => {
const rec = await loadRecording(30);
return {durationInFrames: rec.durationInFrames, props: {rec}};
}}
/>
);
{
"compilerOptions": {
"target": "ES2020", "module": "ESNext", "jsx": "react-jsx",
"esModuleInterop": true, "skipLibCheck": true, "strict": false,
"moduleResolution": "bundler", "lib": ["ES2020", "DOM"]
},
"include": ["src"]
}
#!/usr/bin/env node
// Web-driver adapter: drives a Chrome page over the DevTools Protocol and emits
// a timeline.json in the same contract as `deskagent control`, so the editor
// (export.js) aligns the synthetic cursor / zoom / captions to web actions.
//
// CDP injects input at the renderer level: no OS focus, no foreground, the real
// cursor never moves. That's the whole point - Chrome can sit unfocused (even
// behind other windows) and still be driven while `deskagent record` captures
// its window.
//
// Zero dependencies: speaks CDP over Node's built-in WebSocket (Node >= 22).
//
// Coordinates written to the timeline are window-space CG points - viewport CSS
// pixels plus the browser chrome height - matching pointToCanvasPixel() in
// lib/screenplay.js (which normalizes window-space x/y by the clip's CG-point
// dimensions). CSS px == CG points at 100% page zoom on the captured window.
//
// Usage:
// node drive-web.js <screenplay.json> \
// --cdp-port 9222 \
// --window-frame "x,y,w,h" (CG points, from `deskagent list`) \
// --timeline out/timeline.json \
// [--url-match github.com] (substring to pick the page target)
const http = require("http");
const fs = require("fs");
function fatal(msg) { console.error(`error: ${msg}`); process.exit(5); }
const sleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)));
// ---------------------------------------------------------------------------
// args
// ---------------------------------------------------------------------------
function parseArgs(argv) {
const a = { cdpPort: 9222, urlMatch: null, screenplay: null, timeline: null, windowFrame: null };
const rest = [];
for (let i = 0; i < argv.length; i++) {
const t = argv[i];
if (t === "--cdp-port") a.cdpPort = Number(argv[++i]);
else if (t === "--url-match") a.urlMatch = argv[++i];
else if (t === "--timeline") a.timeline = argv[++i];
else if (t === "--window-frame") a.windowFrame = argv[++i];
else rest.push(t);
}
a.screenplay = rest[0];
if (!a.screenplay) fatal("screenplay path is required");
if (!a.timeline) fatal("--timeline <path> is required");
if (!a.windowFrame) fatal('--window-frame "x,y,w,h" is required (CG points from `deskagent list`)');
const wf = a.windowFrame.split(",").map(Number);
if (wf.length !== 4 || wf.some((n) => !Number.isFinite(n))) fatal(`bad --window-frame: ${a.windowFrame}`);
a.frame = { x: wf[0], y: wf[1], w: wf[2], h: wf[3] };
return a;
}
// ---------------------------------------------------------------------------
// minimal CDP client over the built-in WebSocket
// ---------------------------------------------------------------------------
class CDP {
constructor(wsUrl) {
this.wsUrl = wsUrl;
this.id = 0;
this.pending = new Map();
this.listeners = new Map();
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsUrl);
this.ws.addEventListener("open", () => resolve());
this.ws.addEventListener("error", (e) => reject(new Error(`CDP socket error: ${e.message || e}`)));
this.ws.addEventListener("message", (ev) => this._onMessage(ev.data));
});
}
_onMessage(data) {
let msg;
try { msg = JSON.parse(data); } catch { return; }
if (msg.id != null && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
if (msg.error) reject(new Error(`${msg.error.message} (${msg.error.code})`));
else resolve(msg.result);
} else if (msg.method) {
const cbs = this.listeners.get(msg.method);
if (cbs) for (const cb of cbs) cb(msg.params);
}
}
send(method, params = {}) {
const id = ++this.id;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, method, params }));
});
}
on(method, cb) {
if (!this.listeners.has(method)) this.listeners.set(method, []);
this.listeners.get(method).push(cb);
}
once(method, timeoutMs) {
return new Promise((resolve, reject) => {
const to = setTimeout(() => reject(new Error(`timed out waiting for ${method}`)), timeoutMs);
const cb = (p) => { clearTimeout(to); resolve(p); };
this.on(method, cb);
});
}
close() { try { this.ws.close(); } catch {} }
}
function httpGetJSON(url) {
return new Promise((resolve, reject) => {
// Don't override the Host header: Chrome echoes it back into the
// webSocketDebuggerUrl, so a portless Host yields a portless ws:// URL.
http.get(url, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
}).on("error", reject);
});
}
async function discoverPage(port, urlMatch) {
const targets = await httpGetJSON(`http://127.0.0.1:${port}/json/list`);
const pages = targets.filter((t) => t.type === "page" && t.webSocketDebuggerUrl);
if (pages.length === 0) fatal(`no page targets on CDP port ${port} (is Chrome launched with --remote-debugging-port=${port}?)`);
const pick = urlMatch ? pages.find((p) => (p.url || "").includes(urlMatch)) : pages[0];
if (!pick) fatal(`no page target whose URL matches "${urlMatch}" (have: ${pages.map((p) => p.url).join(", ")})`);
// Normalize the ws authority to the known loopback:port - Chrome derives it
// from the request Host header, which can come back without the port.
const u = new URL(pick.webSocketDebuggerUrl);
u.host = `127.0.0.1:${port}`;
pick.webSocketDebuggerUrl = u.toString();
return pick;
}
// Build a polyline (array of window-space {x,y}) from an action's path/shape.
// Returns null for a plain single-point action. Shapes are authoring sugar
// that compile down to a polyline - the timeline only ever carries points.
function buildPath(act) {
if (Array.isArray(act.path)) return act.path.map((p) => ({ x: Number(p.x), y: Number(p.y) }));
if (act.shape === "circle") {
const cx = Number(act.cx), cy = Number(act.cy), r = Number(act.r);
const seg = Math.max(8, Number(act.points ?? 48));
const turns = Number(act.turns ?? 1);
const start = Number(act.startDeg ?? -90) * Math.PI / 180;
const dir = act.ccw ? -1 : 1;
const total = Math.max(1, Math.round(seg * turns));
const pts = [];
for (let i = 0; i <= total; i++) {
const a = start + dir * (i / seg) * 2 * Math.PI;
pts.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
return pts;
}
if (act.shape === "line") {
return [{ x: Number(act.x1), y: Number(act.y1) }, { x: Number(act.x2), y: Number(act.y2) }];
}
return null;
}
// ---------------------------------------------------------------------------
// driver
// ---------------------------------------------------------------------------
async function main() {
const args = parseArgs(process.argv.slice(2));
const screenplay = JSON.parse(fs.readFileSync(args.screenplay, "utf8"));
if (!Array.isArray(screenplay.scenes)) fatal(`screenplay missing "scenes" array`);
const page = await discoverPage(args.cdpPort, args.urlMatch);
const cdp = new CDP(page.webSocketDebuggerUrl);
await cdp.connect();
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");
await cdp.send("DOM.enable");
// Force 100% page zoom so CSS px == CG points for the coord mapping.
await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 1 }).catch(() => {});
const eval_ = async (expression) => {
const r = await cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.text || "evaluate threw");
return r.result.value;
};
const viewport = await eval_("({iw: window.innerWidth, ih: window.innerHeight, dpr: window.devicePixelRatio})");
// Browser chrome (tabs + toolbar) height in CG points: total window minus
// the web content viewport. Content is flush-left, so x offset is ~0.
const chromeH = args.frame.h - viewport.ih;
const winPoint = (cssX, cssY) => [cssX, chromeH + cssY];
// Pointer state for down -> move -> up gestures, and last cursor position.
let buttons = 0;
let lastCss = [Math.round(viewport.iw / 2), Math.round(viewport.ih / 2)];
// Resolve an action's target to viewport CSS px (selector/text/raw/last).
async function resolveCss(act) {
let c;
if (act.selector || act.text != null) {
const b = await boxOf(act, { scroll: act.scroll !== false, smooth: act.smooth !== false });
c = [b.x + b.w / 2, b.y + b.h / 2];
} else if (act.x != null) {
c = [act.x, act.y - chromeH];
} else {
c = lastCss;
}
lastCss = c;
return c;
}
// Carries the current button state, so a held button makes this a drag.
// Densify ~6px between consecutive points: a sparse path (e.g. a 2-point
// line) must emit enough move events for the page to draw incrementally,
// otherwise the stroke jumps straight to the end and pops in fully drawn.
async function hoverAlong(path, durationMs) {
const dense = [[path[0].x, path[0].y - chromeH]];
for (let i = 1; i < path.length; i++) {
const prev = [path[i - 1].x, path[i - 1].y - chromeH];
const cur = [path[i].x, path[i].y - chromeH];
const steps = Math.max(1, Math.round(Math.hypot(cur[0] - prev[0], cur[1] - prev[1]) / 6));
for (let s = 1; s <= steps; s++) {
dense.push([prev[0] + (cur[0] - prev[0]) * s / steps, prev[1] + (cur[1] - prev[1]) * s / steps]);
}
}
const stepMs = durationMs / Math.max(1, dense.length - 1);
for (const css of dense) {
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: css[0], y: css[1], buttons });
lastCss = css;
await sleep(stepMs);
}
}
// --- per-action primitives ----------------------------------------------
// A locator is { selector } (CSS) or { text, tag? } (element whose text
// matches; tag defaults to headings). Returns a JS expression yielding the
// element or null.
function elementJS(loc) {
if (loc.selector) return `document.querySelector(${JSON.stringify(loc.selector)})`;
if (loc.text != null) {
const tag = JSON.stringify(loc.tag || "h1,h2,h3,h4,h5,h6");
const t = JSON.stringify(String(loc.text));
// Visible only: GitHub renders duplicate mobile/desktop rows, and a
// hidden duplicate has a zero box -> clicking it would miss.
return `(() => { const els = [...document.querySelectorAll(${tag})]`
+ `.filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; });`
+ ` return els.find(e => e.textContent.trim() === ${t})`
+ ` || els.find(e => e.textContent.trim().includes(${t})) || null; })()`;
}
return "null";
}
const locDesc = (loc) => loc.selector ? `selector "${loc.selector}"` : `text "${loc.text}"`;
async function mouseClick(cssX, cssY) {
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: cssX, y: cssY, buttons: 0 });
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x: cssX, y: cssY, button: "left", buttons: 1, clickCount: 1 });
await sleep(35);
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: cssX, y: cssY, button: "left", buttons: 0, clickCount: 1 });
}
// Resolve a locator's on-screen box (CSS px, viewport-relative). Optionally
// scroll it into view (smooth) first, settling so the recording catches it.
async function boxOf(loc, { scroll = false, smooth = true } = {}) {
const E = elementJS(loc);
const found = await eval_(`!!(${E})`);
if (!found) throw new Error(`element not found by ${locDesc(loc)}`);
if (scroll) {
await eval_(`(${E}).scrollIntoView({behavior: ${smooth ? "'smooth'" : "'auto'"}, block: 'center', inline: 'center'})`);
// settle: poll the element's own viewport top until stable. Works whether
// the page scrolls window or an inner container (GitHub blob view does).
let lastTop = NaN;
for (let i = 0; i < 60; i++) {
const top = await eval_(`(() => { const e = ${E}; return e ? e.getBoundingClientRect().top : 0; })()`);
if (Number.isFinite(lastTop) && Math.abs(top - lastTop) < 0.5) break;
lastTop = top; await sleep(40);
}
await sleep(150);
}
return eval_(`(() => { const e = ${E}; const b = e.getBoundingClientRect(); return {x: b.x, y: b.y, w: b.width, h: b.height}; })()`);
}
async function waitFor(loc, timeoutMs = 8000) {
const E = elementJS(loc);
const deadline = Date.now() + timeoutMs;
for (;;) {
if (await eval_(`!!(${E})`)) return;
if (Date.now() > deadline) throw new Error(`wait_for timed out: ${locDesc(loc)}`);
await sleep(120);
}
}
// --- timeline -------------------------------------------------------------
const t0 = Date.now();
const events = [];
const elapsed = () => Date.now() - t0;
const SPACE = "window";
const pushScene = (kind, sceneId, sceneIndex) => {
const ms = elapsed(), wall = Date.now();
events.push({
type: kind, scene_id: sceneId, scene_index: sceneIndex,
action_id: null, action_index: null, action: null,
startedAtMs: ms, endedAtMs: ms,
startedAtWallclockMs: wall, endedAtWallclockMs: wall,
x: null, y: null, coordinate_space: SPACE,
});
};
// --- dispatch one action, return { x, y, path? } in window space ---------
async function runAction(act) {
switch (act.action) {
case "wait":
await sleep(act.ms ?? 0);
return { x: null, y: null };
case "navigate": {
const loaded = cdp.once("Page.loadEventFired", act.timeoutMs ?? 15000).catch(() => {});
await cdp.send("Page.navigate", { url: act.url });
await loaded;
await sleep(300);
return { x: null, y: null };
}
case "wait_for":
await waitFor(act, act.timeoutMs);
return { x: null, y: null };
case "scroll_to": {
const b = await boxOf(act, { scroll: true, smooth: act.smooth !== false });
const [x, y] = winPoint(b.x + b.w / 2, b.y + b.h / 2);
return { x, y };
}
case "scroll": {
// relative scroll in CSS px; dy>0 scrolls down (content up)
await eval_(`window.scrollBy({top: ${Number(act.dy ?? 0)}, left: ${Number(act.dx ?? 0)}, behavior: 'smooth'})`);
await sleep(act.settleMs ?? 500);
return { x: null, y: null };
}
case "scroll_page": {
// Cinematic rAF-driven scroll over durationMs (easeInOutQuad). dy = px
// to travel; omit dy to scroll to the bottom. Smoothness depends on the
// window compositing at full rate (launch Chrome with the
// --disable-*backgrounding* / throttling flags when recording unfocused).
const dur = Number(act.durationMs ?? 4000);
const dyExpr = act.dy != null ? `Math.min(max, start + ${Number(act.dy)})` : `max`;
await eval_(`new Promise((res) => {
const dur = ${dur};
const start = window.scrollY;
const max = document.documentElement.scrollHeight - window.innerHeight;
const target = ${dyExpr};
const dist = target - start;
const t0 = performance.now();
function step(now) {
const p = Math.min(1, (now - t0) / dur);
const e = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
window.scrollTo(0, start + dist * e);
if (p < 1) requestAnimationFrame(step); else res();
}
requestAnimationFrame(step);
})`);
await sleep(150);
return { x: null, y: null };
}
case "click": {
const [cssX, cssY] = await resolveCss(act);
await mouseClick(cssX, cssY);
await sleep(act.settleMs ?? 200);
const [x, y] = winPoint(cssX, cssY);
return { x, y };
}
case "move": {
// Cursor waypoint to draw the viewer's eye. A `path`/`shape` traces a
// trajectory (the editor follows the polyline); otherwise it glides to
// a single element/point over durationMs.
const path = buildPath(act);
if (path) {
await hoverAlong(path, Number(act.durationMs ?? Math.max(600, path.length * 35)));
const last = path[path.length - 1];
return { x: last.x, y: last.y, path };
}
const [cssX, cssY] = await resolveCss(act);
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: cssX, y: cssY, buttons });
// The action's duration becomes the glide time (editor eases the cursor
// to this point over it). durationMs controls speed.
await sleep(act.durationMs ?? 700);
const [x, y] = winPoint(cssX, cssY);
return { x, y };
}
case "pointer_down": {
const [cssX, cssY] = await resolveCss(act);
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x: cssX, y: cssY, button: "left", buttons: 1, clickCount: 1 });
buttons = 1;
await sleep(act.settleMs ?? 120);
const [x, y] = winPoint(cssX, cssY);
return { x, y };
}
case "pointer_move": {
// A held button makes this a drag, so down/move/up composes a gesture.
const path = buildPath(act);
if (path) {
await hoverAlong(path, Number(act.durationMs ?? Math.max(400, path.length * 35)));
const last = path[path.length - 1];
return { x: last.x, y: last.y, path };
}
const [cssX, cssY] = await resolveCss(act);
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: cssX, y: cssY, buttons });
await sleep(act.durationMs ?? 500);
const [x, y] = winPoint(cssX, cssY);
return { x, y };
}
case "pointer_up": {
const [cssX, cssY] = await resolveCss(act);
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: cssX, y: cssY, button: "left", buttons: 0, clickCount: 1 });
buttons = 0;
await sleep(act.settleMs ?? 150);
const [x, y] = winPoint(cssX, cssY);
return { x, y };
}
case "key": {
const vk = { Escape: 27, Enter: 13, Tab: 9, ArrowDown: 40, ArrowUp: 38, PageDown: 34, PageUp: 33 };
const k = String(act.key || "");
await cdp.send("Input.dispatchKeyEvent", { type: "keyDown", key: k, code: k, windowsVirtualKeyCode: vk[k] || 0 });
await cdp.send("Input.dispatchKeyEvent", { type: "keyUp", key: k, code: k, windowsVirtualKeyCode: vk[k] || 0 });
await sleep(act.settleMs ?? 250);
return { x: null, y: null };
}
case "type": {
if (act.selector) await eval_(`document.querySelector(${JSON.stringify(act.selector)}).focus()`);
for (const ch of String(act.text ?? "")) {
await cdp.send("Input.insertText", { text: ch });
await sleep(act.perCharMs ?? 40);
}
return { x: null, y: null };
}
default:
fatal(`unknown web action: ${act.action}`);
}
}
// --- walk scenes ----------------------------------------------------------
for (const [sceneIndex, scene] of screenplay.scenes.entries()) {
pushScene("scene_start", scene.id, sceneIndex);
for (const [actionIndex, act] of (scene.actions || []).entries()) {
const startMs = elapsed(), startWall = Date.now();
let res = { x: null, y: null };
try {
res = await runAction(act);
} catch (e) {
fatal(`scene "${scene.id}" action ${actionIndex} (${act.action}): ${e.message}`);
}
const endMs = elapsed(), endWall = Date.now();
events.push({
type: "action", scene_id: scene.id, scene_index: sceneIndex,
action_id: `${scene.id}/${actionIndex}`, action_index: actionIndex,
action: act.action,
startedAtMs: startMs, endedAtMs: endMs,
startedAtWallclockMs: startWall, endedAtWallclockMs: endWall,
x: res.x, y: res.y, coordinate_space: SPACE,
...(res.path ? { path: res.path } : {}),
});
}
pushScene("scene_end", scene.id, sceneIndex);
}
fs.writeFileSync(args.timeline, JSON.stringify(events, null, 2));
cdp.close();
console.log(JSON.stringify({ status: "ok", events: events.length, timeline: args.timeline, chromeHeightPts: chromeH, viewport }, null, 2));
}
main().catch((e) => fatal(e.stack || e.message));
#!/usr/bin/env node
// One-shot end-user CLI. Loads the editor context once, calls each stage's
// `generate()` to collect filter fragments, assembles them into one
// filter_complex, and runs ffmpeg ONCE to produce the final mp4 in a single
// decode→filter→encode pass.
//
// CLI:
// node scripts/export.js <recordingDir> <screenplay> <timeline> <out.mp4> [format]
// [--quality standard|high|h264|pro] default: high
// [--width N --height N] explicit output dims (override format)
// [--skip compose|highlights|zoom|captions|speedups] (repeatable)
// [--dry-run] prints assembled ffmpeg cmd
// [--debug] also prints filtergraph
//
// `format` is optional. When omitted, the export sizes to the user's main
// display's native pixel resolution (so QuickTime plays the result 1:1 on
// this machine). Named formats:
// display | horizontal_16_9 | square_1_1 | vertical_9_16 | hd_720 | uhd_4k
// --width/--height override any format choice with an exact pixel size.
const { loadContext } = require("./lib/screenplay");
const { joinFilters } = require("./lib/filtergraph");
const { encoderArgs, formatSize, runFfmpeg } = require("./lib/ffmpeg");
const compose = require("./stages/compose");
const highlights = require("./stages/highlights");
const zoom = require("./stages/zoom");
const captions = require("./stages/captions");
const speedups = require("./stages/speedups");
function main() {
const argv = process.argv.slice(2);
if (argv.length < 4) {
console.error("usage: export.js <recordingDir> <screenplay> <timeline> <out.mp4> [format] [--quality Q] [--width N --height N] [--skip stage]... [--dry-run] [--debug]");
process.exit(2);
}
const [recordingDir, screenplay, timeline, outMp4] = argv.slice(0, 4);
// 5th positional is optional (format name). If it looks like a flag, treat it as flags-only.
const maybeFormat = argv[4];
const formatPositional = (maybeFormat && !maybeFormat.startsWith("--")) ? maybeFormat : null;
const flagsStart = formatPositional ? 5 : 4;
const opts = parseFlags(argv.slice(flagsStart));
// Precedence: --width/--height override > format positional > display default.
const format = formatPositional;
const ctx = loadContext({ recordingDir, screenplayPath: screenplay, timelinePath: timeline });
const fragments = [];
// compose provides the clip inputs every other stage chains off, so it
// can't be skipped.
if (opts.skip.has("compose")) {
console.error("error: --skip compose is not supported (compose provides the per-source clip inputs every other stage chains off of)");
process.exit(2);
}
let lastOut = null;
{
const f = compose.generate(ctx);
fragments.push({ name: "compose", f });
lastOut = f.outputs;
}
function chainStage(name, mod) {
if (opts.skip.has(name)) return;
const f = mod.generate(ctx, { inputLabel: lastOut });
fragments.push({ name, f });
lastOut = f.outputs;
}
chainStage("highlights", highlights);
chainStage("zoom", zoom);
chainStage("captions", captions);
chainStage("speedups", speedups);
if (fragments.length === 0) {
console.error("error: every stage was --skip'd; nothing to do");
process.exit(2);
}
// Output size precedence: --width/--height > positional format > display native.
let fmtW, fmtH;
if (opts.width != null && opts.height != null) {
fmtW = opts.width; fmtH = opts.height;
} else {
[fmtW, fmtH] = formatSize(format);
}
const lastLabel = lastOut.replace(/^\[|\]$/g, "");
const finalFilters = [
`[${lastLabel}]scale=${fmtW}:${fmtH}:force_original_aspect_ratio=decrease,` +
`pad=${fmtW}:${fmtH}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1[final]`,
];
// ${capInput<N>} → absolute ffmpeg input index: prior stages' extraInputs
// are added to argv before this stage's, so offset by their running count.
let runningOffset = 0;
const allFilters = [];
for (const { name, f } of fragments) {
const offset = runningOffset;
const localCount = f.extraInputs?.length ?? 0;
// A declared-but-unreferenced extraInput is a dangling -i: decoded, wasted.
const filterJoined = (f.filters ?? []).join(";");
for (let i = 0; i < localCount; i++) {
if (!filterJoined.includes(`\${capInput${i}}`)) {
process.stderr.write(`warn: stage "${name}" declared extraInputs[${i}] but no filter references \${capInput${i}} - input will be loaded but unused\n`);
}
}
const localFilters = f.filters.map((s) => substituteInputs(s, offset, localCount));
allFilters.push(...localFilters);
runningOffset += localCount;
}
allFilters.push(...finalFilters);
const args = ["-y"];
for (const { f } of fragments) {
for (const e of f.extraInputs ?? []) args.push(...e.argv);
}
args.push("-filter_complex", joinFilters(allFilters));
args.push("-map", "[final]");
const finalDuration = computeFinalDuration(ctx, fragments);
if (finalDuration != null) args.push("-t", finalDuration.toFixed(3));
args.push(...encoderArgs(opts.quality));
args.push(outMp4);
if (opts.debug) {
process.stderr.write("=== filtergraph ===\n" + joinFilters(allFilters) + "\n");
}
const r = runFfmpeg(args, { dryRun: opts.dryRun });
if (!opts.dryRun) {
process.stderr.write(`\nstages: ${fragments.map((x) => x.name).join(" → ")} → encode(${opts.quality})\n`);
process.stderr.write(`output: ${outMp4}\n`);
}
process.exit(r.status ?? 0);
}
function substituteInputs(filterStr, offset, localInputCount) {
return filterStr.replace(/\$\{capInput(\d+)\}/g, (_, n) => {
const local = Number(n);
if (local >= localInputCount) {
throw new Error(`stage referenced capInput${local} but only declared ${localInputCount} extraInputs`);
}
return String(offset + local);
});
}
function computeFinalDuration(ctx, fragments) {
// Source time after the last warp segment still plays at 1× and adds to
// the final length, so account for that tail beyond last.dstEnd.
let dur = ctx.shared.durationSec;
for (const { f } of fragments) {
const tw = f.sidecars?.timewarp;
if (Array.isArray(tw) && tw.length > 0) {
const last = tw[tw.length - 1];
const tailSrc = Math.max(0, ctx.shared.durationSec - last.srcEnd);
dur = last.dstEnd + tailSrc;
}
}
return dur;
}
function parseFlags(rest) {
const opts = { quality: "high", skip: new Set(), dryRun: false, debug: false, width: null, height: null };
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a === "--quality") { opts.quality = rest[++i]; continue; }
if (a === "--width") { opts.width = Number(rest[++i]); continue; }
if (a === "--height") { opts.height = Number(rest[++i]); continue; }
if (a === "--skip") { opts.skip.add(rest[++i]); continue; }
if (a === "--dry-run") { opts.dryRun = true; continue; }
if (a === "--debug") { opts.debug = true; continue; }
console.error(`unknown flag: ${a}`);
process.exit(2);
}
if (!["standard", "high", "h264", "pro"].includes(opts.quality)) {
console.error(`unknown --quality: ${opts.quality}`);
process.exit(2);
}
if ((opts.width != null) !== (opts.height != null)) {
console.error(`--width and --height must be set together`);
process.exit(2);
}
if (opts.width != null && (!Number.isFinite(opts.width) || opts.width <= 0 || !Number.isFinite(opts.height) || opts.height <= 0)) {
console.error(`--width/--height must be positive integers`);
process.exit(2);
}
return opts;
}
if (require.main === module) main();
#!/usr/bin/env node
// generate_copy.js <timeline.json> <prompt.txt> <copy.md>
//
// Deterministic placeholder for upload copy. Pulls captions/intents out of
// the timeline; the agent invoking the skill can rewrite the output if it
// wants more polish.
const fs = require("fs");
const path = require("path");
if (process.argv.length < 5) {
console.error("usage: generate_copy.js <timeline.json> <prompt.txt> <copy.md>");
process.exit(2);
}
const [, , TIMELINE, PROMPT, OUT] = process.argv;
const events = JSON.parse(fs.readFileSync(TIMELINE, "utf8"));
const prompt = fs.existsSync(PROMPT) ? fs.readFileSync(PROMPT, "utf8").trim() : "";
const captions = events.map((e) => e.caption).filter(Boolean);
const intents = events.map((e) => e.intent).filter(Boolean);
const truncWords = (s, n) => s.split(/\s+/).slice(0, n).join(" ");
const truncChars = (s, n) => (s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "...");
const strongest = captions.slice().sort((a, b) => b.length - a.length)[0];
const title = strongest || intents[0] || "Demo";
const firstCaption = captions[0] || intents[0] || "Watch the demo";
const shortsTitle = truncChars(firstCaption, 40);
const thumbnailText = truncWords(firstCaption, 5);
const flowSentence = intents.length > 0
? `Quick walk-through: ${intents.slice(0, 4).join(", ")}.`
: `Quick demo of the flow.`;
const shortPost = [
flowSentence,
firstCaption.endsWith(".") ? firstCaption : firstCaption + ".",
prompt ? `Context: ${truncWords(prompt, 25)}.` : "",
].filter(Boolean).join("\n\n");
const out = `# Title
${title}
# Short post
${shortPost}
# YouTube Shorts title
${shortsTitle}
# Thumbnail text
${thumbnailText}
`;
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, out);
console.log(`Copy -> ${OUT}`);
Related skills
How it compares
Use instead of manually recording your screen with QuickTime or Loom and re-shooting after every UI change.
FAQ
What does desktop-recorder-skill do?
It lets an agent explore your desktop or web app, script a recording, then capture and edit a polished mp4 screencast with zoom, captions and click highlights.
What platforms does it support?
macOS only, on Apple Silicon (macOS 14+). It drives native and web apps through the deskagent CLI and exports an mp4.
How is it reproducible?
The recording is scripted as a screenplay, so when your UI changes you re-run it and the demo regenerates instead of re-shooting by hand.