@odori/cli 0.0.3 → 0.0.5

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.
Files changed (38) hide show
  1. package/dist/{chunk-RXLB2CXH.js → chunk-RHG23EWW.js} +457 -241
  2. package/dist/cli.js +1 -1
  3. package/dist/index.d.ts +45 -5
  4. package/dist/index.js +3 -3
  5. package/dist/registry-snapshot-JEVXYGS2.js +4868 -0
  6. package/package.json +3 -3
  7. package/src/assets.ts +90 -0
  8. package/src/brand-file.ts +16 -4
  9. package/src/cli.ts +38 -13
  10. package/src/commands/add.ts +37 -1
  11. package/src/commands/dev.ts +32 -2
  12. package/src/commands/doctor.ts +47 -2
  13. package/src/commands/exportVideo.ts +6 -0
  14. package/src/commands/{still.ts → frame.ts} +23 -9
  15. package/src/commands/update.ts +58 -7
  16. package/src/discovery.ts +63 -2
  17. package/src/index.ts +1 -1
  18. package/src/jobs.ts +1 -1
  19. package/src/registry-snapshot.json +1529 -327
  20. package/src/registry-source.ts +37 -2
  21. package/src/render.ts +8 -1
  22. package/src/server.ts +7 -1
  23. package/studio/src/Studio.tsx +6 -22
  24. package/studio/src/components/ExportPanel.tsx +90 -6
  25. package/studio/src/components/Inspector.tsx +101 -1
  26. package/studio/src/components/Navigator.tsx +149 -0
  27. package/studio/src/components/Settings.tsx +109 -0
  28. package/studio/src/components/Transport.tsx +98 -55
  29. package/studio/src/components/ui.tsx +16 -1
  30. package/studio/src/lib/highlight.ts +85 -0
  31. package/studio/src/settings.ts +87 -0
  32. package/studio/src/studio.css +350 -8
  33. package/studio/src/views/BrandsView.tsx +18 -1
  34. package/studio/src/views/ComponentsView.tsx +191 -26
  35. package/studio/src/views/HomeView.tsx +7 -4
  36. package/studio/src/views/VideosView.tsx +33 -6
  37. package/studio/src/virtual.d.ts +4 -1
  38. package/dist/registry-snapshot-BDP6PVYB.js +0 -3559
@@ -20,9 +20,9 @@ var log = {
20
20
  };
21
21
 
22
22
  // src/commands/add.ts
23
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
24
- import { existsSync as existsSync6 } from "fs";
25
- import { relative as relative4, resolve as resolve6 } from "path";
23
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile6 } from "fs/promises";
24
+ import { existsSync as existsSync7 } from "fs";
25
+ import { relative as relative5, resolve as resolve7 } from "path";
26
26
  import { hashString as hashString2 } from "odori";
27
27
 
28
28
  // src/config.ts
@@ -86,6 +86,7 @@ var brandFiles = async (config) => {
86
86
  return found.sort((left, right) => left.depth - right.depth).map((entry) => entry.file);
87
87
  };
88
88
  var registerCueInBrand = async (config, cue, componentName) => {
89
+ const url = "url" in cue ? cue.url : null;
89
90
  for (const file of await brandFiles(config)) {
90
91
  const source = await readFile(file, "utf8");
91
92
  if (source.includes(`"${cue.name}"`) || source.includes(`'${cue.name}'`)) {
@@ -93,19 +94,24 @@ var registerCueInBrand = async (config, cue, componentName) => {
93
94
  }
94
95
  const block = source.match(/(audio:\s*\{[\s\S]*?cues:\s*\{)([\s\S]*?)(\n(\s*)\},)/);
95
96
  const inline = source.match(/(audio:\s*\{[^\n}]*cues:\s*\{)([^\n{}]*)(\})/);
97
+ const value = url ? JSON.stringify(url) : `${cue.export}()`;
96
98
  let withCue;
97
99
  if (block) {
98
100
  const indent = `${block[4]} `;
99
101
  const entry = `
100
- ${indent}"${cue.name}": ${cue.export}(),`;
102
+ ${indent}"${cue.name}": ${value},`;
101
103
  withCue = source.replace(block[0], `${block[1]}${block[2]}${entry}${block[3]}`);
102
104
  } else if (inline) {
103
105
  const existing = inline[2].trim();
104
- const entry = `"${cue.name}": ${cue.export}()`;
106
+ const entry = `"${cue.name}": ${value}`;
105
107
  withCue = source.replace(inline[0], `${inline[1]}${existing ? `${existing.replace(/,$/, "")}, ` : ""}${entry}${inline[3]}`);
106
108
  } else {
107
109
  continue;
108
110
  }
111
+ if (url) {
112
+ await writeFile(file, withCue, "utf8");
113
+ return { file: relative(config.root, file), already: false };
114
+ }
109
115
  const from = resolve2(config.root, config.componentsDir, componentName, componentName);
110
116
  const specifier = relative(resolve2(file, ".."), from).split("\\").join("/");
111
117
  const importLine = `import {${cue.export}} from "${specifier.startsWith(".") ? specifier : `./${specifier}`}";`;
@@ -288,12 +294,21 @@ var resolveWithinRoot = (root, target) => {
288
294
  };
289
295
  var DEFAULT_URL = "https://odori.dev/r/v1";
290
296
  var registryUrl = (config) => (config.registryUrl ?? process.env.ODORI_REGISTRY ?? DEFAULT_URL).replace(/\/$/, "");
297
+ var registryOrigin = (config) => {
298
+ const url = registryUrl(config);
299
+ try {
300
+ return new URL(url).origin;
301
+ } catch {
302
+ return url.replace(/\/r(\/v\d+)?$/, "");
303
+ }
304
+ };
291
305
  var cacheDir = (url) => resolve4(cacheRoot(), "registry", createHash("sha256").update(url).digest("hex").slice(0, 16));
292
306
  var toComponent = (item) => ({
293
307
  name: item.name,
294
308
  namespaced: item.meta?.namespaced ?? `@odori/${item.name}`,
295
309
  kind: item.meta?.kind ?? "component",
296
310
  ...item.meta?.cue ? { cue: item.meta.cue } : {},
311
+ ...item.meta?.asset ? { asset: item.meta.asset } : {},
297
312
  family: item.meta?.family ?? "Uncategorized",
298
313
  description: item.description ?? "",
299
314
  files: item.files.map((file) => file.path.split("/").pop() ?? file.path),
@@ -302,7 +317,7 @@ var toComponent = (item) => ({
302
317
  });
303
318
  var snapshotItems = async () => {
304
319
  try {
305
- const loaded = await import("./registry-snapshot-BDP6PVYB.js");
320
+ const loaded = await import("./registry-snapshot-JEVXYGS2.js");
306
321
  return loaded.default.items;
307
322
  } catch {
308
323
  throw new Error(
@@ -393,10 +408,67 @@ Nothing was written. This is a truncated download, a stale proxy, or a tampered
393
408
  );
394
409
  };
395
410
 
396
- // src/commands/update.ts
397
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
411
+ // src/assets.ts
412
+ import { createHash as createHash2 } from "crypto";
398
413
  import { existsSync as existsSync5 } from "fs";
399
- import { relative as relative3, resolve as resolve5 } from "path";
414
+ import { mkdir as mkdir3, readFile as readFile4, rm, writeFile as writeFile4 } from "fs/promises";
415
+ import { dirname as dirname3, relative as relative3, resolve as resolve5 } from "path";
416
+ var assetCache = (integrity) => resolve5(cacheRoot(), "assets", createHash2("sha256").update(integrity).digest("hex").slice(0, 16));
417
+ var verify = (bytes, expected, source) => {
418
+ const actual = `sha256-${createHash2("sha256").update(bytes).digest("base64")}`;
419
+ if (actual === expected) return;
420
+ throw new Error(
421
+ `The bytes at ${source} do not match the hash the registry published.
422
+ expected ${expected}
423
+ received ${actual}
424
+ Nothing was written. This is a truncated download, a stale proxy, or a tampered file.`
425
+ );
426
+ };
427
+ var installAsset = async (config, component, options = {}) => {
428
+ const asset = component.asset;
429
+ if (!asset) return false;
430
+ const destination = resolveWithinRoot(config.root, asset.target);
431
+ const exists = existsSync5(destination);
432
+ log.detail(` ${exists ? "replace" : "create "} ${relative3(config.root, destination)}`);
433
+ if (options.dryRun) return false;
434
+ if (exists && !options.force) {
435
+ log.warn(`${relative3(config.root, destination)} already exists. Keeping it. Use --force to replace it.`);
436
+ return true;
437
+ }
438
+ const cached = assetCache(asset.integrity);
439
+ let bytes = null;
440
+ if (existsSync5(cached)) {
441
+ const stored = await readFile4(cached);
442
+ try {
443
+ verify(stored, asset.integrity, cached);
444
+ bytes = stored;
445
+ } catch {
446
+ log.detail(" the cached copy did not verify; downloading it again");
447
+ await rm(cached, { force: true });
448
+ }
449
+ }
450
+ if (!bytes) {
451
+ const url = new URL(asset.url, `${registryOrigin(config)}/`).toString();
452
+ const response = await fetch(url, { signal: AbortSignal.timeout(3e4) });
453
+ if (!response.ok) throw new Error(`Could not download ${url}: ${response.status} ${response.statusText}`);
454
+ const downloaded = new Uint8Array(await response.arrayBuffer());
455
+ verify(downloaded, asset.integrity, url);
456
+ await mkdir3(dirname3(cached), { recursive: true });
457
+ await writeFile4(cached, downloaded);
458
+ bytes = downloaded;
459
+ }
460
+ await mkdir3(dirname3(destination), { recursive: true });
461
+ await writeFile4(destination, bytes);
462
+ log.success(`${component.namespaced} to ${relative3(config.root, destination)}`);
463
+ log.detail(` ${component.family} \xB7 ${Math.round(asset.bytes / 1024)} KB \xB7 answers to "${asset.cue}"`);
464
+ return true;
465
+ };
466
+
467
+ // src/commands/update.ts
468
+ import { mkdir as mkdir4, readFile as readFile5, rm as rm2, writeFile as writeFile5 } from "fs/promises";
469
+ import { existsSync as existsSync6 } from "fs";
470
+ import { readdir as readdir2 } from "fs/promises";
471
+ import { relative as relative4, resolve as resolve6 } from "path";
400
472
  import { hashString } from "odori";
401
473
 
402
474
  // src/diff.ts
@@ -459,16 +531,24 @@ var formatDiff = (lines, context = 2) => {
459
531
  };
460
532
 
461
533
  // src/commands/update.ts
462
- var provenanceFile = (config) => resolve5(config.root, config.outDir, "components.json");
534
+ var LOCKFILE = "odori.lock.json";
535
+ var provenanceFile = (config) => resolve6(config.root, LOCKFILE);
536
+ var legacyProvenanceFile = (config) => resolve6(config.root, config.outDir, "components.json");
463
537
  var readProvenance = async (config) => {
464
- const file = provenanceFile(config);
465
- if (!existsSync5(file)) return {};
466
- return JSON.parse(await readFile4(file, "utf8"));
538
+ const file = existsSync6(provenanceFile(config)) ? provenanceFile(config) : legacyProvenanceFile(config);
539
+ if (!existsSync6(file)) return {};
540
+ try {
541
+ return JSON.parse(await readFile5(file, "utf8"));
542
+ } catch {
543
+ log.warn(`${relative4(config.root, file)} is not readable JSON. Ignoring it.`);
544
+ return {};
545
+ }
467
546
  };
468
547
  var writeProvenance = async (config, provenance) => {
469
- await mkdir3(resolve5(config.root, config.outDir), { recursive: true });
470
- await writeFile4(provenanceFile(config), `${JSON.stringify(provenance, null, 2)}
548
+ await mkdir4(config.root, { recursive: true });
549
+ await writeFile5(provenanceFile(config), `${JSON.stringify(provenance, null, 2)}
471
550
  `, "utf8");
551
+ await rm2(legacyProvenanceFile(config), { force: true });
472
552
  };
473
553
  var componentStatus = async (config, only) => {
474
554
  const { items: registry } = await resolveRegistry(config);
@@ -483,10 +563,10 @@ var componentStatus = async (config, only) => {
483
563
  const upstreamFiles = new Map(item.files.map((file) => [file.path.split("/").pop() ?? file.path, file.content]));
484
564
  const files = await Promise.all(
485
565
  component.files.map(async (file) => {
486
- const localPath = resolve5(config.root, config.componentsDir, name, file);
566
+ const localPath = resolve6(config.root, config.componentsDir, name, file);
487
567
  const content = upstreamFiles.get(file);
488
568
  if (content === void 0) throw new Error(`The registry document for "${name}" has no file named ${file}.`);
489
- const local = existsSync5(localPath) ? hashString(await readFile4(localPath, "utf8")) : null;
569
+ const local = existsSync6(localPath) ? hashString(await readFile5(localPath, "utf8")) : null;
490
570
  return {
491
571
  file,
492
572
  localPath,
@@ -512,25 +592,39 @@ var LABELS = {
512
592
  diverged: "modified locally and updated upstream",
513
593
  missing: "files missing"
514
594
  };
595
+ var explainEmpty = async (config, named) => {
596
+ if (named.length > 0) {
597
+ log.detail(`${named.join(", ")} ${named.length === 1 ? "is" : "are"} not recorded in ${LOCKFILE}.`);
598
+ return;
599
+ }
600
+ const components = resolve6(config.root, config.componentsDir);
601
+ const installed = existsSync6(components) ? (await readdir2(components)).filter((e) => !e.startsWith(".")) : [];
602
+ if (installed.length === 0) {
603
+ log.detail("No registry components are installed yet. Run odori add first.");
604
+ return;
605
+ }
606
+ log.warn(`${installed.length} components are in ${config.componentsDir} but none are recorded in ${LOCKFILE}.`);
607
+ log.detail("Run odori add <name> to re-record them, or commit the lockfile if a teammate has one.");
608
+ };
515
609
  var diffCommand = async (names, options = {}) => {
516
610
  const config = await loadConfig(process.cwd());
517
611
  const statuses = await componentStatus(config, names.length > 0 ? names : void 0);
518
612
  if (statuses.length === 0) {
519
- log.detail("No registry components are installed yet. Run odori add first.");
613
+ await explainEmpty(config, names);
520
614
  return;
521
615
  }
522
616
  for (const status of statuses) {
523
617
  log.title(`@odori/${status.name} ${LABELS[status.state]}`);
524
618
  for (const file of status.files) {
525
619
  if (file.local === null) {
526
- log.error(` ${file.file} is missing from ${relative3(config.root, resolve5(file.localPath, ".."))}`);
620
+ log.error(` ${file.file} is missing from ${relative4(config.root, resolve6(file.localPath, ".."))}`);
527
621
  continue;
528
622
  }
529
623
  if (file.local === file.upstream) {
530
624
  log.detail(` ${file.file} identical to upstream`);
531
625
  continue;
532
626
  }
533
- const lines = diffLines(await readFile4(file.localPath, "utf8"), file.content);
627
+ const lines = diffLines(await readFile5(file.localPath, "utf8"), file.content);
534
628
  const { added, removed } = countChanges(lines);
535
629
  log.info(` ${file.file} +${added} -${removed} against upstream`);
536
630
  if (options.full) for (const line of formatDiff(lines)) log.detail(` ${line}`);
@@ -542,7 +636,7 @@ var updateCommand = async (names, options = {}) => {
542
636
  const config = await loadConfig(process.cwd());
543
637
  const statuses = await componentStatus(config, names.length > 0 ? names : void 0);
544
638
  if (statuses.length === 0) {
545
- log.detail("No registry components are installed yet. Run odori add first.");
639
+ await explainEmpty(config, names);
546
640
  return;
547
641
  }
548
642
  const provenance = await readProvenance(config);
@@ -563,8 +657,8 @@ var updateCommand = async (names, options = {}) => {
563
657
  continue;
564
658
  }
565
659
  for (const file of status.files) {
566
- await mkdir3(resolve5(file.localPath, ".."), { recursive: true });
567
- await writeFile4(file.localPath, file.content, "utf8");
660
+ await mkdir4(resolve6(file.localPath, ".."), { recursive: true });
661
+ await writeFile5(file.localPath, file.content, "utf8");
568
662
  }
569
663
  provenance[status.name] = {
570
664
  source: `@odori/${status.name}`,
@@ -591,6 +685,7 @@ var addCommand = async (names, options = {}) => {
591
685
  else log.warn(`registry: the copy built into this CLI. It may be older than ${registryUrl(config)}.`);
592
686
  const queue = [...names.map(normalizeComponentName)];
593
687
  const installed = [];
688
+ const kept = [];
594
689
  while (queue.length > 0) {
595
690
  const name = queue.shift();
596
691
  if (installed.includes(name)) continue;
@@ -604,34 +699,55 @@ var addCommand = async (names, options = {}) => {
604
699
  if (provider) queue.push(provider.name);
605
700
  else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
606
701
  }
702
+ if (component.kind === "asset" && component.asset) {
703
+ const written = await installAsset(config, component, { dryRun: options.dryRun, force: options.force });
704
+ if (!options.dryRun && written) {
705
+ installed.push(component.name);
706
+ const registered = await registerCueInBrand(
707
+ config,
708
+ { name: component.asset.cue, url: component.asset.url },
709
+ component.name
710
+ );
711
+ if (registered?.already) {
712
+ log.detail(` "${component.asset.cue}" is already registered in ${registered.file}`);
713
+ } else if (registered) {
714
+ log.detail(` registered "${component.asset.cue}" in ${registered.file}`);
715
+ } else {
716
+ log.warn(` No brand with an audio.cues block found. Add it yourself:`);
717
+ log.detail(` audio: {cues: {"${component.asset.cue}": "${component.asset.url}"}}`);
718
+ }
719
+ }
720
+ continue;
721
+ }
607
722
  const { item, origin } = await resolveItem(config, component.name);
608
723
  verifyIntegrity(item, origin);
609
- const target = resolve6(config.root, config.componentsDir, assertSafeName(component.name));
724
+ const target = resolve7(config.root, config.componentsDir, assertSafeName(component.name));
610
725
  const hashes = {};
611
726
  for (const file of item.files) {
612
727
  const destination = resolveWithinRoot(config.root, file.target);
613
- const exists = existsSync6(destination);
614
- log.detail(` ${exists ? "replace" : "create "} ${relative4(config.root, destination)}`);
728
+ const exists = existsSync7(destination);
729
+ log.detail(` ${exists ? "replace" : "create "} ${relative5(config.root, destination)}`);
615
730
  }
616
731
  if (options.dryRun) {
617
732
  installed.push(component.name);
618
733
  continue;
619
734
  }
620
- await mkdir4(target, { recursive: true });
735
+ await mkdir5(target, { recursive: true });
621
736
  for (const file of item.files) {
622
737
  const name2 = file.path.split("/").pop() ?? file.path;
623
738
  const destination = resolveWithinRoot(config.root, file.target);
624
739
  hashes[name2] = hashString2(file.content);
625
- if (existsSync6(destination) && !options.force) {
626
- const current = hashString2(await readFile5(destination, "utf8"));
740
+ if (existsSync7(destination) && !options.force) {
741
+ const current = hashString2(await readFile6(destination, "utf8"));
627
742
  const recorded = provenance[component.name]?.hashes[name2];
628
743
  if (current !== recorded) {
629
- log.warn(`${relative4(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
744
+ log.warn(`${relative5(config.root, destination)} was modified locally. Keeping your version.`);
745
+ kept.push(relative5(config.root, destination));
630
746
  continue;
631
747
  }
632
748
  }
633
- await mkdir4(resolve6(destination, ".."), { recursive: true });
634
- await writeFile5(destination, file.content, "utf8");
749
+ await mkdir5(resolve7(destination, ".."), { recursive: true });
750
+ await writeFile6(destination, file.content, "utf8");
635
751
  }
636
752
  provenance[component.name] = {
637
753
  source: component.namespaced,
@@ -640,7 +756,7 @@ var addCommand = async (names, options = {}) => {
640
756
  hashes
641
757
  };
642
758
  installed.push(component.name);
643
- log.success(`${component.namespaced} to ${relative4(config.root, target)}/`);
759
+ log.success(`${component.namespaced} to ${relative5(config.root, target)}/`);
644
760
  if (component.kind === "cue" && component.cue) {
645
761
  log.detail(
646
762
  ` ${component.family} \xB7 ${component.contract.recommendedDurationInFrames} frames \xB7 registers "${component.cue.name}"`
@@ -666,6 +782,11 @@ var addCommand = async (names, options = {}) => {
666
782
  return;
667
783
  }
668
784
  await writeProvenance(config, provenance);
785
+ if (kept.length > 0) {
786
+ log.warn(`Kept ${kept.length} locally modified ${kept.length === 1 ? "file" : "files"}:`);
787
+ for (const file of kept) log.detail(` ${file}`);
788
+ log.detail(`Run odori diff to see what upstream changed, or odori add <name> --force to replace them.`);
789
+ }
669
790
  log.detail("Run odori dev to preview the installed component fixtures.");
670
791
  };
671
792
  var registryCommand = async () => {
@@ -688,19 +809,19 @@ var registryCommand = async () => {
688
809
  };
689
810
 
690
811
  // src/commands/dev.ts
691
- import { resolve as resolve18 } from "path";
812
+ import { relative as relative7, resolve as resolve19 } from "path";
692
813
  import { homedir as homedir2 } from "os";
693
- import { existsSync as existsSync15 } from "fs";
694
- import { readFile as readFile12 } from "fs/promises";
814
+ import { existsSync as existsSync16 } from "fs";
815
+ import { readFile as readFile13 } from "fs/promises";
695
816
 
696
817
  // src/jobs.ts
697
- import { mkdir as mkdir5, readFile as readFile6, readdir as readdir2, rename, writeFile as writeFile6 } from "fs/promises";
698
- import { existsSync as existsSync7 } from "fs";
699
- import { join as join2, resolve as resolve7 } from "path";
700
- var buildsDir = (config) => resolve7(config.root, config.outDir, "builds");
818
+ import { mkdir as mkdir6, readFile as readFile7, readdir as readdir3, rename, writeFile as writeFile7 } from "fs/promises";
819
+ import { existsSync as existsSync8 } from "fs";
820
+ import { join as join2, resolve as resolve8 } from "path";
821
+ var buildsDir = (config) => resolve8(config.root, config.outDir, "builds");
701
822
  var jobFile = (config, id) => join2(buildsDir(config), `${id}.json`);
702
823
  var createJob = async (config, manifest, output, render) => {
703
- await mkdir5(buildsDir(config), { recursive: true });
824
+ await mkdir6(buildsDir(config), { recursive: true });
704
825
  const now = (/* @__PURE__ */ new Date()).toISOString();
705
826
  const job = {
706
827
  id: `job-${manifest.manifestHash.slice(0, 10)}-${Date.now().toString(36)}`,
@@ -714,14 +835,14 @@ var createJob = async (config, manifest, output, render) => {
714
835
  updatedAt: now
715
836
  };
716
837
  const record = { job, manifest, output, ...render ? { render } : {} };
717
- await writeFile6(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
838
+ await writeFile7(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
718
839
  `, "utf8");
719
840
  return record;
720
841
  };
721
842
  var readJob = async (config, id) => {
722
843
  const file = jobFile(config, id);
723
- if (!existsSync7(file)) throw new Error(`Unknown job "${id}". Run odori jobs to list them.`);
724
- return JSON.parse(await readFile6(file, "utf8"));
844
+ if (!existsSync8(file)) throw new Error(`Unknown job "${id}". Run odori jobs to list them.`);
845
+ return JSON.parse(await readFile7(file, "utf8"));
725
846
  };
726
847
  var writeLocks = /* @__PURE__ */ new Map();
727
848
  var withJobLock = (id, task) => {
@@ -736,7 +857,7 @@ var withJobLock = (id, task) => {
736
857
  var writeRecord = async (config, record) => {
737
858
  const file = jobFile(config, record.job.id);
738
859
  const temporary = `${file}.${process.pid}.tmp`;
739
- await writeFile6(temporary, `${JSON.stringify(record, null, 2)}
860
+ await writeFile7(temporary, `${JSON.stringify(record, null, 2)}
740
861
  `, "utf8");
741
862
  await rename(temporary, file);
742
863
  };
@@ -779,13 +900,13 @@ var reconcileJobs = async (config) => {
779
900
  return stale.length;
780
901
  };
781
902
  var listJobs = async (config, options = {}) => {
782
- if (!existsSync7(buildsDir(config))) return [];
903
+ if (!existsSync8(buildsDir(config))) return [];
783
904
  if (options.reconcile !== false) await reconcileJobs(config);
784
- const files = (await readdir2(buildsDir(config))).filter((file) => file.endsWith(".json"));
905
+ const files = (await readdir3(buildsDir(config))).filter((file) => file.endsWith(".json"));
785
906
  const jobs = [];
786
907
  for (const file of files) {
787
908
  try {
788
- const raw = await readFile6(join2(buildsDir(config), file), "utf8");
909
+ const raw = await readFile7(join2(buildsDir(config), file), "utf8");
789
910
  jobs.push(JSON.parse(raw).job);
790
911
  } catch {
791
912
  continue;
@@ -803,13 +924,13 @@ var JobQueue = class {
803
924
  };
804
925
 
805
926
  // src/discovery.ts
806
- import { mkdir as mkdir6, readdir as readdir3, readFile as readFile7, stat, writeFile as writeFile7 } from "fs/promises";
807
- import { existsSync as existsSync8 } from "fs";
808
- import { join as join3, relative as relative5, resolve as resolve8, sep as sep2 } from "path";
927
+ import { mkdir as mkdir7, readdir as readdir4, readFile as readFile8, stat, writeFile as writeFile8 } from "fs/promises";
928
+ import { existsSync as existsSync9 } from "fs";
929
+ import { join as join3, relative as relative6, resolve as resolve9, sep as sep2 } from "path";
809
930
  import { hashString as hashString3 } from "odori";
810
931
  var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
811
932
  var walk = async (directory2, files = []) => {
812
- const entries = await readdir3(directory2, { withFileTypes: true });
933
+ const entries = await readdir4(directory2, { withFileTypes: true });
813
934
  for (const entry of entries) {
814
935
  if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
815
936
  const full = join3(directory2, entry.name);
@@ -827,22 +948,22 @@ var toIdentifier = (value, prefix) => {
827
948
  };
828
949
  var AUDIO_EXTENSIONS = /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i;
829
950
  var discoverAudio = async (config) => {
830
- const root = resolve8(config.root, config.audioDir);
831
- if (!existsSync8(root)) return [];
832
- const publicRoot = resolve8(config.root, "public");
951
+ const root = resolve9(config.root, config.audioDir);
952
+ if (!existsSync9(root)) return [];
953
+ const publicRoot = resolve9(config.root, "public");
833
954
  const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
834
955
  return Promise.all(
835
956
  files.map(async (file) => ({
836
- name: relative5(root, file).replace(AUDIO_EXTENSIONS, "").split(sep2).join("/"),
837
- url: file.startsWith(`${publicRoot}${sep2}`) ? `/${relative5(publicRoot, file).split(sep2).join("/")}` : `/${relative5(config.root, file).split(sep2).join("/")}`,
838
- relativeFile: relative5(config.root, file),
957
+ name: relative6(root, file).replace(AUDIO_EXTENSIONS, "").split(sep2).join("/"),
958
+ url: file.startsWith(`${publicRoot}${sep2}`) ? `/${relative6(publicRoot, file).split(sep2).join("/")}` : `/${relative6(config.root, file).split(sep2).join("/")}`,
959
+ relativeFile: relative6(config.root, file),
839
960
  bytes: (await stat(file)).size
840
961
  }))
841
962
  );
842
963
  };
843
964
  var discoverProject = async (config) => {
844
- const videosRoot = resolve8(config.root, config.videosDir);
845
- if (!existsSync8(videosRoot)) {
965
+ const videosRoot = resolve9(config.root, config.videosDir);
966
+ if (!existsSync9(videosRoot)) {
846
967
  throw new Error(`No ${config.videosDir}/ directory found in ${config.root}. Run "odori init" first.`);
847
968
  }
848
969
  const files = (await walk(videosRoot)).sort();
@@ -850,18 +971,42 @@ var discoverProject = async (config) => {
850
971
  const videos = [];
851
972
  const previews = [];
852
973
  const brands = [];
974
+ const categories = [];
853
975
  const hashParts = [];
854
- const componentsRoot = resolve8(config.root, config.componentsDir);
976
+ const importedBy = {};
977
+ const componentsRoot = resolve9(config.root, config.componentsDir);
855
978
  for (const file of files) {
856
- const relativeFile = relative5(config.root, file);
979
+ const relativeFile = relative6(config.root, file);
857
980
  let contents = "";
858
981
  if (/\.(tsx|ts|css|json)$/.test(file)) {
859
- contents = await readFile7(file, "utf8");
982
+ contents = await readFile8(file, "utf8");
860
983
  hashParts.push(`${relativeFile}:${hashString3(contents)}`);
861
984
  }
862
985
  const base = file.split(sep2).pop() ?? "";
986
+ if (base === "category.json") {
987
+ const path = relative6(componentsRoot, resolve9(file, "..")).split(sep2).join("/");
988
+ if (!path.startsWith("..")) {
989
+ try {
990
+ const declared = JSON.parse(contents);
991
+ categories.push({
992
+ path,
993
+ ...typeof declared.name === "string" ? { name: declared.name } : {},
994
+ ...typeof declared.order === "number" ? { order: declared.order } : {}
995
+ });
996
+ } catch (error) {
997
+ log.warn(
998
+ `${relativeFile} is not valid JSON, so that directory names itself: ${error instanceof Error ? error.message : String(error)}`
999
+ );
1000
+ }
1001
+ }
1002
+ }
1003
+ if (base === "video.tsx") {
1004
+ for (const match of contents.matchAll(/from\s+["'][^"']*\/components\/([^/"']+)\//g)) {
1005
+ (importedBy[match[1]] ??= /* @__PURE__ */ new Set()).add(relative6(videosRoot, file).replace(/\/?video\.tsx$/, "") || "video");
1006
+ }
1007
+ }
863
1008
  if (base === "video.tsx") {
864
- const slug = relative5(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
1009
+ const slug = relative6(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
865
1010
  videos.push({
866
1011
  slug,
867
1012
  file,
@@ -887,7 +1032,7 @@ var discoverProject = async (config) => {
887
1032
  // From the whole relative path, like previews: basenames repeat
888
1033
  // (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
889
1034
  identifier: toIdentifier(
890
- `${relative5(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
1035
+ `${relative6(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
891
1036
  "brands"
892
1037
  )
893
1038
  });
@@ -898,15 +1043,19 @@ var discoverProject = async (config) => {
898
1043
  file,
899
1044
  relativeFile,
900
1045
  importPath: file,
901
- identifier: toIdentifier(`${relative5(videosRoot, file).split(sep2).join("-")}`, "preview")
1046
+ identifier: toIdentifier(`${relative6(videosRoot, file).split(sep2).join("-")}`, "preview")
902
1047
  });
903
1048
  }
904
1049
  }
905
- return { videos, previews, brands, audio, sourceHash: hashString3(hashParts.join("|")) };
1050
+ for (const preview of previews) {
1051
+ const users = importedBy[preview.name];
1052
+ if (users) preview.usedBy = [...users].sort();
1053
+ }
1054
+ return { videos, previews, brands, audio, categories, sourceHash: hashString3(hashParts.join("|")) };
906
1055
  };
907
1056
  var generateImports = (graph, outDir) => {
908
1057
  const importPath = (file) => {
909
- const relativePath = relative5(outDir, file).split(sep2).join("/");
1058
+ const relativePath = relative6(outDir, file).split(sep2).join("/");
910
1059
  return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
911
1060
  };
912
1061
  const lines = [
@@ -939,17 +1088,21 @@ var generateImports = (graph, outDir) => {
939
1088
  return lines.join("\n");
940
1089
  };
941
1090
  var writeGenerated = async (config, graph) => {
942
- const outDir = resolve8(config.root, config.outDir);
943
- await mkdir6(outDir, { recursive: true });
1091
+ const outDir = resolve9(config.root, config.outDir);
1092
+ await mkdir7(outDir, { recursive: true });
944
1093
  const target = join3(outDir, "imports.generated.ts");
945
- await writeFile7(target, generateImports(graph, outDir), "utf8");
946
- await writeFile7(
1094
+ await writeFile8(target, generateImports(graph, outDir), "utf8");
1095
+ await writeFile8(
947
1096
  join3(outDir, "catalog.json"),
948
1097
  `${JSON.stringify(
949
1098
  {
950
1099
  sourceHash: graph.sourceHash,
951
1100
  videos: graph.videos.map((video) => ({ slug: video.slug, file: video.relativeFile })),
952
- previews: graph.previews.map((preview) => ({ name: preview.name, file: preview.relativeFile })),
1101
+ previews: graph.previews.map((preview) => ({
1102
+ name: preview.name,
1103
+ file: preview.relativeFile,
1104
+ usedBy: preview.usedBy ?? []
1105
+ })),
953
1106
  brands: graph.brands.map((brand) => ({ name: brand.name, file: brand.relativeFile })),
954
1107
  audio: graph.audio.map((entry) => ({ name: entry.name, url: entry.url, file: entry.relativeFile }))
955
1108
  },
@@ -963,7 +1116,7 @@ var writeGenerated = async (config, graph) => {
963
1116
  };
964
1117
 
965
1118
  // src/project.ts
966
- import { resolve as resolve11 } from "path";
1119
+ import { resolve as resolve12 } from "path";
967
1120
  import { pathToFileURL as pathToFileURL2 } from "url";
968
1121
  import {
969
1122
  createRenderManifest,
@@ -973,45 +1126,45 @@ import {
973
1126
  } from "odori";
974
1127
 
975
1128
  // src/integrity.ts
976
- import { createHash as createHash2 } from "crypto";
977
- import { existsSync as existsSync9 } from "fs";
978
- import { mkdir as mkdir7, readFile as readFile8, writeFile as writeFile8 } from "fs/promises";
979
- import { dirname as dirname3, resolve as resolve9 } from "path";
980
- var cacheFile = (config) => resolve9(config.root, config.outDir, "cache", "integrity.json");
1129
+ import { createHash as createHash3 } from "crypto";
1130
+ import { existsSync as existsSync10 } from "fs";
1131
+ import { mkdir as mkdir8, readFile as readFile9, writeFile as writeFile9 } from "fs/promises";
1132
+ import { dirname as dirname4, resolve as resolve10 } from "path";
1133
+ var cacheFile = (config) => resolve10(config.root, config.outDir, "cache", "integrity.json");
981
1134
  var readCache = async (config) => {
982
1135
  const file = cacheFile(config);
983
- if (!existsSync9(file)) return {};
1136
+ if (!existsSync10(file)) return {};
984
1137
  try {
985
- return JSON.parse(await readFile8(file, "utf8"));
1138
+ return JSON.parse(await readFile9(file, "utf8"));
986
1139
  } catch {
987
1140
  return {};
988
1141
  }
989
1142
  };
990
1143
  var writeCache = async (config, cache) => {
991
1144
  const file = cacheFile(config);
992
- await mkdir7(dirname3(file), { recursive: true });
993
- await writeFile8(file, `${JSON.stringify(cache, null, 2)}
1145
+ await mkdir8(dirname4(file), { recursive: true });
1146
+ await writeFile9(file, `${JSON.stringify(cache, null, 2)}
994
1147
  `, "utf8");
995
1148
  };
996
- var sha256 = (bytes) => `sha256-${createHash2("sha256").update(bytes).digest("base64")}`;
1149
+ var sha256 = (bytes) => `sha256-${createHash3("sha256").update(bytes).digest("base64")}`;
997
1150
  var localCandidates = (config, url) => [
998
- resolve9(config.root, "public", url.replace(/^\//, "")),
999
- resolve9(config.root, url.replace(/^\//, ""))
1151
+ resolve10(config.root, "public", url.replace(/^\//, "")),
1152
+ resolve10(config.root, url.replace(/^\//, ""))
1000
1153
  ];
1001
- var isServed = (config, file) => file.startsWith(resolve9(config.root, "public") + "/");
1154
+ var isServed = (config, file) => file.startsWith(resolve10(config.root, "public") + "/");
1002
1155
  var createIntegrityResolver = async (config) => {
1003
1156
  const cache = await readCache(config);
1004
1157
  const warned = /* @__PURE__ */ new Set();
1005
1158
  let dirty = false;
1006
1159
  const resolveIntegrity = async (url) => {
1007
1160
  if (url.startsWith("/__odori/cue/")) return `cue-${url.slice(url.lastIndexOf("-") + 1).replace(/\.wav$/, "")}`;
1008
- const local = localCandidates(config, url).find((candidate) => existsSync9(candidate));
1161
+ const local = localCandidates(config, url).find((candidate) => existsSync10(candidate));
1009
1162
  if (local) {
1010
1163
  if (!isServed(config, local) && !warned.has(url)) {
1011
1164
  warned.add(url);
1012
1165
  log.warn(`${url} resolves to ${local}, which is outside public/ and will not be served. Move it into public/.`);
1013
1166
  }
1014
- const bytes = await readFile8(local);
1167
+ const bytes = await readFile9(local);
1015
1168
  const { mtimeMs } = await import("fs/promises").then((fs) => fs.stat(local));
1016
1169
  const hit = cache[url];
1017
1170
  if (hit && hit.mtimeMs === mtimeMs && hit.size === bytes.byteLength) return hit.integrity;
@@ -1043,9 +1196,9 @@ var createIntegrityResolver = async (config) => {
1043
1196
  };
1044
1197
 
1045
1198
  // src/prepare-cache.ts
1046
- import { existsSync as existsSync10 } from "fs";
1047
- import { mkdir as mkdir8, readFile as readFile9, readdir as readdir4, rm, writeFile as writeFile9 } from "fs/promises";
1048
- import { join as join4, resolve as resolve10 } from "path";
1199
+ import { existsSync as existsSync11 } from "fs";
1200
+ import { mkdir as mkdir9, readFile as readFile10, readdir as readdir5, rm as rm3, writeFile as writeFile10 } from "fs/promises";
1201
+ import { join as join4, resolve as resolve11 } from "path";
1049
1202
  import { hashValue } from "odori";
1050
1203
 
1051
1204
  // src/paths.ts
@@ -1053,13 +1206,13 @@ var outputName = (id) => id.split("/").join("-");
1053
1206
  var fileKey = (id) => id.split("/").join("+");
1054
1207
 
1055
1208
  // src/prepare-cache.ts
1056
- var directory = (config) => resolve10(config.root, config.outDir, "cache", "prepare");
1209
+ var directory = (config) => resolve11(config.root, config.outDir, "cache", "prepare");
1057
1210
  var prepareCacheKey = (key) => `${fileKey(key.videoId)}__${hashValue(key)}`;
1058
1211
  var readPrepareCache = async (config, key) => {
1059
1212
  const file = join4(directory(config), `${prepareCacheKey(key)}.json`);
1060
- if (!existsSync10(file)) return { hit: false, value: void 0 };
1213
+ if (!existsSync11(file)) return { hit: false, value: void 0 };
1061
1214
  try {
1062
- const entry = JSON.parse(await readFile9(file, "utf8"));
1215
+ const entry = JSON.parse(await readFile10(file, "utf8"));
1063
1216
  return { hit: true, value: entry.value };
1064
1217
  } catch {
1065
1218
  return { hit: false, value: void 0 };
@@ -1068,17 +1221,17 @@ var readPrepareCache = async (config, key) => {
1068
1221
  var writePrepareCache = async (config, key, value) => {
1069
1222
  if (value === void 0) return;
1070
1223
  const target = directory(config);
1071
- await mkdir8(target, { recursive: true });
1224
+ await mkdir9(target, { recursive: true });
1072
1225
  const entry = { key, value, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1073
- await writeFile9(join4(target, `${prepareCacheKey(key)}.json`), `${JSON.stringify(entry, null, 2)}
1226
+ await writeFile10(join4(target, `${prepareCacheKey(key)}.json`), `${JSON.stringify(entry, null, 2)}
1074
1227
  `, "utf8");
1075
1228
  };
1076
1229
  var clearPrepareCache = async (config, videoId) => {
1077
1230
  const target = directory(config);
1078
- if (!existsSync10(target)) return 0;
1079
- const files = await readdir4(target);
1231
+ if (!existsSync11(target)) return 0;
1232
+ const files = await readdir5(target);
1080
1233
  const matches = files.filter((file) => videoId ? file.startsWith(`${fileKey(videoId)}__`) : file.endsWith(".json"));
1081
- await Promise.all(matches.map((file) => rm(join4(target, file), { force: true })));
1234
+ await Promise.all(matches.map((file) => rm3(join4(target, file), { force: true })));
1082
1235
  return matches.length;
1083
1236
  };
1084
1237
 
@@ -1122,7 +1275,7 @@ var findVideo = (videos, id) => {
1122
1275
  return found;
1123
1276
  };
1124
1277
  var runPrepare = async (video, config, graph, input, options = {}) => {
1125
- const prepareFile = resolve11(video.file, "..", "prepare.ts");
1278
+ const prepareFile = resolve12(video.file, "..", "prepare.ts");
1126
1279
  let prepare;
1127
1280
  try {
1128
1281
  const module = await import(pathToFileURL2(prepareFile).href);
@@ -1319,20 +1472,20 @@ var alphaWarning = (format, transparent) => transparent && !format.alpha ? `${fo
1319
1472
 
1320
1473
  // src/render.ts
1321
1474
  import { spawn as spawn2 } from "child_process";
1322
- import { copyFile as copyFile2, mkdir as mkdir11, rm as rm2, writeFile as writeFile12 } from "fs/promises";
1475
+ import { copyFile as copyFile2, mkdir as mkdir12, rm as rm4, writeFile as writeFile13 } from "fs/promises";
1323
1476
  import { cpus } from "os";
1324
- import { dirname as dirname4, join as join6, resolve as resolve15 } from "path";
1477
+ import { dirname as dirname5, join as join6, resolve as resolve16 } from "path";
1325
1478
  import { chromium } from "playwright-core";
1326
1479
 
1327
1480
  // src/audio-mix.ts
1328
- import { existsSync as existsSync12 } from "fs";
1329
- import { resolve as resolve13 } from "path";
1481
+ import { existsSync as existsSync13 } from "fs";
1482
+ import { resolve as resolve14 } from "path";
1330
1483
  import { duckEnvelope, envelopeAtFrame } from "odori";
1331
1484
 
1332
1485
  // src/cues.ts
1333
- import { existsSync as existsSync11, statSync } from "fs";
1334
- import { mkdir as mkdir9, writeFile as writeFile10 } from "fs/promises";
1335
- import { basename, resolve as resolve12 } from "path";
1486
+ import { existsSync as existsSync12, statSync } from "fs";
1487
+ import { mkdir as mkdir10, writeFile as writeFile11 } from "fs/promises";
1488
+ import { basename, resolve as resolve13 } from "path";
1336
1489
  import { pathToFileURL as pathToFileURL3 } from "url";
1337
1490
  import {
1338
1491
  SAMPLE_RATE,
@@ -1343,8 +1496,8 @@ import {
1343
1496
  isCueDefinition,
1344
1497
  resolveEntryLayout as resolveEntryLayout2
1345
1498
  } from "odori";
1346
- var cueCacheDir = (config) => resolve12(config.root, config.outDir, "cues");
1347
- var cueFile = (config, url) => resolve12(cueCacheDir(config), basename(url));
1499
+ var cueCacheDir = (config) => resolve13(config.root, config.outDir, "cues");
1500
+ var cueFile = (config, url) => resolve13(cueCacheDir(config), basename(url));
1348
1501
  var materializeCues = async (config, brands, fps) => {
1349
1502
  const seen = /* @__PURE__ */ new Map();
1350
1503
  for (const brand of brands) {
@@ -1353,17 +1506,17 @@ var materializeCues = async (config, brands, fps) => {
1353
1506
  }
1354
1507
  }
1355
1508
  if (seen.size === 0) return [];
1356
- await mkdir9(cueCacheDir(config), { recursive: true });
1509
+ await mkdir10(cueCacheDir(config), { recursive: true });
1357
1510
  const written = [];
1358
1511
  for (const [url, cue] of seen) {
1359
1512
  const file = cueFile(config, url);
1360
- if (existsSync11(file)) {
1513
+ if (existsSync12(file)) {
1361
1514
  written.push({ cue, file, rendered: false });
1362
1515
  continue;
1363
1516
  }
1364
1517
  const samples = cueSamples(cue, fps);
1365
1518
  const signal = cue.render({ samples, sampleRate: SAMPLE_RATE });
1366
- await writeFile10(file, encodeWav(signal));
1519
+ await writeFile11(file, encodeWav(signal));
1367
1520
  written.push({ cue, file, rendered: true });
1368
1521
  }
1369
1522
  return written;
@@ -1421,13 +1574,13 @@ var resolveCueFile = (config, src) => {
1421
1574
  if (/^https?:\/\//.test(src)) return null;
1422
1575
  if (src.startsWith("/__odori/cue/")) {
1423
1576
  const generated = cueFile(config, src);
1424
- return existsSync12(generated) ? generated : null;
1577
+ return existsSync13(generated) ? generated : null;
1425
1578
  }
1426
1579
  const candidates = [
1427
- resolve13(config.root, "public", src.replace(/^\//, "")),
1428
- resolve13(config.root, src.replace(/^\//, ""))
1580
+ resolve14(config.root, "public", src.replace(/^\//, "")),
1581
+ resolve14(config.root, src.replace(/^\//, ""))
1429
1582
  ];
1430
- return candidates.find((candidate) => existsSync12(candidate)) ?? null;
1583
+ return candidates.find((candidate) => existsSync13(candidate)) ?? null;
1431
1584
  };
1432
1585
  var volumeFilter = (cue, cues, fps) => {
1433
1586
  const authored = cue.gainPoints ?? [];
@@ -1547,11 +1700,11 @@ var planChunks = ({
1547
1700
  var chunkFrames = (chunk) => Array.from({ length: length(chunk) }, (_, offset) => chunk.start + offset);
1548
1701
 
1549
1702
  // src/chunk-cache.ts
1550
- import { existsSync as existsSync13 } from "fs";
1551
- import { copyFile, mkdir as mkdir10, readFile as readFile10, writeFile as writeFile11 } from "fs/promises";
1552
- import { join as join5, resolve as resolve14 } from "path";
1703
+ import { existsSync as existsSync14 } from "fs";
1704
+ import { copyFile, mkdir as mkdir11, readFile as readFile11, writeFile as writeFile12 } from "fs/promises";
1705
+ import { join as join5, resolve as resolve15 } from "path";
1553
1706
  import { hashValue as hashValue2 } from "odori";
1554
- var cacheDir2 = (config) => resolve14(config.root, config.outDir, "cache", "chunks");
1707
+ var cacheDir2 = (config) => resolve15(config.root, config.outDir, "cache", "chunks");
1555
1708
  var chunkKey = (identity) => hashValue2({
1556
1709
  videoId: identity.videoId,
1557
1710
  // The browser that drew the frames is part of what the frames are. Without
@@ -1576,9 +1729,9 @@ var chunkKey = (identity) => hashValue2({
1576
1729
  var readChunkRecord = async (config, key) => {
1577
1730
  const meta = join5(cacheDir2(config), `${key}.json`);
1578
1731
  const media = join5(cacheDir2(config), `${key}.mp4`);
1579
- if (!existsSync13(meta) || !existsSync13(media)) return null;
1732
+ if (!existsSync14(meta) || !existsSync14(media)) return null;
1580
1733
  try {
1581
- return JSON.parse(await readFile10(meta, "utf8"));
1734
+ return JSON.parse(await readFile11(meta, "utf8"));
1582
1735
  } catch {
1583
1736
  return null;
1584
1737
  }
@@ -1588,10 +1741,10 @@ var useChunkRecord = async (config, key, destination) => {
1588
1741
  };
1589
1742
  var writeChunkRecord = async (config, key, signatures, file) => {
1590
1743
  const directory2 = cacheDir2(config);
1591
- await mkdir10(directory2, { recursive: true });
1744
+ await mkdir11(directory2, { recursive: true });
1592
1745
  await copyFile(file, join5(directory2, `${key}.mp4`));
1593
1746
  const record = { key, signatures, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1594
- await writeFile11(join5(directory2, `${key}.json`), `${JSON.stringify(record)}
1747
+ await writeFile12(join5(directory2, `${key}.json`), `${JSON.stringify(record)}
1595
1748
  `, "utf8");
1596
1749
  };
1597
1750
  var signaturesMatch = (recorded, observed) => recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
@@ -1739,7 +1892,7 @@ var ensureFfmpeg = async (config) => {
1739
1892
  var renderStill = async (origin, target, frame, output, config) => {
1740
1893
  const { browser, page, errors } = await openRenderPage(origin, target, config);
1741
1894
  try {
1742
- await mkdir11(dirname4(output), { recursive: true });
1895
+ await mkdir12(dirname5(output), { recursive: true });
1743
1896
  await seekTo(page, frame);
1744
1897
  await page.screenshot({ path: output });
1745
1898
  if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
@@ -1895,8 +2048,8 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1895
2048
  const chunkFormat = chunkable ? format : LOSSLESS;
1896
2049
  const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
1897
2050
  const cache = options.cache ?? config.cacheChunks ?? true;
1898
- const work = options.workDir ?? resolve15(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
1899
- await mkdir11(work, { recursive: true });
2051
+ const work = options.workDir ?? resolve16(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
2052
+ await mkdir12(work, { recursive: true });
1900
2053
  const concurrency = chunkable ? requested : 1;
1901
2054
  const { chunks, lanes } = planChunks({
1902
2055
  durationInFrames: target.durationInFrames,
@@ -1907,7 +2060,7 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1907
2060
  const stats = { captured: 0, reused: 0, cachedChunks: 0 };
1908
2061
  let succeeded = false;
1909
2062
  try {
1910
- await mkdir11(dirname4(output), { recursive: true });
2063
+ await mkdir12(dirname5(output), { recursive: true });
1911
2064
  const captureStart = performance.now();
1912
2065
  await Promise.all(
1913
2066
  lanes.map(
@@ -1942,7 +2095,7 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1942
2095
  );
1943
2096
  const captureMs = performance.now() - captureStart;
1944
2097
  onProgress?.(1, "encoding");
1945
- const mixInputs = (target.audio ?? []).map((cue) => {
2098
+ const mixInputs = (options.audio === false ? [] : target.audio ?? []).map((cue) => {
1946
2099
  const file = resolveCueFile(config, cue.src);
1947
2100
  if (!file) log.warn(`Skipping audio cue ${cue.src}: only project-local files can be encoded.`);
1948
2101
  return file ? { file, cue } : null;
@@ -1954,12 +2107,12 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1954
2107
  await copyFile2(ordered[0], silent);
1955
2108
  } else {
1956
2109
  const list = join6(work, "chunks.txt");
1957
- await writeFile12(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
2110
+ await writeFile13(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
1958
2111
  await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
1959
2112
  }
1960
2113
  if (!chunkable) {
1961
2114
  const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
1962
- if (destination !== output) await mkdir11(dirname4(destination), { recursive: true });
2115
+ if (destination !== output) await mkdir12(dirname5(destination), { recursive: true });
1963
2116
  await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
1964
2117
  if (mixInputs.length > 0) {
1965
2118
  log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
@@ -2013,7 +2166,7 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
2013
2166
  succeeded = true;
2014
2167
  return output;
2015
2168
  } finally {
2016
- if (succeeded && !options.workDir) await rm2(work, { recursive: true, force: true });
2169
+ if (succeeded && !options.workDir) await rm4(work, { recursive: true, force: true });
2017
2170
  else if (!succeeded) log.detail(`Chunks left for inspection in ${work}`);
2018
2171
  }
2019
2172
  };
@@ -2045,25 +2198,25 @@ var openInBrowser = (url) => {
2045
2198
  };
2046
2199
 
2047
2200
  // src/server.ts
2048
- import { existsSync as existsSync14 } from "fs";
2201
+ import { existsSync as existsSync15 } from "fs";
2049
2202
  import { createRequire as createRequire2 } from "module";
2050
2203
  import { fileURLToPath } from "url";
2051
2204
  import { createServer } from "vite";
2052
2205
  import react from "@vitejs/plugin-react";
2053
- import { readFile as readFile11 } from "fs/promises";
2054
- import { dirname as dirname5, resolve as resolve16, sep as sep3 } from "path";
2055
- var cliRoot = resolve16(dirname5(fileURLToPath(import.meta.url)), "..");
2056
- var studioRoot = resolve16(cliRoot, "studio");
2057
- var studioEntry = resolve16(studioRoot, "index.html");
2058
- var installRoot = resolve16(cliRoot, "..", "..");
2206
+ import { readFile as readFile12 } from "fs/promises";
2207
+ import { dirname as dirname6, resolve as resolve17, sep as sep3 } from "path";
2208
+ var cliRoot = resolve17(dirname6(fileURLToPath(import.meta.url)), "..");
2209
+ var studioRoot = resolve17(cliRoot, "studio");
2210
+ var studioEntry = resolve17(studioRoot, "index.html");
2211
+ var installRoot = resolve17(cliRoot, "..", "..");
2059
2212
  var VIRTUAL_ID = "virtual:odori-project";
2060
2213
  var RESOLVED_ID = `\0${VIRTUAL_ID}`;
2061
2214
  var runtimeSource = (root) => {
2062
- for (const from of [resolve16(root, "package.json"), import.meta.url]) {
2215
+ for (const from of [resolve17(root, "package.json"), import.meta.url]) {
2063
2216
  try {
2064
2217
  const manifest = createRequire2(from).resolve("odori/package.json");
2065
- const src = resolve16(manifest, "..", "src");
2066
- if (existsSync14(resolve16(src, "index.tsx"))) return src;
2218
+ const src = resolve17(manifest, "..", "src");
2219
+ if (existsSync15(resolve17(src, "index.tsx"))) return src;
2067
2220
  } catch {
2068
2221
  }
2069
2222
  }
@@ -2102,6 +2255,8 @@ var odoriProjectPlugin = (config, getGraph) => ({
2102
2255
  `export const project = ${JSON.stringify({
2103
2256
  root: config.root,
2104
2257
  videosDir: config.videosDir,
2258
+ componentsDir: config.componentsDir,
2259
+ categories: graph.categories,
2105
2260
  exportDir: config.exportDir,
2106
2261
  audioDir: config.audioDir,
2107
2262
  docsUrl: config.docsUrl,
@@ -2110,7 +2265,11 @@ var odoriProjectPlugin = (config, getGraph) => ({
2110
2265
  assets: config.assets ?? [],
2111
2266
  files: {
2112
2267
  videos: graph.videos.map((video) => ({ id: video.slug, file: video.relativeFile })),
2113
- previews: graph.previews.map((preview) => ({ id: preview.name, file: preview.relativeFile })),
2268
+ previews: graph.previews.map((preview) => ({
2269
+ id: preview.name,
2270
+ file: preview.relativeFile,
2271
+ usedBy: preview.usedBy ?? []
2272
+ })),
2114
2273
  brands: graph.brands.map((brand) => ({ id: brand.name, file: brand.relativeFile }))
2115
2274
  }
2116
2275
  })};`
@@ -2180,15 +2339,15 @@ var startStudioServer = async (initialConfig, options = {}) => {
2180
2339
  ],
2181
2340
  // The project's public/ directory is served at the root, so brand fonts,
2182
2341
  // logos, and footage resolve identically in preview and render.
2183
- publicDir: existsSync14(resolve16(config.root, "public")) ? resolve16(config.root, "public") : false,
2342
+ publicDir: existsSync15(resolve17(config.root, "public")) ? resolve17(config.root, "public") : false,
2184
2343
  resolve: {
2185
2344
  dedupe: ["react", "react-dom", "odori"],
2186
2345
  // Only when the runtime is present as source. A consumer resolves the
2187
2346
  // published package through its exports map instead.
2188
2347
  alias: odoriSrc ? [
2189
- { find: /^odori\/preview$/, replacement: resolve16(odoriSrc, "preview.ts") },
2190
- { find: /^odori\/manifest$/, replacement: resolve16(odoriSrc, "manifest.ts") },
2191
- { find: /^odori$/, replacement: resolve16(odoriSrc, "index.tsx") }
2348
+ { find: /^odori\/preview$/, replacement: resolve17(odoriSrc, "preview.ts") },
2349
+ { find: /^odori\/manifest$/, replacement: resolve17(odoriSrc, "manifest.ts") },
2350
+ { find: /^odori$/, replacement: resolve17(odoriSrc, "index.tsx") }
2192
2351
  ] : []
2193
2352
  },
2194
2353
  server: {
@@ -2218,7 +2377,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2218
2377
  }, 150);
2219
2378
  };
2220
2379
  const rediscover = async (file) => {
2221
- if (!file.startsWith(resolve16(config.root, config.videosDir))) return;
2380
+ if (!file.startsWith(resolve17(config.root, config.videosDir))) return;
2222
2381
  const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep3}brands${sep3}`);
2223
2382
  if (!isEntry) return;
2224
2383
  try {
@@ -2235,7 +2394,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2235
2394
  };
2236
2395
  vite.watcher.on("add", (file) => void rediscover(file));
2237
2396
  vite.watcher.on("unlink", (file) => void rediscover(file));
2238
- vite.watcher.add(resolve16(config.root, config.videosDir));
2397
+ vite.watcher.add(resolve17(config.root, config.videosDir));
2239
2398
  const reloadConfig = async (file) => {
2240
2399
  if (!/odori\.config\.(?:ts|mjs|js)$/.test(file)) return;
2241
2400
  try {
@@ -2252,14 +2411,14 @@ var startStudioServer = async (initialConfig, options = {}) => {
2252
2411
  vite.ws.send({ type: "full-reload" });
2253
2412
  };
2254
2413
  const refreshCues = (file) => {
2255
- if (!file.startsWith(resolve16(config.root, config.videosDir) + sep3)) return;
2414
+ if (!file.startsWith(resolve17(config.root, config.videosDir) + sep3)) return;
2256
2415
  if (!/\.tsx?$/.test(file)) return;
2257
2416
  scheduleCueRefresh();
2258
2417
  };
2259
2418
  vite.watcher.on("change", (file) => refreshCues(file));
2260
2419
  vite.watcher.on("change", (file) => void reloadConfig(file));
2261
2420
  for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
2262
- vite.watcher.add(resolve16(config.root, name));
2421
+ vite.watcher.add(resolve17(config.root, name));
2263
2422
  }
2264
2423
  vite.middlewares.use(async (request, response, next) => {
2265
2424
  const url = (request.url ?? "/").split("?")[0];
@@ -2269,7 +2428,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2269
2428
  return;
2270
2429
  }
2271
2430
  try {
2272
- const html = await readFile11(studioEntry, "utf8");
2431
+ const html = await readFile12(studioEntry, "utf8");
2273
2432
  response.statusCode = 200;
2274
2433
  response.setHeader("content-type", "text/html");
2275
2434
  response.end(await vite.transformIndexHtml(url, html));
@@ -2290,7 +2449,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2290
2449
  };
2291
2450
 
2292
2451
  // src/commands/exportVideo.ts
2293
- import { resolve as resolve17 } from "path";
2452
+ import { resolve as resolve18 } from "path";
2294
2453
  import { resolveEntryLayout as resolveEntryLayout4 } from "odori";
2295
2454
 
2296
2455
  // src/commands/shared.ts
@@ -2405,6 +2564,7 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
2405
2564
  quality: options.quality ?? record.render?.quality,
2406
2565
  scale: options.scale ?? record.render?.scale,
2407
2566
  format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : void 0),
2567
+ audio: options.audio ?? record.render?.audio,
2408
2568
  skipUnchangedFrames: options.skipUnchangedFrames,
2409
2569
  signal: controller.signal,
2410
2570
  onTimings: (timings) => {
@@ -2459,7 +2619,7 @@ var exportCommand = async (id, options = {}) => {
2459
2619
  options.input ?? {},
2460
2620
  { scenes: compiled.scenes, audio: compiled.audio }
2461
2621
  );
2462
- const output = resolve17(
2622
+ const output = resolve18(
2463
2623
  config.root,
2464
2624
  options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
2465
2625
  );
@@ -2467,6 +2627,7 @@ var exportCommand = async (id, options = {}) => {
2467
2627
  format: format.name,
2468
2628
  quality,
2469
2629
  scale,
2630
+ audio: options.audio !== false,
2470
2631
  ...options.preset ? { preset: options.preset } : {}
2471
2632
  });
2472
2633
  })();
@@ -2483,6 +2644,7 @@ var exportCommand = async (id, options = {}) => {
2483
2644
  quality: options.retry && options.quality === void 0 ? void 0 : quality,
2484
2645
  scale: options.retry && options.scale === void 0 ? void 0 : scale,
2485
2646
  format: options.retry ? void 0 : format,
2647
+ audio: options.audio,
2486
2648
  skipUnchangedFrames: options.skipUnchangedFrames,
2487
2649
  onProgress: (next) => {
2488
2650
  if (next.status === "rendering" || next.status === "encoding") {
@@ -2525,8 +2687,8 @@ var json = (response, status, payload) => {
2525
2687
  response.end(JSON.stringify(payload));
2526
2688
  };
2527
2689
  var exportDestination = (config) => {
2528
- const downloads = resolve18(homedir2(), "Downloads");
2529
- return existsSync15(downloads) ? downloads : resolve18(config.root, config.exportDir);
2690
+ const downloads = resolve19(homedir2(), "Downloads");
2691
+ return existsSync16(downloads) ? downloads : resolve19(config.root, config.exportDir);
2530
2692
  };
2531
2693
  var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
2532
2694
  var hostOf = (value) => {
@@ -2585,7 +2747,7 @@ var devCommand = async (options = {}) => {
2585
2747
  );
2586
2748
  const frame = Number(body.frame ?? 0);
2587
2749
  const inline = body.inline === true;
2588
- const file = inline ? resolve18(config.root, config.outDir, `${outputName(video.entry.metadata.id)}-${frame}.png`) : resolve18(exportDestination(config), `${outputName(video.entry.metadata.id)}-${frame}.png`);
2750
+ const file = inline ? resolve19(config.root, config.outDir, `${outputName(video.entry.metadata.id)}-${frame}.png`) : resolve19(exportDestination(config), `${outputName(video.entry.metadata.id)}-${frame}.png`);
2589
2751
  await renderStill(
2590
2752
  origin,
2591
2753
  targetFor(
@@ -2601,7 +2763,7 @@ var devCommand = async (options = {}) => {
2601
2763
  if (inline) {
2602
2764
  response.statusCode = 200;
2603
2765
  response.setHeader("content-type", "image/png");
2604
- response.end(await readFile12(file));
2766
+ response.end(await readFile13(file));
2605
2767
  return;
2606
2768
  }
2607
2769
  json(response, 200, { id: "still", status: "ready", progress: 1, output: file });
@@ -2623,11 +2785,12 @@ var devCommand = async (options = {}) => {
2623
2785
  input,
2624
2786
  { scenes: compiled.scenes, audio: compiled.audio }
2625
2787
  );
2626
- const output = resolve18(
2788
+ const output = resolve19(
2627
2789
  exportDestination(config),
2628
2790
  `${outputName(video.entry.metadata.id)}${format.extension}`
2629
2791
  );
2630
- const record = await createJob(config, manifest, output, { format: format.name, quality, scale });
2792
+ const audio = body.audio !== false;
2793
+ const record = await createJob(config, manifest, output, { format: format.name, quality, scale, audio });
2631
2794
  json(response, 202, record.job);
2632
2795
  void runJob(config, origin, record, video).catch((error) => {
2633
2796
  log.error(`Export failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -2656,6 +2819,24 @@ var devCommand = async (options = {}) => {
2656
2819
  json(response, 200, (await readJob(config, id)).job);
2657
2820
  return;
2658
2821
  }
2822
+ if (request.method === "GET" && url.startsWith("/source")) {
2823
+ const asked = new URL(url, "http://localhost").searchParams.get("file") ?? "";
2824
+ const file = resolve19(config.root, asked);
2825
+ const inside = relative7(config.root, file);
2826
+ const readable = /\.(tsx?|jsx?|css|json|md)$/.test(file);
2827
+ if (!inside || inside.startsWith("..") || !readable) {
2828
+ json(response, 400, { error: `Refusing to read ${asked}.` });
2829
+ return;
2830
+ }
2831
+ try {
2832
+ response.statusCode = 200;
2833
+ response.setHeader("content-type", "text/plain; charset=utf-8");
2834
+ response.end(await readFile13(file, "utf8"));
2835
+ } catch {
2836
+ json(response, 404, { error: `${asked} is not there.` });
2837
+ }
2838
+ return;
2839
+ }
2659
2840
  if (request.method === "GET" && url.startsWith("/jobs")) {
2660
2841
  json(response, 200, await listJobs(config));
2661
2842
  return;
@@ -2680,16 +2861,16 @@ var devCommand = async (options = {}) => {
2680
2861
 
2681
2862
  // src/commands/doctor.ts
2682
2863
  import { constants } from "fs";
2683
- import { access, mkdir as mkdir12, readFile as readFile13, rm as rm3, writeFile as writeFile13 } from "fs/promises";
2684
- import { existsSync as existsSync16 } from "fs";
2864
+ import { access, mkdir as mkdir13, readFile as readFile14, rm as rm5, writeFile as writeFile14 } from "fs/promises";
2865
+ import { existsSync as existsSync17 } from "fs";
2685
2866
  import { createRequire as createRequire3 } from "module";
2686
- import { relative as relative6, resolve as resolve19 } from "path";
2867
+ import { relative as relative8, resolve as resolve20 } from "path";
2687
2868
  var MINIMUM_NODE = 20;
2688
2869
  var version = (value) => value.replace(/^v/, "").split(".").map(Number);
2689
2870
  var runChecks = async (root) => {
2690
2871
  const checks = [];
2691
2872
  const config = await loadConfig(root);
2692
- const require2 = createRequire3(resolve19(root, "package.json"));
2873
+ const require2 = createRequire3(resolve20(root, "package.json"));
2693
2874
  const [major] = version(process.version);
2694
2875
  checks.push({
2695
2876
  name: "Node",
@@ -2700,7 +2881,7 @@ var runChecks = async (root) => {
2700
2881
  let react2 = "not found";
2701
2882
  let reactOk = false;
2702
2883
  try {
2703
- const manifest = JSON.parse(await readFile13(require2.resolve("react/package.json"), "utf8"));
2884
+ const manifest = JSON.parse(await readFile14(require2.resolve("react/package.json"), "utf8"));
2704
2885
  react2 = manifest.version;
2705
2886
  reactOk = version(react2)[0] >= 19;
2706
2887
  } catch {
@@ -2712,16 +2893,16 @@ var runChecks = async (root) => {
2712
2893
  ok: reactOk,
2713
2894
  fix: "The runtime needs React 19. Install it: npm install react@19 react-dom@19"
2714
2895
  });
2715
- const videosDir = resolve19(config.root, config.videosDir);
2896
+ const videosDir = resolve20(config.root, config.videosDir);
2716
2897
  checks.push({
2717
2898
  name: "Source root",
2718
- detail: existsSync16(videosDir) ? relative6(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2719
- ok: existsSync16(videosDir),
2899
+ detail: existsSync17(videosDir) ? relative8(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2900
+ ok: existsSync17(videosDir),
2720
2901
  fix: 'Run "odori init" to add the videos source root.'
2721
2902
  });
2722
2903
  checks.push({
2723
2904
  name: "Config",
2724
- detail: config.configPath ? relative6(config.root, config.configPath) : "defaults (no odori.config.ts)",
2905
+ detail: config.configPath ? relative8(config.root, config.configPath) : "defaults (no odori.config.ts)",
2725
2906
  // Loading got this far, so a config that exists also parsed.
2726
2907
  ok: true
2727
2908
  });
@@ -2747,23 +2928,42 @@ var runChecks = async (root) => {
2747
2928
  detail: unpinned.length === 0 ? `pinned binaries from ${cacheRoot()}` : `${unpinned.length} of 2 from the host; frames may differ from another machine`,
2748
2929
  ok: true
2749
2930
  });
2750
- const generated = resolve19(config.root, ".odori");
2931
+ const generated = resolve20(config.root, ".odori");
2751
2932
  let writable = false;
2752
2933
  try {
2753
- await mkdir12(generated, { recursive: true });
2754
- const probe = resolve19(generated, ".doctor");
2755
- await writeFile13(probe, "", "utf8");
2934
+ await mkdir13(generated, { recursive: true });
2935
+ const probe = resolve20(generated, ".doctor");
2936
+ await writeFile14(probe, "", "utf8");
2756
2937
  await access(probe, constants.W_OK);
2757
- await rm3(probe, { force: true });
2938
+ await rm5(probe, { force: true });
2758
2939
  writable = true;
2759
2940
  } catch {
2760
2941
  writable = false;
2761
2942
  }
2943
+ const componentsRoot = resolve20(root, config.componentsDir);
2944
+ const orphans = [];
2945
+ if (existsSync17(componentsRoot)) {
2946
+ const { readdir: readdir9 } = await import("fs/promises");
2947
+ for (const entry of await readdir9(componentsRoot, { withFileTypes: true })) {
2948
+ if (!entry.isDirectory()) continue;
2949
+ const files = await readdir9(resolve20(componentsRoot, entry.name));
2950
+ const source = files.some((file) => /\.tsx$/.test(file) && !file.endsWith(".preview.tsx"));
2951
+ const fixture = files.some((file) => file.endsWith(".preview.tsx"));
2952
+ if (source && !fixture) orphans.push(entry.name);
2953
+ }
2954
+ }
2955
+ checks.push({
2956
+ name: "Component previews",
2957
+ detail: orphans.length === 0 ? "every component has a fixture" : `${orphans.length} without a fixture: ${orphans.slice(0, 4).join(", ")}${orphans.length > 4 ? ", \u2026" : ""}`,
2958
+ ok: true,
2959
+ warn: orphans.length > 0,
2960
+ 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.`
2961
+ });
2762
2962
  checks.push({
2763
2963
  name: "Generated cache",
2764
2964
  detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
2765
2965
  ok: writable,
2766
- fix: `Odori writes its import graph and render cache to ${relative6(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
2966
+ fix: `Odori writes its import graph and render cache to ${relative8(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
2767
2967
  });
2768
2968
  return checks;
2769
2969
  };
@@ -2773,12 +2973,15 @@ var doctorCommand = async (root = process.cwd()) => {
2773
2973
  log.title("odori doctor");
2774
2974
  for (const check of checks) {
2775
2975
  const label = check.name.padEnd(width);
2776
- if (check.ok) log.success(`${label} ${check.detail}`);
2976
+ if (check.ok && check.warn) log.warn(`${label} ${check.detail}`);
2977
+ else if (check.ok) log.success(`${label} ${check.detail}`);
2777
2978
  else log.error(`${label} ${check.detail}`);
2778
2979
  }
2980
+ const warned = checks.filter((check) => check.ok && check.warn);
2779
2981
  const failed = checks.filter((check) => !check.ok);
2780
2982
  if (failed.length === 0) {
2781
2983
  log.detail("Everything a render needs is present.");
2984
+ for (const check of warned) log.detail(` ${check.fix}`);
2782
2985
  return 0;
2783
2986
  }
2784
2987
  log.info("");
@@ -2787,14 +2990,14 @@ var doctorCommand = async (root = process.cwd()) => {
2787
2990
  };
2788
2991
 
2789
2992
  // src/commands/init.ts
2790
- import { mkdir as mkdir14, writeFile as writeFile15 } from "fs/promises";
2791
- import { existsSync as existsSync18 } from "fs";
2792
- import { relative as relative8, resolve as resolve21 } from "path";
2993
+ import { mkdir as mkdir15, writeFile as writeFile16 } from "fs/promises";
2994
+ import { existsSync as existsSync19 } from "fs";
2995
+ import { relative as relative10, resolve as resolve22 } from "path";
2793
2996
 
2794
2997
  // src/commands/new.ts
2795
- import { mkdir as mkdir13, readdir as readdir5, writeFile as writeFile14 } from "fs/promises";
2796
- import { existsSync as existsSync17 } from "fs";
2797
- import { relative as relative7, resolve as resolve20 } from "path";
2998
+ import { mkdir as mkdir14, readdir as readdir6, writeFile as writeFile15 } from "fs/promises";
2999
+ import { existsSync as existsSync18 } from "fs";
3000
+ import { relative as relative9, resolve as resolve21 } from "path";
2798
3001
  var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2799
3002
  var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
2800
3003
  var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
@@ -2852,27 +3055,27 @@ ${closing}
2852
3055
  `;
2853
3056
  };
2854
3057
  var installedParts = async (config) => {
2855
- const componentsDir = resolve20(config.root, config.componentsDir);
2856
- if (!existsSync17(componentsDir)) return { title: false, end: false };
2857
- const entries = (await readdir5(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
3058
+ const componentsDir = resolve21(config.root, config.componentsDir);
3059
+ if (!existsSync18(componentsDir)) return { title: false, end: false };
3060
+ const entries = (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2858
3061
  return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
2859
3062
  };
2860
3063
  var newCommand = async (name, options = {}) => {
2861
3064
  if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("Use a lowercase, dash separated name.");
2862
3065
  const config = await loadConfig(process.cwd());
2863
- const directory2 = resolve20(config.root, config.videosDir, name);
2864
- const file = resolve20(directory2, "video.tsx");
2865
- if (existsSync17(file)) throw new Error(`${relative7(config.root, file)} already exists.`);
2866
- const hasLayout = existsSync17(resolve20(config.root, config.videosDir, "layout.tsx"));
3066
+ const directory2 = resolve21(config.root, config.videosDir, name);
3067
+ const file = resolve21(directory2, "video.tsx");
3068
+ if (existsSync18(file)) throw new Error(`${relative9(config.root, file)} already exists.`);
3069
+ const hasLayout = existsSync18(resolve21(config.root, config.videosDir, "layout.tsx"));
2867
3070
  const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
2868
3071
  const composed = parts.title || parts.end;
2869
- await mkdir13(directory2, { recursive: true });
2870
- await writeFile14(
3072
+ await mkdir14(directory2, { recursive: true });
3073
+ await writeFile15(
2871
3074
  file,
2872
3075
  composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
2873
3076
  "utf8"
2874
3077
  );
2875
- log.success(`Created ${relative7(config.root, file)}`);
3078
+ log.success(`Created ${relative9(config.root, file)}`);
2876
3079
  if (composed) log.detail("Composed from the components this project has installed.");
2877
3080
  else if (options.blank !== true) {
2878
3081
  log.detail("No registry components installed yet: odori add title-reveal end-card");
@@ -2906,21 +3109,21 @@ export const productLayout = defineVideoLayout({
2906
3109
  });
2907
3110
  `;
2908
3111
  var initCommand = async (root = process.cwd()) => {
2909
- const videosDir = resolve21(root, defaultConfig.videosDir);
2910
- await mkdir14(resolve21(videosDir, "components"), { recursive: true });
3112
+ const videosDir = resolve22(root, defaultConfig.videosDir);
3113
+ await mkdir15(resolve22(videosDir, "components"), { recursive: true });
2911
3114
  const files = [
2912
- [resolve21(root, "odori.config.ts"), CONFIG_TEMPLATE],
2913
- [resolve21(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
2914
- [resolve21(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
3115
+ [resolve22(root, "odori.config.ts"), CONFIG_TEMPLATE],
3116
+ [resolve22(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
3117
+ [resolve22(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
2915
3118
  ];
2916
3119
  for (const [file, contents] of files) {
2917
- if (existsSync18(file)) {
2918
- log.detail(`Kept existing ${relative8(root, file)}`);
3120
+ if (existsSync19(file)) {
3121
+ log.detail(`Kept existing ${relative10(root, file)}`);
2919
3122
  continue;
2920
3123
  }
2921
- await mkdir14(resolve21(file, ".."), { recursive: true });
2922
- await writeFile15(file, contents, "utf8");
2923
- log.success(`Created ${relative8(root, file)}`);
3124
+ await mkdir15(resolve22(file, ".."), { recursive: true });
3125
+ await writeFile16(file, contents, "utf8");
3126
+ log.success(`Created ${relative10(root, file)}`);
2924
3127
  }
2925
3128
  log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
2926
3129
  };
@@ -3011,15 +3214,16 @@ var listCommand = async () => {
3011
3214
  }
3012
3215
  };
3013
3216
 
3014
- // src/commands/still.ts
3015
- import { resolve as resolve22 } from "path";
3016
- var stillCommand = async (id, options = {}) => {
3017
- const frame = options.frame ?? 0;
3018
- if (!Number.isInteger(frame) || frame < 0) {
3019
- throw new Error(`Frame must be a whole number of frames from the start, got ${String(frame)}.`);
3020
- }
3217
+ // src/commands/frame.ts
3218
+ import { resolve as resolve23 } from "path";
3219
+ import { framesFromOffset, resolveEntryLayout as resolveEntryLayout7 } from "odori";
3220
+ var frameCommand = async (id, options = {}) => {
3221
+ const at = options.at ?? 0;
3222
+ framesFromOffset(at, 30);
3021
3223
  const { config, graph, videos } = await createContext();
3022
3224
  const video = findVideo(videos, id);
3225
+ const fps = resolveEntryLayout7(video.entry).format.fps;
3226
+ const frame = framesFromOffset(at, fps);
3023
3227
  const output = await withServer(config, async (server) => {
3024
3228
  const { durationInFrames, scenes, audio } = await compileInBrowser(server.url, targetFor(video, options.input), config);
3025
3229
  const { manifest, input, prepared } = await freezeManifest(
@@ -3033,21 +3237,21 @@ var stillCommand = async (id, options = {}) => {
3033
3237
  throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
3034
3238
  }
3035
3239
  const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
3036
- const file = resolve22(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3240
+ const file = resolve23(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3037
3241
  return renderStill(server.url, target, frame, file, config);
3038
3242
  });
3039
- log.success(`Still frame ${frame} written to ${output}`);
3243
+ log.success(`Frame ${frame} written to ${output}`);
3040
3244
  return output;
3041
3245
  };
3042
3246
 
3043
3247
  // src/commands/test.ts
3044
- import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3248
+ import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout9 } from "odori";
3045
3249
 
3046
3250
  // src/contracts.ts
3047
- import { existsSync as existsSync19 } from "fs";
3048
- import { readdir as readdir6 } from "fs/promises";
3049
- import { resolve as resolve23 } from "path";
3050
- import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout7 } from "odori";
3251
+ import { existsSync as existsSync20 } from "fs";
3252
+ import { readdir as readdir7 } from "fs/promises";
3253
+ import { resolve as resolve24 } from "path";
3254
+ import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3051
3255
  var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
3052
3256
  var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
3053
3257
  "system-ui",
@@ -3113,8 +3317,8 @@ var checkAudioWindows = (cues, brand, videoId) => {
3113
3317
  return failures;
3114
3318
  };
3115
3319
  var checkInstalledContracts = async (config, videos) => {
3116
- const componentsDir = resolve23(config.root, config.componentsDir);
3117
- const onDisk = existsSync19(componentsDir) ? (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
3320
+ const componentsDir = resolve24(config.root, config.componentsDir);
3321
+ const onDisk = existsSync20(componentsDir) ? (await readdir7(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
3118
3322
  const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
3119
3323
  if (names.size === 0) return [];
3120
3324
  const { items } = await resolveRegistry(config, { allowNetwork: false });
@@ -3123,7 +3327,7 @@ var checkInstalledContracts = async (config, videos) => {
3123
3327
  const seen = /* @__PURE__ */ new Set();
3124
3328
  const failures = [];
3125
3329
  for (const video of videos) {
3126
- const { brand } = resolveEntryLayout7(video.entry);
3330
+ const { brand } = resolveEntryLayout8(video.entry);
3127
3331
  for (const failure of checkComponentRequirements(installed, brand, video.entry.metadata.id)) {
3128
3332
  const key = `${brand.name}:${failure.message}`;
3129
3333
  if (seen.has(key)) continue;
@@ -3135,9 +3339,9 @@ var checkInstalledContracts = async (config, videos) => {
3135
3339
  };
3136
3340
 
3137
3341
  // src/determinism.ts
3138
- import { readdir as readdir7, readFile as readFile14 } from "fs/promises";
3139
- import { existsSync as existsSync20 } from "fs";
3140
- import { join as join7, relative as relative9, resolve as resolve24 } from "path";
3342
+ import { readdir as readdir8, readFile as readFile15 } from "fs/promises";
3343
+ import { existsSync as existsSync21 } from "fs";
3344
+ import { join as join7, relative as relative11, resolve as resolve25 } from "path";
3141
3345
  var FORBIDDEN = [
3142
3346
  {
3143
3347
  pattern: /\bMath\.random\s*\(/,
@@ -3169,7 +3373,7 @@ var scanSource = (source, file) => {
3169
3373
  return findings;
3170
3374
  };
3171
3375
  var walk2 = async (directory2, files = []) => {
3172
- for (const entry of await readdir7(directory2, { withFileTypes: true })) {
3376
+ for (const entry of await readdir8(directory2, { withFileTypes: true })) {
3173
3377
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
3174
3378
  const full = join7(directory2, entry.name);
3175
3379
  if (entry.isDirectory()) await walk2(full, files);
@@ -3178,11 +3382,11 @@ var walk2 = async (directory2, files = []) => {
3178
3382
  return files;
3179
3383
  };
3180
3384
  var checkDeterminism = async (config) => {
3181
- const root = resolve24(config.root, config.videosDir);
3182
- if (!existsSync20(root)) return [];
3385
+ const root = resolve25(config.root, config.videosDir);
3386
+ if (!existsSync21(root)) return [];
3183
3387
  const files = await walk2(root);
3184
3388
  const findings = await Promise.all(
3185
- files.map(async (file) => scanSource(await readFile14(file, "utf8"), relative9(config.root, file)))
3389
+ files.map(async (file) => scanSource(await readFile15(file, "utf8"), relative11(config.root, file)))
3186
3390
  );
3187
3391
  return findings.flat();
3188
3392
  };
@@ -3281,7 +3485,7 @@ var FRAME_SCRIPT = `(() => {
3281
3485
  })()`;
3282
3486
  var testVideo = async (origin, video, config, failures, quiet = false) => {
3283
3487
  const id = video.entry.metadata.id;
3284
- const layout = resolveEntryLayout8(video.entry);
3488
+ const layout = resolveEntryLayout9(video.entry);
3285
3489
  if (isOdoriSchema2(video.entry.metadata.schema)) {
3286
3490
  const result = video.entry.metadata.schema.safeParse(video.entry.metadata.defaultProps ?? {});
3287
3491
  if (!result.success) failures.push({ video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}` });
@@ -3390,6 +3594,8 @@ var testCommand = async (id, options = {}) => {
3390
3594
  };
3391
3595
 
3392
3596
  // src/cli.ts
3597
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["force", "dry-run", "json", "no-audio", "no-frame-skip", "no-open", "open", "help", "version"]);
3598
+ var RENAMED = { still: "frame" };
3393
3599
  var parseArgs = (argv) => {
3394
3600
  const [command2 = "help", ...rest] = argv;
3395
3601
  const positionals = [];
@@ -3404,7 +3610,7 @@ var parseArgs = (argv) => {
3404
3610
  }
3405
3611
  const name = token.slice(2);
3406
3612
  const next = rest[index + 1];
3407
- if (next === void 0 || next.startsWith("--")) flags[name] = true;
3613
+ if (BOOLEAN_FLAGS.has(name) || next === void 0 || next.startsWith("--")) flags[name] = true;
3408
3614
  else {
3409
3615
  flags[name] = next;
3410
3616
  index += 1;
@@ -3446,9 +3652,9 @@ var COMMAND_FLAGS = {
3446
3652
  update: ["force"],
3447
3653
  list: [],
3448
3654
  inspect: ["json", "input"],
3449
- still: ["frame", "output", "input"],
3655
+ frame: ["at", "output", "input"],
3450
3656
  test: ["json"],
3451
- export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
3657
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "no-frame-skip", "retry"],
3452
3658
  jobs: [],
3453
3659
  help: []
3454
3660
  };
@@ -3509,18 +3715,20 @@ var USAGE = {
3509
3715
  Print discovered video ids and formats.`,
3510
3716
  inspect: `odori inspect <id> [--json] [--input <json>]
3511
3717
  Show resolved layout, inputs, scenes, and assets.`,
3512
- still: `odori still <id> --frame <n> [--output <path>] [--input <json>]
3513
- Render one deterministic frame.`,
3718
+ frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
3719
+ Render one deterministic frame to a PNG. --at is a duration: 4s, 500ms, or
3720
+ 120f for frame 120. A bare number is seconds.`,
3514
3721
  test: `odori test [id] [--json]
3515
3722
  Validate contracts and representative frames. --json emits one object per
3516
3723
  check, for CI.`,
3517
3724
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
3518
3725
  [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
3519
- [--no-frame-skip] [--retry <job>]
3726
+ [--no-audio] [--no-frame-skip] [--retry <job>]
3520
3727
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
3521
3728
  or png; without it the output's extension decides, and mp4 is the default.
3522
3729
  --quality is studio, social, or web. --scale multiplies the output size,
3523
- 0.25 to 2. A retry keeps the settings its job was created with.`,
3730
+ 0.25 to 2. --no-audio writes the picture with no sound. A retry keeps the
3731
+ settings its job was created with.`,
3524
3732
  jobs: `odori jobs
3525
3733
  List export jobs and their status.`
3526
3734
  };
@@ -3538,14 +3746,14 @@ Usage
3538
3746
  odori update [components] Apply upstream component changes
3539
3747
  odori list Print discovered video ids and formats
3540
3748
  odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
3541
- odori still <id> --frame 120 Render one deterministic frame
3749
+ odori frame <id> --at 4s Render one deterministic frame to a PNG
3542
3750
  odori test [id] [--json] Validate contracts and representative frames
3543
3751
  odori export <id> [--output f] Render and encode a distributable file
3544
3752
  odori jobs List export jobs and their status
3545
3753
 
3546
3754
  Options
3547
3755
  --input '{"headline":"..."}' Serializable input for the video schema
3548
- --output <path> Output path for still and export
3756
+ --output <path> Output path for frame and export
3549
3757
  --force Replace locally modified component source
3550
3758
  --concurrency <n> Parallel render workers for export
3551
3759
  --preset <name> x264 preset for export, default medium
@@ -3622,9 +3830,11 @@ var run2 = async (argv) => {
3622
3830
  case "inspect":
3623
3831
  await inspectCommand(positionals[0] ?? "", { json: flags.json === true, input: parseInput(flags) });
3624
3832
  return 0;
3625
- case "still":
3626
- await stillCommand(positionals[0] ?? "", {
3627
- frame: numberFlag(flags, "frame") ?? 0,
3833
+ case "frame":
3834
+ await frameCommand(positionals[0] ?? "", {
3835
+ // A duration, so "4s" and "120f" both work; a bare number is
3836
+ // seconds, the way every other time value in Odori reads.
3837
+ at: typeof flags.at === "string" ? flags.at : numberFlag(flags, "at") ?? 0,
3628
3838
  output: typeof flags.output === "string" ? flags.output : void 0,
3629
3839
  input: parseInput(flags)
3630
3840
  });
@@ -3641,6 +3851,7 @@ var run2 = async (argv) => {
3641
3851
  quality: typeof flags.quality === "string" ? flags.quality : void 0,
3642
3852
  scale: numberFlag(flags, "scale"),
3643
3853
  format: typeof flags.format === "string" ? flags.format : void 0,
3854
+ audio: flags["no-audio"] === true ? false : void 0,
3644
3855
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
3645
3856
  retry: typeof flags.retry === "string" ? flags.retry : void 0
3646
3857
  });
@@ -3654,6 +3865,11 @@ var run2 = async (argv) => {
3654
3865
  log.info(HELP);
3655
3866
  return 0;
3656
3867
  default: {
3868
+ const renamed = RENAMED[command2];
3869
+ if (renamed) {
3870
+ log.error(`"odori ${command2}" is now "odori ${renamed}".`);
3871
+ return 1;
3872
+ }
3657
3873
  const commands = Object.keys(COMMAND_FLAGS).filter((name) => name !== "help");
3658
3874
  const suggestion = nearest(command2, commands);
3659
3875
  log.error(`Unknown command "${command2}".${suggestion ? ` Did you mean "odori ${suggestion}"?` : ""}`);
@@ -3729,7 +3945,7 @@ export {
3729
3945
  exportCommand,
3730
3946
  jobsCommand,
3731
3947
  devCommand,
3732
- stillCommand,
3948
+ frameCommand,
3733
3949
  diffLines,
3734
3950
  countChanges,
3735
3951
  formatDiff,