@tandryukha/aidemo 0.8.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -1,12 +1,27 @@
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 { parseCookieFlag } from "./setup.js";
15
+ import { extractFrames } from "./frames.js";
16
+ import { inspectPage } from "./inspect.js";
17
+ import { importTrace, scaffoldImported } from "./import-trace.js";
18
+ import { GUIDE_TOPIC_NAMES, guideHeadings, readGuide, sliceGuide } from "./guide.js";
19
+ import { lintStoryboard, logLint } from "./lint.js";
20
+ import { readJson, writeJson } from "./util.js";
7
21
  import {
8
22
  buildProbeGolden,
9
23
  diffGolden,
24
+ driftFilesForDiff,
10
25
  readProbeGolden,
11
26
  writeProbeGolden,
12
27
  } from "./golden.js";
@@ -17,6 +32,7 @@ import { compose } from "./compose.js";
17
32
  import { exportGif } from "./gif.js";
18
33
  import { buildEmbed, formatEmbed } from "./embed.js";
19
34
  import { extractStills, storyboardHasStills } from "./stills.js";
35
+ import { exportWalkthrough } from "./walkthrough.js";
20
36
  import { localizeStoryboard, missingNarrations } from "./i18n.js";
21
37
  import { synthesizeMusicBed } from "./music.js";
22
38
  import { loadVariants, renderVariants } from "./variants.js";
@@ -29,6 +45,7 @@ import {
29
45
  setLogFile,
30
46
  closeLogFile,
31
47
  teeStageLog,
48
+ exists,
32
49
  } from "./util.js";
33
50
  import {
34
51
  scaffoldDemo,
@@ -70,12 +87,54 @@ program
70
87
  .command("init")
71
88
  .argument("<name>", "demo name (creates ./demos/<name>/)")
72
89
  .option("--force", "overwrite an existing demos/<name>/", false)
90
+ .option(
91
+ "--from-url <url>",
92
+ "draft from a live page: inspect it, headings → scenes, unique selectors → beats (no LLM)"
93
+ )
94
+ .option("--headed", "show the browser while inspecting (--from-url)", false)
95
+ .option("--profile <dir>", "Chrome user-data dir for --from-url (logged-in pages)")
96
+ .option("--viewport <WxH>", "viewport for --from-url (default 1280x720)")
73
97
  .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}`);
98
+ .action(
99
+ async (
100
+ name: string,
101
+ opts: { force?: boolean; fromUrl?: string; headed?: boolean; profile?: string; viewport?: string }
102
+ ) => {
103
+ // Scaffold into the *current* working directory, so `init` works both in the
104
+ // engine's own repo and in a consumer repo that invoked it via npx.
105
+ const vp = opts.viewport ? /^(\d+)x(\d+)$/.exec(opts.viewport) : null;
106
+ if (opts.viewport && !vp) throw new Error(`--viewport expects WxH, got ${opts.viewport}`);
107
+ const dir = await scaffoldDemo(process.cwd(), name, {
108
+ force: opts.force,
109
+ fromUrl: opts.fromUrl,
110
+ headed: opts.headed,
111
+ profileDir: opts.profile,
112
+ ...(vp ? { viewport: { width: Number(vp[1]), height: Number(vp[2]) } } : {}),
113
+ });
114
+ ok(
115
+ opts.fromUrl
116
+ ? `write the narration in generated/storyboard.json (input/brief.md lists what inspect saw), then: aidemo probe ${dir}`
117
+ : `edit generated/storyboard.json, then: aidemo render ${dir}`
118
+ );
119
+ }
120
+ );
121
+
122
+ program
123
+ .command("import-trace")
124
+ .argument("<file>", "Playwright trace.zip (context.tracing / --trace on) or a *.spec.ts test file")
125
+ .requiredOption("--name <name>", "demo name (creates ./demos/<name>/)")
126
+ .option("--force", "overwrite an existing demos/<name>/storyboard", false)
127
+ .option("--json", "print the draft storyboard to stdout instead of writing a demo", false)
128
+ .description("draft a storyboard from a Playwright trace or test: its actions + selectors become scenes (no LLM)")
129
+ .action(async (file: string, opts: { name: string; force?: boolean; json?: boolean }) => {
130
+ const result = await importTrace(resolve(file), opts.name);
131
+ if (opts.json) {
132
+ process.stdout.write(JSON.stringify(result.storyboard, null, 2) + "\n");
133
+ return;
134
+ }
135
+ const dir = await scaffoldImported(process.cwd(), opts.name, result, { force: opts.force });
136
+ for (const n of result.notes) log(` · ${n}`);
137
+ ok(`write the narration in ${dir}/generated/storyboard.json, then: aidemo probe ${dir}`);
79
138
  });
80
139
 
81
140
  program
@@ -125,22 +184,82 @@ program
125
184
  program
126
185
  .command("guide")
127
186
  .description("print the agent-neutral authoring guide (docs/AUTHORING.md)")
128
- .action(async () => {
187
+ .option(
188
+ "--topic <name>",
189
+ `print one slice: ${GUIDE_TOPIC_NAMES.join(", ")} — or an H2 heading prefix`
190
+ )
191
+ .option("--list", "list topics and section headings")
192
+ .action(async (opts: { topic?: string; list?: boolean }) => {
129
193
  // 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
- );
194
+ const md = await readGuide();
195
+ if (opts.list) {
196
+ process.stdout.write(
197
+ `topics: ${GUIDE_TOPIC_NAMES.join(", ")}\n\nsections:\n` +
198
+ guideHeadings(md)
199
+ .map((h) => ` ${h}`)
200
+ .join("\n") +
201
+ "\n"
202
+ );
203
+ return;
204
+ }
205
+ if (!opts.topic) {
206
+ process.stdout.write(md);
207
+ return;
208
+ }
209
+ const slice = sliceGuide(md, opts.topic);
210
+ if (slice == null) {
211
+ fail(
212
+ `unknown guide topic "${opts.topic}" — topics: ${GUIDE_TOPIC_NAMES.join(", ")}; ` +
213
+ `sections: ${guideHeadings(md).join(" · ")}`
214
+ );
215
+ process.exit(1);
216
+ }
217
+ process.stdout.write(slice);
133
218
  });
134
219
 
220
+ /** Read all of stdin as UTF-8 (for `--body-file -`). */
221
+ async function readStdin(): Promise<string> {
222
+ const chunks: Buffer[] = [];
223
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
224
+ return Buffer.concat(chunks).toString("utf8");
225
+ }
226
+
135
227
  program
136
228
  .command("feedback")
137
229
  .argument("[dir]", "demo project directory (adds storyboard + log context)")
230
+ .option("--title <text>", "issue title (default: \"Demo feedback: <dir>\")")
231
+ .option("--body <text>", "issue body; environment + log context is appended")
232
+ .option("--body-file <file>", "read the body from a file, or \"-\" for stdin")
138
233
  .option("--web", "open a prefilled New Issue page instead of filing directly", false)
139
234
  .option("--dry-run", "print the issue title/body without filing", false)
140
235
  .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
- });
236
+ .action(
237
+ async (
238
+ dir: string | undefined,
239
+ opts: {
240
+ title?: string;
241
+ body?: string;
242
+ bodyFile?: string;
243
+ web?: boolean;
244
+ dryRun?: boolean;
245
+ }
246
+ ) => {
247
+ // Non-interactive body so an agent (or a script) can file without the
248
+ // placeholder template — the reason issue #38 was filed with `gh` by hand.
249
+ let body = opts.body;
250
+ if (opts.bodyFile) {
251
+ body = await (opts.bodyFile === "-"
252
+ ? readStdin()
253
+ : readFile(opts.bodyFile, "utf8"));
254
+ }
255
+ await feedback(dir, {
256
+ web: opts.web,
257
+ dryRun: opts.dryRun,
258
+ title: opts.title,
259
+ description: body,
260
+ });
261
+ }
262
+ );
144
263
 
145
264
  program
146
265
  .command("doctor")
@@ -150,6 +269,21 @@ program
150
269
  await doctor(opts.dir);
151
270
  });
152
271
 
272
+ /**
273
+ * `--tts <provider>` — a discoverable spelling of AIDEMO_TTS_PROVIDER (the env
274
+ * var is canonical; the flag just sets it before the pipeline reads it).
275
+ */
276
+ function applyTtsFlag(value?: string): void {
277
+ if (value == null) return;
278
+ if (value !== "openai" && value !== "elevenlabs" && value !== "local") {
279
+ throw new Error(`--tts must be openai, elevenlabs or local (got "${value}")`);
280
+ }
281
+ process.env.AIDEMO_TTS_PROVIDER = value;
282
+ }
283
+
284
+ const TTS_OPT_DESC =
285
+ "TTS provider: openai (default) | elevenlabs | local (in-process Kokoro; needs `npm install kokoro-js`) — same as AIDEMO_TTS_PROVIDER";
286
+
153
287
  function parsePositiveInt(name: string, value: string): number {
154
288
  const n = Number(value);
155
289
  if (!Number.isInteger(n) || n <= 0)
@@ -180,6 +314,8 @@ function parseParams(pairs?: string[]): Record<string, string> | undefined {
180
314
  return out;
181
315
  }
182
316
 
317
+ const FROM_SCENE_OPT_DESC =
318
+ "resume: keep the previous take's scenes before this id (verified unchanged) and record from it";
183
319
  const PARAM_OPT_DESC =
184
320
  "set a storyboard template param (key=value; repeatable; must be declared in the storyboard's params block)";
185
321
 
@@ -194,6 +330,30 @@ const LANGS_OPT_DESC =
194
330
  * `[undefined]` when neither is given — the default single-language render,
195
331
  * byte-for-byte unchanged (undefined = the base storyboard, unsuffixed paths).
196
332
  */
333
+ const STORAGE_STATE_OPT_DESC =
334
+ "seed a Playwright storageState JSON (cookies + localStorage) into the profile before the take";
335
+ const COOKIE_OPT_DESC =
336
+ 'seed a cookie before the take: "name=value;domain=host[;path=/;secure;httpOnly;sameSite=Lax]" (repeatable)';
337
+ const PROFILE_SEEDED_OPT_DESC =
338
+ "the profile is seeded on purpose (login / cookie gate): silence the carried-over-state warning";
339
+
340
+ /** Profile-seeding flags shared by record/probe/render → RecordOptions fields. */
341
+ function seedOpts(opts: {
342
+ storageState?: string;
343
+ cookie?: string[];
344
+ profileSeeded?: boolean;
345
+ }): {
346
+ storageState?: string;
347
+ cookies?: ReturnType<typeof parseCookieFlag>[];
348
+ profileSeeded?: boolean;
349
+ } {
350
+ return {
351
+ storageState: opts.storageState,
352
+ cookies: opts.cookie?.length ? opts.cookie.map(parseCookieFlag) : undefined,
353
+ profileSeeded: opts.profileSeeded,
354
+ };
355
+ }
356
+
197
357
  function langsFrom(opts: { lang?: string; langs?: string }): Array<string | undefined> {
198
358
  const list = [
199
359
  ...(opts.langs ? opts.langs.split(",") : []),
@@ -214,29 +374,359 @@ function warnPartialCoverage(storyboard: Parameters<typeof missingNarrations>[0]
214
374
  );
215
375
  }
216
376
 
377
+ program
378
+ .command("login")
379
+ .argument("[profile]", "Chrome user-data dir (default: the engine's recording profile)")
380
+ .option("--url <url>", "page to open for the login", "https://chatgpt.com/")
381
+ .option("--no-wait", "launch Chrome and return without waiting for it to quit")
382
+ .description(
383
+ "open a manual-login Chrome on the recording profile (mock-keychain cookie " +
384
+ "store, same as record) and wait until you quit it"
385
+ )
386
+ .action(async (profile: string | undefined, opts: { url: string; wait: boolean }) => {
387
+ const { login } = await import("./login.js");
388
+ await login(profile, { url: opts.url, noWait: !opts.wait });
389
+ });
390
+
391
+ program
392
+ .command("profile")
393
+ .argument("[action]", "path (default) | reset", "path")
394
+ .option("--profile <dir>", "operate on this Chrome user-data dir instead of the default")
395
+ .description(
396
+ "show or wipe the recording Chrome profile (a profile carrying state for " +
397
+ "the app can silently record the wrong story)"
398
+ )
399
+ .action(async (action: string, opts: { profile?: string }) => {
400
+ const dir = opts.profile ?? chromeProfileDir();
401
+ if (action === "reset") {
402
+ await resetProfile(dir);
403
+ step("Profile reset");
404
+ ok(`wiped → ${dir}`);
405
+ ok("the next take starts from a clean browser identity (you'll need to log in again)");
406
+ return;
407
+ }
408
+ if (action !== "path") {
409
+ throw new Error(`unknown profile action "${action}" — use: path | reset`);
410
+ }
411
+ step("Recording profile");
412
+ ok(dir);
413
+ ok(`exists: ${(await exists(dir)) ? "yes" : "no"}`);
414
+ ok("wipe it with: aidemo profile reset (or record/probe with --fresh)");
415
+ });
416
+
417
+ program
418
+ .command("validate")
419
+ .argument("[dir]", "demo project directory (validates generated/storyboard.json)")
420
+ .option("--file <path>", "validate this storyboard file instead of <dir>/generated/storyboard.json")
421
+ .option("--relaxed", "narration optional (probe semantics)", false)
422
+ .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
423
+ .option("--json", "print the structured result as JSON", false)
424
+ .description(
425
+ "validate a storyboard against the engine schema without running anything " +
426
+ "(the CLI twin of the MCP validate_storyboard tool; non-zero exit on issues)"
427
+ )
428
+ .action(
429
+ async (
430
+ dir: string | undefined,
431
+ opts: { file?: string; relaxed?: boolean; param?: string[]; json?: boolean }
432
+ ) => {
433
+ if (!dir && !opts.file) {
434
+ throw new Error("pass a demo dir or --file <storyboard.json>");
435
+ }
436
+ const path = opts.file ? resolve(opts.file) : new Project(dir!).storyboardPath;
437
+ let raw: unknown;
438
+ try {
439
+ raw = await readJson<unknown>(path);
440
+ } catch (err) {
441
+ const result = {
442
+ valid: false,
443
+ storyboardPath: path,
444
+ issues: [{ path: "", message: (err as Error).message, code: "unreadable" }],
445
+ warnings: [] as string[],
446
+ };
447
+ if (opts.json) console.log(JSON.stringify(result, null, 2));
448
+ else fail(`${path}: ${(err as Error).message}`);
449
+ process.exitCode = 1;
450
+ return;
451
+ }
452
+ const params = parseParams(opts.param);
453
+ const parsed = parseStoryboard(raw, {
454
+ relaxed: opts.relaxed,
455
+ params,
456
+ strict: params != null,
457
+ });
458
+ const result = parsed.ok
459
+ ? {
460
+ valid: true,
461
+ storyboardPath: path,
462
+ title: parsed.storyboard.title,
463
+ sceneCount: parsed.storyboard.scenes.length,
464
+ issues: [] as Array<{ path: string; message: string; code: string }>,
465
+ warnings: parsed.warnings,
466
+ }
467
+ : { valid: false, storyboardPath: path, issues: parsed.issues, warnings: [] as string[] };
468
+ const lint = parsed.ok ? lintStoryboard(parsed.storyboard) : null;
469
+ if (opts.json) {
470
+ console.log(JSON.stringify({ ...result, lint: lint?.issues ?? [] }, null, 2));
471
+ } else if (result.valid) {
472
+ step("Storyboard valid");
473
+ ok(`${path} — "${result.title}", ${result.sceneCount} scene(s)`);
474
+ for (const w of result.warnings) log(w);
475
+ if (lint) logLint(lint, log);
476
+ } else {
477
+ fail(`${path}: ${result.issues.length} issue(s)`);
478
+ for (const i of result.issues) log(` - ${i.path || "<root>"}: ${i.message}`);
479
+ }
480
+ if (!result.valid) process.exitCode = 1;
481
+ }
482
+ );
483
+
484
+ program
485
+ .command("lint")
486
+ .argument("[dir]", "demo project directory (lints generated/storyboard.json)")
487
+ .option("--file <path>", "lint this storyboard file instead of <dir>/generated/storyboard.json")
488
+ .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
489
+ .option("--lang <code>", "lint the narrations[code] translation + that language's pace")
490
+ .option("--json", "print the structured result as JSON", false)
491
+ .option("--strict", "non-zero exit on warnings too (default: errors only)", false)
492
+ .description(
493
+ "preflight a storyboard without a browser: pacing forecast (which scenes " +
494
+ "compose will freeze or cut), selector/wait pitfalls, no-op keys"
495
+ )
496
+ .action(
497
+ async (
498
+ dir: string | undefined,
499
+ opts: { file?: string; param?: string[]; lang?: string; json?: boolean; strict?: boolean }
500
+ ) => {
501
+ if (!dir && !opts.file) {
502
+ throw new Error("pass a demo dir or --file <storyboard.json>");
503
+ }
504
+ const path = opts.file ? resolve(opts.file) : new Project(dir!).storyboardPath;
505
+ const params = parseParams(opts.param);
506
+ const parsed = parseStoryboard(await readJson<unknown>(path), {
507
+ relaxed: true,
508
+ params,
509
+ strict: params != null,
510
+ });
511
+ if (!parsed.ok) {
512
+ fail(`${path}: ${parsed.issues.length} schema issue(s) — run validate first`);
513
+ for (const i of parsed.issues) log(` - ${i.path || "<root>"}: ${i.message}`);
514
+ process.exitCode = 1;
515
+ return;
516
+ }
517
+ const result = lintStoryboard(parsed.storyboard, { lang: opts.lang });
518
+ if (opts.json) {
519
+ console.log(JSON.stringify({ storyboardPath: path, ...result }, null, 2));
520
+ } else {
521
+ step(`Lint "${parsed.storyboard.title}"`);
522
+ log(
523
+ ` ${"scene".padEnd(12)}${"words".padStart(6)}${"narr".padStart(7)}${"action".padStart(8)}${"hold%".padStart(7)}`
524
+ );
525
+ for (const e of result.estimate) {
526
+ log(
527
+ ` ${e.id.padEnd(12)}${String(e.words).padStart(6)}` +
528
+ `${(e.narrationMs / 1000).toFixed(1).padStart(6)}s` +
529
+ `${(e.actionMs / 1000).toFixed(1).padStart(7)}s` +
530
+ `${e.holdPct > 0 ? `${Math.round(e.holdPct * 100)}%`.padStart(7) : "".padStart(7)}` +
531
+ (e.overrunMs > 0 ? ` cut ≈${(e.overrunMs / 1000).toFixed(1)}s` : "")
532
+ );
533
+ }
534
+ if (result.issues.length === 0) {
535
+ ok(`no issues — predicted narration ≈${(result.narrationTotalMs / 1000).toFixed(0)}s`);
536
+ } else {
537
+ logLint(result, log);
538
+ }
539
+ }
540
+ const errors = result.issues.filter((i) => i.severity === "error").length;
541
+ const warns = result.issues.filter((i) => i.severity === "warn").length;
542
+ if (errors > 0 || (opts.strict && warns > 0)) process.exitCode = 1;
543
+ }
544
+ );
545
+
546
+ program
547
+ .command("walkthrough")
548
+ .argument("<dir>", "demo project directory")
549
+ .option("--lang <code>", "language variant (final-demo.<lang>.mp4)")
550
+ .option("--width <px>", "frame width in px (default 960)", "960")
551
+ .option("--out <dir>", "output directory (default <dir>/output/walkthrough)")
552
+ .description(
553
+ "export output/walkthrough/: index.html + guide.md + per-scene frames + captions from the final video"
554
+ )
555
+ .action(async (dir: string, opts: { lang?: string; width: string; out?: string }) => {
556
+ const base = new Project(dir);
557
+ const project = opts.lang ? new Project(dir, opts.lang) : base;
558
+ await beginCommand(project, "walkthrough");
559
+ const storyboard = await base.loadStoryboard({ relaxed: true });
560
+ const sb = opts.lang ? localizeStoryboard(storyboard, opts.lang) : storyboard;
561
+ const res = await exportWalkthrough(project, sb, {
562
+ width: Number(opts.width) || 960,
563
+ outDir: opts.out,
564
+ });
565
+ ok(`walkthrough → ${res.index} (${res.scenes.length} scene(s); guide.md alongside)`);
566
+ });
567
+
568
+ program
569
+ .command("inspect")
570
+ .argument("<url>", "page to inspect (absolute URL)")
571
+ .option("--dir <dir>", "demo directory to write logs/inspect-*.json + screenshot into")
572
+ .option("--profile <dir>", "Chrome user-data dir (default: the recording profile)")
573
+ .option("--headed", "show the browser (default headless)", false)
574
+ .option("--limit <n>", "max elements (default 80)", "80")
575
+ .option("--viewport <WxH>", "viewport (default 1280x720)")
576
+ .option("--frame <name=selector>", "also scan this iframe (repeatable)", collectKv, [])
577
+ .option("--json", "print the full JSON result", false)
578
+ .description(
579
+ "list the page's visible interactive elements with unique selectors (no selector guessing)"
580
+ )
581
+ .action(
582
+ async (
583
+ url: string,
584
+ opts: {
585
+ dir?: string;
586
+ profile?: string;
587
+ headed?: boolean;
588
+ limit: string;
589
+ viewport?: string;
590
+ frame: string[];
591
+ json?: boolean;
592
+ }
593
+ ) => {
594
+ const vp = opts.viewport?.match(/^(\d+)x(\d+)$/);
595
+ const frames: Record<string, string> = {};
596
+ for (const kv of opts.frame) {
597
+ const i = kv.indexOf("=");
598
+ if (i > 0) frames[kv.slice(0, i)] = kv.slice(i + 1);
599
+ }
600
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
601
+ const project = opts.dir ? new Project(opts.dir) : null;
602
+ if (project) await ensureDir(project.p("logs"));
603
+ const res = await inspectPage({
604
+ url,
605
+ headless: !opts.headed,
606
+ profileDir: opts.profile,
607
+ limit: Number(opts.limit) || 80,
608
+ ...(vp ? { viewport: { width: Number(vp[1]), height: Number(vp[2]) } } : {}),
609
+ frames,
610
+ ...(project ? { screenshotPath: project.p("logs", `inspect-${stamp}.png`) } : {}),
611
+ });
612
+ if (project) {
613
+ const file = project.p("logs", `inspect-${stamp}.json`);
614
+ await writeJson(file, res);
615
+ log(`inspect → ${file}`);
616
+ }
617
+ if (opts.json) {
618
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
619
+ return;
620
+ }
621
+ process.stdout.write(`${res.title || "(untitled)"} — ${res.finalUrl}\n`);
622
+ if (res.headings.length) {
623
+ process.stdout.write(
624
+ res.headings.map((h) => ` ${"#".repeat(h.level)} ${h.text}`).join("\n") + "\n"
625
+ );
626
+ }
627
+ if (res.iframes.length) {
628
+ process.stdout.write(
629
+ `iframes: ${res.iframes.map((f) => `${f.name} (${f.selector})`).join(", ")}\n`
630
+ );
631
+ }
632
+ process.stdout.write(`${res.elements.length} interactive element(s)${res.truncated ? " (truncated — raise --limit)" : ""}:\n`);
633
+ const pad = (t: string, n: number) => (t.length > n ? t.slice(0, n - 1) + "…" : t.padEnd(n));
634
+ for (const el of res.elements) {
635
+ process.stdout.write(
636
+ ` ${pad(el.role, 9)} ${pad(el.name || "(no name)", 34)} ${el.inViewport ? " " : "↓"} ${el.frame ? `[${el.frame}] ` : ""}${el.selector || "(no unique selector)"}\n`
637
+ );
638
+ }
639
+ }
640
+ );
641
+
642
+ program
643
+ .command("frames")
644
+ .argument("<dir>", "demo project directory")
645
+ .option("--every <sec>", "seconds between frames (default 3)", "3")
646
+ .option(
647
+ "--source <which>",
648
+ "final (output/final-demo.mp4, default) | raw (latest raw file) | take (the whole take, across a resume's raw files)",
649
+ "final"
650
+ )
651
+ .option("--width <px>", "frame width in px, aspect kept (default 640)", "640")
652
+ .option("--out <dir>", "output directory (default <dir>/output/frames)")
653
+ .description(
654
+ "dump evenly spaced PNG frames from the final video (or the raw take) for review"
655
+ )
656
+ .action(
657
+ async (
658
+ dir: string,
659
+ opts: { every: string; source: string; width: string; out?: string }
660
+ ) => {
661
+ const everySec = Number(opts.every);
662
+ if (!(everySec > 0)) throw new Error(`--every must be a positive number of seconds`);
663
+ if (!["final", "raw", "take"].includes(opts.source)) {
664
+ throw new Error(`--source must be "final", "raw" or "take"`);
665
+ }
666
+ const project = new Project(dir);
667
+ const res = await extractFrames(project, {
668
+ everySec,
669
+ source: opts.source as "final" | "raw" | "take",
670
+ width: parsePositiveInt("--width", opts.width),
671
+ outDir: opts.out ? resolve(opts.out) : undefined,
672
+ });
673
+ step("Frames");
674
+ ok(
675
+ `${res.files.length} frame(s) every ${res.everySec}s from ${res.source} ` +
676
+ `(${(res.durationMs / 1000).toFixed(1)}s)`
677
+ );
678
+ for (const f of res.files) log(` ${f}`);
679
+ }
680
+ );
681
+
217
682
  program
218
683
  .command("record")
219
684
  .argument("<dir>", "demo project directory")
220
685
  .option("--profile <dir>", "Chrome user-data dir (logged-in profile)")
686
+ .option(
687
+ "--fresh",
688
+ "wipe and use a throwaway profile for this take (no carried-over cookies/localStorage)",
689
+ false
690
+ )
691
+ .option("--storage-state <file>", STORAGE_STATE_OPT_DESC)
692
+ .option("--cookie <spec>", COOKIE_OPT_DESC, collectKv, [])
693
+ .option("--profile-seeded", PROFILE_SEEDED_OPT_DESC, false)
221
694
  .option("--headless", "run headless (not recommended for real Chrome)", false)
222
695
  .option(
223
696
  "--capture <mode>",
224
697
  "capture path: playwright (default) | native (ffmpeg screen grab) | obs"
225
698
  )
226
699
  .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
700
+ .option("--from-scene <id>", FROM_SCENE_OPT_DESC)
227
701
  .description("drive the storyboard in Chrome and record raw video + timeline.json")
228
702
  .action(
229
703
  async (
230
704
  dir: string,
231
- opts: { profile?: string; headless?: boolean; capture?: string; param?: string[] }
705
+ opts: {
706
+ profile?: string;
707
+ fresh?: boolean;
708
+ storageState?: string;
709
+ cookie?: string[];
710
+ profileSeeded?: boolean;
711
+ headless?: boolean;
712
+ capture?: string;
713
+ param?: string[];
714
+ fromScene?: string;
715
+ }
232
716
  ) => {
233
717
  const project = new Project(dir);
234
718
  await beginCommand(project, "record");
235
- const storyboard = await project.loadStoryboard({ params: parseParams(opts.param) });
719
+ const load = () => project.loadStoryboard({ params: parseParams(opts.param) });
720
+ const storyboard = await load();
721
+ logLint(lintStoryboard(storyboard), log);
236
722
  await record(project, storyboard, {
237
723
  profileDir: opts.profile,
724
+ fresh: opts.fresh,
725
+ ...seedOpts(opts),
726
+ reloadStoryboard: load,
238
727
  headed: !opts.headless,
239
728
  capture: parseCapture(opts.capture),
729
+ fromScene: opts.fromScene,
240
730
  });
241
731
  }
242
732
  );
@@ -245,6 +735,14 @@ program
245
735
  .command("probe")
246
736
  .argument("<dir>", "demo project directory")
247
737
  .option("--profile <dir>", "Chrome user-data dir (logged-in profile)")
738
+ .option(
739
+ "--fresh",
740
+ "wipe and use a throwaway profile for this take (no carried-over cookies/localStorage)",
741
+ false
742
+ )
743
+ .option("--storage-state <file>", STORAGE_STATE_OPT_DESC)
744
+ .option("--cookie <spec>", COOKIE_OPT_DESC, collectKv, [])
745
+ .option("--profile-seeded", PROFILE_SEEDED_OPT_DESC, false)
248
746
  .option("--headless", "run headless (not recommended for real Chrome)", false)
249
747
  .option(
250
748
  "--capture <mode>",
@@ -269,6 +767,10 @@ program
269
767
  dir: string,
270
768
  opts: {
271
769
  profile?: string;
770
+ fresh?: boolean;
771
+ storageState?: string;
772
+ cookie?: string[];
773
+ profileSeeded?: boolean;
272
774
  headless?: boolean;
273
775
  capture?: string;
274
776
  param?: string[];
@@ -279,14 +781,20 @@ program
279
781
  const project = new Project(dir);
280
782
  await beginCommand(project, "probe");
281
783
  // 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
- });
784
+ const load = () =>
785
+ project.loadStoryboard({
786
+ relaxed: true,
787
+ params: parseParams(opts.param),
788
+ });
789
+ const storyboard = await load();
790
+ logLint(lintStoryboard(storyboard), log);
286
791
  const goldenMode = !!(opts.golden || opts.updateGolden);
287
792
  const probeScenes: ProbeGoldenScene[] = [];
288
793
  await record(project, storyboard, {
289
794
  profileDir: opts.profile,
795
+ fresh: opts.fresh,
796
+ ...seedOpts(opts),
797
+ reloadStoryboard: load,
290
798
  headed: !opts.headless,
291
799
  capture: parseCapture(opts.capture),
292
800
  ...(goldenMode ? { probe: probeScenes } : {}),
@@ -317,6 +825,10 @@ program
317
825
  if (diffs.length > 0) {
318
826
  fail(`golden probe mismatch — ${diffs.length} field difference(s):`);
319
827
  for (const d of diffs) log(d);
828
+ const drift = await driftFilesForDiff(project, actual, diffs);
829
+ for (const f of drift) {
830
+ log(` selector drift → ${f.file} (scene ${f.scene}, action #${f.action}: nearest candidates)`);
831
+ }
320
832
  throw new Error(
321
833
  `probe --golden failed: the recorded flow drifted from ` +
322
834
  `${project.goldenProbePath} (${diffs.length} difference(s)). ` +
@@ -339,6 +851,7 @@ program
339
851
  .argument("<dir>", "demo project directory")
340
852
  .option("--scene <id>", "regenerate only this scene's narration")
341
853
  .option("--force", "re-synthesize every scene even if unchanged", false)
854
+ .option("--tts <provider>", TTS_OPT_DESC)
342
855
  .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
343
856
  .option("--lang <code>", LANG_OPT_DESC)
344
857
  .option("--langs <codes>", LANGS_OPT_DESC)
@@ -346,8 +859,16 @@ program
346
859
  .action(
347
860
  async (
348
861
  dir: string,
349
- opts: { scene?: string; force?: boolean; param?: string[]; lang?: string; langs?: string }
862
+ opts: {
863
+ scene?: string;
864
+ force?: boolean;
865
+ tts?: string;
866
+ param?: string[];
867
+ lang?: string;
868
+ langs?: string;
869
+ }
350
870
  ) => {
871
+ applyTtsFlag(opts.tts);
351
872
  const storyboard = await new Project(dir).loadStoryboard({
352
873
  params: parseParams(opts.param),
353
874
  });
@@ -444,6 +965,10 @@ program
444
965
  const sb = lang ? localizeStoryboard(storyboard, lang) : storyboard;
445
966
  await compose(project, sb);
446
967
  if (opts.gif) await exportGif(project);
968
+ // `output.walkthrough` is a pure post-process on the final video, so
969
+ // honour it here too (issue #45): a pipeline that drives the stages
970
+ // itself got chapters + poster and a silently ignored flag.
971
+ if (sb.output?.walkthrough) await exportWalkthrough(project, sb);
447
972
  }
448
973
  }
449
974
  );
@@ -514,12 +1039,21 @@ program
514
1039
  .command("render")
515
1040
  .argument("<dir>", "demo project directory")
516
1041
  .option("--profile <dir>", "Chrome user-data dir (logged-in profile)")
1042
+ .option(
1043
+ "--fresh",
1044
+ "wipe and use a throwaway profile for this take (no carried-over cookies/localStorage)",
1045
+ false
1046
+ )
1047
+ .option("--storage-state <file>", STORAGE_STATE_OPT_DESC)
1048
+ .option("--cookie <spec>", COOKIE_OPT_DESC, collectKv, [])
1049
+ .option("--profile-seeded", PROFILE_SEEDED_OPT_DESC, false)
517
1050
  .option("--headless", "run headless", false)
518
1051
  .option(
519
1052
  "--capture <mode>",
520
1053
  "capture path: playwright (default) | native (ffmpeg screen grab) | obs"
521
1054
  )
522
1055
  .option("--force-voice", "re-synthesize narration even if unchanged", false)
1056
+ .option("--tts <provider>", TTS_OPT_DESC)
523
1057
  .option("--gif", "also export output/final-demo.gif after the mux", false)
524
1058
  .option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
525
1059
  .option(
@@ -528,22 +1062,30 @@ program
528
1062
  )
529
1063
  .option("--lang <code>", LANG_OPT_DESC)
530
1064
  .option("--langs <codes>", `${LANGS_OPT_DESC} — records ONCE, then renders each`)
1065
+ .option("--from-scene <id>", FROM_SCENE_OPT_DESC)
531
1066
  .description("run the full pipeline: voice → record → captions → compose")
532
1067
  .action(
533
1068
  async (
534
1069
  dir: string,
535
1070
  opts: {
536
1071
  profile?: string;
1072
+ fresh?: boolean;
1073
+ storageState?: string;
1074
+ cookie?: string[];
1075
+ profileSeeded?: boolean;
537
1076
  headless?: boolean;
538
1077
  capture?: string;
539
1078
  forceVoice?: boolean;
1079
+ tts?: string;
540
1080
  gif?: boolean;
541
1081
  param?: string[];
542
1082
  variants?: string;
543
1083
  lang?: string;
544
1084
  langs?: string;
1085
+ fromScene?: string;
545
1086
  }
546
1087
  ) => {
1088
+ applyTtsFlag(opts.tts);
547
1089
  const base = new Project(dir);
548
1090
  await beginCommand(base, "render");
549
1091
 
@@ -553,6 +1095,8 @@ program
553
1095
  const results = await renderVariants(dir, variants, {
554
1096
  record: {
555
1097
  profileDir: opts.profile,
1098
+ fresh: opts.fresh,
1099
+ ...seedOpts(opts),
556
1100
  headed: !opts.headless,
557
1101
  capture: parseCapture(opts.capture),
558
1102
  },
@@ -575,9 +1119,12 @@ program
575
1119
  }
576
1120
  };
577
1121
 
578
- const storyboard = await base.loadStoryboard({ params: parseParams(opts.param) });
1122
+ const load = () => base.loadStoryboard({ params: parseParams(opts.param) });
1123
+ const storyboard = await load();
579
1124
  const langs = langsFrom(opts);
580
1125
  const multi = !(langs.length === 1 && langs[0] === undefined);
1126
+ // Browser-free preflight: pacing forecast + pitfalls, before TTS is paid for.
1127
+ for (const lang of langs) logLint(lintStoryboard(storyboard, { lang }), log);
581
1128
 
582
1129
  if (!multi) {
583
1130
  // Default single-language pipeline — voice → record → captions → compose.
@@ -589,8 +1136,12 @@ program
589
1136
  await stageLog(base, "record", () =>
590
1137
  record(base, storyboard, {
591
1138
  profileDir: opts.profile,
1139
+ fresh: opts.fresh,
1140
+ ...seedOpts(opts),
1141
+ reloadStoryboard: load,
592
1142
  headed: !opts.headless,
593
1143
  capture: parseCapture(opts.capture),
1144
+ fromScene: opts.fromScene,
594
1145
  })
595
1146
  );
596
1147
  await stageLog(base, "captions", () => captionsFor(base, storyboard));
@@ -601,6 +1152,9 @@ program
601
1152
  if (storyboardHasStills(storyboard)) {
602
1153
  await stageLog(base, "stills", () => extractStills(base));
603
1154
  }
1155
+ if (storyboard.output?.walkthrough) {
1156
+ await stageLog(base, "walkthrough", () => exportWalkthrough(base, storyboard));
1157
+ }
604
1158
  step("Done");
605
1159
  ok(`▶ open ${base.outputPath}`);
606
1160
  return;
@@ -612,8 +1166,12 @@ program
612
1166
  await stageLog(base, "record", () =>
613
1167
  record(base, storyboard, {
614
1168
  profileDir: opts.profile,
1169
+ fresh: opts.fresh,
1170
+ ...seedOpts(opts),
1171
+ reloadStoryboard: load,
615
1172
  headed: !opts.headless,
616
1173
  capture: parseCapture(opts.capture),
1174
+ fromScene: opts.fromScene,
617
1175
  })
618
1176
  );
619
1177
  if (storyboardHasStills(storyboard)) {