@tandryukha/aidemo 0.8.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/record-demo/SKILL.md +5 -2
- package/README.md +16 -4
- package/docs/AUTHORING.md +734 -48
- package/package.json +3 -2
- package/src/anchors.ts +128 -0
- package/src/attention.ts +365 -0
- package/src/caption-render.ts +17 -8
- package/src/captions.ts +244 -36
- package/src/capture.ts +113 -0
- package/src/cards.ts +15 -6
- package/src/cli.ts +711 -26
- package/src/compose.ts +1318 -101
- package/src/cursor-overlay.ts +7 -113
- package/src/cursor.ts +19 -4
- package/src/distribute.ts +37 -4
- package/src/expr.ts +130 -0
- package/src/ffmpeg.ts +224 -0
- package/src/frame.ts +205 -0
- package/src/frames.ts +208 -0
- package/src/golden.ts +27 -0
- package/src/guide.ts +102 -0
- package/src/i18n.ts +7 -4
- package/src/import-trace.ts +640 -0
- package/src/inspect.ts +316 -0
- package/src/lint.ts +673 -0
- package/src/login.ts +143 -0
- package/src/mcp/jobs.ts +5 -1
- package/src/mcp/server.ts +468 -27
- package/src/player.ts +1286 -41
- package/src/profile.ts +128 -0
- package/src/project.ts +12 -2
- package/src/qa.ts +295 -0
- package/src/recorder.ts +316 -21
- package/src/safe-regex.ts +36 -0
- package/src/setup.ts +248 -0
- package/src/starter.ts +244 -0
- package/src/stills.ts +15 -4
- package/src/types.ts +796 -22
- package/src/variants.ts +1 -1
- package/src/voice-local.ts +7 -1
- package/src/voice.ts +60 -1
- package/src/walkthrough.ts +250 -0
- package/src/zoom.ts +85 -39
package/src/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 {
|
|
5
|
-
|
|
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(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
131
|
-
|
|
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(
|
|
142
|
-
|
|
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: {
|
|
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
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
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: {
|
|
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
|
|
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", () =>
|
|
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", () =>
|
|
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
|
|