@odori/cli 0.0.10 → 0.0.11

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.
@@ -1023,6 +1023,13 @@ var registerCues = (brands, fps) => {
1023
1023
  }
1024
1024
  return known.size;
1025
1025
  };
1026
+ var registerVariantCues = (variants, fps) => {
1027
+ for (const overrides of Object.values(variants ?? {})) {
1028
+ for (const value of Object.values(overrides)) {
1029
+ if (isCueDefinition(value)) known.set(cueUrl(value), { cue: value, fps });
1030
+ }
1031
+ }
1032
+ };
1026
1033
  var renderedCue = (url) => {
1027
1034
  const cached = rendered.get(url);
1028
1035
  if (cached) return cached;
@@ -1053,6 +1060,7 @@ var registerProjectCues = async (graph, load = importFresh) => {
1053
1060
  const entry = { component: module.default, metadata: module.metadata };
1054
1061
  const layout = resolveEntryLayout(entry);
1055
1062
  registerCues([layout.brand], layout.format.fps);
1063
+ registerVariantCues(entry.metadata.audio?.variants, layout.format.fps);
1056
1064
  } catch (error) {
1057
1065
  log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
1058
1066
  }
@@ -1365,6 +1373,7 @@ var renderUrl = (origin, target, frame) => {
1365
1373
  const params = new URLSearchParams({ render: "1", video: target.videoId, frame: String(frame) });
1366
1374
  if (target.input) params.set("input", encodeParam(target.input));
1367
1375
  if (target.prepared !== void 0) params.set("prepared", encodeParam(target.prepared));
1376
+ if (target.audioVariant) params.set("audio", target.audioVariant);
1368
1377
  return `${origin}/?${params.toString()}`;
1369
1378
  };
1370
1379
  var DEFAULT_GRAPHICS = "software";
@@ -2757,10 +2766,11 @@ var createContext = async (root = process.cwd()) => {
2757
2766
  const layout = resolveEntryLayout3(video.entry);
2758
2767
  brands.set(layout.brand.name, layout.brand);
2759
2768
  registerCues([layout.brand], layout.format.fps);
2769
+ registerVariantCues(video.entry.metadata.audio?.variants, layout.format.fps);
2760
2770
  }
2761
2771
  return { config, graph, videos };
2762
2772
  };
2763
- var targetFor = (video, input, prepared, audio, scenes) => {
2773
+ var targetFor = (video, input, prepared, audio, scenes, audioVariant) => {
2764
2774
  const layout = resolveEntryLayout3(video.entry);
2765
2775
  return {
2766
2776
  videoId: video.entry.metadata.id,
@@ -2772,6 +2782,7 @@ var targetFor = (video, input, prepared, audio, scenes) => {
2772
2782
  prepared,
2773
2783
  audio,
2774
2784
  scenes,
2785
+ audioVariant,
2775
2786
  targetLufs: layout.audio.targetLufs
2776
2787
  };
2777
2788
  };
@@ -2906,7 +2917,19 @@ var exportCommand = async (id, options = {}) => {
2906
2917
  return withServer(config, async (server) => {
2907
2918
  const record = options.retry ? await readJob(config, options.retry) : await (async () => {
2908
2919
  const video2 = findVideo(videos, id);
2909
- const compiled = await compileInBrowser(server.url, targetFor(video2, options.input), config);
2920
+ if (options.audioVariant) {
2921
+ const declared = Object.keys(video2.entry.metadata.audio?.variants ?? {});
2922
+ if (!declared.includes(options.audioVariant)) {
2923
+ throw new Error(
2924
+ declared.length ? `No audio variant named "${options.audioVariant}". Declared: ${declared.join(", ")}.` : `"${id}" declares no audio variants; drop --audio-variant or add metadata.audio.variants.`
2925
+ );
2926
+ }
2927
+ }
2928
+ const compiled = await compileInBrowser(
2929
+ server.url,
2930
+ targetFor(video2, options.input, void 0, void 0, void 0, options.audioVariant),
2931
+ config
2932
+ );
2910
2933
  const { manifest } = await freezeManifest(
2911
2934
  { ...video2, durationInFrames: compiled.durationInFrames },
2912
2935
  graph,
@@ -2914,9 +2937,10 @@ var exportCommand = async (id, options = {}) => {
2914
2937
  options.input ?? {},
2915
2938
  { scenes: compiled.scenes, audio: compiled.audio }
2916
2939
  );
2940
+ const suffix = options.audioVariant ? `-${options.audioVariant}` : "";
2917
2941
  const output = resolve19(
2918
2942
  config.root,
2919
- options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
2943
+ options.output ?? `${config.exportDir}/${outputName(id)}${suffix}${format.extension}`
2920
2944
  );
2921
2945
  return createJob(config, manifest, output, {
2922
2946
  format: format.name,
@@ -3078,7 +3102,12 @@ var devCommand = async (options = {}) => {
3078
3102
  const { graph, videos } = await context();
3079
3103
  const video = findVideo(videos, String(body.videoId));
3080
3104
  const input = body.input ?? {};
3081
- const compiled = await compileInBrowser(origin, targetFor(video, input), config);
3105
+ const audioVariant = typeof body.audioVariant === "string" ? body.audioVariant : void 0;
3106
+ const compiled = await compileInBrowser(
3107
+ origin,
3108
+ targetFor(video, input, void 0, void 0, void 0, audioVariant),
3109
+ config
3110
+ );
3082
3111
  const { manifest } = await freezeManifest(
3083
3112
  { ...video, durationInFrames: compiled.durationInFrames },
3084
3113
  graph,
@@ -4188,7 +4217,7 @@ var COMMAND_FLAGS = {
4188
4217
  bed: ["role", "output", "target", "generate", "provider", "seconds"],
4189
4218
  narrate: ["output", "voice", "role", "provider"],
4190
4219
  test: ["json"],
4191
- export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
4220
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "audio-variant", "fast", "no-frame-skip", "retry"],
4192
4221
  jobs: [],
4193
4222
  help: []
4194
4223
  };
@@ -4272,7 +4301,7 @@ var USAGE = {
4272
4301
  check, for CI.`,
4273
4302
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
4274
4303
  [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
4275
- [--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
4304
+ [--no-audio] [--audio-variant <name>] [--fast] [--no-frame-skip] [--retry <job>]
4276
4305
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
4277
4306
  or png; without it the output's extension decides, and mp4 is the default.
4278
4307
  --quality is studio, social, or web. --scale multiplies the output size,
@@ -4428,6 +4457,7 @@ var run3 = async (argv) => {
4428
4457
  scale: numberFlag(flags, "scale"),
4429
4458
  format: typeof flags.format === "string" ? flags.format : void 0,
4430
4459
  audio: flags["no-audio"] === true ? false : void 0,
4460
+ audioVariant: typeof flags["audio-variant"] === "string" ? flags["audio-variant"] : void 0,
4431
4461
  fast: flags.fast === true,
4432
4462
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
4433
4463
  retry: typeof flags.retry === "string" ? flags.retry : void 0
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  checkFlags,
3
3
  parseArgs,
4
4
  run
5
- } from "./chunk-AJBK4XRB.js";
5
+ } from "./chunk-77R3UC27.js";
6
6
  export {
7
7
  checkFlags,
8
8
  parseArgs,
package/dist/index.d.ts CHANGED
@@ -251,6 +251,8 @@ type RenderTarget = {
251
251
  input?: Record<string, unknown>;
252
252
  prepared?: unknown;
253
253
  audio?: ManifestAudioCue[];
254
+ /** A declared audio variant's name, applied when the page compiles cues. */
255
+ audioVariant?: string;
254
256
  targetLufs?: number;
255
257
  scenes?: Array<{
256
258
  id: string;
@@ -360,7 +362,7 @@ declare const targetFor: (video: LoadedVideo, input?: Record<string, unknown>, p
360
362
  id: string;
361
363
  start: number;
362
364
  durationInFrames: number;
363
- }>) => RenderTarget;
365
+ }>, audioVariant?: string) => RenderTarget;
364
366
  /**
365
367
  * `metadata.duration` is authoritative. When it is absent, the compiled
366
368
  * timeline reported by the runtime is used instead.
@@ -607,6 +609,8 @@ declare const exportCommand: (id: string, options?: {
607
609
  format?: string;
608
610
  /** False writes the picture with no audio track. */
609
611
  audio?: boolean;
612
+ /** A declared audio variant to voice this export with. */
613
+ audioVariant?: string;
610
614
  /** True draws on the machine's GPU instead of the reproducible backend. */
611
615
  fast?: boolean;
612
616
  skipUnchangedFrames?: boolean;
package/dist/index.js CHANGED
@@ -75,7 +75,7 @@ import {
75
75
  withServer,
76
76
  writeGenerated,
77
77
  writePrepareCache
78
- } from "./chunk-AJBK4XRB.js";
78
+ } from "./chunk-77R3UC27.js";
79
79
  export {
80
80
  CHROME_BUILD,
81
81
  FORMATS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
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",
@@ -8,10 +8,7 @@
8
8
  "odori": "./bin/odori.mjs"
9
9
  },
10
10
  "exports": {
11
- ".": {
12
- "types": "./dist/index.d.ts",
13
- "import": "./dist/index.js"
14
- }
11
+ ".": "./src/index.ts"
15
12
  },
16
13
  "files": [
17
14
  "bin",
@@ -19,16 +16,23 @@
19
16
  "src",
20
17
  "studio"
21
18
  ],
19
+ "scripts": {
20
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.studio.json",
21
+ "build": "tsup",
22
+ "snapshot": "tsx scripts/snapshot.ts",
23
+ "verify:package": "tsx scripts/verify-package.ts",
24
+ "prepack": "pnpm snapshot && pnpm build && pnpm verify:package"
25
+ },
22
26
  "dependencies": {
23
27
  "@puppeteer/browsers": "3.2.0",
24
28
  "@vitejs/plugin-react": "5.1.2",
25
29
  "ffmpeg-static": "5.3.0",
30
+ "odori": "workspace:*",
26
31
  "react": "19.2.3",
27
32
  "react-dom": "19.2.3",
28
33
  "playwright-core": "1.55.0",
29
34
  "tsx": "4.20.5",
30
- "vite": "7.3.0",
31
- "odori": "0.0.10"
35
+ "vite": "7.3.0"
32
36
  },
33
37
  "devDependencies": {
34
38
  "@types/node": "22.19.0",
@@ -36,15 +40,15 @@
36
40
  "@types/react-dom": "19.2.3",
37
41
  "tsup": "^8.5.1",
38
42
  "typescript": "5.9.3",
39
- "@odori/registry": "0.0.9"
43
+ "@odori/registry": "workspace:*"
40
44
  },
41
45
  "publishConfig": {
42
- "access": "public"
43
- },
44
- "scripts": {
45
- "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.studio.json",
46
- "build": "tsup",
47
- "snapshot": "tsx scripts/snapshot.ts",
48
- "verify:package": "tsx scripts/verify-package.ts"
46
+ "access": "public",
47
+ "exports": {
48
+ ".": {
49
+ "types": "./dist/index.d.ts",
50
+ "import": "./dist/index.js"
51
+ }
52
+ }
49
53
  }
50
- }
54
+ }
package/src/cli.ts CHANGED
@@ -105,7 +105,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
105
105
  bed: ["role", "output", "target", "generate", "provider", "seconds"],
106
106
  narrate: ["output", "voice", "role", "provider"],
107
107
  test: ["json"],
108
- export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
108
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "audio-variant", "fast", "no-frame-skip", "retry"],
109
109
  jobs: [],
110
110
  help: [],
111
111
  };
@@ -202,7 +202,7 @@ const USAGE: Record<string, string> = {
202
202
  check, for CI.`,
203
203
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
204
204
  [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
205
- [--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
205
+ [--no-audio] [--audio-variant <name>] [--fast] [--no-frame-skip] [--retry <job>]
206
206
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
207
207
  or png; without it the output's extension decides, and mp4 is the default.
208
208
  --quality is studio, social, or web. --scale multiplies the output size,
@@ -365,6 +365,7 @@ export const run = async (argv: string[]): Promise<number> => {
365
365
  scale: numberFlag(flags, "scale"),
366
366
  format: typeof flags.format === "string" ? flags.format : undefined,
367
367
  audio: flags["no-audio"] === true ? false : undefined,
368
+ audioVariant: typeof flags["audio-variant"] === "string" ? flags["audio-variant"] : undefined,
368
369
  fast: flags.fast === true,
369
370
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
370
371
  retry: typeof flags.retry === "string" ? flags.retry : undefined,
@@ -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,
@@ -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,
@@ -1,6 +1,6 @@
1
1
  import {resolveEntryLayout, type AudioCue, type ManifestAudioCue, type Brand} from "odori";
2
2
  import {loadConfig, type ResolvedConfig} from "../config";
3
- import {registerCues} from "../cues";
3
+ import {registerCues, registerVariantCues} from "../cues";
4
4
  import {discoverProject, type ProjectGraph} from "../discovery";
5
5
  import {loadVideos, type LoadedVideo} from "../project";
6
6
  import {openRenderPage, readAudio, readTimeline, type RenderTarget} from "../render";
@@ -19,6 +19,7 @@ export const createContext = async (root = process.cwd()): Promise<Context> => {
19
19
  const layout = resolveEntryLayout(video.entry);
20
20
  brands.set(layout.brand.name, layout.brand);
21
21
  registerCues([layout.brand], layout.format.fps);
22
+ registerVariantCues(video.entry.metadata.audio?.variants, layout.format.fps);
22
23
  }
23
24
  return {config, graph, videos};
24
25
  };
@@ -29,6 +30,7 @@ export const targetFor = (
29
30
  prepared?: unknown,
30
31
  audio?: ManifestAudioCue[],
31
32
  scenes?: Array<{id: string; start: number; durationInFrames: number}>,
33
+ audioVariant?: string,
32
34
  ): RenderTarget => {
33
35
  const layout = resolveEntryLayout(video.entry);
34
36
  return {
@@ -41,6 +43,7 @@ export const targetFor = (
41
43
  prepared,
42
44
  audio,
43
45
  scenes,
46
+ audioVariant,
44
47
  targetLufs: layout.audio.targetLufs,
45
48
  };
46
49
  };
package/src/cues.ts CHANGED
@@ -80,6 +80,22 @@ export const registerCues = (brands: Brand[], fps: number): number => {
80
80
  return known.size;
81
81
  };
82
82
 
83
+ /**
84
+ * Variant cues live on a video's metadata rather than on a brand, and a
85
+ * synthesized one still has to be answerable by URL: an audition that plays
86
+ * the default voice but 404s the alternatives is not an audition.
87
+ */
88
+ export const registerVariantCues = (
89
+ variants: Record<string, Record<string, string | CueDefinition>> | undefined,
90
+ fps: number,
91
+ ): void => {
92
+ for (const overrides of Object.values(variants ?? {})) {
93
+ for (const value of Object.values(overrides)) {
94
+ if (isCueDefinition(value)) known.set(cueUrl(value), {cue: value, fps});
95
+ }
96
+ }
97
+ };
98
+
83
99
  export const renderedCue = (url: string): Uint8Array | null => {
84
100
  const cached = rendered.get(url);
85
101
  if (cached) return cached;
@@ -143,6 +159,7 @@ export const registerProjectCues = async (graph: ProjectGraph, load: ModuleLoade
143
159
  const entry = {component: module.default, metadata: module.metadata} as VideoEntry;
144
160
  const layout = resolveEntryLayout(entry);
145
161
  registerCues([layout.brand], layout.format.fps);
162
+ registerVariantCues(entry.metadata.audio?.variants, layout.format.fps);
146
163
  } catch (error) {
147
164
  log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
148
165
  }
package/src/render.ts CHANGED
@@ -22,6 +22,8 @@ export type RenderTarget = {
22
22
  input?: Record<string, unknown>;
23
23
  prepared?: unknown;
24
24
  audio?: ManifestAudioCue[];
25
+ /** A declared audio variant's name, applied when the page compiles cues. */
26
+ audioVariant?: string;
25
27
  targetLufs?: number;
26
28
  scenes?: Array<{id: string; start: number; durationInFrames: number}>;
27
29
  };
@@ -32,6 +34,7 @@ const renderUrl = (origin: string, target: RenderTarget, frame: number) => {
32
34
  const params = new URLSearchParams({render: "1", video: target.videoId, frame: String(frame)});
33
35
  if (target.input) params.set("input", encodeParam(target.input));
34
36
  if (target.prepared !== undefined) params.set("prepared", encodeParam(target.prepared));
37
+ if (target.audioVariant) params.set("audio", target.audioVariant);
35
38
  return `${origin}/?${params.toString()}`;
36
39
  };
37
40
 
@@ -51,12 +51,18 @@ const outputName = (videoId: string) => videoId.split("/").join("-");
51
51
  * started here can be retried from either surface.
52
52
  */
53
53
  export const ExportPanel = ({
54
+ audioVariant = null,
55
+ sound = true,
54
56
  videoId,
55
57
  input,
56
58
  frame,
57
59
  width,
58
60
  height,
59
61
  }: {
62
+ /** The audio choices, made where the audio is described: the inspector's
63
+ Audio section owns the controls, the export ships what is playing. */
64
+ audioVariant?: string | null;
65
+ sound?: boolean;
60
66
  videoId: string;
61
67
  input: Record<string, unknown>;
62
68
  frame: number;
@@ -70,7 +76,6 @@ export const ExportPanel = ({
70
76
  /* Sound is part of the video, so it ships unless it is turned off. GIF has
71
77
  no audio track at all and a still is one frame, so the choice only exists
72
78
  where it means something. */
73
- const [sound, setSound] = useState(true);
74
79
  const [format, setFormat] = useState<Format>("mp4");
75
80
  const [quality, setQuality] = useState<Quality>("studio");
76
81
  const [scale, setScale] = useState(1);
@@ -193,7 +198,7 @@ export const ExportPanel = ({
193
198
  const run = async () => {
194
199
  setNotice(null);
195
200
  if (format === "frame") return post("/__odori/still", {videoId, input, frame});
196
- return post("/__odori/exports", {videoId, input, format, quality, scale, audio: hasSound ? sound : false});
201
+ return post("/__odori/exports", {videoId, input, format, quality, scale, audio: hasSound ? sound : false, ...(audioVariant ? {audioVariant} : {})});
197
202
  };
198
203
 
199
204
  const file = outputName(videoId);
@@ -250,7 +255,10 @@ export const ExportPanel = ({
250
255
  disabled={busy}
251
256
  onClick={() => setScale(option)}
252
257
  >
253
- {option}x
258
+ {/* A resolution, not a multiplier: 1080p means something at a
259
+ glance and 1.5x means arithmetic. The height names the
260
+ chip because that is how people say resolutions. */}
261
+ {scaled(height, option)}p
254
262
  </Button>
255
263
  ))}
256
264
  </div>
@@ -280,21 +288,6 @@ export const ExportPanel = ({
280
288
  </div>
281
289
  ) : null}
282
290
 
283
- {hasSound ? (
284
- <div className="export-row">
285
- <span className="export-label">Sound</span>
286
- <div className="export-options">
287
- <Button variant="outline" active={sound} disabled={busy} onClick={() => setSound(true)}
288
- title="Mix the brand's cues into the file">
289
- On
290
- </Button>
291
- <Button variant="outline" active={!sound} disabled={busy} onClick={() => setSound(false)}
292
- title="Write the picture with no audio track">
293
- Off
294
- </Button>
295
- </div>
296
- </div>
297
- ) : null}
298
291
 
299
292
  <div className="export-actions">
300
293
  <Button
@@ -20,6 +20,7 @@ if (renderMode) {
20
20
  <RenderSurface
21
21
  entry={entry}
22
22
  initialFrame={Number(query.get("frame") ?? 0)}
23
+ audioVariant={query.get("audio") ?? undefined}
23
24
  input={input}
24
25
  prepared={prepared}
25
26
  assets={project.assets}
@@ -1181,6 +1181,18 @@ html[data-inspector="collapsed"] .main:not([data-single="true"]) {
1181
1181
  flex-wrap: wrap;
1182
1182
  gap: 6px;
1183
1183
  }
1184
+ /* The Voice and Sound rows above the cue facts: each row breathes, and the
1185
+ pair stands clear of the list it governs. */
1186
+ .audio-controls {
1187
+ display: grid;
1188
+ gap: 12px;
1189
+ margin: 2px 0 14px;
1190
+ }
1191
+ /* Cue labels are file names, which outgrow the fact grid's 84px column:
1192
+ bed-launch.m4a wrapped onto two lines in the default width. */
1193
+ .facts-audio .fact {
1194
+ grid-template-columns: 122px minmax(0, 1fr);
1195
+ }
1184
1196
  .export-dimensions {
1185
1197
  color: var(--subtle);
1186
1198
  font-family: var(--font-mono);
@@ -1216,6 +1228,12 @@ html[data-inspector="collapsed"] .main:not([data-single="true"]) {
1216
1228
  gap: 6px;
1217
1229
  padding: 16px 20px 0;
1218
1230
  }
1231
+ .catalog-filter-rule {
1232
+ align-self: stretch;
1233
+ background: var(--border);
1234
+ margin: 2px 6px;
1235
+ width: 1px;
1236
+ }
1219
1237
  .catalog-filter button {
1220
1238
  background: transparent;
1221
1239
  border: 1px solid var(--border);
@@ -159,7 +159,11 @@ export const ComponentsView = ({
159
159
  }) => {
160
160
  // Finding one by name is ⌘K; the gallery is for browsing them all. A route
161
161
  // with no selection is the gallery; a selection is that component's player.
162
- const [usedIn, setUsedIn] = useState<string | null>(null);
162
+ //
163
+ // One active filter, of either kind: a video from the derived usage, or a
164
+ // tag an author declared. Stacking them multiplies chips into a query
165
+ // builder, and the question is always singular: what belongs to this?
166
+ const [filter, setFilter] = useState<{kind: "video" | "tag"; value: string} | null>(null);
163
167
 
164
168
  /**
165
169
  * Which video a component appears in, derived from the imports rather than
@@ -174,11 +178,17 @@ export const ComponentsView = ({
174
178
  () => [...new Set(Object.values(usage).flat())].sort(),
175
179
  [usage],
176
180
  );
181
+ const tags = useMemo(
182
+ () => [...new Set(componentPreviews.flatMap((item) => item.preview.tags ?? []))].sort(),
183
+ [],
184
+ );
177
185
 
178
186
  const filtered = useMemo(() => {
179
187
  const all = [...componentPreviews].sort(byFamily);
180
- return usedIn ? all.filter((item) => (usage[item.id] ?? []).includes(usedIn)) : all;
181
- }, [usedIn, usage]);
188
+ if (!filter) return all;
189
+ if (filter.kind === "video") return all.filter((item) => (usage[item.id] ?? []).includes(filter.value));
190
+ return all.filter((item) => (item.preview.tags ?? []).includes(filter.value));
191
+ }, [filter, usage]);
182
192
  const selected = selection ? (filtered.find((entry) => entry.id === selection) ?? null) : null;
183
193
  const brands = projectBrands();
184
194
  const [brandName, setBrandName] = useState(brands[0]?.name ?? defaultBrand.name);
@@ -246,21 +256,32 @@ export const ComponentsView = ({
246
256
  }
247
257
  return (
248
258
  <div style={{overflowY: "auto", width: "100%"}}>
249
- {videosUsing.length > 0 ? (
259
+ {videosUsing.length > 0 || tags.length > 0 ? (
250
260
  <div className="catalog-filter">
251
- <button type="button" data-active={usedIn === null ? "true" : undefined} onClick={() => setUsedIn(null)}>
261
+ <button type="button" data-active={filter === null ? "true" : undefined} onClick={() => setFilter(null)}>
252
262
  All
253
263
  </button>
254
264
  {videosUsing.map((video) => (
255
265
  <button
256
266
  key={video}
257
267
  type="button"
258
- data-active={usedIn === video ? "true" : undefined}
259
- onClick={() => setUsedIn(video)}
268
+ data-active={filter?.kind === "video" && filter.value === video ? "true" : undefined}
269
+ onClick={() => setFilter({kind: "video", value: video})}
260
270
  >
261
271
  {video}
262
272
  </button>
263
273
  ))}
274
+ {tags.length > 0 && videosUsing.length > 0 ? <span className="catalog-filter-rule" /> : null}
275
+ {tags.map((tag) => (
276
+ <button
277
+ key={tag}
278
+ type="button"
279
+ data-active={filter?.kind === "tag" && filter.value === tag ? "true" : undefined}
280
+ onClick={() => setFilter({kind: "tag", value: tag})}
281
+ >
282
+ {tag}
283
+ </button>
284
+ ))}
264
285
  </div>
265
286
  ) : null}
266
287
  {families.map((family) => (
@@ -1,5 +1,6 @@
1
1
  import {useCallback, useEffect, useMemo, useRef, useState} from "react";
2
2
  import {
3
+ AudioVariantContext,
3
4
  DUCK_GAIN,
4
5
  OdoriRuntime,
5
6
  entryDurationInFrames,
@@ -21,7 +22,7 @@ import {ExportPanel} from "../components/ExportPanel";
21
22
  import {Diagnostics} from "../components/Diagnostics";
22
23
  import {Inspector} from "../components/Inspector";
23
24
  import {Navigator} from "../components/Navigator";
24
- import {Badge, Empty, Fact, SectionTitle, Separator} from "../components/ui";
25
+ import {Badge, Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
25
26
  import {measureTrack} from "../lib/mix-loudness";
26
27
  import {readStartSound} from "../settings";
27
28
  import {useShortcuts} from "../shortcuts";
@@ -46,7 +47,16 @@ export const VideosView = ({
46
47
  }) => {
47
48
  // Finding one by name is ⌘K; the gallery is for browsing them all. A route
48
49
  // with no selection is the gallery; a selection is that video's player.
49
- const filtered = videos;
50
+ // The same chips the catalog offers: one active tag, or all of them.
51
+ const [tag, setTag] = useState<string | null>(null);
52
+ // Which declared audio variant plays. Lives here rather than in the export
53
+ // panel because the player has to hear the same voice the export will ship.
54
+ const [audioVariant, setAudioVariant] = useState<string | null>(null);
55
+ // Whether the export ships its track. Lives beside the voice choice: both
56
+ // are audio decisions, and the export panel only inherits them.
57
+ const [sound, setSound] = useState(true);
58
+ const tags = useMemo(() => [...new Set(videos.flatMap((video) => video.metadata.tags ?? []))].sort(), []);
59
+ const filtered = tag ? videos.filter((video) => (video.metadata.tags ?? []).includes(tag)) : videos;
50
60
  const selected = selection ? (filtered.find((video) => video.metadata.id === selection) ?? null) : null;
51
61
  const [input, setInput] = useState<Record<string, unknown>>({});
52
62
  const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
@@ -180,6 +190,24 @@ export const VideosView = ({
180
190
 
181
191
  if (!selected || !layout) {
182
192
  return (
193
+ <div style={{overflowY: "auto", width: "100%"}}>
194
+ {tags.length > 0 ? (
195
+ <div className="catalog-filter">
196
+ <button type="button" data-active={tag === null ? "true" : undefined} onClick={() => setTag(null)}>
197
+ All
198
+ </button>
199
+ {tags.map((name) => (
200
+ <button
201
+ key={name}
202
+ type="button"
203
+ data-active={tag === name ? "true" : undefined}
204
+ onClick={() => setTag(name)}
205
+ >
206
+ {name}
207
+ </button>
208
+ ))}
209
+ </div>
210
+ ) : null}
183
211
  <div className="gallery">
184
212
  {filtered.map((video) => {
185
213
  const videoLayout = resolveEntryLayout(video);
@@ -204,6 +232,7 @@ export const VideosView = ({
204
232
  );
205
233
  })}
206
234
  </div>
235
+ </div>
207
236
  );
208
237
  }
209
238
 
@@ -243,14 +272,18 @@ export const VideosView = ({
243
272
  transport, the inspector, and the file list stay usable while
244
273
  the error is read and fixed. */}
245
274
  <PreviewBoundary resetKey={selected.metadata.id}>
246
- <OdoriRuntime
247
- entry={selected}
248
- frame={preview ?? playback.frame}
249
- input={input}
250
- assets={project.assets}
251
- onTimeline={handleTimeline}
252
- onAudio={handleAudio}
253
- />
275
+ <AudioVariantContext.Provider
276
+ value={(audioVariant && selected.metadata.audio?.variants?.[audioVariant]) || null}
277
+ >
278
+ <OdoriRuntime
279
+ entry={selected}
280
+ frame={preview ?? playback.frame}
281
+ input={input}
282
+ assets={project.assets}
283
+ onTimeline={handleTimeline}
284
+ onAudio={handleAudio}
285
+ />
286
+ </AudioVariantContext.Provider>
254
287
  </PreviewBoundary>
255
288
  </CanvasStage>
256
289
  <Transport
@@ -277,6 +310,8 @@ export const VideosView = ({
277
310
  {/* Export sits first: it is the pane's one action, and the header
278
311
  names what is being worked on right above it. */}
279
312
  <ExportPanel
313
+ audioVariant={audioVariant}
314
+ sound={sound}
280
315
  videoId={selected.metadata.id}
281
316
  input={input}
282
317
  frame={playback.frame}
@@ -322,7 +357,44 @@ export const VideosView = ({
322
357
  <>
323
358
  <Separator />
324
359
  <SectionTitle>Audio</SectionTitle>
325
- <dl className="facts">
360
+ <div className="audio-controls">
361
+ {Object.keys(selected.metadata.audio?.variants ?? {}).length > 0 ? (
362
+ <div className="export-row">
363
+ {/* "Variant", not "Voice": the field is audio.variants and a variant
364
+ can re-point any cue, a bed or a language track as readily as a
365
+ narrator. The label matches the metadata and the export flag, so
366
+ the same word names the thing everywhere it appears. */}
367
+ <span className="export-label">Variant</span>
368
+ <div className="export-options">
369
+ <Button variant="outline" active={audioVariant === null} onClick={() => setAudioVariant(null)}>
370
+ Default
371
+ </Button>
372
+ {Object.keys(selected.metadata.audio?.variants ?? {}).map((name) => (
373
+ <Button
374
+ key={name}
375
+ variant="outline"
376
+ active={audioVariant === name}
377
+ onClick={() => setAudioVariant(name)}
378
+ >
379
+ {name}
380
+ </Button>
381
+ ))}
382
+ </div>
383
+ </div>
384
+ ) : null}
385
+ <div className="export-row">
386
+ <span className="export-label">Sound</span>
387
+ <div className="export-options">
388
+ <Button variant="outline" active={sound} onClick={() => setSound(true)}>
389
+ On
390
+ </Button>
391
+ <Button variant="outline" active={!sound} onClick={() => setSound(false)}>
392
+ Off
393
+ </Button>
394
+ </div>
395
+ </div>
396
+ </div>
397
+ <dl className="facts facts-audio">
326
398
  {track.cues.map((cue) => (
327
399
  <Fact key={cue.id} label={cueLabel(cue.src)} title={cue.src.split("/").pop()}>
328
400
  {cue.fromFrame}-{cue.fromFrame + cue.durationInFrames - 1} · gain {cue.gain}
package/LICENSE DELETED
@@ -1,22 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Allen Zhou
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
22
-