@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.
- package/.claude/skills/record-demo/SKILL.md +66 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/aidemo.mjs +53 -0
- package/docs/AUTHORING.md +728 -0
- package/package.json +47 -0
- package/src/caption-render.ts +69 -0
- package/src/captions.ts +441 -0
- package/src/capture.ts +574 -0
- package/src/cards.ts +67 -0
- package/src/cli.ts +646 -0
- package/src/compose.ts +937 -0
- package/src/config.ts +222 -0
- package/src/cursor-overlay.ts +186 -0
- package/src/cursor.ts +134 -0
- package/src/distribute.ts +713 -0
- package/src/embed.ts +202 -0
- package/src/ffmpeg.ts +83 -0
- package/src/gif.ts +65 -0
- package/src/golden.ts +109 -0
- package/src/i18n.ts +86 -0
- package/src/mcp/jobs.ts +285 -0
- package/src/mcp/server.ts +1016 -0
- package/src/music.ts +153 -0
- package/src/params.ts +134 -0
- package/src/player.ts +1173 -0
- package/src/project.ts +287 -0
- package/src/recorder.ts +320 -0
- package/src/starter.ts +141 -0
- package/src/stills.ts +121 -0
- package/src/types.ts +660 -0
- package/src/util.ts +171 -0
- package/src/variants.ts +117 -0
- package/src/voice-local.ts +230 -0
- package/src/voice.ts +405 -0
- package/src/zoom.ts +183 -0
|
@@ -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.
|