@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/.claude/skills/record-demo/SKILL.md +5 -2
- package/README.md +16 -4
- package/docs/AUTHORING.md +609 -45
- 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 +70 -6
- package/src/capture.ts +113 -0
- package/src/cards.ts +15 -6
- package/src/cli.ts +580 -22
- package/src/compose.ts +1006 -93
- package/src/cursor-overlay.ts +7 -113
- package/src/cursor.ts +9 -3
- package/src/distribute.ts +37 -4
- package/src/expr.ts +130 -0
- package/src/ffmpeg.ts +156 -0
- package/src/frame.ts +205 -0
- package/src/frames.ts +159 -0
- package/src/golden.ts +27 -0
- package/src/guide.ts +102 -0
- package/src/i18n.ts +7 -4
- package/src/import-trace.ts +629 -0
- package/src/inspect.ts +307 -0
- package/src/lint.ts +601 -0
- package/src/login.ts +143 -0
- package/src/mcp/jobs.ts +4 -1
- package/src/mcp/server.ts +403 -26
- package/src/player.ts +975 -32
- package/src/profile.ts +128 -0
- package/src/project.ts +12 -2
- package/src/recorder.ts +252 -15
- package/src/setup.ts +248 -0
- package/src/starter.ts +244 -0
- package/src/stills.ts +15 -4
- package/src/types.ts +643 -5
- 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,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 {
|
|
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 { 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(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
131
|
-
|
|
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(
|
|
142
|
-
|
|
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: {
|
|
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
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
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: {
|
|
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
|
|
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)) {
|