@odori/cli 0.0.3 → 0.0.4

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.
@@ -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-MSH2EA36.js");
306
321
  return loaded.default.items;
307
322
  } catch {
308
323
  throw new Error(
@@ -393,10 +408,66 @@ 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, writeFile as writeFile5 } from "fs/promises";
469
+ import { existsSync as existsSync6 } from "fs";
470
+ import { relative as relative4, resolve as resolve6 } from "path";
400
471
  import { hashString } from "odori";
401
472
 
402
473
  // src/diff.ts
@@ -459,15 +530,15 @@ var formatDiff = (lines, context = 2) => {
459
530
  };
460
531
 
461
532
  // src/commands/update.ts
462
- var provenanceFile = (config) => resolve5(config.root, config.outDir, "components.json");
533
+ var provenanceFile = (config) => resolve6(config.root, config.outDir, "components.json");
463
534
  var readProvenance = async (config) => {
464
535
  const file = provenanceFile(config);
465
- if (!existsSync5(file)) return {};
466
- return JSON.parse(await readFile4(file, "utf8"));
536
+ if (!existsSync6(file)) return {};
537
+ return JSON.parse(await readFile5(file, "utf8"));
467
538
  };
468
539
  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)}
540
+ await mkdir4(resolve6(config.root, config.outDir), { recursive: true });
541
+ await writeFile5(provenanceFile(config), `${JSON.stringify(provenance, null, 2)}
471
542
  `, "utf8");
472
543
  };
473
544
  var componentStatus = async (config, only) => {
@@ -483,10 +554,10 @@ var componentStatus = async (config, only) => {
483
554
  const upstreamFiles = new Map(item.files.map((file) => [file.path.split("/").pop() ?? file.path, file.content]));
484
555
  const files = await Promise.all(
485
556
  component.files.map(async (file) => {
486
- const localPath = resolve5(config.root, config.componentsDir, name, file);
557
+ const localPath = resolve6(config.root, config.componentsDir, name, file);
487
558
  const content = upstreamFiles.get(file);
488
559
  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;
560
+ const local = existsSync6(localPath) ? hashString(await readFile5(localPath, "utf8")) : null;
490
561
  return {
491
562
  file,
492
563
  localPath,
@@ -523,14 +594,14 @@ var diffCommand = async (names, options = {}) => {
523
594
  log.title(`@odori/${status.name} ${LABELS[status.state]}`);
524
595
  for (const file of status.files) {
525
596
  if (file.local === null) {
526
- log.error(` ${file.file} is missing from ${relative3(config.root, resolve5(file.localPath, ".."))}`);
597
+ log.error(` ${file.file} is missing from ${relative4(config.root, resolve6(file.localPath, ".."))}`);
527
598
  continue;
528
599
  }
529
600
  if (file.local === file.upstream) {
530
601
  log.detail(` ${file.file} identical to upstream`);
531
602
  continue;
532
603
  }
533
- const lines = diffLines(await readFile4(file.localPath, "utf8"), file.content);
604
+ const lines = diffLines(await readFile5(file.localPath, "utf8"), file.content);
534
605
  const { added, removed } = countChanges(lines);
535
606
  log.info(` ${file.file} +${added} -${removed} against upstream`);
536
607
  if (options.full) for (const line of formatDiff(lines)) log.detail(` ${line}`);
@@ -563,8 +634,8 @@ var updateCommand = async (names, options = {}) => {
563
634
  continue;
564
635
  }
565
636
  for (const file of status.files) {
566
- await mkdir3(resolve5(file.localPath, ".."), { recursive: true });
567
- await writeFile4(file.localPath, file.content, "utf8");
637
+ await mkdir4(resolve6(file.localPath, ".."), { recursive: true });
638
+ await writeFile5(file.localPath, file.content, "utf8");
568
639
  }
569
640
  provenance[status.name] = {
570
641
  source: `@odori/${status.name}`,
@@ -604,34 +675,54 @@ var addCommand = async (names, options = {}) => {
604
675
  if (provider) queue.push(provider.name);
605
676
  else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
606
677
  }
678
+ if (component.kind === "asset" && component.asset) {
679
+ const written = await installAsset(config, component, { dryRun: options.dryRun, force: options.force });
680
+ if (!options.dryRun && written) {
681
+ installed.push(component.name);
682
+ const registered = await registerCueInBrand(
683
+ config,
684
+ { name: component.asset.cue, url: component.asset.url },
685
+ component.name
686
+ );
687
+ if (registered?.already) {
688
+ log.detail(` "${component.asset.cue}" is already registered in ${registered.file}`);
689
+ } else if (registered) {
690
+ log.detail(` registered "${component.asset.cue}" in ${registered.file}`);
691
+ } else {
692
+ log.warn(` No brand with an audio.cues block found. Add it yourself:`);
693
+ log.detail(` audio: {cues: {"${component.asset.cue}": "${component.asset.url}"}}`);
694
+ }
695
+ }
696
+ continue;
697
+ }
607
698
  const { item, origin } = await resolveItem(config, component.name);
608
699
  verifyIntegrity(item, origin);
609
- const target = resolve6(config.root, config.componentsDir, assertSafeName(component.name));
700
+ const target = resolve7(config.root, config.componentsDir, assertSafeName(component.name));
610
701
  const hashes = {};
611
702
  for (const file of item.files) {
612
703
  const destination = resolveWithinRoot(config.root, file.target);
613
- const exists = existsSync6(destination);
614
- log.detail(` ${exists ? "replace" : "create "} ${relative4(config.root, destination)}`);
704
+ const exists = existsSync7(destination);
705
+ log.detail(` ${exists ? "replace" : "create "} ${relative5(config.root, destination)}`);
615
706
  }
616
707
  if (options.dryRun) {
617
708
  installed.push(component.name);
618
709
  continue;
619
710
  }
620
- await mkdir4(target, { recursive: true });
711
+ await mkdir5(target, { recursive: true });
621
712
  for (const file of item.files) {
622
713
  const name2 = file.path.split("/").pop() ?? file.path;
623
714
  const destination = resolveWithinRoot(config.root, file.target);
624
715
  hashes[name2] = hashString2(file.content);
625
- if (existsSync6(destination) && !options.force) {
626
- const current = hashString2(await readFile5(destination, "utf8"));
716
+ if (existsSync7(destination) && !options.force) {
717
+ const current = hashString2(await readFile6(destination, "utf8"));
627
718
  const recorded = provenance[component.name]?.hashes[name2];
628
719
  if (current !== recorded) {
629
- log.warn(`${relative4(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
720
+ log.warn(`${relative5(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
630
721
  continue;
631
722
  }
632
723
  }
633
- await mkdir4(resolve6(destination, ".."), { recursive: true });
634
- await writeFile5(destination, file.content, "utf8");
724
+ await mkdir5(resolve7(destination, ".."), { recursive: true });
725
+ await writeFile6(destination, file.content, "utf8");
635
726
  }
636
727
  provenance[component.name] = {
637
728
  source: component.namespaced,
@@ -640,7 +731,7 @@ var addCommand = async (names, options = {}) => {
640
731
  hashes
641
732
  };
642
733
  installed.push(component.name);
643
- log.success(`${component.namespaced} to ${relative4(config.root, target)}/`);
734
+ log.success(`${component.namespaced} to ${relative5(config.root, target)}/`);
644
735
  if (component.kind === "cue" && component.cue) {
645
736
  log.detail(
646
737
  ` ${component.family} \xB7 ${component.contract.recommendedDurationInFrames} frames \xB7 registers "${component.cue.name}"`
@@ -688,19 +779,19 @@ var registryCommand = async () => {
688
779
  };
689
780
 
690
781
  // src/commands/dev.ts
691
- import { resolve as resolve18 } from "path";
782
+ import { relative as relative7, resolve as resolve19 } from "path";
692
783
  import { homedir as homedir2 } from "os";
693
- import { existsSync as existsSync15 } from "fs";
694
- import { readFile as readFile12 } from "fs/promises";
784
+ import { existsSync as existsSync16 } from "fs";
785
+ import { readFile as readFile13 } from "fs/promises";
695
786
 
696
787
  // 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");
788
+ import { mkdir as mkdir6, readFile as readFile7, readdir as readdir2, rename, writeFile as writeFile7 } from "fs/promises";
789
+ import { existsSync as existsSync8 } from "fs";
790
+ import { join as join2, resolve as resolve8 } from "path";
791
+ var buildsDir = (config) => resolve8(config.root, config.outDir, "builds");
701
792
  var jobFile = (config, id) => join2(buildsDir(config), `${id}.json`);
702
793
  var createJob = async (config, manifest, output, render) => {
703
- await mkdir5(buildsDir(config), { recursive: true });
794
+ await mkdir6(buildsDir(config), { recursive: true });
704
795
  const now = (/* @__PURE__ */ new Date()).toISOString();
705
796
  const job = {
706
797
  id: `job-${manifest.manifestHash.slice(0, 10)}-${Date.now().toString(36)}`,
@@ -714,14 +805,14 @@ var createJob = async (config, manifest, output, render) => {
714
805
  updatedAt: now
715
806
  };
716
807
  const record = { job, manifest, output, ...render ? { render } : {} };
717
- await writeFile6(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
808
+ await writeFile7(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
718
809
  `, "utf8");
719
810
  return record;
720
811
  };
721
812
  var readJob = async (config, id) => {
722
813
  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"));
814
+ if (!existsSync8(file)) throw new Error(`Unknown job "${id}". Run odori jobs to list them.`);
815
+ return JSON.parse(await readFile7(file, "utf8"));
725
816
  };
726
817
  var writeLocks = /* @__PURE__ */ new Map();
727
818
  var withJobLock = (id, task) => {
@@ -736,7 +827,7 @@ var withJobLock = (id, task) => {
736
827
  var writeRecord = async (config, record) => {
737
828
  const file = jobFile(config, record.job.id);
738
829
  const temporary = `${file}.${process.pid}.tmp`;
739
- await writeFile6(temporary, `${JSON.stringify(record, null, 2)}
830
+ await writeFile7(temporary, `${JSON.stringify(record, null, 2)}
740
831
  `, "utf8");
741
832
  await rename(temporary, file);
742
833
  };
@@ -779,13 +870,13 @@ var reconcileJobs = async (config) => {
779
870
  return stale.length;
780
871
  };
781
872
  var listJobs = async (config, options = {}) => {
782
- if (!existsSync7(buildsDir(config))) return [];
873
+ if (!existsSync8(buildsDir(config))) return [];
783
874
  if (options.reconcile !== false) await reconcileJobs(config);
784
875
  const files = (await readdir2(buildsDir(config))).filter((file) => file.endsWith(".json"));
785
876
  const jobs = [];
786
877
  for (const file of files) {
787
878
  try {
788
- const raw = await readFile6(join2(buildsDir(config), file), "utf8");
879
+ const raw = await readFile7(join2(buildsDir(config), file), "utf8");
789
880
  jobs.push(JSON.parse(raw).job);
790
881
  } catch {
791
882
  continue;
@@ -803,9 +894,9 @@ var JobQueue = class {
803
894
  };
804
895
 
805
896
  // 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";
897
+ import { mkdir as mkdir7, readdir as readdir3, readFile as readFile8, stat, writeFile as writeFile8 } from "fs/promises";
898
+ import { existsSync as existsSync9 } from "fs";
899
+ import { join as join3, relative as relative6, resolve as resolve9, sep as sep2 } from "path";
809
900
  import { hashString as hashString3 } from "odori";
810
901
  var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
811
902
  var walk = async (directory2, files = []) => {
@@ -827,22 +918,22 @@ var toIdentifier = (value, prefix) => {
827
918
  };
828
919
  var AUDIO_EXTENSIONS = /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i;
829
920
  var discoverAudio = async (config) => {
830
- const root = resolve8(config.root, config.audioDir);
831
- if (!existsSync8(root)) return [];
832
- const publicRoot = resolve8(config.root, "public");
921
+ const root = resolve9(config.root, config.audioDir);
922
+ if (!existsSync9(root)) return [];
923
+ const publicRoot = resolve9(config.root, "public");
833
924
  const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
834
925
  return Promise.all(
835
926
  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),
927
+ name: relative6(root, file).replace(AUDIO_EXTENSIONS, "").split(sep2).join("/"),
928
+ url: file.startsWith(`${publicRoot}${sep2}`) ? `/${relative6(publicRoot, file).split(sep2).join("/")}` : `/${relative6(config.root, file).split(sep2).join("/")}`,
929
+ relativeFile: relative6(config.root, file),
839
930
  bytes: (await stat(file)).size
840
931
  }))
841
932
  );
842
933
  };
843
934
  var discoverProject = async (config) => {
844
- const videosRoot = resolve8(config.root, config.videosDir);
845
- if (!existsSync8(videosRoot)) {
935
+ const videosRoot = resolve9(config.root, config.videosDir);
936
+ if (!existsSync9(videosRoot)) {
846
937
  throw new Error(`No ${config.videosDir}/ directory found in ${config.root}. Run "odori init" first.`);
847
938
  }
848
939
  const files = (await walk(videosRoot)).sort();
@@ -850,18 +941,42 @@ var discoverProject = async (config) => {
850
941
  const videos = [];
851
942
  const previews = [];
852
943
  const brands = [];
944
+ const categories = [];
853
945
  const hashParts = [];
854
- const componentsRoot = resolve8(config.root, config.componentsDir);
946
+ const importedBy = {};
947
+ const componentsRoot = resolve9(config.root, config.componentsDir);
855
948
  for (const file of files) {
856
- const relativeFile = relative5(config.root, file);
949
+ const relativeFile = relative6(config.root, file);
857
950
  let contents = "";
858
951
  if (/\.(tsx|ts|css|json)$/.test(file)) {
859
- contents = await readFile7(file, "utf8");
952
+ contents = await readFile8(file, "utf8");
860
953
  hashParts.push(`${relativeFile}:${hashString3(contents)}`);
861
954
  }
862
955
  const base = file.split(sep2).pop() ?? "";
956
+ if (base === "category.json") {
957
+ const path = relative6(componentsRoot, resolve9(file, "..")).split(sep2).join("/");
958
+ if (!path.startsWith("..")) {
959
+ try {
960
+ const declared = JSON.parse(contents);
961
+ categories.push({
962
+ path,
963
+ ...typeof declared.name === "string" ? { name: declared.name } : {},
964
+ ...typeof declared.order === "number" ? { order: declared.order } : {}
965
+ });
966
+ } catch (error) {
967
+ log.warn(
968
+ `${relativeFile} is not valid JSON, so that directory names itself: ${error instanceof Error ? error.message : String(error)}`
969
+ );
970
+ }
971
+ }
972
+ }
863
973
  if (base === "video.tsx") {
864
- const slug = relative5(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
974
+ for (const match of contents.matchAll(/from\s+["'][^"']*\/components\/([^/"']+)\//g)) {
975
+ (importedBy[match[1]] ??= /* @__PURE__ */ new Set()).add(relative6(videosRoot, file).replace(/\/?video\.tsx$/, "") || "video");
976
+ }
977
+ }
978
+ if (base === "video.tsx") {
979
+ const slug = relative6(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
865
980
  videos.push({
866
981
  slug,
867
982
  file,
@@ -887,7 +1002,7 @@ var discoverProject = async (config) => {
887
1002
  // From the whole relative path, like previews: basenames repeat
888
1003
  // (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
889
1004
  identifier: toIdentifier(
890
- `${relative5(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
1005
+ `${relative6(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
891
1006
  "brands"
892
1007
  )
893
1008
  });
@@ -898,15 +1013,19 @@ var discoverProject = async (config) => {
898
1013
  file,
899
1014
  relativeFile,
900
1015
  importPath: file,
901
- identifier: toIdentifier(`${relative5(videosRoot, file).split(sep2).join("-")}`, "preview")
1016
+ identifier: toIdentifier(`${relative6(videosRoot, file).split(sep2).join("-")}`, "preview")
902
1017
  });
903
1018
  }
904
1019
  }
905
- return { videos, previews, brands, audio, sourceHash: hashString3(hashParts.join("|")) };
1020
+ for (const preview of previews) {
1021
+ const users = importedBy[preview.name];
1022
+ if (users) preview.usedBy = [...users].sort();
1023
+ }
1024
+ return { videos, previews, brands, audio, categories, sourceHash: hashString3(hashParts.join("|")) };
906
1025
  };
907
1026
  var generateImports = (graph, outDir) => {
908
1027
  const importPath = (file) => {
909
- const relativePath = relative5(outDir, file).split(sep2).join("/");
1028
+ const relativePath = relative6(outDir, file).split(sep2).join("/");
910
1029
  return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
911
1030
  };
912
1031
  const lines = [
@@ -939,17 +1058,21 @@ var generateImports = (graph, outDir) => {
939
1058
  return lines.join("\n");
940
1059
  };
941
1060
  var writeGenerated = async (config, graph) => {
942
- const outDir = resolve8(config.root, config.outDir);
943
- await mkdir6(outDir, { recursive: true });
1061
+ const outDir = resolve9(config.root, config.outDir);
1062
+ await mkdir7(outDir, { recursive: true });
944
1063
  const target = join3(outDir, "imports.generated.ts");
945
- await writeFile7(target, generateImports(graph, outDir), "utf8");
946
- await writeFile7(
1064
+ await writeFile8(target, generateImports(graph, outDir), "utf8");
1065
+ await writeFile8(
947
1066
  join3(outDir, "catalog.json"),
948
1067
  `${JSON.stringify(
949
1068
  {
950
1069
  sourceHash: graph.sourceHash,
951
1070
  videos: graph.videos.map((video) => ({ slug: video.slug, file: video.relativeFile })),
952
- previews: graph.previews.map((preview) => ({ name: preview.name, file: preview.relativeFile })),
1071
+ previews: graph.previews.map((preview) => ({
1072
+ name: preview.name,
1073
+ file: preview.relativeFile,
1074
+ usedBy: preview.usedBy ?? []
1075
+ })),
953
1076
  brands: graph.brands.map((brand) => ({ name: brand.name, file: brand.relativeFile })),
954
1077
  audio: graph.audio.map((entry) => ({ name: entry.name, url: entry.url, file: entry.relativeFile }))
955
1078
  },
@@ -963,7 +1086,7 @@ var writeGenerated = async (config, graph) => {
963
1086
  };
964
1087
 
965
1088
  // src/project.ts
966
- import { resolve as resolve11 } from "path";
1089
+ import { resolve as resolve12 } from "path";
967
1090
  import { pathToFileURL as pathToFileURL2 } from "url";
968
1091
  import {
969
1092
  createRenderManifest,
@@ -973,45 +1096,45 @@ import {
973
1096
  } from "odori";
974
1097
 
975
1098
  // 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");
1099
+ import { createHash as createHash3 } from "crypto";
1100
+ import { existsSync as existsSync10 } from "fs";
1101
+ import { mkdir as mkdir8, readFile as readFile9, writeFile as writeFile9 } from "fs/promises";
1102
+ import { dirname as dirname4, resolve as resolve10 } from "path";
1103
+ var cacheFile = (config) => resolve10(config.root, config.outDir, "cache", "integrity.json");
981
1104
  var readCache = async (config) => {
982
1105
  const file = cacheFile(config);
983
- if (!existsSync9(file)) return {};
1106
+ if (!existsSync10(file)) return {};
984
1107
  try {
985
- return JSON.parse(await readFile8(file, "utf8"));
1108
+ return JSON.parse(await readFile9(file, "utf8"));
986
1109
  } catch {
987
1110
  return {};
988
1111
  }
989
1112
  };
990
1113
  var writeCache = async (config, cache) => {
991
1114
  const file = cacheFile(config);
992
- await mkdir7(dirname3(file), { recursive: true });
993
- await writeFile8(file, `${JSON.stringify(cache, null, 2)}
1115
+ await mkdir8(dirname4(file), { recursive: true });
1116
+ await writeFile9(file, `${JSON.stringify(cache, null, 2)}
994
1117
  `, "utf8");
995
1118
  };
996
- var sha256 = (bytes) => `sha256-${createHash2("sha256").update(bytes).digest("base64")}`;
1119
+ var sha256 = (bytes) => `sha256-${createHash3("sha256").update(bytes).digest("base64")}`;
997
1120
  var localCandidates = (config, url) => [
998
- resolve9(config.root, "public", url.replace(/^\//, "")),
999
- resolve9(config.root, url.replace(/^\//, ""))
1121
+ resolve10(config.root, "public", url.replace(/^\//, "")),
1122
+ resolve10(config.root, url.replace(/^\//, ""))
1000
1123
  ];
1001
- var isServed = (config, file) => file.startsWith(resolve9(config.root, "public") + "/");
1124
+ var isServed = (config, file) => file.startsWith(resolve10(config.root, "public") + "/");
1002
1125
  var createIntegrityResolver = async (config) => {
1003
1126
  const cache = await readCache(config);
1004
1127
  const warned = /* @__PURE__ */ new Set();
1005
1128
  let dirty = false;
1006
1129
  const resolveIntegrity = async (url) => {
1007
1130
  if (url.startsWith("/__odori/cue/")) return `cue-${url.slice(url.lastIndexOf("-") + 1).replace(/\.wav$/, "")}`;
1008
- const local = localCandidates(config, url).find((candidate) => existsSync9(candidate));
1131
+ const local = localCandidates(config, url).find((candidate) => existsSync10(candidate));
1009
1132
  if (local) {
1010
1133
  if (!isServed(config, local) && !warned.has(url)) {
1011
1134
  warned.add(url);
1012
1135
  log.warn(`${url} resolves to ${local}, which is outside public/ and will not be served. Move it into public/.`);
1013
1136
  }
1014
- const bytes = await readFile8(local);
1137
+ const bytes = await readFile9(local);
1015
1138
  const { mtimeMs } = await import("fs/promises").then((fs) => fs.stat(local));
1016
1139
  const hit = cache[url];
1017
1140
  if (hit && hit.mtimeMs === mtimeMs && hit.size === bytes.byteLength) return hit.integrity;
@@ -1043,9 +1166,9 @@ var createIntegrityResolver = async (config) => {
1043
1166
  };
1044
1167
 
1045
1168
  // 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";
1169
+ import { existsSync as existsSync11 } from "fs";
1170
+ import { mkdir as mkdir9, readFile as readFile10, readdir as readdir4, rm as rm2, writeFile as writeFile10 } from "fs/promises";
1171
+ import { join as join4, resolve as resolve11 } from "path";
1049
1172
  import { hashValue } from "odori";
1050
1173
 
1051
1174
  // src/paths.ts
@@ -1053,13 +1176,13 @@ var outputName = (id) => id.split("/").join("-");
1053
1176
  var fileKey = (id) => id.split("/").join("+");
1054
1177
 
1055
1178
  // src/prepare-cache.ts
1056
- var directory = (config) => resolve10(config.root, config.outDir, "cache", "prepare");
1179
+ var directory = (config) => resolve11(config.root, config.outDir, "cache", "prepare");
1057
1180
  var prepareCacheKey = (key) => `${fileKey(key.videoId)}__${hashValue(key)}`;
1058
1181
  var readPrepareCache = async (config, key) => {
1059
1182
  const file = join4(directory(config), `${prepareCacheKey(key)}.json`);
1060
- if (!existsSync10(file)) return { hit: false, value: void 0 };
1183
+ if (!existsSync11(file)) return { hit: false, value: void 0 };
1061
1184
  try {
1062
- const entry = JSON.parse(await readFile9(file, "utf8"));
1185
+ const entry = JSON.parse(await readFile10(file, "utf8"));
1063
1186
  return { hit: true, value: entry.value };
1064
1187
  } catch {
1065
1188
  return { hit: false, value: void 0 };
@@ -1068,17 +1191,17 @@ var readPrepareCache = async (config, key) => {
1068
1191
  var writePrepareCache = async (config, key, value) => {
1069
1192
  if (value === void 0) return;
1070
1193
  const target = directory(config);
1071
- await mkdir8(target, { recursive: true });
1194
+ await mkdir9(target, { recursive: true });
1072
1195
  const entry = { key, value, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1073
- await writeFile9(join4(target, `${prepareCacheKey(key)}.json`), `${JSON.stringify(entry, null, 2)}
1196
+ await writeFile10(join4(target, `${prepareCacheKey(key)}.json`), `${JSON.stringify(entry, null, 2)}
1074
1197
  `, "utf8");
1075
1198
  };
1076
1199
  var clearPrepareCache = async (config, videoId) => {
1077
1200
  const target = directory(config);
1078
- if (!existsSync10(target)) return 0;
1201
+ if (!existsSync11(target)) return 0;
1079
1202
  const files = await readdir4(target);
1080
1203
  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 })));
1204
+ await Promise.all(matches.map((file) => rm2(join4(target, file), { force: true })));
1082
1205
  return matches.length;
1083
1206
  };
1084
1207
 
@@ -1122,7 +1245,7 @@ var findVideo = (videos, id) => {
1122
1245
  return found;
1123
1246
  };
1124
1247
  var runPrepare = async (video, config, graph, input, options = {}) => {
1125
- const prepareFile = resolve11(video.file, "..", "prepare.ts");
1248
+ const prepareFile = resolve12(video.file, "..", "prepare.ts");
1126
1249
  let prepare;
1127
1250
  try {
1128
1251
  const module = await import(pathToFileURL2(prepareFile).href);
@@ -1319,20 +1442,20 @@ var alphaWarning = (format, transparent) => transparent && !format.alpha ? `${fo
1319
1442
 
1320
1443
  // src/render.ts
1321
1444
  import { spawn as spawn2 } from "child_process";
1322
- import { copyFile as copyFile2, mkdir as mkdir11, rm as rm2, writeFile as writeFile12 } from "fs/promises";
1445
+ import { copyFile as copyFile2, mkdir as mkdir12, rm as rm3, writeFile as writeFile13 } from "fs/promises";
1323
1446
  import { cpus } from "os";
1324
- import { dirname as dirname4, join as join6, resolve as resolve15 } from "path";
1447
+ import { dirname as dirname5, join as join6, resolve as resolve16 } from "path";
1325
1448
  import { chromium } from "playwright-core";
1326
1449
 
1327
1450
  // src/audio-mix.ts
1328
- import { existsSync as existsSync12 } from "fs";
1329
- import { resolve as resolve13 } from "path";
1451
+ import { existsSync as existsSync13 } from "fs";
1452
+ import { resolve as resolve14 } from "path";
1330
1453
  import { duckEnvelope, envelopeAtFrame } from "odori";
1331
1454
 
1332
1455
  // 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";
1456
+ import { existsSync as existsSync12, statSync } from "fs";
1457
+ import { mkdir as mkdir10, writeFile as writeFile11 } from "fs/promises";
1458
+ import { basename, resolve as resolve13 } from "path";
1336
1459
  import { pathToFileURL as pathToFileURL3 } from "url";
1337
1460
  import {
1338
1461
  SAMPLE_RATE,
@@ -1343,8 +1466,8 @@ import {
1343
1466
  isCueDefinition,
1344
1467
  resolveEntryLayout as resolveEntryLayout2
1345
1468
  } from "odori";
1346
- var cueCacheDir = (config) => resolve12(config.root, config.outDir, "cues");
1347
- var cueFile = (config, url) => resolve12(cueCacheDir(config), basename(url));
1469
+ var cueCacheDir = (config) => resolve13(config.root, config.outDir, "cues");
1470
+ var cueFile = (config, url) => resolve13(cueCacheDir(config), basename(url));
1348
1471
  var materializeCues = async (config, brands, fps) => {
1349
1472
  const seen = /* @__PURE__ */ new Map();
1350
1473
  for (const brand of brands) {
@@ -1353,17 +1476,17 @@ var materializeCues = async (config, brands, fps) => {
1353
1476
  }
1354
1477
  }
1355
1478
  if (seen.size === 0) return [];
1356
- await mkdir9(cueCacheDir(config), { recursive: true });
1479
+ await mkdir10(cueCacheDir(config), { recursive: true });
1357
1480
  const written = [];
1358
1481
  for (const [url, cue] of seen) {
1359
1482
  const file = cueFile(config, url);
1360
- if (existsSync11(file)) {
1483
+ if (existsSync12(file)) {
1361
1484
  written.push({ cue, file, rendered: false });
1362
1485
  continue;
1363
1486
  }
1364
1487
  const samples = cueSamples(cue, fps);
1365
1488
  const signal = cue.render({ samples, sampleRate: SAMPLE_RATE });
1366
- await writeFile10(file, encodeWav(signal));
1489
+ await writeFile11(file, encodeWav(signal));
1367
1490
  written.push({ cue, file, rendered: true });
1368
1491
  }
1369
1492
  return written;
@@ -1421,13 +1544,13 @@ var resolveCueFile = (config, src) => {
1421
1544
  if (/^https?:\/\//.test(src)) return null;
1422
1545
  if (src.startsWith("/__odori/cue/")) {
1423
1546
  const generated = cueFile(config, src);
1424
- return existsSync12(generated) ? generated : null;
1547
+ return existsSync13(generated) ? generated : null;
1425
1548
  }
1426
1549
  const candidates = [
1427
- resolve13(config.root, "public", src.replace(/^\//, "")),
1428
- resolve13(config.root, src.replace(/^\//, ""))
1550
+ resolve14(config.root, "public", src.replace(/^\//, "")),
1551
+ resolve14(config.root, src.replace(/^\//, ""))
1429
1552
  ];
1430
- return candidates.find((candidate) => existsSync12(candidate)) ?? null;
1553
+ return candidates.find((candidate) => existsSync13(candidate)) ?? null;
1431
1554
  };
1432
1555
  var volumeFilter = (cue, cues, fps) => {
1433
1556
  const authored = cue.gainPoints ?? [];
@@ -1547,11 +1670,11 @@ var planChunks = ({
1547
1670
  var chunkFrames = (chunk) => Array.from({ length: length(chunk) }, (_, offset) => chunk.start + offset);
1548
1671
 
1549
1672
  // 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";
1673
+ import { existsSync as existsSync14 } from "fs";
1674
+ import { copyFile, mkdir as mkdir11, readFile as readFile11, writeFile as writeFile12 } from "fs/promises";
1675
+ import { join as join5, resolve as resolve15 } from "path";
1553
1676
  import { hashValue as hashValue2 } from "odori";
1554
- var cacheDir2 = (config) => resolve14(config.root, config.outDir, "cache", "chunks");
1677
+ var cacheDir2 = (config) => resolve15(config.root, config.outDir, "cache", "chunks");
1555
1678
  var chunkKey = (identity) => hashValue2({
1556
1679
  videoId: identity.videoId,
1557
1680
  // The browser that drew the frames is part of what the frames are. Without
@@ -1576,9 +1699,9 @@ var chunkKey = (identity) => hashValue2({
1576
1699
  var readChunkRecord = async (config, key) => {
1577
1700
  const meta = join5(cacheDir2(config), `${key}.json`);
1578
1701
  const media = join5(cacheDir2(config), `${key}.mp4`);
1579
- if (!existsSync13(meta) || !existsSync13(media)) return null;
1702
+ if (!existsSync14(meta) || !existsSync14(media)) return null;
1580
1703
  try {
1581
- return JSON.parse(await readFile10(meta, "utf8"));
1704
+ return JSON.parse(await readFile11(meta, "utf8"));
1582
1705
  } catch {
1583
1706
  return null;
1584
1707
  }
@@ -1588,10 +1711,10 @@ var useChunkRecord = async (config, key, destination) => {
1588
1711
  };
1589
1712
  var writeChunkRecord = async (config, key, signatures, file) => {
1590
1713
  const directory2 = cacheDir2(config);
1591
- await mkdir10(directory2, { recursive: true });
1714
+ await mkdir11(directory2, { recursive: true });
1592
1715
  await copyFile(file, join5(directory2, `${key}.mp4`));
1593
1716
  const record = { key, signatures, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1594
- await writeFile11(join5(directory2, `${key}.json`), `${JSON.stringify(record)}
1717
+ await writeFile12(join5(directory2, `${key}.json`), `${JSON.stringify(record)}
1595
1718
  `, "utf8");
1596
1719
  };
1597
1720
  var signaturesMatch = (recorded, observed) => recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
@@ -1739,7 +1862,7 @@ var ensureFfmpeg = async (config) => {
1739
1862
  var renderStill = async (origin, target, frame, output, config) => {
1740
1863
  const { browser, page, errors } = await openRenderPage(origin, target, config);
1741
1864
  try {
1742
- await mkdir11(dirname4(output), { recursive: true });
1865
+ await mkdir12(dirname5(output), { recursive: true });
1743
1866
  await seekTo(page, frame);
1744
1867
  await page.screenshot({ path: output });
1745
1868
  if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
@@ -1895,8 +2018,8 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1895
2018
  const chunkFormat = chunkable ? format : LOSSLESS;
1896
2019
  const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
1897
2020
  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 });
2021
+ const work = options.workDir ?? resolve16(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
2022
+ await mkdir12(work, { recursive: true });
1900
2023
  const concurrency = chunkable ? requested : 1;
1901
2024
  const { chunks, lanes } = planChunks({
1902
2025
  durationInFrames: target.durationInFrames,
@@ -1907,7 +2030,7 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1907
2030
  const stats = { captured: 0, reused: 0, cachedChunks: 0 };
1908
2031
  let succeeded = false;
1909
2032
  try {
1910
- await mkdir11(dirname4(output), { recursive: true });
2033
+ await mkdir12(dirname5(output), { recursive: true });
1911
2034
  const captureStart = performance.now();
1912
2035
  await Promise.all(
1913
2036
  lanes.map(
@@ -1954,12 +2077,12 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1954
2077
  await copyFile2(ordered[0], silent);
1955
2078
  } else {
1956
2079
  const list = join6(work, "chunks.txt");
1957
- await writeFile12(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
2080
+ await writeFile13(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
1958
2081
  await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
1959
2082
  }
1960
2083
  if (!chunkable) {
1961
2084
  const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
1962
- if (destination !== output) await mkdir11(dirname4(destination), { recursive: true });
2085
+ if (destination !== output) await mkdir12(dirname5(destination), { recursive: true });
1963
2086
  await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
1964
2087
  if (mixInputs.length > 0) {
1965
2088
  log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
@@ -2013,7 +2136,7 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
2013
2136
  succeeded = true;
2014
2137
  return output;
2015
2138
  } finally {
2016
- if (succeeded && !options.workDir) await rm2(work, { recursive: true, force: true });
2139
+ if (succeeded && !options.workDir) await rm3(work, { recursive: true, force: true });
2017
2140
  else if (!succeeded) log.detail(`Chunks left for inspection in ${work}`);
2018
2141
  }
2019
2142
  };
@@ -2045,25 +2168,25 @@ var openInBrowser = (url) => {
2045
2168
  };
2046
2169
 
2047
2170
  // src/server.ts
2048
- import { existsSync as existsSync14 } from "fs";
2171
+ import { existsSync as existsSync15 } from "fs";
2049
2172
  import { createRequire as createRequire2 } from "module";
2050
2173
  import { fileURLToPath } from "url";
2051
2174
  import { createServer } from "vite";
2052
2175
  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, "..", "..");
2176
+ import { readFile as readFile12 } from "fs/promises";
2177
+ import { dirname as dirname6, resolve as resolve17, sep as sep3 } from "path";
2178
+ var cliRoot = resolve17(dirname6(fileURLToPath(import.meta.url)), "..");
2179
+ var studioRoot = resolve17(cliRoot, "studio");
2180
+ var studioEntry = resolve17(studioRoot, "index.html");
2181
+ var installRoot = resolve17(cliRoot, "..", "..");
2059
2182
  var VIRTUAL_ID = "virtual:odori-project";
2060
2183
  var RESOLVED_ID = `\0${VIRTUAL_ID}`;
2061
2184
  var runtimeSource = (root) => {
2062
- for (const from of [resolve16(root, "package.json"), import.meta.url]) {
2185
+ for (const from of [resolve17(root, "package.json"), import.meta.url]) {
2063
2186
  try {
2064
2187
  const manifest = createRequire2(from).resolve("odori/package.json");
2065
- const src = resolve16(manifest, "..", "src");
2066
- if (existsSync14(resolve16(src, "index.tsx"))) return src;
2188
+ const src = resolve17(manifest, "..", "src");
2189
+ if (existsSync15(resolve17(src, "index.tsx"))) return src;
2067
2190
  } catch {
2068
2191
  }
2069
2192
  }
@@ -2102,6 +2225,8 @@ var odoriProjectPlugin = (config, getGraph) => ({
2102
2225
  `export const project = ${JSON.stringify({
2103
2226
  root: config.root,
2104
2227
  videosDir: config.videosDir,
2228
+ componentsDir: config.componentsDir,
2229
+ categories: graph.categories,
2105
2230
  exportDir: config.exportDir,
2106
2231
  audioDir: config.audioDir,
2107
2232
  docsUrl: config.docsUrl,
@@ -2110,7 +2235,11 @@ var odoriProjectPlugin = (config, getGraph) => ({
2110
2235
  assets: config.assets ?? [],
2111
2236
  files: {
2112
2237
  videos: graph.videos.map((video) => ({ id: video.slug, file: video.relativeFile })),
2113
- previews: graph.previews.map((preview) => ({ id: preview.name, file: preview.relativeFile })),
2238
+ previews: graph.previews.map((preview) => ({
2239
+ id: preview.name,
2240
+ file: preview.relativeFile,
2241
+ usedBy: preview.usedBy ?? []
2242
+ })),
2114
2243
  brands: graph.brands.map((brand) => ({ id: brand.name, file: brand.relativeFile }))
2115
2244
  }
2116
2245
  })};`
@@ -2180,15 +2309,15 @@ var startStudioServer = async (initialConfig, options = {}) => {
2180
2309
  ],
2181
2310
  // The project's public/ directory is served at the root, so brand fonts,
2182
2311
  // logos, and footage resolve identically in preview and render.
2183
- publicDir: existsSync14(resolve16(config.root, "public")) ? resolve16(config.root, "public") : false,
2312
+ publicDir: existsSync15(resolve17(config.root, "public")) ? resolve17(config.root, "public") : false,
2184
2313
  resolve: {
2185
2314
  dedupe: ["react", "react-dom", "odori"],
2186
2315
  // Only when the runtime is present as source. A consumer resolves the
2187
2316
  // published package through its exports map instead.
2188
2317
  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") }
2318
+ { find: /^odori\/preview$/, replacement: resolve17(odoriSrc, "preview.ts") },
2319
+ { find: /^odori\/manifest$/, replacement: resolve17(odoriSrc, "manifest.ts") },
2320
+ { find: /^odori$/, replacement: resolve17(odoriSrc, "index.tsx") }
2192
2321
  ] : []
2193
2322
  },
2194
2323
  server: {
@@ -2218,7 +2347,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2218
2347
  }, 150);
2219
2348
  };
2220
2349
  const rediscover = async (file) => {
2221
- if (!file.startsWith(resolve16(config.root, config.videosDir))) return;
2350
+ if (!file.startsWith(resolve17(config.root, config.videosDir))) return;
2222
2351
  const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep3}brands${sep3}`);
2223
2352
  if (!isEntry) return;
2224
2353
  try {
@@ -2235,7 +2364,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2235
2364
  };
2236
2365
  vite.watcher.on("add", (file) => void rediscover(file));
2237
2366
  vite.watcher.on("unlink", (file) => void rediscover(file));
2238
- vite.watcher.add(resolve16(config.root, config.videosDir));
2367
+ vite.watcher.add(resolve17(config.root, config.videosDir));
2239
2368
  const reloadConfig = async (file) => {
2240
2369
  if (!/odori\.config\.(?:ts|mjs|js)$/.test(file)) return;
2241
2370
  try {
@@ -2252,14 +2381,14 @@ var startStudioServer = async (initialConfig, options = {}) => {
2252
2381
  vite.ws.send({ type: "full-reload" });
2253
2382
  };
2254
2383
  const refreshCues = (file) => {
2255
- if (!file.startsWith(resolve16(config.root, config.videosDir) + sep3)) return;
2384
+ if (!file.startsWith(resolve17(config.root, config.videosDir) + sep3)) return;
2256
2385
  if (!/\.tsx?$/.test(file)) return;
2257
2386
  scheduleCueRefresh();
2258
2387
  };
2259
2388
  vite.watcher.on("change", (file) => refreshCues(file));
2260
2389
  vite.watcher.on("change", (file) => void reloadConfig(file));
2261
2390
  for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
2262
- vite.watcher.add(resolve16(config.root, name));
2391
+ vite.watcher.add(resolve17(config.root, name));
2263
2392
  }
2264
2393
  vite.middlewares.use(async (request, response, next) => {
2265
2394
  const url = (request.url ?? "/").split("?")[0];
@@ -2269,7 +2398,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2269
2398
  return;
2270
2399
  }
2271
2400
  try {
2272
- const html = await readFile11(studioEntry, "utf8");
2401
+ const html = await readFile12(studioEntry, "utf8");
2273
2402
  response.statusCode = 200;
2274
2403
  response.setHeader("content-type", "text/html");
2275
2404
  response.end(await vite.transformIndexHtml(url, html));
@@ -2290,7 +2419,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2290
2419
  };
2291
2420
 
2292
2421
  // src/commands/exportVideo.ts
2293
- import { resolve as resolve17 } from "path";
2422
+ import { resolve as resolve18 } from "path";
2294
2423
  import { resolveEntryLayout as resolveEntryLayout4 } from "odori";
2295
2424
 
2296
2425
  // src/commands/shared.ts
@@ -2459,7 +2588,7 @@ var exportCommand = async (id, options = {}) => {
2459
2588
  options.input ?? {},
2460
2589
  { scenes: compiled.scenes, audio: compiled.audio }
2461
2590
  );
2462
- const output = resolve17(
2591
+ const output = resolve18(
2463
2592
  config.root,
2464
2593
  options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
2465
2594
  );
@@ -2525,8 +2654,8 @@ var json = (response, status, payload) => {
2525
2654
  response.end(JSON.stringify(payload));
2526
2655
  };
2527
2656
  var exportDestination = (config) => {
2528
- const downloads = resolve18(homedir2(), "Downloads");
2529
- return existsSync15(downloads) ? downloads : resolve18(config.root, config.exportDir);
2657
+ const downloads = resolve19(homedir2(), "Downloads");
2658
+ return existsSync16(downloads) ? downloads : resolve19(config.root, config.exportDir);
2530
2659
  };
2531
2660
  var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
2532
2661
  var hostOf = (value) => {
@@ -2585,7 +2714,7 @@ var devCommand = async (options = {}) => {
2585
2714
  );
2586
2715
  const frame = Number(body.frame ?? 0);
2587
2716
  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`);
2717
+ 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
2718
  await renderStill(
2590
2719
  origin,
2591
2720
  targetFor(
@@ -2601,7 +2730,7 @@ var devCommand = async (options = {}) => {
2601
2730
  if (inline) {
2602
2731
  response.statusCode = 200;
2603
2732
  response.setHeader("content-type", "image/png");
2604
- response.end(await readFile12(file));
2733
+ response.end(await readFile13(file));
2605
2734
  return;
2606
2735
  }
2607
2736
  json(response, 200, { id: "still", status: "ready", progress: 1, output: file });
@@ -2623,7 +2752,7 @@ var devCommand = async (options = {}) => {
2623
2752
  input,
2624
2753
  { scenes: compiled.scenes, audio: compiled.audio }
2625
2754
  );
2626
- const output = resolve18(
2755
+ const output = resolve19(
2627
2756
  exportDestination(config),
2628
2757
  `${outputName(video.entry.metadata.id)}${format.extension}`
2629
2758
  );
@@ -2656,6 +2785,24 @@ var devCommand = async (options = {}) => {
2656
2785
  json(response, 200, (await readJob(config, id)).job);
2657
2786
  return;
2658
2787
  }
2788
+ if (request.method === "GET" && url.startsWith("/source")) {
2789
+ const asked = new URL(url, "http://localhost").searchParams.get("file") ?? "";
2790
+ const file = resolve19(config.root, asked);
2791
+ const inside = relative7(config.root, file);
2792
+ const readable = /\.(tsx?|jsx?|css|json|md)$/.test(file);
2793
+ if (!inside || inside.startsWith("..") || !readable) {
2794
+ json(response, 400, { error: `Refusing to read ${asked}.` });
2795
+ return;
2796
+ }
2797
+ try {
2798
+ response.statusCode = 200;
2799
+ response.setHeader("content-type", "text/plain; charset=utf-8");
2800
+ response.end(await readFile13(file, "utf8"));
2801
+ } catch {
2802
+ json(response, 404, { error: `${asked} is not there.` });
2803
+ }
2804
+ return;
2805
+ }
2659
2806
  if (request.method === "GET" && url.startsWith("/jobs")) {
2660
2807
  json(response, 200, await listJobs(config));
2661
2808
  return;
@@ -2680,16 +2827,16 @@ var devCommand = async (options = {}) => {
2680
2827
 
2681
2828
  // src/commands/doctor.ts
2682
2829
  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";
2830
+ import { access, mkdir as mkdir13, readFile as readFile14, rm as rm4, writeFile as writeFile14 } from "fs/promises";
2831
+ import { existsSync as existsSync17 } from "fs";
2685
2832
  import { createRequire as createRequire3 } from "module";
2686
- import { relative as relative6, resolve as resolve19 } from "path";
2833
+ import { relative as relative8, resolve as resolve20 } from "path";
2687
2834
  var MINIMUM_NODE = 20;
2688
2835
  var version = (value) => value.replace(/^v/, "").split(".").map(Number);
2689
2836
  var runChecks = async (root) => {
2690
2837
  const checks = [];
2691
2838
  const config = await loadConfig(root);
2692
- const require2 = createRequire3(resolve19(root, "package.json"));
2839
+ const require2 = createRequire3(resolve20(root, "package.json"));
2693
2840
  const [major] = version(process.version);
2694
2841
  checks.push({
2695
2842
  name: "Node",
@@ -2700,7 +2847,7 @@ var runChecks = async (root) => {
2700
2847
  let react2 = "not found";
2701
2848
  let reactOk = false;
2702
2849
  try {
2703
- const manifest = JSON.parse(await readFile13(require2.resolve("react/package.json"), "utf8"));
2850
+ const manifest = JSON.parse(await readFile14(require2.resolve("react/package.json"), "utf8"));
2704
2851
  react2 = manifest.version;
2705
2852
  reactOk = version(react2)[0] >= 19;
2706
2853
  } catch {
@@ -2712,16 +2859,16 @@ var runChecks = async (root) => {
2712
2859
  ok: reactOk,
2713
2860
  fix: "The runtime needs React 19. Install it: npm install react@19 react-dom@19"
2714
2861
  });
2715
- const videosDir = resolve19(config.root, config.videosDir);
2862
+ const videosDir = resolve20(config.root, config.videosDir);
2716
2863
  checks.push({
2717
2864
  name: "Source root",
2718
- detail: existsSync16(videosDir) ? relative6(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2719
- ok: existsSync16(videosDir),
2865
+ detail: existsSync17(videosDir) ? relative8(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2866
+ ok: existsSync17(videosDir),
2720
2867
  fix: 'Run "odori init" to add the videos source root.'
2721
2868
  });
2722
2869
  checks.push({
2723
2870
  name: "Config",
2724
- detail: config.configPath ? relative6(config.root, config.configPath) : "defaults (no odori.config.ts)",
2871
+ detail: config.configPath ? relative8(config.root, config.configPath) : "defaults (no odori.config.ts)",
2725
2872
  // Loading got this far, so a config that exists also parsed.
2726
2873
  ok: true
2727
2874
  });
@@ -2747,23 +2894,42 @@ var runChecks = async (root) => {
2747
2894
  detail: unpinned.length === 0 ? `pinned binaries from ${cacheRoot()}` : `${unpinned.length} of 2 from the host; frames may differ from another machine`,
2748
2895
  ok: true
2749
2896
  });
2750
- const generated = resolve19(config.root, ".odori");
2897
+ const generated = resolve20(config.root, ".odori");
2751
2898
  let writable = false;
2752
2899
  try {
2753
- await mkdir12(generated, { recursive: true });
2754
- const probe = resolve19(generated, ".doctor");
2755
- await writeFile13(probe, "", "utf8");
2900
+ await mkdir13(generated, { recursive: true });
2901
+ const probe = resolve20(generated, ".doctor");
2902
+ await writeFile14(probe, "", "utf8");
2756
2903
  await access(probe, constants.W_OK);
2757
- await rm3(probe, { force: true });
2904
+ await rm4(probe, { force: true });
2758
2905
  writable = true;
2759
2906
  } catch {
2760
2907
  writable = false;
2761
2908
  }
2909
+ const componentsRoot = resolve20(root, config.componentsDir);
2910
+ const orphans = [];
2911
+ if (existsSync17(componentsRoot)) {
2912
+ const { readdir: readdir8 } = await import("fs/promises");
2913
+ for (const entry of await readdir8(componentsRoot, { withFileTypes: true })) {
2914
+ if (!entry.isDirectory()) continue;
2915
+ const files = await readdir8(resolve20(componentsRoot, entry.name));
2916
+ const source = files.some((file) => /\.tsx$/.test(file) && !file.endsWith(".preview.tsx"));
2917
+ const fixture = files.some((file) => file.endsWith(".preview.tsx"));
2918
+ if (source && !fixture) orphans.push(entry.name);
2919
+ }
2920
+ }
2921
+ checks.push({
2922
+ name: "Component previews",
2923
+ detail: orphans.length === 0 ? "every component has a fixture" : `${orphans.length} without a fixture: ${orphans.slice(0, 4).join(", ")}${orphans.length > 4 ? ", \u2026" : ""}`,
2924
+ ok: true,
2925
+ warn: orphans.length > 0,
2926
+ 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.`
2927
+ });
2762
2928
  checks.push({
2763
2929
  name: "Generated cache",
2764
2930
  detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
2765
2931
  ok: writable,
2766
- fix: `Odori writes its import graph and render cache to ${relative6(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
2932
+ fix: `Odori writes its import graph and render cache to ${relative8(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
2767
2933
  });
2768
2934
  return checks;
2769
2935
  };
@@ -2773,12 +2939,15 @@ var doctorCommand = async (root = process.cwd()) => {
2773
2939
  log.title("odori doctor");
2774
2940
  for (const check of checks) {
2775
2941
  const label = check.name.padEnd(width);
2776
- if (check.ok) log.success(`${label} ${check.detail}`);
2942
+ if (check.ok && check.warn) log.warn(`${label} ${check.detail}`);
2943
+ else if (check.ok) log.success(`${label} ${check.detail}`);
2777
2944
  else log.error(`${label} ${check.detail}`);
2778
2945
  }
2946
+ const warned = checks.filter((check) => check.ok && check.warn);
2779
2947
  const failed = checks.filter((check) => !check.ok);
2780
2948
  if (failed.length === 0) {
2781
2949
  log.detail("Everything a render needs is present.");
2950
+ for (const check of warned) log.detail(` ${check.fix}`);
2782
2951
  return 0;
2783
2952
  }
2784
2953
  log.info("");
@@ -2787,14 +2956,14 @@ var doctorCommand = async (root = process.cwd()) => {
2787
2956
  };
2788
2957
 
2789
2958
  // 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";
2959
+ import { mkdir as mkdir15, writeFile as writeFile16 } from "fs/promises";
2960
+ import { existsSync as existsSync19 } from "fs";
2961
+ import { relative as relative10, resolve as resolve22 } from "path";
2793
2962
 
2794
2963
  // 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";
2964
+ import { mkdir as mkdir14, readdir as readdir5, writeFile as writeFile15 } from "fs/promises";
2965
+ import { existsSync as existsSync18 } from "fs";
2966
+ import { relative as relative9, resolve as resolve21 } from "path";
2798
2967
  var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2799
2968
  var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
2800
2969
  var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
@@ -2852,27 +3021,27 @@ ${closing}
2852
3021
  `;
2853
3022
  };
2854
3023
  var installedParts = async (config) => {
2855
- const componentsDir = resolve20(config.root, config.componentsDir);
2856
- if (!existsSync17(componentsDir)) return { title: false, end: false };
3024
+ const componentsDir = resolve21(config.root, config.componentsDir);
3025
+ if (!existsSync18(componentsDir)) return { title: false, end: false };
2857
3026
  const entries = (await readdir5(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2858
3027
  return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
2859
3028
  };
2860
3029
  var newCommand = async (name, options = {}) => {
2861
3030
  if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("Use a lowercase, dash separated name.");
2862
3031
  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"));
3032
+ const directory2 = resolve21(config.root, config.videosDir, name);
3033
+ const file = resolve21(directory2, "video.tsx");
3034
+ if (existsSync18(file)) throw new Error(`${relative9(config.root, file)} already exists.`);
3035
+ const hasLayout = existsSync18(resolve21(config.root, config.videosDir, "layout.tsx"));
2867
3036
  const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
2868
3037
  const composed = parts.title || parts.end;
2869
- await mkdir13(directory2, { recursive: true });
2870
- await writeFile14(
3038
+ await mkdir14(directory2, { recursive: true });
3039
+ await writeFile15(
2871
3040
  file,
2872
3041
  composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
2873
3042
  "utf8"
2874
3043
  );
2875
- log.success(`Created ${relative7(config.root, file)}`);
3044
+ log.success(`Created ${relative9(config.root, file)}`);
2876
3045
  if (composed) log.detail("Composed from the components this project has installed.");
2877
3046
  else if (options.blank !== true) {
2878
3047
  log.detail("No registry components installed yet: odori add title-reveal end-card");
@@ -2906,21 +3075,21 @@ export const productLayout = defineVideoLayout({
2906
3075
  });
2907
3076
  `;
2908
3077
  var initCommand = async (root = process.cwd()) => {
2909
- const videosDir = resolve21(root, defaultConfig.videosDir);
2910
- await mkdir14(resolve21(videosDir, "components"), { recursive: true });
3078
+ const videosDir = resolve22(root, defaultConfig.videosDir);
3079
+ await mkdir15(resolve22(videosDir, "components"), { recursive: true });
2911
3080
  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)]
3081
+ [resolve22(root, "odori.config.ts"), CONFIG_TEMPLATE],
3082
+ [resolve22(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
3083
+ [resolve22(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
2915
3084
  ];
2916
3085
  for (const [file, contents] of files) {
2917
- if (existsSync18(file)) {
2918
- log.detail(`Kept existing ${relative8(root, file)}`);
3086
+ if (existsSync19(file)) {
3087
+ log.detail(`Kept existing ${relative10(root, file)}`);
2919
3088
  continue;
2920
3089
  }
2921
- await mkdir14(resolve21(file, ".."), { recursive: true });
2922
- await writeFile15(file, contents, "utf8");
2923
- log.success(`Created ${relative8(root, file)}`);
3090
+ await mkdir15(resolve22(file, ".."), { recursive: true });
3091
+ await writeFile16(file, contents, "utf8");
3092
+ log.success(`Created ${relative10(root, file)}`);
2924
3093
  }
2925
3094
  log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
2926
3095
  };
@@ -3011,15 +3180,16 @@ var listCommand = async () => {
3011
3180
  }
3012
3181
  };
3013
3182
 
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
- }
3183
+ // src/commands/frame.ts
3184
+ import { resolve as resolve23 } from "path";
3185
+ import { framesFromOffset, resolveEntryLayout as resolveEntryLayout7 } from "odori";
3186
+ var frameCommand = async (id, options = {}) => {
3187
+ const at = options.at ?? 0;
3188
+ framesFromOffset(at, 30);
3021
3189
  const { config, graph, videos } = await createContext();
3022
3190
  const video = findVideo(videos, id);
3191
+ const fps = resolveEntryLayout7(video.entry).format.fps;
3192
+ const frame = framesFromOffset(at, fps);
3023
3193
  const output = await withServer(config, async (server) => {
3024
3194
  const { durationInFrames, scenes, audio } = await compileInBrowser(server.url, targetFor(video, options.input), config);
3025
3195
  const { manifest, input, prepared } = await freezeManifest(
@@ -3033,21 +3203,21 @@ var stillCommand = async (id, options = {}) => {
3033
3203
  throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
3034
3204
  }
3035
3205
  const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
3036
- const file = resolve22(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3206
+ const file = resolve23(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3037
3207
  return renderStill(server.url, target, frame, file, config);
3038
3208
  });
3039
- log.success(`Still frame ${frame} written to ${output}`);
3209
+ log.success(`Frame ${frame} written to ${output}`);
3040
3210
  return output;
3041
3211
  };
3042
3212
 
3043
3213
  // src/commands/test.ts
3044
- import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3214
+ import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout9 } from "odori";
3045
3215
 
3046
3216
  // src/contracts.ts
3047
- import { existsSync as existsSync19 } from "fs";
3217
+ import { existsSync as existsSync20 } from "fs";
3048
3218
  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";
3219
+ import { resolve as resolve24 } from "path";
3220
+ import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3051
3221
  var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
3052
3222
  var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
3053
3223
  "system-ui",
@@ -3113,8 +3283,8 @@ var checkAudioWindows = (cues, brand, videoId) => {
3113
3283
  return failures;
3114
3284
  };
3115
3285
  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) : [];
3286
+ const componentsDir = resolve24(config.root, config.componentsDir);
3287
+ const onDisk = existsSync20(componentsDir) ? (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
3118
3288
  const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
3119
3289
  if (names.size === 0) return [];
3120
3290
  const { items } = await resolveRegistry(config, { allowNetwork: false });
@@ -3123,7 +3293,7 @@ var checkInstalledContracts = async (config, videos) => {
3123
3293
  const seen = /* @__PURE__ */ new Set();
3124
3294
  const failures = [];
3125
3295
  for (const video of videos) {
3126
- const { brand } = resolveEntryLayout7(video.entry);
3296
+ const { brand } = resolveEntryLayout8(video.entry);
3127
3297
  for (const failure of checkComponentRequirements(installed, brand, video.entry.metadata.id)) {
3128
3298
  const key = `${brand.name}:${failure.message}`;
3129
3299
  if (seen.has(key)) continue;
@@ -3135,9 +3305,9 @@ var checkInstalledContracts = async (config, videos) => {
3135
3305
  };
3136
3306
 
3137
3307
  // 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";
3308
+ import { readdir as readdir7, readFile as readFile15 } from "fs/promises";
3309
+ import { existsSync as existsSync21 } from "fs";
3310
+ import { join as join7, relative as relative11, resolve as resolve25 } from "path";
3141
3311
  var FORBIDDEN = [
3142
3312
  {
3143
3313
  pattern: /\bMath\.random\s*\(/,
@@ -3178,11 +3348,11 @@ var walk2 = async (directory2, files = []) => {
3178
3348
  return files;
3179
3349
  };
3180
3350
  var checkDeterminism = async (config) => {
3181
- const root = resolve24(config.root, config.videosDir);
3182
- if (!existsSync20(root)) return [];
3351
+ const root = resolve25(config.root, config.videosDir);
3352
+ if (!existsSync21(root)) return [];
3183
3353
  const files = await walk2(root);
3184
3354
  const findings = await Promise.all(
3185
- files.map(async (file) => scanSource(await readFile14(file, "utf8"), relative9(config.root, file)))
3355
+ files.map(async (file) => scanSource(await readFile15(file, "utf8"), relative11(config.root, file)))
3186
3356
  );
3187
3357
  return findings.flat();
3188
3358
  };
@@ -3281,7 +3451,7 @@ var FRAME_SCRIPT = `(() => {
3281
3451
  })()`;
3282
3452
  var testVideo = async (origin, video, config, failures, quiet = false) => {
3283
3453
  const id = video.entry.metadata.id;
3284
- const layout = resolveEntryLayout8(video.entry);
3454
+ const layout = resolveEntryLayout9(video.entry);
3285
3455
  if (isOdoriSchema2(video.entry.metadata.schema)) {
3286
3456
  const result = video.entry.metadata.schema.safeParse(video.entry.metadata.defaultProps ?? {});
3287
3457
  if (!result.success) failures.push({ video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}` });
@@ -3446,7 +3616,7 @@ var COMMAND_FLAGS = {
3446
3616
  update: ["force"],
3447
3617
  list: [],
3448
3618
  inspect: ["json", "input"],
3449
- still: ["frame", "output", "input"],
3619
+ frame: ["at", "output", "input"],
3450
3620
  test: ["json"],
3451
3621
  export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
3452
3622
  jobs: [],
@@ -3509,8 +3679,9 @@ var USAGE = {
3509
3679
  Print discovered video ids and formats.`,
3510
3680
  inspect: `odori inspect <id> [--json] [--input <json>]
3511
3681
  Show resolved layout, inputs, scenes, and assets.`,
3512
- still: `odori still <id> --frame <n> [--output <path>] [--input <json>]
3513
- Render one deterministic frame.`,
3682
+ frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
3683
+ Render one deterministic frame to a PNG. --at is a duration: 4s, 500ms, or
3684
+ 120f for frame 120. A bare number is seconds.`,
3514
3685
  test: `odori test [id] [--json]
3515
3686
  Validate contracts and representative frames. --json emits one object per
3516
3687
  check, for CI.`,
@@ -3538,14 +3709,14 @@ Usage
3538
3709
  odori update [components] Apply upstream component changes
3539
3710
  odori list Print discovered video ids and formats
3540
3711
  odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
3541
- odori still <id> --frame 120 Render one deterministic frame
3712
+ odori frame <id> --at 4s Render one deterministic frame to a PNG
3542
3713
  odori test [id] [--json] Validate contracts and representative frames
3543
3714
  odori export <id> [--output f] Render and encode a distributable file
3544
3715
  odori jobs List export jobs and their status
3545
3716
 
3546
3717
  Options
3547
3718
  --input '{"headline":"..."}' Serializable input for the video schema
3548
- --output <path> Output path for still and export
3719
+ --output <path> Output path for frame and export
3549
3720
  --force Replace locally modified component source
3550
3721
  --concurrency <n> Parallel render workers for export
3551
3722
  --preset <name> x264 preset for export, default medium
@@ -3622,9 +3793,11 @@ var run2 = async (argv) => {
3622
3793
  case "inspect":
3623
3794
  await inspectCommand(positionals[0] ?? "", { json: flags.json === true, input: parseInput(flags) });
3624
3795
  return 0;
3625
- case "still":
3626
- await stillCommand(positionals[0] ?? "", {
3627
- frame: numberFlag(flags, "frame") ?? 0,
3796
+ case "frame":
3797
+ await frameCommand(positionals[0] ?? "", {
3798
+ // A duration, so "4s" and "120f" both work; a bare number is
3799
+ // seconds, the way every other time value in Odori reads.
3800
+ at: typeof flags.at === "string" ? flags.at : numberFlag(flags, "at") ?? 0,
3628
3801
  output: typeof flags.output === "string" ? flags.output : void 0,
3629
3802
  input: parseInput(flags)
3630
3803
  });
@@ -3729,7 +3902,7 @@ export {
3729
3902
  exportCommand,
3730
3903
  jobsCommand,
3731
3904
  devCommand,
3732
- stillCommand,
3905
+ frameCommand,
3733
3906
  diffLines,
3734
3907
  countChanges,
3735
3908
  formatDiff,