@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
package/src/types.ts
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The storyboard is the single source of truth for a demo. Claude (via the
|
|
5
|
+
* `record-demo` skill) authors it; the CLI mechanically executes it.
|
|
6
|
+
*
|
|
7
|
+
* One storyboard = an ordered list of scenes. Each scene bundles everything the
|
|
8
|
+
* pipeline needs for that beat: what to say (narration + voice plan), what music
|
|
9
|
+
* cue to use, and the browser actions to perform.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Actions — a small, fixed vocabulary the player interprets. No generated code
|
|
14
|
+
// is ever executed; this keeps recording deterministic, safe, and editable.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How to locate an element. Either a raw CSS/text selector on the main page, or
|
|
19
|
+
* a selector inside a named frame (e.g. a ChatGPT app widget iframe). Frames are
|
|
20
|
+
* declared once in the storyboard `frames` map.
|
|
21
|
+
*/
|
|
22
|
+
const TargetSchema = z.object({
|
|
23
|
+
/** Named frame to resolve via frameLocator; omit for the top page. */
|
|
24
|
+
frame: z.string().optional(),
|
|
25
|
+
/** Playwright selector. Prefer role/text/data-testid for stability. */
|
|
26
|
+
selector: z.string().optional(),
|
|
27
|
+
/**
|
|
28
|
+
* Shorthand semantic target the player knows how to resolve without a raw
|
|
29
|
+
* selector (currently: "composer" = the ChatGPT prompt box). Optional.
|
|
30
|
+
*/
|
|
31
|
+
named: z.enum(["composer"]).optional(),
|
|
32
|
+
/**
|
|
33
|
+
* When multiple frames match `frame` (e.g. a ChatGPT conversation renders one
|
|
34
|
+
* widget iframe per tool call), pick the NEWEST one. Essential for multi-turn
|
|
35
|
+
* demos so each `waitForWidget` targets the current reply, not the first.
|
|
36
|
+
* On a plain (frameless) target it picks the last matching ELEMENT instead —
|
|
37
|
+
* e.g. the newest assistant message in a conversation.
|
|
38
|
+
*/
|
|
39
|
+
last: z.boolean().optional(),
|
|
40
|
+
/** Pick the nth matching frame — or element, when frameless (0-based). Overridden by `last`. */
|
|
41
|
+
nth: z.number().optional(),
|
|
42
|
+
});
|
|
43
|
+
export type Target = z.infer<typeof TargetSchema>;
|
|
44
|
+
|
|
45
|
+
const BaseAction = { comment: z.string().optional() };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Scroll easing presets. All are eased wheel scrolls; they differ in feel:
|
|
49
|
+
* smooth = balanced ease-in-out (default), snappy = quick with a soft landing,
|
|
50
|
+
* glide = long cinematic drift, linear = constant rate.
|
|
51
|
+
*/
|
|
52
|
+
export const EasingPresetSchema = z.enum(["smooth", "snappy", "glide", "linear"]);
|
|
53
|
+
export type EasingPreset = z.infer<typeof EasingPresetSchema>;
|
|
54
|
+
|
|
55
|
+
export const ActionSchema = z.discriminatedUnion("op", [
|
|
56
|
+
z.object({ ...BaseAction, op: z.literal("goto"), url: z.string() }),
|
|
57
|
+
z.object({
|
|
58
|
+
...BaseAction,
|
|
59
|
+
op: z.literal("click"),
|
|
60
|
+
target: TargetSchema,
|
|
61
|
+
}),
|
|
62
|
+
z.object({
|
|
63
|
+
...BaseAction,
|
|
64
|
+
op: z.literal("type"),
|
|
65
|
+
target: TargetSchema,
|
|
66
|
+
text: z.string(),
|
|
67
|
+
/** Per-keystroke jitter for a human cadence. Default true. */
|
|
68
|
+
humanize: z.boolean().optional(),
|
|
69
|
+
}),
|
|
70
|
+
z.object({ ...BaseAction, op: z.literal("press"), key: z.string() }),
|
|
71
|
+
z.object({
|
|
72
|
+
...BaseAction,
|
|
73
|
+
op: z.literal("hover"),
|
|
74
|
+
target: TargetSchema,
|
|
75
|
+
}),
|
|
76
|
+
z.object({
|
|
77
|
+
...BaseAction,
|
|
78
|
+
op: z.literal("scrollTo"),
|
|
79
|
+
target: TargetSchema,
|
|
80
|
+
/** Scroll feel preset. Default "smooth". */
|
|
81
|
+
easing: EasingPresetSchema.optional(),
|
|
82
|
+
/** Override the preset's duration, ms. */
|
|
83
|
+
durationMs: z.number().optional(),
|
|
84
|
+
}),
|
|
85
|
+
z.object({
|
|
86
|
+
...BaseAction,
|
|
87
|
+
op: z.literal("scrollBy"),
|
|
88
|
+
/** CSS pixels to scroll; positive = down. */
|
|
89
|
+
dy: z.number(),
|
|
90
|
+
/** Scroll feel preset. Default "smooth". */
|
|
91
|
+
easing: EasingPresetSchema.optional(),
|
|
92
|
+
/** Override the preset's duration, ms. */
|
|
93
|
+
durationMs: z.number().optional(),
|
|
94
|
+
}),
|
|
95
|
+
z.object({
|
|
96
|
+
...BaseAction,
|
|
97
|
+
op: z.literal("waitFor"),
|
|
98
|
+
target: TargetSchema,
|
|
99
|
+
timeoutMs: z.number().optional(),
|
|
100
|
+
}),
|
|
101
|
+
/**
|
|
102
|
+
* Wait for an IN-PLACE mutation of an existing element — the gap that neither
|
|
103
|
+
* waitFor (fires instantly if the selector already matches) nor waitForWidget
|
|
104
|
+
* (waits for a wholly NEW widget) covers. Widget-side interactions re-render
|
|
105
|
+
* the same widget (click-Add renders the basket bar into [data-cart-bar]; the
|
|
106
|
+
* qty +/- re-renders the row), so the reliable signal is "this element's
|
|
107
|
+
* content changed", not "a new element appeared".
|
|
108
|
+
*
|
|
109
|
+
* Captures a baseline signature (presence + text) of `target`, then waits
|
|
110
|
+
* until it differs. With `textMatches`, waits for a change whose NEW text
|
|
111
|
+
* matches the (case-insensitive) JS regex — e.g. target the qty display and
|
|
112
|
+
* pass "2" to wait out a 1→2 re-render.
|
|
113
|
+
*/
|
|
114
|
+
z.object({
|
|
115
|
+
...BaseAction,
|
|
116
|
+
op: z.literal("waitForChange"),
|
|
117
|
+
target: TargetSchema,
|
|
118
|
+
/** Require the changed element's new text to match this JS regex (i-flag). */
|
|
119
|
+
textMatches: z.string().optional(),
|
|
120
|
+
timeoutMs: z.number().optional(),
|
|
121
|
+
/** Record the elapsed span as idle (like waitForWidget) so compose trims it. */
|
|
122
|
+
idle: z.boolean().optional(),
|
|
123
|
+
label: z.string().default("updating"),
|
|
124
|
+
}),
|
|
125
|
+
/**
|
|
126
|
+
* Like waitFor but the elapsed span is recorded as IDLE in the timeline.
|
|
127
|
+
* Use it for "ChatGPT is thinking" waits so compose can trim/speed them.
|
|
128
|
+
*/
|
|
129
|
+
z.object({
|
|
130
|
+
...BaseAction,
|
|
131
|
+
op: z.literal("waitForWidget"),
|
|
132
|
+
target: TargetSchema,
|
|
133
|
+
/**
|
|
134
|
+
* Require the NEW widget frame's text to match this JS regex (i-flag).
|
|
135
|
+
* Different tools share selectors (product-carousel and product-compare
|
|
136
|
+
* both carry button[data-add-id]); when a prompt could render either, an
|
|
137
|
+
* unqualified wait passes on the wrong widget and the desync only shows in
|
|
138
|
+
* review. Pick a string unique to the widget type you expect.
|
|
139
|
+
*/
|
|
140
|
+
textMatches: z.string().optional(),
|
|
141
|
+
label: z.string().default("thinking"),
|
|
142
|
+
timeoutMs: z.number().optional(),
|
|
143
|
+
}),
|
|
144
|
+
/**
|
|
145
|
+
* Wait for a NEW assistant reply that answers in TEXT (no widget) — the
|
|
146
|
+
* main-frame twin of waitForWidget, for tools that reply without rendering
|
|
147
|
+
* one (e.g. a delivery-options lookup). Baseline = assistant-message count at
|
|
148
|
+
* wait start; satisfied when it grows AND generation has finished (composer
|
|
149
|
+
* ready again). Always recorded as IDLE so compose trims the thinking time.
|
|
150
|
+
*/
|
|
151
|
+
z.object({
|
|
152
|
+
...BaseAction,
|
|
153
|
+
op: z.literal("waitForReply"),
|
|
154
|
+
/** Selector counting assistant messages. Default: ChatGPT's message nodes. */
|
|
155
|
+
selector: z.string().optional(),
|
|
156
|
+
/** Require the new reply's text to match this JS regex (i-flag). */
|
|
157
|
+
textMatches: z.string().optional(),
|
|
158
|
+
label: z.string().default("thinking"),
|
|
159
|
+
timeoutMs: z.number().optional(),
|
|
160
|
+
}),
|
|
161
|
+
/** Fixed pause (non-idle) — a deliberate on-screen beat. */
|
|
162
|
+
z.object({ ...BaseAction, op: z.literal("pause"), ms: z.number() }),
|
|
163
|
+
/**
|
|
164
|
+
* Deliberate camera focus: record a zoom-in on `target` without interacting
|
|
165
|
+
* with it (auto-zoom already fires on clicks/typing). Purely a timeline
|
|
166
|
+
* marker — the zoom itself is rendered at compose time.
|
|
167
|
+
*/
|
|
168
|
+
z.object({
|
|
169
|
+
...BaseAction,
|
|
170
|
+
op: z.literal("focus"),
|
|
171
|
+
target: TargetSchema,
|
|
172
|
+
/** Zoom level for this focus; defaults to the storyboard zoom scale. */
|
|
173
|
+
scale: z.number().min(1).max(4).optional(),
|
|
174
|
+
/** How long to stay zoomed, ms; defaults to the storyboard zoom hold. */
|
|
175
|
+
holdMs: z.number().optional(),
|
|
176
|
+
}),
|
|
177
|
+
/**
|
|
178
|
+
* Screenshot mode: mark a named still at this beat. Purely a timeline marker
|
|
179
|
+
* (like `focus`) — NO screenshot is taken at record time. `aidemo stills`
|
|
180
|
+
* (and `render`) extract the PNG at compose time from the CLEAN take, so a
|
|
181
|
+
* still is a re-extract away and never a re-record. `name` becomes the file
|
|
182
|
+
* name (`output/stills/<name>.png`) so keep it a simple slug; duplicate names
|
|
183
|
+
* across the storyboard are a hard error at extraction.
|
|
184
|
+
*/
|
|
185
|
+
z.object({ ...BaseAction, op: z.literal("still"), name: z.string() }),
|
|
186
|
+
]);
|
|
187
|
+
export type Action = z.infer<typeof ActionSchema>;
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Voice + music plans
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
export const VoicePlanSchema = z.object({
|
|
194
|
+
/**
|
|
195
|
+
* Voice id for the active TTS provider. Default provider = OpenAI (alloy,
|
|
196
|
+
* ash, ballad, cedar, coral, echo, fable, marin, nova, onyx, sage, shimmer,
|
|
197
|
+
* verse); with AIDEMO_TTS_PROVIDER=elevenlabs this is an ElevenLabs voice id.
|
|
198
|
+
*/
|
|
199
|
+
voiceId: z.string().default("marin"),
|
|
200
|
+
/** Steering prompt for gpt-4o-mini-tts: tone, emotion, pace. */
|
|
201
|
+
instructions: z.string().optional(),
|
|
202
|
+
/** Playback speed hint passed to the provider when supported. Default 1.0. */
|
|
203
|
+
speed: z.number().min(0.5).max(2).optional(),
|
|
204
|
+
});
|
|
205
|
+
export type VoicePlan = z.infer<typeof VoicePlanSchema>;
|
|
206
|
+
|
|
207
|
+
export const MusicCueSchema = z.object({
|
|
208
|
+
/**
|
|
209
|
+
* Named cue within the track. NOTE: currently informational only — per-scene
|
|
210
|
+
* cues do nothing (the bed plays continuously and ducks under narration).
|
|
211
|
+
* Setting one logs a warning at storyboard load so it isn't a silent no-op.
|
|
212
|
+
*/
|
|
213
|
+
cue: z.string().optional(),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Scene + Storyboard
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
export const SceneSchema = z.object({
|
|
221
|
+
id: z.string(),
|
|
222
|
+
/** Spoken narration for this beat. Also the caption source of truth. */
|
|
223
|
+
narration: z.string(),
|
|
224
|
+
/**
|
|
225
|
+
* Translated narration per language code (e.g. {"de":"…","fr":"…"}) for
|
|
226
|
+
* multi-language renders from ONE take (see src/i18n.ts). `render`/`voice`/
|
|
227
|
+
* `captions`/`compose --lang <code>` speak/caption `narrations[code]` instead
|
|
228
|
+
* of `narration` over the shared recording; the default (no `--lang`) always
|
|
229
|
+
* uses `narration`. Translations are authored — the pipeline never calls an
|
|
230
|
+
* LLM to translate. A code missing here falls back to `narration`.
|
|
231
|
+
*/
|
|
232
|
+
narrations: z.record(z.string(), z.string()).optional(),
|
|
233
|
+
voice: VoicePlanSchema.optional(),
|
|
234
|
+
music: MusicCueSchema.optional(),
|
|
235
|
+
/** Set false to suppress auto-zoom for this scene's clicks/typing. */
|
|
236
|
+
zoom: z.boolean().optional(),
|
|
237
|
+
actions: z.array(ActionSchema).default([]),
|
|
238
|
+
});
|
|
239
|
+
export type Scene = z.infer<typeof SceneSchema>;
|
|
240
|
+
|
|
241
|
+
export const MusicPlanSchema = z.object({
|
|
242
|
+
/** Path to a local audio file, relative to the project dir, or absolute. */
|
|
243
|
+
track: z.string().optional(),
|
|
244
|
+
/**
|
|
245
|
+
* Ducking mode. "sidechain" (default) compresses the music with the narration
|
|
246
|
+
* as the key signal, so the bed breathes — up in pauses, down under speech.
|
|
247
|
+
* "constant" is the legacy fixed-level duck (see duckToDb).
|
|
248
|
+
*/
|
|
249
|
+
ducking: z.enum(["sidechain", "constant"]).default("sidechain"),
|
|
250
|
+
/** Base music-bed gain in dB (pre-duck level heard in narration gaps). Default -14. */
|
|
251
|
+
gainDb: z.number().optional(),
|
|
252
|
+
/** Constant-mode ducked level for the whole bed, in dB. Default -22. */
|
|
253
|
+
duckToDb: z.number().optional(),
|
|
254
|
+
/** Sidechain knobs (sane defaults; rarely need touching). */
|
|
255
|
+
duckThreshold: z.number().optional(),
|
|
256
|
+
duckRatio: z.number().optional(),
|
|
257
|
+
duckAttackMs: z.number().optional(),
|
|
258
|
+
duckReleaseMs: z.number().optional(),
|
|
259
|
+
/** Fade the music out over the last N ms of the video. Default 1800. */
|
|
260
|
+
fadeOutMs: z.number().optional(),
|
|
261
|
+
});
|
|
262
|
+
export type MusicPlan = z.infer<typeof MusicPlanSchema>;
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Cinematic polish — auto-zoom config and intro/outro cards
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Screen-Studio-style auto-zoom. When present on the storyboard, compose zooms
|
|
270
|
+
* into each recorded focus point (clicks, typing, explicit `focus` actions)
|
|
271
|
+
* with eased in/hold/out choreography, panning between nearby focus points
|
|
272
|
+
* instead of zooming out. Omit the key entirely to disable.
|
|
273
|
+
*/
|
|
274
|
+
export const ZoomConfigSchema = z.object({
|
|
275
|
+
enabled: z.boolean().default(true),
|
|
276
|
+
/** Zoom-in level. 1.4–1.8 reads well at 720p. Default 1.55. */
|
|
277
|
+
scale: z.number().min(1).max(4).default(1.55),
|
|
278
|
+
/** Zoom in/out transition length, ms. Default 600. */
|
|
279
|
+
easeMs: z.number().default(600),
|
|
280
|
+
/** How long to stay zoomed after a focus point, ms. Default 1700. */
|
|
281
|
+
holdMs: z.number().default(1700),
|
|
282
|
+
});
|
|
283
|
+
export type ZoomConfig = z.infer<typeof ZoomConfigSchema>;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Motion blur (opt-in, compose-time). Averages a small sliding window of frames
|
|
287
|
+
* (ffmpeg `tmix`), so fast motion — cursor glides, eased scrolls, zoom pans —
|
|
288
|
+
* gets a subtle trail while static UI stays sharp (identical frames average to
|
|
289
|
+
* themselves). Pure post-processing over the content: omit the key and compose
|
|
290
|
+
* behaves exactly as before. Portable — `tmix` is a baseline libavfilter filter,
|
|
291
|
+
* no drawtext/subtitles needed.
|
|
292
|
+
*/
|
|
293
|
+
export const MotionBlurSchema = z.object({
|
|
294
|
+
enabled: z.boolean().default(true),
|
|
295
|
+
/** Frames averaged in the sliding window. 2 = whisper, 3 = default, up to 6. */
|
|
296
|
+
frames: z.number().int().min(2).max(6).default(3),
|
|
297
|
+
});
|
|
298
|
+
export type MotionBlur = z.infer<typeof MotionBlurSchema>;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Cursor rendering (opt-in, compose-time control). Omit the key for the default:
|
|
302
|
+
* the animated cursor is BAKED into the recording (unchanged, byte-for-byte).
|
|
303
|
+
*
|
|
304
|
+
* Add a `cursor` block and the cursor becomes a **compose-time layer**: the
|
|
305
|
+
* recorder leaves the take cursor-free while the player logs the cursor path,
|
|
306
|
+
* and compose draws the cursor as an overlay along that path (over the content,
|
|
307
|
+
* so it zooms/pans with the frame). Because it's now a compose layer, styling it
|
|
308
|
+
* is a recompose, never a re-record — hide it for the whole demo (`hidden`, for
|
|
309
|
+
* clean product shots), hide it only on certain scenes (`hideScenes`), or resize
|
|
310
|
+
* it (`scale`). Requires re-recording ONCE with the block present (so the clean,
|
|
311
|
+
* cursor-free take + path exist); after that, every hide/resize is compose-only.
|
|
312
|
+
*/
|
|
313
|
+
export const CursorConfigSchema = z.object({
|
|
314
|
+
/** Hide the cursor for the entire demo (no overlay drawn). */
|
|
315
|
+
hidden: z.boolean().optional(),
|
|
316
|
+
/** Hide the cursor only during these scene ids (e.g. a full-screen result). */
|
|
317
|
+
hideScenes: z.array(z.string()).optional(),
|
|
318
|
+
/** Cursor size multiplier (1 = the 24px baseline arrow). */
|
|
319
|
+
scale: z.number().min(0.5).max(4).optional(),
|
|
320
|
+
});
|
|
321
|
+
export type CursorConfig = z.infer<typeof CursorConfigSchema>;
|
|
322
|
+
|
|
323
|
+
/** A rendered intro/outro title card. */
|
|
324
|
+
export const CardSchema = z.object({
|
|
325
|
+
title: z.string(),
|
|
326
|
+
subtitle: z.string().optional(),
|
|
327
|
+
/** On-screen time, ms (excluding fades). Default 2600. */
|
|
328
|
+
durationMs: z.number().default(2600),
|
|
329
|
+
/** CSS background (color or gradient). Default: dark gradient. */
|
|
330
|
+
background: z.string().optional(),
|
|
331
|
+
/** Accent color for the underline detail. Default #6c8cff. */
|
|
332
|
+
accent: z.string().optional(),
|
|
333
|
+
/** Fade in/out length, ms. Default 350. */
|
|
334
|
+
fadeMs: z.number().default(350),
|
|
335
|
+
/**
|
|
336
|
+
* Translated card copy per language code, for multi-language renders
|
|
337
|
+
* (`--lang <code>`). Each entry may override `title` and/or `subtitle`; an
|
|
338
|
+
* absent field falls back to the base card. Only narration + captions + card
|
|
339
|
+
* copy localize — the recorded UI itself is unchanged.
|
|
340
|
+
*/
|
|
341
|
+
i18n: z
|
|
342
|
+
.record(
|
|
343
|
+
z.string(),
|
|
344
|
+
z.object({ title: z.string().optional(), subtitle: z.string().optional() })
|
|
345
|
+
)
|
|
346
|
+
.optional(),
|
|
347
|
+
});
|
|
348
|
+
export type Card = z.infer<typeof CardSchema>;
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Scene-to-scene transition (opt-in). When present, compose crossfades the
|
|
352
|
+
* video across every scene boundary instead of hard-cutting. The overlap is
|
|
353
|
+
* stolen from each scene's own frozen tail so the timeline does NOT shrink —
|
|
354
|
+
* total duration and per-scene narration alignment are preserved exactly.
|
|
355
|
+
* Omit the key for the default hard cuts (unchanged stream-copy concat).
|
|
356
|
+
*/
|
|
357
|
+
export const TransitionSchema = z.object({
|
|
358
|
+
type: z.literal("crossfade"),
|
|
359
|
+
/** Crossfade length, ms. Default 400. */
|
|
360
|
+
durationMs: z.number().default(400),
|
|
361
|
+
});
|
|
362
|
+
export type Transition = z.infer<typeof TransitionSchema>;
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Master loudness target for the final muxed audio (ffmpeg `loudnorm`). Lands
|
|
366
|
+
* the master at streaming/broadcast norms so it isn't quiet next to anything
|
|
367
|
+
* else the viewer plays. A single-pass `loudnorm` runs LAST over the muxed
|
|
368
|
+
* audio; it is default-ON only when a `music` block is present (the music mix
|
|
369
|
+
* otherwise leaves the master ~-29 LUFS) and OFF for a plain narration-only
|
|
370
|
+
* render. See `output.loudness` to override or disable. Partial overrides fill
|
|
371
|
+
* from these defaults.
|
|
372
|
+
*/
|
|
373
|
+
export const LoudnessSchema = z.object({
|
|
374
|
+
/** Integrated loudness target, LUFS. Default -16 (YouTube/podcast norm). */
|
|
375
|
+
integrated: z.number().default(-16),
|
|
376
|
+
/** Maximum true peak, dBTP. Default -1.5. */
|
|
377
|
+
truePeak: z.number().default(-1.5),
|
|
378
|
+
/** Loudness range target, LU. Default 11. */
|
|
379
|
+
lra: z.number().default(11),
|
|
380
|
+
});
|
|
381
|
+
export type Loudness = z.infer<typeof LoudnessSchema>;
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Final output sizing + loudness (opt-in). Renders the composed video at a
|
|
385
|
+
* different size/aspect than the recording — e.g. a vertical 1080x1920 social
|
|
386
|
+
* clip from a 1280x720 take. Applied AFTER cards + captions, so the whole frame
|
|
387
|
+
* is scaled as one. Omit to keep the recording size. width/height should be
|
|
388
|
+
* even (yuv420p) and are set together. Uses core scale/pad/crop filters only.
|
|
389
|
+
* `loudness` (independent of resize) tunes the master level; omit it entirely
|
|
390
|
+
* for the automatic default (loudnorm when there's music, none otherwise).
|
|
391
|
+
*/
|
|
392
|
+
export const OutputSchema = z
|
|
393
|
+
.object({
|
|
394
|
+
width: z.number().optional(),
|
|
395
|
+
height: z.number().optional(),
|
|
396
|
+
/**
|
|
397
|
+
* "contain" (default) = scale to fit + pad the remainder (letterbox bars).
|
|
398
|
+
* "cover" = scale to fill + center-crop the overflow (no bars).
|
|
399
|
+
*/
|
|
400
|
+
fit: z.enum(["contain", "cover"]).default("contain"),
|
|
401
|
+
/** Pad color for "contain" (ffmpeg color syntax, e.g. black, 0x1a1a1a). Default black. */
|
|
402
|
+
background: z.string().optional(),
|
|
403
|
+
/**
|
|
404
|
+
* Master loudness normalization. `false` disables the final loudnorm pass;
|
|
405
|
+
* an object overrides the targets (partial objects fill from LoudnessSchema
|
|
406
|
+
* defaults) and also FORCES the pass on even for a narration-only master.
|
|
407
|
+
* Omit for the default: loudnorm when a `music` block is present, none for a
|
|
408
|
+
* plain narration render (so that path stays byte-for-byte unchanged).
|
|
409
|
+
*/
|
|
410
|
+
loudness: z.union([z.literal(false), LoudnessSchema]).optional(),
|
|
411
|
+
})
|
|
412
|
+
// Resizing needs both dimensions or neither; loudness works on its own.
|
|
413
|
+
.refine((o) => (o.width == null) === (o.height == null), {
|
|
414
|
+
message: "output.width and output.height must be set together",
|
|
415
|
+
});
|
|
416
|
+
export type Output = z.infer<typeof OutputSchema>;
|
|
417
|
+
|
|
418
|
+
export const StoryboardSchema = z.object({
|
|
419
|
+
title: z.string(),
|
|
420
|
+
/**
|
|
421
|
+
* BCP-47/ISO-639-1 code for the base `narration`'s spoken language (e.g.
|
|
422
|
+
* "et" for Estonian, "de" for German). Optional metadata — distinct from
|
|
423
|
+
* the `--lang` render-selection mechanism (src/i18n.ts), which picks a
|
|
424
|
+
* scene's `narrations[code]` translation; this field describes whatever
|
|
425
|
+
* language the DEFAULT `narration` is already written in (useful for a
|
|
426
|
+
* monolingual non-English demo with no translations at all). `captions`
|
|
427
|
+
* uses it (falling back to an active `--lang`) to hint Whisper's
|
|
428
|
+
* `language` param and to warn that `--offline` is the guaranteed-correct
|
|
429
|
+
* fallback for non-English narration. Omit for English / to auto-detect.
|
|
430
|
+
*/
|
|
431
|
+
language: z.string().optional(),
|
|
432
|
+
/**
|
|
433
|
+
* Declared template parameters: name → default value. Enables `{{name}}`
|
|
434
|
+
* placeholders in any storyboard string (narration, action url, `type` text,
|
|
435
|
+
* card title/subtitle, waitFor* textMatches, voice instructions, …). At load
|
|
436
|
+
* time each param resolves to a `--param`/MCP/variant override else its
|
|
437
|
+
* default; unresolved or undeclared placeholders are a hard load-time error.
|
|
438
|
+
* Omit entirely for a non-parameterized storyboard (renders identically).
|
|
439
|
+
*/
|
|
440
|
+
params: z.record(z.string(), z.string()).optional(),
|
|
441
|
+
targetLengthSeconds: z.number().optional(),
|
|
442
|
+
/** Recording viewport / video size. Default 1280x720. */
|
|
443
|
+
video: z
|
|
444
|
+
.object({ width: z.number(), height: z.number() })
|
|
445
|
+
.default({ width: 1280, height: 720 }),
|
|
446
|
+
/** Named frames used by actions, mapping name -> iframe selector. */
|
|
447
|
+
frames: z.record(z.string(), z.string()).default({}),
|
|
448
|
+
/** Default voice plan; scenes may override. */
|
|
449
|
+
voice: VoicePlanSchema.optional(),
|
|
450
|
+
music: MusicPlanSchema.optional(),
|
|
451
|
+
/** Auto-zoom on focus. Omit to disable (no zoom). */
|
|
452
|
+
zoom: ZoomConfigSchema.optional(),
|
|
453
|
+
/** Optional title cards around the demo (music plays under both). */
|
|
454
|
+
intro: CardSchema.optional(),
|
|
455
|
+
outro: CardSchema.optional(),
|
|
456
|
+
/** Scene-to-scene transition (opt-in crossfade). Omit for hard cuts. */
|
|
457
|
+
transition: TransitionSchema.optional(),
|
|
458
|
+
/** Final output sizing (opt-in letterbox/crop). Omit to keep recording size. */
|
|
459
|
+
output: OutputSchema.optional(),
|
|
460
|
+
/** Subtle motion blur on fast motion (opt-in, compose-time). Omit to disable. */
|
|
461
|
+
motionBlur: MotionBlurSchema.optional(),
|
|
462
|
+
/** Compose-time cursor control (hide/resize post-hoc). Omit to bake the cursor. */
|
|
463
|
+
cursor: CursorConfigSchema.optional(),
|
|
464
|
+
scenes: z.array(SceneSchema).min(1),
|
|
465
|
+
});
|
|
466
|
+
export type Storyboard = z.infer<typeof StoryboardSchema>;
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Timeline manifest — emitted by the player during recording. Wall-clock
|
|
470
|
+
// offsets in ms, relative to record start (t=0). Drives sync + gap-trimming.
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
export const IdleSpanSchema = z.object({
|
|
474
|
+
startMs: z.number(),
|
|
475
|
+
endMs: z.number(),
|
|
476
|
+
label: z.string(),
|
|
477
|
+
});
|
|
478
|
+
export type IdleSpan = z.infer<typeof IdleSpanSchema>;
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* A point of user attention the player recorded (click, typing start, or an
|
|
482
|
+
* explicit `focus` action). Coordinates are viewport CSS px at the moment of
|
|
483
|
+
* the event; tMs is timeline time. Compose turns these into auto-zoom moves.
|
|
484
|
+
*/
|
|
485
|
+
export const FocusEventSchema = z.object({
|
|
486
|
+
tMs: z.number(),
|
|
487
|
+
x: z.number(),
|
|
488
|
+
y: z.number(),
|
|
489
|
+
kind: z.string().optional(),
|
|
490
|
+
/** Per-event overrides (from the `focus` action). */
|
|
491
|
+
scale: z.number().optional(),
|
|
492
|
+
holdMs: z.number().optional(),
|
|
493
|
+
});
|
|
494
|
+
export type FocusEvent = z.infer<typeof FocusEventSchema>;
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* A named still the player recorded (from a `still` action). tMs is timeline
|
|
498
|
+
* time; screenshot mode maps it to take-video time and extracts a PNG at
|
|
499
|
+
* compose time. Default [] keeps pre-screenshot-mode timelines valid.
|
|
500
|
+
*/
|
|
501
|
+
export const StillEventSchema = z.object({
|
|
502
|
+
tMs: z.number(),
|
|
503
|
+
name: z.string(),
|
|
504
|
+
});
|
|
505
|
+
export type StillEvent = z.infer<typeof StillEventSchema>;
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A cursor position the player sampled while gliding the mouse (viewport CSS px
|
|
509
|
+
* at `tMs` timeline time). Only recorded when the storyboard opts into
|
|
510
|
+
* compose-time cursor rendering (`cursor` block) — compose replays these as an
|
|
511
|
+
* overlay along the path. Default [] keeps pre-cursor-overlay timelines valid.
|
|
512
|
+
*/
|
|
513
|
+
export const CursorSampleSchema = z.object({
|
|
514
|
+
tMs: z.number(),
|
|
515
|
+
x: z.number(),
|
|
516
|
+
y: z.number(),
|
|
517
|
+
});
|
|
518
|
+
export type CursorSample = z.infer<typeof CursorSampleSchema>;
|
|
519
|
+
|
|
520
|
+
export const TimelineSceneSchema = z.object({
|
|
521
|
+
id: z.string(),
|
|
522
|
+
startMs: z.number(),
|
|
523
|
+
endMs: z.number(),
|
|
524
|
+
idleSpans: z.array(IdleSpanSchema).default([]),
|
|
525
|
+
focusEvents: z.array(FocusEventSchema).default([]),
|
|
526
|
+
stillEvents: z.array(StillEventSchema).default([]),
|
|
527
|
+
cursorSamples: z.array(CursorSampleSchema).default([]),
|
|
528
|
+
});
|
|
529
|
+
export type TimelineScene = z.infer<typeof TimelineSceneSchema>;
|
|
530
|
+
|
|
531
|
+
export const TimelineSchema = z.object({
|
|
532
|
+
totalMs: z.number(),
|
|
533
|
+
/**
|
|
534
|
+
* Ms between video-recording start and timeline t=0 (the about:blank lead-in).
|
|
535
|
+
* compose adds this to every scene offset to map timeline time -> video time.
|
|
536
|
+
*/
|
|
537
|
+
leadInMs: z.number().default(0),
|
|
538
|
+
scenes: z.array(TimelineSceneSchema),
|
|
539
|
+
});
|
|
540
|
+
export type Timeline = z.infer<typeof TimelineSchema>;
|
|
541
|
+
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
// Voice manifest — emitted by the voice step. compose uses per-scene durations
|
|
544
|
+
// as the target length for each scene's video segment.
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
export const VoiceManifestSchema = z.object({
|
|
548
|
+
/** Silence inserted between scenes in the narration track, ms. */
|
|
549
|
+
gapMs: z.number(),
|
|
550
|
+
scenes: z.array(
|
|
551
|
+
z.object({
|
|
552
|
+
id: z.string(),
|
|
553
|
+
file: z.string(),
|
|
554
|
+
durationMs: z.number(),
|
|
555
|
+
/**
|
|
556
|
+
* sha256 of (narration + resolved voice plan). Lets a re-run skip TTS for
|
|
557
|
+
* scenes whose narration/voice is unchanged. Optional for back-compat with
|
|
558
|
+
* manifests written before hashing existed.
|
|
559
|
+
*/
|
|
560
|
+
hash: z.string().optional(),
|
|
561
|
+
})
|
|
562
|
+
),
|
|
563
|
+
});
|
|
564
|
+
export type VoiceManifest = z.infer<typeof VoiceManifestSchema>;
|
|
565
|
+
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
// Captions manifest — emitted by the captions step. Mirrors voice.json's
|
|
568
|
+
// content-hash reuse (VoiceManifest.hash): a captions re-run whose inputs are
|
|
569
|
+
// unchanged reuses the stored word timings instead of re-transcribing. Optional
|
|
570
|
+
// artifact; captions still work if it's absent (first run / deleted).
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
/** A word with scene-relative timing (seconds from the scene's own start). */
|
|
574
|
+
export const CaptionWordSchema = z.object({
|
|
575
|
+
word: z.string(),
|
|
576
|
+
start: z.number(),
|
|
577
|
+
end: z.number(),
|
|
578
|
+
});
|
|
579
|
+
export type CaptionWord = z.infer<typeof CaptionWordSchema>;
|
|
580
|
+
|
|
581
|
+
export const CaptionCueSchema = z.object({
|
|
582
|
+
index: z.number(),
|
|
583
|
+
startMs: z.number(),
|
|
584
|
+
endMs: z.number(),
|
|
585
|
+
text: z.string(),
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
export const CaptionsManifestSchema = z.object({
|
|
589
|
+
/** Which path wrote this: "stt" (Whisper/local STT) or "offline" (script-timed). */
|
|
590
|
+
mode: z.enum(["stt", "offline"]),
|
|
591
|
+
/**
|
|
592
|
+
* Combined hash of every caption-affecting input. A full match means the
|
|
593
|
+
* stored cues can be reused verbatim — no transcription at all.
|
|
594
|
+
*/
|
|
595
|
+
inputHash: z.string(),
|
|
596
|
+
/** Cue-grouping config; a change re-segments even with identical words. */
|
|
597
|
+
config: z.object({
|
|
598
|
+
gapMs: z.number(),
|
|
599
|
+
maxWords: z.number(),
|
|
600
|
+
maxCueMs: z.number(),
|
|
601
|
+
}),
|
|
602
|
+
/**
|
|
603
|
+
* Per-scene inputs + reusable scene-relative word timings (offline path only).
|
|
604
|
+
* Enables per-scene reuse: only a scene whose narration/duration changed is
|
|
605
|
+
* re-derived; the rest keep their stored words.
|
|
606
|
+
*/
|
|
607
|
+
scenes: z
|
|
608
|
+
.array(
|
|
609
|
+
z.object({
|
|
610
|
+
id: z.string(),
|
|
611
|
+
/** sha256 of (narration + durationMs + config) — the scene's caption identity. */
|
|
612
|
+
hash: z.string(),
|
|
613
|
+
durationMs: z.number(),
|
|
614
|
+
words: z.array(CaptionWordSchema),
|
|
615
|
+
})
|
|
616
|
+
)
|
|
617
|
+
.optional(),
|
|
618
|
+
/** The assembled cues — the reusable result (both modes). */
|
|
619
|
+
cues: z.array(CaptionCueSchema),
|
|
620
|
+
});
|
|
621
|
+
export type CaptionsManifest = z.infer<typeof CaptionsManifestSchema>;
|
|
622
|
+
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
// Probe golden — a normalized, deterministic projection of a probe run, used as
|
|
625
|
+
// a checked-in regression baseline (vhs's golden-file pattern). Timing-free by
|
|
626
|
+
// design: only stable outcomes (op, resolved target, ok, found, goto final URL)
|
|
627
|
+
// so `aidemo probe --golden` fails a CI check when a UI change breaks the flow.
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
|
|
630
|
+
export const ProbeActionOutcomeSchema = z.object({
|
|
631
|
+
op: z.string(),
|
|
632
|
+
/** For target actions: the resolved selector (named→selector, frame-prefixed).
|
|
633
|
+
* For goto: the requested URL. Omitted for press/pause/scrollBy. */
|
|
634
|
+
target: z.string().optional(),
|
|
635
|
+
/** The action completed without throwing (deterministic replay outcome). */
|
|
636
|
+
ok: z.boolean(),
|
|
637
|
+
/** Target-bearing actions: did the selector resolve to ≥1 element on the page. */
|
|
638
|
+
found: z.boolean().optional(),
|
|
639
|
+
/** goto only: page URL after navigation (redirects surface here). */
|
|
640
|
+
finalUrl: z.string().optional(),
|
|
641
|
+
key: z.string().optional(),
|
|
642
|
+
ms: z.number().optional(),
|
|
643
|
+
dy: z.number().optional(),
|
|
644
|
+
label: z.string().optional(),
|
|
645
|
+
});
|
|
646
|
+
export type ProbeActionOutcome = z.infer<typeof ProbeActionOutcomeSchema>;
|
|
647
|
+
|
|
648
|
+
export const ProbeGoldenSceneSchema = z.object({
|
|
649
|
+
id: z.string(),
|
|
650
|
+
actions: z.array(ProbeActionOutcomeSchema),
|
|
651
|
+
});
|
|
652
|
+
export type ProbeGoldenScene = z.infer<typeof ProbeGoldenSceneSchema>;
|
|
653
|
+
|
|
654
|
+
export const ProbeGoldenSchema = z.object({
|
|
655
|
+
version: z.literal(1),
|
|
656
|
+
title: z.string(),
|
|
657
|
+
video: z.object({ width: z.number(), height: z.number() }),
|
|
658
|
+
scenes: z.array(ProbeGoldenSceneSchema),
|
|
659
|
+
});
|
|
660
|
+
export type ProbeGolden = z.infer<typeof ProbeGoldenSchema>;
|