@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/src/lint.ts ADDED
@@ -0,0 +1,673 @@
1
+ /**
2
+ * `aidemo lint` / MCP `lint_storyboard` — a browser-free preflight over a
3
+ * parsed storyboard. Schema validation says "this is a storyboard"; lint says
4
+ * "this storyboard will produce a bad video": scenes whose narration outlasts
5
+ * their action (compose will freeze-hold most of them — every real-world demo
6
+ * hit this), selectors that match the wrong node, ops that need a partner op,
7
+ * and no-op keys. Every rule here used to be a prose gotcha in AUTHORING.md
8
+ * that authors learned by burning a take.
9
+ *
10
+ * Pure: no Chrome, no ffmpeg, no network. Estimates are constants read off the
11
+ * player's sleeps (src/player.ts) and the compose retime bounds
12
+ * (src/compose.ts) — good to ±25 %, which is enough to flag a scene that will
13
+ * be 60 % frozen frame before TTS is paid for.
14
+ */
15
+
16
+ import { OUTPUT_PRESETS, type Storyboard, type Scene, type Action } from "./types.js";
17
+ import { clampScaleForWidth } from "./zoom.js";
18
+ import { compileUserRegex } from "./safe-regex.js";
19
+
20
+ export type LintSeverity = "error" | "warn" | "info";
21
+
22
+ export interface LintIssue {
23
+ severity: LintSeverity;
24
+ /** Stable rule id, e.g. "scene-pacing". */
25
+ code: string;
26
+ scene?: string;
27
+ /** 0-based action index within the scene. */
28
+ action?: number;
29
+ message: string;
30
+ /** What to change. */
31
+ fix?: string;
32
+ }
33
+
34
+ export interface LintSceneEstimate {
35
+ id: string;
36
+ words: number;
37
+ /** Predicted narration ms (words / rate). */
38
+ narrationMs: number;
39
+ /** Predicted recorded action ms (after idle trimming). */
40
+ actionMs: number;
41
+ /** Predicted hold share after compose's ≤1.6x slow-down (0..1). */
42
+ holdPct: number;
43
+ /** Predicted tail trim ms when the action outruns 2x speed-up. */
44
+ overrunMs: number;
45
+ }
46
+
47
+ export interface LintResult {
48
+ issues: LintIssue[];
49
+ /** Per-scene pacing forecast (what report.json will say after compose). */
50
+ estimate: LintSceneEstimate[];
51
+ /** Predicted narration total (scenes + gaps), ms. */
52
+ narrationTotalMs: number;
53
+ /** Words-per-second rate the estimate used. */
54
+ wordsPerSec: number;
55
+ }
56
+
57
+ /**
58
+ * Spoken words per second by language, at speed 1.0. English is the guide's
59
+ * planning figure; others are measured from real OpenAI TTS takes (Estonian
60
+ * `marin` ≈ 1.75–1.8 w/s, issue #15) or interpolated for similar morphology.
61
+ */
62
+ const WORDS_PER_SEC: Record<string, number> = {
63
+ en: 2.5,
64
+ et: 1.8,
65
+ fi: 1.85,
66
+ hu: 1.8,
67
+ de: 2.0,
68
+ nl: 2.1,
69
+ sv: 2.1,
70
+ da: 2.1,
71
+ no: 2.1,
72
+ fr: 2.2,
73
+ es: 2.3,
74
+ it: 2.2,
75
+ pt: 2.2,
76
+ pl: 1.9,
77
+ cs: 1.9,
78
+ ru: 1.9,
79
+ uk: 1.9,
80
+ ja: 2.0,
81
+ zh: 2.4,
82
+ ko: 2.0,
83
+ };
84
+ const DEFAULT_WPS = 2.2;
85
+
86
+ /** Inter-scene silence in the narration track (src/voice.ts GAP_MS). */
87
+ const GAP_MS = 300;
88
+ /** Compose retime bounds (src/compose.ts). */
89
+ const MAX_STRETCH = 1.6;
90
+ const MIN_FACTOR = 0.5;
91
+ /** Idle spans survive trimming as a sliver of this length (src/compose.ts). */
92
+ const IDLE_CAP_MS = 400;
93
+ /** Hold share past which compose itself warns (`scene-freeze`). */
94
+ const FREEZE_WARN_PCT = 0.4;
95
+
96
+ /** Default `autoIdle.minMs` — the forecast's view of what counts as dead air. */
97
+ const AUTO_IDLE_MIN_MS = 1500;
98
+
99
+ const EASING_MS: Record<string, number> = {
100
+ smooth: 850,
101
+ snappy: 450,
102
+ glide: 1500,
103
+ linear: 700,
104
+ };
105
+
106
+ export function countWords(text: string): number {
107
+ const trimmed = text.trim();
108
+ return trimmed ? trimmed.split(/\s+/).length : 0;
109
+ }
110
+
111
+ /**
112
+ * Predicted recorded ms one action contributes AFTER compose trims idle.
113
+ * `autoIdle` (compose-time idle detection) also collapses motionless waits the
114
+ * storyboard never marked — a long `pause` is the one the forecast can see.
115
+ */
116
+ export function estimateActionMs(a: Action, autoIdle = false): number {
117
+ switch (a.op) {
118
+ case "goto":
119
+ return 1400; // domcontentloaded + 600 ms settle + typical paint
120
+ case "click":
121
+ return 700; // glide ≈250 + 120 + 60 + 150 sleeps + scrollIntoView
122
+ case "type": {
123
+ const chars = a.text.length;
124
+ const spaces = (a.text.match(/[\s.,!?]/g) ?? []).length;
125
+ return 700 + (a.humanize === false ? chars * 25 : chars * 72 + spaces * 60);
126
+ }
127
+ case "press":
128
+ return 200;
129
+ case "hover":
130
+ return 600;
131
+ case "scrollTo":
132
+ return (a.durationMs ?? EASING_MS[a.easing ?? "smooth"]) + (a.settleMs ?? 300);
133
+ case "scrollBy":
134
+ return (a.durationMs ?? EASING_MS[a.easing ?? "smooth"]) + (a.settleMs ?? 250);
135
+ case "pause":
136
+ // Nothing moves during a pause, so autoIdle trims it to the same cap an
137
+ // annotated wait gets — but only past the detector's own threshold.
138
+ return autoIdle && a.ms >= AUTO_IDLE_MIN_MS ? IDLE_CAP_MS : a.ms;
139
+ case "focus":
140
+ return 150; // the zoom hold is compose-time and adds NO record time
141
+ case "still":
142
+ return 120;
143
+ case "waitFor":
144
+ return 500; // active (not idle-marked); usually resolves fast
145
+ case "waitForWidget":
146
+ case "waitForReply":
147
+ return IDLE_CAP_MS; // idle-marked → trimmed to the cap
148
+ case "waitForChange":
149
+ return a.idle ? IDLE_CAP_MS : 900;
150
+ case "highlight":
151
+ case "spotlight":
152
+ case "callout":
153
+ // Record-time dwell only; the overlay hold itself is compose-time.
154
+ return Math.min(a.holdMs ?? (a.op === "callout" ? 2000 : 1600), 800) + 150;
155
+ case "moveTo":
156
+ return 450; // glide + 150 ms settle
157
+ case "assert":
158
+ return 250; // usually already true; the poll returns on first check
159
+ case "select":
160
+ return 900; // click + programmatic pick + settle
161
+ case "drag":
162
+ return 1100; // two glides + press/release sleeps
163
+ case "upload":
164
+ return 800;
165
+ case "back":
166
+ return 1400; // like goto
167
+ case "dialog":
168
+ // Arming a handler costs nothing; `holdMs` pauses before answering.
169
+ return 20 + (a.holdMs ?? 0);
170
+ default:
171
+ return 300;
172
+ }
173
+ }
174
+
175
+ function languageOf(sb: Storyboard, lang?: string): string {
176
+ const code = (lang ?? sb.language ?? "en").toLowerCase().split(/[-_]/)[0];
177
+ return code;
178
+ }
179
+
180
+ /** Narration text a render of `lang` would speak for this scene. */
181
+ function narrationFor(scene: Scene, lang?: string): string {
182
+ if (lang && scene.narrations?.[lang]) return scene.narrations[lang];
183
+ return scene.narration ?? "";
184
+ }
185
+
186
+ /**
187
+ * Lint a parsed storyboard. `lang` selects `narrations[lang]` (multi-language
188
+ * renders) and the pacing rate; omit for the base narration.
189
+ */
190
+ export function lintStoryboard(
191
+ sb: Storyboard,
192
+ opts: { lang?: string } = {}
193
+ ): LintResult {
194
+ const issues: LintIssue[] = [];
195
+ const push = (i: LintIssue): void => {
196
+ issues.push(i);
197
+ };
198
+ const code = languageOf(sb, opts.lang);
199
+ const baseWps = WORDS_PER_SEC[code] ?? DEFAULT_WPS;
200
+ const speed = sb.voice?.speed ?? 1;
201
+ const wps = baseWps * speed;
202
+
203
+ const estimate: LintSceneEstimate[] = [];
204
+ let narrationTotalMs = 0;
205
+ const stillNames = new Map<string, string>();
206
+ const hasZoom = !!sb.zoom && sb.zoom.enabled !== false;
207
+
208
+ sb.scenes.forEach((scene, si) => {
209
+ const sceneWps = (sb.voice?.speed ?? 1) !== (scene.voice?.speed ?? sb.voice?.speed ?? 1)
210
+ ? baseWps * (scene.voice?.speed ?? 1)
211
+ : wps;
212
+ const text = narrationFor(scene, opts.lang);
213
+ const words = countWords(text);
214
+ const narrationMs = words > 0 ? Math.round((words / sceneWps) * 1000) : 0;
215
+ if (narrationMs > 0) narrationTotalMs += narrationMs + GAP_MS;
216
+
217
+ let actionMs = 0;
218
+ const sceneAutoIdle =
219
+ scene.autoIdle ??
220
+ (sb.autoIdle === true ||
221
+ (typeof sb.autoIdle === "object" && sb.autoIdle.enabled !== false));
222
+ for (const a of scene.actions) actionMs += estimateActionMs(a, sceneAutoIdle);
223
+
224
+ // --- pacing: what compose will have to do with this scene ---
225
+ let holdPct = 0;
226
+ let overrunMs = 0;
227
+ if (narrationMs > 0) {
228
+ const targetMs = narrationMs + GAP_MS;
229
+ const stretched = actionMs * MAX_STRETCH;
230
+ if (stretched < targetMs) {
231
+ holdPct = (targetMs - stretched) / targetMs;
232
+ } else if (actionMs * MIN_FACTOR > targetMs) {
233
+ overrunMs = Math.round(actionMs * MIN_FACTOR - targetMs);
234
+ }
235
+ if (holdPct > FREEZE_WARN_PCT) {
236
+ const holdSec = ((targetMs - stretched) / 1000).toFixed(1);
237
+ push({
238
+ severity: holdPct > 0.6 ? "warn" : "info",
239
+ code: "scene-pacing",
240
+ scene: scene.id,
241
+ message:
242
+ `≈${(actionMs / 1000).toFixed(1)}s of action for ≈${(narrationMs / 1000).toFixed(1)}s ` +
243
+ `of narration (${words} words @ ${sceneWps.toFixed(1)} w/s) — compose will hold ` +
244
+ `a frame for ≈${holdSec}s (${Math.round(holdPct * 100)}% of the scene)`,
245
+ fix:
246
+ "add on-screen beats (hover, scrollTo/scrollBy, focus + pause) so the action " +
247
+ "carries the narration, shorten the narration, or set `hold: {mode: \"drift\"}` " +
248
+ "so the hold reads as a slow push-in instead of a freeze",
249
+ });
250
+ } else if (overrunMs > 0) {
251
+ push({
252
+ severity: "warn",
253
+ code: "scene-overrun",
254
+ scene: scene.id,
255
+ message:
256
+ `≈${(actionMs / 1000).toFixed(1)}s of action for ≈${(narrationMs / 1000).toFixed(1)}s ` +
257
+ `of narration — even at 2x speed-up compose must cut ≈${(overrunMs / 1000).toFixed(1)}s ` +
258
+ `from the scene's tail (where the payoff usually is)`,
259
+ fix:
260
+ "lengthen the narration, mark long waits idle (waitForWidget/waitForReply, " +
261
+ "waitForChange idle:true), shorten `pause`s or `type` text, or split the scene",
262
+ });
263
+ }
264
+ } else if (scene.actions.length > 0 && !opts.lang) {
265
+ push({
266
+ severity: "info",
267
+ code: "scene-no-narration",
268
+ scene: scene.id,
269
+ message: "scene has actions but no narration — compose skips scenes without audio",
270
+ fix: "add narration (or drop the scene; probe runs it anyway)",
271
+ });
272
+ }
273
+ if (scene.actions.length === 0 && si > 0) {
274
+ push({
275
+ severity: "info",
276
+ code: "scene-no-actions",
277
+ scene: scene.id,
278
+ message: "scene has no actions — its whole narration plays over a held frame",
279
+ fix: "add a `pause`/`hover`/`focus` beat, or fold the narration into the neighbour scene",
280
+ });
281
+ }
282
+ estimate.push({ id: scene.id, words, narrationMs, actionMs, holdPct: Number(holdPct.toFixed(3)), overrunMs });
283
+
284
+ // --- narration anchors: every {{@name}} needs an action and vice versa ---
285
+ const anchorDefs =
286
+ (opts.lang && scene.narrations?.[opts.lang] ? scene.narrationAnchors?.[opts.lang] : undefined) ??
287
+ scene.anchors ??
288
+ {};
289
+ const anchorUse = new Map<string, number[]>();
290
+ scene.actions.forEach((a, ai) => {
291
+ if (!a.anchor) return;
292
+ anchorUse.set(a.anchor, [...(anchorUse.get(a.anchor) ?? []), ai]);
293
+ });
294
+ for (const [name, idxs] of anchorUse) {
295
+ if (!(name in anchorDefs)) {
296
+ push({
297
+ severity: "error",
298
+ code: "anchor-missing",
299
+ scene: scene.id,
300
+ action: idxs[0],
301
+ message: `action anchor "${name}" has no {{@${name}}} marker in this scene's narration — compose can't place the beat`,
302
+ fix: `write {{@${name}}} right before the word the action should land on`,
303
+ });
304
+ }
305
+ if (idxs.length > 1) {
306
+ push({
307
+ severity: "error",
308
+ code: "anchor-duplicate",
309
+ scene: scene.id,
310
+ action: idxs[1],
311
+ message: `anchor "${name}" is on ${idxs.length} actions — a marker can only place one beat`,
312
+ fix: "use distinct anchor names ({{@add}}, {{@checkout}})",
313
+ });
314
+ }
315
+ }
316
+ for (const name of Object.keys(anchorDefs)) {
317
+ if (anchorUse.has(name)) continue;
318
+ push({
319
+ severity: "warn",
320
+ code: "anchor-unused",
321
+ scene: scene.id,
322
+ message: `narration marks {{@${name}}} but no action in this scene has anchor: "${name}" — the marker does nothing`,
323
+ fix: `add anchor: "${name}" to the click/type/press that should land on that word`,
324
+ });
325
+ }
326
+
327
+ // --- structural rules over the action list ---
328
+ scene.actions.forEach((a, ai) => {
329
+ const next = scene.actions[ai + 1];
330
+ const rest = scene.actions.slice(ai + 1);
331
+ const sel = "target" in a && a.target ? a.target.selector ?? "" : "";
332
+
333
+ if (a.op === "moveTo" && !a.target && (a.x == null || a.y == null)) {
334
+ push({
335
+ severity: "error",
336
+ code: "moveto-no-point",
337
+ scene: scene.id,
338
+ action: ai,
339
+ message: "`moveTo` needs a `target` or both `x` and `y` — the take fails at this action",
340
+ fix: "give it a target, or an absolute viewport point {x, y}",
341
+ });
342
+ }
343
+ if (a.op === "assert") {
344
+ if (!a.target && !a.url) {
345
+ push({
346
+ severity: "error",
347
+ code: "assert-no-check",
348
+ scene: scene.id,
349
+ action: ai,
350
+ message: "`assert` needs a `target` and/or a `url` — the take fails at this action",
351
+ fix: "assert the payoff element ({target, textMatches?}) or the destination ({url})",
352
+ });
353
+ } else if (a.textMatches && !a.target) {
354
+ push({
355
+ severity: "warn",
356
+ code: "assert-text-no-target",
357
+ scene: scene.id,
358
+ action: ai,
359
+ message: "`assert.textMatches` is ignored without a `target`",
360
+ fix: "add the target whose text should match",
361
+ });
362
+ }
363
+ for (const re of [a.textMatches, a.url]) {
364
+ if (re == null) continue;
365
+ try {
366
+ compileUserRegex(re, "", "assert");
367
+ } catch (e) {
368
+ push({
369
+ severity: "error",
370
+ code: "assert-bad-regex",
371
+ scene: scene.id,
372
+ action: ai,
373
+ message: `\`assert\` regex ${JSON.stringify(re)} is unusable: ${(e as Error).message}`,
374
+ });
375
+ }
376
+ }
377
+ }
378
+ if (
379
+ a.retry &&
380
+ !["click", "type", "hover", "scrollTo", "focus", "moveTo", "assert", "select", "drag", "upload"].includes(a.op)
381
+ ) {
382
+ push({
383
+ severity: "info",
384
+ code: "retry-noop",
385
+ scene: scene.id,
386
+ action: ai,
387
+ message: `\`retry\` does nothing on \`${a.op}\` — only interactions and assert re-attempt`,
388
+ });
389
+ }
390
+
391
+ if (a.op === "type" && next?.op === "press" && /^enter$/i.test(next.key)) {
392
+ const waits = rest.some((r) => /^wait/.test(r.op));
393
+ if (!waits) {
394
+ push({
395
+ severity: "warn",
396
+ code: "type-enter-no-wait",
397
+ scene: scene.id,
398
+ action: ai,
399
+ message: "`type` + `press Enter` with no wait afterwards — the scene ends (or the next action fires) before the result renders",
400
+ fix: "follow it with waitFor (destination content), waitForChange, or waitForWidget/waitForReply",
401
+ });
402
+ }
403
+ }
404
+ if (a.op === "type" && a.target.named === "composer" && a.humanize !== false) {
405
+ push({
406
+ severity: "warn",
407
+ code: "composer-humanize",
408
+ scene: scene.id,
409
+ action: ai,
410
+ message: "typing into the ChatGPT composer with humanized keystrokes makes the scene's active video shorter than the reply it waits for",
411
+ fix: "set `humanize: false` on composer `type` actions (see AUTHORING → ChatGPT Apps SDK)",
412
+ });
413
+ }
414
+ if (a.op === "focus" && !hasZoom) {
415
+ push({
416
+ severity: "warn",
417
+ code: "focus-without-zoom",
418
+ scene: scene.id,
419
+ action: ai,
420
+ message: "`focus` is a zoom marker, but the storyboard has no `zoom` block — it renders as a 150 ms no-op",
421
+ fix: 'add a top-level `"zoom": {}` (or remove the focus)',
422
+ });
423
+ }
424
+ if (a.op === "still") {
425
+ const prev = stillNames.get(a.name);
426
+ if (prev) {
427
+ push({
428
+ severity: "error",
429
+ code: "still-duplicate",
430
+ scene: scene.id,
431
+ action: ai,
432
+ message: `still "${a.name}" is also declared in scene ${prev} — stills extraction fails on duplicate names`,
433
+ fix: "give each still a unique slug",
434
+ });
435
+ } else {
436
+ stillNames.set(a.name, scene.id);
437
+ }
438
+ }
439
+ if (sel.includes(":text-is(")) {
440
+ push({
441
+ severity: "info",
442
+ code: "text-is-selector",
443
+ scene: scene.id,
444
+ action: ai,
445
+ message: "`:text-is()` matches the innermost node with that exact text — on nested labels (RN-web, icon+label buttons) it resolves to the inner <div>, not the button",
446
+ fix: 'use `:has-text("…")` on the interactive wrapper (e.g. `[role="button"]:has-text("Library")`)',
447
+ });
448
+ }
449
+ if (a.op === "waitForChange" && a.textMatches && /^\^|\$$/.test(a.textMatches)) {
450
+ push({
451
+ severity: "info",
452
+ code: "waitforchange-anchored",
453
+ scene: scene.id,
454
+ action: ai,
455
+ message: `waitForChange matches the NEW text against /${a.textMatches}/ — if the element already reads that way (e.g. "+10" vs /^10$/) it never fires`,
456
+ fix: "match a substring the change introduces, or wait on an element the change creates",
457
+ });
458
+ }
459
+ if (
460
+ (a.op === "waitFor" || a.op === "waitForWidget" || a.op === "waitForChange") &&
461
+ a.timeoutMs != null &&
462
+ a.timeoutMs > 60000
463
+ ) {
464
+ push({
465
+ severity: "info",
466
+ code: "long-timeout",
467
+ scene: scene.id,
468
+ action: ai,
469
+ message: `${a.op} timeoutMs ${a.timeoutMs} — a missing element stalls the take for over a minute before failing`,
470
+ fix: "keep wait budgets ≤ 30 s unless the app is genuinely that slow",
471
+ });
472
+ }
473
+ // Only worth a note when the wait is *long*: an optional wait with a
474
+ // short budget is a legitimate "probe for this, move on" (issue #45 —
475
+ // the note used to fire for every optional wait, so taking its own
476
+ // advice could never clear it).
477
+ const optWaitMs =
478
+ a.op === "waitFor" || a.op === "waitForWidget" || a.op === "waitForChange"
479
+ ? a.timeoutMs
480
+ : undefined;
481
+ if (a.optional && /^wait/.test(a.op) && (optWaitMs == null || optWaitMs > 5000)) {
482
+ push({
483
+ severity: "info",
484
+ code: "optional-wait",
485
+ scene: scene.id,
486
+ action: ai,
487
+ message: `optional ${a.op}${
488
+ optWaitMs == null ? " (default timeout)" : ` timeoutMs ${optWaitMs}`
489
+ } still burns its full timeout as recorded (non-idle) time when the target never appears`,
490
+ fix: "give it a timeoutMs of 5000 or less — an optional wait should be a quick probe",
491
+ });
492
+ }
493
+ });
494
+
495
+ // Pre-navigate hint: a scene (not the first) that opens with navigation
496
+ // shows the OLD screen for the cursor glide, so its narration starts over
497
+ // the wrong page.
498
+ const first = scene.actions[0];
499
+ if (si > 0 && first && (first.op === "goto" || first.op === "click")) {
500
+ const lastPrev = sb.scenes[si - 1].actions.at(-1);
501
+ const prevEndsWithNav = lastPrev && (lastPrev.op === "goto" || lastPrev.op === "click");
502
+ if (first.op === "goto" || !prevEndsWithNav) {
503
+ push({
504
+ severity: "info",
505
+ code: "nav-first",
506
+ scene: scene.id,
507
+ action: 0,
508
+ message: `scene opens with a ${first.op} — the previous screen stays up (and this scene's narration starts over it) while the navigation lands`,
509
+ fix: "pre-navigate: move the nav action to the END of the previous scene so this scene opens on its screen",
510
+ });
511
+ }
512
+ }
513
+
514
+ if (scene.music?.cue) {
515
+ push({
516
+ severity: "info",
517
+ code: "music-cue-noop",
518
+ scene: scene.id,
519
+ message: "per-scene `music.cue` is informational only and ignored (the bed plays continuously)",
520
+ fix: "remove it, or accept the continuous ducked bed",
521
+ });
522
+ }
523
+ });
524
+
525
+ // --- storyboard-level rules ---
526
+
527
+ if (sb.attention?.clicks && !sb.cursor) {
528
+ push({
529
+ severity: "info",
530
+ code: "clicks-without-cursor",
531
+ message: "`attention.clicks` draws click rings only with the compose-time cursor — no `cursor` block, so no rings",
532
+ fix: "add a `cursor: {}` block (the take is then recorded cursor-free and the cursor is overlaid at compose)",
533
+ });
534
+ }
535
+ if (sb.zoom && sb.zoom.enabled !== false) {
536
+ const capped = clampScaleForWidth(sb.zoom.scale, sb.video.width);
537
+ if (capped < sb.zoom.scale) {
538
+ push({
539
+ severity: "info",
540
+ code: "zoom-scale-viewport",
541
+ message: `zoom.scale ${sb.zoom.scale} exceeds what a ${sb.video.width}px-wide viewport can show; compose caps it to ${capped.toFixed(2)}`,
542
+ fix: `set zoom.scale ≤ ${capped.toFixed(2)} (or widen the viewport)`,
543
+ });
544
+ }
545
+ }
546
+ if (sb.targetLengthSeconds != null) {
547
+ const introMs = sb.intro?.durationMs ?? 0;
548
+ const outroMs = sb.outro?.durationMs ?? 0;
549
+ const totalMs = narrationTotalMs + introMs + outroMs;
550
+ const targetMs = sb.targetLengthSeconds * 1000;
551
+ if (totalMs > targetMs * 1.1) {
552
+ const totalWords = estimate.reduce((a, e) => a + e.words, 0);
553
+ const budget = Math.round(((targetMs - introMs - outroMs) / 1000) * wps);
554
+ push({
555
+ severity: "warn",
556
+ code: "target-length",
557
+ message:
558
+ `≈${(totalMs / 1000).toFixed(0)}s predicted (${totalWords} words @ ${wps.toFixed(1)} w/s` +
559
+ (introMs || outroMs ? " + cards" : "") +
560
+ `) vs targetLengthSeconds ${sb.targetLengthSeconds}`,
561
+ fix: `trim the script to ≈${budget} words, or raise targetLengthSeconds`,
562
+ });
563
+ }
564
+ }
565
+ if (sb.cursor && !sb.cursor.hidden && sb.motionBlur && sb.motionBlur.enabled !== false) {
566
+ const scrolls = sb.scenes.reduce(
567
+ (n, s) => n + s.actions.filter((a) => a.op === "scrollTo" || a.op === "scrollBy").length,
568
+ 0
569
+ );
570
+ if (scrolls >= 3) {
571
+ push({
572
+ severity: "info",
573
+ code: "motionblur-scroll",
574
+ message: `motionBlur is on and the storyboard scrolls ${scrolls} times — tmix smears scrolling text`,
575
+ fix: "drop motionBlur on scroll-heavy demos (or use frames: 2)",
576
+ });
577
+ }
578
+ }
579
+
580
+ // --- legibility + framing (issues #51, #58) ---
581
+ const dsf = sb.video.deviceScaleFactor ?? 1;
582
+ const framePad = sb.frame?.padding ?? (sb.frame ? 48 : 0);
583
+ if (sb.video.width >= 1800 && dsf === 1) {
584
+ const contentPct = sb.frame
585
+ ? Math.round(((sb.video.width - 2 * framePad) / sb.video.width) * 100)
586
+ : 100;
587
+ push({
588
+ severity: "warn",
589
+ code: "viewport-legibility",
590
+ message:
591
+ `recording at ${sb.video.width}x${sb.video.height} CSS px: a desktop site lays out its ` +
592
+ `content column in the middle of that viewport, so body text ends up ~14px` +
593
+ (sb.frame ? ` inside a frame that keeps only ${contentPct}% of the canvas` : "") +
594
+ " — unreadable after the output downscale",
595
+ fix:
596
+ "record at 1280–1440 CSS px with `video.deviceScaleFactor: 2` and let `output.preset` " +
597
+ "scale the 2x raw down; the UI is a third bigger on screen and stays crisp",
598
+ });
599
+ }
600
+ const preset = sb.output?.preset ? OUTPUT_PRESETS[sb.output.preset] : null;
601
+ const outW = sb.output?.width ?? preset?.width;
602
+ const outH = sb.output?.height ?? preset?.height;
603
+ const fit = sb.output?.fit ?? preset?.fit ?? "contain";
604
+ if (outW && outH) {
605
+ const capAspect = sb.video.width / sb.video.height;
606
+ const outAspect = outW / outH;
607
+ if (fit === "contain" && Math.abs(capAspect - outAspect) / outAspect > 0.02) {
608
+ const barPx =
609
+ capAspect > outAspect
610
+ ? Math.round((outH - outW / capAspect) / 2)
611
+ : Math.round((outW - outH * capAspect) / 2);
612
+ const side = capAspect > outAspect ? "top and bottom" : "left and right";
613
+ push({
614
+ severity: "warn",
615
+ code: "output-letterbox",
616
+ message:
617
+ `capture is ${sb.video.width}x${sb.video.height} (${capAspect.toFixed(2)}:1) but the output ` +
618
+ `is ${outW}x${outH} (${outAspect.toFixed(2)}:1) with fit "contain" — ${barPx}px bars on the ${side}` +
619
+ (sb.frame ? ", on top of the frame padding's own bars" : ""),
620
+ fix:
621
+ `either record at the output's aspect (e.g. ${outH === 1080 ? "1600x900" : `${outW / 2}x${outH / 2}`} ` +
622
+ `with video.deviceScaleFactor: 2) or set output.fit: "cover" for an edge-to-edge cut`,
623
+ });
624
+ }
625
+ }
626
+
627
+ // --- opening frame (issue #47) ---
628
+ const firstScene = sb.scenes[0];
629
+ const opener = firstScene?.actions[0];
630
+ if (opener?.op === "goto") {
631
+ const settles = firstScene.actions
632
+ .slice(1)
633
+ .findIndex((a) => a.op === "waitFor" || a.op === "pause" || a.op === "waitForChange");
634
+ if (settles !== 0) {
635
+ push({
636
+ severity: "info",
637
+ code: "blank-open",
638
+ scene: firstScene.id,
639
+ action: 0,
640
+ message:
641
+ "the demo opens on a `goto` with nothing between it and the first beat — the video " +
642
+ "(and the poster) starts on the browser's white pre-paint while the first caption is already up",
643
+ fix: "follow the goto with a waitFor on the page's own content (or set `output.trimLeadingBlank: true`)",
644
+ });
645
+ }
646
+ }
647
+
648
+ const order: Record<LintSeverity, number> = { error: 0, warn: 1, info: 2 };
649
+ issues.sort((a, b) => order[a.severity] - order[b.severity]);
650
+ return { issues, estimate, narrationTotalMs, wordsPerSec: wps };
651
+ }
652
+
653
+ /** Log-friendly one-liners. */
654
+ export function formatLintIssue(i: LintIssue): string {
655
+ const glyph = i.severity === "error" ? "✗" : i.severity === "warn" ? "⚠" : "·";
656
+ const where = i.scene
657
+ ? ` ${i.scene}${i.action != null ? `#${i.action + 1}` : ""}`
658
+ : "";
659
+ return `${glyph} [${i.code}]${where}: ${i.message}${i.fix ? `\n → ${i.fix}` : ""}`;
660
+ }
661
+
662
+ /** Log every issue (used by probe/record/render as a non-fatal preflight). */
663
+ export function logLint(result: LintResult, logFn: (s: string) => void): void {
664
+ if (result.issues.length === 0) return;
665
+ const counts = { error: 0, warn: 0, info: 0 };
666
+ for (const i of result.issues) counts[i.severity]++;
667
+ logFn(
668
+ `lint: ${counts.error} error(s), ${counts.warn} warning(s), ${counts.info} note(s)` +
669
+ ` — predicted narration ≈${(result.narrationTotalMs / 1000).toFixed(0)}s` +
670
+ ` @ ${result.wordsPerSec.toFixed(1)} w/s`
671
+ );
672
+ for (const i of result.issues) logFn(` ${formatLintIssue(i)}`);
673
+ }