@tandryukha/aidemo 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,728 @@
1
+ # Authoring demos with aidemo — the canonical guide
2
+
3
+ This is the engine-versioned authoring guide for **any** agent or human
4
+ producing a demo video with aidemo. It is served by the engine itself, so it
5
+ always matches the engine that will render:
6
+
7
+ - **MCP** (preferred for agents): call the `get_authoring_guide` tool on the
8
+ `aidemo` MCP server — it returns this document.
9
+ - **CLI**: `aidemo guide` prints it.
10
+
11
+ You are the **demo director**: turn a feature + loose directions into a
12
+ polished 30–60s narrated, captioned browser-demo MP4. You author the
13
+ **storyboard** (script + per-scene voice/music plan + declarative browser
14
+ action-spec); the engine does the mechanical steps deterministically.
15
+
16
+ ## Interfaces: MCP tools ↔ CLI commands
17
+
18
+ Every operation exists on both surfaces. Agents should prefer the MCP server
19
+ (structured results, job tracking); the CLI is the human/CI surface. `aidemo
20
+ <cmd>` means `node bin/aidemo.mjs <cmd>` inside the engine repo, or
21
+ `npx -y github:tandryukha/aidemo#stable <cmd>` anywhere else.
22
+
23
+ | Operation | MCP tool | CLI |
24
+ |---|---|---|
25
+ | Read this guide | `get_authoring_guide` | `aidemo guide` |
26
+ | Storyboard JSON Schema | `get_storyboard_schema` | — (see below) |
27
+ | Validate a storyboard | `validate_storyboard` | (validated on every run) |
28
+ | Scaffold a demo | `init_demo` | `aidemo init <name>` |
29
+ | Environment check | `doctor` | `aidemo doctor` |
30
+ | Dry-run the flow | `probe` (job) | `aidemo probe <dir>` |
31
+ | Full pipeline | `render` (job) | `aidemo render <dir>` |
32
+ | One stage | `voice` / `record` / `captions` / `compose` (jobs) | `aidemo voice\|record\|captions\|compose <dir>` |
33
+ | README GIF | `gif` (job) | `aidemo gif <dir>` |
34
+ | Named stills (screenshot mode) | `stills` (job) | `aidemo stills <dir>` |
35
+ | Job progress / result | `job_status`, `job_list`, `job_cancel` | (CLI runs block in the foreground) |
36
+
37
+ **The job model (MCP).** Pipeline operations touch TTS/STT, a real Chrome, or
38
+ ffmpeg and can run for minutes — so their tools return `{jobId, demoDir,
39
+ logFile}` **immediately**. Poll `job_status {jobId}` for stage, per-scene
40
+ progress, a log tail, and the final `result` or `error`. On failure, `error`
41
+ carries `failureArtifacts` (screenshot + frame-dump paths), the log file, and
42
+ `salvaged` when a partial take was kept. One long job runs at a time — a
43
+ second submission is rejected with the running job's id (poll or cancel it).
44
+ Job ids live in server memory: after a reconnect, look in `<demoDir>/logs/`
45
+ instead.
46
+
47
+ **Always pass absolute demo directories** to MCP tools — the server's working
48
+ directory is not necessarily your repo (Codex registers servers globally).
49
+
50
+ **Validate early.** Run `validate_storyboard` after every storyboard edit
51
+ (cheap, structured errors) instead of discovering schema issues inside a
52
+ render job.
53
+
54
+ ## Pipeline (what the engine does)
55
+
56
+ `storyboard.json` → **voice** (TTS per scene) → **record** (drives Chrome,
57
+ injected animated cursor, video + timeline) → **captions** (Whisper word
58
+ timing) → **compose** (trim idle "thinking" spans, sync each scene's video to
59
+ its narration length, overlay captions, mux audio) → `output/final-demo.mp4`.
60
+
61
+ No LLM runs during capture — the recording is a deterministic replay of your
62
+ fixed action-spec. Voice and captions call an OpenAI-compatible endpoint
63
+ (`OPENAI_API_KEY`, or `OPENAI_BASE_URL` pointing at a local server such as
64
+ speaches — then no key is needed); probe/record/compose never touch the
65
+ network.
66
+
67
+ ## Steps
68
+
69
+ 1. **Clarify the brief** (only if genuinely ambiguous): target flow/URL, the
70
+ one thing to prove, audience, tone, length, CTA. Otherwise infer sensible
71
+ defaults.
72
+ 2. **Scaffold**: `init_demo` / `aidemo init <name>` → creates `demos/<name>/`
73
+ with a starter `generated/storyboard.json`.
74
+ 3. **Confirm selectors for a new/changed flow.** Don't guess. The cheapest way
75
+ is a **probe** — a record-only dry run (narration optional) that drives the
76
+ real flow in ~90 s without spending TTS or a full take, so you can verify
77
+ selectors, iframe descent, and cart continuity live. Map each iframe once in
78
+ `storyboard.frames`. A probe that hits a bad selector leaves a screenshot +
79
+ frame dump in `logs/` (surfaced as `failureArtifacts` in `job_status`).
80
+ 4. **Write the storyboard** (schema + principles below), then
81
+ `validate_storyboard`.
82
+ 5. **Render**: run the `render` job (headed for real ChatGPT: keep
83
+ `headless: false` / drop `--headless`). Then inspect `output/final-demo.mp4`
84
+ — extract a few frames with ffmpeg and look at them.
85
+ CLI note: **don't** pipe `aidemo record … | tee log` — the exit code becomes
86
+ tee's (0), masking a failed take. The engine already tees itself to
87
+ `logs/<command>.log`.
88
+ 6. **Iterate cheaply**: change one scene's narration and re-run `voice` then
89
+ `compose` — voice **skips unchanged scenes** (hashes narration + voice
90
+ plan), so only the edited scene is re-voiced and an approved take is
91
+ preserved (`force` re-voices all; `scene <id>` targets one). Re-record only
92
+ if the browser flow changed.
93
+
94
+ ## Demo-director principles (make it feel human-made)
95
+
96
+ - **Hook in the first ~3 seconds.** Open on the value, not a login screen.
97
+ - **One idea per scene.** 4–6 scenes for a 45s demo.
98
+ - **Narration ≈ 2.5 words/sec.** A 6s scene ≈ 15 words. Keep it tight and
99
+ spoken, not written ("here's the thing" > "the following functionality").
100
+ That figure is English-centric: non-English OpenAI TTS voices can read
101
+ 30-40% slower — plan at ≈1.8-2.0 words/sec for morphology-rich languages
102
+ (Estonian, Finnish, German, …), or voice one scene first (`aidemo voice
103
+ <dir> --scene <id>`) to measure the actual pace before writing the rest. If
104
+ the storyboard declares `targetLengthSeconds`, `voice` compares the real
105
+ narration length against it and warns with a per-scene words/sec table when
106
+ it's over budget.
107
+ - **Match narration to the action.** The engine time-stretches each scene's
108
+ video to its narration and freeze-holds a static page for any remainder — so
109
+ a scene that's mostly a pause should have shorter narration, and a busy scene
110
+ more.
111
+ - **End on a clear CTA.**
112
+ - **Voice plan per scene**: pick `voiceId` (marin/alloy/verse/… on the default
113
+ OpenAI TTS; an ElevenLabs voice id if the engine runs with
114
+ `AIDEMO_TTS_PROVIDER=elevenlabs`; a Kokoro voice id — af_heart, am_adam, … —
115
+ if it runs with `AIDEMO_TTS_PROVIDER=local`; both non-default providers
116
+ ignore `instructions`) and `instructions` to steer tone/emotion/pace. Keep it
117
+ consistent across scenes.
118
+
119
+ ## Storyboard schema (quick reference)
120
+
121
+ The precise contract is the JSON Schema from `get_storyboard_schema`
122
+ (generated from the engine's own zod schema, `src/types.ts`).
123
+
124
+ Top level: `title`, `language?`, `targetLengthSeconds?`, `video{width,height}`
125
+ (default 1280x720), `frames{ name: iframeSelector }`,
126
+ `voice{voiceId,instructions,speed}` (default, scenes may override), `music?`,
127
+ `zoom?`, `intro?`, `outro?`, `transition?`, `output?`, `scenes[]`.
128
+
129
+ `language?` is a BCP-47/ISO-639-1 code (e.g. `"et"`) describing what language
130
+ the base `narration` is already written in — set it for a monolingual
131
+ non-English demo (no `narrations` translations at all). It's metadata, not a
132
+ render switch: it only feeds the `captions` STT language hint (see below).
133
+ Distinct from `--lang`, which *selects* a scene's `narrations[code]`
134
+ translation (Multi-language renders, below).
135
+
136
+ Cinematic keys (all opt-in; omit for the plain look):
137
+ - `zoom: {scale?=1.55, easeMs?=600, holdMs?=1700}` — **auto-zoom on focus**:
138
+ every click/typed prompt zooms in on the interaction point at compose time,
139
+ holds, eases out; near-consecutive focus points pan instead of bouncing.
140
+ Set `"zoom": false` on a scene to opt just that scene out.
141
+ - `intro` / `outro: {title, subtitle?, durationMs?=2600, background?, accent?,
142
+ fadeMs?=350}` — typographic title cards; narration/captions shift
143
+ automatically and music runs under the cards.
144
+ - `music: {track, gainDb?=-14, ducking?="sidechain", fadeOutMs?=1800, ...}` —
145
+ the bed ducks under narration via a sidechain compressor and swells back in
146
+ pauses (`"ducking": "constant"` + `duckToDb` = legacy fixed bed). No bed
147
+ ships with the repo (license provenance matters); generate a **license-free**
148
+ one with `aidemo music assets/music.wav` and point `track` at it. Per-scene
149
+ `music.cue` is informational only — it's ignored (the bed plays
150
+ continuously), and setting one logs a warning at load. **Adding music
151
+ auto-normalizes the master** to ~−16 LUFS / −1.5 dBTP (a final `loudnorm`
152
+ pass) so it isn't quiet next to other clips — tune or disable it with
153
+ `output.loudness` (see below).
154
+ - `transition: {type:"crossfade", durationMs?=400}` — **cross-dissolve every
155
+ scene boundary** instead of hard-cutting (see below).
156
+ - `output: {width?, height?, fit?="contain", background?, loudness?}` — **render
157
+ at a different size/aspect** (`width`+`height`, set together) and/or **set the
158
+ master loudness** (`loudness`), e.g. a vertical social clip (see below).
159
+ - `motionBlur: {frames?=3}` — **subtle motion blur** on fast motion (cursor,
160
+ scroll, zoom pan); static UI stays sharp (see below).
161
+ - `cursor: {hidden?, hideScenes?, scale?}` — **compose-time cursor control**:
162
+ hide or resize the cursor post-hoc instead of baking it (see below).
163
+
164
+ Each scene: `id`, `narration`, `voice?`, `music?`, `zoom?` (false to disable),
165
+ `actions[]`.
166
+
167
+ ## Transitions, output sizing & loudness
168
+
169
+ Opt-in, compose-time polish keys. All are pure post-processing — they never
170
+ touch the recording, so tuning them is always a recompose, never a re-record.
171
+ Omit them and compose behaves exactly as before (hard-cut scenes, recording-size
172
+ output, no loudness pass on a narration-only render).
173
+
174
+ **`transition: {type:"crossfade", durationMs?=400}`** — cross-dissolves the video
175
+ across every scene boundary. Narration is a single continuous track and stays
176
+ authoritative: the crossfade steals its overlap from each scene's own frozen
177
+ tail frames (a hold beyond the scene's end), so the timeline does **not** shrink.
178
+ The final video's total duration, frame count, and per-scene narration alignment
179
+ are identical to the hard-cut version — only the cuts become dissolves. Cost: the
180
+ scene join is re-encoded (the default hard-cut path is a lossless stream-copy
181
+ concat). Needs ≥2 scenes; a shorter `durationMs` (250–400) reads as a snappy
182
+ dissolve, longer (600+) as a slow cinematic fade.
183
+
184
+ **`output: {width, height, fit?, background?}`** — reframes the finished video
185
+ (cards and captions already baked in) to a target size, applied as the last
186
+ step. `fit`:
187
+ - `"contain"` (default) — scale to fit inside `width×height` and pad the
188
+ remainder with `background` (ffmpeg color syntax, e.g. `black`, `0x1a1a1a`;
189
+ default black). Letterbox/pillarbox bars, nothing cropped.
190
+ - `"cover"` — scale to fill and center-crop the overflow. No bars, but edges are
191
+ lost.
192
+
193
+ This seeds the **social-clips lane**: a vertical `{width:1080, height:1920}` (or
194
+ `720×1280`) clip from a landscape 1280×720 take. Use even width/height (yuv420p).
195
+ Only core scale/pad/crop filters are used, so it stays portable across ffmpeg
196
+ builds. `width` and `height` are set together (or both omitted) — an `output`
197
+ block may carry only `loudness`.
198
+
199
+ **`output: {loudness?}`** — master loudness normalization (a final `loudnorm`
200
+ pass, portable core filter). This exists because **mixing a music bed drops the
201
+ master to ~−29 LUFS** — far below streaming norms (YouTube/podcast sit around
202
+ −14…−16 LUFS), so the video sounds quiet next to anything else. The fix runs
203
+ automatically:
204
+ - **With a `music` block:** the master is normalized to **−16 LUFS integrated /
205
+ −1.5 dBTP** by default (LRA 11). No `output` block needed.
206
+ - **Narration only (no music):** no loudness pass runs — that path is
207
+ byte-for-byte unchanged.
208
+
209
+ Override or disable via `loudness`:
210
+ - `"loudness": false` — disable the pass entirely (even with music).
211
+ - `"loudness": {integrated?=-16, truePeak?=-1.5, lra?=11}` — override any target
212
+ (partial objects fill from the defaults). Setting an object also **forces the
213
+ pass on for a narration-only render** (e.g. to hit a specific delivery target).
214
+
215
+ Targets are the ffmpeg `loudnorm` `I` (LUFS), `TP` (dBTP), and `LRA` (LU). The
216
+ pass runs last over the muxed audio and pins the rate back to 44.1 kHz.
217
+
218
+ ## Motion blur & cursor
219
+
220
+ Two more compose-time polish keys, both opt-in and portable (baseline `tmix` /
221
+ `overlay` — no `drawtext`/`subtitles`). Omit them and the render is byte-for-byte
222
+ the old behavior.
223
+
224
+ **`motionBlur: {frames?=3}`** — averages a small sliding window of frames so fast
225
+ motion (the cursor gliding, an eased scroll, a zoom pan) gets a subtle trail.
226
+ Static UI is untouched — identical frames average to themselves, so only moving
227
+ pixels blur. `frames` is the window size (2 = whisper, 3 = default, up to 6 for a
228
+ heavier smear). Pure post-processing over the content — recompose to tune it.
229
+
230
+ **`cursor: {hidden?, hideScenes?, scale?}`** — moves the cursor from a *baked*
231
+ record-time artifact to a *compose-time layer*, so you can style it **post-hoc,
232
+ without re-recording**:
233
+ - `hidden: true` — no cursor at all (clean product shots; a `still` grabbed in
234
+ this mode is cursor-free too).
235
+ - `hideScenes: ["s5"]` — hide the cursor only on those scenes (e.g. a
236
+ full-screen result or confirmation).
237
+ - `scale: 1.3` — resize the cursor (1 = the 24px baseline arrow).
238
+
239
+ How it works: with a `cursor` block present, `record` leaves the take
240
+ **cursor-free** and logs the cursor path into `timeline.json`; `compose` draws
241
+ the cursor as an overlay along that path (over the content, so it zooms and pans
242
+ with the frame, exactly like a baked cursor). This means the cursor block must be
243
+ present **when you record** (so the clean take + path exist) — after that, every
244
+ hide/resize is a recompose, never a re-record. Without the block, the cursor is
245
+ baked at record time as before (the default).
246
+
247
+ ## Action vocabulary
248
+
249
+ A `target` is `{selector}` or `{frame,selector}` or `{named:"composer"}`:
250
+ - `{op:"goto", url}`
251
+ - `{op:"type", target, text, humanize?}` — human-cadence typing
252
+ - `{op:"press", key}` — e.g. "Enter"
253
+ - `{op:"click", target}` · `{op:"hover", target}`
254
+ - `{op:"scrollTo", target, easing?, durationMs?}` · `{op:"scrollBy", dy,
255
+ easing?, durationMs?}` — easing presets: `"smooth"` (default) | `"snappy"` |
256
+ `"glide"` | `"linear"`
257
+ - `{op:"focus", target, scale?, holdMs?}` — deliberate zoom beat on an element
258
+ without clicking it (needs top-level `zoom` enabled)
259
+ - `{op:"still", name}` — **screenshot mode**: mark a named still at this beat.
260
+ A pure timeline marker (no screenshot at record time); `aidemo stills` /
261
+ `render` extract `output/stills/<name>.png` from the clean take. See *Stills /
262
+ screenshot mode* below.
263
+ - `{op:"waitFor", target, timeoutMs?}` — normal wait (fires instantly if the
264
+ selector already matches — no good for in-place changes)
265
+ - `{op:"waitForWidget", target, textMatches?, label?, timeoutMs?}` — **records
266
+ the wait as idle** so compose trims/speeds it. Use for every ChatGPT
267
+ "thinking" wait for a brand-**new** widget. When a prompt could render
268
+ either of two widget types that share the selector (e.g. carousel vs compare
269
+ both carry `button[data-add-id]`), pass `textMatches` (case-insensitive regex
270
+ on the new widget's text) with a string unique to the type you expect — an
271
+ unqualified wait passes on the wrong widget and the desync only shows in
272
+ review.
273
+ - `{op:"waitForReply", selector?, textMatches?, label?, timeoutMs?}` — wait for
274
+ a **new TEXT-ONLY assistant reply** (a tool that answers without a widget,
275
+ e.g. a delivery-options lookup) — the main-frame twin of `waitForWidget`:
276
+ assistant-message count growth + generation finished, always recorded as
277
+ trimmable idle. `selector` defaults to ChatGPT's message nodes
278
+ (`[data-message-author-role="assistant"]`); `textMatches` additionally
279
+ asserts the finished reply's text (fails the scene if the model answered
280
+ off-script).
281
+ - `{op:"waitForChange", target, textMatches?, timeoutMs?, idle?, label?}` —
282
+ wait for an **in-place mutation** of an existing element (the gap between
283
+ waitFor and waitForWidget). Use after a widget-side click that re-renders the
284
+ SAME widget: click-Add renders the basket bar into `[data-cart-bar]`; a qty
285
+ `±` re-renders the row. Pass `textMatches` (case-insensitive regex on the new
286
+ text) to wait for a specific state, e.g. target the qty display and pass
287
+ `"2"` for a 1→2 re-render. `idle:true` records it as trimmable idle like
288
+ `waitForWidget`.
289
+ - `{op:"pause", ms}` — a deliberate on-screen beat (not trimmed)
290
+
291
+ Target `last`/`nth` pick among matching **frames** on framed targets and among
292
+ matching **elements** on plain (frameless) targets — e.g.
293
+ `{selector:"[data-message-author-role=\"assistant\"]", last:true}` is the
294
+ newest reply.
295
+
296
+ Typing into the composer while the prior reply is still streaming is safe: the
297
+ player waits the composer out and **records that wait as trimmable idle**, so a
298
+ long streaming turn can't blow the next scene past its narration.
299
+
300
+ High-fidelity capture: `capture: "native"` (macOS ffmpeg screen grab; terminal
301
+ needs Screen Recording permission) or `"obs"` (OBS via obs-websocket; scene =
302
+ Display Capture of the primary screen). Both require a headed browser. Default
303
+ remains Playwright's built-in recorder.
304
+
305
+ **⚠ Frame-review every native/OBS take before publishing — a bad crop can
306
+ leak your desktop.** These modes record the REAL screen and crop to the
307
+ browser viewport afterwards; a wrong crop silently ships whatever else is on
308
+ screen (desktop icons, notifications, permission dialogs, other windows) into
309
+ the video. The recorder measures the window via CDP, sizes it to the
310
+ storyboard viewport, and **aborts** when the computed crop doesn't match the
311
+ viewport instead of shipping a mis-cropped take
312
+ (`AIDEMO_NATIVE_CROP_UNSAFE=1` downgrades the abort to a loud warning — only
313
+ for debugging, and then review every frame). No automatic check can see
314
+ everything, so eyeball the frames anyway. `record` also preserves the
315
+ previous take as one `.prev` generation (`recordings/raw.prev.*` +
316
+ `generated/timeline.prev.json`) instead of deleting it, so a bad re-record
317
+ never destroys the last good take — copy the `.prev` files back over the
318
+ current ones to roll back.
319
+
320
+ ## Stills / screenshot mode
321
+
322
+ One storyboard can also emit **named still images** — for READMEs, docs, blog
323
+ posts, and app-store / listing media — from the *same* take that produces the
324
+ video. No second tool, no separate capture pass.
325
+
326
+ Drop a `still` marker wherever you want a frame:
327
+
328
+ ```json
329
+ { "op": "still", "name": "hero" }
330
+ ```
331
+
332
+ It's a **pure timeline marker** — like `focus`, it records *when* (timeline
333
+ time) but takes no screenshot during recording. The PNG is extracted at
334
+ **compose time** from the recorded take:
335
+
336
+ - `aidemo stills <dir>` (MCP `stills` job) extracts every marker from an
337
+ existing recording into `output/stills/<name>.png`. It reads only
338
+ `timeline.json` + the raw take — **no API key, no re-record** — so re-running
339
+ it (or adding a new marker + re-recording) is cheap. This mirrors the rest of
340
+ the engine's "polish is a re-extract, never a re-take" rule.
341
+ - `aidemo render <dir>` (and the MCP `render` job) runs the extraction
342
+ automatically whenever the storyboard contains any `still` marker.
343
+
344
+ **Stills come from the CLEAN take** — the raw recording, before captions, zoom,
345
+ or title cards are burned in. That's deliberate: a still is the bare product UI
346
+ at that instant, exactly what a README or a store listing wants, not a frame
347
+ with a caption pill across it. They're written at the recording's native
348
+ resolution — the storyboard `video` size (1280×720 by default); a `native`/`obs`
349
+ retina capture yields correspondingly larger stills.
350
+
351
+ The timeline→frame mapping is the same one compose uses to sync scenes:
352
+ `frame_time = marker.tMs + timeline.leadInMs`. Place the marker **after the page
353
+ settles** (e.g. right after a `scrollTo`, before a `pause`) so the extracted
354
+ frame isn't mid-transition — the marker itself waits a short beat before
355
+ recording its time for exactly this reason.
356
+
357
+ **Names must be unique and file-safe.** Each `name` becomes `<name>.png`
358
+ (letters, digits, dot, dash, underscore). Duplicate names across the storyboard
359
+ are a **hard error** at extraction, so one still never silently clobbers
360
+ another.
361
+
362
+ ## ChatGPT Apps SDK recording — hard-won facts (READ before a ChatGPT demo)
363
+
364
+ > **App-specific.** This whole section applies only when the demo *subject* is a
365
+ > ChatGPT Apps SDK widget (an app embedded inside chatgpt.com). For a plain web
366
+ > app (a marketplace, a dashboard, a SaaS UI) skip it entirely — you just author
367
+ > `goto`/`type`/`click`/`scrollTo` against the site's own selectors.
368
+
369
+ Confirmed by recording a real production shopping app (2026-07-06). The engine
370
+ already handles the frame/timing/stealth cases below; you mostly need to author
371
+ correctly.
372
+
373
+ **Login & profile (the crux).**
374
+ - Use a dedicated Chrome profile logged into ChatGPT with the app's dev
375
+ connector enabled (`AIDEMO_CHROME_PROFILE` or the `profile` option). Run
376
+ **headed**; quit any Chrome on that profile first (Playwright needs the lock).
377
+ - You **cannot copy** an existing ChatGPT login in: real Chrome encrypts
378
+ cookies with the macOS Keychain, but Playwright launches with
379
+ `--use-mock-keychain --password-store=basic`, so copied cookies won't
380
+ decrypt. Log in **once** in a Chrome that uses the *same* mock store, then
381
+ quit and record:
382
+ `open -na "Google Chrome" --args --user-data-dir=<profile> --password-store=basic --use-mock-keychain https://chatgpt.com/`
383
+ (A normal window like this also dodges Google's SSO automation block.)
384
+ - Cloudflare: the engine launches with
385
+ `--disable-blink-features=AutomationControlled` so `navigator.webdriver` is
386
+ false and the profile's real `cf_clearance` is honored (otherwise you get a
387
+ "Verify you are human" wall mid-record).
388
+
389
+ **Widgets render in a NESTED iframe.** The app UI is inside an
390
+ `<iframe name="root">` (about:blank) within the
391
+ `*.web-sandbox.oaiusercontent.com` sandbox — a single-level `frameLocator` only
392
+ reaches the empty wrapper, and ChatGPT renders **two** sandbox iframes per
393
+ widget (one populated, one empty). The engine auto-descends and picks the
394
+ populated frame **when your `frame` selector contains `oaiusercontent`** — so
395
+ declare:
396
+ `"widget": "iframe[src*=\"web-sandbox.oaiusercontent.com\"], iframe[src*=\"<app>\"]"`.
397
+ Always set `"last": true` on widget targets (newest widget for this turn).
398
+
399
+ **Authoring rules that actually work:**
400
+ - **Prefix every prompt with the app name** ("YourApp, …") or the model does a
401
+ plain web search instead of calling the app.
402
+ - **`humanize: false`** on composer typing — fast typing keeps each scene's
403
+ *active* video shorter than its narration, so compose freeze-holds/gently
404
+ slows the widget (good dwell) instead of speeding past it. Human-cadence
405
+ typing flashed widgets.
406
+ - **`waitForWidget` on every reply** — the engine waits for a *new* widget
407
+ (match count grows past a pre-send baseline) and folds the "generation
408
+ finished" wait + a composer clear into the trimmed idle span, so multi-turn
409
+ prompts don't interleave.
410
+ - **Drive a tool by clicking its widget button when the model is unreliable.**
411
+ Some tools (e.g. checkout) the model narrates in text instead of calling — a
412
+ typed prompt produces no widget. Use a `click` on the widget's own control
413
+ instead, e.g. `{op:"click", target:{frame:"widget", last:true,
414
+ selector:"button[data-cart-act=\"checkout\"]"}}`.
415
+ - **Real-world widget hooks** (from a production shopping app — widgets use
416
+ inline styles + `data-*`, not `data-testid`; yours will differ, so confirm
417
+ with a probe): carousel/product-card = `button[data-add-id]`; cart =
418
+ `button[data-cart-act]` (inc/dec/remove/`="checkout"`); external hand-off =
419
+ `button[data-ext-href]` (`:has-text("checkout")` to distinguish the checkout
420
+ CTA from a card's "View" link).
421
+ - **External checkout opens a NEW TAB** (a separate page, *not* in the video).
422
+ End the scene on the visible hand-off CTA and let narration cover the jump.
423
+ (Recorder keeps the largest `.webm` so the stray new-tab recording is
424
+ discarded.)
425
+ - Collapse the sidebar (`[data-testid="close-sidebar-button"]`, persist it in
426
+ the profile) to hide chat-history PII and clean the frame. Note the home
427
+ screen greets the account by first name for ~2 s — blur or accept per the
428
+ owner.
429
+ - **Widget-side mutations render in the SAME widget, not a new one** (v2
430
+ recording, 2026-07-06): click-Add renders a basket bar into the widget's
431
+ `[data-cart-bar]` slot; the cart's `±` re-renders the row in place. Wait with
432
+ **`waitForChange`** on the mutating element (`{op:"waitForChange",
433
+ target:{frame:"widget", last:true, selector:"[data-cart-bar]"},
434
+ textMatches:"checkout"}`) — it waits for the element to actually change,
435
+ unlike `waitFor` (fires instantly on a stale match) or `waitForWidget` (times
436
+ out — no new widget). A bare `pause` races the server.
437
+ - **Widget-initiated tool results are invisible to the model** (v3 recording,
438
+ 2026-07-06): a widget-click `add_to_cart` returns its result to the widget
439
+ only — the model never learns the session id, so a later *prompted* "show my
440
+ basket" can render an EMPTY cart. (v2's adjacency success was luck, not
441
+ contract.) Rule: a flow that needs model-side cart continuity must make its
442
+ FIRST cart mutation a typed/model-invoked one; after that, widget clicks
443
+ (qty `±`, checkout) are safe.
444
+ - **Dev-connector widgets carry a "CSP off" badge** on their header (new since
445
+ 2026-07-06). Cosmetic, appears in every frame, not fixable app-side (CSP
446
+ `_meta` verified riding `resources/read`) — accept it, or frame zooms to keep
447
+ the header out of the payoff shots.
448
+ - **Pin prompts to exact catalog titles.** A fuzzy product reference ("Gold
449
+ Standard 900 g") can trigger an intermediate model search that renders a
450
+ "0 products found" widget mid-video. Use the product's exact catalog title in
451
+ the prompt.
452
+ - **Don't hand-author scrolls before widget clicks** — the player self-scrolls:
453
+ sandbox iframes are out-of-process, so a scroll from inside the widget can't
454
+ move ChatGPT's page; `boxOf` wheels the main scroller until the target sits
455
+ in the viewport band, and `humanClick` hit-tests and dodges overlays
456
+ (ChatGPT's floating scroll-to-bottom arrow otherwise eats coordinate clicks
457
+ aimed at widget footers).
458
+ - **ChatGPT can hijack a turn with an A/B "Which response do you prefer?" eval
459
+ screen** (two text responses, no widget, blocks on a human choice), or a
460
+ consent wall. The player runs **best-effort interruption handlers** before
461
+ composer typing and inside the widget wait — they try to dismiss these and
462
+ log what they did. When one can't self-resolve, the scene still fails, but
463
+ with a *named* error + a screenshot in `logs/` (so you can see it was
464
+ platform roulette, not your storyboard). Selectors for these handlers track
465
+ live ChatGPT and may drift; a re-run is still the reliable fix.
466
+ - **Cheap live probe before a full take**: a probe runs the storyboard
467
+ **record-only** with narration optional — a 3-scene probe (search → click →
468
+ prompted follow-up) answers selector/continuity questions in ~90 s without
469
+ spending TTS or a full take. On a bad selector it leaves
470
+ `logs/fail-<scene>-<n>.{png,json}` (screenshot + which frames matched).
471
+ - **A failed take is salvaged, not lost**: `record` writes a partial
472
+ `timeline.json` and keeps the main recording even when a late scene fails, so
473
+ a scene-7-of-7 failure doesn't discard the good footage. Re-run to get a
474
+ clean take.
475
+
476
+ `init_demo` / `aidemo init` scaffolds a storyboard already using all of the
477
+ above.
478
+
479
+ ## Parameterized storyboards & variants
480
+
481
+ One storyboard, many personalized renders (per prospect, per segment). Declare
482
+ template params, reference them as `{{name}}`, then override per run or render a
483
+ whole matrix at once.
484
+
485
+ **Declare** a top-level `params` map (name → default value). Every declared
486
+ param needs a default so the storyboard still renders with no overrides (and so
487
+ `validate_storyboard` stays green):
488
+
489
+ ```json
490
+ {
491
+ "title": "{{customer}} — supplement finder",
492
+ "params": { "customer": "there", "query": "whey isolate under 30 euros" },
493
+ "scenes": [
494
+ {
495
+ "id": "s1",
496
+ "narration": "Hi {{customer}}! Just tell DemoFit what you need.",
497
+ "actions": [
498
+ { "op": "goto", "url": "http://localhost:8787/" },
499
+ { "op": "type", "target": { "selector": "#search" }, "text": "{{query}}" },
500
+ { "op": "click", "target": { "selector": "#search-btn" } }
501
+ ]
502
+ }
503
+ ],
504
+ "intro": { "title": "A demo for {{customer}}" }
505
+ }
506
+ ```
507
+
508
+ **Where placeholders work:** any storyboard string — narration, action `url`,
509
+ `type` `text`, card `title`/`subtitle`, `waitFor*` `textMatches`, voice
510
+ `instructions`, even selectors. Not numbers: a `{{n}}` in a numeric field fails
511
+ schema validation (put the value in a string field). The `params` defaults
512
+ themselves are literal (not recursively substituted).
513
+
514
+ **Resolution & errors** (both caught at load, before any stage runs):
515
+ - an override key that isn't declared → hard error (typo guard);
516
+ - a `{{name}}` used but not declared → hard error listing the missing names.
517
+
518
+ **Override a single render** with repeatable `--param key=value` (CLI) — works
519
+ on every storyboard-loading command (`probe`, `record`, `voice`, `captions`,
520
+ `compose`, `render`); the MCP tools take an equivalent `params` object:
521
+
522
+ ```bash
523
+ aidemo render <dir> --param customer="Acme Corp" --param query="creatine"
524
+ aidemo probe <dir> --param customer="Acme Corp" # verify the take, zero spend
525
+ ```
526
+
527
+ **Consistency across stages.** One resolved param set applies to the whole run,
528
+ so a param in narration re-voices + re-captions and one in a url/typed text
529
+ changes the recorded take. The resolved set is persisted to
530
+ `generated/params.json`, so a later stage-only re-run (e.g. `aidemo compose
531
+ <dir>`) reuses the exact same values — pass `--param` again only to change them
532
+ (a take-affecting change then needs a re-record, per "polish is compose-time").
533
+
534
+ **Render a whole matrix** with `--variants variants.json`, an array of
535
+ `{ name, params }`:
536
+
537
+ ```json
538
+ [
539
+ { "name": "acme", "params": { "customer": "Acme Corp", "query": "creatine" } },
540
+ { "name": "globex", "params": { "customer": "Globex", "query": "omega 3" } }
541
+ ]
542
+ ```
543
+
544
+ ```bash
545
+ aidemo render <dir> --variants variants.json
546
+ ```
547
+
548
+ Each entry renders as an isolated full pipeline into
549
+ `output/variants/<name>/` (its own take — content differs; voice hashing still
550
+ skips unchanged scenes within a variant). Runs are sequential. Because a variant
551
+ renders relative to its own dir, keep any local asset paths (e.g. a music
552
+ `track`) absolute.
553
+
554
+ ## Verify before declaring done
555
+
556
+ Play (or frame-extract) `output/final-demo.mp4`: cursor glides and clicks
557
+ pulse, narration matches on-screen actions, captions are readable and in sync,
558
+ no dead air, and the key moment (e.g. checkout confirmed) is actually visible
559
+ on screen. If the demo is headed for a README, run the `gif` job — GIFs
560
+ autoplay on GitHub; MP4s don't.
561
+
562
+ Then: if you hit any engine bug, surprise, or workaround this session, file
563
+ it now while the context is fresh — the MCP `feedback` tool (title + body;
564
+ environment/log context is auto-attached) or `aidemo feedback <dir>` on the
565
+ CLI. If nothing came up, skip this.
566
+
567
+ ## Debugging
568
+
569
+ - Every run tees its output to `<demo>/logs/<command>.log`; a failed take also
570
+ leaves `logs/fail-<scene>-<n>.{png,json}`. `job_status` surfaces all of these
571
+ paths on failure.
572
+ - `AIDEMO_KEEP_TMP=1` preserves `.compose-tmp/` intermediates when debugging
573
+ compose.
574
+ - `doctor` checks Node, ffmpeg, Chrome, the TTS/STT endpoint (and flags
575
+ LLM-only servers like Ollama, which have no audio endpoints — point
576
+ `OPENAI_BASE_URL` at a speech server such as speaches instead).
577
+
578
+ ## Demo as regression test
579
+
580
+ A probe run is a deterministic replay of the storyboard, so it doubles as a
581
+ regression test for the UI it drives: if a selector moves, a page renames a
582
+ `data-testid`, or a navigation breaks, the flow changes — catch it as a failed
583
+ check, not a user complaint. This is the golden-file pattern (borrowed from
584
+ vhs).
585
+
586
+ `aidemo probe <dir> --update-golden` writes `golden/probe.json`: a **normalized,
587
+ timing-free** projection of the probe — per scene, the ordered action outcomes
588
+ (`op`, the resolved `target`/selector, `ok`, the element-`found` boolean, and
589
+ each `goto`'s `finalUrl`). No wall-clock timings, coordinates, or anything
590
+ volatile, so it is stable across runs. Commit it next to the storyboard.
591
+
592
+ `aidemo probe <dir> --golden` re-runs the probe, normalizes it the same way, and
593
+ deep-compares against `golden/probe.json`. On a match it exits 0; on drift it
594
+ prints a readable field-level diff (`$.scenes[2].actions[0].found: expected
595
+ true, got false`) and exits non-zero. A missing baseline is a clear error
596
+ telling you to run `--update-golden` first. In golden mode a failing action is
597
+ recorded (`ok:false`) and the run continues, so you get the full diff instead of
598
+ an abort at the first break. (The MCP `probe` tool takes the same `updateGolden`
599
+ / `golden` params and returns `golden.match` + `golden.diffs` in its result.)
600
+
601
+ Wire it into CI so a breaking UI change is a failed check:
602
+
603
+ ```yaml
604
+ # after starting your app/fixture server on its expected URL
605
+ - run: npx -y github:tandryukha/aidemo#stable probe path/to/demo --golden --headless
606
+ ```
607
+
608
+ Regenerate the baseline (`--update-golden`) and re-commit `golden/probe.json`
609
+ whenever the flow changes on purpose.
610
+
611
+ ## Captions: Whisper STT vs. offline (script-timed)
612
+
613
+ Default `aidemo captions <dir>` transcribes the composed narration audio with
614
+ Whisper word-level timestamps — real per-word timing, but a plain audio
615
+ transcription can still misspell narration (this bit non-English demos hard:
616
+ Estonian "uus kuub" came back "Scoop", "AI-treener" came back "EI treener").
617
+ To keep the transcript honest, the request is biased with what the engine
618
+ already knows about the script:
619
+
620
+ - **Prompt bias (always on).** The storyboard's narration text (in scene
621
+ order) is sent as Whisper's `prompt`, nudging the transcript to converge on
622
+ the actual scripted words/spelling instead of guessing from audio phonetics
623
+ alone — while still keeping real word-level timing from the audio. This
624
+ changes STT output for every non-trivial demo (strictly for the better:
625
+ caption text lines up with the script); there's no flag to opt out of it
626
+ short of `--offline`.
627
+ - **Language hint.** A BCP-47/ISO-639-1 code is sent as Whisper's `language`
628
+ param when known, in this priority order: an explicit `--stt-lang <code>`
629
+ → an active `--lang <code>` (multi-language render) → the storyboard's own
630
+ top-level `language` field → unset (auto-detect).
631
+ - **`aidemo captions <dir> --stt-lang <code>`** sets the language hint
632
+ directly, independent of `--lang` — use it for a one-off STT fix (or a
633
+ monolingual non-English demo) without editing the storyboard.
634
+
635
+ **Non-English narration:** prompt bias + a language hint fix the common case,
636
+ but Whisper can still misspell unusual or compound words. If burned-in
637
+ captions still look wrong, **`aidemo captions <dir> --offline` is the
638
+ guaranteed-correct fallback** — cues are derived directly from the storyboard
639
+ script (correct spelling by construction, since there's no transcription at
640
+ all) using each scene's measured duration from `voice.json`; only the timing
641
+ *within* a scene is approximate (words spread proportional to length, not
642
+ measured speech rhythm). `captions` logs a one-line reminder of this whenever
643
+ the resolved language isn't English.
644
+
645
+ ## Multi-language renders (one take, N languages)
646
+
647
+ Record the browser flow **once**, then ship the same demo in several languages —
648
+ each with its own voiceover and captions — without re-recording. The take (raw
649
+ video + `timeline.json`) is language-independent: it replays your action-spec,
650
+ which never mentions the spoken words. Only the **narration, captions, and
651
+ intro/outro card copy** localize; `compose` already stretches each scene's video
652
+ to its own narration length, so a longer or shorter translation just retimes the
653
+ shared footage.
654
+
655
+ Translations are **authored into the storyboard** — the pipeline never calls an
656
+ LLM to translate (the capture loop and the render pipeline both stay LLM-free).
657
+
658
+ **Authoring pattern.** Add a `narrations` map to any scene (language code →
659
+ translated text) and, for title cards, an `i18n` map:
660
+
661
+ ```jsonc
662
+ {
663
+ "intro": {
664
+ "title": "DemoFit",
665
+ "subtitle": "Find your supplement",
666
+ "i18n": { "de": { "subtitle": "Finde dein Supplement" } }
667
+ },
668
+ "scenes": [
669
+ {
670
+ "id": "s1",
671
+ "narration": "Just tell DemoFit what you need.", // base language
672
+ "narrations": { // per-language overrides
673
+ "de": "Sag DemoFit einfach, was du brauchst.",
674
+ "fr": "Dites simplement à DemoFit ce qu'il vous faut."
675
+ },
676
+ "actions": [ /* … shared across every language … */ ]
677
+ }
678
+ ]
679
+ }
680
+ ```
681
+
682
+ A code missing from a scene's `narrations` (or a card's `i18n`) falls back to the
683
+ base `narration` / card copy, and the CLI warns which scenes fell back — partial
684
+ translation is allowed.
685
+
686
+ **One-take → N-renders flow.**
687
+
688
+ 1. `record` once (or a full default `render`) — produces the shared take and the
689
+ default-language MP4.
690
+ 2. For each extra language, run **only** the language-dependent stages against
691
+ the same take:
692
+ `aidemo voice <dir> --lang de` → `aidemo captions <dir> --lang de` →
693
+ `aidemo compose <dir> --lang de`. No re-record.
694
+ 3. Or use the convenience matrix: `aidemo render <dir> --langs de,fr` records
695
+ **once**, then voices + captions + composes each language. `aidemo compose
696
+ <dir> --langs de,fr` re-composes several languages from an existing take.
697
+
698
+ **Artifacts are namespaced so a language never clobbers the default:**
699
+
700
+ | Artifact | Default | `--lang de` |
701
+ |---|---|---|
702
+ | Per-scene + assembled audio | `audio/…` | `audio/de/…` |
703
+ | Voice manifest | `audio/voice.json` | `audio/de/voice.json` |
704
+ | Captions | `generated/captions.{srt,vtt,cues.json}` | `generated/captions.de.{srt,vtt,cues.json}` |
705
+ | Final video | `output/final-demo.mp4` | `output/final-demo.de.mp4` |
706
+
707
+ The **shared** take — `recordings/raw.*`, `generated/timeline.json`,
708
+ `generated/storyboard.json` — is not namespaced (that's what one recording feeds
709
+ every language). Running without `--lang` is byte-for-byte the old behavior.
710
+
711
+ **MCP.** `voice`, `captions`, `compose`, and `render` each take an optional
712
+ `lang` argument (same semantics; `render` records once then renders that
713
+ language). Author the `narrations`/`i18n` maps, then submit one job per language.
714
+
715
+ **Caveat (honest for v1): only the narration, captions, and card copy change —
716
+ the recorded UI does not.** On-screen text stays in whatever language the app
717
+ rendered during the take. That's the right tradeoff for a voiceover-led product
718
+ demo; if the UI itself must be localized, record a separate take per locale (a
719
+ different storyboard) rather than using `--lang`.
720
+
721
+ **Local-voice reality check.** The in-process Kokoro provider
722
+ (`AIDEMO_TTS_PROVIDER=local`) ships **English voices only** — en-US (`af_*`,
723
+ `am_*`) and en-GB (`bf_*`, `bm_*`) accents. It's perfect for `en` / `en-GB`
724
+ variants (and for validating the matrix offline), but a non-English `--lang`
725
+ would be read by an English voice and mispronounced — `voice` warns when you do
726
+ this. For genuine non-English speech, render that language with
727
+ `AIDEMO_TTS_PROVIDER=elevenlabs` (multilingual) or OpenAI TTS. Set the storyboard
728
+ `voice.voiceId` (globally, or per scene) if a language wants a different voice.