@tandryukha/aidemo 0.14.1 → 0.15.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/ffmpeg.ts CHANGED
@@ -171,6 +171,74 @@ export function probeFlatTailMs(
171
171
  });
172
172
  }
173
173
 
174
+ /**
175
+ * Mirror of `probeFlatTailMs` for the HEAD of a file: how many ms at the start
176
+ * are a flat, contentless frame (the browser's white pre-paint after a `goto`).
177
+ * Used to open the video on the product instead of on a blank page, and to pick
178
+ * a poster frame that isn't a white rectangle (issue #47). Best-effort: any
179
+ * probe failure resolves to 0.
180
+ */
181
+ export function probeFlatLeadMs(
182
+ file: string,
183
+ windowMs = 4000,
184
+ startMs = 0
185
+ ): Promise<number> {
186
+ return new Promise((resolvePromise) => {
187
+ const proc = spawn(
188
+ "ffmpeg",
189
+ [
190
+ "-hide_banner",
191
+ "-nostats",
192
+ ...(startMs > 0 ? ["-ss", (startMs / 1000).toFixed(3)] : []),
193
+ "-i",
194
+ file,
195
+ "-t",
196
+ (windowMs / 1000).toFixed(3),
197
+ "-vf",
198
+ "signalstats,metadata=print:file=-",
199
+ "-f",
200
+ "null",
201
+ "-",
202
+ ],
203
+ { stdio: ["ignore", "pipe", "ignore"] }
204
+ );
205
+ let out = "";
206
+ proc.stdout.on("data", (d) => (out += d.toString()));
207
+ proc.on("error", () => resolvePromise(0));
208
+ proc.on("close", () => {
209
+ const frames: Array<{ t: number; spread: number }> = [];
210
+ let t = NaN;
211
+ let low = NaN;
212
+ let high = NaN;
213
+ const flush = (): void => {
214
+ if (!Number.isNaN(t) && !Number.isNaN(low) && !Number.isNaN(high)) {
215
+ frames.push({ t, spread: high - low });
216
+ }
217
+ low = NaN;
218
+ high = NaN;
219
+ };
220
+ for (const line of out.split("\n")) {
221
+ const ts = /pts_time:\s*([\d.]+)/.exec(line);
222
+ if (ts) {
223
+ flush();
224
+ t = parseFloat(ts[1]);
225
+ continue;
226
+ }
227
+ const lo = /YLOW=\s*([\d.]+)/.exec(line);
228
+ if (lo) low = parseFloat(lo[1]);
229
+ const hi = /YHIGH=\s*([\d.]+)/.exec(line);
230
+ if (hi) high = parseFloat(hi[1]);
231
+ }
232
+ flush();
233
+ const FLAT_SPREAD = 4;
234
+ if (!frames.length || frames[0].spread > FLAT_SPREAD) return resolvePromise(0);
235
+ let i = 0;
236
+ while (i + 1 < frames.length && frames[i + 1].spread <= FLAT_SPREAD) i++;
237
+ resolvePromise(Math.max(0, Math.round(frames[i].t * 1000)));
238
+ });
239
+ });
240
+ }
241
+
174
242
  /** A motionless span found by `freezedetect`, in ms from the file's start. */
175
243
  export interface FreezeSpan {
176
244
  startMs: number;
package/src/frames.ts CHANGED
@@ -26,6 +26,14 @@ export interface FramesOptions {
26
26
  width?: number;
27
27
  /** Output directory. Default <demo>/output/frames. */
28
28
  outDir?: string;
29
+ /**
30
+ * Also tile the extracted frames into one contact sheet PNG
31
+ * (`output/frames/<source>-sheet.png`) — the single most useful review
32
+ * artifact for a finished cut (issue #52), and one image instead of 40.
33
+ */
34
+ sheet?: boolean;
35
+ /** Columns in the contact sheet. Default 5. */
36
+ sheetColumns?: number;
29
37
  }
30
38
 
31
39
  export interface FramesResult {
@@ -33,6 +41,8 @@ export interface FramesResult {
33
41
  durationMs: number;
34
42
  everySec: number;
35
43
  files: string[];
44
+ /** The contact sheet, when `sheet` was asked for. */
45
+ sheet?: string;
36
46
  }
37
47
 
38
48
  /** `mm-ss` (plus tenths when the cadence isn't whole seconds) — unique + sortable. */
@@ -52,7 +62,7 @@ export async function extractFrames(
52
62
  const everySec = Math.max(0.2, opts.everySec ?? 3);
53
63
  const width = Math.max(64, Math.round(opts.width ?? 640));
54
64
  const kind = opts.source ?? "final";
55
- if (kind === "take") return takeFrames(project, everySec, width, opts.outDir);
65
+ if (kind === "take") return takeFrames(project, everySec, width, opts.outDir, opts);
56
66
  const source = kind === "raw" ? await project.resolveRawVideo() : project.outputPath;
57
67
  if (!(await exists(source))) {
58
68
  throw new Error(
@@ -76,8 +86,8 @@ export async function extractFrames(
76
86
  source,
77
87
  "-vf",
78
88
  `fps=1/${everySec},scale=${width}:-2`,
79
- "-vsync",
80
- "vfr",
89
+ // No `-vsync vfr` here: the `fps` filter already sets the rate, and
90
+ // ffmpeg 8 removed the option outright (portability baseline).
81
91
  pattern,
82
92
  ]);
83
93
  // ffmpeg numbers frames 0001…; rename to the timestamp each one represents.
@@ -92,7 +102,41 @@ export async function extractFrames(
92
102
  await fs.rename(join(outDir, f), join(outDir, name));
93
103
  files.push(join(outDir, name));
94
104
  }
95
- return { source, durationMs, everySec, files };
105
+ const sheet = opts.sheet
106
+ ? await buildSheet(files, join(outDir, `${kind}-sheet.png`), opts.sheetColumns)
107
+ : undefined;
108
+ return { source, durationMs, everySec, files, ...(sheet ? { sheet } : {}) };
109
+ }
110
+
111
+ /**
112
+ * Tile frames into one contact sheet (ffmpeg `tile`). Uses the already-scaled
113
+ * PNGs as an image sequence, so no second decode of the video.
114
+ */
115
+ export async function buildSheet(
116
+ files: string[],
117
+ outPath: string,
118
+ columns = 5
119
+ ): Promise<string | undefined> {
120
+ if (!files.length) return undefined;
121
+ const cols = Math.max(1, Math.min(10, columns));
122
+ const rows = Math.ceil(files.length / cols);
123
+ const inputs = files.flatMap((f) => ["-i", f]);
124
+ const chain =
125
+ files.map((_, i) => `[${i}:v]scale=480:-2,pad=iw+8:ih+8:4:4:color=0x11141c[t${i}]`).join(";") +
126
+ ";" +
127
+ files.map((_, i) => `[t${i}]`).join("") +
128
+ `concat=n=${files.length}:v=1:a=0,tile=${cols}x${rows}[sheet]`;
129
+ await runFfmpeg([
130
+ ...inputs,
131
+ "-filter_complex",
132
+ chain,
133
+ "-map",
134
+ "[sheet]",
135
+ "-frames:v",
136
+ "1",
137
+ outPath,
138
+ ]);
139
+ return outPath;
96
140
  }
97
141
 
98
142
  /**
@@ -106,7 +150,8 @@ async function takeFrames(
106
150
  project: Project,
107
151
  everySec: number,
108
152
  width: number,
109
- outDirOpt?: string
153
+ outDirOpt?: string,
154
+ sheetOpts?: Pick<FramesOptions, "sheet" | "sheetColumns">
110
155
  ): Promise<FramesResult> {
111
156
  const timeline = TimelineSchema.parse(await readJson(project.timelinePath));
112
157
  const rawVideo = await project.resolveRawVideo();
@@ -150,10 +195,14 @@ async function takeFrames(
150
195
  carryMs = Math.max(0, carryMs + shot * stepMs - sceneMs);
151
196
  elapsedMs += sceneMs;
152
197
  }
198
+ const sheet = sheetOpts?.sheet
199
+ ? await buildSheet(files, join(outDir, "take-sheet.png"), sheetOpts.sheetColumns)
200
+ : undefined;
153
201
  return {
154
202
  source: [...sources].map((f) => relative(project.dir, f)).join(", "),
155
203
  durationMs: elapsedMs,
156
204
  everySec,
157
205
  files,
206
+ ...(sheet ? { sheet } : {}),
158
207
  };
159
208
  }
@@ -274,6 +274,17 @@ function stepsFromTrace(entries: Map<string, Buffer>, notes: string[]): Step[] {
274
274
  return steps;
275
275
  }
276
276
 
277
+ /**
278
+ * Escape a literal for embedding inside a double-quoted selector value. The
279
+ * captured text still carries the spec's own source escapes, so unescape once
280
+ * first; then escape backslashes before quotes — escaping only `"` leaves a
281
+ * trailing `\` producing `"foo\"`, which swallows the closing quote and
282
+ * rewrites the selector.
283
+ */
284
+ function q(v: string): string {
285
+ return v.replace(/\\(.)/g, "$1").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
286
+ }
287
+
277
288
  /** Tolerant line-based parse of a Playwright test file (page.* chains). */
278
289
  export function stepsFromTest(src: string, notes: string[]): Step[] {
279
290
  const steps: Step[] = [];
@@ -310,30 +321,30 @@ export function stepsFromTest(src: string, notes: string[]): Step[] {
310
321
  let part = "";
311
322
  switch (fn) {
312
323
  case "getByRole":
313
- part = name ? `role=${arg}[name="${S(name, 1)}"${exact ? "" : "i"}]` : `role=${arg}`;
324
+ part = name ? `role=${arg}[name="${q(S(name, 1))}"${exact ? "" : "i"}]` : `role=${arg}`;
314
325
  break;
315
326
  case "getByTestId":
316
- part = `[data-testid="${arg}"]`;
327
+ part = `[data-testid="${q(arg)}"]`;
317
328
  break;
318
329
  case "getByText":
319
- part = exact ? `text="${arg}"` : `text=${arg}`;
330
+ part = exact ? `text="${q(arg)}"` : `text=${arg}`;
320
331
  break;
321
332
  case "getByLabel":
322
333
  // Same engine the trace importer emits: resolves to the control.
323
- part = `internal:label="${arg.replace(/"/g, '\\"')}"${exact ? "s" : "i"}`;
334
+ part = `internal:label="${q(arg)}"${exact ? "s" : "i"}`;
324
335
  break;
325
336
  case "getByPlaceholder":
326
- part = `[placeholder="${arg}"]`;
337
+ part = `[placeholder="${q(arg)}"]`;
327
338
  break;
328
339
  case "getByTitle":
329
- part = `[title="${arg}"]`;
340
+ part = `[title="${q(arg)}"]`;
330
341
  break;
331
342
  case "getByAltText":
332
- part = `[alt="${arg}"]`;
343
+ part = `[alt="${q(arg)}"]`;
333
344
  break;
334
345
  case "locator":
335
346
  part = arg;
336
- if (hasText) part += `:has-text("${S(hasText, 1)}")`;
347
+ if (hasText) part += `:has-text("${q(S(hasText, 1))}")`;
337
348
  break;
338
349
  case "frameLocator":
339
350
  frameSel = arg;
@@ -426,7 +437,7 @@ export function stepsFromTest(src: string, notes: string[]): Step[] {
426
437
  if (attr && val && !regexArg) {
427
438
  steps.push({
428
439
  method: "assert",
429
- selector: `${selector} >> :scope[${attr}="${val.replace(/"/g, '\\"')}"]`,
440
+ selector: `${selector} >> :scope[${attr}="${q(val)}"]`,
430
441
  });
431
442
  } else if (attr) {
432
443
  steps.push({ method: "assert", selector: `${selector} >> :scope[${attr}]` });
package/src/inspect.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import { chromium, type Frame, type Page } from "playwright";
14
14
  import { chromeProfileDir } from "./config.js";
15
15
  import { ensureProfileUnlocked } from "./login.js";
16
+ import { applySeeds, type Seeds } from "./setup.js";
16
17
 
17
18
  export interface InspectElement {
18
19
  /** ARIA role (explicit or implied by the tag). */
@@ -60,6 +61,13 @@ export interface InspectOptions {
60
61
  frames?: Record<string, string>;
61
62
  /** Settle time after load (ms, default 1200). */
62
63
  settleMs?: number;
64
+ /**
65
+ * Cookies / localStorage to inject before the first navigation — the same
66
+ * seeding `probe`/`record` do, so an authenticated page can be scanned
67
+ * (issue #53). Collect them with `collectSeeds` (storyboard `setup` +
68
+ * `--storage-state` / `--cookie`).
69
+ */
70
+ seeds?: Seeds | null;
63
71
  }
64
72
 
65
73
  const DEFAULT_LIMIT = 80;
@@ -199,6 +207,7 @@ export async function inspectPage(opts: InspectOptions): Promise<InspectResult>
199
207
  });
200
208
  try {
201
209
  const page = context.pages()[0] ?? (await context.newPage());
210
+ if (opts.seeds) await applySeeds(context, page, opts.seeds);
202
211
  await page.goto(opts.url, { waitUntil: "domcontentloaded" });
203
212
  await page
204
213
  .waitForLoadState("networkidle", { timeout: 4000 })
package/src/lint.ts CHANGED
@@ -13,8 +13,9 @@
13
13
  * be 60 % frozen frame before TTS is paid for.
14
14
  */
15
15
 
16
- import type { Storyboard, Scene, Action } from "./types.js";
16
+ import { OUTPUT_PRESETS, type Storyboard, type Scene, type Action } from "./types.js";
17
17
  import { clampScaleForWidth } from "./zoom.js";
18
+ import { compileUserRegex } from "./safe-regex.js";
18
19
 
19
20
  export type LintSeverity = "error" | "warn" | "info";
20
21
 
@@ -163,6 +164,9 @@ export function estimateActionMs(a: Action, autoIdle = false): number {
163
164
  return 800;
164
165
  case "back":
165
166
  return 1400; // like goto
167
+ case "dialog":
168
+ // Arming a handler costs nothing; `holdMs` pauses before answering.
169
+ return 20 + (a.holdMs ?? 0);
166
170
  default:
167
171
  return 300;
168
172
  }
@@ -359,14 +363,14 @@ export function lintStoryboard(
359
363
  for (const re of [a.textMatches, a.url]) {
360
364
  if (re == null) continue;
361
365
  try {
362
- new RegExp(re);
366
+ compileUserRegex(re, "", "assert");
363
367
  } catch (e) {
364
368
  push({
365
369
  severity: "error",
366
370
  code: "assert-bad-regex",
367
371
  scene: scene.id,
368
372
  action: ai,
369
- message: `\`assert\` regex ${JSON.stringify(re)} is invalid: ${(e as Error).message}`,
373
+ message: `\`assert\` regex ${JSON.stringify(re)} is unusable: ${(e as Error).message}`,
370
374
  });
371
375
  }
372
376
  }
@@ -573,6 +577,74 @@ export function lintStoryboard(
573
577
  }
574
578
  }
575
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
+
576
648
  const order: Record<LintSeverity, number> = { error: 0, warn: 1, info: 2 };
577
649
  issues.sort((a, b) => order[a.severity] - order[b.severity]);
578
650
  return { issues, estimate, narrationTotalMs, wordsPerSec: wps };
package/src/mcp/jobs.ts CHANGED
@@ -27,6 +27,7 @@ export type JobKind =
27
27
  | "gif"
28
28
  | "stills"
29
29
  | "frames"
30
+ | "qa"
30
31
  | "inspect"
31
32
  | "walkthrough";
32
33
 
package/src/mcp/server.ts CHANGED
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { zodToJsonSchema } from "zod-to-json-schema";
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { resolve } from "node:path";
7
- import { ENGINE_ROOT, engineVersion, captionsAutoOffline } from "../config.js";
7
+ import { ENGINE_ROOT, engineVersion, captionsAutoOffline, chromeProfileDir } from "../config.js";
8
8
  import { Project, parseStoryboard } from "../project.js";
9
9
  import { StoryboardSchema, SeedCookieSchema, type SeedCookie, type Storyboard } from "../types.js";
10
10
  import { generateVoice } from "../voice.js";
@@ -23,7 +23,9 @@ import { exportGif } from "../gif.js";
23
23
  import { buildEmbed } from "../embed.js";
24
24
  import { extractStills, storyboardHasStills } from "../stills.js";
25
25
  import { extractFrames } from "../frames.js";
26
+ import { runQa, logQa } from "../qa.js";
26
27
  import { inspectPage } from "../inspect.js";
28
+ import { collectSeeds, runPreflight } from "../setup.js";
27
29
  import { importTrace, scaffoldImported } from "../import-trace.js";
28
30
  import { exportWalkthrough } from "../walkthrough.js";
29
31
  import { GUIDE_TOPIC_NAMES, guideHeadings, guidePath as guideFilePath, sliceGuide } from "../guide.js";
@@ -1079,7 +1081,11 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1079
1081
  captionsSrt: lp.captionsSrtPath,
1080
1082
  report: lp.reportPath,
1081
1083
  durationMs: report.durationMs,
1084
+ // warningCount lets an orchestrating agent gate on the render
1085
+ // without parsing the warning list (issue #59).
1086
+ warningCount: report.warnings.length,
1082
1087
  warnings: report.warnings,
1088
+ ...(report.blankOpenMs ? { blankOpenMs: report.blankOpenMs } : {}),
1083
1089
  };
1084
1090
  })
1085
1091
  );
@@ -1168,11 +1174,37 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1168
1174
  ...(gifPath ? { gif: gifPath } : {}),
1169
1175
  report: lp.reportPath,
1170
1176
  durationMs: report.durationMs,
1177
+ // warningCount lets an orchestrating agent gate on the render
1178
+ // without parsing the warning list (issue #59).
1179
+ warningCount: report.warnings.length,
1171
1180
  warnings: report.warnings,
1181
+ ...(report.blankOpenMs ? { blankOpenMs: report.blankOpenMs } : {}),
1172
1182
  };
1173
1183
  })
1174
1184
  );
1175
1185
 
1186
+ registerJob(
1187
+ "qa",
1188
+ "Post-render checks on the FINISHED MP4 — blank opening, blank poster, " +
1189
+ "master loudness, music-bed level, per-scene hold shares + compose " +
1190
+ "warnings, caption readability, frame/chapter hygiene. Run it after " +
1191
+ "compose/render: it sees what a viewer sees, which neither lint (pre-take) " +
1192
+ "nor report.json (structural) can.",
1193
+ {
1194
+ dir: DIR_INPUT,
1195
+ params: PARAMS_INPUT,
1196
+ lang: LANG_INPUT,
1197
+ },
1198
+ (project, args) => async (job) =>
1199
+ jobs.runStage(job, "qa", async () => {
1200
+ const storyboard = await project.loadStoryboard({ params: args.params });
1201
+ const sb = args.lang ? localizeStoryboard(storyboard, args.lang) : storyboard;
1202
+ const res = await runQa(langProject(project, args.lang), sb);
1203
+ logQa(res);
1204
+ return res;
1205
+ })
1206
+ );
1207
+
1176
1208
  registerJob(
1177
1209
  "gif",
1178
1210
  "Convert output/final-demo.mp4 to a README-ready GIF.",
@@ -1295,18 +1327,50 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
1295
1327
  .record(z.string(), z.string())
1296
1328
  .optional()
1297
1329
  .describe("iframe selectors to scan too, as in the storyboard `frames` block"),
1330
+ useSetup: z
1331
+ .boolean()
1332
+ .optional()
1333
+ .describe(
1334
+ "honour the storyboard's setup block (storageState/cookies/preflight) so an " +
1335
+ "authenticated page can be scanned. Default true."
1336
+ ),
1337
+ storageState: z
1338
+ .string()
1339
+ .optional()
1340
+ .describe("extra Playwright storageState JSON to seed before the scan"),
1298
1341
  },
1299
1342
  (project, args) => async (job) =>
1300
1343
  jobs.runStage(job, "inspect", async () => {
1301
1344
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1302
1345
  const png = project.p("logs", `inspect-${stamp}.png`);
1346
+ // #53: scan authenticated pages — same seeding surface as probe/record.
1347
+ let storyboard: Storyboard | null = null;
1348
+ if (args.useSetup !== false) {
1349
+ storyboard = await project.loadStoryboard({ relaxed: true }).catch(() => null);
1350
+ }
1351
+ if (storyboard?.setup?.preflight) {
1352
+ await runPreflight(storyboard.setup.preflight, {
1353
+ demoDir: project.dir,
1354
+ storyboardPath: project.storyboardPath,
1355
+ profileDir: args.profile ?? chromeProfileDir(),
1356
+ });
1357
+ storyboard = await project
1358
+ .loadStoryboard({ relaxed: true })
1359
+ .catch(() => storyboard);
1360
+ }
1361
+ const seeds = await collectSeeds(
1362
+ project.dir,
1363
+ storyboard ?? ({ setup: undefined } as never),
1364
+ { storageState: args.storageState }
1365
+ );
1303
1366
  const res = await inspectPage({
1304
1367
  url: args.url,
1305
1368
  headless: args.headless !== false,
1306
1369
  profileDir: args.profile,
1307
1370
  limit: args.limit,
1308
- viewport: args.viewport,
1309
- frames: args.frames,
1371
+ viewport: args.viewport ?? storyboard?.video,
1372
+ frames: args.frames ?? storyboard?.frames,
1373
+ seeds,
1310
1374
  screenshotPath: png,
1311
1375
  });
1312
1376
  const file = project.p("logs", `inspect-${stamp}.json`);