@tandryukha/aidemo 0.8.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 +66 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/aidemo.mjs +53 -0
- package/docs/AUTHORING.md +728 -0
- package/package.json +47 -0
- package/src/caption-render.ts +69 -0
- package/src/captions.ts +441 -0
- package/src/capture.ts +574 -0
- package/src/cards.ts +67 -0
- package/src/cli.ts +646 -0
- package/src/compose.ts +937 -0
- package/src/config.ts +222 -0
- package/src/cursor-overlay.ts +186 -0
- package/src/cursor.ts +134 -0
- package/src/distribute.ts +713 -0
- package/src/embed.ts +202 -0
- package/src/ffmpeg.ts +83 -0
- package/src/gif.ts +65 -0
- package/src/golden.ts +109 -0
- package/src/i18n.ts +86 -0
- package/src/mcp/jobs.ts +285 -0
- package/src/mcp/server.ts +1016 -0
- package/src/music.ts +153 -0
- package/src/params.ts +134 -0
- package/src/player.ts +1173 -0
- package/src/project.ts +287 -0
- package/src/recorder.ts +320 -0
- package/src/starter.ts +141 -0
- package/src/stills.ts +121 -0
- package/src/types.ts +660 -0
- package/src/util.ts +171 -0
- package/src/variants.ts +117 -0
- package/src/voice-local.ts +230 -0
- package/src/voice.ts +405 -0
- package/src/zoom.ts +183 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { loadEnv, engineVersion, ENGINE_ROOT, captionsAutoOffline } from "./config.js";
|
|
5
|
+
import { Project } from "./project.js";
|
|
6
|
+
import { record } from "./recorder.js";
|
|
7
|
+
import {
|
|
8
|
+
buildProbeGolden,
|
|
9
|
+
diffGolden,
|
|
10
|
+
readProbeGolden,
|
|
11
|
+
writeProbeGolden,
|
|
12
|
+
} from "./golden.js";
|
|
13
|
+
import type { ProbeGoldenScene } from "./types.js";
|
|
14
|
+
import { generateVoice } from "./voice.js";
|
|
15
|
+
import { generateCaptions, generateCaptionsOffline } from "./captions.js";
|
|
16
|
+
import { compose } from "./compose.js";
|
|
17
|
+
import { exportGif } from "./gif.js";
|
|
18
|
+
import { buildEmbed, formatEmbed } from "./embed.js";
|
|
19
|
+
import { extractStills, storyboardHasStills } from "./stills.js";
|
|
20
|
+
import { localizeStoryboard, missingNarrations } from "./i18n.js";
|
|
21
|
+
import { synthesizeMusicBed } from "./music.js";
|
|
22
|
+
import { loadVariants, renderVariants } from "./variants.js";
|
|
23
|
+
import {
|
|
24
|
+
ensureDir,
|
|
25
|
+
ok,
|
|
26
|
+
step,
|
|
27
|
+
fail,
|
|
28
|
+
log,
|
|
29
|
+
setLogFile,
|
|
30
|
+
closeLogFile,
|
|
31
|
+
teeStageLog,
|
|
32
|
+
} from "./util.js";
|
|
33
|
+
import {
|
|
34
|
+
scaffoldDemo,
|
|
35
|
+
repoInit,
|
|
36
|
+
installSkill,
|
|
37
|
+
checkSkill,
|
|
38
|
+
feedback,
|
|
39
|
+
doctor,
|
|
40
|
+
} from "./distribute.js";
|
|
41
|
+
|
|
42
|
+
/** Ensure the project dirs exist and tee this command's output into logs/. */
|
|
43
|
+
async function beginCommand(project: Project, name: string): Promise<void> {
|
|
44
|
+
await project.ensureDirs();
|
|
45
|
+
setLogFile(project.p("logs", `${name}.log`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tee one composite-pipeline stage's output into its OWN stable
|
|
50
|
+
* logs/<stage>.log (freshly truncated), in addition to the composite
|
|
51
|
+
* command's own log (`render` → logs/render.log via beginCommand/
|
|
52
|
+
* setLogFile). Without this, `aidemo render` never touches logs/voice.log,
|
|
53
|
+
* logs/record.log, logs/captions.log or logs/compose.log — a log-watcher
|
|
54
|
+
* polling one of those between renders sees a stale line (e.g. an old
|
|
55
|
+
* `✓ final video → …`) from whatever standalone command last ran that stage.
|
|
56
|
+
*/
|
|
57
|
+
function stageLog<T>(project: Project, stage: string, fn: () => Promise<T>): Promise<T> {
|
|
58
|
+
return teeStageLog(project.p("logs", `${stage}.log`), fn);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await loadEnv();
|
|
62
|
+
|
|
63
|
+
const program = new Command();
|
|
64
|
+
program
|
|
65
|
+
.name("aidemo")
|
|
66
|
+
.description("AI Demo Engine — storyboard → narrated, captioned demo video")
|
|
67
|
+
.version(engineVersion());
|
|
68
|
+
|
|
69
|
+
program
|
|
70
|
+
.command("init")
|
|
71
|
+
.argument("<name>", "demo name (creates ./demos/<name>/)")
|
|
72
|
+
.option("--force", "overwrite an existing demos/<name>/", false)
|
|
73
|
+
.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}`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
program
|
|
82
|
+
.command("repo-init")
|
|
83
|
+
.option("--dir <dir>", "target repo (default: current directory)")
|
|
84
|
+
.option("--force", "overwrite existing skill/settings/example", false)
|
|
85
|
+
.description("bootstrap a repo to use record-demo (skill + update hook + example)")
|
|
86
|
+
.action(async (opts: { dir?: string; force?: boolean }) => {
|
|
87
|
+
await repoInit(opts.dir, { force: opts.force });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const skill = program
|
|
91
|
+
.command("skill")
|
|
92
|
+
.description("manage the installed record-demo skill copy");
|
|
93
|
+
skill
|
|
94
|
+
.command("install")
|
|
95
|
+
.option("--dir <dir>", "target repo (default: current directory)")
|
|
96
|
+
.option("--force", "overwrite an existing skill copy", false)
|
|
97
|
+
.description("copy the current record-demo skill into this repo + stamp installed.json")
|
|
98
|
+
.action(async (opts: { dir?: string; force?: boolean }) => {
|
|
99
|
+
await installSkill(opts.dir, { force: opts.force });
|
|
100
|
+
});
|
|
101
|
+
skill
|
|
102
|
+
.command("update")
|
|
103
|
+
.option("--dir <dir>", "target repo (default: current directory)")
|
|
104
|
+
.description("update the installed skill copy to this engine's version")
|
|
105
|
+
.action(async (opts: { dir?: string }) => {
|
|
106
|
+
await installSkill(opts.dir, { force: true });
|
|
107
|
+
});
|
|
108
|
+
skill
|
|
109
|
+
.command("check")
|
|
110
|
+
.option("--dir <dir>", "target repo (default: current directory)")
|
|
111
|
+
.option("--verbose", "also print when up to date / not installed", false)
|
|
112
|
+
.description("notify if a newer skill is available (used by the SessionStart hook)")
|
|
113
|
+
.action(async (opts: { dir?: string; verbose?: boolean }) => {
|
|
114
|
+
await checkSkill(opts.dir, { verbose: opts.verbose });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
program
|
|
118
|
+
.command("mcp")
|
|
119
|
+
.description("run the stdio MCP server — the agent interface (stdout = JSON-RPC)")
|
|
120
|
+
.action(async () => {
|
|
121
|
+
const { runMcpServer } = await import("./mcp/server.js");
|
|
122
|
+
await runMcpServer();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
program
|
|
126
|
+
.command("guide")
|
|
127
|
+
.description("print the agent-neutral authoring guide (docs/AUTHORING.md)")
|
|
128
|
+
.action(async () => {
|
|
129
|
+
// 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
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
program
|
|
136
|
+
.command("feedback")
|
|
137
|
+
.argument("[dir]", "demo project directory (adds storyboard + log context)")
|
|
138
|
+
.option("--web", "open a prefilled New Issue page instead of filing directly", false)
|
|
139
|
+
.option("--dry-run", "print the issue title/body without filing", false)
|
|
140
|
+
.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
|
+
});
|
|
144
|
+
|
|
145
|
+
program
|
|
146
|
+
.command("doctor")
|
|
147
|
+
.option("--dir <dir>", "repo to check for an installed skill (default: current directory)")
|
|
148
|
+
.description("check prereqs (ffmpeg/chrome/openai key) + installed-vs-stable skill version")
|
|
149
|
+
.action(async (opts: { dir?: string }) => {
|
|
150
|
+
await doctor(opts.dir);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
function parsePositiveInt(name: string, value: string): number {
|
|
154
|
+
const n = Number(value);
|
|
155
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
156
|
+
throw new Error(`${name} must be a positive integer (got "${value}")`);
|
|
157
|
+
return n;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseCapture(value?: string): "playwright" | "native" | "obs" | undefined {
|
|
161
|
+
if (value == null) return undefined;
|
|
162
|
+
if (value === "playwright" || value === "native" || value === "obs") return value;
|
|
163
|
+
throw new Error(`--capture must be playwright, native or obs (got "${value}")`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Commander collector for a repeatable option, accumulating into an array. */
|
|
167
|
+
function collectKv(value: string, previous: string[]): string[] {
|
|
168
|
+
return previous.concat([value]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Parse repeated `--param key=value` pairs into a params record (or undefined). */
|
|
172
|
+
function parseParams(pairs?: string[]): Record<string, string> | undefined {
|
|
173
|
+
if (!pairs || !pairs.length) return undefined;
|
|
174
|
+
const out: Record<string, string> = {};
|
|
175
|
+
for (const p of pairs) {
|
|
176
|
+
const eq = p.indexOf("=");
|
|
177
|
+
if (eq <= 0) throw new Error(`--param must be key=value (got "${p}")`);
|
|
178
|
+
out[p.slice(0, eq).trim()] = p.slice(eq + 1);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const PARAM_OPT_DESC =
|
|
184
|
+
"set a storyboard template param (key=value; repeatable; must be declared in the storyboard's params block)";
|
|
185
|
+
|
|
186
|
+
const LANG_OPT_DESC =
|
|
187
|
+
"render a language variant from scene.narrations[<code>] over the shared take " +
|
|
188
|
+
"(artifacts namespaced: audio/<code>/, captions.<code>.*, output/final-demo.<code>.mp4)";
|
|
189
|
+
const LANGS_OPT_DESC =
|
|
190
|
+
"comma-separated language matrix (e.g. de,fr) — each rendered like --lang";
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Languages to run this invocation, resolved from --lang / --langs. Returns
|
|
194
|
+
* `[undefined]` when neither is given — the default single-language render,
|
|
195
|
+
* byte-for-byte unchanged (undefined = the base storyboard, unsuffixed paths).
|
|
196
|
+
*/
|
|
197
|
+
function langsFrom(opts: { lang?: string; langs?: string }): Array<string | undefined> {
|
|
198
|
+
const list = [
|
|
199
|
+
...(opts.langs ? opts.langs.split(",") : []),
|
|
200
|
+
...(opts.lang ? [opts.lang] : []),
|
|
201
|
+
]
|
|
202
|
+
.map((s) => s.trim())
|
|
203
|
+
.filter(Boolean);
|
|
204
|
+
return list.length ? [...new Set(list)] : [undefined];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Warn (don't fail) when a language variant is only partially translated. */
|
|
208
|
+
function warnPartialCoverage(storyboard: Parameters<typeof missingNarrations>[0], lang: string): void {
|
|
209
|
+
const missing = missingNarrations(storyboard, lang);
|
|
210
|
+
if (missing.length)
|
|
211
|
+
log(
|
|
212
|
+
`⚠ lang "${lang}": scene(s) ${missing.join(", ")} have no narrations[${lang}] — ` +
|
|
213
|
+
`using the base narration`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
program
|
|
218
|
+
.command("record")
|
|
219
|
+
.argument("<dir>", "demo project directory")
|
|
220
|
+
.option("--profile <dir>", "Chrome user-data dir (logged-in profile)")
|
|
221
|
+
.option("--headless", "run headless (not recommended for real Chrome)", false)
|
|
222
|
+
.option(
|
|
223
|
+
"--capture <mode>",
|
|
224
|
+
"capture path: playwright (default) | native (ffmpeg screen grab) | obs"
|
|
225
|
+
)
|
|
226
|
+
.option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
|
|
227
|
+
.description("drive the storyboard in Chrome and record raw video + timeline.json")
|
|
228
|
+
.action(
|
|
229
|
+
async (
|
|
230
|
+
dir: string,
|
|
231
|
+
opts: { profile?: string; headless?: boolean; capture?: string; param?: string[] }
|
|
232
|
+
) => {
|
|
233
|
+
const project = new Project(dir);
|
|
234
|
+
await beginCommand(project, "record");
|
|
235
|
+
const storyboard = await project.loadStoryboard({ params: parseParams(opts.param) });
|
|
236
|
+
await record(project, storyboard, {
|
|
237
|
+
profileDir: opts.profile,
|
|
238
|
+
headed: !opts.headless,
|
|
239
|
+
capture: parseCapture(opts.capture),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
program
|
|
245
|
+
.command("probe")
|
|
246
|
+
.argument("<dir>", "demo project directory")
|
|
247
|
+
.option("--profile <dir>", "Chrome user-data dir (logged-in profile)")
|
|
248
|
+
.option("--headless", "run headless (not recommended for real Chrome)", false)
|
|
249
|
+
.option(
|
|
250
|
+
"--capture <mode>",
|
|
251
|
+
"capture path: playwright (default) | native (ffmpeg screen grab) | obs"
|
|
252
|
+
)
|
|
253
|
+
.option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
|
|
254
|
+
.option(
|
|
255
|
+
"--update-golden",
|
|
256
|
+
"write golden/probe.json from this probe (commit it as the regression baseline)",
|
|
257
|
+
false
|
|
258
|
+
)
|
|
259
|
+
.option(
|
|
260
|
+
"--golden",
|
|
261
|
+
"compare this probe against golden/probe.json; readable diff + non-zero exit on drift (CI)",
|
|
262
|
+
false
|
|
263
|
+
)
|
|
264
|
+
.description(
|
|
265
|
+
"record-only dry run to verify selectors/timing (narration optional)"
|
|
266
|
+
)
|
|
267
|
+
.action(
|
|
268
|
+
async (
|
|
269
|
+
dir: string,
|
|
270
|
+
opts: {
|
|
271
|
+
profile?: string;
|
|
272
|
+
headless?: boolean;
|
|
273
|
+
capture?: string;
|
|
274
|
+
param?: string[];
|
|
275
|
+
golden?: boolean;
|
|
276
|
+
updateGolden?: boolean;
|
|
277
|
+
}
|
|
278
|
+
) => {
|
|
279
|
+
const project = new Project(dir);
|
|
280
|
+
await beginCommand(project, "probe");
|
|
281
|
+
// 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
|
+
});
|
|
286
|
+
const goldenMode = !!(opts.golden || opts.updateGolden);
|
|
287
|
+
const probeScenes: ProbeGoldenScene[] = [];
|
|
288
|
+
await record(project, storyboard, {
|
|
289
|
+
profileDir: opts.profile,
|
|
290
|
+
headed: !opts.headless,
|
|
291
|
+
capture: parseCapture(opts.capture),
|
|
292
|
+
...(goldenMode ? { probe: probeScenes } : {}),
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (opts.updateGolden) {
|
|
296
|
+
const golden = buildProbeGolden(storyboard, probeScenes);
|
|
297
|
+
await writeProbeGolden(project, golden);
|
|
298
|
+
const actions = golden.scenes.reduce((a, s) => a + s.actions.length, 0);
|
|
299
|
+
step("Probe golden updated");
|
|
300
|
+
ok(
|
|
301
|
+
`golden → ${project.goldenProbePath} (${golden.scenes.length} scenes, ${actions} actions)`
|
|
302
|
+
);
|
|
303
|
+
ok(`commit it, then 'aidemo probe ${dir} --golden' guards the flow in CI`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (opts.golden) {
|
|
308
|
+
const expected = await readProbeGolden(project);
|
|
309
|
+
if (!expected) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`no golden baseline at ${project.goldenProbePath} — create it first: ` +
|
|
312
|
+
`aidemo probe ${dir} --update-golden`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const actual = buildProbeGolden(storyboard, probeScenes);
|
|
316
|
+
const diffs = diffGolden(expected, actual);
|
|
317
|
+
if (diffs.length > 0) {
|
|
318
|
+
fail(`golden probe mismatch — ${diffs.length} field difference(s):`);
|
|
319
|
+
for (const d of diffs) log(d);
|
|
320
|
+
throw new Error(
|
|
321
|
+
`probe --golden failed: the recorded flow drifted from ` +
|
|
322
|
+
`${project.goldenProbePath} (${diffs.length} difference(s)). ` +
|
|
323
|
+
`Re-run with --update-golden if this change is intended.`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
step("Probe golden matches");
|
|
327
|
+
ok(`flow unchanged vs ${project.goldenProbePath}`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
step("Probe done");
|
|
332
|
+
ok(`inspect ${project.rawVideoPath} + ${project.timelinePath}`);
|
|
333
|
+
ok(`any failure left a screenshot + frame dump in ${project.p("logs")}`);
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
program
|
|
338
|
+
.command("voice")
|
|
339
|
+
.argument("<dir>", "demo project directory")
|
|
340
|
+
.option("--scene <id>", "regenerate only this scene's narration")
|
|
341
|
+
.option("--force", "re-synthesize every scene even if unchanged", false)
|
|
342
|
+
.option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
|
|
343
|
+
.option("--lang <code>", LANG_OPT_DESC)
|
|
344
|
+
.option("--langs <codes>", LANGS_OPT_DESC)
|
|
345
|
+
.description("generate per-scene TTS and assemble narration.mp3 + voice.json")
|
|
346
|
+
.action(
|
|
347
|
+
async (
|
|
348
|
+
dir: string,
|
|
349
|
+
opts: { scene?: string; force?: boolean; param?: string[]; lang?: string; langs?: string }
|
|
350
|
+
) => {
|
|
351
|
+
const storyboard = await new Project(dir).loadStoryboard({
|
|
352
|
+
params: parseParams(opts.param),
|
|
353
|
+
});
|
|
354
|
+
for (const lang of langsFrom(opts)) {
|
|
355
|
+
const project = new Project(dir, lang);
|
|
356
|
+
await beginCommand(project, "voice");
|
|
357
|
+
const sb = lang ? localizeStoryboard(storyboard, lang) : storyboard;
|
|
358
|
+
if (lang) warnPartialCoverage(storyboard, lang);
|
|
359
|
+
await generateVoice(project, sb, { only: opts.scene, force: opts.force });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
program
|
|
365
|
+
.command("music")
|
|
366
|
+
.argument("[out]", "output .wav path", "assets/music.wav")
|
|
367
|
+
.description("synthesize a license-free background-music bed (no samples)")
|
|
368
|
+
.action(async (out: string) => {
|
|
369
|
+
const outPath = resolve(process.cwd(), out);
|
|
370
|
+
await ensureDir(dirname(outPath));
|
|
371
|
+
step("Synthesizing music bed");
|
|
372
|
+
const { durationSec } = await synthesizeMusicBed(outPath);
|
|
373
|
+
ok(`music → ${outPath} (${durationSec.toFixed(1)}s, license-free)`);
|
|
374
|
+
ok(`use it via storyboard "music": { "track": "${out}" }`);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
program
|
|
378
|
+
.command("captions")
|
|
379
|
+
.argument("<dir>", "demo project directory")
|
|
380
|
+
.option(
|
|
381
|
+
"--offline",
|
|
382
|
+
"generate approximate captions from the storyboard script + voice.json timings — no network/STT",
|
|
383
|
+
false
|
|
384
|
+
)
|
|
385
|
+
.option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
|
|
386
|
+
.option("--lang <code>", LANG_OPT_DESC)
|
|
387
|
+
.option("--langs <codes>", LANGS_OPT_DESC)
|
|
388
|
+
.option(
|
|
389
|
+
"--stt-lang <code>",
|
|
390
|
+
"override the Whisper STT language hint (ISO-639-1, e.g. et) — defaults to " +
|
|
391
|
+
"--lang or the storyboard's own \"language\" field; ignored with --offline"
|
|
392
|
+
)
|
|
393
|
+
.description("transcribe narration.mp3 to captions.srt/vtt with word timing")
|
|
394
|
+
.action(
|
|
395
|
+
async (
|
|
396
|
+
dir: string,
|
|
397
|
+
opts: {
|
|
398
|
+
offline?: boolean;
|
|
399
|
+
param?: string[];
|
|
400
|
+
lang?: string;
|
|
401
|
+
langs?: string;
|
|
402
|
+
sttLang?: string;
|
|
403
|
+
}
|
|
404
|
+
) => {
|
|
405
|
+
const langs = langsFrom(opts);
|
|
406
|
+
// The storyboard is always needed now: --offline derives cues from it,
|
|
407
|
+
// and the default STT path biases the Whisper request with its
|
|
408
|
+
// narration text + language (see src/captions.ts).
|
|
409
|
+
const base = await new Project(dir).loadStoryboard({
|
|
410
|
+
params: parseParams(opts.param),
|
|
411
|
+
});
|
|
412
|
+
for (const lang of langs) {
|
|
413
|
+
const project = new Project(dir, lang);
|
|
414
|
+
await beginCommand(project, "captions");
|
|
415
|
+
const sb = lang ? localizeStoryboard(base, lang) : base;
|
|
416
|
+
if (opts.offline) {
|
|
417
|
+
await generateCaptionsOffline(project, sb);
|
|
418
|
+
} else {
|
|
419
|
+
await generateCaptions(project, sb, { language: opts.sttLang });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
program
|
|
426
|
+
.command("compose")
|
|
427
|
+
.argument("<dir>", "demo project directory")
|
|
428
|
+
.option("--gif", "also export output/final-demo.gif after the mux", false)
|
|
429
|
+
.option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
|
|
430
|
+
.option("--lang <code>", LANG_OPT_DESC)
|
|
431
|
+
.option("--langs <codes>", LANGS_OPT_DESC)
|
|
432
|
+
.description("trim, sync, mux and caption into output/final-demo.mp4")
|
|
433
|
+
.action(
|
|
434
|
+
async (
|
|
435
|
+
dir: string,
|
|
436
|
+
opts: { gif?: boolean; param?: string[]; lang?: string; langs?: string }
|
|
437
|
+
) => {
|
|
438
|
+
const storyboard = await new Project(dir).loadStoryboard({
|
|
439
|
+
params: parseParams(opts.param),
|
|
440
|
+
});
|
|
441
|
+
for (const lang of langsFrom(opts)) {
|
|
442
|
+
const project = new Project(dir, lang);
|
|
443
|
+
await beginCommand(project, "compose");
|
|
444
|
+
const sb = lang ? localizeStoryboard(storyboard, lang) : storyboard;
|
|
445
|
+
await compose(project, sb);
|
|
446
|
+
if (opts.gif) await exportGif(project);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
program
|
|
452
|
+
.command("gif")
|
|
453
|
+
.argument("<dir>", "demo project directory")
|
|
454
|
+
.option("--width <px>", "output width in pixels", "960")
|
|
455
|
+
.option("--fps <n>", "GIF frame rate", "12")
|
|
456
|
+
.option("--out <path>", "output path (default: <dir>/output/final-demo.gif)")
|
|
457
|
+
.description("convert output/final-demo.mp4 to a README-ready GIF")
|
|
458
|
+
.action(
|
|
459
|
+
async (dir: string, opts: { width: string; fps: string; out?: string }) => {
|
|
460
|
+
const project = new Project(dir);
|
|
461
|
+
await beginCommand(project, "gif");
|
|
462
|
+
await exportGif(project, {
|
|
463
|
+
width: parsePositiveInt("--width", opts.width),
|
|
464
|
+
fps: parsePositiveInt("--fps", opts.fps),
|
|
465
|
+
out: opts.out,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
program
|
|
471
|
+
.command("embed")
|
|
472
|
+
.argument("<dir>", "demo project directory (its basename is the demo name)")
|
|
473
|
+
.option(
|
|
474
|
+
"--repo <dir>",
|
|
475
|
+
"consuming repo to detect owner/repo from (default: current directory)"
|
|
476
|
+
)
|
|
477
|
+
.option("--still <name>", "still-frame basename under stills/", "poster")
|
|
478
|
+
.option("--json", "print machine-readable JSON instead of snippets", false)
|
|
479
|
+
.description(
|
|
480
|
+
"print ready-to-paste always-fresh embed snippets (stable raw GitHub URLs)"
|
|
481
|
+
)
|
|
482
|
+
.action(
|
|
483
|
+
async (
|
|
484
|
+
dir: string,
|
|
485
|
+
opts: { repo?: string; still?: string; json?: boolean }
|
|
486
|
+
) => {
|
|
487
|
+
// Pure string generation — no logs/, no storyboard load, stdout only.
|
|
488
|
+
const result = await buildEmbed(dir, {
|
|
489
|
+
repoDir: opts.repo,
|
|
490
|
+
still: opts.still,
|
|
491
|
+
});
|
|
492
|
+
process.stdout.write(
|
|
493
|
+
opts.json
|
|
494
|
+
? JSON.stringify(result, null, 2) + "\n"
|
|
495
|
+
: formatEmbed(result)
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
program
|
|
501
|
+
.command("stills")
|
|
502
|
+
.argument("<dir>", "demo project directory")
|
|
503
|
+
.option("--out <dir>", "output directory (default: <dir>/output/stills)")
|
|
504
|
+
.description("extract named stills (screenshot mode) from the recorded take")
|
|
505
|
+
.action(async (dir: string, opts: { out?: string }) => {
|
|
506
|
+
const project = new Project(dir);
|
|
507
|
+
await beginCommand(project, "stills");
|
|
508
|
+
// Stills come straight from timeline.json + the raw take — no key, no
|
|
509
|
+
// storyboard reload, so this works against any existing recording.
|
|
510
|
+
await extractStills(project, { outDir: opts.out });
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
program
|
|
514
|
+
.command("render")
|
|
515
|
+
.argument("<dir>", "demo project directory")
|
|
516
|
+
.option("--profile <dir>", "Chrome user-data dir (logged-in profile)")
|
|
517
|
+
.option("--headless", "run headless", false)
|
|
518
|
+
.option(
|
|
519
|
+
"--capture <mode>",
|
|
520
|
+
"capture path: playwright (default) | native (ffmpeg screen grab) | obs"
|
|
521
|
+
)
|
|
522
|
+
.option("--force-voice", "re-synthesize narration even if unchanged", false)
|
|
523
|
+
.option("--gif", "also export output/final-demo.gif after the mux", false)
|
|
524
|
+
.option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
|
|
525
|
+
.option(
|
|
526
|
+
"--variants <file>",
|
|
527
|
+
"render one full pipeline per entry of a variants JSON file → output/variants/<name>/"
|
|
528
|
+
)
|
|
529
|
+
.option("--lang <code>", LANG_OPT_DESC)
|
|
530
|
+
.option("--langs <codes>", `${LANGS_OPT_DESC} — records ONCE, then renders each`)
|
|
531
|
+
.description("run the full pipeline: voice → record → captions → compose")
|
|
532
|
+
.action(
|
|
533
|
+
async (
|
|
534
|
+
dir: string,
|
|
535
|
+
opts: {
|
|
536
|
+
profile?: string;
|
|
537
|
+
headless?: boolean;
|
|
538
|
+
capture?: string;
|
|
539
|
+
forceVoice?: boolean;
|
|
540
|
+
gif?: boolean;
|
|
541
|
+
param?: string[];
|
|
542
|
+
variants?: string;
|
|
543
|
+
lang?: string;
|
|
544
|
+
langs?: string;
|
|
545
|
+
}
|
|
546
|
+
) => {
|
|
547
|
+
const base = new Project(dir);
|
|
548
|
+
await beginCommand(base, "render");
|
|
549
|
+
|
|
550
|
+
// Variants matrix: one isolated full render per entry (params differ).
|
|
551
|
+
if (opts.variants) {
|
|
552
|
+
const variants = await loadVariants(opts.variants);
|
|
553
|
+
const results = await renderVariants(dir, variants, {
|
|
554
|
+
record: {
|
|
555
|
+
profileDir: opts.profile,
|
|
556
|
+
headed: !opts.headless,
|
|
557
|
+
capture: parseCapture(opts.capture),
|
|
558
|
+
},
|
|
559
|
+
forceVoice: opts.forceVoice,
|
|
560
|
+
});
|
|
561
|
+
step("Done");
|
|
562
|
+
ok(`▶ ${results.length} variant(s) under ${base.p("output", "variants")}`);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const captionsFor = async (
|
|
567
|
+
project: Project,
|
|
568
|
+
sb: Awaited<ReturnType<Project["loadStoryboard"]>>
|
|
569
|
+
) => {
|
|
570
|
+
if (captionsAutoOffline()) {
|
|
571
|
+
log("local TTS and no STT endpoint/key — deriving captions offline from the script");
|
|
572
|
+
await generateCaptionsOffline(project, sb);
|
|
573
|
+
} else {
|
|
574
|
+
await generateCaptions(project, sb);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const storyboard = await base.loadStoryboard({ params: parseParams(opts.param) });
|
|
579
|
+
const langs = langsFrom(opts);
|
|
580
|
+
const multi = !(langs.length === 1 && langs[0] === undefined);
|
|
581
|
+
|
|
582
|
+
if (!multi) {
|
|
583
|
+
// Default single-language pipeline — voice → record → captions → compose.
|
|
584
|
+
// Each stage also gets its own stable logs/<stage>.log (in addition to
|
|
585
|
+
// this run's logs/render.log) — see stageLog's doc comment.
|
|
586
|
+
await stageLog(base, "voice", () =>
|
|
587
|
+
generateVoice(base, storyboard, { force: opts.forceVoice })
|
|
588
|
+
);
|
|
589
|
+
await stageLog(base, "record", () =>
|
|
590
|
+
record(base, storyboard, {
|
|
591
|
+
profileDir: opts.profile,
|
|
592
|
+
headed: !opts.headless,
|
|
593
|
+
capture: parseCapture(opts.capture),
|
|
594
|
+
})
|
|
595
|
+
);
|
|
596
|
+
await stageLog(base, "captions", () => captionsFor(base, storyboard));
|
|
597
|
+
await stageLog(base, "compose", () => compose(base, storyboard));
|
|
598
|
+
if (opts.gif) await stageLog(base, "gif", () => exportGif(base));
|
|
599
|
+
// Screenshot mode: emit any `still` PNGs from the clean take (a
|
|
600
|
+
// re-extract, not a re-record).
|
|
601
|
+
if (storyboardHasStills(storyboard)) {
|
|
602
|
+
await stageLog(base, "stills", () => extractStills(base));
|
|
603
|
+
}
|
|
604
|
+
step("Done");
|
|
605
|
+
ok(`▶ open ${base.outputPath}`);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Multi-language: record the SHARED take ONCE (language-independent), then
|
|
610
|
+
// voice + captions + compose per language over the same footage. Stills
|
|
611
|
+
// come from the shared clean take, so extract them once from the base.
|
|
612
|
+
await stageLog(base, "record", () =>
|
|
613
|
+
record(base, storyboard, {
|
|
614
|
+
profileDir: opts.profile,
|
|
615
|
+
headed: !opts.headless,
|
|
616
|
+
capture: parseCapture(opts.capture),
|
|
617
|
+
})
|
|
618
|
+
);
|
|
619
|
+
if (storyboardHasStills(storyboard)) {
|
|
620
|
+
await stageLog(base, "stills", () => extractStills(base));
|
|
621
|
+
}
|
|
622
|
+
for (const lang of langs) {
|
|
623
|
+
const project = new Project(dir, lang!);
|
|
624
|
+
await beginCommand(project, "render");
|
|
625
|
+
const sb = localizeStoryboard(storyboard, lang!);
|
|
626
|
+
warnPartialCoverage(storyboard, lang!);
|
|
627
|
+
await stageLog(project, "voice", () =>
|
|
628
|
+
generateVoice(project, sb, { force: opts.forceVoice })
|
|
629
|
+
);
|
|
630
|
+
await stageLog(project, "captions", () => captionsFor(project, sb));
|
|
631
|
+
await stageLog(project, "compose", () => compose(project, sb));
|
|
632
|
+
if (opts.gif) await stageLog(project, "gif", () => exportGif(project));
|
|
633
|
+
ok(`▶ open ${project.outputPath}`);
|
|
634
|
+
}
|
|
635
|
+
step("Done");
|
|
636
|
+
}
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
await program.parseAsync(process.argv);
|
|
641
|
+
await closeLogFile();
|
|
642
|
+
} catch (err) {
|
|
643
|
+
fail((err as Error).message);
|
|
644
|
+
await closeLogFile();
|
|
645
|
+
process.exit(1);
|
|
646
|
+
}
|