@tandryukha/aidemo 0.8.0 → 0.14.1

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 CHANGED
@@ -42,7 +42,35 @@ const TargetSchema = z.object({
42
42
  });
43
43
  export type Target = z.infer<typeof TargetSchema>;
44
44
 
45
- const BaseAction = { comment: z.string().optional() };
45
+ const BaseAction = {
46
+ comment: z.string().optional(),
47
+ /**
48
+ * Best-effort action: when its target never appears or the interaction
49
+ * fails, log and continue instead of failing the take. For state-dependent
50
+ * one-time UI — e.g. collapsing the ChatGPT sidebar persists in the Chrome
51
+ * profile, so a re-run finds the close button absent and a required click
52
+ * would kill the run. Interaction ops (click/type/hover/scrollTo/focus)
53
+ * probe briefly for the target instead of stalling out the full action
54
+ * timeout; wait ops honor their own timeoutMs and then continue. Don't mark
55
+ * actions the demo's visuals depend on — a skipped optional action is only
56
+ * a log line.
57
+ */
58
+ optional: z.boolean().optional(),
59
+ /**
60
+ * Re-attempt an interaction (click/type/hover/scrollTo/focus/moveTo/assert)
61
+ * up to this many extra times when it throws — a beat apart — before the
62
+ * take fails. For UI that re-renders under the cursor (a list that
63
+ * reorders on load, a button that mounts twice). Retries land in
64
+ * timeline.json `actions[].retries`. Optional actions skip instead.
65
+ */
66
+ retry: z.number().int().min(0).max(5).optional(),
67
+ /**
68
+ * Narration anchor: land this action on the word marked `{{@name}}` in the
69
+ * scene's narration. Compose retimes the scene piecewise so the beat plays
70
+ * as the word is spoken. See docs/AUTHORING.md "Narration-anchored beats".
71
+ */
72
+ anchor: z.string().regex(/^[\w.-]+$/).optional(),
73
+ };
46
74
 
47
75
  /**
48
76
  * Scroll easing presets. All are eased wheel scrolls; they differ in feel:
@@ -52,12 +80,39 @@ const BaseAction = { comment: z.string().optional() };
52
80
  export const EasingPresetSchema = z.enum(["smooth", "snappy", "glide", "linear"]);
53
81
  export type EasingPreset = z.infer<typeof EasingPresetSchema>;
54
82
 
83
+ /**
84
+ * Element state a wait/scroll resolves on. "visible" (default) is Playwright's
85
+ * non-empty-box visibility; "attached" only requires the element to be in the
86
+ * DOM — the escape hatch for zero-height lazy mount points.
87
+ */
88
+ export const WaitStateSchema = z.enum(["visible", "attached"]);
89
+ export type WaitState = z.infer<typeof WaitStateSchema>;
90
+
91
+ /** Look of an attention overlay (highlight ring, spotlight edge, callout pill). */
92
+ export const AttentionStyleSchema = z.object({
93
+ /** CSS color. Default: the storyboard `attention.color`, else #ff5a5f. */
94
+ color: z.string().optional(),
95
+ /** Outline thickness, px. Default 3. */
96
+ thickness: z.number().min(1).max(12).optional(),
97
+ /** Room between the element's box and the outline, px. Default 6. */
98
+ padding: z.number().min(0).max(60).optional(),
99
+ /** "box" (rounded rectangle, default) or "ring" (pill/circle). */
100
+ shape: z.enum(["box", "ring"]).optional(),
101
+ });
102
+ export type AttentionStyle = z.infer<typeof AttentionStyleSchema>;
103
+
55
104
  export const ActionSchema = z.discriminatedUnion("op", [
56
105
  z.object({ ...BaseAction, op: z.literal("goto"), url: z.string() }),
57
106
  z.object({
58
107
  ...BaseAction,
59
108
  op: z.literal("click"),
60
109
  target: TargetSchema,
110
+ /**
111
+ * When the click opens a NEW TAB (target=_blank, window.open), close it and
112
+ * continue in the recorded tab at that URL — the take is one window, a
113
+ * second tab is never in the video. Default false (a popup is left alone).
114
+ */
115
+ followPopup: z.boolean().optional(),
61
116
  }),
62
117
  z.object({
63
118
  ...BaseAction,
@@ -67,7 +122,13 @@ export const ActionSchema = z.discriminatedUnion("op", [
67
122
  /** Per-keystroke jitter for a human cadence. Default true. */
68
123
  humanize: z.boolean().optional(),
69
124
  }),
70
- z.object({ ...BaseAction, op: z.literal("press"), key: z.string() }),
125
+ z.object({
126
+ ...BaseAction,
127
+ op: z.literal("press"),
128
+ key: z.string(),
129
+ /** Show a keystroke chip for this press (overrides the top-level `keystrokes`). */
130
+ keystrokes: z.boolean().optional(),
131
+ }),
71
132
  z.object({
72
133
  ...BaseAction,
73
134
  op: z.literal("hover"),
@@ -81,22 +142,51 @@ export const ActionSchema = z.discriminatedUnion("op", [
81
142
  easing: EasingPresetSchema.optional(),
82
143
  /** Override the preset's duration, ms. */
83
144
  durationMs: z.number().optional(),
145
+ /**
146
+ * Which element state to wait for before scrolling. Default "visible".
147
+ * "attached" accepts a zero-size mount point (a lazily-filled empty <div>
148
+ * that only renders once scrolled into view) — such an element is in the
149
+ * DOM but never "visible", so the default would time out (issue #44).
150
+ */
151
+ state: WaitStateSchema.optional(),
152
+ /**
153
+ * After the scroll, wait until the scroll position has been still for this
154
+ * many ms (bounded at 3 s) — an honest "scroll finished" instead of a fixed
155
+ * pause against smooth-scroll settle races. Omit for the old fixed settle.
156
+ */
157
+ settleMs: z.number().optional(),
84
158
  }),
85
159
  z.object({
86
160
  ...BaseAction,
87
161
  op: z.literal("scrollBy"),
88
162
  /** CSS pixels to scroll; positive = down. */
89
163
  dy: z.number(),
164
+ /**
165
+ * Scope the scroll to this element's own scroller: the wheel is dispatched
166
+ * over the element, and `dy` is clamped to the room its nearest scrollable
167
+ * ancestor actually has — so a bottomed-out inner panel never chains the
168
+ * wheel to the page and slides the whole app off-screen (issue #43). Omit
169
+ * to scroll whatever is under the cursor (the page, usually).
170
+ */
171
+ target: TargetSchema.optional(),
90
172
  /** Scroll feel preset. Default "smooth". */
91
173
  easing: EasingPresetSchema.optional(),
92
174
  /** Override the preset's duration, ms. */
93
175
  durationMs: z.number().optional(),
176
+ /** Wait until the scroll position has been still for this many ms (see scrollTo). */
177
+ settleMs: z.number().optional(),
94
178
  }),
95
179
  z.object({
96
180
  ...BaseAction,
97
181
  op: z.literal("waitFor"),
98
182
  target: TargetSchema,
99
183
  timeoutMs: z.number().optional(),
184
+ /**
185
+ * Element state to wait for. Default "visible". "attached" = present in
186
+ * the DOM even if zero-size/hidden — for lazily-filled mount points that
187
+ * only become visible once something renders into them (issue #44).
188
+ */
189
+ state: WaitStateSchema.optional(),
100
190
  }),
101
191
  /**
102
192
  * Wait for an IN-PLACE mutation of an existing element — the gap that neither
@@ -183,6 +273,109 @@ export const ActionSchema = z.discriminatedUnion("op", [
183
273
  * across the storyboard are a hard error at extraction.
184
274
  */
185
275
  z.object({ ...BaseAction, op: z.literal("still"), name: z.string() }),
276
+ /**
277
+ * Attention beats — compose-time overlays drawn from a box the player
278
+ * measures (like `focus`, they are timeline markers; the drawing happens at
279
+ * compose, so restyling is a recompose). Each dwells briefly at record time
280
+ * (min(holdMs, 800) ms) so the eye registers it before the next action.
281
+ */
282
+ /** Outline `target` (box or ring) for `holdMs`. */
283
+ z.object({
284
+ ...BaseAction,
285
+ op: z.literal("highlight"),
286
+ target: TargetSchema,
287
+ /** How long the outline stays, ms. Default 1600. */
288
+ holdMs: z.number().min(100).max(15000).optional(),
289
+ style: AttentionStyleSchema.optional(),
290
+ }),
291
+ /** Dim everything except `target` for `holdMs`. */
292
+ z.object({
293
+ ...BaseAction,
294
+ op: z.literal("spotlight"),
295
+ target: TargetSchema,
296
+ holdMs: z.number().min(100).max(15000).optional(),
297
+ /** Opacity of the dim layer, 0–0.9. Default 0.55. */
298
+ dimTo: z.number().min(0).max(0.9).optional(),
299
+ /** Extra room around the element, px. Default 10. */
300
+ padding: z.number().min(0).max(80).optional(),
301
+ style: AttentionStyleSchema.optional(),
302
+ }),
303
+ /** Pin a short label to `target` for `holdMs`. */
304
+ z.object({
305
+ ...BaseAction,
306
+ op: z.literal("callout"),
307
+ target: TargetSchema,
308
+ text: z.string().min(1).max(80),
309
+ /** Where the label sits relative to the element. Default "auto". */
310
+ placement: z.enum(["auto", "top", "bottom", "left", "right"]).optional(),
311
+ holdMs: z.number().min(100).max(15000).optional(),
312
+ style: AttentionStyleSchema.optional(),
313
+ }),
314
+ /**
315
+ * Park the cursor deliberately: glide it to `target` (element center) or an
316
+ * absolute viewport point (`x`,`y` CSS px) without clicking. Use it to move
317
+ * the cursor out of the way before a reveal, or to point at what the
318
+ * narration is talking about without an interaction.
319
+ */
320
+ z.object({
321
+ ...BaseAction,
322
+ op: z.literal("moveTo"),
323
+ target: TargetSchema.optional(),
324
+ x: z.number().optional(),
325
+ y: z.number().optional(),
326
+ }),
327
+ /**
328
+ * Prove the payoff happened. Fails the take with a named error unless
329
+ * `target` is visible (and, when set, its text matches `textMatches`) and/or
330
+ * the page URL matches `url` — polled until `timeoutMs` (default 5000). A
331
+ * demo whose confirmation never rendered should fail loudly at record time,
332
+ * not ship a video of a spinner.
333
+ */
334
+ z.object({
335
+ ...BaseAction,
336
+ op: z.literal("assert"),
337
+ target: TargetSchema.optional(),
338
+ /** Regex the target's text must match (JS syntax, e.g. "Order #\\d+"). */
339
+ textMatches: z.string().optional(),
340
+ /** Regex the page URL must match. */
341
+ url: z.string().optional(),
342
+ timeoutMs: z.number().optional(),
343
+ }),
344
+ /** Choose an option in a native <select> by value or visible label (the cursor clicks it first). */
345
+ z.object({
346
+ ...BaseAction,
347
+ op: z.literal("select"),
348
+ target: TargetSchema,
349
+ value: z.string().optional(),
350
+ label: z.string().optional(),
351
+ }),
352
+ /**
353
+ * Drag `target` to `to` (another target, or absolute viewport `x`,`y`):
354
+ * press, glide with the cursor, release — the drag shows in the take.
355
+ */
356
+ z.object({
357
+ ...BaseAction,
358
+ op: z.literal("drag"),
359
+ target: TargetSchema,
360
+ to: z.object({
361
+ target: TargetSchema.optional(),
362
+ x: z.number().optional(),
363
+ y: z.number().optional(),
364
+ }),
365
+ }),
366
+ /**
367
+ * Attach files: `target` is a file input (set directly) or the button that
368
+ * opens the OS file chooser (clicked; the chooser is answered headlessly —
369
+ * no dialog appears in the take). Paths resolve against the demo dir.
370
+ */
371
+ z.object({
372
+ ...BaseAction,
373
+ op: z.literal("upload"),
374
+ target: TargetSchema,
375
+ files: z.array(z.string()).min(1),
376
+ }),
377
+ /** Browser back (history), with the same readiness wait as `goto`. */
378
+ z.object({ ...BaseAction, op: z.literal("back") }),
186
379
  ]);
187
380
  export type Action = z.infer<typeof ActionSchema>;
188
381
 
@@ -201,6 +394,13 @@ export const VoicePlanSchema = z.object({
201
394
  instructions: z.string().optional(),
202
395
  /** Playback speed hint passed to the provider when supported. Default 1.0. */
203
396
  speed: z.number().min(0.5).max(2).optional(),
397
+ /**
398
+ * TTS-only substitutions, whole-word, case-sensitive: `{"aidemo":"A.I. demo",
399
+ * "SQL":"sequel"}`. Applied to the text sent to the voice provider; the
400
+ * narration (and therefore the captions) keeps the written form. Merged
401
+ * storyboard → scene (scene entries win).
402
+ */
403
+ pronounce: z.record(z.string(), z.string()).optional(),
204
404
  });
205
405
  export type VoicePlan = z.infer<typeof VoicePlanSchema>;
206
406
 
@@ -217,8 +417,127 @@ export const MusicCueSchema = z.object({
217
417
  // Scene + Storyboard
218
418
  // ---------------------------------------------------------------------------
219
419
 
420
+ /**
421
+ * A region to blur at compose time. The player re-measures the selector after
422
+ * every action (all matches, up to 8) and logs the spans; compose crops, blurs
423
+ * and overlays each span back — so a price, an email, a token never ships,
424
+ * and the strength is a recompose. Frame-relative selectors use `frame`.
425
+ */
426
+ export const RedactSchema = z.object({
427
+ selector: z.string(),
428
+ frame: z.string().optional(),
429
+ /** boxblur radius, px at the logical size. Default 14. */
430
+ blur: z.number().min(2).max(60).optional(),
431
+ });
432
+ export type Redact = z.infer<typeof RedactSchema>;
433
+
434
+ /** Caption strip placement + look. */
435
+ export const CaptionsConfigSchema = z.object({
436
+ /**
437
+ * "bottom" (default) or "top". With "auto-flip" (the default behaviour when
438
+ * omitted at scene level) compose moves the strip to the top while a
439
+ * highlight/spotlight/callout box or a keystroke chip would collide with it.
440
+ */
441
+ position: z.enum(["bottom", "top"]).optional(),
442
+ /** "pill" (default, centered rounded box), "bar" (full-width band), "none" (no captions burned). */
443
+ style: z.enum(["pill", "bar", "none"]).optional(),
444
+ /** CSS font-family. Default: the brand font, else the system sans. */
445
+ font: z.string().optional(),
446
+ /** Font size, px at the logical width. Default 30. */
447
+ size: z.number().min(14).max(72).optional(),
448
+ /** Text color. Default #fff. */
449
+ color: z.string().optional(),
450
+ /** Pill/bar background (CSS). Default rgba(12,14,22,.66). */
451
+ background: z.string().optional(),
452
+ });
453
+ export type CaptionsConfig = z.infer<typeof CaptionsConfigSchema>;
454
+
455
+ /** Storyboard-wide defaults for the attention overlays. */
456
+ export const AttentionConfigSchema = z.object({
457
+ /** Default outline / pill accent color. Default #ff5a5f. */
458
+ color: z.string().optional(),
459
+ /** Draw a click ring where every click lands (compose-time cursor only). */
460
+ clicks: z.boolean().optional(),
461
+ });
462
+ export type AttentionConfig = z.infer<typeof AttentionConfigSchema>;
463
+
464
+ /**
465
+ * Produced-look frame around the content: padding, background, rounded
466
+ * corners, drop shadow, optional browser chrome. Compose-time; captions and
467
+ * cards render at the framed canvas size. Omit for the raw recording frame.
468
+ */
469
+ export const FrameSchema = z.object({
470
+ /** Padding around the video, px at the logical size. Default 48. */
471
+ padding: z.number().min(0).max(400).optional(),
472
+ /** CSS background (color or gradient). Default: dark gradient tinted by brand.accent. */
473
+ background: z.string().optional(),
474
+ /** Corner radius of the video window, px. Default 14. */
475
+ radius: z.number().min(0).max(80).optional(),
476
+ /** Drop shadow under the window. Default true. */
477
+ shadow: z.boolean().optional(),
478
+ /**
479
+ * Window chrome: "none" (default), "browser" / "mac" (a bar with dots or
480
+ * traffic lights above the video), or a device bezel for phone-sized
481
+ * viewports — "iphone" (rounded bezel + dynamic island) / "android" (bezel +
482
+ * punch-hole camera). Device chrome adds no bar; `radius` defaults to 40.
483
+ */
484
+ chrome: z.enum(["none", "browser", "mac", "iphone", "android"]).optional(),
485
+ /**
486
+ * Device chrome only: reserve a status-bar strip of bezel ABOVE the video
487
+ * (px at the logical size) instead of drawing the island / punch-hole over
488
+ * the recording's top pixels. `true` = 46 px (an iPhone status bar). Use it
489
+ * when the app paints something at the very top that must stay visible.
490
+ */
491
+ safeTop: z.union([z.boolean(), z.number().min(0).max(120)]).optional(),
492
+ /** Text in the chrome's address pill (url wins over title). */
493
+ title: z.string().optional(),
494
+ url: z.string().optional(),
495
+ });
496
+ export type Frame = z.infer<typeof FrameSchema>;
497
+
498
+ /** Brand kit applied across cards, frame, captions, callouts and a watermark. */
499
+ export const BrandSchema = z.object({
500
+ /** Logo image path (png/svg/jpg), relative to the demo dir or absolute. Used as the watermark. */
501
+ logo: z.string().optional(),
502
+ /** Accent color: card rule, frame tint, default attention color. */
503
+ accent: z.string().optional(),
504
+ /** CSS font-family for cards, captions, callouts, chrome. */
505
+ font: z.string().optional(),
506
+ /** Watermark placement for `logo`. */
507
+ watermark: z
508
+ .object({
509
+ position: z
510
+ .enum(["top-left", "top-right", "bottom-left", "bottom-right"])
511
+ .optional(),
512
+ /** 0–1. Default 0.85. */
513
+ opacity: z.number().min(0).max(1).optional(),
514
+ /** Logo width as a fraction of the frame width. Default 0.11. */
515
+ scale: z.number().min(0.03).max(0.5).optional(),
516
+ /** Set false to keep the logo out of the video (cards only). */
517
+ enabled: z.boolean().optional(),
518
+ })
519
+ .optional(),
520
+ });
521
+ export type Brand = z.infer<typeof BrandSchema>;
522
+
523
+ /** Tuning for compose-time idle detection (`autoIdle`). */
524
+ export const AutoIdleSchema = z.object({
525
+ enabled: z.boolean().optional(),
526
+ /** Shortest motionless span that counts as idle, ms. Default 1500. */
527
+ minMs: z.number().min(400).max(20000).optional(),
528
+ /**
529
+ * Per-pixel noise floor below which two frames count as identical (ffmpeg
530
+ * `freezedetect` noise, 0..1). Default 0.003 — tolerant of encoder dither,
531
+ * tight enough that a blinking caret still reads as motion.
532
+ */
533
+ noise: z.number().min(0).max(0.2).optional(),
534
+ });
535
+ export type AutoIdle = z.infer<typeof AutoIdleSchema>;
536
+
220
537
  export const SceneSchema = z.object({
221
538
  id: z.string(),
539
+ /** Chapter title for this beat (`output.chapters`); defaults to the scene id. */
540
+ title: z.string().optional(),
222
541
  /** Spoken narration for this beat. Also the caption source of truth. */
223
542
  narration: z.string(),
224
543
  /**
@@ -230,10 +549,26 @@ export const SceneSchema = z.object({
230
549
  * LLM to translate. A code missing here falls back to `narration`.
231
550
  */
232
551
  narrations: z.record(z.string(), z.string()).optional(),
552
+ /**
553
+ * Engine-populated at parse time from `{{@name}}` markers in `narration`:
554
+ * name → 0-based index of the word the beat lands on. Don't author this —
555
+ * write the marker in the narration instead.
556
+ */
557
+ anchors: z.record(z.string(), z.number().int().min(0)).optional(),
558
+ /** Same, per `narrations` language code. */
559
+ narrationAnchors: z.record(z.string(), z.record(z.string(), z.number().int().min(0))).optional(),
233
560
  voice: VoicePlanSchema.optional(),
234
561
  music: MusicCueSchema.optional(),
235
562
  /** Set false to suppress auto-zoom for this scene's clicks/typing. */
236
563
  zoom: z.boolean().optional(),
564
+ /** Override the top-level `autoIdle` for this scene (true enables, false disables). */
565
+ autoIdle: z.boolean().optional(),
566
+ /** Caption placement for this scene (overrides top-level `captions.position`). */
567
+ captions: CaptionsConfigSchema.optional(),
568
+ /** Blur these regions while this scene records (adds to top-level `redact`). */
569
+ redact: z.array(RedactSchema).optional(),
570
+ /** Hide these selectors for this scene (adds to top-level `hide`). */
571
+ hide: z.array(z.string()).optional(),
237
572
  actions: z.array(ActionSchema).default([]),
238
573
  });
239
574
  export type Scene = z.infer<typeof SceneSchema>;
@@ -317,6 +652,10 @@ export const CursorConfigSchema = z.object({
317
652
  hideScenes: z.array(z.string()).optional(),
318
653
  /** Cursor size multiplier (1 = the 24px baseline arrow). */
319
654
  scale: z.number().min(0.5).max(4).optional(),
655
+ /** Pointer look: classic "arrow" (default) or a soft "dot" (screen-recorder style). */
656
+ style: z.enum(["arrow", "dot"]).optional(),
657
+ /** Dot style only: fill color. Default rgba(255,90,95,.85). */
658
+ color: z.string().optional(),
320
659
  });
321
660
  export type CursorConfig = z.infer<typeof CursorConfigSchema>;
322
661
 
@@ -389,8 +728,33 @@ export type Loudness = z.infer<typeof LoudnessSchema>;
389
728
  * `loudness` (independent of resize) tunes the master level; omit it entirely
390
729
  * for the automatic default (loudnorm when there's music, none otherwise).
391
730
  */
731
+ /** Named output presets — fill width/height/fit when they are not set explicitly. */
732
+ export const OutputPresetSchema = z.enum(["youtube", "short", "readme-gif", "x"]);
733
+ export type OutputPreset = z.infer<typeof OutputPresetSchema>;
734
+ export const OUTPUT_PRESETS: Record<
735
+ OutputPreset,
736
+ { width: number; height: number; fit: "contain" | "cover" }
737
+ > = {
738
+ youtube: { width: 1920, height: 1080, fit: "contain" },
739
+ short: { width: 1080, height: 1920, fit: "contain" },
740
+ "readme-gif": { width: 960, height: 540, fit: "contain" },
741
+ x: { width: 1280, height: 720, fit: "contain" },
742
+ };
743
+
392
744
  export const OutputSchema = z
393
745
  .object({
746
+ /** Preset sizing (see OUTPUT_PRESETS); explicit width/height/fit win. */
747
+ preset: OutputPresetSchema.optional(),
748
+ /** Write MP4 chapter markers, one per scene (scene `title` or id). Default false. */
749
+ chapters: z.boolean().optional(),
750
+ /** Also extract output/poster.png (first content frame after the intro). Default false. */
751
+ poster: z.boolean().optional(),
752
+ /**
753
+ * Also export output/walkthrough/ (index.html + guide.md + per-scene
754
+ * frames + captions) after every render. Default false; `aidemo
755
+ * walkthrough <dir>` / the `walkthrough` job export on demand.
756
+ */
757
+ walkthrough: z.boolean().optional(),
394
758
  width: z.number().optional(),
395
759
  height: z.number().optional(),
396
760
  /**
@@ -415,6 +779,85 @@ export const OutputSchema = z
415
779
  });
416
780
  export type Output = z.infer<typeof OutputSchema>;
417
781
 
782
+
783
+ /**
784
+ * What compose does when a scene's narration outlasts its recorded action
785
+ * (after the ≤1.6x slow-down): the remainder is a HOLD on the last frame.
786
+ * Default "freeze" clones that frame (byte-for-byte the pre-`hold` behavior).
787
+ * "drift" instead pushes in very slowly on it (a Ken-Burns-style creep of
788
+ * `driftScale` over the hold), so a 10 s hold reads as a deliberate dwell
789
+ * rather than a stalled video — the fix for narration-heavy demos where half
790
+ * the runtime was a frozen frame (issue #40). `backoffMs` takes the held frame
791
+ * from slightly BEFORE the segment's end, dodging a half-painted transition or
792
+ * a hover state caught mid-flight. Opt-in: omit the key for the old behavior.
793
+ */
794
+ export const HoldSchema = z.object({
795
+ mode: z.enum(["freeze", "drift"]).default("freeze"),
796
+ /** Drift mode: zoom level reached at the end of the hold. Default 1.06. */
797
+ driftScale: z.number().min(1).max(1.5).default(1.06),
798
+ /** Take the held frame this many ms before the segment end. Default 0. */
799
+ backoffMs: z.number().min(0).max(2000).default(0),
800
+ });
801
+ export type HoldConfig = z.infer<typeof HoldSchema>;
802
+
803
+ /**
804
+ * One cookie to seed into the recording profile before the take. Mirrors
805
+ * Playwright's cookie shape; `domain` + `path` (or `url`) place it.
806
+ */
807
+ export const SeedCookieSchema = z.object({
808
+ name: z.string(),
809
+ value: z.string(),
810
+ /** Cookie domain, e.g. ".example.com" or "app.example.com". */
811
+ domain: z.string().optional(),
812
+ /** Cookie path. Default "/". */
813
+ path: z.string().optional(),
814
+ /** Alternative to domain+path: the URL the cookie belongs to. */
815
+ url: z.string().optional(),
816
+ /** Unix time in seconds; omit for a session cookie. */
817
+ expires: z.number().optional(),
818
+ httpOnly: z.boolean().optional(),
819
+ secure: z.boolean().optional(),
820
+ sameSite: z.enum(["Strict", "Lax", "None"]).optional(),
821
+ });
822
+ export type SeedCookie = z.infer<typeof SeedCookieSchema>;
823
+
824
+ /**
825
+ * How the take is prepared BEFORE the first action — the answer to "the
826
+ * feature is behind a cookie gate / a preview token / a rotating fixture"
827
+ * without a hand-written Playwright seed script (issue #44). Everything here
828
+ * is opt-in; a storyboard without `setup` records exactly as before.
829
+ */
830
+ export const SetupSchema = z.object({
831
+ /**
832
+ * Path (relative to the demo dir, or absolute) to a Playwright storageState
833
+ * JSON (`{cookies:[…], origins:[{origin, localStorage:[{name,value}]}]}`),
834
+ * e.g. one saved by `context.storageState({path})`. Its cookies are added
835
+ * to the recording profile and each origin's localStorage entries are set
836
+ * before the storyboard runs. CLI: `--storage-state <file>`.
837
+ */
838
+ storageState: z.string().optional(),
839
+ /** Cookies to add before the take. CLI: `--cookie name=value;domain=host` (repeatable). */
840
+ cookies: z.array(SeedCookieSchema).optional(),
841
+ /**
842
+ * Shell command run from the demo dir before EVERY take (record/probe/render),
843
+ * after the profile is resolved and before Chrome launches — e.g. a script
844
+ * that re-seeds a fixture, or patches the storyboard for whichever variant
845
+ * the app will serve today. Env: AIDEMO_DEMO_DIR, AIDEMO_STORYBOARD (the
846
+ * storyboard path — re-read after the hook, so edits are honored),
847
+ * AIDEMO_PROFILE (the Chrome user-data dir the take will use). A non-zero
848
+ * exit aborts the take.
849
+ */
850
+ preflight: z.string().optional(),
851
+ /**
852
+ * Acknowledge that the recording profile is deliberately seeded (a login,
853
+ * a cookie gate) and silence the carried-over-state warning for this
854
+ * storyboard. Implied when storageState/cookies are given. CLI:
855
+ * `--profile-seeded`.
856
+ */
857
+ expectState: z.boolean().optional(),
858
+ });
859
+ export type Setup = z.infer<typeof SetupSchema>;
860
+
418
861
  export const StoryboardSchema = z.object({
419
862
  title: z.string(),
420
863
  /**
@@ -429,6 +872,15 @@ export const StoryboardSchema = z.object({
429
872
  * fallback for non-English narration. Omit for English / to auto-detect.
430
873
  */
431
874
  language: z.string().optional(),
875
+ /**
876
+ * Product names, brands and jargon that Whisper reliably mis-hears — the
877
+ * transcript is what gets burned in, so "fitness.ee" coming back as
878
+ * "fitness topam" ships as a caption (issue #38). Listed here they are sent
879
+ * at the FRONT of the STT prompt, inside the window Whisper actually biases
880
+ * on, even when the script itself is long. Cheaper than `captions --offline`,
881
+ * which fixes spelling but gives up real word timings.
882
+ */
883
+ knownTerms: z.array(z.string()).optional(),
432
884
  /**
433
885
  * Declared template parameters: name → default value. Enables `{{name}}`
434
886
  * placeholders in any storyboard string (narration, action url, `type` text,
@@ -461,6 +913,32 @@ export const StoryboardSchema = z.object({
461
913
  motionBlur: MotionBlurSchema.optional(),
462
914
  /** Compose-time cursor control (hide/resize post-hoc). Omit to bake the cursor. */
463
915
  cursor: CursorConfigSchema.optional(),
916
+ /** Pre-take preparation: cookies / storageState seeding, a preflight hook. Opt-in. */
917
+ setup: SetupSchema.optional(),
918
+ /** How compose fills narration that outlasts a scene's action (freeze | drift). Opt-in. */
919
+ hold: HoldSchema.optional(),
920
+ /**
921
+ * Trim un-annotated dead air: compose scans the take for spans where nothing
922
+ * on screen moves and treats them as idle (capped like `waitFor*` idle is),
923
+ * so a slow XHR or a long `pause` stops inflating the scene. Opt-in — omit
924
+ * for the old behavior, where only annotated waits count as idle. Pass an
925
+ * object to tune the threshold; per-scene `autoIdle` overrides it.
926
+ */
927
+ autoIdle: z.union([z.boolean(), AutoIdleSchema]).optional(),
928
+ /** Defaults for highlight/spotlight/callout overlays + click rings. Opt-in. */
929
+ attention: AttentionConfigSchema.optional(),
930
+ /** Produced-look frame: padding/background/radius/shadow/chrome. Opt-in. */
931
+ frame: FrameSchema.optional(),
932
+ /** Brand kit: logo watermark, accent, font. Opt-in. */
933
+ brand: BrandSchema.optional(),
934
+ /** Show a keystroke chip on every `press` (per-action `keystrokes` overrides). Opt-in. */
935
+ keystrokes: z.boolean().optional(),
936
+ /** Caption strip placement (bottom | top). Opt-in; per-scene `captions` overrides. */
937
+ captions: CaptionsConfigSchema.optional(),
938
+ /** Regions blurred at compose time throughout the demo. Opt-in. */
939
+ redact: z.array(RedactSchema).optional(),
940
+ /** Selectors hidden (visibility:hidden) at record time throughout the demo. Opt-in. */
941
+ hide: z.array(z.string()).optional(),
464
942
  scenes: z.array(SceneSchema).min(1),
465
943
  });
466
944
  export type Storyboard = z.infer<typeof StoryboardSchema>;
@@ -517,6 +995,63 @@ export const CursorSampleSchema = z.object({
517
995
  });
518
996
  export type CursorSample = z.infer<typeof CursorSampleSchema>;
519
997
 
998
+ /**
999
+ * What one storyboard action actually did during the take: wall-clock window,
1000
+ * outcome, retries, and anything worth a second look (failed requests seen in
1001
+ * its window, an optional skip). The per-action twin of the compose report —
1002
+ * an agent reads this to see which action ate the time or was skipped without
1003
+ * regex-mining the log. Default [] keeps older timelines valid.
1004
+ */
1005
+ export const TimelineActionSchema = z.object({
1006
+ /** 0-based index in the scene's actions[]. */
1007
+ index: z.number(),
1008
+ op: z.string(),
1009
+ /** Resolved target (named→selector, frame-prefixed) / goto URL, when any. */
1010
+ target: z.string().optional(),
1011
+ startMs: z.number(),
1012
+ endMs: z.number(),
1013
+ ok: z.boolean(),
1014
+ /** An `optional` action whose target never appeared / that failed. */
1015
+ skipped: z.boolean().optional(),
1016
+ /** Extra attempts consumed before success (from `retry`). */
1017
+ retries: z.number().optional(),
1018
+ warnings: z.array(z.string()).optional(),
1019
+ });
1020
+ export type TimelineAction = z.infer<typeof TimelineActionSchema>;
1021
+
1022
+ /** A measured attention beat (highlight/spotlight/callout) — viewport CSS px. */
1023
+ export const AttentionEventSchema = z.object({
1024
+ tMs: z.number(),
1025
+ kind: z.enum(["highlight", "spotlight", "callout"]),
1026
+ x: z.number(),
1027
+ y: z.number(),
1028
+ w: z.number(),
1029
+ h: z.number(),
1030
+ holdMs: z.number(),
1031
+ text: z.string().optional(),
1032
+ placement: z.string().optional(),
1033
+ dimTo: z.number().optional(),
1034
+ padding: z.number().optional(),
1035
+ style: AttentionStyleSchema.optional(),
1036
+ });
1037
+ export type AttentionEvent = z.infer<typeof AttentionEventSchema>;
1038
+
1039
+ /** A key press to show as a chip (already prettified, e.g. "⌘ K", "Enter"). */
1040
+ export const KeyEventSchema = z.object({ tMs: z.number(), keys: z.string() });
1041
+ export type KeyEvent = z.infer<typeof KeyEventSchema>;
1042
+
1043
+ /** A region blurred at compose time over [startMs, endMs] — viewport CSS px. */
1044
+ export const RedactSpanSchema = z.object({
1045
+ startMs: z.number(),
1046
+ endMs: z.number(),
1047
+ x: z.number(),
1048
+ y: z.number(),
1049
+ w: z.number(),
1050
+ h: z.number(),
1051
+ blur: z.number(),
1052
+ });
1053
+ export type RedactSpan = z.infer<typeof RedactSpanSchema>;
1054
+
520
1055
  export const TimelineSceneSchema = z.object({
521
1056
  id: z.string(),
522
1057
  startMs: z.number(),
@@ -525,6 +1060,21 @@ export const TimelineSceneSchema = z.object({
525
1060
  focusEvents: z.array(FocusEventSchema).default([]),
526
1061
  stillEvents: z.array(StillEventSchema).default([]),
527
1062
  cursorSamples: z.array(CursorSampleSchema).default([]),
1063
+ actions: z.array(TimelineActionSchema).default([]),
1064
+ attentionEvents: z.array(AttentionEventSchema).default([]),
1065
+ keyEvents: z.array(KeyEventSchema).default([]),
1066
+ redactSpans: z.array(RedactSpanSchema).default([]),
1067
+ /** Anchored actions: when the beat happened in the raw take (scene-relative like tMs elsewhere). */
1068
+ anchorEvents: z.array(z.object({ name: z.string(), tMs: z.number(), action: z.number() })).default([]),
1069
+ /**
1070
+ * Resume (`record --from-scene`): scenes reused from an earlier take name the
1071
+ * raw file they live in (relative to the demo dir) and that take's lead-in;
1072
+ * absent = the current take (`recordings/raw.*` + the timeline's leadInMs).
1073
+ */
1074
+ source: z.string().optional(),
1075
+ leadInMs: z.number().optional(),
1076
+ /** Identity of the scene's action-spec at record time — resume reuses a scene only when it matches. */
1077
+ hash: z.string().optional(),
528
1078
  });
529
1079
  export type TimelineScene = z.infer<typeof TimelineSceneSchema>;
530
1080
 
@@ -539,6 +1089,86 @@ export const TimelineSchema = z.object({
539
1089
  });
540
1090
  export type Timeline = z.infer<typeof TimelineSchema>;
541
1091
 
1092
+
1093
+ // ---------------------------------------------------------------------------
1094
+ // Compose report — written to output/report.json by every compose. The
1095
+ // structured twin of the compose log: per-scene retime facts (how much was
1096
+ // stretched, held, trimmed) plus machine-readable warnings, so an agent can
1097
+ // judge a take without regex-mining logTail (issue #40's 28-scene log
1098
+ // overflowed the 40-line tail; the freeze-hold dominance was invisible).
1099
+ // ---------------------------------------------------------------------------
1100
+
1101
+ export const ComposeWarningSchema = z.object({
1102
+ /** Stable code: scene-freeze | overrun-trim | blank-tail | stale-captions |
1103
+ * focus-dropped | cursor-missing | scene-no-narration */
1104
+ code: z.string(),
1105
+ scene: z.string().optional(),
1106
+ message: z.string(),
1107
+ });
1108
+ export type ComposeWarning = z.infer<typeof ComposeWarningSchema>;
1109
+
1110
+ export const ComposeSceneReportSchema = z.object({
1111
+ id: z.string(),
1112
+ /** Recorded (kept, non-idle) ms feeding this scene. */
1113
+ srcMs: z.number(),
1114
+ /** Narration + gap ms the scene had to fill. */
1115
+ targetMs: z.number(),
1116
+ /** Time-stretch factor applied (0.5 … 1.6). */
1117
+ factor: z.number(),
1118
+ /** Ms of hold (frozen / drifting last frame) appended after the stretch. */
1119
+ holdMs: z.number(),
1120
+ /** holdMs / targetMs — the share of the scene that is a held frame. */
1121
+ holdPct: z.number(),
1122
+ /** Ms of recorded action cut from the tail because it overran the narration. */
1123
+ tailTrimMs: z.number(),
1124
+ /** Ms of solid-color tail dropped before holding (white pre-paint). */
1125
+ blankTrimMs: z.number(),
1126
+ /** Ms of motionless footage `autoIdle` marked trimmable in this scene (0 when off). */
1127
+ autoIdleMs: z.number().default(0),
1128
+ /** Kept spans the scene was cut from (idle spans trimmed between them). */
1129
+ spans: z.number(),
1130
+ /** Focus points (zoom) this scene contributed. */
1131
+ focusEvents: z.number(),
1132
+ /** Narration anchors: where the beat was asked to land vs where it did (content ms, scene-relative). */
1133
+ anchors: z
1134
+ .array(
1135
+ z.object({
1136
+ name: z.string(),
1137
+ targetMs: z.number(),
1138
+ landedMs: z.number(),
1139
+ /** landedMs − targetMs; |offMs| > 150 raises `anchor-unreachable`. */
1140
+ offMs: z.number(),
1141
+ })
1142
+ )
1143
+ .optional(),
1144
+ });
1145
+ export type ComposeSceneReport = z.infer<typeof ComposeSceneReportSchema>;
1146
+
1147
+ export const ComposeReportSchema = z.object({
1148
+ output: z.string(),
1149
+ /** Final video length, ms. */
1150
+ durationMs: z.number(),
1151
+ scenes: z.array(ComposeSceneReportSchema),
1152
+ zoom: z.object({ focusTotal: z.number(), focusDropped: z.number() }),
1153
+ cursorPoints: z.number(),
1154
+ captions: z.object({ cues: z.number(), passes: z.number() }),
1155
+ /** Attention overlays drawn (highlight/spotlight/callout), key chips, click rings, redact spans. */
1156
+ attention: z
1157
+ .object({
1158
+ events: z.number(),
1159
+ keys: z.number(),
1160
+ clicks: z.number(),
1161
+ redactSpans: z.number(),
1162
+ captionsFlipped: z.number(),
1163
+ })
1164
+ .optional(),
1165
+ hold: z.string(),
1166
+ /** output/poster.png when `output.poster` is set. */
1167
+ poster: z.string().optional(),
1168
+ warnings: z.array(ComposeWarningSchema),
1169
+ });
1170
+ export type ComposeReport = z.infer<typeof ComposeReportSchema>;
1171
+
542
1172
  // ---------------------------------------------------------------------------
543
1173
  // Voice manifest — emitted by the voice step. compose uses per-scene durations
544
1174
  // as the target length for each scene's video segment.
@@ -600,9 +1230,10 @@ export const CaptionsManifestSchema = z.object({
600
1230
  maxCueMs: z.number(),
601
1231
  }),
602
1232
  /**
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.
1233
+ * Per-scene inputs + reusable scene-relative word timings. Offline path:
1234
+ * enables per-scene reuse (only a changed scene is re-derived). STT path:
1235
+ * the transcript split per scene, so compose can place narration anchors
1236
+ * on the word that was actually spoken.
606
1237
  */
607
1238
  scenes: z
608
1239
  .array(
@@ -642,6 +1273,13 @@ export const ProbeActionOutcomeSchema = z.object({
642
1273
  ms: z.number().optional(),
643
1274
  dy: z.number().optional(),
644
1275
  label: z.string().optional(),
1276
+ /**
1277
+ * The action is marked `optional` in the storyboard. Optional actions always
1278
+ * record ok:true and no `found`: their real outcome is environment-dependent
1279
+ * by design (that's what optional is for), so it's normalized out of the
1280
+ * golden projection to keep it deterministic across runs.
1281
+ */
1282
+ optional: z.boolean().optional(),
645
1283
  });
646
1284
  export type ProbeActionOutcome = z.infer<typeof ProbeActionOutcomeSchema>;
647
1285