@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/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>;