@odori/cli 0.0.10 → 0.0.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "type": "module",
5
5
  "description": "The odori command line: discovery, Studio, component installation, stills, tests, and export jobs.",
6
6
  "license": "Apache-2.0",
@@ -28,7 +28,7 @@
28
28
  "playwright-core": "1.55.0",
29
29
  "tsx": "4.20.5",
30
30
  "vite": "7.3.0",
31
- "odori": "0.0.10"
31
+ "odori": "0.0.12"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "22.19.0",
@@ -36,7 +36,7 @@
36
36
  "@types/react-dom": "19.2.3",
37
37
  "tsup": "^8.5.1",
38
38
  "typescript": "5.9.3",
39
- "@odori/registry": "0.0.9"
39
+ "@odori/registry": "0.0.12"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"
@@ -45,6 +45,7 @@
45
45
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.studio.json",
46
46
  "build": "tsup",
47
47
  "snapshot": "tsx scripts/snapshot.ts",
48
- "verify:package": "tsx scripts/verify-package.ts"
48
+ "verify:package": "tsx scripts/verify-package.ts",
49
+ "snapshot:docs": "tsx scripts/docs-snapshot.ts"
49
50
  }
50
51
  }
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import {log} from "./log";
3
3
  import {addCommand, registryCommand} from "./commands/add";
4
4
  import {diffCommand, updateCommand} from "./commands/update";
5
5
  import {devCommand} from "./commands/dev";
6
+ import {docsCommand} from "./commands/docs";
6
7
  import {doctorCommand} from "./commands/doctor";
7
8
  import {installCommand} from "./binaries";
8
9
  import {exportCommand, jobsCommand} from "./commands/exportVideo";
@@ -12,8 +13,10 @@ import {inspectCommand} from "./commands/inspect";
12
13
  import {listCommand} from "./commands/list";
13
14
  import {newCommand} from "./commands/new";
14
15
  import {bedCommand} from "./commands/bed";
16
+ import {blocksCommand} from "./commands/blocks";
15
17
  import {narrateCommand} from "./commands/narrate";
16
18
  import {frameCommand} from "./commands/frame";
19
+ import {graphCommand} from "./commands/graph";
17
20
  import {testCommand} from "./commands/test";
18
21
 
19
22
  type Flags = Record<string, string | boolean>;
@@ -100,12 +103,13 @@ const COMMAND_FLAGS: Record<string, string[]> = {
100
103
  diff: ["full"],
101
104
  update: ["force"],
102
105
  list: [],
106
+ graph: ["json"],
103
107
  inspect: ["json", "input"],
104
108
  frame: ["at", "output", "input"],
105
109
  bed: ["role", "output", "target", "generate", "provider", "seconds"],
106
110
  narrate: ["output", "voice", "role", "provider"],
107
111
  test: ["json"],
108
- export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
112
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "audio-variant", "fast", "no-frame-skip", "retry"],
109
113
  jobs: [],
110
114
  help: [],
111
115
  };
@@ -184,6 +188,11 @@ const USAGE: Record<string, string> = {
184
188
  add: `odori add <components...> [--force] [--dry-run]
185
189
  Install editable component source, fetched from the registry and cached.
186
190
  --force replaces local edits. --dry-run lists the files and writes nothing.`,
191
+ blocks: `odori blocks <ls|show|cat|add> [id] [file] [--out <dir>] [--force] [--dry-run] [--json]
192
+ Whole videos, published like components. ls lists them, show names the files
193
+ and components one is built from, cat prints one of those files, and add
194
+ installs the block and everything it imports. --out installs into another
195
+ directory rather than the one the block was authored in.`,
187
196
  registry: `odori registry
188
197
  List available registry components and cues.`,
189
198
  diff: `odori diff [components] [--full]
@@ -192,6 +201,11 @@ const USAGE: Record<string, string> = {
192
201
  Apply upstream component changes.`,
193
202
  list: `odori list
194
203
  Print discovered video ids and formats.`,
204
+ graph: `odori graph [--json]
205
+ Compile the project into .odori/graph.json: every video with its format,
206
+ duration, brand, tags, audio variants, and components, plus the component
207
+ catalog, the audio library, and structure findings. --json prints the same
208
+ document to stdout. Exits non-zero when the structure has errors.`,
195
209
  inspect: `odori inspect <id> [--json] [--input <json>]
196
210
  Show resolved layout, inputs, scenes, and assets.`,
197
211
  frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
@@ -202,7 +216,7 @@ const USAGE: Record<string, string> = {
202
216
  check, for CI.`,
203
217
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
204
218
  [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
205
- [--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
219
+ [--no-audio] [--audio-variant <name>] [--fast] [--no-frame-skip] [--retry <job>]
206
220
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
207
221
  or png; without it the output's extension decides, and mp4 is the default.
208
222
  --quality is studio, social, or web. --scale multiplies the output size,
@@ -221,14 +235,17 @@ const HELP = `odori - build videos like applications
221
235
  Usage
222
236
  odori dev [--port 4300] Discover project resources and start Studio
223
237
  odori init Add videos/ and odori.config.ts to a project
238
+ odori docs [page|search <text>] Read the documentation offline
224
239
  odori doctor Check everything a render and an encode need
225
240
  odori install Download the pinned Chrome and FFmpeg
226
241
  odori new <name> Generate a video.tsx entry
227
242
  odori add <components...> Install editable component source
243
+ odori blocks <ls|show|add> Browse and install whole videos
228
244
  odori registry List available registry components
229
245
  odori diff [components] Compare installed components with upstream
230
246
  odori update [components] Apply upstream component changes
231
247
  odori list Print discovered video ids and formats
248
+ odori graph [--json] Compile the project graph and check its shape
232
249
  odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
233
250
  odori frame <id> --at 4s Render one deterministic frame to a PNG
234
251
  odori test [id] [--json] Validate contracts and representative frames
@@ -299,6 +316,8 @@ export const run = async (argv: string[]): Promise<number> => {
299
316
  case "integrations":
300
317
  await integrationsCommand();
301
318
  return 0;
319
+ case "docs":
320
+ return await docsCommand(positionals, {json: flags.json === true});
302
321
  case "doctor":
303
322
  return await doctorCommand();
304
323
  case "install":
@@ -309,6 +328,14 @@ export const run = async (argv: string[]): Promise<number> => {
309
328
  case "add":
310
329
  await addCommand(positionals, {force: flags.force === true, dryRun: flags["dry-run"] === true});
311
330
  return 0;
331
+ case "blocks":
332
+ await blocksCommand(positionals, {
333
+ out: typeof flags.out === "string" ? flags.out : undefined,
334
+ force: flags.force === true,
335
+ dryRun: flags["dry-run"] === true,
336
+ json: flags.json === true,
337
+ });
338
+ return 0;
312
339
  case "registry":
313
340
  await registryCommand();
314
341
  return 0;
@@ -321,6 +348,8 @@ export const run = async (argv: string[]): Promise<number> => {
321
348
  case "list":
322
349
  await listCommand();
323
350
  return 0;
351
+ case "graph":
352
+ return await graphCommand({json: flags.json === true});
324
353
  case "inspect":
325
354
  await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
326
355
  return 0;
@@ -365,6 +394,7 @@ export const run = async (argv: string[]): Promise<number> => {
365
394
  scale: numberFlag(flags, "scale"),
366
395
  format: typeof flags.format === "string" ? flags.format : undefined,
367
396
  audio: flags["no-audio"] === true ? false : undefined,
397
+ audioVariant: typeof flags["audio-variant"] === "string" ? flags["audio-variant"] : undefined,
368
398
  fast: flags.fast === true,
369
399
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
370
400
  retry: typeof flags.retry === "string" ? flags.retry : undefined,
@@ -0,0 +1,188 @@
1
+ import {existsSync} from "node:fs";
2
+ import {mkdir, writeFile} from "node:fs/promises";
3
+ import {dirname} from "node:path";
4
+ import {loadConfig} from "../config";
5
+ import {log} from "../log";
6
+ import {
7
+ normalizeComponentName,
8
+ resolveBlock,
9
+ resolveBlocks,
10
+ resolveWithinRoot,
11
+ verifyBlockIntegrity,
12
+ type BlockDocument,
13
+ } from "../registry-source";
14
+ import {addCommand} from "./add";
15
+
16
+ /**
17
+ * Blocks from the command line.
18
+ *
19
+ * `odori add` installs a component and `create odori` scaffolds a project, so
20
+ * the largest thing the catalog ships was the one thing a reader had to
21
+ * rebuild by hand: open the page, copy each file, paste it in. `add` closes
22
+ * that, and the other three exist so running it is not a leap — see what a
23
+ * block is, read a file, then take it.
24
+ *
25
+ * Everything a block install must get right is already solved for components,
26
+ * so none of it is rewritten here: resolution and its cache, the integrity
27
+ * hash over the exact bytes, the refusal to write outside the project, and
28
+ * the component install itself, which `add` performs by calling the same
29
+ * command a person would.
30
+ */
31
+
32
+ /** Where a block's files land, once `--out` has had its say. */
33
+ const rebase = (block: BlockDocument, target: string, out?: string): string => {
34
+ if (!out) return target;
35
+ const home = block.entry.slice(0, block.entry.lastIndexOf("/"));
36
+ // Only the block's own directory moves. A target that somehow sits outside
37
+ // it keeps its place rather than being silently reparented.
38
+ if (!target.startsWith(`${home}/`)) return target;
39
+ return `${out.replace(/\/$/, "")}/${target.slice(home.length + 1)}`;
40
+ };
41
+
42
+ const findBlock = async (config: Awaited<ReturnType<typeof loadConfig>>, id: string) => {
43
+ const index = await resolveBlocks(config);
44
+ const summary = index.items.find((item) => item.id === id);
45
+ if (!summary) {
46
+ const names = index.items.map((item) => item.id).join(", ");
47
+ throw new Error(`No block named ${JSON.stringify(id)}. Available: ${names}`);
48
+ }
49
+ return {summary, index};
50
+ };
51
+
52
+ export const blocksListCommand = async (options: {json?: boolean} = {}) => {
53
+ const config = await loadConfig(process.cwd());
54
+ const {items, origin, detail} = await resolveBlocks(config);
55
+
56
+ if (options.json) {
57
+ log.info(JSON.stringify({origin, items}, null, 2));
58
+ return;
59
+ }
60
+
61
+ if (origin === "cache") log.detail(`blocks: cached copy (offline)`);
62
+ else log.detail(`blocks: ${detail}`);
63
+ log.title(`${items.length} block${items.length === 1 ? "" : "s"}`);
64
+ for (const item of items) {
65
+ log.info(` ${item.id} ${item.title}`);
66
+ log.detail(` ${item.format} · ${item.tags.join(", ")}`);
67
+ }
68
+ log.detail(`\nodori blocks show <id> for its files, odori blocks add <id> to install one.`);
69
+ };
70
+
71
+ export const blocksShowCommand = async (id: string, options: {json?: boolean} = {}) => {
72
+ const config = await loadConfig(process.cwd());
73
+ const {summary} = await findBlock(config, id);
74
+
75
+ if (options.json) {
76
+ log.info(JSON.stringify(summary, null, 2));
77
+ return;
78
+ }
79
+
80
+ log.title(`${summary.title} (${summary.id})`);
81
+ log.info(` ${summary.description}`);
82
+ log.detail(` ${summary.format} · ${summary.tags.join(", ")}`);
83
+ log.title("files");
84
+ for (const file of summary.files) log.info(` ${file.target}${file.target === summary.entry ? " (entry)" : ""}`);
85
+ log.title("components");
86
+ for (const component of summary.components) log.info(` ${component}`);
87
+ log.detail(`\nodori blocks add ${summary.id}`);
88
+ };
89
+
90
+ export const blocksCatCommand = async (id: string, file: string) => {
91
+ const config = await loadConfig(process.cwd());
92
+ await findBlock(config, id);
93
+ const {block} = await resolveBlock(config, id);
94
+
95
+ // A reader types the name they saw, which may be the whole target or just
96
+ // the file at the end of it.
97
+ const found =
98
+ block.files.find((item) => item.target === file) ??
99
+ block.files.find((item) => item.target.endsWith(`/${file}`));
100
+ if (!found) {
101
+ throw new Error(
102
+ `Block "${id}" has no file ${JSON.stringify(file)}. It ships: ${block.files
103
+ .map((item) => item.target)
104
+ .join(", ")}`,
105
+ );
106
+ }
107
+ log.info(found.content);
108
+ };
109
+
110
+ export const blocksAddCommand = async (
111
+ id: string,
112
+ options: {out?: string; force?: boolean; dryRun?: boolean} = {},
113
+ ) => {
114
+ const config = await loadConfig(process.cwd());
115
+ await findBlock(config, id);
116
+ const {block, origin} = await resolveBlock(config, id);
117
+ if (origin === "cache") log.detail("blocks: cached copy (offline)");
118
+ verifyBlockIntegrity(block);
119
+
120
+ const writes = block.files.map((file) => {
121
+ const target = rebase(block, file.target, options.out);
122
+ return {target, destination: resolveWithinRoot(config.root, target), content: file.content};
123
+ });
124
+
125
+ // Every refusal is collected before anything is written, so a block never
126
+ // lands half-installed on the first file that already existed.
127
+ const clashes = writes.filter((write) => existsSync(write.destination));
128
+ if (clashes.length > 0 && !options.force) {
129
+ throw new Error(
130
+ `These files already exist:\n${clashes.map((write) => ` ${write.target}`).join("\n")}\n` +
131
+ "Nothing was written. Pass --out to install beside them, or --force to replace them.",
132
+ );
133
+ }
134
+
135
+ if (options.dryRun) {
136
+ log.title(`${block.title} would write ${writes.length} file${writes.length === 1 ? "" : "s"}`);
137
+ for (const write of writes) log.info(` ${write.target}`);
138
+ if (block.components.length > 0) log.detail(` and install ${block.components.join(", ")}`);
139
+ return;
140
+ }
141
+
142
+ /* The components first: a block's files import them, so a project that
143
+ stops halfway should be missing the video rather than the parts. */
144
+ if (block.components.length > 0) {
145
+ await addCommand(block.components.map(normalizeComponentName), {force: options.force});
146
+ }
147
+
148
+ for (const write of writes) {
149
+ await mkdir(dirname(write.destination), {recursive: true});
150
+ await writeFile(write.destination, write.content, "utf8");
151
+ log.info(` ${write.target}`);
152
+ }
153
+
154
+ const entry = rebase(block, block.entry, options.out);
155
+ log.title(`${block.title} installed`);
156
+ log.detail(` entry: ${entry}`);
157
+ /* The id is written inside the entry, not taken from the directory, so a
158
+ block installed beside itself would be discovered twice under one name.
159
+ Better said here than found as a duplicate-id error on the next run. */
160
+ if (options.out) log.detail(` the video id is set in the entry; change it if this project already has "${block.id}".`);
161
+ log.detail(` odori dev, then open the video to watch it.`);
162
+ };
163
+
164
+ /** The one entry point the CLI dispatches to, so `blocks` reads as one word. */
165
+ export const blocksCommand = async (
166
+ positionals: string[],
167
+ flags: {out?: string; force?: boolean; dryRun?: boolean; json?: boolean} = {},
168
+ ) => {
169
+ const [action = "ls", ...rest] = positionals;
170
+ switch (action) {
171
+ case "ls":
172
+ case "list":
173
+ return blocksListCommand({json: flags.json});
174
+ case "show":
175
+ if (!rest[0]) throw new Error("Name a block, for example: odori blocks show launch");
176
+ return blocksShowCommand(rest[0], {json: flags.json});
177
+ case "cat":
178
+ if (!rest[0] || !rest[1]) {
179
+ throw new Error("Name a block and a file, for example: odori blocks cat launch video.tsx");
180
+ }
181
+ return blocksCatCommand(rest[0], rest[1]);
182
+ case "add":
183
+ if (!rest[0]) throw new Error("Name a block, for example: odori blocks add launch");
184
+ return blocksAddCommand(rest[0], {out: flags.out, force: flags.force, dryRun: flags.dryRun});
185
+ default:
186
+ throw new Error(`Unknown blocks command ${JSON.stringify(action)}. Try: ls, show, cat, add.`);
187
+ }
188
+ };
@@ -165,7 +165,12 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
165
165
  const {graph, videos} = await context();
166
166
  const video = findVideo(videos, String(body.videoId));
167
167
  const input = (body.input ?? {}) as Record<string, unknown>;
168
- const compiled = await compileInBrowser(origin, targetFor(video, input), config);
168
+ const audioVariant = typeof body.audioVariant === "string" ? body.audioVariant : undefined;
169
+ const compiled = await compileInBrowser(
170
+ origin,
171
+ targetFor(video, input, undefined, undefined, undefined, audioVariant),
172
+ config,
173
+ );
169
174
  const {manifest} = await freezeManifest(
170
175
  {...video, durationInFrames: compiled.durationInFrames},
171
176
  graph,
@@ -0,0 +1,88 @@
1
+ import {docPages, findDoc, searchDocs} from "../docs";
2
+ import {log} from "../log";
3
+
4
+ /**
5
+ * The documentation, in the terminal the agent is already in.
6
+ *
7
+ * The pages ship inside this package, so this answers with no network and no
8
+ * browser: `odori docs` lists what there is, `odori docs <page>` prints one,
9
+ * and `odori docs search <text>` finds the line that says it. An agent
10
+ * working in an unfamiliar project can read the framework the same way it
11
+ * reads the project.
12
+ */
13
+ export const docsCommand = async (positionals: string[], options: {json?: boolean} = {}) => {
14
+ const [first, ...rest] = positionals;
15
+
16
+ if (first === "search") {
17
+ const query = rest.join(" ").trim();
18
+ if (!query) {
19
+ log.warn("Usage: odori docs search <text>");
20
+ return 1;
21
+ }
22
+ const hits = searchDocs(query);
23
+ if (options.json) {
24
+ log.info(JSON.stringify(hits.map(({page, line, text}) => ({slug: page.slug, line, text})), null, 2));
25
+ return hits.length > 0 ? 0 : 1;
26
+ }
27
+ if (hits.length === 0) {
28
+ log.warn(`No page mentions ${query}.`);
29
+ return 1;
30
+ }
31
+ log.title(`${hits.length} line${hits.length === 1 ? "" : "s"} mentioning ${query}`);
32
+ for (const hit of hits) {
33
+ log.info(` ${hit.page.slug}:${hit.line}`);
34
+ log.detail(` ${hit.text}`);
35
+ }
36
+ log.detail(`Read one with: odori docs ${hits[0].page.slug}`);
37
+ return 0;
38
+ }
39
+
40
+ if (!first) {
41
+ const pages = docPages();
42
+ if (options.json) {
43
+ log.info(
44
+ JSON.stringify(
45
+ pages.map(({slug, title, description}) => ({slug, title, description})),
46
+ null,
47
+ 2,
48
+ ),
49
+ );
50
+ return 0;
51
+ }
52
+ log.title(`${pages.length} pages`);
53
+ /* Grouped the way the site files them, because a flat list of eighteen
54
+ slugs is a list to read rather than a shape to recognise. */
55
+ const sections = new Map<string, typeof pages>();
56
+ for (const page of pages) {
57
+ const key = page.section || "";
58
+ sections.set(key, [...(sections.get(key) ?? []), page]);
59
+ }
60
+ for (const [section, group] of sections) {
61
+ if (section) log.info(` ${section}/`);
62
+ for (const page of group) {
63
+ const name = section ? page.slug.slice(section.length + 1) : page.slug;
64
+ log.info(` ${section ? " " : ""}${name.padEnd(section ? 20 : 22)}${page.title}`);
65
+ }
66
+ }
67
+ log.detail("Read one with: odori docs <page> · search with: odori docs search <text>");
68
+ return 0;
69
+ }
70
+
71
+ const page = findDoc(first);
72
+ if (!page) {
73
+ log.warn(`No page called ${first}.`);
74
+ log.detail("List them with: odori docs");
75
+ return 1;
76
+ }
77
+
78
+ if (options.json) {
79
+ log.info(JSON.stringify(page, null, 2));
80
+ return 0;
81
+ }
82
+
83
+ log.title(page.title);
84
+ if (page.description) log.detail(page.description);
85
+ log.info("");
86
+ log.info(page.body);
87
+ return 0;
88
+ };
@@ -4,6 +4,9 @@ import {existsSync} from "node:fs";
4
4
  import {createRequire} from "node:module";
5
5
  import {relative, resolve} from "node:path";
6
6
  import {loadConfig} from "../config";
7
+ import {discoverProject} from "../discovery";
8
+ import {loadVideos} from "../project";
9
+ import {checkStructure} from "../structure";
7
10
  import {keySource} from "../keystore";
8
11
  import {musicProviders} from "../providers";
9
12
  import {CHROME_BUILD, cacheRoot, resolveBrowser, resolveFfmpeg} from "../binaries";
@@ -167,6 +170,41 @@ export const runChecks = async (root: string): Promise<Check[]> => {
167
170
  fix: `Add a sibling <name>.preview.tsx with defineComponentPreview so Studio can play it on its own. A component with no fixture only ever renders inside a video.`,
168
171
  });
169
172
 
173
+ /**
174
+ * The filesystem contract, checked the way the graph command checks it. A
175
+ * file one keystroke away from being an entry, a fixture credited to
176
+ * nothing, a sound with no file behind it: each is invisible to discovery
177
+ * by design, which is exactly why doctor has to say it. Loading the videos
178
+ * can itself fail — a missing export, a colliding id — and that failure is
179
+ * this check's answer rather than doctor's crash.
180
+ */
181
+ if (existsSync(resolve(config.root, config.videosDir))) {
182
+ try {
183
+ const graph = await discoverProject(config);
184
+ const videos = await loadVideos(graph);
185
+ const findings = await checkStructure(config, graph, videos);
186
+ const errors = findings.filter((finding) => finding.level === "error");
187
+ const first = errors[0] ?? findings[0];
188
+ checks.push({
189
+ name: "Structure",
190
+ detail:
191
+ findings.length === 0
192
+ ? `${videos.length} video${videos.length === 1 ? "" : "s"}, shape is sound`
193
+ : `${first.file}: ${first.message}${findings.length > 1 ? ` (and ${findings.length - 1} more)` : ""}`,
194
+ ok: errors.length === 0,
195
+ warn: errors.length === 0 && findings.length > 0,
196
+ fix: 'Run "odori graph" for the full report.',
197
+ });
198
+ } catch (error) {
199
+ checks.push({
200
+ name: "Structure",
201
+ detail: error instanceof Error ? error.message : String(error),
202
+ ok: false,
203
+ fix: "A video entry failed to load. Open the file the message names.",
204
+ });
205
+ }
206
+ }
207
+
170
208
  // A missing provider key is not a broken project — generation is optional —
171
209
  // but doctor is exactly where "why does --generate fail" gets answered.
172
210
  for (const provider of Object.values(musicProviders)) {
@@ -158,6 +158,8 @@ export const exportCommand = async (
158
158
  format?: string;
159
159
  /** False writes the picture with no audio track. */
160
160
  audio?: boolean;
161
+ /** A declared audio variant to voice this export with. */
162
+ audioVariant?: string;
161
163
  /** True draws on the machine's GPU instead of the reproducible backend. */
162
164
  fast?: boolean;
163
165
  skipUnchangedFrames?: boolean;
@@ -176,7 +178,21 @@ export const exportCommand = async (
176
178
  ? await readJob(config, options.retry)
177
179
  : await (async () => {
178
180
  const video = findVideo(videos, id);
179
- const compiled = await compileInBrowser(server.url, targetFor(video, options.input), config);
181
+ if (options.audioVariant) {
182
+ const declared = Object.keys(video.entry.metadata.audio?.variants ?? {});
183
+ if (!declared.includes(options.audioVariant)) {
184
+ throw new Error(
185
+ declared.length
186
+ ? `No audio variant named "${options.audioVariant}". Declared: ${declared.join(", ")}.`
187
+ : `"${id}" declares no audio variants; drop --audio-variant or add metadata.audio.variants.`,
188
+ );
189
+ }
190
+ }
191
+ const compiled = await compileInBrowser(
192
+ server.url,
193
+ targetFor(video, options.input, undefined, undefined, undefined, options.audioVariant),
194
+ config,
195
+ );
180
196
  const {manifest} = await freezeManifest(
181
197
  {...video, durationInFrames: compiled.durationInFrames},
182
198
  graph,
@@ -184,9 +200,12 @@ export const exportCommand = async (
184
200
  options.input ?? {},
185
201
  {scenes: compiled.scenes, audio: compiled.audio},
186
202
  );
203
+ // The variant names the file, so two voices cannot overwrite each
204
+ // other's exports.
205
+ const suffix = options.audioVariant ? `-${options.audioVariant}` : "";
187
206
  const output = resolve(
188
207
  config.root,
189
- options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`,
208
+ options.output ?? `${config.exportDir}/${outputName(id)}${suffix}${format.extension}`,
190
209
  );
191
210
  return createJob(config, manifest, output, {
192
211
  format: format.name,