@tandryukha/aidemo 0.8.0 → 0.15.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 +5 -2
- package/README.md +16 -4
- package/docs/AUTHORING.md +734 -48
- package/package.json +3 -2
- package/src/anchors.ts +128 -0
- package/src/attention.ts +365 -0
- package/src/caption-render.ts +17 -8
- package/src/captions.ts +244 -36
- package/src/capture.ts +113 -0
- package/src/cards.ts +15 -6
- package/src/cli.ts +711 -26
- package/src/compose.ts +1318 -101
- package/src/cursor-overlay.ts +7 -113
- package/src/cursor.ts +19 -4
- package/src/distribute.ts +37 -4
- package/src/expr.ts +130 -0
- package/src/ffmpeg.ts +224 -0
- package/src/frame.ts +205 -0
- package/src/frames.ts +208 -0
- package/src/golden.ts +27 -0
- package/src/guide.ts +102 -0
- package/src/i18n.ts +7 -4
- package/src/import-trace.ts +640 -0
- package/src/inspect.ts +316 -0
- package/src/lint.ts +673 -0
- package/src/login.ts +143 -0
- package/src/mcp/jobs.ts +5 -1
- package/src/mcp/server.ts +468 -27
- package/src/player.ts +1286 -41
- package/src/profile.ts +128 -0
- package/src/project.ts +12 -2
- package/src/qa.ts +295 -0
- package/src/recorder.ts +316 -21
- package/src/safe-regex.ts +36 -0
- package/src/setup.ts +248 -0
- package/src/starter.ts +244 -0
- package/src/stills.ts +15 -4
- package/src/types.ts +796 -22
- package/src/variants.ts +1 -1
- package/src/voice-local.ts +7 -1
- package/src/voice.ts +60 -1
- package/src/walkthrough.ts +250 -0
- package/src/zoom.ts +85 -39
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 = {
|
|
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,11 +122,26 @@ 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({
|
|
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"),
|
|
74
135
|
target: TargetSchema,
|
|
136
|
+
/**
|
|
137
|
+
* Where the pointer parks relative to the element. Default "center" — but
|
|
138
|
+
* on a small chip or a number the narration is about, the cursor then
|
|
139
|
+
* COVERS the thing it points at (issue #50). "edge" (or a side) parks it
|
|
140
|
+
* just outside the element's box, still close enough to read as pointing.
|
|
141
|
+
*/
|
|
142
|
+
anchor: z.enum(["center", "edge", "top", "bottom", "left", "right"]).optional(),
|
|
143
|
+
/** Extra nudge from the anchor point, CSS px. */
|
|
144
|
+
offset: z.object({ x: z.number(), y: z.number() }).optional(),
|
|
75
145
|
}),
|
|
76
146
|
z.object({
|
|
77
147
|
...BaseAction,
|
|
@@ -81,22 +151,51 @@ export const ActionSchema = z.discriminatedUnion("op", [
|
|
|
81
151
|
easing: EasingPresetSchema.optional(),
|
|
82
152
|
/** Override the preset's duration, ms. */
|
|
83
153
|
durationMs: z.number().optional(),
|
|
154
|
+
/**
|
|
155
|
+
* Which element state to wait for before scrolling. Default "visible".
|
|
156
|
+
* "attached" accepts a zero-size mount point (a lazily-filled empty <div>
|
|
157
|
+
* that only renders once scrolled into view) — such an element is in the
|
|
158
|
+
* DOM but never "visible", so the default would time out (issue #44).
|
|
159
|
+
*/
|
|
160
|
+
state: WaitStateSchema.optional(),
|
|
161
|
+
/**
|
|
162
|
+
* After the scroll, wait until the scroll position has been still for this
|
|
163
|
+
* many ms (bounded at 3 s) — an honest "scroll finished" instead of a fixed
|
|
164
|
+
* pause against smooth-scroll settle races. Omit for the old fixed settle.
|
|
165
|
+
*/
|
|
166
|
+
settleMs: z.number().optional(),
|
|
84
167
|
}),
|
|
85
168
|
z.object({
|
|
86
169
|
...BaseAction,
|
|
87
170
|
op: z.literal("scrollBy"),
|
|
88
171
|
/** CSS pixels to scroll; positive = down. */
|
|
89
172
|
dy: z.number(),
|
|
173
|
+
/**
|
|
174
|
+
* Scope the scroll to this element's own scroller: the wheel is dispatched
|
|
175
|
+
* over the element, and `dy` is clamped to the room its nearest scrollable
|
|
176
|
+
* ancestor actually has — so a bottomed-out inner panel never chains the
|
|
177
|
+
* wheel to the page and slides the whole app off-screen (issue #43). Omit
|
|
178
|
+
* to scroll whatever is under the cursor (the page, usually).
|
|
179
|
+
*/
|
|
180
|
+
target: TargetSchema.optional(),
|
|
90
181
|
/** Scroll feel preset. Default "smooth". */
|
|
91
182
|
easing: EasingPresetSchema.optional(),
|
|
92
183
|
/** Override the preset's duration, ms. */
|
|
93
184
|
durationMs: z.number().optional(),
|
|
185
|
+
/** Wait until the scroll position has been still for this many ms (see scrollTo). */
|
|
186
|
+
settleMs: z.number().optional(),
|
|
94
187
|
}),
|
|
95
188
|
z.object({
|
|
96
189
|
...BaseAction,
|
|
97
190
|
op: z.literal("waitFor"),
|
|
98
191
|
target: TargetSchema,
|
|
99
192
|
timeoutMs: z.number().optional(),
|
|
193
|
+
/**
|
|
194
|
+
* Element state to wait for. Default "visible". "attached" = present in
|
|
195
|
+
* the DOM even if zero-size/hidden — for lazily-filled mount points that
|
|
196
|
+
* only become visible once something renders into them (issue #44).
|
|
197
|
+
*/
|
|
198
|
+
state: WaitStateSchema.optional(),
|
|
100
199
|
}),
|
|
101
200
|
/**
|
|
102
201
|
* Wait for an IN-PLACE mutation of an existing element — the gap that neither
|
|
@@ -183,6 +282,138 @@ export const ActionSchema = z.discriminatedUnion("op", [
|
|
|
183
282
|
* across the storyboard are a hard error at extraction.
|
|
184
283
|
*/
|
|
185
284
|
z.object({ ...BaseAction, op: z.literal("still"), name: z.string() }),
|
|
285
|
+
/**
|
|
286
|
+
* Attention beats — compose-time overlays drawn from a box the player
|
|
287
|
+
* measures (like `focus`, they are timeline markers; the drawing happens at
|
|
288
|
+
* compose, so restyling is a recompose). Each dwells briefly at record time
|
|
289
|
+
* (min(holdMs, 800) ms) so the eye registers it before the next action.
|
|
290
|
+
*/
|
|
291
|
+
/** Outline `target` (box or ring) for `holdMs`. */
|
|
292
|
+
z.object({
|
|
293
|
+
...BaseAction,
|
|
294
|
+
op: z.literal("highlight"),
|
|
295
|
+
target: TargetSchema,
|
|
296
|
+
/** How long the outline stays, ms. Default 1600. */
|
|
297
|
+
holdMs: z.number().min(100).max(15000).optional(),
|
|
298
|
+
style: AttentionStyleSchema.optional(),
|
|
299
|
+
}),
|
|
300
|
+
/** Dim everything except `target` for `holdMs`. */
|
|
301
|
+
z.object({
|
|
302
|
+
...BaseAction,
|
|
303
|
+
op: z.literal("spotlight"),
|
|
304
|
+
target: TargetSchema,
|
|
305
|
+
holdMs: z.number().min(100).max(15000).optional(),
|
|
306
|
+
/** Opacity of the dim layer, 0–0.9. Default 0.55. */
|
|
307
|
+
dimTo: z.number().min(0).max(0.9).optional(),
|
|
308
|
+
/** Extra room around the element, px. Default 10. */
|
|
309
|
+
padding: z.number().min(0).max(80).optional(),
|
|
310
|
+
style: AttentionStyleSchema.optional(),
|
|
311
|
+
}),
|
|
312
|
+
/** Pin a short label to `target` for `holdMs`. */
|
|
313
|
+
z.object({
|
|
314
|
+
...BaseAction,
|
|
315
|
+
op: z.literal("callout"),
|
|
316
|
+
target: TargetSchema,
|
|
317
|
+
text: z.string().min(1).max(80),
|
|
318
|
+
/** Where the label sits relative to the element. Default "auto". */
|
|
319
|
+
placement: z.enum(["auto", "top", "bottom", "left", "right"]).optional(),
|
|
320
|
+
holdMs: z.number().min(100).max(15000).optional(),
|
|
321
|
+
style: AttentionStyleSchema.optional(),
|
|
322
|
+
}),
|
|
323
|
+
/**
|
|
324
|
+
* Park the cursor deliberately: glide it to `target` (element center) or an
|
|
325
|
+
* absolute viewport point (`x`,`y` CSS px) without clicking. Use it to move
|
|
326
|
+
* the cursor out of the way before a reveal, or to point at what the
|
|
327
|
+
* narration is talking about without an interaction.
|
|
328
|
+
*/
|
|
329
|
+
z.object({
|
|
330
|
+
...BaseAction,
|
|
331
|
+
op: z.literal("moveTo"),
|
|
332
|
+
target: TargetSchema.optional(),
|
|
333
|
+
x: z.number().optional(),
|
|
334
|
+
y: z.number().optional(),
|
|
335
|
+
}),
|
|
336
|
+
/**
|
|
337
|
+
* Prove the payoff happened. Fails the take with a named error unless
|
|
338
|
+
* `target` is visible (and, when set, its text matches `textMatches`) and/or
|
|
339
|
+
* the page URL matches `url` — polled until `timeoutMs` (default 5000). A
|
|
340
|
+
* demo whose confirmation never rendered should fail loudly at record time,
|
|
341
|
+
* not ship a video of a spinner.
|
|
342
|
+
*/
|
|
343
|
+
z.object({
|
|
344
|
+
...BaseAction,
|
|
345
|
+
op: z.literal("assert"),
|
|
346
|
+
target: TargetSchema.optional(),
|
|
347
|
+
/** Regex the target's text must match (JS syntax, e.g. "Order #\\d+"). */
|
|
348
|
+
textMatches: z.string().optional(),
|
|
349
|
+
/** Regex the page URL must match. */
|
|
350
|
+
url: z.string().optional(),
|
|
351
|
+
timeoutMs: z.number().optional(),
|
|
352
|
+
}),
|
|
353
|
+
/** Choose an option in a native <select> by value or visible label (the cursor clicks it first). */
|
|
354
|
+
z.object({
|
|
355
|
+
...BaseAction,
|
|
356
|
+
op: z.literal("select"),
|
|
357
|
+
target: TargetSchema,
|
|
358
|
+
value: z.string().optional(),
|
|
359
|
+
label: z.string().optional(),
|
|
360
|
+
}),
|
|
361
|
+
/**
|
|
362
|
+
* Drag `target` to `to` (another target, or absolute viewport `x`,`y`):
|
|
363
|
+
* press, glide with the cursor, release — the drag shows in the take.
|
|
364
|
+
*/
|
|
365
|
+
z.object({
|
|
366
|
+
...BaseAction,
|
|
367
|
+
op: z.literal("drag"),
|
|
368
|
+
target: TargetSchema,
|
|
369
|
+
to: z.object({
|
|
370
|
+
target: TargetSchema.optional(),
|
|
371
|
+
x: z.number().optional(),
|
|
372
|
+
y: z.number().optional(),
|
|
373
|
+
}),
|
|
374
|
+
}),
|
|
375
|
+
/**
|
|
376
|
+
* Attach files: `target` is a file input (set directly) or the button that
|
|
377
|
+
* opens the OS file chooser (clicked; the chooser is answered headlessly —
|
|
378
|
+
* no dialog appears in the take). Paths resolve against the demo dir.
|
|
379
|
+
*/
|
|
380
|
+
z.object({
|
|
381
|
+
...BaseAction,
|
|
382
|
+
op: z.literal("upload"),
|
|
383
|
+
target: TargetSchema,
|
|
384
|
+
files: z.array(z.string()).min(1),
|
|
385
|
+
}),
|
|
386
|
+
/** Browser back (history), with the same readiness wait as `goto`. */
|
|
387
|
+
z.object({ ...BaseAction, op: z.literal("back") }),
|
|
388
|
+
/**
|
|
389
|
+
* Arm a one-shot handler for the NEXT native dialog (`alert`, `confirm`,
|
|
390
|
+
* `prompt`, `beforeunload`) so the action that triggers it actually goes
|
|
391
|
+
* through (issue #54). Playwright auto-DISMISSES dialogs when nothing is
|
|
392
|
+
* listening, so without this a filmed click on an "Archive this?" button
|
|
393
|
+
* looks like it worked while the handler never ran — a false outcome on
|
|
394
|
+
* camera. Put the `dialog` action immediately BEFORE the click that opens it.
|
|
395
|
+
*
|
|
396
|
+
* The dialog itself is chrome, not page content, so it is not visible in the
|
|
397
|
+
* take; `holdMs` only paces the beat before answering.
|
|
398
|
+
*/
|
|
399
|
+
z.object({
|
|
400
|
+
...BaseAction,
|
|
401
|
+
op: z.literal("dialog"),
|
|
402
|
+
/** Answer the dialog with OK (default) or Cancel. */
|
|
403
|
+
action: z.enum(["accept", "dismiss"]).default("accept"),
|
|
404
|
+
/** Text typed into a `prompt()` before accepting. */
|
|
405
|
+
promptText: z.string().optional(),
|
|
406
|
+
/** Wait this long before answering, ms. Default 0. */
|
|
407
|
+
holdMs: z.number().min(0).max(5000).optional(),
|
|
408
|
+
/** How long to wait for the dialog to appear before warning, ms. Default 5000. */
|
|
409
|
+
timeoutMs: z.number().optional(),
|
|
410
|
+
/**
|
|
411
|
+
* Set false when the dialog is not guaranteed to open — the take then only
|
|
412
|
+
* notes that none came. Default true: a `dialog` whose dialog never fired
|
|
413
|
+
* fails the take, because the filmed action silently did nothing.
|
|
414
|
+
*/
|
|
415
|
+
required: z.boolean().optional(),
|
|
416
|
+
}),
|
|
186
417
|
]);
|
|
187
418
|
export type Action = z.infer<typeof ActionSchema>;
|
|
188
419
|
|
|
@@ -201,6 +432,13 @@ export const VoicePlanSchema = z.object({
|
|
|
201
432
|
instructions: z.string().optional(),
|
|
202
433
|
/** Playback speed hint passed to the provider when supported. Default 1.0. */
|
|
203
434
|
speed: z.number().min(0.5).max(2).optional(),
|
|
435
|
+
/**
|
|
436
|
+
* TTS-only substitutions, whole-word, case-sensitive: `{"aidemo":"A.I. demo",
|
|
437
|
+
* "SQL":"sequel"}`. Applied to the text sent to the voice provider; the
|
|
438
|
+
* narration (and therefore the captions) keeps the written form. Merged
|
|
439
|
+
* storyboard → scene (scene entries win).
|
|
440
|
+
*/
|
|
441
|
+
pronounce: z.record(z.string(), z.string()).optional(),
|
|
204
442
|
});
|
|
205
443
|
export type VoicePlan = z.infer<typeof VoicePlanSchema>;
|
|
206
444
|
|
|
@@ -217,8 +455,169 @@ export const MusicCueSchema = z.object({
|
|
|
217
455
|
// Scene + Storyboard
|
|
218
456
|
// ---------------------------------------------------------------------------
|
|
219
457
|
|
|
458
|
+
/**
|
|
459
|
+
* A region to blur at compose time. The player re-measures the selector after
|
|
460
|
+
* every action (all matches, up to 8) and logs the spans; compose crops, blurs
|
|
461
|
+
* and overlays each span back — so a price, an email, a token never ships,
|
|
462
|
+
* and the strength is a recompose. Frame-relative selectors use `frame`.
|
|
463
|
+
*/
|
|
464
|
+
export const RedactSchema = z.object({
|
|
465
|
+
selector: z.string(),
|
|
466
|
+
frame: z.string().optional(),
|
|
467
|
+
/** boxblur radius, px at the logical size. Default 14. */
|
|
468
|
+
blur: z.number().min(2).max(60).optional(),
|
|
469
|
+
});
|
|
470
|
+
export type Redact = z.infer<typeof RedactSchema>;
|
|
471
|
+
|
|
472
|
+
/** Caption strip placement + look. */
|
|
473
|
+
export const CaptionsConfigSchema = z.object({
|
|
474
|
+
/**
|
|
475
|
+
* "bottom" (default) or "top". With "auto-flip" (the default behaviour when
|
|
476
|
+
* omitted at scene level) compose moves the strip to the top while a
|
|
477
|
+
* highlight/spotlight/callout box or a keystroke chip would collide with it.
|
|
478
|
+
*/
|
|
479
|
+
position: z.enum(["bottom", "top"]).optional(),
|
|
480
|
+
/** "pill" (default, centered rounded box), "bar" (full-width band), "none" (no captions burned). */
|
|
481
|
+
style: z.enum(["pill", "bar", "none"]).optional(),
|
|
482
|
+
/** CSS font-family. Default: the brand font, else the system sans. */
|
|
483
|
+
font: z.string().optional(),
|
|
484
|
+
/** Font size, px at the logical width. Default 30. */
|
|
485
|
+
size: z.number().min(14).max(72).optional(),
|
|
486
|
+
/** Text color. Default #fff. */
|
|
487
|
+
color: z.string().optional(),
|
|
488
|
+
/** Pill/bar background (CSS). Default rgba(12,14,22,.66). */
|
|
489
|
+
background: z.string().optional(),
|
|
490
|
+
/**
|
|
491
|
+
* Cue segmentation (storyboard level only — per-scene overrides just move
|
|
492
|
+
* the strip). Defaults follow broadcast practice: up to 2 lines of ~38
|
|
493
|
+
* characters, 1–6 s per cue, broken at punctuation and conjunctions rather
|
|
494
|
+
* than at a hard word count (issue #48).
|
|
495
|
+
*/
|
|
496
|
+
/** Max words per cue. Default 12 (≈2 lines). */
|
|
497
|
+
maxWords: z.number().int().min(2).max(30).optional(),
|
|
498
|
+
/** Max cue length, ms. Default 6000. */
|
|
499
|
+
maxCueMs: z.number().min(500).max(12000).optional(),
|
|
500
|
+
/** Cues shorter than this are merged into a neighbour where they fit, ms. Default 1000. */
|
|
501
|
+
minCueMs: z.number().min(0).max(4000).optional(),
|
|
502
|
+
/** Lines a cue may occupy. Default 2. */
|
|
503
|
+
maxLines: z.number().int().min(1).max(3).optional(),
|
|
504
|
+
/** Characters per line used to size a cue. Default 38. */
|
|
505
|
+
maxCharsPerLine: z.number().int().min(16).max(80).optional(),
|
|
506
|
+
});
|
|
507
|
+
export type CaptionsConfig = z.infer<typeof CaptionsConfigSchema>;
|
|
508
|
+
|
|
509
|
+
/** Storyboard-wide defaults for the attention overlays. */
|
|
510
|
+
export const AttentionConfigSchema = z.object({
|
|
511
|
+
/** Default outline / pill accent color. Default #ff5a5f. */
|
|
512
|
+
color: z.string().optional(),
|
|
513
|
+
/** Draw a click ring where every click lands (compose-time cursor only). */
|
|
514
|
+
clicks: z.boolean().optional(),
|
|
515
|
+
/**
|
|
516
|
+
* Move the cursor out of the way before a highlight/spotlight/callout/focus
|
|
517
|
+
* beat, so the pointer never sits on top of the value being showcased
|
|
518
|
+
* (issue #50). Opt-in; the cursor glides to just outside the target's box.
|
|
519
|
+
*/
|
|
520
|
+
cursorClear: z.boolean().optional(),
|
|
521
|
+
});
|
|
522
|
+
export type AttentionConfig = z.infer<typeof AttentionConfigSchema>;
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Produced-look frame around the content: padding, background, rounded
|
|
526
|
+
* corners, drop shadow, optional browser chrome. Compose-time; captions and
|
|
527
|
+
* cards render at the framed canvas size. Omit for the raw recording frame.
|
|
528
|
+
*/
|
|
529
|
+
export const FrameSchema = z.object({
|
|
530
|
+
/** Padding around the video, px at the logical size. Default 48. */
|
|
531
|
+
padding: z.number().min(0).max(400).optional(),
|
|
532
|
+
/** CSS background (color or gradient). Default: dark gradient tinted by brand.accent. */
|
|
533
|
+
background: z.string().optional(),
|
|
534
|
+
/** Corner radius of the video window, px. Default 14. */
|
|
535
|
+
radius: z.number().min(0).max(80).optional(),
|
|
536
|
+
/** Drop shadow under the window. Default true. */
|
|
537
|
+
shadow: z.boolean().optional(),
|
|
538
|
+
/**
|
|
539
|
+
* Window chrome: "none" (default), "browser" / "mac" (a bar with dots or
|
|
540
|
+
* traffic lights above the video), or a device bezel for phone-sized
|
|
541
|
+
* viewports — "iphone" (rounded bezel + dynamic island) / "android" (bezel +
|
|
542
|
+
* punch-hole camera). Device chrome adds no bar; `radius` defaults to 40.
|
|
543
|
+
*/
|
|
544
|
+
chrome: z.enum(["none", "browser", "mac", "iphone", "android"]).optional(),
|
|
545
|
+
/**
|
|
546
|
+
* Device chrome only: reserve a status-bar strip of bezel ABOVE the video
|
|
547
|
+
* (px at the logical size) instead of drawing the island / punch-hole over
|
|
548
|
+
* the recording's top pixels. `true` = 46 px (an iPhone status bar). Use it
|
|
549
|
+
* when the app paints something at the very top that must stay visible.
|
|
550
|
+
*/
|
|
551
|
+
safeTop: z.union([z.boolean(), z.number().min(0).max(120)]).optional(),
|
|
552
|
+
/** Text in the chrome's address pill (url wins over title). */
|
|
553
|
+
title: z.string().optional(),
|
|
554
|
+
/**
|
|
555
|
+
* Address-pill text. `"auto"` tracks the page: each scene's pill shows the
|
|
556
|
+
* host of the URL that scene is actually on (from the take's `goto`s), so a
|
|
557
|
+
* demo that moves between hosts stops printing one fixed address over all of
|
|
558
|
+
* them (issue #52). Any other string is used verbatim for the whole video.
|
|
559
|
+
*/
|
|
560
|
+
url: z.string().optional(),
|
|
561
|
+
});
|
|
562
|
+
export type Frame = z.infer<typeof FrameSchema>;
|
|
563
|
+
|
|
564
|
+
/** Brand kit applied across cards, frame, captions, callouts and a watermark. */
|
|
565
|
+
export const BrandSchema = z.object({
|
|
566
|
+
/** Logo image path (png/svg/jpg), relative to the demo dir or absolute. Used as the watermark. */
|
|
567
|
+
logo: z.string().optional(),
|
|
568
|
+
/** Accent color: card rule, frame tint, default attention color. */
|
|
569
|
+
accent: z.string().optional(),
|
|
570
|
+
/** CSS font-family for cards, captions, callouts, chrome. */
|
|
571
|
+
font: z.string().optional(),
|
|
572
|
+
/** Watermark placement for `logo`. */
|
|
573
|
+
watermark: z
|
|
574
|
+
.object({
|
|
575
|
+
position: z
|
|
576
|
+
.enum(["top-left", "top-right", "bottom-left", "bottom-right"])
|
|
577
|
+
.optional(),
|
|
578
|
+
/** 0–1. Default 0.85. */
|
|
579
|
+
opacity: z.number().min(0).max(1).optional(),
|
|
580
|
+
/** Logo width as a fraction of the frame width. Default 0.11. */
|
|
581
|
+
scale: z.number().min(0.03).max(0.5).optional(),
|
|
582
|
+
/** Set false to keep the logo out of the video (cards only). */
|
|
583
|
+
enabled: z.boolean().optional(),
|
|
584
|
+
})
|
|
585
|
+
.optional(),
|
|
586
|
+
});
|
|
587
|
+
export type Brand = z.infer<typeof BrandSchema>;
|
|
588
|
+
|
|
589
|
+
/** Tuning for compose-time idle detection (`autoIdle`). */
|
|
590
|
+
export const AutoIdleSchema = z.object({
|
|
591
|
+
enabled: z.boolean().optional(),
|
|
592
|
+
/** Shortest motionless span that counts as idle, ms. Default 1500. */
|
|
593
|
+
minMs: z.number().min(400).max(20000).optional(),
|
|
594
|
+
/**
|
|
595
|
+
* Per-pixel noise floor below which two frames count as identical (ffmpeg
|
|
596
|
+
* `freezedetect` noise, 0..1). Default 0.003 — tolerant of encoder dither,
|
|
597
|
+
* tight enough that a blinking caret still reads as motion.
|
|
598
|
+
*/
|
|
599
|
+
noise: z.number().min(0).max(0.2).optional(),
|
|
600
|
+
});
|
|
601
|
+
export type AutoIdle = z.infer<typeof AutoIdleSchema>;
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Scene-to-scene transition (opt-in). When present, compose crossfades the
|
|
605
|
+
* video across every scene boundary instead of hard-cutting. The overlap is
|
|
606
|
+
* stolen from each scene's own frozen tail so the timeline does NOT shrink —
|
|
607
|
+
* total duration and per-scene narration alignment are preserved exactly.
|
|
608
|
+
* Omit the key for the default hard cuts (unchanged stream-copy concat).
|
|
609
|
+
*/
|
|
610
|
+
export const TransitionSchema = z.object({
|
|
611
|
+
type: z.literal("crossfade"),
|
|
612
|
+
/** Crossfade length, ms. Default 400. */
|
|
613
|
+
durationMs: z.number().default(400),
|
|
614
|
+
});
|
|
615
|
+
export type Transition = z.infer<typeof TransitionSchema>;
|
|
616
|
+
|
|
220
617
|
export const SceneSchema = z.object({
|
|
221
618
|
id: z.string(),
|
|
619
|
+
/** Chapter title for this beat (`output.chapters`); defaults to the scene id. */
|
|
620
|
+
title: z.string().optional(),
|
|
222
621
|
/** Spoken narration for this beat. Also the caption source of truth. */
|
|
223
622
|
narration: z.string(),
|
|
224
623
|
/**
|
|
@@ -230,10 +629,34 @@ export const SceneSchema = z.object({
|
|
|
230
629
|
* LLM to translate. A code missing here falls back to `narration`.
|
|
231
630
|
*/
|
|
232
631
|
narrations: z.record(z.string(), z.string()).optional(),
|
|
632
|
+
/**
|
|
633
|
+
* Engine-populated at parse time from `{{@name}}` markers in `narration`:
|
|
634
|
+
* name → 0-based index of the word the beat lands on. Don't author this —
|
|
635
|
+
* write the marker in the narration instead.
|
|
636
|
+
*/
|
|
637
|
+
anchors: z.record(z.string(), z.number().int().min(0)).optional(),
|
|
638
|
+
/** Same, per `narrations` language code. */
|
|
639
|
+
narrationAnchors: z.record(z.string(), z.record(z.string(), z.number().int().min(0))).optional(),
|
|
233
640
|
voice: VoicePlanSchema.optional(),
|
|
234
641
|
music: MusicCueSchema.optional(),
|
|
235
642
|
/** Set false to suppress auto-zoom for this scene's clicks/typing. */
|
|
236
643
|
zoom: z.boolean().optional(),
|
|
644
|
+
/** Override the top-level `autoIdle` for this scene (true enables, false disables). */
|
|
645
|
+
autoIdle: z.boolean().optional(),
|
|
646
|
+
/** Caption placement for this scene (overrides top-level `captions.position`). */
|
|
647
|
+
captions: CaptionsConfigSchema.optional(),
|
|
648
|
+
/**
|
|
649
|
+
* Override the storyboard `transition` at the boundary ENTERING this scene:
|
|
650
|
+
* `false` hard-cuts into it, an object changes the crossfade. Use it when a
|
|
651
|
+
* dissolve would ghost — e.g. this scene opens on a new page still painting
|
|
652
|
+
* (issue #49). Boundaries that cross a navigation hard-cut automatically;
|
|
653
|
+
* this is the manual override.
|
|
654
|
+
*/
|
|
655
|
+
transition: z.union([z.literal(false), TransitionSchema]).optional(),
|
|
656
|
+
/** Blur these regions while this scene records (adds to top-level `redact`). */
|
|
657
|
+
redact: z.array(RedactSchema).optional(),
|
|
658
|
+
/** Hide these selectors for this scene (adds to top-level `hide`). */
|
|
659
|
+
hide: z.array(z.string()).optional(),
|
|
237
660
|
actions: z.array(ActionSchema).default([]),
|
|
238
661
|
});
|
|
239
662
|
export type Scene = z.infer<typeof SceneSchema>;
|
|
@@ -317,6 +740,20 @@ export const CursorConfigSchema = z.object({
|
|
|
317
740
|
hideScenes: z.array(z.string()).optional(),
|
|
318
741
|
/** Cursor size multiplier (1 = the 24px baseline arrow). */
|
|
319
742
|
scale: z.number().min(0.5).max(4).optional(),
|
|
743
|
+
/** Pointer look: classic "arrow" (default) or a soft "dot" (screen-recorder style). */
|
|
744
|
+
style: z.enum(["arrow", "dot"]).optional(),
|
|
745
|
+
/** Dot style only: fill color. Default rgba(255,90,95,.85). */
|
|
746
|
+
color: z.string().optional(),
|
|
747
|
+
/**
|
|
748
|
+
* Outline drawn around the pointer so it stays findable on brand-coloured
|
|
749
|
+
* UI (a gold dot on a gold chip disappears — issue #50). CSS color; default
|
|
750
|
+
* rgba(255,255,255,.9) for "dot", the arrow's dark stroke otherwise.
|
|
751
|
+
*/
|
|
752
|
+
outline: z.string().optional(),
|
|
753
|
+
/** Outline thickness in cursor units (the pointer is 24 wide). Default 2. */
|
|
754
|
+
outlineWidth: z.number().min(0).max(6).optional(),
|
|
755
|
+
/** Soft contrasting halo behind the pointer — findable on any background. */
|
|
756
|
+
halo: z.boolean().optional(),
|
|
320
757
|
});
|
|
321
758
|
export type CursorConfig = z.infer<typeof CursorConfigSchema>;
|
|
322
759
|
|
|
@@ -347,19 +784,6 @@ export const CardSchema = z.object({
|
|
|
347
784
|
});
|
|
348
785
|
export type Card = z.infer<typeof CardSchema>;
|
|
349
786
|
|
|
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
787
|
|
|
364
788
|
/**
|
|
365
789
|
* Master loudness target for the final muxed audio (ffmpeg `loudnorm`). Lands
|
|
@@ -389,15 +813,57 @@ export type Loudness = z.infer<typeof LoudnessSchema>;
|
|
|
389
813
|
* `loudness` (independent of resize) tunes the master level; omit it entirely
|
|
390
814
|
* for the automatic default (loudnorm when there's music, none otherwise).
|
|
391
815
|
*/
|
|
816
|
+
/** Named output presets — fill width/height/fit when they are not set explicitly. */
|
|
817
|
+
export const OutputPresetSchema = z.enum(["youtube", "short", "readme-gif", "x"]);
|
|
818
|
+
export type OutputPreset = z.infer<typeof OutputPresetSchema>;
|
|
819
|
+
export const OUTPUT_PRESETS: Record<
|
|
820
|
+
OutputPreset,
|
|
821
|
+
{ width: number; height: number; fit: "contain" | "cover" }
|
|
822
|
+
> = {
|
|
823
|
+
youtube: { width: 1920, height: 1080, fit: "contain" },
|
|
824
|
+
short: { width: 1080, height: 1920, fit: "contain" },
|
|
825
|
+
"readme-gif": { width: 960, height: 540, fit: "contain" },
|
|
826
|
+
x: { width: 1280, height: 720, fit: "contain" },
|
|
827
|
+
};
|
|
828
|
+
|
|
392
829
|
export const OutputSchema = z
|
|
393
830
|
.object({
|
|
831
|
+
/** Preset sizing (see OUTPUT_PRESETS); explicit width/height/fit win. */
|
|
832
|
+
preset: OutputPresetSchema.optional(),
|
|
833
|
+
/** Write MP4 chapter markers, one per scene (scene `title` or id). Default false. */
|
|
834
|
+
chapters: z.boolean().optional(),
|
|
835
|
+
/**
|
|
836
|
+
* Also extract output/poster.png. `true` picks the first frame after the
|
|
837
|
+
* intro card that actually shows CONTENT (a blank white load frame is
|
|
838
|
+
* skipped — issue #47); a number picks the frame at that ms of the final
|
|
839
|
+
* video instead. Default false.
|
|
840
|
+
*/
|
|
841
|
+
poster: z.union([z.boolean(), z.number().min(0)]).optional(),
|
|
842
|
+
/**
|
|
843
|
+
* Drop the browser's blank pre-paint at the very start of the video, so
|
|
844
|
+
* the demo opens on the product instead of on ~0.5 s of white under the
|
|
845
|
+
* first caption (issue #47). At most `blankOpenCapMs` of the leading flat
|
|
846
|
+
* span is kept. Default false (unchanged opening).
|
|
847
|
+
*/
|
|
848
|
+
trimLeadingBlank: z.boolean().optional(),
|
|
849
|
+
/** With `trimLeadingBlank`: leading blank kept, ms. Default 100. */
|
|
850
|
+
blankOpenCapMs: z.number().min(0).max(2000).optional(),
|
|
851
|
+
/**
|
|
852
|
+
* Also export output/walkthrough/ (index.html + guide.md + per-scene
|
|
853
|
+
* frames + captions) after every render. Default false; `aidemo
|
|
854
|
+
* walkthrough <dir>` / the `walkthrough` job export on demand.
|
|
855
|
+
*/
|
|
856
|
+
walkthrough: z.boolean().optional(),
|
|
394
857
|
width: z.number().optional(),
|
|
395
858
|
height: z.number().optional(),
|
|
396
859
|
/**
|
|
397
860
|
* "contain" (default) = scale to fit + pad the remainder (letterbox bars).
|
|
398
|
-
* "cover" = scale to fill + center-crop the overflow (no bars)
|
|
861
|
+
* "cover" = scale to fill + center-crop the overflow (no bars) — the
|
|
862
|
+
* edge-to-edge answer when the capture's aspect differs from the
|
|
863
|
+
* preset's (issue #58). Set explicitly it also wins over a `preset`'s
|
|
864
|
+
* own fit; leave it out to take the preset's.
|
|
399
865
|
*/
|
|
400
|
-
fit: z.enum(["contain", "cover"]).
|
|
866
|
+
fit: z.enum(["contain", "cover"]).optional(),
|
|
401
867
|
/** Pad color for "contain" (ffmpeg color syntax, e.g. black, 0x1a1a1a). Default black. */
|
|
402
868
|
background: z.string().optional(),
|
|
403
869
|
/**
|
|
@@ -415,6 +881,99 @@ export const OutputSchema = z
|
|
|
415
881
|
});
|
|
416
882
|
export type Output = z.infer<typeof OutputSchema>;
|
|
417
883
|
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* What compose does when a scene's narration outlasts its recorded action
|
|
887
|
+
* (after the ≤1.6x slow-down): the remainder is a HOLD on the last frame.
|
|
888
|
+
* Default "freeze" clones that frame (byte-for-byte the pre-`hold` behavior).
|
|
889
|
+
* "drift" instead pushes in very slowly on it (a Ken-Burns-style creep of
|
|
890
|
+
* `driftScale` over the hold), so a 10 s hold reads as a deliberate dwell
|
|
891
|
+
* rather than a stalled video — the fix for narration-heavy demos where half
|
|
892
|
+
* the runtime was a frozen frame (issue #40). `backoffMs` takes the held frame
|
|
893
|
+
* from slightly BEFORE the segment's end, dodging a half-painted transition or
|
|
894
|
+
* a hover state caught mid-flight. Opt-in: omit the key for the old behavior.
|
|
895
|
+
*/
|
|
896
|
+
export const HoldSchema = z.object({
|
|
897
|
+
mode: z.enum(["freeze", "drift"]).default("freeze"),
|
|
898
|
+
/** Drift mode: zoom level reached at the end of the hold. Default 1.06. */
|
|
899
|
+
driftScale: z.number().min(1).max(1.5).default(1.06),
|
|
900
|
+
/** Take the held frame this many ms before the segment end. Default 0. */
|
|
901
|
+
backoffMs: z.number().min(0).max(2000).default(0),
|
|
902
|
+
/**
|
|
903
|
+
* Shortest hold that still drifts, ms. Default 2000: a 6-px push over a
|
|
904
|
+
* second reads as a bob — a jitter bug — not as a dwell, so short holds
|
|
905
|
+
* freeze instead (issue #57).
|
|
906
|
+
*/
|
|
907
|
+
minDriftMs: z.number().min(0).max(20000).default(2000),
|
|
908
|
+
/**
|
|
909
|
+
* Drift even when a window `frame.chrome` is composited around the video.
|
|
910
|
+
* Default false: the drift zooms the captured PAGE while the drawn browser
|
|
911
|
+
* chrome stays put, so the eye — locked on the static window edge and the
|
|
912
|
+
* sticky header — sees the page sliding inside its frame, i.e. a scroll
|
|
913
|
+
* glitch. Such scenes freeze instead unless you set this (issue #57).
|
|
914
|
+
*/
|
|
915
|
+
driftInFrame: z.boolean().default(false),
|
|
916
|
+
});
|
|
917
|
+
export type HoldConfig = z.infer<typeof HoldSchema>;
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* One cookie to seed into the recording profile before the take. Mirrors
|
|
921
|
+
* Playwright's cookie shape; `domain` + `path` (or `url`) place it.
|
|
922
|
+
*/
|
|
923
|
+
export const SeedCookieSchema = z.object({
|
|
924
|
+
name: z.string(),
|
|
925
|
+
value: z.string(),
|
|
926
|
+
/** Cookie domain, e.g. ".example.com" or "app.example.com". */
|
|
927
|
+
domain: z.string().optional(),
|
|
928
|
+
/** Cookie path. Default "/". */
|
|
929
|
+
path: z.string().optional(),
|
|
930
|
+
/** Alternative to domain+path: the URL the cookie belongs to. */
|
|
931
|
+
url: z.string().optional(),
|
|
932
|
+
/** Unix time in seconds; omit for a session cookie. */
|
|
933
|
+
expires: z.number().optional(),
|
|
934
|
+
httpOnly: z.boolean().optional(),
|
|
935
|
+
secure: z.boolean().optional(),
|
|
936
|
+
sameSite: z.enum(["Strict", "Lax", "None"]).optional(),
|
|
937
|
+
});
|
|
938
|
+
export type SeedCookie = z.infer<typeof SeedCookieSchema>;
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* How the take is prepared BEFORE the first action — the answer to "the
|
|
942
|
+
* feature is behind a cookie gate / a preview token / a rotating fixture"
|
|
943
|
+
* without a hand-written Playwright seed script (issue #44). Everything here
|
|
944
|
+
* is opt-in; a storyboard without `setup` records exactly as before.
|
|
945
|
+
*/
|
|
946
|
+
export const SetupSchema = z.object({
|
|
947
|
+
/**
|
|
948
|
+
* Path (relative to the demo dir, or absolute) to a Playwright storageState
|
|
949
|
+
* JSON (`{cookies:[…], origins:[{origin, localStorage:[{name,value}]}]}`),
|
|
950
|
+
* e.g. one saved by `context.storageState({path})`. Its cookies are added
|
|
951
|
+
* to the recording profile and each origin's localStorage entries are set
|
|
952
|
+
* before the storyboard runs. CLI: `--storage-state <file>`.
|
|
953
|
+
*/
|
|
954
|
+
storageState: z.string().optional(),
|
|
955
|
+
/** Cookies to add before the take. CLI: `--cookie name=value;domain=host` (repeatable). */
|
|
956
|
+
cookies: z.array(SeedCookieSchema).optional(),
|
|
957
|
+
/**
|
|
958
|
+
* Shell command run from the demo dir before EVERY take (record/probe/render),
|
|
959
|
+
* after the profile is resolved and before Chrome launches — e.g. a script
|
|
960
|
+
* that re-seeds a fixture, or patches the storyboard for whichever variant
|
|
961
|
+
* the app will serve today. Env: AIDEMO_DEMO_DIR, AIDEMO_STORYBOARD (the
|
|
962
|
+
* storyboard path — re-read after the hook, so edits are honored),
|
|
963
|
+
* AIDEMO_PROFILE (the Chrome user-data dir the take will use). A non-zero
|
|
964
|
+
* exit aborts the take.
|
|
965
|
+
*/
|
|
966
|
+
preflight: z.string().optional(),
|
|
967
|
+
/**
|
|
968
|
+
* Acknowledge that the recording profile is deliberately seeded (a login,
|
|
969
|
+
* a cookie gate) and silence the carried-over-state warning for this
|
|
970
|
+
* storyboard. Implied when storageState/cookies are given. CLI:
|
|
971
|
+
* `--profile-seeded`.
|
|
972
|
+
*/
|
|
973
|
+
expectState: z.boolean().optional(),
|
|
974
|
+
});
|
|
975
|
+
export type Setup = z.infer<typeof SetupSchema>;
|
|
976
|
+
|
|
418
977
|
export const StoryboardSchema = z.object({
|
|
419
978
|
title: z.string(),
|
|
420
979
|
/**
|
|
@@ -429,6 +988,15 @@ export const StoryboardSchema = z.object({
|
|
|
429
988
|
* fallback for non-English narration. Omit for English / to auto-detect.
|
|
430
989
|
*/
|
|
431
990
|
language: z.string().optional(),
|
|
991
|
+
/**
|
|
992
|
+
* Product names, brands and jargon that Whisper reliably mis-hears — the
|
|
993
|
+
* transcript is what gets burned in, so "fitness.ee" coming back as
|
|
994
|
+
* "fitness topam" ships as a caption (issue #38). Listed here they are sent
|
|
995
|
+
* at the FRONT of the STT prompt, inside the window Whisper actually biases
|
|
996
|
+
* on, even when the script itself is long. Cheaper than `captions --offline`,
|
|
997
|
+
* which fixes spelling but gives up real word timings.
|
|
998
|
+
*/
|
|
999
|
+
knownTerms: z.array(z.string()).optional(),
|
|
432
1000
|
/**
|
|
433
1001
|
* Declared template parameters: name → default value. Enables `{{name}}`
|
|
434
1002
|
* placeholders in any storyboard string (narration, action url, `type` text,
|
|
@@ -439,9 +1007,27 @@ export const StoryboardSchema = z.object({
|
|
|
439
1007
|
*/
|
|
440
1008
|
params: z.record(z.string(), z.string()).optional(),
|
|
441
1009
|
targetLengthSeconds: z.number().optional(),
|
|
442
|
-
/**
|
|
1010
|
+
/**
|
|
1011
|
+
* Recording viewport (CSS px) and, with `deviceScaleFactor`, the raw video
|
|
1012
|
+
* size. Default 1280x720.
|
|
1013
|
+
*/
|
|
443
1014
|
video: z
|
|
444
|
-
.object({
|
|
1015
|
+
.object({
|
|
1016
|
+
width: z.number(),
|
|
1017
|
+
height: z.number(),
|
|
1018
|
+
/**
|
|
1019
|
+
* Record the video at `width*n x height*n` pixels while the PAGE still
|
|
1020
|
+
* lays out at `width x height` CSS px — the standard fix for "the
|
|
1021
|
+
* product is unreadable" (issue #51). A desktop app recorded at a
|
|
1022
|
+
* 1920-px viewport renders its 1200-px content column tiny; record at
|
|
1023
|
+
* 1280–1440 CSS px with `deviceScaleFactor: 2` instead and let
|
|
1024
|
+
* `output.preset` scale the 2x raw down to 1080p: the UI is a third
|
|
1025
|
+
* bigger on screen and stays crisp. Default 1 (unchanged output).
|
|
1026
|
+
* Built-in (Playwright) capture only — native/OBS capture records real
|
|
1027
|
+
* screen pixels.
|
|
1028
|
+
*/
|
|
1029
|
+
deviceScaleFactor: z.number().min(1).max(3).optional(),
|
|
1030
|
+
})
|
|
445
1031
|
.default({ width: 1280, height: 720 }),
|
|
446
1032
|
/** Named frames used by actions, mapping name -> iframe selector. */
|
|
447
1033
|
frames: z.record(z.string(), z.string()).default({}),
|
|
@@ -461,6 +1047,32 @@ export const StoryboardSchema = z.object({
|
|
|
461
1047
|
motionBlur: MotionBlurSchema.optional(),
|
|
462
1048
|
/** Compose-time cursor control (hide/resize post-hoc). Omit to bake the cursor. */
|
|
463
1049
|
cursor: CursorConfigSchema.optional(),
|
|
1050
|
+
/** Pre-take preparation: cookies / storageState seeding, a preflight hook. Opt-in. */
|
|
1051
|
+
setup: SetupSchema.optional(),
|
|
1052
|
+
/** How compose fills narration that outlasts a scene's action (freeze | drift). Opt-in. */
|
|
1053
|
+
hold: HoldSchema.optional(),
|
|
1054
|
+
/**
|
|
1055
|
+
* Trim un-annotated dead air: compose scans the take for spans where nothing
|
|
1056
|
+
* on screen moves and treats them as idle (capped like `waitFor*` idle is),
|
|
1057
|
+
* so a slow XHR or a long `pause` stops inflating the scene. Opt-in — omit
|
|
1058
|
+
* for the old behavior, where only annotated waits count as idle. Pass an
|
|
1059
|
+
* object to tune the threshold; per-scene `autoIdle` overrides it.
|
|
1060
|
+
*/
|
|
1061
|
+
autoIdle: z.union([z.boolean(), AutoIdleSchema]).optional(),
|
|
1062
|
+
/** Defaults for highlight/spotlight/callout overlays + click rings. Opt-in. */
|
|
1063
|
+
attention: AttentionConfigSchema.optional(),
|
|
1064
|
+
/** Produced-look frame: padding/background/radius/shadow/chrome. Opt-in. */
|
|
1065
|
+
frame: FrameSchema.optional(),
|
|
1066
|
+
/** Brand kit: logo watermark, accent, font. Opt-in. */
|
|
1067
|
+
brand: BrandSchema.optional(),
|
|
1068
|
+
/** Show a keystroke chip on every `press` (per-action `keystrokes` overrides). Opt-in. */
|
|
1069
|
+
keystrokes: z.boolean().optional(),
|
|
1070
|
+
/** Caption strip placement (bottom | top). Opt-in; per-scene `captions` overrides. */
|
|
1071
|
+
captions: CaptionsConfigSchema.optional(),
|
|
1072
|
+
/** Regions blurred at compose time throughout the demo. Opt-in. */
|
|
1073
|
+
redact: z.array(RedactSchema).optional(),
|
|
1074
|
+
/** Selectors hidden (visibility:hidden) at record time throughout the demo. Opt-in. */
|
|
1075
|
+
hide: z.array(z.string()).optional(),
|
|
464
1076
|
scenes: z.array(SceneSchema).min(1),
|
|
465
1077
|
});
|
|
466
1078
|
export type Storyboard = z.infer<typeof StoryboardSchema>;
|
|
@@ -517,6 +1129,63 @@ export const CursorSampleSchema = z.object({
|
|
|
517
1129
|
});
|
|
518
1130
|
export type CursorSample = z.infer<typeof CursorSampleSchema>;
|
|
519
1131
|
|
|
1132
|
+
/**
|
|
1133
|
+
* What one storyboard action actually did during the take: wall-clock window,
|
|
1134
|
+
* outcome, retries, and anything worth a second look (failed requests seen in
|
|
1135
|
+
* its window, an optional skip). The per-action twin of the compose report —
|
|
1136
|
+
* an agent reads this to see which action ate the time or was skipped without
|
|
1137
|
+
* regex-mining the log. Default [] keeps older timelines valid.
|
|
1138
|
+
*/
|
|
1139
|
+
export const TimelineActionSchema = z.object({
|
|
1140
|
+
/** 0-based index in the scene's actions[]. */
|
|
1141
|
+
index: z.number(),
|
|
1142
|
+
op: z.string(),
|
|
1143
|
+
/** Resolved target (named→selector, frame-prefixed) / goto URL, when any. */
|
|
1144
|
+
target: z.string().optional(),
|
|
1145
|
+
startMs: z.number(),
|
|
1146
|
+
endMs: z.number(),
|
|
1147
|
+
ok: z.boolean(),
|
|
1148
|
+
/** An `optional` action whose target never appeared / that failed. */
|
|
1149
|
+
skipped: z.boolean().optional(),
|
|
1150
|
+
/** Extra attempts consumed before success (from `retry`). */
|
|
1151
|
+
retries: z.number().optional(),
|
|
1152
|
+
warnings: z.array(z.string()).optional(),
|
|
1153
|
+
});
|
|
1154
|
+
export type TimelineAction = z.infer<typeof TimelineActionSchema>;
|
|
1155
|
+
|
|
1156
|
+
/** A measured attention beat (highlight/spotlight/callout) — viewport CSS px. */
|
|
1157
|
+
export const AttentionEventSchema = z.object({
|
|
1158
|
+
tMs: z.number(),
|
|
1159
|
+
kind: z.enum(["highlight", "spotlight", "callout"]),
|
|
1160
|
+
x: z.number(),
|
|
1161
|
+
y: z.number(),
|
|
1162
|
+
w: z.number(),
|
|
1163
|
+
h: z.number(),
|
|
1164
|
+
holdMs: z.number(),
|
|
1165
|
+
text: z.string().optional(),
|
|
1166
|
+
placement: z.string().optional(),
|
|
1167
|
+
dimTo: z.number().optional(),
|
|
1168
|
+
padding: z.number().optional(),
|
|
1169
|
+
style: AttentionStyleSchema.optional(),
|
|
1170
|
+
});
|
|
1171
|
+
export type AttentionEvent = z.infer<typeof AttentionEventSchema>;
|
|
1172
|
+
|
|
1173
|
+
/** A key press to show as a chip (already prettified, e.g. "⌘ K", "Enter"). */
|
|
1174
|
+
export const KeyEventSchema = z.object({ tMs: z.number(), keys: z.string() });
|
|
1175
|
+
export type KeyEvent = z.infer<typeof KeyEventSchema>;
|
|
1176
|
+
|
|
1177
|
+
/** A region blurred at compose time over [startMs, endMs] — viewport CSS px. */
|
|
1178
|
+
export const RedactSpanSchema = z.object({
|
|
1179
|
+
startMs: z.number(),
|
|
1180
|
+
endMs: z.number(),
|
|
1181
|
+
x: z.number(),
|
|
1182
|
+
y: z.number(),
|
|
1183
|
+
w: z.number(),
|
|
1184
|
+
h: z.number(),
|
|
1185
|
+
blur: z.number(),
|
|
1186
|
+
});
|
|
1187
|
+
export type RedactSpan = z.infer<typeof RedactSpanSchema>;
|
|
1188
|
+
|
|
520
1189
|
export const TimelineSceneSchema = z.object({
|
|
521
1190
|
id: z.string(),
|
|
522
1191
|
startMs: z.number(),
|
|
@@ -525,6 +1194,21 @@ export const TimelineSceneSchema = z.object({
|
|
|
525
1194
|
focusEvents: z.array(FocusEventSchema).default([]),
|
|
526
1195
|
stillEvents: z.array(StillEventSchema).default([]),
|
|
527
1196
|
cursorSamples: z.array(CursorSampleSchema).default([]),
|
|
1197
|
+
actions: z.array(TimelineActionSchema).default([]),
|
|
1198
|
+
attentionEvents: z.array(AttentionEventSchema).default([]),
|
|
1199
|
+
keyEvents: z.array(KeyEventSchema).default([]),
|
|
1200
|
+
redactSpans: z.array(RedactSpanSchema).default([]),
|
|
1201
|
+
/** Anchored actions: when the beat happened in the raw take (scene-relative like tMs elsewhere). */
|
|
1202
|
+
anchorEvents: z.array(z.object({ name: z.string(), tMs: z.number(), action: z.number() })).default([]),
|
|
1203
|
+
/**
|
|
1204
|
+
* Resume (`record --from-scene`): scenes reused from an earlier take name the
|
|
1205
|
+
* raw file they live in (relative to the demo dir) and that take's lead-in;
|
|
1206
|
+
* absent = the current take (`recordings/raw.*` + the timeline's leadInMs).
|
|
1207
|
+
*/
|
|
1208
|
+
source: z.string().optional(),
|
|
1209
|
+
leadInMs: z.number().optional(),
|
|
1210
|
+
/** Identity of the scene's action-spec at record time — resume reuses a scene only when it matches. */
|
|
1211
|
+
hash: z.string().optional(),
|
|
528
1212
|
});
|
|
529
1213
|
export type TimelineScene = z.infer<typeof TimelineSceneSchema>;
|
|
530
1214
|
|
|
@@ -539,6 +1223,88 @@ export const TimelineSchema = z.object({
|
|
|
539
1223
|
});
|
|
540
1224
|
export type Timeline = z.infer<typeof TimelineSchema>;
|
|
541
1225
|
|
|
1226
|
+
|
|
1227
|
+
// ---------------------------------------------------------------------------
|
|
1228
|
+
// Compose report — written to output/report.json by every compose. The
|
|
1229
|
+
// structured twin of the compose log: per-scene retime facts (how much was
|
|
1230
|
+
// stretched, held, trimmed) plus machine-readable warnings, so an agent can
|
|
1231
|
+
// judge a take without regex-mining logTail (issue #40's 28-scene log
|
|
1232
|
+
// overflowed the 40-line tail; the freeze-hold dominance was invisible).
|
|
1233
|
+
// ---------------------------------------------------------------------------
|
|
1234
|
+
|
|
1235
|
+
export const ComposeWarningSchema = z.object({
|
|
1236
|
+
/** Stable code: scene-freeze | overrun-trim | blank-tail | stale-captions |
|
|
1237
|
+
* focus-dropped | cursor-missing | scene-no-narration */
|
|
1238
|
+
code: z.string(),
|
|
1239
|
+
scene: z.string().optional(),
|
|
1240
|
+
message: z.string(),
|
|
1241
|
+
});
|
|
1242
|
+
export type ComposeWarning = z.infer<typeof ComposeWarningSchema>;
|
|
1243
|
+
|
|
1244
|
+
export const ComposeSceneReportSchema = z.object({
|
|
1245
|
+
id: z.string(),
|
|
1246
|
+
/** Recorded (kept, non-idle) ms feeding this scene. */
|
|
1247
|
+
srcMs: z.number(),
|
|
1248
|
+
/** Narration + gap ms the scene had to fill. */
|
|
1249
|
+
targetMs: z.number(),
|
|
1250
|
+
/** Time-stretch factor applied (0.5 … 1.6). */
|
|
1251
|
+
factor: z.number(),
|
|
1252
|
+
/** Ms of hold (frozen / drifting last frame) appended after the stretch. */
|
|
1253
|
+
holdMs: z.number(),
|
|
1254
|
+
/** holdMs / targetMs — the share of the scene that is a held frame. */
|
|
1255
|
+
holdPct: z.number(),
|
|
1256
|
+
/** Ms of recorded action cut from the tail because it overran the narration. */
|
|
1257
|
+
tailTrimMs: z.number(),
|
|
1258
|
+
/** Ms of solid-color tail dropped before holding (white pre-paint). */
|
|
1259
|
+
blankTrimMs: z.number(),
|
|
1260
|
+
/** Ms of motionless footage `autoIdle` marked trimmable in this scene (0 when off). */
|
|
1261
|
+
autoIdleMs: z.number().default(0),
|
|
1262
|
+
/** Kept spans the scene was cut from (idle spans trimmed between them). */
|
|
1263
|
+
spans: z.number(),
|
|
1264
|
+
/** Focus points (zoom) this scene contributed. */
|
|
1265
|
+
focusEvents: z.number(),
|
|
1266
|
+
/** Narration anchors: where the beat was asked to land vs where it did (content ms, scene-relative). */
|
|
1267
|
+
anchors: z
|
|
1268
|
+
.array(
|
|
1269
|
+
z.object({
|
|
1270
|
+
name: z.string(),
|
|
1271
|
+
targetMs: z.number(),
|
|
1272
|
+
landedMs: z.number(),
|
|
1273
|
+
/** landedMs − targetMs; |offMs| > 150 raises `anchor-unreachable`. */
|
|
1274
|
+
offMs: z.number(),
|
|
1275
|
+
})
|
|
1276
|
+
)
|
|
1277
|
+
.optional(),
|
|
1278
|
+
});
|
|
1279
|
+
export type ComposeSceneReport = z.infer<typeof ComposeSceneReportSchema>;
|
|
1280
|
+
|
|
1281
|
+
export const ComposeReportSchema = z.object({
|
|
1282
|
+
output: z.string(),
|
|
1283
|
+
/** Final video length, ms. */
|
|
1284
|
+
durationMs: z.number(),
|
|
1285
|
+
scenes: z.array(ComposeSceneReportSchema),
|
|
1286
|
+
zoom: z.object({ focusTotal: z.number(), focusDropped: z.number() }),
|
|
1287
|
+
cursorPoints: z.number(),
|
|
1288
|
+
captions: z.object({ cues: z.number(), passes: z.number() }),
|
|
1289
|
+
/** Attention overlays drawn (highlight/spotlight/callout), key chips, click rings, redact spans. */
|
|
1290
|
+
attention: z
|
|
1291
|
+
.object({
|
|
1292
|
+
events: z.number(),
|
|
1293
|
+
keys: z.number(),
|
|
1294
|
+
clicks: z.number(),
|
|
1295
|
+
redactSpans: z.number(),
|
|
1296
|
+
captionsFlipped: z.number(),
|
|
1297
|
+
})
|
|
1298
|
+
.optional(),
|
|
1299
|
+
hold: z.string(),
|
|
1300
|
+
/** output/poster.png when `output.poster` is set. */
|
|
1301
|
+
poster: z.string().optional(),
|
|
1302
|
+
/** Ms of blank (contentless) video at the very start of the final cut. */
|
|
1303
|
+
blankOpenMs: z.number().optional(),
|
|
1304
|
+
warnings: z.array(ComposeWarningSchema),
|
|
1305
|
+
});
|
|
1306
|
+
export type ComposeReport = z.infer<typeof ComposeReportSchema>;
|
|
1307
|
+
|
|
542
1308
|
// ---------------------------------------------------------------------------
|
|
543
1309
|
// Voice manifest — emitted by the voice step. compose uses per-scene durations
|
|
544
1310
|
// as the target length for each scene's video segment.
|
|
@@ -600,9 +1366,10 @@ export const CaptionsManifestSchema = z.object({
|
|
|
600
1366
|
maxCueMs: z.number(),
|
|
601
1367
|
}),
|
|
602
1368
|
/**
|
|
603
|
-
* Per-scene inputs + reusable scene-relative word timings
|
|
604
|
-
*
|
|
605
|
-
*
|
|
1369
|
+
* Per-scene inputs + reusable scene-relative word timings. Offline path:
|
|
1370
|
+
* enables per-scene reuse (only a changed scene is re-derived). STT path:
|
|
1371
|
+
* the transcript split per scene, so compose can place narration anchors
|
|
1372
|
+
* on the word that was actually spoken.
|
|
606
1373
|
*/
|
|
607
1374
|
scenes: z
|
|
608
1375
|
.array(
|
|
@@ -642,6 +1409,13 @@ export const ProbeActionOutcomeSchema = z.object({
|
|
|
642
1409
|
ms: z.number().optional(),
|
|
643
1410
|
dy: z.number().optional(),
|
|
644
1411
|
label: z.string().optional(),
|
|
1412
|
+
/**
|
|
1413
|
+
* The action is marked `optional` in the storyboard. Optional actions always
|
|
1414
|
+
* record ok:true and no `found`: their real outcome is environment-dependent
|
|
1415
|
+
* by design (that's what optional is for), so it's normalized out of the
|
|
1416
|
+
* golden projection to keep it deterministic across runs.
|
|
1417
|
+
*/
|
|
1418
|
+
optional: z.boolean().optional(),
|
|
645
1419
|
});
|
|
646
1420
|
export type ProbeActionOutcome = z.infer<typeof ProbeActionOutcomeSchema>;
|
|
647
1421
|
|