@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/cli.ts CHANGED
@@ -1,12 +1,28 @@
1
1
  import { Command } from "commander";
2
2
  import { resolve, dirname } from "node:path";
3
3
  import { readFile } from "node:fs/promises";
4
- import { loadEnv, engineVersion, ENGINE_ROOT, captionsAutoOffline } from "./config.js";
5
- import { Project } from "./project.js";
4
+ import {
5
+ loadEnv,
6
+ engineVersion,
7
+ ENGINE_ROOT,
8
+ captionsAutoOffline,
9
+ chromeProfileDir,
10
+ } from "./config.js";
11
+ import { resetProfile } from "./profile.js";
12
+ import { Project, parseStoryboard } from "./project.js";
6
13
  import { record } from "./recorder.js";
14
+ import { collectSeeds, parseCookieFlag, runPreflight } from "./setup.js";
15
+ import { extractFrames } from "./frames.js";
16
+ import { runQa, logQa } from "./qa.js";
17
+ import { inspectPage } from "./inspect.js";
18
+ import { importTrace, scaffoldImported } from "./import-trace.js";
19
+ import { GUIDE_TOPIC_NAMES, guideHeadings, readGuide, sliceGuide } from "./guide.js";
20
+ import { lintStoryboard, logLint } from "./lint.js";
21
+ import { readJson, writeJson } from "./util.js";
7
22
  import {
8
23
  buildProbeGolden,
9
24
  diffGolden,
25
+ driftFilesForDiff,
10
26
  readProbeGolden,
11
27
  writeProbeGolden,
12
28
  } from "./golden.js";
@@ -17,6 +33,7 @@ import { compose } from "./compose.js";
17
33
  import { exportGif } from "./gif.js";
18
34
  import { buildEmbed, formatEmbed } from "./embed.js";
19
35
  import { extractStills, storyboardHasStills } from "./stills.js";
36
+ import { exportWalkthrough } from "./walkthrough.js";
20
37
  import { localizeStoryboard, missingNarrations } from "./i18n.js";
21
38
  import { synthesizeMusicBed } from "./music.js";
22
39
  import { loadVariants, renderVariants } from "./variants.js";
@@ -29,6 +46,7 @@ import {
29
46
  setLogFile,
30
47
  closeLogFile,
31
48
  teeStageLog,
49
+ exists,
32
50
  } from "./util.js";
33
51
  import {
34
52
  scaffoldDemo,
@@ -70,12 +88,54 @@ program
70
88
  .command("init")
71
89
  .argument("<name>", "demo name (creates ./demos/<name>/)")
72
90
  .option("--force", "overwrite an existing demos/<name>/", false)
91
+ .option(
92
+ "--from-url <url>",
93
+ "draft from a live page: inspect it, headings → scenes, unique selectors → beats (no LLM)"
94
+ )
95
+ .option("--headed", "show the browser while inspecting (--from-url)", false)
96
+ .option("--profile <dir>", "Chrome user-data dir for --from-url (logged-in pages)")
97
+ .option("--viewport <WxH>", "viewport for --from-url (default 1280x720)")
73
98
  .description("scaffold a new demo project (in the current repo) with a starter storyboard")
74
- .action(async (name: string, opts: { force?: boolean }) => {
75
- // Scaffold into the *current* working directory, so `init` works both in the
76
- // engine's own repo and in a consumer repo that invoked it via npx.
77
- const dir = await scaffoldDemo(process.cwd(), name, { force: opts.force });
78
- ok(`edit generated/storyboard.json, then: aidemo render ${dir}`);
99
+ .action(
100
+ async (
101
+ name: string,
102
+ opts: { force?: boolean; fromUrl?: string; headed?: boolean; profile?: string; viewport?: string }
103
+ ) => {
104
+ // Scaffold into the *current* working directory, so `init` works both in the
105
+ // engine's own repo and in a consumer repo that invoked it via npx.
106
+ const vp = opts.viewport ? /^(\d+)x(\d+)$/.exec(opts.viewport) : null;
107
+ if (opts.viewport && !vp) throw new Error(`--viewport expects WxH, got ${opts.viewport}`);
108
+ const dir = await scaffoldDemo(process.cwd(), name, {
109
+ force: opts.force,
110
+ fromUrl: opts.fromUrl,
111
+ headed: opts.headed,
112
+ profileDir: opts.profile,
113
+ ...(vp ? { viewport: { width: Number(vp[1]), height: Number(vp[2]) } } : {}),
114
+ });
115
+ ok(
116
+ opts.fromUrl
117
+ ? `write the narration in generated/storyboard.json (input/brief.md lists what inspect saw), then: aidemo probe ${dir}`
118
+ : `edit generated/storyboard.json, then: aidemo render ${dir}`
119
+ );
120
+ }
121
+ );
122
+
123
+ program
124
+ .command("import-trace")
125
+ .argument("<file>", "Playwright trace.zip (context.tracing / --trace on) or a *.spec.ts test file")
126
+ .requiredOption("--name <name>", "demo name (creates ./demos/<name>/)")
127
+ .option("--force", "overwrite an existing demos/<name>/storyboard", false)
128
+ .option("--json", "print the draft storyboard to stdout instead of writing a demo", false)
129
+ .description("draft a storyboard from a Playwright trace or test: its actions + selectors become scenes (no LLM)")
130
+ .action(async (file: string, opts: { name: string; force?: boolean; json?: boolean }) => {
131
+ const result = await importTrace(resolve(file), opts.name);
132
+ if (opts.json) {
133
+ process.stdout.write(JSON.stringify(result.storyboard, null, 2) + "\n");
134
+ return;
135
+ }
136
+ const dir = await scaffoldImported(process.cwd(), opts.name, result, { force: opts.force });
137
+ for (const n of result.notes) log(` · ${n}`);
138
+ ok(`write the narration in ${dir}/generated/storyboard.json, then: aidemo probe ${dir}`);
79
139
  });
80
140
 
81
141
  program
@@ -125,22 +185,82 @@ program
125
185
  program
126
186
  .command("guide")
127
187
  .description("print the agent-neutral authoring guide (docs/AUTHORING.md)")
128
- .action(async () => {
188
+ .option(
189
+ "--topic <name>",
190
+ `print one slice: ${GUIDE_TOPIC_NAMES.join(", ")} — or an H2 heading prefix`
191
+ )
192
+ .option("--list", "list topics and section headings")
193
+ .action(async (opts: { topic?: string; list?: boolean }) => {
129
194
  // Deliberate stdout (not the stderr logger): the guide is the output.
130
- process.stdout.write(
131
- await readFile(resolve(ENGINE_ROOT, "docs/AUTHORING.md"), "utf8")
132
- );
195
+ const md = await readGuide();
196
+ if (opts.list) {
197
+ process.stdout.write(
198
+ `topics: ${GUIDE_TOPIC_NAMES.join(", ")}\n\nsections:\n` +
199
+ guideHeadings(md)
200
+ .map((h) => ` ${h}`)
201
+ .join("\n") +
202
+ "\n"
203
+ );
204
+ return;
205
+ }
206
+ if (!opts.topic) {
207
+ process.stdout.write(md);
208
+ return;
209
+ }
210
+ const slice = sliceGuide(md, opts.topic);
211
+ if (slice == null) {
212
+ fail(
213
+ `unknown guide topic "${opts.topic}" — topics: ${GUIDE_TOPIC_NAMES.join(", ")}; ` +
214
+ `sections: ${guideHeadings(md).join(" · ")}`
215
+ );
216
+ process.exit(1);
217
+ }
218
+ process.stdout.write(slice);
133
219
  });
134
220
 
221
+ /** Read all of stdin as UTF-8 (for `--body-file -`). */
222
+ async function readStdin(): Promise<string> {
223
+ const chunks: Buffer[] = [];
224
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
225
+ return Buffer.concat(chunks).toString("utf8");
226
+ }
227
+
135
228
  program
136
229
  .command("feedback")
137
230
  .argument("[dir]", "demo project directory (adds storyboard + log context)")
231
+ .option("--title <text>", "issue title (default: \"Demo feedback: <dir>\")")
232
+ .option("--body <text>", "issue body; environment + log context is appended")
233
+ .option("--body-file <file>", "read the body from a file, or \"-\" for stdin")
138
234
  .option("--web", "open a prefilled New Issue page instead of filing directly", false)
139
235
  .option("--dry-run", "print the issue title/body without filing", false)
140
236
  .description("file demo-recording feedback as a GitHub issue on the engine repo")
141
- .action(async (dir: string | undefined, opts: { web?: boolean; dryRun?: boolean }) => {
142
- await feedback(dir, { web: opts.web, dryRun: opts.dryRun });
143
- });
237
+ .action(
238
+ async (
239
+ dir: string | undefined,
240
+ opts: {
241
+ title?: string;
242
+ body?: string;
243
+ bodyFile?: string;
244
+ web?: boolean;
245
+ dryRun?: boolean;
246
+ }
247
+ ) => {
248
+ // Non-interactive body so an agent (or a script) can file without the
249
+ // placeholder template — the reason issue #38 was filed with `gh` by hand.
250
+ let body = opts.body;
251
+ if (opts.bodyFile) {
252
+ body = await (opts.bodyFile === "-"
253
+ ? readStdin()
254
+ : readFile(opts.bodyFile, "utf8"));
255
+ }
256
+ await feedback(dir, {
257
+ web: opts.web,
258
+ dryRun: opts.dryRun,
259
+ title: opts.title,
260
+ description: body,
261
+ });
262
+ }
263
+ );
144
264
 
145
265
  program
146
266
  .command("doctor")
@@ -150,6 +270,21 @@ program
150
270
  await doctor(opts.dir);
151
271
  });
152
272
 
273
+ /**
274
+ * `--tts <provider>` — a discoverable spelling of AIDEMO_TTS_PROVIDER (the env
275
+ * var is canonical; the flag just sets it before the pipeline reads it).
276
+ */
277
+ function applyTtsFlag(value?: string): void {
278
+ if (value == null) return;
279
+ if (value !== "openai" && value !== "elevenlabs" && value !== "local") {
280
+ throw new Error(`--tts must be openai, elevenlabs or local (got "${value}")`);
281
+ }
282
+ process.env.AIDEMO_TTS_PROVIDER = value;
283
+ }
284
+
285
+ const TTS_OPT_DESC =
286
+ "TTS provider: openai (default) | elevenlabs | local (in-process Kokoro; needs `npm install kokoro-js`) — same as AIDEMO_TTS_PROVIDER";
287
+
153
288
  function parsePositiveInt(name: string, value: string): number {
154
289
  const n = Number(value);
155
290
  if (!Number.isInteger(n) || n <= 0)
@@ -180,6 +315,10 @@ function parseParams(pairs?: string[]): Record<string, string> | undefined {
180
315
  return out;
181
316
  }
182
317
 
318
+ const NO_REPLAY_OPT_DESC =
319
+ "with --from-scene: do not replay the reused scenes' actions — the profile already holds the state (balances earned, a day rolled forward); the resumed scene must open with its own goto";
320
+ const FROM_SCENE_OPT_DESC =
321
+ "resume: keep the previous take's scenes before this id (verified unchanged) and record from it";
183
322
  const PARAM_OPT_DESC =
184
323
  "set a storyboard template param (key=value; repeatable; must be declared in the storyboard's params block)";
185
324
 
@@ -194,6 +333,32 @@ const LANGS_OPT_DESC =
194
333
  * `[undefined]` when neither is given — the default single-language render,
195
334
  * byte-for-byte unchanged (undefined = the base storyboard, unsuffixed paths).
196
335
  */
336
+ const STRICT_OPT_DESC =
337
+ "exit 1 when compose reports any warning (anchor-unreachable, overrun-trim, scene-freeze, blank-open …) — each one maps to a defect a viewer will see";
338
+ const STORAGE_STATE_OPT_DESC =
339
+ "seed a Playwright storageState JSON (cookies + localStorage) into the profile before the take";
340
+ const COOKIE_OPT_DESC =
341
+ 'seed a cookie before the take: "name=value;domain=host[;path=/;secure;httpOnly;sameSite=Lax]" (repeatable)';
342
+ const PROFILE_SEEDED_OPT_DESC =
343
+ "the profile is seeded on purpose (login / cookie gate): silence the carried-over-state warning";
344
+
345
+ /** Profile-seeding flags shared by record/probe/render → RecordOptions fields. */
346
+ function seedOpts(opts: {
347
+ storageState?: string;
348
+ cookie?: string[];
349
+ profileSeeded?: boolean;
350
+ }): {
351
+ storageState?: string;
352
+ cookies?: ReturnType<typeof parseCookieFlag>[];
353
+ profileSeeded?: boolean;
354
+ } {
355
+ return {
356
+ storageState: opts.storageState,
357
+ cookies: opts.cookie?.length ? opts.cookie.map(parseCookieFlag) : undefined,
358
+ profileSeeded: opts.profileSeeded,
359
+ };
360
+ }
361
+
197
362
  function langsFrom(opts: { lang?: string; langs?: string }): Array<string | undefined> {
198
363
  const list = [
199
364
  ...(opts.langs ? opts.langs.split(",") : []),
@@ -214,29 +379,415 @@ function warnPartialCoverage(storyboard: Parameters<typeof missingNarrations>[0]
214
379
  );
215
380
  }
216
381
 
382
+ program
383
+ .command("login")
384
+ .argument("[profile]", "Chrome user-data dir (default: the engine's recording profile)")
385
+ .option("--url <url>", "page to open for the login", "https://chatgpt.com/")
386
+ .option("--no-wait", "launch Chrome and return without waiting for it to quit")
387
+ .description(
388
+ "open a manual-login Chrome on the recording profile (mock-keychain cookie " +
389
+ "store, same as record) and wait until you quit it"
390
+ )
391
+ .action(async (profile: string | undefined, opts: { url: string; wait: boolean }) => {
392
+ const { login } = await import("./login.js");
393
+ await login(profile, { url: opts.url, noWait: !opts.wait });
394
+ });
395
+
396
+ program
397
+ .command("profile")
398
+ .argument("[action]", "path (default) | reset", "path")
399
+ .option("--profile <dir>", "operate on this Chrome user-data dir instead of the default")
400
+ .description(
401
+ "show or wipe the recording Chrome profile (a profile carrying state for " +
402
+ "the app can silently record the wrong story)"
403
+ )
404
+ .action(async (action: string, opts: { profile?: string }) => {
405
+ const dir = opts.profile ?? chromeProfileDir();
406
+ if (action === "reset") {
407
+ await resetProfile(dir);
408
+ step("Profile reset");
409
+ ok(`wiped → ${dir}`);
410
+ ok("the next take starts from a clean browser identity (you'll need to log in again)");
411
+ return;
412
+ }
413
+ if (action !== "path") {
414
+ throw new Error(`unknown profile action "${action}" — use: path | reset`);
415
+ }
416
+ step("Recording profile");
417
+ ok(dir);
418
+ ok(`exists: ${(await exists(dir)) ? "yes" : "no"}`);
419
+ ok("wipe it with: aidemo profile reset (or record/probe with --fresh)");
420
+ });
421
+
422
+ program
423
+ .command("validate")
424
+ .argument("[dir]", "demo project directory (validates generated/storyboard.json)")
425
+ .option("--file <path>", "validate this storyboard file instead of <dir>/generated/storyboard.json")
426
+ .option("--relaxed", "narration optional (probe semantics)", false)
427
+ .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
428
+ .option("--json", "print the structured result as JSON", false)
429
+ .description(
430
+ "validate a storyboard against the engine schema without running anything " +
431
+ "(the CLI twin of the MCP validate_storyboard tool; non-zero exit on issues)"
432
+ )
433
+ .action(
434
+ async (
435
+ dir: string | undefined,
436
+ opts: { file?: string; relaxed?: boolean; param?: string[]; json?: boolean }
437
+ ) => {
438
+ if (!dir && !opts.file) {
439
+ throw new Error("pass a demo dir or --file <storyboard.json>");
440
+ }
441
+ const path = opts.file ? resolve(opts.file) : new Project(dir!).storyboardPath;
442
+ let raw: unknown;
443
+ try {
444
+ raw = await readJson<unknown>(path);
445
+ } catch (err) {
446
+ const result = {
447
+ valid: false,
448
+ storyboardPath: path,
449
+ issues: [{ path: "", message: (err as Error).message, code: "unreadable" }],
450
+ warnings: [] as string[],
451
+ };
452
+ if (opts.json) console.log(JSON.stringify(result, null, 2));
453
+ else fail(`${path}: ${(err as Error).message}`);
454
+ process.exitCode = 1;
455
+ return;
456
+ }
457
+ const params = parseParams(opts.param);
458
+ const parsed = parseStoryboard(raw, {
459
+ relaxed: opts.relaxed,
460
+ params,
461
+ strict: params != null,
462
+ });
463
+ const result = parsed.ok
464
+ ? {
465
+ valid: true,
466
+ storyboardPath: path,
467
+ title: parsed.storyboard.title,
468
+ sceneCount: parsed.storyboard.scenes.length,
469
+ issues: [] as Array<{ path: string; message: string; code: string }>,
470
+ warnings: parsed.warnings,
471
+ }
472
+ : { valid: false, storyboardPath: path, issues: parsed.issues, warnings: [] as string[] };
473
+ const lint = parsed.ok ? lintStoryboard(parsed.storyboard) : null;
474
+ if (opts.json) {
475
+ console.log(JSON.stringify({ ...result, lint: lint?.issues ?? [] }, null, 2));
476
+ } else if (result.valid) {
477
+ step("Storyboard valid");
478
+ ok(`${path} — "${result.title}", ${result.sceneCount} scene(s)`);
479
+ for (const w of result.warnings) log(w);
480
+ if (lint) logLint(lint, log);
481
+ } else {
482
+ fail(`${path}: ${result.issues.length} issue(s)`);
483
+ for (const i of result.issues) log(` - ${i.path || "<root>"}: ${i.message}`);
484
+ }
485
+ if (!result.valid) process.exitCode = 1;
486
+ }
487
+ );
488
+
489
+ program
490
+ .command("lint")
491
+ .argument("[dir]", "demo project directory (lints generated/storyboard.json)")
492
+ .option("--file <path>", "lint this storyboard file instead of <dir>/generated/storyboard.json")
493
+ .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
494
+ .option("--lang <code>", "lint the narrations[code] translation + that language's pace")
495
+ .option("--json", "print the structured result as JSON", false)
496
+ .option("--strict", "non-zero exit on warnings too (default: errors only)", false)
497
+ .description(
498
+ "preflight a storyboard without a browser: pacing forecast (which scenes " +
499
+ "compose will freeze or cut), selector/wait pitfalls, no-op keys"
500
+ )
501
+ .action(
502
+ async (
503
+ dir: string | undefined,
504
+ opts: { file?: string; param?: string[]; lang?: string; json?: boolean; strict?: boolean }
505
+ ) => {
506
+ if (!dir && !opts.file) {
507
+ throw new Error("pass a demo dir or --file <storyboard.json>");
508
+ }
509
+ const path = opts.file ? resolve(opts.file) : new Project(dir!).storyboardPath;
510
+ const params = parseParams(opts.param);
511
+ const parsed = parseStoryboard(await readJson<unknown>(path), {
512
+ relaxed: true,
513
+ params,
514
+ strict: params != null,
515
+ });
516
+ if (!parsed.ok) {
517
+ fail(`${path}: ${parsed.issues.length} schema issue(s) — run validate first`);
518
+ for (const i of parsed.issues) log(` - ${i.path || "<root>"}: ${i.message}`);
519
+ process.exitCode = 1;
520
+ return;
521
+ }
522
+ const result = lintStoryboard(parsed.storyboard, { lang: opts.lang });
523
+ if (opts.json) {
524
+ console.log(JSON.stringify({ storyboardPath: path, ...result }, null, 2));
525
+ } else {
526
+ step(`Lint "${parsed.storyboard.title}"`);
527
+ log(
528
+ ` ${"scene".padEnd(12)}${"words".padStart(6)}${"narr".padStart(7)}${"action".padStart(8)}${"hold%".padStart(7)}`
529
+ );
530
+ for (const e of result.estimate) {
531
+ log(
532
+ ` ${e.id.padEnd(12)}${String(e.words).padStart(6)}` +
533
+ `${(e.narrationMs / 1000).toFixed(1).padStart(6)}s` +
534
+ `${(e.actionMs / 1000).toFixed(1).padStart(7)}s` +
535
+ `${e.holdPct > 0 ? `${Math.round(e.holdPct * 100)}%`.padStart(7) : "".padStart(7)}` +
536
+ (e.overrunMs > 0 ? ` cut ≈${(e.overrunMs / 1000).toFixed(1)}s` : "")
537
+ );
538
+ }
539
+ if (result.issues.length === 0) {
540
+ ok(`no issues — predicted narration ≈${(result.narrationTotalMs / 1000).toFixed(0)}s`);
541
+ } else {
542
+ logLint(result, log);
543
+ }
544
+ }
545
+ const errors = result.issues.filter((i) => i.severity === "error").length;
546
+ const warns = result.issues.filter((i) => i.severity === "warn").length;
547
+ if (errors > 0 || (opts.strict && warns > 0)) process.exitCode = 1;
548
+ }
549
+ );
550
+
551
+ program
552
+ .command("walkthrough")
553
+ .argument("<dir>", "demo project directory")
554
+ .option("--lang <code>", "language variant (final-demo.<lang>.mp4)")
555
+ .option("--width <px>", "frame width in px (default 960)", "960")
556
+ .option("--out <dir>", "output directory (default <dir>/output/walkthrough)")
557
+ .description(
558
+ "export output/walkthrough/: index.html + guide.md + per-scene frames + captions from the final video"
559
+ )
560
+ .action(async (dir: string, opts: { lang?: string; width: string; out?: string }) => {
561
+ const base = new Project(dir);
562
+ const project = opts.lang ? new Project(dir, opts.lang) : base;
563
+ await beginCommand(project, "walkthrough");
564
+ const storyboard = await base.loadStoryboard({ relaxed: true });
565
+ const sb = opts.lang ? localizeStoryboard(storyboard, opts.lang) : storyboard;
566
+ const res = await exportWalkthrough(project, sb, {
567
+ width: Number(opts.width) || 960,
568
+ outDir: opts.out,
569
+ });
570
+ ok(`walkthrough → ${res.index} (${res.scenes.length} scene(s); guide.md alongside)`);
571
+ });
572
+
573
+ program
574
+ .command("inspect")
575
+ .argument("<url>", "page to inspect (absolute URL)")
576
+ .option("--dir <dir>", "demo directory to write logs/inspect-*.json + screenshot into")
577
+ .option("--profile <dir>", "Chrome user-data dir (default: the recording profile)")
578
+ .option("--headed", "show the browser (default headless)", false)
579
+ .option("--limit <n>", "max elements (default 80)", "80")
580
+ .option("--viewport <WxH>", "viewport (default 1280x720)")
581
+ .option("--frame <name=selector>", "also scan this iframe (repeatable)", collectKv, [])
582
+ .option("--storage-state <file>", STORAGE_STATE_OPT_DESC)
583
+ .option("--cookie <spec>", COOKIE_OPT_DESC, collectKv, [])
584
+ .option(
585
+ "--no-setup",
586
+ "with --dir: ignore the storyboard's setup block (cookies/storageState/preflight)"
587
+ )
588
+ .option("--json", "print the full JSON result", false)
589
+ .description(
590
+ "list the page's visible interactive elements with unique selectors (no selector guessing)"
591
+ )
592
+ .action(
593
+ async (
594
+ url: string,
595
+ opts: {
596
+ dir?: string;
597
+ profile?: string;
598
+ headed?: boolean;
599
+ limit: string;
600
+ viewport?: string;
601
+ frame: string[];
602
+ storageState?: string;
603
+ cookie: string[];
604
+ setup?: boolean;
605
+ json?: boolean;
606
+ }
607
+ ) => {
608
+ const vp = opts.viewport?.match(/^(\d+)x(\d+)$/);
609
+ const frames: Record<string, string> = {};
610
+ for (const kv of opts.frame) {
611
+ const i = kv.indexOf("=");
612
+ if (i > 0) frames[kv.slice(0, i)] = kv.slice(i + 1);
613
+ }
614
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
615
+ const project = opts.dir ? new Project(opts.dir) : null;
616
+ if (project) await ensureDir(project.p("logs"));
617
+ // #53: authenticate the scan the same way a take does — the storyboard
618
+ // already declares how (setup.storageState / cookies / preflight), and
619
+ // --storage-state / --cookie layer on top for a project-less inspect.
620
+ const useSetup = opts.setup !== false;
621
+ let storyboard: Awaited<ReturnType<Project["loadStoryboard"]>> | null = null;
622
+ if (project && useSetup) {
623
+ storyboard = await project.loadStoryboard({ relaxed: true }).catch(() => null);
624
+ }
625
+ if (storyboard?.setup?.preflight) {
626
+ await runPreflight(storyboard.setup.preflight, {
627
+ demoDir: project!.dir,
628
+ storyboardPath: project!.storyboardPath,
629
+ profileDir: opts.profile ?? chromeProfileDir(),
630
+ });
631
+ storyboard = await project!.loadStoryboard({ relaxed: true }).catch(() => storyboard);
632
+ }
633
+ const seeds = await collectSeeds(
634
+ project?.dir ?? process.cwd(),
635
+ storyboard ?? ({ setup: undefined } as never),
636
+ {
637
+ storageState: opts.storageState,
638
+ cookies: opts.cookie?.length ? opts.cookie.map(parseCookieFlag) : undefined,
639
+ }
640
+ );
641
+ for (const [name, selector] of Object.entries(storyboard?.frames ?? {})) {
642
+ if (!(name in frames)) frames[name] = selector;
643
+ }
644
+ const res = await inspectPage({
645
+ url,
646
+ headless: !opts.headed,
647
+ profileDir: opts.profile,
648
+ limit: Number(opts.limit) || 80,
649
+ ...(vp
650
+ ? { viewport: { width: Number(vp[1]), height: Number(vp[2]) } }
651
+ : storyboard?.video
652
+ ? { viewport: storyboard.video }
653
+ : {}),
654
+ seeds,
655
+ frames,
656
+ ...(project ? { screenshotPath: project.p("logs", `inspect-${stamp}.png`) } : {}),
657
+ });
658
+ if (project) {
659
+ const file = project.p("logs", `inspect-${stamp}.json`);
660
+ await writeJson(file, res);
661
+ log(`inspect → ${file}`);
662
+ }
663
+ if (opts.json) {
664
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
665
+ return;
666
+ }
667
+ process.stdout.write(`${res.title || "(untitled)"} — ${res.finalUrl}\n`);
668
+ if (res.headings.length) {
669
+ process.stdout.write(
670
+ res.headings.map((h) => ` ${"#".repeat(h.level)} ${h.text}`).join("\n") + "\n"
671
+ );
672
+ }
673
+ if (res.iframes.length) {
674
+ process.stdout.write(
675
+ `iframes: ${res.iframes.map((f) => `${f.name} (${f.selector})`).join(", ")}\n`
676
+ );
677
+ }
678
+ process.stdout.write(`${res.elements.length} interactive element(s)${res.truncated ? " (truncated — raise --limit)" : ""}:\n`);
679
+ const pad = (t: string, n: number) => (t.length > n ? t.slice(0, n - 1) + "…" : t.padEnd(n));
680
+ for (const el of res.elements) {
681
+ process.stdout.write(
682
+ ` ${pad(el.role, 9)} ${pad(el.name || "(no name)", 34)} ${el.inViewport ? " " : "↓"} ${el.frame ? `[${el.frame}] ` : ""}${el.selector || "(no unique selector)"}\n`
683
+ );
684
+ }
685
+ }
686
+ );
687
+
688
+ program
689
+ .command("frames")
690
+ .argument("<dir>", "demo project directory")
691
+ .option("--every <sec>", "seconds between frames (default 3)", "3")
692
+ .option(
693
+ "--source <which>",
694
+ "final (output/final-demo.mp4, default) | raw (latest raw file) | take (the whole take, across a resume's raw files)",
695
+ "final"
696
+ )
697
+ .option("--width <px>", "frame width in px, aspect kept (default 640)", "640")
698
+ .option("--out <dir>", "output directory (default <dir>/output/frames)")
699
+ .option("--sheet", "also tile the frames into one contact-sheet PNG", false)
700
+ .option("--columns <n>", "contact-sheet columns (default 5)", "5")
701
+ .description(
702
+ "dump evenly spaced PNG frames from the final video (or the raw take) for review"
703
+ )
704
+ .action(
705
+ async (
706
+ dir: string,
707
+ opts: {
708
+ every: string;
709
+ source: string;
710
+ width: string;
711
+ out?: string;
712
+ sheet?: boolean;
713
+ columns: string;
714
+ }
715
+ ) => {
716
+ const everySec = Number(opts.every);
717
+ if (!(everySec > 0)) throw new Error(`--every must be a positive number of seconds`);
718
+ if (!["final", "raw", "take"].includes(opts.source)) {
719
+ throw new Error(`--source must be "final", "raw" or "take"`);
720
+ }
721
+ const project = new Project(dir);
722
+ const res = await extractFrames(project, {
723
+ everySec,
724
+ source: opts.source as "final" | "raw" | "take",
725
+ width: parsePositiveInt("--width", opts.width),
726
+ outDir: opts.out ? resolve(opts.out) : undefined,
727
+ sheet: opts.sheet,
728
+ sheetColumns: parsePositiveInt("--columns", opts.columns),
729
+ });
730
+ step("Frames");
731
+ ok(
732
+ `${res.files.length} frame(s) every ${res.everySec}s from ${res.source} ` +
733
+ `(${(res.durationMs / 1000).toFixed(1)}s)`
734
+ );
735
+ for (const f of res.files) log(` ${f}`);
736
+ if (res.sheet) ok(`contact sheet → ${res.sheet}`);
737
+ }
738
+ );
739
+
217
740
  program
218
741
  .command("record")
219
742
  .argument("<dir>", "demo project directory")
220
743
  .option("--profile <dir>", "Chrome user-data dir (logged-in profile)")
744
+ .option(
745
+ "--fresh",
746
+ "wipe and use a throwaway profile for this take (no carried-over cookies/localStorage)",
747
+ false
748
+ )
749
+ .option("--storage-state <file>", STORAGE_STATE_OPT_DESC)
750
+ .option("--cookie <spec>", COOKIE_OPT_DESC, collectKv, [])
751
+ .option("--profile-seeded", PROFILE_SEEDED_OPT_DESC, false)
221
752
  .option("--headless", "run headless (not recommended for real Chrome)", false)
222
753
  .option(
223
754
  "--capture <mode>",
224
755
  "capture path: playwright (default) | native (ffmpeg screen grab) | obs"
225
756
  )
226
757
  .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
758
+ .option("--from-scene <id>", FROM_SCENE_OPT_DESC)
759
+ .option("--no-replay", NO_REPLAY_OPT_DESC)
227
760
  .description("drive the storyboard in Chrome and record raw video + timeline.json")
228
761
  .action(
229
762
  async (
230
763
  dir: string,
231
- opts: { profile?: string; headless?: boolean; capture?: string; param?: string[] }
764
+ opts: {
765
+ profile?: string;
766
+ fresh?: boolean;
767
+ storageState?: string;
768
+ cookie?: string[];
769
+ profileSeeded?: boolean;
770
+ headless?: boolean;
771
+ capture?: string;
772
+ param?: string[];
773
+ fromScene?: string;
774
+ replay?: boolean;
775
+ }
232
776
  ) => {
233
777
  const project = new Project(dir);
234
778
  await beginCommand(project, "record");
235
- const storyboard = await project.loadStoryboard({ params: parseParams(opts.param) });
779
+ const load = () => project.loadStoryboard({ params: parseParams(opts.param) });
780
+ const storyboard = await load();
781
+ logLint(lintStoryboard(storyboard), log);
236
782
  await record(project, storyboard, {
237
783
  profileDir: opts.profile,
784
+ fresh: opts.fresh,
785
+ ...seedOpts(opts),
786
+ reloadStoryboard: load,
238
787
  headed: !opts.headless,
239
788
  capture: parseCapture(opts.capture),
789
+ fromScene: opts.fromScene,
790
+ noReplay: opts.replay === false,
240
791
  });
241
792
  }
242
793
  );
@@ -245,6 +796,14 @@ program
245
796
  .command("probe")
246
797
  .argument("<dir>", "demo project directory")
247
798
  .option("--profile <dir>", "Chrome user-data dir (logged-in profile)")
799
+ .option(
800
+ "--fresh",
801
+ "wipe and use a throwaway profile for this take (no carried-over cookies/localStorage)",
802
+ false
803
+ )
804
+ .option("--storage-state <file>", STORAGE_STATE_OPT_DESC)
805
+ .option("--cookie <spec>", COOKIE_OPT_DESC, collectKv, [])
806
+ .option("--profile-seeded", PROFILE_SEEDED_OPT_DESC, false)
248
807
  .option("--headless", "run headless (not recommended for real Chrome)", false)
249
808
  .option(
250
809
  "--capture <mode>",
@@ -269,6 +828,10 @@ program
269
828
  dir: string,
270
829
  opts: {
271
830
  profile?: string;
831
+ fresh?: boolean;
832
+ storageState?: string;
833
+ cookie?: string[];
834
+ profileSeeded?: boolean;
272
835
  headless?: boolean;
273
836
  capture?: string;
274
837
  param?: string[];
@@ -279,14 +842,20 @@ program
279
842
  const project = new Project(dir);
280
843
  await beginCommand(project, "probe");
281
844
  // Relaxed: narration is optional — a probe just exercises the flow.
282
- const storyboard = await project.loadStoryboard({
283
- relaxed: true,
284
- params: parseParams(opts.param),
285
- });
845
+ const load = () =>
846
+ project.loadStoryboard({
847
+ relaxed: true,
848
+ params: parseParams(opts.param),
849
+ });
850
+ const storyboard = await load();
851
+ logLint(lintStoryboard(storyboard), log);
286
852
  const goldenMode = !!(opts.golden || opts.updateGolden);
287
853
  const probeScenes: ProbeGoldenScene[] = [];
288
854
  await record(project, storyboard, {
289
855
  profileDir: opts.profile,
856
+ fresh: opts.fresh,
857
+ ...seedOpts(opts),
858
+ reloadStoryboard: load,
290
859
  headed: !opts.headless,
291
860
  capture: parseCapture(opts.capture),
292
861
  ...(goldenMode ? { probe: probeScenes } : {}),
@@ -317,6 +886,10 @@ program
317
886
  if (diffs.length > 0) {
318
887
  fail(`golden probe mismatch — ${diffs.length} field difference(s):`);
319
888
  for (const d of diffs) log(d);
889
+ const drift = await driftFilesForDiff(project, actual, diffs);
890
+ for (const f of drift) {
891
+ log(` selector drift → ${f.file} (scene ${f.scene}, action #${f.action}: nearest candidates)`);
892
+ }
320
893
  throw new Error(
321
894
  `probe --golden failed: the recorded flow drifted from ` +
322
895
  `${project.goldenProbePath} (${diffs.length} difference(s)). ` +
@@ -339,6 +912,7 @@ program
339
912
  .argument("<dir>", "demo project directory")
340
913
  .option("--scene <id>", "regenerate only this scene's narration")
341
914
  .option("--force", "re-synthesize every scene even if unchanged", false)
915
+ .option("--tts <provider>", TTS_OPT_DESC)
342
916
  .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
343
917
  .option("--lang <code>", LANG_OPT_DESC)
344
918
  .option("--langs <codes>", LANGS_OPT_DESC)
@@ -346,8 +920,16 @@ program
346
920
  .action(
347
921
  async (
348
922
  dir: string,
349
- opts: { scene?: string; force?: boolean; param?: string[]; lang?: string; langs?: string }
923
+ opts: {
924
+ scene?: string;
925
+ force?: boolean;
926
+ tts?: string;
927
+ param?: string[];
928
+ lang?: string;
929
+ langs?: string;
930
+ }
350
931
  ) => {
932
+ applyTtsFlag(opts.tts);
351
933
  const storyboard = await new Project(dir).loadStoryboard({
352
934
  params: parseParams(opts.param),
353
935
  });
@@ -429,25 +1011,69 @@ program
429
1011
  .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
430
1012
  .option("--lang <code>", LANG_OPT_DESC)
431
1013
  .option("--langs <codes>", LANGS_OPT_DESC)
1014
+ .option("--strict", STRICT_OPT_DESC, false)
432
1015
  .description("trim, sync, mux and caption into output/final-demo.mp4")
433
1016
  .action(
434
1017
  async (
435
1018
  dir: string,
436
- opts: { gif?: boolean; param?: string[]; lang?: string; langs?: string }
1019
+ opts: { gif?: boolean; param?: string[]; lang?: string; langs?: string; strict?: boolean }
437
1020
  ) => {
438
1021
  const storyboard = await new Project(dir).loadStoryboard({
439
1022
  params: parseParams(opts.param),
440
1023
  });
1024
+ let warningTotal = 0;
441
1025
  for (const lang of langsFrom(opts)) {
442
1026
  const project = new Project(dir, lang);
443
1027
  await beginCommand(project, "compose");
444
1028
  const sb = lang ? localizeStoryboard(storyboard, lang) : storyboard;
445
- await compose(project, sb);
1029
+ const report = await compose(project, sb);
1030
+ warningTotal += report.warnings.length;
446
1031
  if (opts.gif) await exportGif(project);
1032
+ // `output.walkthrough` is a pure post-process on the final video, so
1033
+ // honour it here too (issue #45): a pipeline that drives the stages
1034
+ // itself got chapters + poster and a silently ignored flag.
1035
+ if (sb.output?.walkthrough) await exportWalkthrough(project, sb);
1036
+ }
1037
+ if (opts.strict && warningTotal > 0) {
1038
+ process.exitCode = 1;
1039
+ fail(
1040
+ `--strict: ${warningTotal} compose warning(s) — see the grouped list above, ` +
1041
+ `output/report.json and the frames in output/warnings/`
1042
+ );
447
1043
  }
448
1044
  }
449
1045
  );
450
1046
 
1047
+ program
1048
+ .command("qa")
1049
+ .argument("<dir>", "demo project directory")
1050
+ .option("--lang <code>", "check the <code> language variant's video")
1051
+ .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
1052
+ .option("--json", "print the full JSON result", false)
1053
+ .option("--strict", "exit 1 when any check warns", false)
1054
+ .description(
1055
+ "post-render checks on the finished MP4: blank open, blank poster, loudness, music bed, hold shares, cue readability, frame/chapter hygiene"
1056
+ )
1057
+ .action(
1058
+ async (
1059
+ dir: string,
1060
+ opts: { lang?: string; param?: string[]; json?: boolean; strict?: boolean }
1061
+ ) => {
1062
+ const base = new Project(dir);
1063
+ const storyboard = await base.loadStoryboard({ params: parseParams(opts.param) });
1064
+ const project = new Project(dir, opts.lang);
1065
+ const sb = opts.lang ? localizeStoryboard(storyboard, opts.lang) : storyboard;
1066
+ const res = await runQa(project, sb);
1067
+ if (opts.json) {
1068
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
1069
+ } else {
1070
+ step(`demo QA: ${res.video}`);
1071
+ logQa(res);
1072
+ }
1073
+ if (opts.strict && res.warnings > 0) process.exitCode = 1;
1074
+ }
1075
+ );
1076
+
451
1077
  program
452
1078
  .command("gif")
453
1079
  .argument("<dir>", "demo project directory")
@@ -514,12 +1140,21 @@ program
514
1140
  .command("render")
515
1141
  .argument("<dir>", "demo project directory")
516
1142
  .option("--profile <dir>", "Chrome user-data dir (logged-in profile)")
1143
+ .option(
1144
+ "--fresh",
1145
+ "wipe and use a throwaway profile for this take (no carried-over cookies/localStorage)",
1146
+ false
1147
+ )
1148
+ .option("--storage-state <file>", STORAGE_STATE_OPT_DESC)
1149
+ .option("--cookie <spec>", COOKIE_OPT_DESC, collectKv, [])
1150
+ .option("--profile-seeded", PROFILE_SEEDED_OPT_DESC, false)
517
1151
  .option("--headless", "run headless", false)
518
1152
  .option(
519
1153
  "--capture <mode>",
520
1154
  "capture path: playwright (default) | native (ffmpeg screen grab) | obs"
521
1155
  )
522
1156
  .option("--force-voice", "re-synthesize narration even if unchanged", false)
1157
+ .option("--tts <provider>", TTS_OPT_DESC)
523
1158
  .option("--gif", "also export output/final-demo.gif after the mux", false)
524
1159
  .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
525
1160
  .option(
@@ -528,22 +1163,44 @@ program
528
1163
  )
529
1164
  .option("--lang <code>", LANG_OPT_DESC)
530
1165
  .option("--langs <codes>", `${LANGS_OPT_DESC} — records ONCE, then renders each`)
1166
+ .option("--from-scene <id>", FROM_SCENE_OPT_DESC)
1167
+ .option("--no-replay", NO_REPLAY_OPT_DESC)
1168
+ .option("--strict", STRICT_OPT_DESC, false)
531
1169
  .description("run the full pipeline: voice → record → captions → compose")
532
1170
  .action(
533
1171
  async (
534
1172
  dir: string,
535
1173
  opts: {
536
1174
  profile?: string;
1175
+ fresh?: boolean;
1176
+ storageState?: string;
1177
+ cookie?: string[];
1178
+ profileSeeded?: boolean;
537
1179
  headless?: boolean;
538
1180
  capture?: string;
539
1181
  forceVoice?: boolean;
1182
+ tts?: string;
540
1183
  gif?: boolean;
541
1184
  param?: string[];
542
1185
  variants?: string;
543
1186
  lang?: string;
544
1187
  langs?: string;
1188
+ fromScene?: string;
1189
+ replay?: boolean;
1190
+ strict?: boolean;
545
1191
  }
546
1192
  ) => {
1193
+ applyTtsFlag(opts.tts);
1194
+ let warningTotal = 0;
1195
+ const strictGate = (): void => {
1196
+ if (opts.strict && warningTotal > 0) {
1197
+ process.exitCode = 1;
1198
+ fail(
1199
+ `--strict: ${warningTotal} compose warning(s) — see the grouped list above, ` +
1200
+ `output/report.json and the frames in output/warnings/`
1201
+ );
1202
+ }
1203
+ };
547
1204
  const base = new Project(dir);
548
1205
  await beginCommand(base, "render");
549
1206
 
@@ -553,6 +1210,8 @@ program
553
1210
  const results = await renderVariants(dir, variants, {
554
1211
  record: {
555
1212
  profileDir: opts.profile,
1213
+ fresh: opts.fresh,
1214
+ ...seedOpts(opts),
556
1215
  headed: !opts.headless,
557
1216
  capture: parseCapture(opts.capture),
558
1217
  },
@@ -575,9 +1234,12 @@ program
575
1234
  }
576
1235
  };
577
1236
 
578
- const storyboard = await base.loadStoryboard({ params: parseParams(opts.param) });
1237
+ const load = () => base.loadStoryboard({ params: parseParams(opts.param) });
1238
+ const storyboard = await load();
579
1239
  const langs = langsFrom(opts);
580
1240
  const multi = !(langs.length === 1 && langs[0] === undefined);
1241
+ // Browser-free preflight: pacing forecast + pitfalls, before TTS is paid for.
1242
+ for (const lang of langs) logLint(lintStoryboard(storyboard, { lang }), log);
581
1243
 
582
1244
  if (!multi) {
583
1245
  // Default single-language pipeline — voice → record → captions → compose.
@@ -589,20 +1251,33 @@ program
589
1251
  await stageLog(base, "record", () =>
590
1252
  record(base, storyboard, {
591
1253
  profileDir: opts.profile,
1254
+ fresh: opts.fresh,
1255
+ ...seedOpts(opts),
1256
+ reloadStoryboard: load,
592
1257
  headed: !opts.headless,
593
1258
  capture: parseCapture(opts.capture),
1259
+ fromScene: opts.fromScene,
1260
+ noReplay: opts.replay === false,
594
1261
  })
595
1262
  );
596
1263
  await stageLog(base, "captions", () => captionsFor(base, storyboard));
597
- await stageLog(base, "compose", () => compose(base, storyboard));
1264
+ await stageLog(base, "compose", async () => {
1265
+ const report = await compose(base, storyboard);
1266
+ warningTotal += report.warnings.length;
1267
+ return report;
1268
+ });
598
1269
  if (opts.gif) await stageLog(base, "gif", () => exportGif(base));
599
1270
  // Screenshot mode: emit any `still` PNGs from the clean take (a
600
1271
  // re-extract, not a re-record).
601
1272
  if (storyboardHasStills(storyboard)) {
602
1273
  await stageLog(base, "stills", () => extractStills(base));
603
1274
  }
1275
+ if (storyboard.output?.walkthrough) {
1276
+ await stageLog(base, "walkthrough", () => exportWalkthrough(base, storyboard));
1277
+ }
604
1278
  step("Done");
605
1279
  ok(`▶ open ${base.outputPath}`);
1280
+ strictGate();
606
1281
  return;
607
1282
  }
608
1283
 
@@ -612,8 +1287,13 @@ program
612
1287
  await stageLog(base, "record", () =>
613
1288
  record(base, storyboard, {
614
1289
  profileDir: opts.profile,
1290
+ fresh: opts.fresh,
1291
+ ...seedOpts(opts),
1292
+ reloadStoryboard: load,
615
1293
  headed: !opts.headless,
616
1294
  capture: parseCapture(opts.capture),
1295
+ fromScene: opts.fromScene,
1296
+ noReplay: opts.replay === false,
617
1297
  })
618
1298
  );
619
1299
  if (storyboardHasStills(storyboard)) {
@@ -628,11 +1308,16 @@ program
628
1308
  generateVoice(project, sb, { force: opts.forceVoice })
629
1309
  );
630
1310
  await stageLog(project, "captions", () => captionsFor(project, sb));
631
- await stageLog(project, "compose", () => compose(project, sb));
1311
+ await stageLog(project, "compose", async () => {
1312
+ const report = await compose(project, sb);
1313
+ warningTotal += report.warnings.length;
1314
+ return report;
1315
+ });
632
1316
  if (opts.gif) await stageLog(project, "gif", () => exportGif(project));
633
1317
  ok(`▶ open ${project.outputPath}`);
634
1318
  }
635
1319
  step("Done");
1320
+ strictGate();
636
1321
  }
637
1322
  );
638
1323