@odori/cli 0.0.2 → 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.
Files changed (41) hide show
  1. package/dist/{chunk-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
  2. package/dist/cli.js +1 -1
  3. package/dist/index.d.ts +63 -8
  4. package/dist/index.js +3 -3
  5. package/dist/registry-snapshot-MSH2EA36.js +4867 -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/chunk-cache.ts +6 -0
  10. package/src/cli.ts +22 -12
  11. package/src/commands/add.ts +33 -8
  12. package/src/commands/dev.ts +114 -12
  13. package/src/commands/doctor.ts +47 -2
  14. package/src/commands/exportVideo.ts +39 -5
  15. package/src/commands/{still.ts → frame.ts} +23 -9
  16. package/src/commands/init.ts +1 -1
  17. package/src/commands/new.ts +1 -1
  18. package/src/cues.ts +34 -21
  19. package/src/discovery.ts +85 -5
  20. package/src/formats.ts +67 -8
  21. package/src/index.ts +1 -1
  22. package/src/jobs.ts +6 -2
  23. package/src/registry-snapshot.json +1530 -328
  24. package/src/registry-source.ts +87 -5
  25. package/src/render.ts +48 -13
  26. package/src/server.ts +55 -13
  27. package/studio/src/Studio.tsx +19 -22
  28. package/studio/src/components/ExportPanel.tsx +124 -90
  29. package/studio/src/components/Inspector.tsx +221 -0
  30. package/studio/src/components/Navigator.tsx +145 -0
  31. package/studio/src/components/Thumbnail.tsx +65 -23
  32. package/studio/src/components/ui.tsx +9 -2
  33. package/studio/src/lib/highlight.ts +85 -0
  34. package/studio/src/studio.css +435 -20
  35. package/studio/src/views/AssetsView.tsx +14 -1
  36. package/studio/src/views/BrandsView.tsx +74 -26
  37. package/studio/src/views/ComponentsView.tsx +206 -55
  38. package/studio/src/views/HomeView.tsx +44 -19
  39. package/studio/src/views/VideosView.tsx +53 -48
  40. package/studio/src/virtual.d.ts +4 -1
  41. package/dist/registry-snapshot-NIH2JMQ6.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 relative3, 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}`}";`;
@@ -121,7 +127,7 @@ ${indent}"${cue.name}": ${cue.export}(),`;
121
127
  import { createHash } from "crypto";
122
128
  import { existsSync as existsSync4 } from "fs";
123
129
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
124
- import { dirname as dirname2, resolve as resolve4 } from "path";
130
+ import { dirname as dirname2, isAbsolute, relative as relative2, resolve as resolve4, sep } from "path";
125
131
 
126
132
  // src/binaries.ts
127
133
  import { spawn } from "child_process";
@@ -268,14 +274,41 @@ var installCommand = async () => {
268
274
 
269
275
  // src/registry-source.ts
270
276
  var normalizeComponentName = (name) => name.replace(/^@odori\//, "");
277
+ var assertSafeName = (name) => {
278
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
279
+ throw new Error(
280
+ `Refusing component name ${JSON.stringify(name)}: a name must be a lowercase slug (letters, digits, and single dashes). This registry is malformed or tampered with.`
281
+ );
282
+ }
283
+ return name;
284
+ };
285
+ var resolveWithinRoot = (root, target) => {
286
+ const destination = resolve4(root, target);
287
+ const rel = relative2(root, destination);
288
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
289
+ throw new Error(
290
+ `Refusing to write ${JSON.stringify(target)}: it resolves outside the project. Nothing was written.`
291
+ );
292
+ }
293
+ return destination;
294
+ };
271
295
  var DEFAULT_URL = "https://odori.dev/r/v1";
272
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
+ };
273
305
  var cacheDir = (url) => resolve4(cacheRoot(), "registry", createHash("sha256").update(url).digest("hex").slice(0, 16));
274
306
  var toComponent = (item) => ({
275
307
  name: item.name,
276
308
  namespaced: item.meta?.namespaced ?? `@odori/${item.name}`,
277
309
  kind: item.meta?.kind ?? "component",
278
310
  ...item.meta?.cue ? { cue: item.meta.cue } : {},
311
+ ...item.meta?.asset ? { asset: item.meta.asset } : {},
279
312
  family: item.meta?.family ?? "Uncategorized",
280
313
  description: item.description ?? "",
281
314
  files: item.files.map((file) => file.path.split("/").pop() ?? file.path),
@@ -284,7 +317,7 @@ var toComponent = (item) => ({
284
317
  });
285
318
  var snapshotItems = async () => {
286
319
  try {
287
- const loaded = await import("./registry-snapshot-NIH2JMQ6.js");
320
+ const loaded = await import("./registry-snapshot-MSH2EA36.js");
288
321
  return loaded.default.items;
289
322
  } catch {
290
323
  throw new Error(
@@ -327,6 +360,7 @@ var resolveRegistry = async (config, options = {}) => {
327
360
  return { items: await bundled(), origin: "bundled", detail: "the copy built into this CLI" };
328
361
  };
329
362
  var resolveItem = async (config, name, options = {}) => {
363
+ assertSafeName(name);
330
364
  const url = registryUrl(config);
331
365
  const cache = resolve4(cacheDir(url), `${name}.json`);
332
366
  if (options.allowNetwork !== false) {
@@ -349,9 +383,14 @@ var resolveItem = async (config, name, options = {}) => {
349
383
  if (!item) throw new Error(`No component named "${name}" in the registry at ${url}, in the cache, or in this CLI.`);
350
384
  return { item, origin: "bundled" };
351
385
  };
352
- var verifyIntegrity = (item) => {
386
+ var verifyIntegrity = (item, origin = "network") => {
353
387
  const expected = item.meta?.integrity;
354
- if (!expected) return;
388
+ if (!expected) {
389
+ if (origin === "bundled") return;
390
+ throw new Error(
391
+ `The registry document for "${item.name}" carries no integrity hash. A fetched item must be verifiable; refusing to write it. Nothing was written.`
392
+ );
393
+ }
355
394
  const hash = createHash("sha256");
356
395
  for (const file of [...item.files].sort((left, right) => left.path.localeCompare(right.path))) {
357
396
  hash.update(file.path);
@@ -369,10 +408,66 @@ Nothing was written. This is a truncated download, a stale proxy, or a tampered
369
408
  );
370
409
  };
371
410
 
372
- // src/commands/update.ts
373
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
411
+ // src/assets.ts
412
+ import { createHash as createHash2 } from "crypto";
374
413
  import { existsSync as existsSync5 } from "fs";
375
- import { relative as relative2, 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";
376
471
  import { hashString } from "odori";
377
472
 
378
473
  // src/diff.ts
@@ -435,15 +530,15 @@ var formatDiff = (lines, context = 2) => {
435
530
  };
436
531
 
437
532
  // src/commands/update.ts
438
- var provenanceFile = (config) => resolve5(config.root, config.outDir, "components.json");
533
+ var provenanceFile = (config) => resolve6(config.root, config.outDir, "components.json");
439
534
  var readProvenance = async (config) => {
440
535
  const file = provenanceFile(config);
441
- if (!existsSync5(file)) return {};
442
- return JSON.parse(await readFile4(file, "utf8"));
536
+ if (!existsSync6(file)) return {};
537
+ return JSON.parse(await readFile5(file, "utf8"));
443
538
  };
444
539
  var writeProvenance = async (config, provenance) => {
445
- await mkdir3(resolve5(config.root, config.outDir), { recursive: true });
446
- 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)}
447
542
  `, "utf8");
448
543
  };
449
544
  var componentStatus = async (config, only) => {
@@ -459,10 +554,10 @@ var componentStatus = async (config, only) => {
459
554
  const upstreamFiles = new Map(item.files.map((file) => [file.path.split("/").pop() ?? file.path, file.content]));
460
555
  const files = await Promise.all(
461
556
  component.files.map(async (file) => {
462
- const localPath = resolve5(config.root, config.componentsDir, name, file);
557
+ const localPath = resolve6(config.root, config.componentsDir, name, file);
463
558
  const content = upstreamFiles.get(file);
464
559
  if (content === void 0) throw new Error(`The registry document for "${name}" has no file named ${file}.`);
465
- const local = existsSync5(localPath) ? hashString(await readFile4(localPath, "utf8")) : null;
560
+ const local = existsSync6(localPath) ? hashString(await readFile5(localPath, "utf8")) : null;
466
561
  return {
467
562
  file,
468
563
  localPath,
@@ -499,14 +594,14 @@ var diffCommand = async (names, options = {}) => {
499
594
  log.title(`@odori/${status.name} ${LABELS[status.state]}`);
500
595
  for (const file of status.files) {
501
596
  if (file.local === null) {
502
- log.error(` ${file.file} is missing from ${relative2(config.root, resolve5(file.localPath, ".."))}`);
597
+ log.error(` ${file.file} is missing from ${relative4(config.root, resolve6(file.localPath, ".."))}`);
503
598
  continue;
504
599
  }
505
600
  if (file.local === file.upstream) {
506
601
  log.detail(` ${file.file} identical to upstream`);
507
602
  continue;
508
603
  }
509
- const lines = diffLines(await readFile4(file.localPath, "utf8"), file.content);
604
+ const lines = diffLines(await readFile5(file.localPath, "utf8"), file.content);
510
605
  const { added, removed } = countChanges(lines);
511
606
  log.info(` ${file.file} +${added} -${removed} against upstream`);
512
607
  if (options.full) for (const line of formatDiff(lines)) log.detail(` ${line}`);
@@ -539,8 +634,8 @@ var updateCommand = async (names, options = {}) => {
539
634
  continue;
540
635
  }
541
636
  for (const file of status.files) {
542
- await mkdir3(resolve5(file.localPath, ".."), { recursive: true });
543
- await writeFile4(file.localPath, file.content, "utf8");
637
+ await mkdir4(resolve6(file.localPath, ".."), { recursive: true });
638
+ await writeFile5(file.localPath, file.content, "utf8");
544
639
  }
545
640
  provenance[status.name] = {
546
641
  source: `@odori/${status.name}`,
@@ -557,7 +652,7 @@ var updateCommand = async (names, options = {}) => {
557
652
 
558
653
  // src/commands/add.ts
559
654
  var addCommand = async (names, options = {}) => {
560
- if (names.length === 0) throw new Error("Name at least one component, for example @odori/title-reveal.");
655
+ if (names.length === 0) throw new Error("Name at least one component, for example title-reveal.");
561
656
  const config = await loadConfig(process.cwd());
562
657
  const source = await resolveRegistry(config);
563
658
  const registry = source.items;
@@ -580,34 +675,54 @@ var addCommand = async (names, options = {}) => {
580
675
  if (provider) queue.push(provider.name);
581
676
  else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
582
677
  }
583
- const { item } = await resolveItem(config, component.name);
584
- verifyIntegrity(item);
585
- const target = resolve6(config.root, config.componentsDir, component.name);
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
+ }
698
+ const { item, origin } = await resolveItem(config, component.name);
699
+ verifyIntegrity(item, origin);
700
+ const target = resolve7(config.root, config.componentsDir, assertSafeName(component.name));
586
701
  const hashes = {};
587
702
  for (const file of item.files) {
588
- const destination = resolve6(config.root, file.target);
589
- const exists = existsSync6(destination);
590
- log.detail(` ${exists ? "replace" : "create "} ${relative3(config.root, destination)}`);
703
+ const destination = resolveWithinRoot(config.root, file.target);
704
+ const exists = existsSync7(destination);
705
+ log.detail(` ${exists ? "replace" : "create "} ${relative5(config.root, destination)}`);
591
706
  }
592
707
  if (options.dryRun) {
593
708
  installed.push(component.name);
594
709
  continue;
595
710
  }
596
- await mkdir4(target, { recursive: true });
711
+ await mkdir5(target, { recursive: true });
597
712
  for (const file of item.files) {
598
713
  const name2 = file.path.split("/").pop() ?? file.path;
599
- const destination = resolve6(config.root, file.target);
714
+ const destination = resolveWithinRoot(config.root, file.target);
600
715
  hashes[name2] = hashString2(file.content);
601
- if (existsSync6(destination) && !options.force) {
602
- const current = hashString2(await readFile5(destination, "utf8"));
716
+ if (existsSync7(destination) && !options.force) {
717
+ const current = hashString2(await readFile6(destination, "utf8"));
603
718
  const recorded = provenance[component.name]?.hashes[name2];
604
719
  if (current !== recorded) {
605
- log.warn(`${relative3(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.`);
606
721
  continue;
607
722
  }
608
723
  }
609
- await mkdir4(resolve6(destination, ".."), { recursive: true });
610
- await writeFile5(destination, file.content, "utf8");
724
+ await mkdir5(resolve7(destination, ".."), { recursive: true });
725
+ await writeFile6(destination, file.content, "utf8");
611
726
  }
612
727
  provenance[component.name] = {
613
728
  source: component.namespaced,
@@ -616,7 +731,7 @@ var addCommand = async (names, options = {}) => {
616
731
  hashes
617
732
  };
618
733
  installed.push(component.name);
619
- log.success(`${component.namespaced} to ${relative3(config.root, target)}/`);
734
+ log.success(`${component.namespaced} to ${relative5(config.root, target)}/`);
620
735
  if (component.kind === "cue" && component.cue) {
621
736
  log.detail(
622
737
  ` ${component.family} \xB7 ${component.contract.recommendedDurationInFrames} frames \xB7 registers "${component.cue.name}"`
@@ -664,17 +779,19 @@ var registryCommand = async () => {
664
779
  };
665
780
 
666
781
  // src/commands/dev.ts
667
- import { resolve as resolve18 } from "path";
668
- import { readFile as readFile12 } from "fs/promises";
782
+ import { relative as relative7, resolve as resolve19 } from "path";
783
+ import { homedir as homedir2 } from "os";
784
+ import { existsSync as existsSync16 } from "fs";
785
+ import { readFile as readFile13 } from "fs/promises";
669
786
 
670
787
  // src/jobs.ts
671
- import { mkdir as mkdir5, readFile as readFile6, readdir as readdir2, rename, writeFile as writeFile6 } from "fs/promises";
672
- import { existsSync as existsSync7 } from "fs";
673
- import { join as join2, resolve as resolve7 } from "path";
674
- 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");
675
792
  var jobFile = (config, id) => join2(buildsDir(config), `${id}.json`);
676
- var createJob = async (config, manifest, output) => {
677
- await mkdir5(buildsDir(config), { recursive: true });
793
+ var createJob = async (config, manifest, output, render) => {
794
+ await mkdir6(buildsDir(config), { recursive: true });
678
795
  const now = (/* @__PURE__ */ new Date()).toISOString();
679
796
  const job = {
680
797
  id: `job-${manifest.manifestHash.slice(0, 10)}-${Date.now().toString(36)}`,
@@ -687,15 +804,15 @@ var createJob = async (config, manifest, output) => {
687
804
  createdAt: now,
688
805
  updatedAt: now
689
806
  };
690
- const record = { job, manifest, output };
691
- await writeFile6(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
807
+ const record = { job, manifest, output, ...render ? { render } : {} };
808
+ await writeFile7(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
692
809
  `, "utf8");
693
810
  return record;
694
811
  };
695
812
  var readJob = async (config, id) => {
696
813
  const file = jobFile(config, id);
697
- if (!existsSync7(file)) throw new Error(`Unknown job "${id}". Run odori jobs to list them.`);
698
- 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"));
699
816
  };
700
817
  var writeLocks = /* @__PURE__ */ new Map();
701
818
  var withJobLock = (id, task) => {
@@ -710,7 +827,7 @@ var withJobLock = (id, task) => {
710
827
  var writeRecord = async (config, record) => {
711
828
  const file = jobFile(config, record.job.id);
712
829
  const temporary = `${file}.${process.pid}.tmp`;
713
- await writeFile6(temporary, `${JSON.stringify(record, null, 2)}
830
+ await writeFile7(temporary, `${JSON.stringify(record, null, 2)}
714
831
  `, "utf8");
715
832
  await rename(temporary, file);
716
833
  };
@@ -753,13 +870,13 @@ var reconcileJobs = async (config) => {
753
870
  return stale.length;
754
871
  };
755
872
  var listJobs = async (config, options = {}) => {
756
- if (!existsSync7(buildsDir(config))) return [];
873
+ if (!existsSync8(buildsDir(config))) return [];
757
874
  if (options.reconcile !== false) await reconcileJobs(config);
758
875
  const files = (await readdir2(buildsDir(config))).filter((file) => file.endsWith(".json"));
759
876
  const jobs = [];
760
877
  for (const file of files) {
761
878
  try {
762
- const raw = await readFile6(join2(buildsDir(config), file), "utf8");
879
+ const raw = await readFile7(join2(buildsDir(config), file), "utf8");
763
880
  jobs.push(JSON.parse(raw).job);
764
881
  } catch {
765
882
  continue;
@@ -777,9 +894,9 @@ var JobQueue = class {
777
894
  };
778
895
 
779
896
  // src/discovery.ts
780
- import { mkdir as mkdir6, readdir as readdir3, readFile as readFile7, stat, writeFile as writeFile7 } from "fs/promises";
781
- import { existsSync as existsSync8 } from "fs";
782
- import { join as join3, relative as relative4, resolve as resolve8, sep } 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";
783
900
  import { hashString as hashString3 } from "odori";
784
901
  var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
785
902
  var walk = async (directory2, files = []) => {
@@ -801,22 +918,22 @@ var toIdentifier = (value, prefix) => {
801
918
  };
802
919
  var AUDIO_EXTENSIONS = /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i;
803
920
  var discoverAudio = async (config) => {
804
- const root = resolve8(config.root, config.audioDir);
805
- if (!existsSync8(root)) return [];
806
- 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");
807
924
  const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
808
925
  return Promise.all(
809
926
  files.map(async (file) => ({
810
- name: relative4(root, file).replace(AUDIO_EXTENSIONS, "").split(sep).join("/"),
811
- url: file.startsWith(`${publicRoot}${sep}`) ? `/${relative4(publicRoot, file).split(sep).join("/")}` : `/${relative4(config.root, file).split(sep).join("/")}`,
812
- relativeFile: relative4(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),
813
930
  bytes: (await stat(file)).size
814
931
  }))
815
932
  );
816
933
  };
817
934
  var discoverProject = async (config) => {
818
- const videosRoot = resolve8(config.root, config.videosDir);
819
- if (!existsSync8(videosRoot)) {
935
+ const videosRoot = resolve9(config.root, config.videosDir);
936
+ if (!existsSync9(videosRoot)) {
820
937
  throw new Error(`No ${config.videosDir}/ directory found in ${config.root}. Run "odori init" first.`);
821
938
  }
822
939
  const files = (await walk(videosRoot)).sort();
@@ -824,16 +941,42 @@ var discoverProject = async (config) => {
824
941
  const videos = [];
825
942
  const previews = [];
826
943
  const brands = [];
944
+ const categories = [];
827
945
  const hashParts = [];
946
+ const importedBy = {};
947
+ const componentsRoot = resolve9(config.root, config.componentsDir);
828
948
  for (const file of files) {
829
- const relativeFile = relative4(config.root, file);
949
+ const relativeFile = relative6(config.root, file);
950
+ let contents = "";
830
951
  if (/\.(tsx|ts|css|json)$/.test(file)) {
831
- const contents = await readFile7(file, "utf8");
952
+ contents = await readFile8(file, "utf8");
832
953
  hashParts.push(`${relativeFile}:${hashString3(contents)}`);
833
954
  }
834
- const base = file.split(sep).pop() ?? "";
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
+ }
973
+ if (base === "video.tsx") {
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
+ }
835
978
  if (base === "video.tsx") {
836
- const slug = relative4(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep).join("/") || "video";
979
+ const slug = relative6(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
837
980
  videos.push({
838
981
  slug,
839
982
  file,
@@ -841,13 +984,27 @@ var discoverProject = async (config) => {
841
984
  importPath: file,
842
985
  identifier: toIdentifier(slug, "video")
843
986
  });
844
- } else if (file.split(sep).includes("brands") && /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx")) {
987
+ } else if (
988
+ // A brand module is recognized by what it does, not where it sits. The
989
+ // scaffold defines its brand in videos/layout.tsx, so a directory-name
990
+ // rule alone left the default project's brand invisible to everything
991
+ // that reads this list — most visibly the dev server's cue registry,
992
+ // which then answered new cue URLs with 404s until a restart. Installed
993
+ // component source is excluded the way brand-file.ts excludes it: a
994
+ // component may mention defineBrand without being where a brand lives.
995
+ /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx") && !file.startsWith(componentsRoot + sep2) && (file.split(sep2).includes("brands") || contents.includes("defineBrand("))
996
+ ) {
845
997
  const name = base.replace(/\.tsx?$/, "");
846
998
  brands.push({
847
999
  name,
848
1000
  file,
849
1001
  relativeFile,
850
- identifier: toIdentifier(`${name}-module`, "brands")
1002
+ // From the whole relative path, like previews: basenames repeat
1003
+ // (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
1004
+ identifier: toIdentifier(
1005
+ `${relative6(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
1006
+ "brands"
1007
+ )
851
1008
  });
852
1009
  } else if (base.endsWith(".preview.tsx")) {
853
1010
  const name = base.replace(/\.preview\.tsx$/, "");
@@ -856,15 +1013,19 @@ var discoverProject = async (config) => {
856
1013
  file,
857
1014
  relativeFile,
858
1015
  importPath: file,
859
- identifier: toIdentifier(`${relative4(videosRoot, file).split(sep).join("-")}`, "preview")
1016
+ identifier: toIdentifier(`${relative6(videosRoot, file).split(sep2).join("-")}`, "preview")
860
1017
  });
861
1018
  }
862
1019
  }
863
- 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("|")) };
864
1025
  };
865
1026
  var generateImports = (graph, outDir) => {
866
1027
  const importPath = (file) => {
867
- const relativePath = relative4(outDir, file).split(sep).join("/");
1028
+ const relativePath = relative6(outDir, file).split(sep2).join("/");
868
1029
  return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
869
1030
  };
870
1031
  const lines = [
@@ -897,17 +1058,21 @@ var generateImports = (graph, outDir) => {
897
1058
  return lines.join("\n");
898
1059
  };
899
1060
  var writeGenerated = async (config, graph) => {
900
- const outDir = resolve8(config.root, config.outDir);
901
- await mkdir6(outDir, { recursive: true });
1061
+ const outDir = resolve9(config.root, config.outDir);
1062
+ await mkdir7(outDir, { recursive: true });
902
1063
  const target = join3(outDir, "imports.generated.ts");
903
- await writeFile7(target, generateImports(graph, outDir), "utf8");
904
- await writeFile7(
1064
+ await writeFile8(target, generateImports(graph, outDir), "utf8");
1065
+ await writeFile8(
905
1066
  join3(outDir, "catalog.json"),
906
1067
  `${JSON.stringify(
907
1068
  {
908
1069
  sourceHash: graph.sourceHash,
909
1070
  videos: graph.videos.map((video) => ({ slug: video.slug, file: video.relativeFile })),
910
- 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
+ })),
911
1076
  brands: graph.brands.map((brand) => ({ name: brand.name, file: brand.relativeFile })),
912
1077
  audio: graph.audio.map((entry) => ({ name: entry.name, url: entry.url, file: entry.relativeFile }))
913
1078
  },
@@ -921,7 +1086,7 @@ var writeGenerated = async (config, graph) => {
921
1086
  };
922
1087
 
923
1088
  // src/project.ts
924
- import { resolve as resolve11 } from "path";
1089
+ import { resolve as resolve12 } from "path";
925
1090
  import { pathToFileURL as pathToFileURL2 } from "url";
926
1091
  import {
927
1092
  createRenderManifest,
@@ -931,45 +1096,45 @@ import {
931
1096
  } from "odori";
932
1097
 
933
1098
  // src/integrity.ts
934
- import { createHash as createHash2 } from "crypto";
935
- import { existsSync as existsSync9 } from "fs";
936
- import { mkdir as mkdir7, readFile as readFile8, writeFile as writeFile8 } from "fs/promises";
937
- import { dirname as dirname3, resolve as resolve9 } from "path";
938
- 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");
939
1104
  var readCache = async (config) => {
940
1105
  const file = cacheFile(config);
941
- if (!existsSync9(file)) return {};
1106
+ if (!existsSync10(file)) return {};
942
1107
  try {
943
- return JSON.parse(await readFile8(file, "utf8"));
1108
+ return JSON.parse(await readFile9(file, "utf8"));
944
1109
  } catch {
945
1110
  return {};
946
1111
  }
947
1112
  };
948
1113
  var writeCache = async (config, cache) => {
949
1114
  const file = cacheFile(config);
950
- await mkdir7(dirname3(file), { recursive: true });
951
- await writeFile8(file, `${JSON.stringify(cache, null, 2)}
1115
+ await mkdir8(dirname4(file), { recursive: true });
1116
+ await writeFile9(file, `${JSON.stringify(cache, null, 2)}
952
1117
  `, "utf8");
953
1118
  };
954
- var sha256 = (bytes) => `sha256-${createHash2("sha256").update(bytes).digest("base64")}`;
1119
+ var sha256 = (bytes) => `sha256-${createHash3("sha256").update(bytes).digest("base64")}`;
955
1120
  var localCandidates = (config, url) => [
956
- resolve9(config.root, "public", url.replace(/^\//, "")),
957
- resolve9(config.root, url.replace(/^\//, ""))
1121
+ resolve10(config.root, "public", url.replace(/^\//, "")),
1122
+ resolve10(config.root, url.replace(/^\//, ""))
958
1123
  ];
959
- var isServed = (config, file) => file.startsWith(resolve9(config.root, "public") + "/");
1124
+ var isServed = (config, file) => file.startsWith(resolve10(config.root, "public") + "/");
960
1125
  var createIntegrityResolver = async (config) => {
961
1126
  const cache = await readCache(config);
962
1127
  const warned = /* @__PURE__ */ new Set();
963
1128
  let dirty = false;
964
1129
  const resolveIntegrity = async (url) => {
965
1130
  if (url.startsWith("/__odori/cue/")) return `cue-${url.slice(url.lastIndexOf("-") + 1).replace(/\.wav$/, "")}`;
966
- const local = localCandidates(config, url).find((candidate) => existsSync9(candidate));
1131
+ const local = localCandidates(config, url).find((candidate) => existsSync10(candidate));
967
1132
  if (local) {
968
1133
  if (!isServed(config, local) && !warned.has(url)) {
969
1134
  warned.add(url);
970
1135
  log.warn(`${url} resolves to ${local}, which is outside public/ and will not be served. Move it into public/.`);
971
1136
  }
972
- const bytes = await readFile8(local);
1137
+ const bytes = await readFile9(local);
973
1138
  const { mtimeMs } = await import("fs/promises").then((fs) => fs.stat(local));
974
1139
  const hit = cache[url];
975
1140
  if (hit && hit.mtimeMs === mtimeMs && hit.size === bytes.byteLength) return hit.integrity;
@@ -1001,9 +1166,9 @@ var createIntegrityResolver = async (config) => {
1001
1166
  };
1002
1167
 
1003
1168
  // src/prepare-cache.ts
1004
- import { existsSync as existsSync10 } from "fs";
1005
- import { mkdir as mkdir8, readFile as readFile9, readdir as readdir4, rm, writeFile as writeFile9 } from "fs/promises";
1006
- 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";
1007
1172
  import { hashValue } from "odori";
1008
1173
 
1009
1174
  // src/paths.ts
@@ -1011,13 +1176,13 @@ var outputName = (id) => id.split("/").join("-");
1011
1176
  var fileKey = (id) => id.split("/").join("+");
1012
1177
 
1013
1178
  // src/prepare-cache.ts
1014
- var directory = (config) => resolve10(config.root, config.outDir, "cache", "prepare");
1179
+ var directory = (config) => resolve11(config.root, config.outDir, "cache", "prepare");
1015
1180
  var prepareCacheKey = (key) => `${fileKey(key.videoId)}__${hashValue(key)}`;
1016
1181
  var readPrepareCache = async (config, key) => {
1017
1182
  const file = join4(directory(config), `${prepareCacheKey(key)}.json`);
1018
- if (!existsSync10(file)) return { hit: false, value: void 0 };
1183
+ if (!existsSync11(file)) return { hit: false, value: void 0 };
1019
1184
  try {
1020
- const entry = JSON.parse(await readFile9(file, "utf8"));
1185
+ const entry = JSON.parse(await readFile10(file, "utf8"));
1021
1186
  return { hit: true, value: entry.value };
1022
1187
  } catch {
1023
1188
  return { hit: false, value: void 0 };
@@ -1026,17 +1191,17 @@ var readPrepareCache = async (config, key) => {
1026
1191
  var writePrepareCache = async (config, key, value) => {
1027
1192
  if (value === void 0) return;
1028
1193
  const target = directory(config);
1029
- await mkdir8(target, { recursive: true });
1194
+ await mkdir9(target, { recursive: true });
1030
1195
  const entry = { key, value, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1031
- 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)}
1032
1197
  `, "utf8");
1033
1198
  };
1034
1199
  var clearPrepareCache = async (config, videoId) => {
1035
1200
  const target = directory(config);
1036
- if (!existsSync10(target)) return 0;
1201
+ if (!existsSync11(target)) return 0;
1037
1202
  const files = await readdir4(target);
1038
1203
  const matches = files.filter((file) => videoId ? file.startsWith(`${fileKey(videoId)}__`) : file.endsWith(".json"));
1039
- 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 })));
1040
1205
  return matches.length;
1041
1206
  };
1042
1207
 
@@ -1080,7 +1245,7 @@ var findVideo = (videos, id) => {
1080
1245
  return found;
1081
1246
  };
1082
1247
  var runPrepare = async (video, config, graph, input, options = {}) => {
1083
- const prepareFile = resolve11(video.file, "..", "prepare.ts");
1248
+ const prepareFile = resolve12(video.file, "..", "prepare.ts");
1084
1249
  let prepare;
1085
1250
  try {
1086
1251
  const module = await import(pathToFileURL2(prepareFile).href);
@@ -1162,22 +1327,135 @@ var freezeManifest = async (video, graph, config, rawInput, options = {}) => {
1162
1327
  return { manifest, input, prepared };
1163
1328
  };
1164
1329
 
1330
+ // src/formats.ts
1331
+ var QUALITIES = ["studio", "social", "web"];
1332
+ var scaleFilter = (scale) => `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2:flags=lanczos`;
1333
+ var scaleArgs = (scale) => scale === 1 ? [] : ["-vf", scaleFilter(scale)];
1334
+ var FORMATS = {
1335
+ mp4: {
1336
+ name: "mp4",
1337
+ extension: ".mp4",
1338
+ alpha: false,
1339
+ chunked: true,
1340
+ audio: true,
1341
+ description: "H.264 in MP4. Plays everywhere; the default.",
1342
+ args: ({ preset, quality, scale }) => [
1343
+ ...scaleArgs(scale),
1344
+ "-c:v",
1345
+ "libx264",
1346
+ "-crf",
1347
+ { studio: "17", social: "21", web: "27" }[quality],
1348
+ "-preset",
1349
+ preset,
1350
+ "-pix_fmt",
1351
+ "yuv420p"
1352
+ ]
1353
+ },
1354
+ webm: {
1355
+ name: "webm",
1356
+ extension: ".webm",
1357
+ alpha: true,
1358
+ // VP9 in WebM concatenates cleanly through the demuxer, same as H.264.
1359
+ chunked: true,
1360
+ audio: true,
1361
+ description: "VP9 in WebM, with alpha. For the web, and for overlays.",
1362
+ args: ({ quality, scale }) => [
1363
+ ...scaleArgs(scale),
1364
+ "-c:v",
1365
+ "libvpx-vp9",
1366
+ "-crf",
1367
+ { studio: "24", social: "31", web: "38" }[quality],
1368
+ "-b:v",
1369
+ "0",
1370
+ "-pix_fmt",
1371
+ "yuva420p",
1372
+ "-row-mt",
1373
+ "1"
1374
+ ]
1375
+ },
1376
+ prores: {
1377
+ name: "prores",
1378
+ extension: ".mov",
1379
+ alpha: true,
1380
+ chunked: true,
1381
+ audio: true,
1382
+ description: "ProRes 4444 in MOV, with alpha. For handing to an editor.",
1383
+ // Quality is the profile here, and the profile is the point: an editor
1384
+ // format that quietly compressed would defeat its own reason to exist.
1385
+ args: ({ scale }) => [
1386
+ ...scaleArgs(scale),
1387
+ "-c:v",
1388
+ "prores_ks",
1389
+ "-profile:v",
1390
+ "4444",
1391
+ "-pix_fmt",
1392
+ "yuva444p10le",
1393
+ "-alpha_bits",
1394
+ "8"
1395
+ ]
1396
+ },
1397
+ gif: {
1398
+ name: "gif",
1399
+ extension: ".gif",
1400
+ alpha: false,
1401
+ // A GIF's palette is computed across the whole animation, so chunks would
1402
+ // each invent their own and the result would flicker between them.
1403
+ chunked: false,
1404
+ audio: false,
1405
+ description: "An animated GIF, palette optimised. Silent, by the format.",
1406
+ // The palette pass is a filter graph already, so scaling joins it rather
1407
+ // than adding a second -vf that ffmpeg would silently drop.
1408
+ args: ({ scale }) => [
1409
+ "-vf",
1410
+ `${scale === 1 ? "" : `${scaleFilter(scale)},`}split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3`,
1411
+ "-loop",
1412
+ "0"
1413
+ ]
1414
+ },
1415
+ png: {
1416
+ name: "png",
1417
+ extension: ".png",
1418
+ alpha: true,
1419
+ chunked: false,
1420
+ audio: false,
1421
+ description: "A numbered PNG sequence, with alpha. For a compositor.",
1422
+ args: ({ scale }) => [...scaleArgs(scale), "-c:v", "png", "-pix_fmt", "rgba"]
1423
+ }
1424
+ };
1425
+ var formatNames = () => Object.keys(FORMATS);
1426
+ var resolveFormat = (requested, output) => {
1427
+ if (requested) {
1428
+ const format = FORMATS[requested.toLowerCase()];
1429
+ if (!format) {
1430
+ throw new Error(`Unknown format "${requested}". Available: ${formatNames().join(", ")}`);
1431
+ }
1432
+ return format;
1433
+ }
1434
+ if (output) {
1435
+ const extension = output.slice(output.lastIndexOf(".")).toLowerCase();
1436
+ const matched = Object.values(FORMATS).find((format) => format.extension === extension);
1437
+ if (matched) return matched;
1438
+ }
1439
+ return FORMATS.mp4;
1440
+ };
1441
+ var alphaWarning = (format, transparent) => transparent && !format.alpha ? `${format.name} has no alpha channel, so the transparent background will render black. Use webm, prores, or png.` : null;
1442
+
1165
1443
  // src/render.ts
1166
1444
  import { spawn as spawn2 } from "child_process";
1167
- 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";
1168
1446
  import { cpus } from "os";
1169
- 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";
1170
1448
  import { chromium } from "playwright-core";
1171
1449
 
1172
1450
  // src/audio-mix.ts
1173
- import { existsSync as existsSync12 } from "fs";
1174
- import { resolve as resolve13 } from "path";
1451
+ import { existsSync as existsSync13 } from "fs";
1452
+ import { resolve as resolve14 } from "path";
1175
1453
  import { duckEnvelope, envelopeAtFrame } from "odori";
1176
1454
 
1177
1455
  // src/cues.ts
1178
- import { existsSync as existsSync11, statSync } from "fs";
1179
- import { mkdir as mkdir9, writeFile as writeFile10 } from "fs/promises";
1180
- 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";
1181
1459
  import { pathToFileURL as pathToFileURL3 } from "url";
1182
1460
  import {
1183
1461
  SAMPLE_RATE,
@@ -1188,8 +1466,8 @@ import {
1188
1466
  isCueDefinition,
1189
1467
  resolveEntryLayout as resolveEntryLayout2
1190
1468
  } from "odori";
1191
- var cueCacheDir = (config) => resolve12(config.root, config.outDir, "cues");
1192
- 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));
1193
1471
  var materializeCues = async (config, brands, fps) => {
1194
1472
  const seen = /* @__PURE__ */ new Map();
1195
1473
  for (const brand of brands) {
@@ -1198,17 +1476,17 @@ var materializeCues = async (config, brands, fps) => {
1198
1476
  }
1199
1477
  }
1200
1478
  if (seen.size === 0) return [];
1201
- await mkdir9(cueCacheDir(config), { recursive: true });
1479
+ await mkdir10(cueCacheDir(config), { recursive: true });
1202
1480
  const written = [];
1203
1481
  for (const [url, cue] of seen) {
1204
1482
  const file = cueFile(config, url);
1205
- if (existsSync11(file)) {
1483
+ if (existsSync12(file)) {
1206
1484
  written.push({ cue, file, rendered: false });
1207
1485
  continue;
1208
1486
  }
1209
1487
  const samples = cueSamples(cue, fps);
1210
1488
  const signal = cue.render({ samples, sampleRate: SAMPLE_RATE });
1211
- await writeFile10(file, encodeWav(signal));
1489
+ await writeFile11(file, encodeWav(signal));
1212
1490
  written.push({ cue, file, rendered: true });
1213
1491
  }
1214
1492
  return written;
@@ -1234,23 +1512,28 @@ var renderedCue = (url) => {
1234
1512
  return wav;
1235
1513
  };
1236
1514
  var isBrand = (value) => typeof value === "object" && value !== null && value.kind === "odori-brand";
1515
+ var isLayout = (value) => typeof value === "object" && value !== null && value.kind === "odori-layout";
1237
1516
  var importFresh = async (file) => await import(`${pathToFileURL3(file).href}?odori=${statSync(file).mtimeMs}`);
1238
- var registerProjectCues = async (graph) => {
1517
+ var registerProjectCues = async (graph, load = importFresh) => {
1239
1518
  for (const discovered of graph.brands) {
1240
1519
  try {
1241
- const module = await importFresh(discovered.file);
1242
- registerCues(Object.values(module).filter(isBrand), defaultLayout.format.fps);
1520
+ const values = Object.values(await load(discovered.file));
1521
+ registerCues(values.filter(isBrand), defaultLayout.format.fps);
1522
+ for (const layout of values.filter(isLayout)) registerCues([layout.brand], layout.format.fps);
1243
1523
  } catch (error) {
1244
1524
  log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
1245
1525
  }
1246
1526
  }
1247
- try {
1248
- for (const video of await loadVideos(graph)) {
1249
- const layout = resolveEntryLayout2(video.entry);
1527
+ for (const video of graph.videos) {
1528
+ try {
1529
+ const module = await load(video.file);
1530
+ if (!module.default || !module.metadata) continue;
1531
+ const entry = { component: module.default, metadata: module.metadata };
1532
+ const layout = resolveEntryLayout2(entry);
1250
1533
  registerCues([layout.brand], layout.format.fps);
1534
+ } catch (error) {
1535
+ log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
1251
1536
  }
1252
- } catch (error) {
1253
- log.warn(`[odori] generated cues may use the default frame rate: ${message(error)}`);
1254
1537
  }
1255
1538
  return known.size;
1256
1539
  };
@@ -1261,13 +1544,13 @@ var resolveCueFile = (config, src) => {
1261
1544
  if (/^https?:\/\//.test(src)) return null;
1262
1545
  if (src.startsWith("/__odori/cue/")) {
1263
1546
  const generated = cueFile(config, src);
1264
- return existsSync12(generated) ? generated : null;
1547
+ return existsSync13(generated) ? generated : null;
1265
1548
  }
1266
1549
  const candidates = [
1267
- resolve13(config.root, "public", src.replace(/^\//, "")),
1268
- resolve13(config.root, src.replace(/^\//, ""))
1550
+ resolve14(config.root, "public", src.replace(/^\//, "")),
1551
+ resolve14(config.root, src.replace(/^\//, ""))
1269
1552
  ];
1270
- return candidates.find((candidate) => existsSync12(candidate)) ?? null;
1553
+ return candidates.find((candidate) => existsSync13(candidate)) ?? null;
1271
1554
  };
1272
1555
  var volumeFilter = (cue, cues, fps) => {
1273
1556
  const authored = cue.gainPoints ?? [];
@@ -1387,11 +1670,11 @@ var planChunks = ({
1387
1670
  var chunkFrames = (chunk) => Array.from({ length: length(chunk) }, (_, offset) => chunk.start + offset);
1388
1671
 
1389
1672
  // src/chunk-cache.ts
1390
- import { existsSync as existsSync13 } from "fs";
1391
- import { copyFile, mkdir as mkdir10, readFile as readFile10, writeFile as writeFile11 } from "fs/promises";
1392
- 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";
1393
1676
  import { hashValue as hashValue2 } from "odori";
1394
- var cacheDir2 = (config) => resolve14(config.root, config.outDir, "cache", "chunks");
1677
+ var cacheDir2 = (config) => resolve15(config.root, config.outDir, "cache", "chunks");
1395
1678
  var chunkKey = (identity) => hashValue2({
1396
1679
  videoId: identity.videoId,
1397
1680
  // The browser that drew the frames is part of what the frames are. Without
@@ -1409,14 +1692,16 @@ var chunkKey = (identity) => hashValue2({
1409
1692
  height: identity.height,
1410
1693
  fps: identity.fps,
1411
1694
  preset: identity.preset,
1695
+ quality: identity.quality ?? null,
1696
+ scale: identity.scale ?? null,
1412
1697
  input: identity.input ?? null
1413
1698
  });
1414
1699
  var readChunkRecord = async (config, key) => {
1415
1700
  const meta = join5(cacheDir2(config), `${key}.json`);
1416
1701
  const media = join5(cacheDir2(config), `${key}.mp4`);
1417
- if (!existsSync13(meta) || !existsSync13(media)) return null;
1702
+ if (!existsSync14(meta) || !existsSync14(media)) return null;
1418
1703
  try {
1419
- return JSON.parse(await readFile10(meta, "utf8"));
1704
+ return JSON.parse(await readFile11(meta, "utf8"));
1420
1705
  } catch {
1421
1706
  return null;
1422
1707
  }
@@ -1426,88 +1711,14 @@ var useChunkRecord = async (config, key, destination) => {
1426
1711
  };
1427
1712
  var writeChunkRecord = async (config, key, signatures, file) => {
1428
1713
  const directory2 = cacheDir2(config);
1429
- await mkdir10(directory2, { recursive: true });
1714
+ await mkdir11(directory2, { recursive: true });
1430
1715
  await copyFile(file, join5(directory2, `${key}.mp4`));
1431
1716
  const record = { key, signatures, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1432
- await writeFile11(join5(directory2, `${key}.json`), `${JSON.stringify(record)}
1717
+ await writeFile12(join5(directory2, `${key}.json`), `${JSON.stringify(record)}
1433
1718
  `, "utf8");
1434
1719
  };
1435
1720
  var signaturesMatch = (recorded, observed) => recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
1436
1721
 
1437
- // src/formats.ts
1438
- var FORMATS = {
1439
- mp4: {
1440
- name: "mp4",
1441
- extension: ".mp4",
1442
- alpha: false,
1443
- chunked: true,
1444
- audio: true,
1445
- description: "H.264 in MP4. Plays everywhere; the default.",
1446
- args: (preset) => ["-c:v", "libx264", "-crf", "17", "-preset", preset, "-pix_fmt", "yuv420p"]
1447
- },
1448
- webm: {
1449
- name: "webm",
1450
- extension: ".webm",
1451
- alpha: true,
1452
- // VP9 in WebM concatenates cleanly through the demuxer, same as H.264.
1453
- chunked: true,
1454
- audio: true,
1455
- description: "VP9 in WebM, with alpha. For the web, and for overlays.",
1456
- args: () => ["-c:v", "libvpx-vp9", "-crf", "24", "-b:v", "0", "-pix_fmt", "yuva420p", "-row-mt", "1"]
1457
- },
1458
- prores: {
1459
- name: "prores",
1460
- extension: ".mov",
1461
- alpha: true,
1462
- chunked: true,
1463
- audio: true,
1464
- description: "ProRes 4444 in MOV, with alpha. For handing to an editor.",
1465
- args: () => ["-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le", "-alpha_bits", "8"]
1466
- },
1467
- gif: {
1468
- name: "gif",
1469
- extension: ".gif",
1470
- alpha: false,
1471
- // A GIF's palette is computed across the whole animation, so chunks would
1472
- // each invent their own and the result would flicker between them.
1473
- chunked: false,
1474
- audio: false,
1475
- description: "An animated GIF, palette optimised. Silent, by the format.",
1476
- args: () => [
1477
- "-vf",
1478
- "split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3",
1479
- "-loop",
1480
- "0"
1481
- ]
1482
- },
1483
- png: {
1484
- name: "png",
1485
- extension: ".png",
1486
- alpha: true,
1487
- chunked: false,
1488
- audio: false,
1489
- description: "A numbered PNG sequence, with alpha. For a compositor.",
1490
- args: () => ["-c:v", "png", "-pix_fmt", "rgba"]
1491
- }
1492
- };
1493
- var formatNames = () => Object.keys(FORMATS);
1494
- var resolveFormat = (requested, output) => {
1495
- if (requested) {
1496
- const format = FORMATS[requested.toLowerCase()];
1497
- if (!format) {
1498
- throw new Error(`Unknown format "${requested}". Available: ${formatNames().join(", ")}`);
1499
- }
1500
- return format;
1501
- }
1502
- if (output) {
1503
- const extension = output.slice(output.lastIndexOf(".")).toLowerCase();
1504
- const matched = Object.values(FORMATS).find((format) => format.extension === extension);
1505
- if (matched) return matched;
1506
- }
1507
- return FORMATS.mp4;
1508
- };
1509
- var alphaWarning = (format, transparent) => transparent && !format.alpha ? `${format.name} has no alpha channel, so the transparent background will render black. Use webm, prores, or png.` : null;
1510
-
1511
1722
  // src/render.ts
1512
1723
  var encodeParam = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
1513
1724
  var renderUrl = (origin, target, frame) => {
@@ -1651,7 +1862,7 @@ var ensureFfmpeg = async (config) => {
1651
1862
  var renderStill = async (origin, target, frame, output, config) => {
1652
1863
  const { browser, page, errors } = await openRenderPage(origin, target, config);
1653
1864
  try {
1654
- await mkdir11(dirname4(output), { recursive: true });
1865
+ await mkdir12(dirname5(output), { recursive: true });
1655
1866
  await seekTo(page, frame);
1656
1867
  await page.screenshot({ path: output });
1657
1868
  if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
@@ -1675,10 +1886,19 @@ var sequencePattern = (output) => {
1675
1886
  const extension = dot > 0 ? output.slice(dot) : ".png";
1676
1887
  return join6(stem, `%05d${extension}`);
1677
1888
  };
1678
- var openChunkEncoder = (ffmpeg, file, fps, preset, format, signal) => {
1889
+ var LOSSLESS = {
1890
+ name: "lossless",
1891
+ extension: ".mkv",
1892
+ alpha: true,
1893
+ chunked: true,
1894
+ audio: false,
1895
+ description: "FFV1 in MKV, the lossless intermediate for one-pass formats.",
1896
+ args: () => ["-c:v", "ffv1", "-level", "3"]
1897
+ };
1898
+ var openChunkEncoder = (ffmpeg, file, fps, encode, format, signal) => {
1679
1899
  const child = spawn2(
1680
1900
  ffmpeg,
1681
- ["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(preset), file],
1901
+ ["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(encode), file],
1682
1902
  { stdio: ["pipe", "ignore", "pipe"] }
1683
1903
  );
1684
1904
  let stderr = "";
@@ -1728,7 +1948,7 @@ var captureLane = async (origin, target, config, lane, stats, options) => {
1728
1948
  }
1729
1949
  }
1730
1950
  const file = options.chunkFile(chunk);
1731
- const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.preset, options.format, options.signal);
1951
+ const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.encode, options.format, options.signal);
1732
1952
  const signatures = [];
1733
1953
  let previousSignature = null;
1734
1954
  let previousFrame = null;
@@ -1788,13 +2008,18 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1788
2008
  const browserPath = await browserExecutable(config);
1789
2009
  const renderer = (await resolveBrowser(config))?.version ?? browserPath;
1790
2010
  const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
1791
- const preset = options.preset ?? config.preset ?? "medium";
2011
+ const encode = {
2012
+ preset: options.preset ?? config.preset ?? "medium",
2013
+ quality: options.quality ?? "studio",
2014
+ scale: options.scale ?? 1
2015
+ };
1792
2016
  const format = options.format ?? FORMATS.mp4;
1793
2017
  const chunkable = format.chunked;
2018
+ const chunkFormat = chunkable ? format : LOSSLESS;
1794
2019
  const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
1795
2020
  const cache = options.cache ?? config.cacheChunks ?? true;
1796
- const work = options.workDir ?? resolve15(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
1797
- 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 });
1798
2023
  const concurrency = chunkable ? requested : 1;
1799
2024
  const { chunks, lanes } = planChunks({
1800
2025
  durationInFrames: target.durationInFrames,
@@ -1805,15 +2030,15 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1805
2030
  const stats = { captured: 0, reused: 0, cachedChunks: 0 };
1806
2031
  let succeeded = false;
1807
2032
  try {
1808
- await mkdir11(dirname4(output), { recursive: true });
2033
+ await mkdir12(dirname5(output), { recursive: true });
1809
2034
  const captureStart = performance.now();
1810
2035
  await Promise.all(
1811
2036
  lanes.map(
1812
2037
  (lane) => captureLane(origin, target, config, lane, stats, {
1813
2038
  skipUnchanged,
1814
2039
  signal: options.signal,
1815
- preset,
1816
- format,
2040
+ encode,
2041
+ format: chunkFormat,
1817
2042
  ffmpeg,
1818
2043
  workDir: work,
1819
2044
  chunkFile,
@@ -1821,11 +2046,17 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1821
2046
  videoId: target.videoId,
1822
2047
  chunk,
1823
2048
  renderer,
1824
- format: format.name,
2049
+ format: chunkFormat.name,
1825
2050
  width: target.width,
1826
2051
  height: target.height,
1827
2052
  fps: target.fps,
1828
- preset,
2053
+ preset: encode.preset,
2054
+ // Both change the encoded bytes, so both are part of what a
2055
+ // chunk is: a half-size chunk must never answer for a full
2056
+ // one. A lossless intermediate is the exception by design —
2057
+ // the final pass applies them, so one capture serves all.
2058
+ quality: chunkable ? encode.quality : void 0,
2059
+ scale: chunkable ? encode.scale : void 0,
1829
2060
  input: target.input
1830
2061
  }) : void 0,
1831
2062
  onFrame: () => onProgress?.(stats.captured / Math.max(1, target.durationInFrames), "rendering")
@@ -1846,13 +2077,13 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1846
2077
  await copyFile2(ordered[0], silent);
1847
2078
  } else {
1848
2079
  const list = join6(work, "chunks.txt");
1849
- 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");
1850
2081
  await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
1851
2082
  }
1852
2083
  if (!chunkable) {
1853
2084
  const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
1854
- if (destination !== output) await mkdir11(dirname4(destination), { recursive: true });
1855
- await run(ffmpeg, ["-y", "-i", silent, ...format.args(preset), destination], options.signal);
2085
+ if (destination !== output) await mkdir12(dirname5(destination), { recursive: true });
2086
+ await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
1856
2087
  if (mixInputs.length > 0) {
1857
2088
  log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
1858
2089
  }
@@ -1905,7 +2136,7 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
1905
2136
  succeeded = true;
1906
2137
  return output;
1907
2138
  } finally {
1908
- if (succeeded && !options.workDir) await rm2(work, { recursive: true, force: true });
2139
+ if (succeeded && !options.workDir) await rm3(work, { recursive: true, force: true });
1909
2140
  else if (!succeeded) log.detail(`Chunks left for inspection in ${work}`);
1910
2141
  }
1911
2142
  };
@@ -1937,25 +2168,25 @@ var openInBrowser = (url) => {
1937
2168
  };
1938
2169
 
1939
2170
  // src/server.ts
1940
- import { existsSync as existsSync14 } from "fs";
2171
+ import { existsSync as existsSync15 } from "fs";
1941
2172
  import { createRequire as createRequire2 } from "module";
1942
2173
  import { fileURLToPath } from "url";
1943
2174
  import { createServer } from "vite";
1944
2175
  import react from "@vitejs/plugin-react";
1945
- import { readFile as readFile11 } from "fs/promises";
1946
- import { dirname as dirname5, resolve as resolve16, sep as sep2 } from "path";
1947
- var cliRoot = resolve16(dirname5(fileURLToPath(import.meta.url)), "..");
1948
- var studioRoot = resolve16(cliRoot, "studio");
1949
- var studioEntry = resolve16(studioRoot, "index.html");
1950
- 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, "..", "..");
1951
2182
  var VIRTUAL_ID = "virtual:odori-project";
1952
2183
  var RESOLVED_ID = `\0${VIRTUAL_ID}`;
1953
2184
  var runtimeSource = (root) => {
1954
- for (const from of [resolve16(root, "package.json"), import.meta.url]) {
2185
+ for (const from of [resolve17(root, "package.json"), import.meta.url]) {
1955
2186
  try {
1956
2187
  const manifest = createRequire2(from).resolve("odori/package.json");
1957
- const src = resolve16(manifest, "..", "src");
1958
- if (existsSync14(resolve16(src, "index.tsx"))) return src;
2188
+ const src = resolve17(manifest, "..", "src");
2189
+ if (existsSync15(resolve17(src, "index.tsx"))) return src;
1959
2190
  } catch {
1960
2191
  }
1961
2192
  }
@@ -1994,6 +2225,8 @@ var odoriProjectPlugin = (config, getGraph) => ({
1994
2225
  `export const project = ${JSON.stringify({
1995
2226
  root: config.root,
1996
2227
  videosDir: config.videosDir,
2228
+ componentsDir: config.componentsDir,
2229
+ categories: graph.categories,
1997
2230
  exportDir: config.exportDir,
1998
2231
  audioDir: config.audioDir,
1999
2232
  docsUrl: config.docsUrl,
@@ -2002,7 +2235,11 @@ var odoriProjectPlugin = (config, getGraph) => ({
2002
2235
  assets: config.assets ?? [],
2003
2236
  files: {
2004
2237
  videos: graph.videos.map((video) => ({ id: video.slug, file: video.relativeFile })),
2005
- 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
+ })),
2006
2243
  brands: graph.brands.map((brand) => ({ id: brand.name, file: brand.relativeFile }))
2007
2244
  }
2008
2245
  })};`
@@ -2014,7 +2251,6 @@ var startStudioServer = async (initialConfig, options = {}) => {
2014
2251
  const odoriSrc = runtimeSource(config.root);
2015
2252
  let graph = await discoverProject(config);
2016
2253
  await writeGenerated(config, graph);
2017
- await registerProjectCues(graph);
2018
2254
  const vite = await createServer({
2019
2255
  root: studioRoot,
2020
2256
  configFile: false,
@@ -2073,15 +2309,15 @@ var startStudioServer = async (initialConfig, options = {}) => {
2073
2309
  ],
2074
2310
  // The project's public/ directory is served at the root, so brand fonts,
2075
2311
  // logos, and footage resolve identically in preview and render.
2076
- publicDir: existsSync14(resolve16(config.root, "public")) ? resolve16(config.root, "public") : false,
2312
+ publicDir: existsSync15(resolve17(config.root, "public")) ? resolve17(config.root, "public") : false,
2077
2313
  resolve: {
2078
2314
  dedupe: ["react", "react-dom", "odori"],
2079
2315
  // Only when the runtime is present as source. A consumer resolves the
2080
2316
  // published package through its exports map instead.
2081
2317
  alias: odoriSrc ? [
2082
- { find: /^odori\/preview$/, replacement: resolve16(odoriSrc, "preview.ts") },
2083
- { find: /^odori\/manifest$/, replacement: resolve16(odoriSrc, "manifest.ts") },
2084
- { 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") }
2085
2321
  ] : []
2086
2322
  },
2087
2323
  server: {
@@ -2093,14 +2329,31 @@ var startStudioServer = async (initialConfig, options = {}) => {
2093
2329
  },
2094
2330
  optimizeDeps: { include: ["react", "react-dom", "react/jsx-dev-runtime"] }
2095
2331
  });
2332
+ const loadModule = (file) => vite.ssrLoadModule(`/@fs${file}`);
2333
+ await registerProjectCues(graph, loadModule);
2334
+ let refreshTimer;
2335
+ let refreshChain = Promise.resolve();
2336
+ const enqueueRefresh = (task) => {
2337
+ const next = refreshChain.then(task, task);
2338
+ refreshChain = next.catch(() => void 0);
2339
+ return next;
2340
+ };
2341
+ const scheduleCueRefresh = () => {
2342
+ clearTimeout(refreshTimer);
2343
+ refreshTimer = setTimeout(() => {
2344
+ void enqueueRefresh(() => registerProjectCues(graph, loadModule)).catch((error) => {
2345
+ vite.config.logger.warn(`[odori] cue refresh skipped: ${error instanceof Error ? error.message : String(error)}`);
2346
+ });
2347
+ }, 150);
2348
+ };
2096
2349
  const rediscover = async (file) => {
2097
- if (!file.startsWith(resolve16(config.root, config.videosDir))) return;
2098
- const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep2}brands${sep2}`);
2350
+ if (!file.startsWith(resolve17(config.root, config.videosDir))) return;
2351
+ const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep3}brands${sep3}`);
2099
2352
  if (!isEntry) return;
2100
2353
  try {
2101
2354
  graph = await discoverProject(config);
2102
2355
  await writeGenerated(config, graph);
2103
- await registerProjectCues(graph);
2356
+ await enqueueRefresh(() => registerProjectCues(graph, loadModule));
2104
2357
  } catch (error) {
2105
2358
  vite.config.logger.warn(`[odori] rediscovery skipped: ${error instanceof Error ? error.message : String(error)}`);
2106
2359
  return;
@@ -2111,14 +2364,14 @@ var startStudioServer = async (initialConfig, options = {}) => {
2111
2364
  };
2112
2365
  vite.watcher.on("add", (file) => void rediscover(file));
2113
2366
  vite.watcher.on("unlink", (file) => void rediscover(file));
2114
- vite.watcher.add(resolve16(config.root, config.videosDir));
2367
+ vite.watcher.add(resolve17(config.root, config.videosDir));
2115
2368
  const reloadConfig = async (file) => {
2116
2369
  if (!/odori\.config\.(?:ts|mjs|js)$/.test(file)) return;
2117
2370
  try {
2118
2371
  config = await loadConfig(config.root);
2119
2372
  graph = await discoverProject(config);
2120
2373
  await writeGenerated(config, graph);
2121
- await registerProjectCues(graph);
2374
+ await enqueueRefresh(() => registerProjectCues(graph, loadModule));
2122
2375
  } catch (error) {
2123
2376
  vite.config.logger.warn(`[odori] config reload skipped: ${error instanceof Error ? error.message : String(error)}`);
2124
2377
  return;
@@ -2127,14 +2380,15 @@ var startStudioServer = async (initialConfig, options = {}) => {
2127
2380
  if (module) vite.moduleGraph.invalidateModule(module);
2128
2381
  vite.ws.send({ type: "full-reload" });
2129
2382
  };
2130
- const refreshCues = async (file) => {
2131
- if (!file.startsWith(resolve16(config.root, config.videosDir)) || !file.split(sep2).includes("brands")) return;
2132
- await registerProjectCues(graph);
2383
+ const refreshCues = (file) => {
2384
+ if (!file.startsWith(resolve17(config.root, config.videosDir) + sep3)) return;
2385
+ if (!/\.tsx?$/.test(file)) return;
2386
+ scheduleCueRefresh();
2133
2387
  };
2134
- vite.watcher.on("change", (file) => void refreshCues(file));
2388
+ vite.watcher.on("change", (file) => refreshCues(file));
2135
2389
  vite.watcher.on("change", (file) => void reloadConfig(file));
2136
2390
  for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
2137
- vite.watcher.add(resolve16(config.root, name));
2391
+ vite.watcher.add(resolve17(config.root, name));
2138
2392
  }
2139
2393
  vite.middlewares.use(async (request, response, next) => {
2140
2394
  const url = (request.url ?? "/").split("?")[0];
@@ -2144,7 +2398,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2144
2398
  return;
2145
2399
  }
2146
2400
  try {
2147
- const html = await readFile11(studioEntry, "utf8");
2401
+ const html = await readFile12(studioEntry, "utf8");
2148
2402
  response.statusCode = 200;
2149
2403
  response.setHeader("content-type", "text/html");
2150
2404
  response.end(await vite.transformIndexHtml(url, html));
@@ -2165,7 +2419,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2165
2419
  };
2166
2420
 
2167
2421
  // src/commands/exportVideo.ts
2168
- import { resolve as resolve17 } from "path";
2422
+ import { resolve as resolve18 } from "path";
2169
2423
  import { resolveEntryLayout as resolveEntryLayout4 } from "odori";
2170
2424
 
2171
2425
  // src/commands/shared.ts
@@ -2274,8 +2528,12 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
2274
2528
  },
2275
2529
  {
2276
2530
  concurrency: options.concurrency,
2277
- preset: options.preset,
2278
- format: options.format,
2531
+ // The record freezes how it should be encoded alongside what, so a
2532
+ // retry from any surface produces the same file the first run would.
2533
+ preset: options.preset ?? record.render?.preset,
2534
+ quality: options.quality ?? record.render?.quality,
2535
+ scale: options.scale ?? record.render?.scale,
2536
+ format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : void 0),
2279
2537
  skipUnchangedFrames: options.skipUnchangedFrames,
2280
2538
  signal: controller.signal,
2281
2539
  onTimings: (timings) => {
@@ -2302,9 +2560,23 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
2302
2560
  running.delete(record.job.id);
2303
2561
  }
2304
2562
  });
2563
+ var resolveQuality = (requested) => {
2564
+ if (!requested) return "studio";
2565
+ if (QUALITIES.includes(requested)) return requested;
2566
+ throw new Error(`Unknown quality "${requested}". Available: ${QUALITIES.join(", ")}`);
2567
+ };
2568
+ var resolveScale = (requested) => {
2569
+ if (requested === void 0) return 1;
2570
+ if (!Number.isFinite(requested) || requested < 0.25 || requested > 2) {
2571
+ throw new Error(`Scale ${requested} is out of range. Use a value between 0.25 and 2.`);
2572
+ }
2573
+ return requested;
2574
+ };
2305
2575
  var exportCommand = async (id, options = {}) => {
2306
2576
  const { config, graph, videos } = await createContext();
2307
2577
  const format = resolveFormat(options.format ?? config.format, options.output);
2578
+ const quality = resolveQuality(options.quality);
2579
+ const scale = resolveScale(options.scale);
2308
2580
  return withServer(config, async (server) => {
2309
2581
  const record = options.retry ? await readJob(config, options.retry) : await (async () => {
2310
2582
  const video2 = findVideo(videos, id);
@@ -2316,11 +2588,16 @@ var exportCommand = async (id, options = {}) => {
2316
2588
  options.input ?? {},
2317
2589
  { scenes: compiled.scenes, audio: compiled.audio }
2318
2590
  );
2319
- const output = resolve17(
2591
+ const output = resolve18(
2320
2592
  config.root,
2321
2593
  options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
2322
2594
  );
2323
- return createJob(config, manifest, output);
2595
+ return createJob(config, manifest, output, {
2596
+ format: format.name,
2597
+ quality,
2598
+ scale,
2599
+ ...options.preset ? { preset: options.preset } : {}
2600
+ });
2324
2601
  })();
2325
2602
  const video = findVideo(videos, record.manifest.videoId);
2326
2603
  log.detail(`job ${record.job.id} manifest ${record.manifest.manifestHash}`);
@@ -2331,7 +2608,10 @@ var exportCommand = async (id, options = {}) => {
2331
2608
  const job = await runJob(config, server.url, record, video, {
2332
2609
  concurrency: options.concurrency,
2333
2610
  preset: options.preset,
2334
- format,
2611
+ // A retry keeps what its record froze; an explicit flag still wins.
2612
+ quality: options.retry && options.quality === void 0 ? void 0 : quality,
2613
+ scale: options.retry && options.scale === void 0 ? void 0 : scale,
2614
+ format: options.retry ? void 0 : format,
2335
2615
  skipUnchangedFrames: options.skipUnchangedFrames,
2336
2616
  onProgress: (next) => {
2337
2617
  if (next.status === "rendering" || next.status === "encoding") {
@@ -2373,6 +2653,35 @@ var json = (response, status, payload) => {
2373
2653
  response.setHeader("content-type", "application/json");
2374
2654
  response.end(JSON.stringify(payload));
2375
2655
  };
2656
+ var exportDestination = (config) => {
2657
+ const downloads = resolve19(homedir2(), "Downloads");
2658
+ return existsSync16(downloads) ? downloads : resolve19(config.root, config.exportDir);
2659
+ };
2660
+ var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
2661
+ var hostOf = (value) => {
2662
+ if (!value) return null;
2663
+ const withoutPort = value.startsWith("[") ? value.slice(0, value.indexOf("]") + 1) : value.split(":")[0];
2664
+ return withoutPort || null;
2665
+ };
2666
+ var isLocalRequest = (request) => {
2667
+ const host = hostOf(request.headers.host);
2668
+ if (!host || !LOOPBACK.has(host)) return false;
2669
+ const origin = request.headers.origin;
2670
+ if (origin) {
2671
+ try {
2672
+ if (!LOOPBACK.has(hostOf(new URL(origin).host) ?? "")) return false;
2673
+ } catch {
2674
+ return false;
2675
+ }
2676
+ }
2677
+ return true;
2678
+ };
2679
+ var safeJobId = (id) => {
2680
+ if (!/^[a-zA-Z0-9._-]+$/.test(id) || id.includes("..")) {
2681
+ throw new Error(`Invalid job id ${JSON.stringify(id)}.`);
2682
+ }
2683
+ return id;
2684
+ };
2376
2685
  var devCommand = async (options = {}) => {
2377
2686
  const config = await loadConfig(options.root ?? process.cwd());
2378
2687
  const context = async () => {
@@ -2384,6 +2693,10 @@ var devCommand = async (options = {}) => {
2384
2693
  middleware: (vite) => {
2385
2694
  vite.middlewares.use("/__odori", (request, response, next) => {
2386
2695
  const url = request.url ?? "/";
2696
+ if (!isLocalRequest(request)) {
2697
+ json(response, 403, { error: "This endpoint only answers same-origin requests from Studio on localhost." });
2698
+ return;
2699
+ }
2387
2700
  void (async () => {
2388
2701
  try {
2389
2702
  if (request.method === "POST" && url.startsWith("/still")) {
@@ -2401,8 +2714,7 @@ var devCommand = async (options = {}) => {
2401
2714
  );
2402
2715
  const frame = Number(body.frame ?? 0);
2403
2716
  const inline = body.inline === true;
2404
- const directory2 = inline ? config.outDir : config.exportDir;
2405
- const file = resolve18(config.root, `${directory2}/${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`);
2406
2718
  await renderStill(
2407
2719
  origin,
2408
2720
  targetFor(
@@ -2418,7 +2730,7 @@ var devCommand = async (options = {}) => {
2418
2730
  if (inline) {
2419
2731
  response.statusCode = 200;
2420
2732
  response.setHeader("content-type", "image/png");
2421
- response.end(await readFile12(file));
2733
+ response.end(await readFile13(file));
2422
2734
  return;
2423
2735
  }
2424
2736
  json(response, 200, { id: "still", status: "ready", progress: 1, output: file });
@@ -2426,6 +2738,9 @@ var devCommand = async (options = {}) => {
2426
2738
  }
2427
2739
  if (request.method === "POST" && url.startsWith("/exports")) {
2428
2740
  const body = await readBody(request);
2741
+ const format = resolveFormat(typeof body.format === "string" ? body.format : void 0, void 0);
2742
+ const quality = resolveQuality(typeof body.quality === "string" ? body.quality : void 0);
2743
+ const scale = resolveScale(typeof body.scale === "number" ? body.scale : void 0);
2429
2744
  const { graph, videos } = await context();
2430
2745
  const video = findVideo(videos, String(body.videoId));
2431
2746
  const input = body.input ?? {};
@@ -2437,8 +2752,11 @@ var devCommand = async (options = {}) => {
2437
2752
  input,
2438
2753
  { scenes: compiled.scenes, audio: compiled.audio }
2439
2754
  );
2440
- const output = resolve18(config.root, `${config.exportDir}/${outputName(video.entry.metadata.id)}.mp4`);
2441
- const record = await createJob(config, manifest, output);
2755
+ const output = resolve19(
2756
+ exportDestination(config),
2757
+ `${outputName(video.entry.metadata.id)}${format.extension}`
2758
+ );
2759
+ const record = await createJob(config, manifest, output, { format: format.name, quality, scale });
2442
2760
  json(response, 202, record.job);
2443
2761
  void runJob(config, origin, record, video).catch((error) => {
2444
2762
  log.error(`Export failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -2446,7 +2764,7 @@ var devCommand = async (options = {}) => {
2446
2764
  return;
2447
2765
  }
2448
2766
  if (request.method === "POST" && url.startsWith("/retry/")) {
2449
- const id = url.replace("/retry/", "").split("?")[0];
2767
+ const id = safeJobId(url.replace("/retry/", "").split("?")[0]);
2450
2768
  const record = await readJob(config, id);
2451
2769
  const { videos } = await context();
2452
2770
  const video = findVideo(videos, record.manifest.videoId);
@@ -2457,16 +2775,34 @@ var devCommand = async (options = {}) => {
2457
2775
  return;
2458
2776
  }
2459
2777
  if (request.method === "POST" && url.startsWith("/cancel/")) {
2460
- const id = url.replace("/cancel/", "").split("?")[0];
2778
+ const id = safeJobId(url.replace("/cancel/", "").split("?")[0]);
2461
2779
  const cancelled = cancelJob(id);
2462
2780
  json(response, cancelled ? 202 : 404, { id, cancelled });
2463
2781
  return;
2464
2782
  }
2465
2783
  if (request.method === "GET" && url.startsWith("/jobs/")) {
2466
- const id = url.replace("/jobs/", "").split("?")[0];
2784
+ const id = safeJobId(url.replace("/jobs/", "").split("?")[0]);
2467
2785
  json(response, 200, (await readJob(config, id)).job);
2468
2786
  return;
2469
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
+ }
2470
2806
  if (request.method === "GET" && url.startsWith("/jobs")) {
2471
2807
  json(response, 200, await listJobs(config));
2472
2808
  return;
@@ -2480,7 +2816,7 @@ var devCommand = async (options = {}) => {
2480
2816
  }
2481
2817
  });
2482
2818
  const origin = server.url;
2483
- const entry = `${origin}/videos`;
2819
+ const entry = `${origin}/`;
2484
2820
  log.title("Odori Studio");
2485
2821
  log.info(` ${entry}`);
2486
2822
  log.detail(` ${server.graph.videos.length} videos, ${server.graph.previews.length} component previews`);
@@ -2491,16 +2827,16 @@ var devCommand = async (options = {}) => {
2491
2827
 
2492
2828
  // src/commands/doctor.ts
2493
2829
  import { constants } from "fs";
2494
- import { access, mkdir as mkdir12, readFile as readFile13, rm as rm3, writeFile as writeFile13 } from "fs/promises";
2495
- import { existsSync as existsSync15 } 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";
2496
2832
  import { createRequire as createRequire3 } from "module";
2497
- import { relative as relative5, resolve as resolve19 } from "path";
2833
+ import { relative as relative8, resolve as resolve20 } from "path";
2498
2834
  var MINIMUM_NODE = 20;
2499
2835
  var version = (value) => value.replace(/^v/, "").split(".").map(Number);
2500
2836
  var runChecks = async (root) => {
2501
2837
  const checks = [];
2502
2838
  const config = await loadConfig(root);
2503
- const require2 = createRequire3(resolve19(root, "package.json"));
2839
+ const require2 = createRequire3(resolve20(root, "package.json"));
2504
2840
  const [major] = version(process.version);
2505
2841
  checks.push({
2506
2842
  name: "Node",
@@ -2511,7 +2847,7 @@ var runChecks = async (root) => {
2511
2847
  let react2 = "not found";
2512
2848
  let reactOk = false;
2513
2849
  try {
2514
- 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"));
2515
2851
  react2 = manifest.version;
2516
2852
  reactOk = version(react2)[0] >= 19;
2517
2853
  } catch {
@@ -2523,16 +2859,16 @@ var runChecks = async (root) => {
2523
2859
  ok: reactOk,
2524
2860
  fix: "The runtime needs React 19. Install it: npm install react@19 react-dom@19"
2525
2861
  });
2526
- const videosDir = resolve19(config.root, config.videosDir);
2862
+ const videosDir = resolve20(config.root, config.videosDir);
2527
2863
  checks.push({
2528
2864
  name: "Source root",
2529
- detail: existsSync15(videosDir) ? relative5(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2530
- ok: existsSync15(videosDir),
2865
+ detail: existsSync17(videosDir) ? relative8(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2866
+ ok: existsSync17(videosDir),
2531
2867
  fix: 'Run "odori init" to add the videos source root.'
2532
2868
  });
2533
2869
  checks.push({
2534
2870
  name: "Config",
2535
- detail: config.configPath ? relative5(config.root, config.configPath) : "defaults (no odori.config.ts)",
2871
+ detail: config.configPath ? relative8(config.root, config.configPath) : "defaults (no odori.config.ts)",
2536
2872
  // Loading got this far, so a config that exists also parsed.
2537
2873
  ok: true
2538
2874
  });
@@ -2558,23 +2894,42 @@ var runChecks = async (root) => {
2558
2894
  detail: unpinned.length === 0 ? `pinned binaries from ${cacheRoot()}` : `${unpinned.length} of 2 from the host; frames may differ from another machine`,
2559
2895
  ok: true
2560
2896
  });
2561
- const generated = resolve19(config.root, ".odori");
2897
+ const generated = resolve20(config.root, ".odori");
2562
2898
  let writable = false;
2563
2899
  try {
2564
- await mkdir12(generated, { recursive: true });
2565
- const probe = resolve19(generated, ".doctor");
2566
- await writeFile13(probe, "", "utf8");
2900
+ await mkdir13(generated, { recursive: true });
2901
+ const probe = resolve20(generated, ".doctor");
2902
+ await writeFile14(probe, "", "utf8");
2567
2903
  await access(probe, constants.W_OK);
2568
- await rm3(probe, { force: true });
2904
+ await rm4(probe, { force: true });
2569
2905
  writable = true;
2570
2906
  } catch {
2571
2907
  writable = false;
2572
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
+ });
2573
2928
  checks.push({
2574
2929
  name: "Generated cache",
2575
2930
  detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
2576
2931
  ok: writable,
2577
- fix: `Odori writes its import graph and render cache to ${relative5(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.`
2578
2933
  });
2579
2934
  return checks;
2580
2935
  };
@@ -2584,12 +2939,15 @@ var doctorCommand = async (root = process.cwd()) => {
2584
2939
  log.title("odori doctor");
2585
2940
  for (const check of checks) {
2586
2941
  const label = check.name.padEnd(width);
2587
- 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}`);
2588
2944
  else log.error(`${label} ${check.detail}`);
2589
2945
  }
2946
+ const warned = checks.filter((check) => check.ok && check.warn);
2590
2947
  const failed = checks.filter((check) => !check.ok);
2591
2948
  if (failed.length === 0) {
2592
2949
  log.detail("Everything a render needs is present.");
2950
+ for (const check of warned) log.detail(` ${check.fix}`);
2593
2951
  return 0;
2594
2952
  }
2595
2953
  log.info("");
@@ -2598,14 +2956,14 @@ var doctorCommand = async (root = process.cwd()) => {
2598
2956
  };
2599
2957
 
2600
2958
  // src/commands/init.ts
2601
- import { mkdir as mkdir14, writeFile as writeFile15 } from "fs/promises";
2602
- import { existsSync as existsSync17 } from "fs";
2603
- import { relative as relative7, 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";
2604
2962
 
2605
2963
  // src/commands/new.ts
2606
- import { mkdir as mkdir13, readdir as readdir5, writeFile as writeFile14 } from "fs/promises";
2607
- import { existsSync as existsSync16 } from "fs";
2608
- import { relative as relative6, 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";
2609
2967
  var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2610
2968
  var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
2611
2969
  var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
@@ -2663,30 +3021,30 @@ ${closing}
2663
3021
  `;
2664
3022
  };
2665
3023
  var installedParts = async (config) => {
2666
- const componentsDir = resolve20(config.root, config.componentsDir);
2667
- if (!existsSync16(componentsDir)) return { title: false, end: false };
3024
+ const componentsDir = resolve21(config.root, config.componentsDir);
3025
+ if (!existsSync18(componentsDir)) return { title: false, end: false };
2668
3026
  const entries = (await readdir5(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2669
3027
  return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
2670
3028
  };
2671
3029
  var newCommand = async (name, options = {}) => {
2672
3030
  if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("Use a lowercase, dash separated name.");
2673
3031
  const config = await loadConfig(process.cwd());
2674
- const directory2 = resolve20(config.root, config.videosDir, name);
2675
- const file = resolve20(directory2, "video.tsx");
2676
- if (existsSync16(file)) throw new Error(`${relative6(config.root, file)} already exists.`);
2677
- const hasLayout = existsSync16(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"));
2678
3036
  const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
2679
3037
  const composed = parts.title || parts.end;
2680
- await mkdir13(directory2, { recursive: true });
2681
- await writeFile14(
3038
+ await mkdir14(directory2, { recursive: true });
3039
+ await writeFile15(
2682
3040
  file,
2683
3041
  composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
2684
3042
  "utf8"
2685
3043
  );
2686
- log.success(`Created ${relative6(config.root, file)}`);
3044
+ log.success(`Created ${relative9(config.root, file)}`);
2687
3045
  if (composed) log.detail("Composed from the components this project has installed.");
2688
3046
  else if (options.blank !== true) {
2689
- log.detail("No registry components installed yet: odori add @odori/title-reveal @odori/end-card");
3047
+ log.detail("No registry components installed yet: odori add title-reveal end-card");
2690
3048
  }
2691
3049
  log.detail("Run odori dev to preview it.");
2692
3050
  };
@@ -2717,23 +3075,23 @@ export const productLayout = defineVideoLayout({
2717
3075
  });
2718
3076
  `;
2719
3077
  var initCommand = async (root = process.cwd()) => {
2720
- const videosDir = resolve21(root, defaultConfig.videosDir);
2721
- await mkdir14(resolve21(videosDir, "components"), { recursive: true });
3078
+ const videosDir = resolve22(root, defaultConfig.videosDir);
3079
+ await mkdir15(resolve22(videosDir, "components"), { recursive: true });
2722
3080
  const files = [
2723
- [resolve21(root, "odori.config.ts"), CONFIG_TEMPLATE],
2724
- [resolve21(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
2725
- [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)]
2726
3084
  ];
2727
3085
  for (const [file, contents] of files) {
2728
- if (existsSync17(file)) {
2729
- log.detail(`Kept existing ${relative7(root, file)}`);
3086
+ if (existsSync19(file)) {
3087
+ log.detail(`Kept existing ${relative10(root, file)}`);
2730
3088
  continue;
2731
3089
  }
2732
- await mkdir14(resolve21(file, ".."), { recursive: true });
2733
- await writeFile15(file, contents, "utf8");
2734
- log.success(`Created ${relative7(root, file)}`);
3090
+ await mkdir15(resolve22(file, ".."), { recursive: true });
3091
+ await writeFile16(file, contents, "utf8");
3092
+ log.success(`Created ${relative10(root, file)}`);
2735
3093
  }
2736
- log.detail("Next: odori doctor, then odori add @odori/title-reveal @odori/end-card, then odori dev.");
3094
+ log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
2737
3095
  };
2738
3096
 
2739
3097
  // src/commands/inspect.ts
@@ -2822,15 +3180,16 @@ var listCommand = async () => {
2822
3180
  }
2823
3181
  };
2824
3182
 
2825
- // src/commands/still.ts
2826
- import { resolve as resolve22 } from "path";
2827
- var stillCommand = async (id, options = {}) => {
2828
- const frame = options.frame ?? 0;
2829
- if (!Number.isInteger(frame) || frame < 0) {
2830
- throw new Error(`Frame must be a whole number of frames from the start, got ${String(frame)}.`);
2831
- }
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);
2832
3189
  const { config, graph, videos } = await createContext();
2833
3190
  const video = findVideo(videos, id);
3191
+ const fps = resolveEntryLayout7(video.entry).format.fps;
3192
+ const frame = framesFromOffset(at, fps);
2834
3193
  const output = await withServer(config, async (server) => {
2835
3194
  const { durationInFrames, scenes, audio } = await compileInBrowser(server.url, targetFor(video, options.input), config);
2836
3195
  const { manifest, input, prepared } = await freezeManifest(
@@ -2844,21 +3203,21 @@ var stillCommand = async (id, options = {}) => {
2844
3203
  throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
2845
3204
  }
2846
3205
  const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
2847
- 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`);
2848
3207
  return renderStill(server.url, target, frame, file, config);
2849
3208
  });
2850
- log.success(`Still frame ${frame} written to ${output}`);
3209
+ log.success(`Frame ${frame} written to ${output}`);
2851
3210
  return output;
2852
3211
  };
2853
3212
 
2854
3213
  // src/commands/test.ts
2855
- import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3214
+ import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout9 } from "odori";
2856
3215
 
2857
3216
  // src/contracts.ts
2858
- import { existsSync as existsSync18 } from "fs";
3217
+ import { existsSync as existsSync20 } from "fs";
2859
3218
  import { readdir as readdir6 } from "fs/promises";
2860
- import { resolve as resolve23 } from "path";
2861
- 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";
2862
3221
  var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
2863
3222
  var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
2864
3223
  "system-ui",
@@ -2924,8 +3283,8 @@ var checkAudioWindows = (cues, brand, videoId) => {
2924
3283
  return failures;
2925
3284
  };
2926
3285
  var checkInstalledContracts = async (config, videos) => {
2927
- const componentsDir = resolve23(config.root, config.componentsDir);
2928
- const onDisk = existsSync18(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) : [];
2929
3288
  const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
2930
3289
  if (names.size === 0) return [];
2931
3290
  const { items } = await resolveRegistry(config, { allowNetwork: false });
@@ -2934,7 +3293,7 @@ var checkInstalledContracts = async (config, videos) => {
2934
3293
  const seen = /* @__PURE__ */ new Set();
2935
3294
  const failures = [];
2936
3295
  for (const video of videos) {
2937
- const { brand } = resolveEntryLayout7(video.entry);
3296
+ const { brand } = resolveEntryLayout8(video.entry);
2938
3297
  for (const failure of checkComponentRequirements(installed, brand, video.entry.metadata.id)) {
2939
3298
  const key = `${brand.name}:${failure.message}`;
2940
3299
  if (seen.has(key)) continue;
@@ -2946,9 +3305,9 @@ var checkInstalledContracts = async (config, videos) => {
2946
3305
  };
2947
3306
 
2948
3307
  // src/determinism.ts
2949
- import { readdir as readdir7, readFile as readFile14 } from "fs/promises";
2950
- import { existsSync as existsSync19 } from "fs";
2951
- import { join as join7, relative as relative8, 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";
2952
3311
  var FORBIDDEN = [
2953
3312
  {
2954
3313
  pattern: /\bMath\.random\s*\(/,
@@ -2989,11 +3348,11 @@ var walk2 = async (directory2, files = []) => {
2989
3348
  return files;
2990
3349
  };
2991
3350
  var checkDeterminism = async (config) => {
2992
- const root = resolve24(config.root, config.videosDir);
2993
- if (!existsSync19(root)) return [];
3351
+ const root = resolve25(config.root, config.videosDir);
3352
+ if (!existsSync21(root)) return [];
2994
3353
  const files = await walk2(root);
2995
3354
  const findings = await Promise.all(
2996
- files.map(async (file) => scanSource(await readFile14(file, "utf8"), relative8(config.root, file)))
3355
+ files.map(async (file) => scanSource(await readFile15(file, "utf8"), relative11(config.root, file)))
2997
3356
  );
2998
3357
  return findings.flat();
2999
3358
  };
@@ -3092,7 +3451,7 @@ var FRAME_SCRIPT = `(() => {
3092
3451
  })()`;
3093
3452
  var testVideo = async (origin, video, config, failures, quiet = false) => {
3094
3453
  const id = video.entry.metadata.id;
3095
- const layout = resolveEntryLayout8(video.entry);
3454
+ const layout = resolveEntryLayout9(video.entry);
3096
3455
  if (isOdoriSchema2(video.entry.metadata.schema)) {
3097
3456
  const result = video.entry.metadata.schema.safeParse(video.entry.metadata.defaultProps ?? {});
3098
3457
  if (!result.success) failures.push({ video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}` });
@@ -3257,9 +3616,9 @@ var COMMAND_FLAGS = {
3257
3616
  update: ["force"],
3258
3617
  list: [],
3259
3618
  inspect: ["json", "input"],
3260
- still: ["frame", "output", "input"],
3619
+ frame: ["at", "output", "input"],
3261
3620
  test: ["json"],
3262
- export: ["output", "input", "concurrency", "preset", "format", "no-frame-skip", "retry"],
3621
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
3263
3622
  jobs: [],
3264
3623
  help: []
3265
3624
  };
@@ -3320,15 +3679,19 @@ var USAGE = {
3320
3679
  Print discovered video ids and formats.`,
3321
3680
  inspect: `odori inspect <id> [--json] [--input <json>]
3322
3681
  Show resolved layout, inputs, scenes, and assets.`,
3323
- still: `odori still <id> --frame <n> [--output <path>] [--input <json>]
3324
- 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.`,
3325
3685
  test: `odori test [id] [--json]
3326
3686
  Validate contracts and representative frames. --json emits one object per
3327
3687
  check, for CI.`,
3328
3688
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
3329
- [--preset <name>] [--format <name>] [--no-frame-skip] [--retry <job>]
3689
+ [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
3690
+ [--no-frame-skip] [--retry <job>]
3330
3691
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
3331
- or png; without it the output's extension decides, and mp4 is the default.`,
3692
+ or png; without it the output's extension decides, and mp4 is the default.
3693
+ --quality is studio, social, or web. --scale multiplies the output size,
3694
+ 0.25 to 2. A retry keeps the settings its job was created with.`,
3332
3695
  jobs: `odori jobs
3333
3696
  List export jobs and their status.`
3334
3697
  };
@@ -3346,18 +3709,20 @@ Usage
3346
3709
  odori update [components] Apply upstream component changes
3347
3710
  odori list Print discovered video ids and formats
3348
3711
  odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
3349
- odori still <id> --frame 120 Render one deterministic frame
3712
+ odori frame <id> --at 4s Render one deterministic frame to a PNG
3350
3713
  odori test [id] [--json] Validate contracts and representative frames
3351
3714
  odori export <id> [--output f] Render and encode a distributable file
3352
3715
  odori jobs List export jobs and their status
3353
3716
 
3354
3717
  Options
3355
3718
  --input '{"headline":"..."}' Serializable input for the video schema
3356
- --output <path> Output path for still and export
3719
+ --output <path> Output path for frame and export
3357
3720
  --force Replace locally modified component source
3358
3721
  --concurrency <n> Parallel render workers for export
3359
3722
  --preset <name> x264 preset for export, default medium
3360
3723
  --format <name> mp4, webm, prores, gif, or png
3724
+ --quality <tier> studio, social, or web compression
3725
+ --scale <n> Output size multiplier, 0.25 to 2
3361
3726
  --no-frame-skip Capture every frame, even unchanged ones
3362
3727
  --retry <job id> Re-run a recorded job from its frozen manifest
3363
3728
  --no-open Start dev without opening Studio in a browser
@@ -3428,9 +3793,11 @@ var run2 = async (argv) => {
3428
3793
  case "inspect":
3429
3794
  await inspectCommand(positionals[0] ?? "", { json: flags.json === true, input: parseInput(flags) });
3430
3795
  return 0;
3431
- case "still":
3432
- await stillCommand(positionals[0] ?? "", {
3433
- 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,
3434
3801
  output: typeof flags.output === "string" ? flags.output : void 0,
3435
3802
  input: parseInput(flags)
3436
3803
  });
@@ -3444,6 +3811,8 @@ var run2 = async (argv) => {
3444
3811
  input: parseInput(flags),
3445
3812
  concurrency: numberFlag(flags, "concurrency"),
3446
3813
  preset: typeof flags.preset === "string" ? flags.preset : void 0,
3814
+ quality: typeof flags.quality === "string" ? flags.quality : void 0,
3815
+ scale: numberFlag(flags, "scale"),
3447
3816
  format: typeof flags.format === "string" ? flags.format : void 0,
3448
3817
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
3449
3818
  retry: typeof flags.retry === "string" ? flags.retry : void 0
@@ -3478,6 +3847,7 @@ export {
3478
3847
  discoverProject,
3479
3848
  generateImports,
3480
3849
  writeGenerated,
3850
+ startStudioServer,
3481
3851
  localCandidates,
3482
3852
  createIntegrityResolver,
3483
3853
  outputName,
@@ -3497,7 +3867,6 @@ export {
3497
3867
  findVideo,
3498
3868
  runPrepare,
3499
3869
  freezeManifest,
3500
- startStudioServer,
3501
3870
  resolveCueFile,
3502
3871
  buildAudioFilter,
3503
3872
  planChunks,
@@ -3533,7 +3902,7 @@ export {
3533
3902
  exportCommand,
3534
3903
  jobsCommand,
3535
3904
  devCommand,
3536
- stillCommand,
3905
+ frameCommand,
3537
3906
  diffLines,
3538
3907
  countChanges,
3539
3908
  formatDiff,