@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.
@@ -348,4 +348,143 @@ export const verifyIntegrity = (item: RegistryItemDocument, origin: RegistryOrig
348
348
  );
349
349
  };
350
350
 
351
+ /**
352
+ * Blocks: whole videos, published the same way components are.
353
+ *
354
+ * A block is the larger unit — an entry and the files beside it, plus the
355
+ * components it installs — so it reuses every guarantee this module already
356
+ * makes rather than growing a second way in: the same index-then-document
357
+ * split, the same cache, the same integrity hash over the exact bytes a
358
+ * consumer writes, and the same refusal to write outside the project.
359
+ *
360
+ * There is no bundled floor for blocks. The snapshot exists so a locked-down
361
+ * machine can still install a component and so `odori test` never touches the
362
+ * network; taking a whole video is a deliberate act on a machine that has one.
363
+ * A block therefore resolves network → cache and says so when neither answers.
364
+ */
365
+ export type BlockDocument = {
366
+ id: string;
367
+ title: string;
368
+ description: string;
369
+ tags: string[];
370
+ format: string;
371
+ /** The file to render, relative to the project root. */
372
+ entry: string;
373
+ /** Registry components this block imports, by namespaced name. */
374
+ components: string[];
375
+ files: Array<{path: string; content: string; target: string}>;
376
+ meta?: {integrity?: string};
377
+ };
378
+
379
+ /** The index: every block, without the file contents. */
380
+ export type BlockSummary = Omit<BlockDocument, "files"> & {
381
+ files: Array<{path: string; target: string}>;
382
+ };
383
+
384
+ export type BlockIndex = {items: BlockSummary[]; origin: RegistryOrigin; detail: string};
385
+
386
+ const blocksUnavailable = (url: string): Error =>
387
+ new Error(
388
+ `No blocks available: ${url}/blocks.json could not be fetched and nothing is cached on this machine. ` +
389
+ "Blocks are published rather than bundled, so this needs a network once. " +
390
+ "Components still install offline.",
391
+ );
392
+
393
+ export const resolveBlocks = async (
394
+ config: ResolvedConfig,
395
+ options: {allowNetwork?: boolean} = {},
396
+ ): Promise<BlockIndex> => {
397
+ const url = registryUrl(config);
398
+ const cache = resolve(cacheDir(url), "blocks.json");
399
+
400
+ if (options.allowNetwork !== false) {
401
+ try {
402
+ const index = (await fetchJson(`${url}/blocks.json`)) as {items: BlockSummary[]};
403
+ if (!Array.isArray(index.items)) throw new Error("the index has no items array");
404
+ await mkdir(dirname(cache), {recursive: true});
405
+ await writeFile(cache, JSON.stringify(index), "utf8");
406
+ return {items: index.items, origin: "network", detail: url};
407
+ } catch {
408
+ // As with components: unreachable is not an error until the disk is
409
+ // empty too.
410
+ }
411
+ }
412
+
413
+ if (existsSync(cache)) {
414
+ try {
415
+ const index = JSON.parse(await readFile(cache, "utf8")) as {items: BlockSummary[]};
416
+ return {items: index.items, origin: "cache", detail: cache};
417
+ } catch {
418
+ // A corrupt cache is a cache miss.
419
+ }
420
+ }
421
+
422
+ throw blocksUnavailable(url);
423
+ };
424
+
425
+ /** One block, with the source of every file it ships. */
426
+ export const resolveBlock = async (
427
+ config: ResolvedConfig,
428
+ id: string,
429
+ options: {allowNetwork?: boolean} = {},
430
+ ): Promise<{block: BlockDocument; origin: RegistryOrigin}> => {
431
+ assertSafeName(id);
432
+ const url = registryUrl(config);
433
+ const cache = resolve(cacheDir(url), `block-${id}.json`);
434
+
435
+ if (options.allowNetwork !== false) {
436
+ try {
437
+ const block = (await fetchJson(`${url}/blocks/${id}.json`)) as BlockDocument;
438
+ if (block?.id !== id) throw new Error(`the document at ${url}/blocks/${id}.json is for "${block?.id}"`);
439
+ await mkdir(dirname(cache), {recursive: true});
440
+ await writeFile(cache, JSON.stringify(block), "utf8");
441
+ return {block, origin: "network"};
442
+ } catch {
443
+ // Try the disk before giving up.
444
+ }
445
+ }
446
+
447
+ if (existsSync(cache)) {
448
+ try {
449
+ return {block: JSON.parse(await readFile(cache, "utf8")) as BlockDocument, origin: "cache"};
450
+ } catch {
451
+ // Corrupt: fall through to the error, which names the whole situation.
452
+ }
453
+ }
454
+
455
+ throw blocksUnavailable(url);
456
+ };
457
+
458
+ /**
459
+ * The same hash components use, over a block's files.
460
+ *
461
+ * Integrity proves the bytes and not the intent, which is why every target is
462
+ * still resolved against the project root before anything is written.
463
+ */
464
+ export const verifyBlockIntegrity = (block: BlockDocument): void => {
465
+ const expected = block.meta?.integrity;
466
+ if (!expected) {
467
+ throw new Error(
468
+ `The document for block "${block.id}" carries no integrity hash. ` +
469
+ "A fetched block must be verifiable; refusing to write it. Nothing was written.",
470
+ );
471
+ }
472
+
473
+ const hash = createHash("sha256");
474
+ for (const file of [...block.files].sort((left, right) => left.path.localeCompare(right.path))) {
475
+ hash.update(file.path);
476
+ hash.update("\0");
477
+ hash.update(file.content);
478
+ hash.update("\0");
479
+ }
480
+ const actual = `sha256-${hash.digest("base64")}`;
481
+ if (actual === expected) return;
482
+
483
+ throw new Error(
484
+ `The files for block "${block.id}" do not match the hash the registry published.\n` +
485
+ ` expected ${expected}\n received ${actual}\n` +
486
+ "Nothing was written. This is a truncated download, a stale proxy, or a tampered document.",
487
+ );
488
+ };
489
+
351
490
  export type {RegistryItemDocument};
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
 
@@ -0,0 +1,177 @@
1
+ import {readFile} from "node:fs/promises";
2
+ import {existsSync} from "node:fs";
3
+ import {relative, resolve, sep} from "node:path";
4
+ import {resolveEntryLayout} from "odori";
5
+ import type {ResolvedConfig} from "./config";
6
+ import {walkSource, type ProjectGraph} from "./discovery";
7
+ import type {LoadedVideo} from "./project";
8
+
9
+ /**
10
+ * A fact about the project's shape that discovery itself cannot say.
11
+ *
12
+ * Discovery answers "what exists": it walks the tree and takes what matches.
13
+ * That is the right behaviour for a build — an unrecognized file must never
14
+ * take the project down — and exactly the wrong behaviour for an author,
15
+ * because everything discovery quietly steps over is a file someone wrote on
16
+ * purpose. The filesystem is Odori's contract: `video.tsx` is a video,
17
+ * `*.preview.tsx` is a fixture, a directory under components/ names a
18
+ * component. This module checks the places where that contract can be almost
19
+ * met, which is the one distance discovery cannot see.
20
+ *
21
+ * An `error` is a promise the project makes and breaks at render or play
22
+ * time: a sound that will be silence, an override that will never be heard.
23
+ * A `warn` is a file that works today and will surprise someone later.
24
+ */
25
+ export type StructureFinding = {
26
+ level: "error" | "warn";
27
+ /** The file an author would open to fix it, relative to the project root. */
28
+ file: string;
29
+ message: string;
30
+ };
31
+
32
+ /** Entry basenames one keystroke away from meaning something. */
33
+ const NEAR_MISSES: Array<{test: (base: string) => boolean; message: string}> = [
34
+ {
35
+ test: (base) => base === "video.ts" || base === "video.jsx",
36
+ message: "Only video.tsx is a video entry. This file is invisible to discovery: rename it to video.tsx.",
37
+ },
38
+ {
39
+ test: (base) => base.toLowerCase() === "video.tsx" && base !== "video.tsx",
40
+ message: "Entry names are exact and lower case. This file is invisible to discovery: rename it to video.tsx.",
41
+ },
42
+ {
43
+ test: (base) => /\.preview\.(ts|jsx)$/.test(base),
44
+ message:
45
+ "Only *.preview.tsx is a component fixture. This file is invisible to discovery: rename it to end in .preview.tsx.",
46
+ },
47
+ {
48
+ test: (base) => base.toLowerCase().endsWith(".preview.tsx") && !base.endsWith(".preview.tsx"),
49
+ message:
50
+ "Fixture names are exact and lower case. This file is invisible to discovery: rename it to end in .preview.tsx.",
51
+ },
52
+ ];
53
+
54
+ /** String literals that name a file under the served audio library. */
55
+ const AUDIO_REFERENCE = /["'`](\/audio\/[^"'`\s]+\.[a-z0-9]{2,4})["'`]/gi;
56
+
57
+ /**
58
+ * Check the filesystem contract and report every place it is almost met.
59
+ *
60
+ * Everything here is answerable in Node from the tree and the loaded
61
+ * metadata: no browser, no network, no render. That is what makes it cheap
62
+ * enough to run inside `odori doctor` and first inside `odori test`, before
63
+ * a browser ever starts.
64
+ */
65
+ export const checkStructure = async (
66
+ config: ResolvedConfig,
67
+ graph: ProjectGraph,
68
+ videos: LoadedVideo[],
69
+ ): Promise<StructureFinding[]> => {
70
+ const findings: StructureFinding[] = [];
71
+ const videosRoot = resolve(config.root, config.videosDir);
72
+ const componentsRoot = resolve(config.root, config.componentsDir);
73
+ const files = existsSync(videosRoot) ? await walkSource(videosRoot) : [];
74
+
75
+ // Files one keystroke away from being entries. Discovery is filename
76
+ // driven, so `video.ts` is not a video with a small problem — it is not a
77
+ // video at all, and nothing else in the pipeline will ever say so.
78
+ for (const file of files) {
79
+ const base = file.split(sep).pop() ?? "";
80
+ const miss = NEAR_MISSES.find((candidate) => candidate.test(base));
81
+ if (miss) findings.push({level: "warn", file: relative(config.root, file), message: miss.message});
82
+ }
83
+
84
+ // A fixture named after something other than its directory. The catalog
85
+ // attributes usage by matching the fixture's name to the directory videos
86
+ // import from, so a mismatched name previews fine and is credited to
87
+ // nothing: its "used by" list stays empty forever.
88
+ for (const preview of graph.previews) {
89
+ if (!preview.file.startsWith(componentsRoot + sep)) continue;
90
+ const directory = resolve(preview.file, "..").split(sep).pop() ?? "";
91
+ if (directory && preview.name !== directory) {
92
+ findings.push({
93
+ level: "warn",
94
+ file: preview.relativeFile,
95
+ message:
96
+ `Fixture "${preview.name}" sits in components/${directory}/, so usage is never attributed to it. ` +
97
+ `Name the fixture after its directory: ${directory}.preview.tsx.`,
98
+ });
99
+ }
100
+ }
101
+
102
+ // A literal audio path with no file behind it. The dev server answers 404
103
+ // and plays on; the export hasher shrugs and warns late. The author finds
104
+ // out when the cut is silent where the sound was supposed to be.
105
+ const audioUrls = new Set(graph.audio.map((entry) => entry.url));
106
+ for (const file of files) {
107
+ if (!/\.(tsx|ts)$/.test(file)) continue;
108
+ const contents = await readFile(file, "utf8");
109
+ const reported = new Set<string>();
110
+ for (const match of contents.matchAll(AUDIO_REFERENCE)) {
111
+ const url = match[1];
112
+ // A template literal with an interpolation is a path decided at
113
+ // runtime. Only what is written in full can be answered for here.
114
+ if (url.includes("${") || audioUrls.has(url) || reported.has(url)) continue;
115
+ reported.add(url);
116
+ findings.push({
117
+ level: "error",
118
+ file: relative(config.root, file),
119
+ message: `References ${url}, and no file answers it under ${config.audioDir}/. It will play as silence.`,
120
+ });
121
+ }
122
+ }
123
+
124
+ // Variant overrides that answer to nothing. A variant replaces brand cues
125
+ // by name, so a name the brand never defines is an override nothing asks
126
+ // for, and a file path with no file is a sound that cannot load. Both are
127
+ // authored intent that the player will quietly ignore.
128
+ for (const video of videos) {
129
+ const layout = resolveEntryLayout(video.entry);
130
+ const brandCues = new Set(Object.keys(layout.brand.audio.cues));
131
+ for (const [variant, overrides] of Object.entries(video.entry.metadata.audio?.variants ?? {})) {
132
+ for (const [cue, value] of Object.entries(overrides)) {
133
+ if (!brandCues.has(cue)) {
134
+ findings.push({
135
+ level: "warn",
136
+ file: video.relativeFile,
137
+ message:
138
+ `Variant "${variant}" overrides a cue named "${cue}" that the brand "${layout.brand.name}" does not define. ` +
139
+ `Nothing plays that name, so the override is never heard.`,
140
+ });
141
+ }
142
+ if (typeof value === "string" && value.startsWith("/") && !audioUrls.has(value)) {
143
+ findings.push({
144
+ level: "error",
145
+ file: video.relativeFile,
146
+ message: `Variant "${variant}" points cue "${cue}" at ${value}, and no file answers it under ${config.audioDir}/.`,
147
+ });
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ // A compiled catalog that no longer describes the tree. Anything that
154
+ // reads the artifact instead of running discovery — an agent, a CI step,
155
+ // another tool — would be reading yesterday's project as if it were today's.
156
+ const catalogFile = resolve(config.root, config.outDir, "catalog.json");
157
+ if (existsSync(catalogFile)) {
158
+ try {
159
+ const catalog = JSON.parse(await readFile(catalogFile, "utf8")) as {sourceHash?: string};
160
+ if (catalog.sourceHash && catalog.sourceHash !== graph.sourceHash) {
161
+ findings.push({
162
+ level: "warn",
163
+ file: relative(config.root, catalogFile),
164
+ message: "The compiled catalog is older than the source tree. Run odori graph (or odori dev) to refresh it.",
165
+ });
166
+ }
167
+ } catch {
168
+ findings.push({
169
+ level: "warn",
170
+ file: relative(config.root, catalogFile),
171
+ message: "The compiled catalog is not valid JSON. Run odori graph (or odori dev) to rewrite it.",
172
+ });
173
+ }
174
+ }
175
+
176
+ return findings;
177
+ };
@@ -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}