@odori/cli 0.0.6 → 0.0.8

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.
@@ -317,7 +317,7 @@ var toComponent = (item) => ({
317
317
  });
318
318
  var snapshotItems = async () => {
319
319
  try {
320
- const loaded = await import("./registry-snapshot-ADKSTGDR.js");
320
+ const loaded = await import("./registry-snapshot-GV6FFZXV.js");
321
321
  return loaded.default.items;
322
322
  } catch {
323
323
  throw new Error(
@@ -809,553 +809,390 @@ var registryCommand = async () => {
809
809
  };
810
810
 
811
811
  // src/commands/dev.ts
812
- import { relative as relative7, resolve as resolve19 } from "path";
813
- import { homedir as homedir2 } from "os";
814
- import { existsSync as existsSync16 } from "fs";
815
- import { readFile as readFile13 } from "fs/promises";
812
+ import { relative as relative8, resolve as resolve20 } from "path";
813
+ import { homedir as homedir3 } from "os";
814
+ import { existsSync as existsSync17 } from "fs";
815
+ import { readFile as readFile14 } from "fs/promises";
816
816
 
817
- // src/jobs.ts
818
- import { mkdir as mkdir6, readFile as readFile7, readdir as readdir3, rename, writeFile as writeFile7 } from "fs/promises";
817
+ // src/keystore.ts
819
818
  import { existsSync as existsSync8 } from "fs";
820
- import { join as join2, resolve as resolve8 } from "path";
821
- var buildsDir = (config) => resolve8(config.root, config.outDir, "builds");
822
- var jobFile = (config, id) => join2(buildsDir(config), `${id}.json`);
823
- var createJob = async (config, manifest, output, render) => {
824
- await mkdir6(buildsDir(config), { recursive: true });
825
- const now = (/* @__PURE__ */ new Date()).toISOString();
826
- const job = {
827
- id: `job-${manifest.manifestHash.slice(0, 10)}-${Date.now().toString(36)}`,
828
- videoId: manifest.videoId,
829
- manifestHash: manifest.manifestHash,
830
- status: "queued",
831
- progress: 0,
832
- attempts: 0,
833
- logs: [],
834
- createdAt: now,
835
- updatedAt: now
836
- };
837
- const record = { job, manifest, output, ...render ? { render } : {} };
838
- await writeFile7(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
839
- `, "utf8");
840
- return record;
841
- };
842
- var readJob = async (config, id) => {
843
- const file = jobFile(config, id);
844
- if (!existsSync8(file)) throw new Error(`Unknown job "${id}". Run odori jobs to list them.`);
845
- return JSON.parse(await readFile7(file, "utf8"));
846
- };
847
- var writeLocks = /* @__PURE__ */ new Map();
848
- var withJobLock = (id, task) => {
849
- const previous = writeLocks.get(id) ?? Promise.resolve();
850
- const next = previous.then(task, task);
851
- writeLocks.set(
852
- id,
853
- next.catch(() => void 0)
854
- );
855
- return next;
856
- };
857
- var writeRecord = async (config, record) => {
858
- const file = jobFile(config, record.job.id);
859
- const temporary = `${file}.${process.pid}.tmp`;
860
- await writeFile7(temporary, `${JSON.stringify(record, null, 2)}
861
- `, "utf8");
862
- await rename(temporary, file);
863
- };
864
- var updateJob = async (config, job) => withJobLock(job.id, async () => {
865
- const record = await readJob(config, job.id);
866
- const next = { ...job, logs: record.job.logs, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
867
- await writeRecord(config, { ...record, job: next });
868
- return next;
869
- });
870
- var appendJobLog = async (config, id, message2) => withJobLock(id, async () => {
871
- const record = await readJob(config, id);
872
- const next = {
873
- ...record.job,
874
- logs: [...record.job.logs.slice(-49), `${(/* @__PURE__ */ new Date()).toISOString()} ${message2}`],
875
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
876
- };
877
- await writeRecord(config, { ...record, job: next });
878
- return next;
879
- });
880
- var alive = (pid) => {
881
- if (!pid) return false;
819
+ import { chmod as chmod2, mkdir as mkdir6, readFile as readFile7, rm as rm3, writeFile as writeFile7 } from "fs/promises";
820
+ import { homedir as homedir2 } from "os";
821
+ import { dirname as dirname4, join as join2 } from "path";
822
+ var storePath = () => join2(process.env.XDG_CONFIG_HOME || join2(homedir2(), ".config"), "odori", "keys.json");
823
+ var readStore = async () => {
824
+ const path = storePath();
825
+ if (!existsSync8(path)) return {};
882
826
  try {
883
- process.kill(pid, 0);
884
- return true;
827
+ const parsed = JSON.parse(await readFile7(path, "utf8"));
828
+ if (typeof parsed !== "object" || parsed === null) return {};
829
+ return Object.fromEntries(Object.entries(parsed).filter(([, value]) => typeof value === "string"));
885
830
  } catch {
886
- return false;
831
+ return {};
887
832
  }
888
833
  };
889
- var reconcileJobs = async (config) => {
890
- const stale = (await listJobs(config, { reconcile: false })).filter(
891
- (job) => (job.status === "rendering" || job.status === "encoding") && !alive(job.pid)
892
- );
893
- for (const job of stale) {
894
- await updateJob(config, {
895
- ...job,
896
- status: "failed",
897
- error: "The render process exited before the job finished."
898
- });
834
+ var writeStore = async (store) => {
835
+ const path = storePath();
836
+ if (Object.keys(store).length === 0) {
837
+ await rm3(path, { force: true });
838
+ return;
899
839
  }
900
- return stale.length;
840
+ await mkdir6(dirname4(path), { recursive: true, mode: 448 });
841
+ await writeFile7(path, JSON.stringify(store, null, 2) + "\n", { mode: 384 });
842
+ await chmod2(path, 384);
843
+ };
844
+ var storedKey = async (variable) => (await readStore())[variable];
845
+ var setStoredKey = async (variable, value) => {
846
+ const store = await readStore();
847
+ store[variable] = value;
848
+ await writeStore(store);
849
+ };
850
+ var clearStoredKey = async (variable) => {
851
+ const store = await readStore();
852
+ delete store[variable];
853
+ await writeStore(store);
854
+ };
855
+ var keySource = async (variable) => {
856
+ if (process.env[variable]) return "environment";
857
+ if ((await readStore())[variable]) return "stored";
858
+ return null;
901
859
  };
902
- var listJobs = async (config, options = {}) => {
903
- if (!existsSync8(buildsDir(config))) return [];
904
- if (options.reconcile !== false) await reconcileJobs(config);
905
- const files = (await readdir3(buildsDir(config))).filter((file) => file.endsWith(".json"));
906
- const jobs = [];
907
- for (const file of files) {
908
- try {
909
- const raw = await readFile7(join2(buildsDir(config), file), "utf8");
910
- jobs.push(JSON.parse(raw).job);
911
- } catch {
912
- continue;
860
+
861
+ // src/providers.ts
862
+ var elevenlabs = {
863
+ name: "elevenlabs",
864
+ title: "ElevenLabs Music",
865
+ docsUrl: "https://elevenlabs.io/docs/api-reference/music",
866
+ keyVariable: "ELEVENLABS_API_KEY",
867
+ async verifyKey(apiKey) {
868
+ const response = await fetch("https://api.elevenlabs.io/v1/user", {
869
+ headers: { "xi-api-key": apiKey },
870
+ signal: AbortSignal.timeout(1e4)
871
+ });
872
+ if (response.ok) return true;
873
+ if (response.status === 401 || response.status === 403) return false;
874
+ throw new Error(`ElevenLabs answered ${response.status} to a key check.`);
875
+ },
876
+ async generate({ prompt, seconds, apiKey }) {
877
+ const response = await fetch("https://api.elevenlabs.io/v1/music", {
878
+ method: "POST",
879
+ headers: { "xi-api-key": apiKey, "content-type": "application/json" },
880
+ body: JSON.stringify({ prompt, music_length_ms: Math.round(seconds * 1e3) }),
881
+ signal: AbortSignal.timeout(3e5)
882
+ });
883
+ if (!response.ok) {
884
+ const detail = await response.text().catch(() => "");
885
+ throw new Error(
886
+ `ElevenLabs returned ${response.status} ${response.statusText}.` + (detail ? `
887
+ ${detail.slice(0, 400)}` : "") + (response.status === 401 ? `
888
+ Is ${elevenlabs.keyVariable} a current key?` : "")
889
+ );
913
890
  }
891
+ const type = response.headers.get("content-type") ?? "";
892
+ const extension = type.includes("wav") ? "wav" : type.includes("mp4") ? "m4a" : "mp3";
893
+ return { bytes: new Uint8Array(await response.arrayBuffer()), extension };
914
894
  }
915
- return jobs.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
916
895
  };
917
- var JobQueue = class {
918
- chain = Promise.resolve();
919
- enqueue(task) {
920
- const result = this.chain.then(task, task);
921
- this.chain = result.catch(() => void 0);
922
- return result;
896
+ var musicProviders = { elevenlabs };
897
+ var resolveMusicProvider = (name) => {
898
+ const provider = musicProviders[name];
899
+ if (!provider) {
900
+ throw new Error(
901
+ `No music provider named "${name}". Available: ${Object.keys(musicProviders).join(", ")}.`
902
+ );
923
903
  }
904
+ return provider;
924
905
  };
906
+ var resolveKey = async (provider) => process.env[provider.keyVariable] ?? await storedKey(provider.keyVariable);
925
907
 
926
- // src/discovery.ts
927
- import { mkdir as mkdir7, readdir as readdir4, readFile as readFile8, stat, writeFile as writeFile8 } from "fs/promises";
928
- import { existsSync as existsSync9 } from "fs";
929
- import { join as join3, relative as relative6, resolve as resolve9, sep as sep2 } from "path";
930
- import { hashString as hashString3 } from "odori";
931
- var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
932
- var walk = async (directory2, files = []) => {
933
- const entries = await readdir4(directory2, { withFileTypes: true });
934
- for (const entry of entries) {
935
- if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
936
- const full = join3(directory2, entry.name);
937
- if (entry.isDirectory()) await walk(full, files);
938
- else files.push(full);
908
+ // src/commands/bed.ts
909
+ import { spawn as spawn3 } from "child_process";
910
+ import { mkdir as mkdir10, stat, writeFile as writeFile11 } from "fs/promises";
911
+ import { basename as basename2, dirname as dirname6, extname, join as join5, relative as relative6, resolve as resolve12 } from "path";
912
+
913
+ // src/render.ts
914
+ import { spawn as spawn2 } from "child_process";
915
+ import { copyFile as copyFile2, mkdir as mkdir9, rm as rm4, writeFile as writeFile10 } from "fs/promises";
916
+ import { cpus } from "os";
917
+ import { dirname as dirname5, join as join4, resolve as resolve11 } from "path";
918
+ import { chromium } from "playwright-core";
919
+
920
+ // src/audio-mix.ts
921
+ import { existsSync as existsSync10 } from "fs";
922
+ import { resolve as resolve9 } from "path";
923
+ import { duckEnvelope, envelopeAtFrame } from "odori";
924
+
925
+ // src/cues.ts
926
+ import { existsSync as existsSync9, statSync } from "fs";
927
+ import { mkdir as mkdir7, writeFile as writeFile8 } from "fs/promises";
928
+ import { basename, resolve as resolve8 } from "path";
929
+ import { pathToFileURL as pathToFileURL2 } from "url";
930
+ import {
931
+ SAMPLE_RATE,
932
+ cueSamples,
933
+ cueUrl,
934
+ defaultLayout,
935
+ encodeWav,
936
+ isCueDefinition,
937
+ resolveEntryLayout
938
+ } from "odori";
939
+ var cueCacheDir = (config) => resolve8(config.root, config.outDir, "cues");
940
+ var cueFile = (config, url) => resolve8(cueCacheDir(config), basename(url));
941
+ var materializeCues = async (config, brands, fps) => {
942
+ const seen = /* @__PURE__ */ new Map();
943
+ for (const brand of brands) {
944
+ for (const value of Object.values(brand.audio.cues)) {
945
+ if (isCueDefinition(value)) seen.set(cueUrl(value), value);
946
+ }
939
947
  }
940
- return files;
948
+ if (seen.size === 0) return [];
949
+ await mkdir7(cueCacheDir(config), { recursive: true });
950
+ const written = [];
951
+ for (const [url, cue] of seen) {
952
+ const file = cueFile(config, url);
953
+ if (existsSync9(file)) {
954
+ written.push({ cue, file, rendered: false });
955
+ continue;
956
+ }
957
+ const samples = cueSamples(cue, fps);
958
+ const signal = cue.render({ samples, sampleRate: SAMPLE_RATE });
959
+ await writeFile8(file, encodeWav(signal));
960
+ written.push({ cue, file, rendered: true });
961
+ }
962
+ return written;
941
963
  };
942
- var toIdentifier = (value, prefix) => {
943
- const cleaned = value.replace(
944
- /[^a-zA-Z0-9]+(.)?/g,
945
- (_, character) => character ? character.toUpperCase() : ""
946
- );
947
- return `${prefix}${cleaned.charAt(0).toUpperCase()}${cleaned.slice(1)}`;
964
+ var known = /* @__PURE__ */ new Map();
965
+ var rendered = /* @__PURE__ */ new Map();
966
+ var registerCues = (brands, fps) => {
967
+ for (const brand of brands) {
968
+ for (const value of Object.values(brand.audio.cues)) {
969
+ if (isCueDefinition(value)) known.set(cueUrl(value), { cue: value, fps });
970
+ }
971
+ }
972
+ return known.size;
948
973
  };
949
- var AUDIO_EXTENSIONS = /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i;
950
- var discoverAudio = async (config) => {
951
- const root = resolve9(config.root, config.audioDir);
952
- if (!existsSync9(root)) return [];
953
- const publicRoot = resolve9(config.root, "public");
954
- const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
955
- return Promise.all(
956
- files.map(async (file) => ({
957
- name: relative6(root, file).replace(AUDIO_EXTENSIONS, "").split(sep2).join("/"),
958
- url: file.startsWith(`${publicRoot}${sep2}`) ? `/${relative6(publicRoot, file).split(sep2).join("/")}` : `/${relative6(config.root, file).split(sep2).join("/")}`,
959
- relativeFile: relative6(config.root, file),
960
- bytes: (await stat(file)).size
961
- }))
962
- );
974
+ var renderedCue = (url) => {
975
+ const cached = rendered.get(url);
976
+ if (cached) return cached;
977
+ const entry = known.get(url);
978
+ if (!entry) return null;
979
+ const signal = entry.cue.render({ samples: cueSamples(entry.cue, entry.fps), sampleRate: SAMPLE_RATE });
980
+ const wav = encodeWav(signal);
981
+ rendered.set(url, wav);
982
+ return wav;
963
983
  };
964
- var discoverProject = async (config) => {
965
- const videosRoot = resolve9(config.root, config.videosDir);
966
- if (!existsSync9(videosRoot)) {
967
- throw new Error(`No ${config.videosDir}/ directory found in ${config.root}. Run "odori init" first.`);
968
- }
969
- const files = (await walk(videosRoot)).sort();
970
- const audio = await discoverAudio(config);
971
- const videos = [];
972
- const previews = [];
973
- const brands = [];
974
- const categories = [];
975
- const hashParts = [];
976
- const importedBy = {};
977
- const componentsRoot = resolve9(config.root, config.componentsDir);
978
- for (const file of files) {
979
- const relativeFile = relative6(config.root, file);
980
- let contents = "";
981
- if (/\.(tsx|ts|css|json)$/.test(file)) {
982
- contents = await readFile8(file, "utf8");
983
- hashParts.push(`${relativeFile}:${hashString3(contents)}`);
984
- }
985
- const base = file.split(sep2).pop() ?? "";
986
- if (base === "category.json") {
987
- const path = relative6(componentsRoot, resolve9(file, "..")).split(sep2).join("/");
988
- if (!path.startsWith("..")) {
989
- try {
990
- const declared = JSON.parse(contents);
991
- categories.push({
992
- path,
993
- ...typeof declared.name === "string" ? { name: declared.name } : {},
994
- ...typeof declared.order === "number" ? { order: declared.order } : {}
995
- });
996
- } catch (error) {
997
- log.warn(
998
- `${relativeFile} is not valid JSON, so that directory names itself: ${error instanceof Error ? error.message : String(error)}`
999
- );
1000
- }
1001
- }
984
+ var isBrand = (value) => typeof value === "object" && value !== null && value.kind === "odori-brand";
985
+ var isLayout = (value) => typeof value === "object" && value !== null && value.kind === "odori-layout";
986
+ var importFresh = async (file) => await import(`${pathToFileURL2(file).href}?odori=${statSync(file).mtimeMs}`);
987
+ var registerProjectCues = async (graph, load = importFresh) => {
988
+ for (const discovered of graph.brands) {
989
+ try {
990
+ const values = Object.values(await load(discovered.file));
991
+ registerCues(values.filter(isBrand), defaultLayout.format.fps);
992
+ for (const layout of values.filter(isLayout)) registerCues([layout.brand], layout.format.fps);
993
+ } catch (error) {
994
+ log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
1002
995
  }
1003
- if (base === "video.tsx") {
1004
- for (const match of contents.matchAll(/from\s+["'][^"']*\/components\/([^/"']+)\//g)) {
1005
- (importedBy[match[1]] ??= /* @__PURE__ */ new Set()).add(relative6(videosRoot, file).replace(/\/?video\.tsx$/, "") || "video");
1006
- }
996
+ }
997
+ for (const video of graph.videos) {
998
+ try {
999
+ const module = await load(video.file);
1000
+ if (!module.default || !module.metadata) continue;
1001
+ const entry = { component: module.default, metadata: module.metadata };
1002
+ const layout = resolveEntryLayout(entry);
1003
+ registerCues([layout.brand], layout.format.fps);
1004
+ } catch (error) {
1005
+ log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
1007
1006
  }
1008
- if (base === "video.tsx") {
1009
- const slug = relative6(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
1010
- videos.push({
1011
- slug,
1012
- file,
1013
- relativeFile,
1014
- importPath: file,
1015
- identifier: toIdentifier(slug, "video")
1016
- });
1017
- } else if (
1018
- // A brand module is recognized by what it does, not where it sits. The
1019
- // scaffold defines its brand in videos/layout.tsx, so a directory-name
1020
- // rule alone left the default project's brand invisible to everything
1021
- // that reads this list — most visibly the dev server's cue registry,
1022
- // which then answered new cue URLs with 404s until a restart. Installed
1023
- // component source is excluded the way brand-file.ts excludes it: a
1024
- // component may mention defineBrand without being where a brand lives.
1025
- /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx") && !file.startsWith(componentsRoot + sep2) && (file.split(sep2).includes("brands") || contents.includes("defineBrand("))
1026
- ) {
1027
- const name = base.replace(/\.tsx?$/, "");
1028
- brands.push({
1029
- name,
1030
- file,
1031
- relativeFile,
1032
- // From the whole relative path, like previews: basenames repeat
1033
- // (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
1034
- identifier: toIdentifier(
1035
- `${relative6(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
1036
- "brands"
1037
- )
1038
- });
1039
- } else if (base.endsWith(".preview.tsx")) {
1040
- const name = base.replace(/\.preview\.tsx$/, "");
1041
- previews.push({
1042
- name,
1043
- file,
1044
- relativeFile,
1045
- importPath: file,
1046
- identifier: toIdentifier(`${relative6(videosRoot, file).split(sep2).join("-")}`, "preview")
1047
- });
1048
- }
1049
- }
1050
- for (const preview of previews) {
1051
- const users = importedBy[preview.name];
1052
- if (users) preview.usedBy = [...users].sort();
1053
1007
  }
1054
- return { videos, previews, brands, audio, categories, sourceHash: hashString3(hashParts.join("|")) };
1008
+ return known.size;
1055
1009
  };
1056
- var generateImports = (graph, outDir) => {
1057
- const importPath = (file) => {
1058
- const relativePath = relative6(outDir, file).split(sep2).join("/");
1059
- return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
1060
- };
1061
- const lines = [
1062
- "// Generated by odori. Do not edit.",
1063
- 'import type {VideoEntry} from "odori";',
1064
- "",
1065
- ...graph.videos.map(
1066
- (video) => `import ${video.identifier}, {metadata as ${video.identifier}Metadata} from "${importPath(video.file)}";`
1067
- ),
1068
- ...graph.previews.map((preview) => `import ${preview.identifier} from "${importPath(preview.file)}";`),
1069
- ...graph.brands.map((brand) => `import * as ${brand.identifier} from "${importPath(brand.file)}";`),
1070
- "",
1071
- "export const videos: VideoEntry[] = [",
1072
- ...graph.videos.map(
1073
- (video) => ` {component: ${video.identifier}, metadata: {...${video.identifier}Metadata, id: ${video.identifier}Metadata.id || ${JSON.stringify(video.slug)}}},`
1074
- ),
1075
- "];",
1076
- "",
1077
- "export const componentPreviews = [",
1078
- ...graph.previews.map((preview) => ` {id: ${JSON.stringify(preview.name)}, preview: ${preview.identifier}},`),
1079
- "];",
1080
- "",
1081
- "export const brands = [",
1082
- ...graph.brands.map(
1083
- (brand) => ` ...Object.values(${brand.identifier}).filter((value) => (value as {kind?: string})?.kind === "odori-brand"),`
1084
- ),
1085
- "];",
1086
- ""
1010
+ var message = (error) => error instanceof Error ? error.message : String(error);
1011
+
1012
+ // src/audio-mix.ts
1013
+ var resolveCueFile = (config, src) => {
1014
+ if (/^https?:\/\//.test(src)) return null;
1015
+ if (src.startsWith("/__odori/cue/")) {
1016
+ const generated = cueFile(config, src);
1017
+ return existsSync10(generated) ? generated : null;
1018
+ }
1019
+ const candidates = [
1020
+ resolve9(config.root, "public", src.replace(/^\//, "")),
1021
+ resolve9(config.root, src.replace(/^\//, ""))
1087
1022
  ];
1088
- return lines.join("\n");
1023
+ return candidates.find((candidate) => existsSync10(candidate)) ?? null;
1089
1024
  };
1090
- var writeGenerated = async (config, graph) => {
1091
- const outDir = resolve9(config.root, config.outDir);
1092
- await mkdir7(outDir, { recursive: true });
1093
- const target = join3(outDir, "imports.generated.ts");
1094
- await writeFile8(target, generateImports(graph, outDir), "utf8");
1095
- await writeFile8(
1096
- join3(outDir, "catalog.json"),
1097
- `${JSON.stringify(
1098
- {
1099
- sourceHash: graph.sourceHash,
1100
- videos: graph.videos.map((video) => ({ slug: video.slug, file: video.relativeFile })),
1101
- previews: graph.previews.map((preview) => ({
1102
- name: preview.name,
1103
- file: preview.relativeFile,
1104
- usedBy: preview.usedBy ?? []
1105
- })),
1106
- brands: graph.brands.map((brand) => ({ name: brand.name, file: brand.relativeFile })),
1107
- audio: graph.audio.map((entry) => ({ name: entry.name, url: entry.url, file: entry.relativeFile }))
1108
- },
1109
- null,
1110
- 2
1111
- )}
1112
- `,
1113
- "utf8"
1025
+ var volumeFilter = (cue, cues, fps) => {
1026
+ const authored = cue.gainPoints ?? [];
1027
+ const frames = [
1028
+ .../* @__PURE__ */ new Set([
1029
+ ...duckEnvelope(cue, cues).map((point) => point.frame),
1030
+ ...authored.map((point) => point.frame)
1031
+ ])
1032
+ ].sort(
1033
+ (left, right) => left - right
1114
1034
  );
1115
- return target;
1035
+ const duck = duckEnvelope(cue, cues);
1036
+ const points = frames.map((frame) => ({
1037
+ seconds: (frame - cue.fromFrame) / fps,
1038
+ value: envelopeAtFrame(duck, frame) * (authored.length > 0 ? envelopeAtFrame(authored, frame) : 1) * cue.gain
1039
+ }));
1040
+ const constant = points.every((point) => point.value === points[0].value);
1041
+ if (constant) return `volume=${(points[0]?.value ?? cue.gain).toFixed(4)}`;
1042
+ let expression = points[points.length - 1].value.toFixed(4);
1043
+ for (let index = points.length - 1; index > 0; index -= 1) {
1044
+ const previous = points[index - 1];
1045
+ const current = points[index];
1046
+ const span = current.seconds - previous.seconds;
1047
+ const segment = span <= 0 ? current.value.toFixed(4) : `${previous.value.toFixed(4)}+${(current.value - previous.value).toFixed(4)}*(t-${previous.seconds.toFixed(
1048
+ 4
1049
+ )})/${span.toFixed(4)}`;
1050
+ expression = `if(lt(t,${current.seconds.toFixed(4)}),${segment},${expression})`;
1051
+ }
1052
+ return `volume=volume='${expression}':eval=frame`;
1053
+ };
1054
+ var buildAudioFilter = (inputs, options) => {
1055
+ const { fps, durationInFrames, targetLufs } = options;
1056
+ const totalSeconds = durationInFrames / fps;
1057
+ const cues = inputs.map(({ cue }) => cue);
1058
+ const parts = [];
1059
+ const labels = [];
1060
+ inputs.forEach(({ cue }, index) => {
1061
+ const start = cue.trimStartSeconds;
1062
+ const length2 = cue.durationInFrames / fps;
1063
+ const delay = Math.round(cue.fromFrame / fps * 1e3);
1064
+ const volume = volumeFilter(cue, cues, fps);
1065
+ const label = `a${index}`;
1066
+ const chain = [
1067
+ // Index 1 is the video input, so audio inputs start at 1.
1068
+ cue.loop ? `aloop=loop=-1:size=2147483647` : null,
1069
+ // Every input is brought to one format before anything else touches it.
1070
+ // Cues are mono, files are usually stereo, and a graph that leaves the
1071
+ // difference to be inferred works on the encoder that happens to be
1072
+ // installed and fails on the pinned one.
1073
+ "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo",
1074
+ `atrim=start=${start.toFixed(4)}:duration=${length2.toFixed(4)}`,
1075
+ "asetpts=PTS-STARTPTS",
1076
+ cue.fadeInFrames > 0 ? `afade=t=in:st=0:d=${(cue.fadeInFrames / fps).toFixed(4)}` : null,
1077
+ cue.fadeOutFrames > 0 ? `afade=t=out:st=${Math.max(0, length2 - cue.fadeOutFrames / fps).toFixed(4)}:d=${(cue.fadeOutFrames / fps).toFixed(4)}` : null,
1078
+ volume,
1079
+ delay > 0 ? `adelay=${delay}|${delay}` : null,
1080
+ `apad=whole_dur=${totalSeconds.toFixed(4)}`,
1081
+ `atrim=duration=${totalSeconds.toFixed(4)}`
1082
+ ].filter(Boolean).join(",");
1083
+ parts.push(`[${index + 1}:a]${chain}[${label}]`);
1084
+ labels.push(`[${label}]`);
1085
+ });
1086
+ parts.push(
1087
+ `${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[mixed]`,
1088
+ // loudnorm resamples to its own rate and can drop the layout on the way
1089
+ // out, so the last link states the output format rather than negotiating
1090
+ // it with whatever encoder is downstream.
1091
+ /* No LRA target: a mix cannot be given a loudness range it does not have,
1092
+ so asking for one states an intent the filter cannot honour. Measured
1093
+ against the beds here it changes nothing either way, which is the point
1094
+ — the range comes from the material, and asking for eleven from a bed
1095
+ mastered to two only hides that. */
1096
+ `[mixed]loudnorm=I=${targetLufs}:TP=-1.5,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[audio]`
1097
+ );
1098
+ return { filter: parts.join(";"), label: "[audio]" };
1116
1099
  };
1117
1100
 
1118
- // src/project.ts
1119
- import { resolve as resolve12 } from "path";
1120
- import { pathToFileURL as pathToFileURL2 } from "url";
1121
- import {
1122
- createRenderManifest,
1123
- entryDurationInFrames,
1124
- resolveEntryLayout,
1125
- resolveVideoId
1126
- } from "odori";
1101
+ // src/chunks.ts
1102
+ var length = (chunk) => chunk.end - chunk.start + 1;
1103
+ var planChunks = ({
1104
+ durationInFrames,
1105
+ scenes = [],
1106
+ concurrency,
1107
+ maxChunkFrames = 120
1108
+ }) => {
1109
+ if (durationInFrames <= 0) return { chunks: [], lanes: [] };
1110
+ const bounded = Math.max(1, Math.min(concurrency, durationInFrames));
1111
+ const ordered = [...scenes].filter((scene) => scene.durationInFrames > 0).sort((left, right) => left.start - right.start);
1112
+ const spans = [];
1113
+ let cursor = 0;
1114
+ for (const scene of ordered) {
1115
+ if (scene.start > cursor) spans.push({ start: cursor, end: scene.start - 1 });
1116
+ const end = Math.min(durationInFrames - 1, scene.start + scene.durationInFrames - 1);
1117
+ if (end >= scene.start) spans.push({ start: scene.start, end, sceneId: scene.id });
1118
+ cursor = end + 1;
1119
+ }
1120
+ if (cursor < durationInFrames) spans.push({ start: cursor, end: durationInFrames - 1 });
1121
+ const chunks = [];
1122
+ for (const span of spans) {
1123
+ const target = Math.max(1, Math.min(maxChunkFrames, Math.ceil(durationInFrames / bounded)));
1124
+ const total = span.end - span.start + 1;
1125
+ const pieces = Math.max(1, Math.ceil(total / target));
1126
+ const size = Math.ceil(total / pieces);
1127
+ for (let piece = 0; piece < pieces; piece += 1) {
1128
+ const start = span.start + piece * size;
1129
+ const end = Math.min(span.end, start + size - 1);
1130
+ if (start > end) continue;
1131
+ chunks.push({ index: chunks.length, start, end, sceneId: span.sceneId });
1132
+ }
1133
+ }
1134
+ const lanes = Array.from({ length: bounded }, () => []);
1135
+ const loads = new Array(bounded).fill(0);
1136
+ for (const chunk of [...chunks].sort((left, right) => length(right) - length(left))) {
1137
+ let lane = 0;
1138
+ for (let index = 1; index < bounded; index += 1) if (loads[index] < loads[lane]) lane = index;
1139
+ lanes[lane].push(chunk);
1140
+ loads[lane] += length(chunk);
1141
+ }
1142
+ for (const lane of lanes) lane.sort((left, right) => left.start - right.start);
1143
+ return { chunks, lanes: lanes.filter((lane) => lane.length > 0) };
1144
+ };
1145
+ var chunkFrames = (chunk) => Array.from({ length: length(chunk) }, (_, offset) => chunk.start + offset);
1127
1146
 
1128
- // src/integrity.ts
1129
- import { createHash as createHash3 } from "crypto";
1130
- import { existsSync as existsSync10 } from "fs";
1131
- import { mkdir as mkdir8, readFile as readFile9, writeFile as writeFile9 } from "fs/promises";
1132
- import { dirname as dirname4, resolve as resolve10 } from "path";
1133
- var cacheFile = (config) => resolve10(config.root, config.outDir, "cache", "integrity.json");
1134
- var readCache = async (config) => {
1135
- const file = cacheFile(config);
1136
- if (!existsSync10(file)) return {};
1147
+ // src/chunk-cache.ts
1148
+ import { existsSync as existsSync11 } from "fs";
1149
+ import { copyFile, mkdir as mkdir8, readFile as readFile8, writeFile as writeFile9 } from "fs/promises";
1150
+ import { join as join3, resolve as resolve10 } from "path";
1151
+ import { hashValue } from "odori";
1152
+ var cacheDir2 = (config) => resolve10(config.root, config.outDir, "cache", "chunks");
1153
+ var chunkKey = (identity) => hashValue({
1154
+ videoId: identity.videoId,
1155
+ // The browser that drew the frames is part of what the frames are. Without
1156
+ // it, upgrading Chrome silently reuses pixels the new build would not have
1157
+ // produced, which is the exact drift the pinned toolchain exists to stop.
1158
+ renderer: identity.renderer ?? null,
1159
+ // A chunk is an encoded file, not a bag of frames: H.264 chunks cannot be
1160
+ // copied into a WebM, so a cache that ignored the codec would hand the
1161
+ // muxer streams it cannot write.
1162
+ format: identity.format ?? null,
1163
+ sceneId: identity.chunk.sceneId ?? null,
1164
+ start: identity.chunk.start,
1165
+ end: identity.chunk.end,
1166
+ width: identity.width,
1167
+ height: identity.height,
1168
+ fps: identity.fps,
1169
+ preset: identity.preset,
1170
+ quality: identity.quality ?? null,
1171
+ scale: identity.scale ?? null,
1172
+ input: identity.input ?? null
1173
+ });
1174
+ var readChunkRecord = async (config, key) => {
1175
+ const meta = join3(cacheDir2(config), `${key}.json`);
1176
+ const media = join3(cacheDir2(config), `${key}.mp4`);
1177
+ if (!existsSync11(meta) || !existsSync11(media)) return null;
1137
1178
  try {
1138
- return JSON.parse(await readFile9(file, "utf8"));
1179
+ return JSON.parse(await readFile8(meta, "utf8"));
1139
1180
  } catch {
1140
- return {};
1181
+ return null;
1141
1182
  }
1142
1183
  };
1143
- var writeCache = async (config, cache) => {
1144
- const file = cacheFile(config);
1145
- await mkdir8(dirname4(file), { recursive: true });
1146
- await writeFile9(file, `${JSON.stringify(cache, null, 2)}
1184
+ var useChunkRecord = async (config, key, destination) => {
1185
+ await copyFile(join3(cacheDir2(config), `${key}.mp4`), destination);
1186
+ };
1187
+ var writeChunkRecord = async (config, key, signatures, file) => {
1188
+ const directory2 = cacheDir2(config);
1189
+ await mkdir8(directory2, { recursive: true });
1190
+ await copyFile(file, join3(directory2, `${key}.mp4`));
1191
+ const record = { key, signatures, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1192
+ await writeFile9(join3(directory2, `${key}.json`), `${JSON.stringify(record)}
1147
1193
  `, "utf8");
1148
1194
  };
1149
- var sha256 = (bytes) => `sha256-${createHash3("sha256").update(bytes).digest("base64")}`;
1150
- var localCandidates = (config, url) => [
1151
- resolve10(config.root, "public", url.replace(/^\//, "")),
1152
- resolve10(config.root, url.replace(/^\//, ""))
1153
- ];
1154
- var isServed = (config, file) => file.startsWith(resolve10(config.root, "public") + "/");
1155
- var createIntegrityResolver = async (config) => {
1156
- const cache = await readCache(config);
1157
- const warned = /* @__PURE__ */ new Set();
1158
- let dirty = false;
1159
- const resolveIntegrity = async (url) => {
1160
- if (url.startsWith("/__odori/cue/")) return `cue-${url.slice(url.lastIndexOf("-") + 1).replace(/\.wav$/, "")}`;
1161
- const local = localCandidates(config, url).find((candidate) => existsSync10(candidate));
1162
- if (local) {
1163
- if (!isServed(config, local) && !warned.has(url)) {
1164
- warned.add(url);
1165
- log.warn(`${url} resolves to ${local}, which is outside public/ and will not be served. Move it into public/.`);
1166
- }
1167
- const bytes = await readFile9(local);
1168
- const { mtimeMs } = await import("fs/promises").then((fs) => fs.stat(local));
1169
- const hit = cache[url];
1170
- if (hit && hit.mtimeMs === mtimeMs && hit.size === bytes.byteLength) return hit.integrity;
1171
- const integrity = sha256(bytes);
1172
- cache[url] = { integrity, size: bytes.byteLength, mtimeMs };
1173
- dirty = true;
1174
- return integrity;
1175
- }
1176
- if (!/^https?:\/\//.test(url)) return "unresolved";
1177
- if (cache[url]) return cache[url].integrity;
1178
- try {
1179
- const response = await fetch(url, { signal: AbortSignal.timeout(1e4) });
1180
- if (!response.ok) return "unresolved";
1181
- const bytes = new Uint8Array(await response.arrayBuffer());
1182
- const integrity = sha256(bytes);
1183
- cache[url] = { integrity, size: bytes.byteLength };
1184
- dirty = true;
1185
- return integrity;
1186
- } catch {
1187
- return "unresolved";
1188
- }
1189
- };
1190
- return {
1191
- resolve: resolveIntegrity,
1192
- flush: async () => {
1193
- if (dirty) await writeCache(config, cache);
1194
- }
1195
- };
1196
- };
1197
-
1198
- // src/prepare-cache.ts
1199
- import { existsSync as existsSync11 } from "fs";
1200
- import { mkdir as mkdir9, readFile as readFile10, readdir as readdir5, rm as rm3, writeFile as writeFile10 } from "fs/promises";
1201
- import { join as join4, resolve as resolve11 } from "path";
1202
- import { hashValue } from "odori";
1203
-
1204
- // src/paths.ts
1205
- var outputName = (id) => id.split("/").join("-");
1206
- var fileKey = (id) => id.split("/").join("+");
1207
-
1208
- // src/prepare-cache.ts
1209
- var directory = (config) => resolve11(config.root, config.outDir, "cache", "prepare");
1210
- var prepareCacheKey = (key) => `${fileKey(key.videoId)}__${hashValue(key)}`;
1211
- var readPrepareCache = async (config, key) => {
1212
- const file = join4(directory(config), `${prepareCacheKey(key)}.json`);
1213
- if (!existsSync11(file)) return { hit: false, value: void 0 };
1214
- try {
1215
- const entry = JSON.parse(await readFile10(file, "utf8"));
1216
- return { hit: true, value: entry.value };
1217
- } catch {
1218
- return { hit: false, value: void 0 };
1219
- }
1220
- };
1221
- var writePrepareCache = async (config, key, value) => {
1222
- if (value === void 0) return;
1223
- const target = directory(config);
1224
- await mkdir9(target, { recursive: true });
1225
- const entry = { key, value, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1226
- await writeFile10(join4(target, `${prepareCacheKey(key)}.json`), `${JSON.stringify(entry, null, 2)}
1227
- `, "utf8");
1228
- };
1229
- var clearPrepareCache = async (config, videoId) => {
1230
- const target = directory(config);
1231
- if (!existsSync11(target)) return 0;
1232
- const files = await readdir5(target);
1233
- const matches = files.filter((file) => videoId ? file.startsWith(`${fileKey(videoId)}__`) : file.endsWith(".json"));
1234
- await Promise.all(matches.map((file) => rm3(join4(target, file), { force: true })));
1235
- return matches.length;
1236
- };
1237
-
1238
- // src/project.ts
1239
- var loadVideos = async (graph) => {
1240
- const loaded = [];
1241
- for (const discovered of graph.videos) {
1242
- const module = await import(pathToFileURL2(discovered.file).href);
1243
- if (!module.default || !module.metadata) {
1244
- throw new Error(`${discovered.relativeFile} must export metadata and a default React component.`);
1245
- }
1246
- const entry = {
1247
- component: module.default,
1248
- metadata: { ...module.metadata, id: resolveVideoId(module.metadata.id, discovered.slug) }
1249
- };
1250
- loaded.push({
1251
- entry,
1252
- file: discovered.file,
1253
- relativeFile: discovered.relativeFile,
1254
- durationInFrames: entryDurationInFrames(entry, resolveEntryLayout(entry))
1255
- });
1256
- }
1257
- const byId = /* @__PURE__ */ new Map();
1258
- for (const video of loaded) {
1259
- const id = video.entry.metadata.id;
1260
- const first = byId.get(id);
1261
- if (first) throw new Error(`Duplicate video id "${id}":
1262
- ${first}
1263
- ${video.relativeFile}`);
1264
- byId.set(id, video.relativeFile);
1265
- }
1266
- return loaded;
1267
- };
1268
- var findVideo = (videos, id) => {
1269
- const found = videos.find((video) => video.entry.metadata.id === id);
1270
- if (!found) {
1271
- throw new Error(
1272
- `Unknown video "${id}". Known videos: ${videos.map((video) => video.entry.metadata.id).join(", ") || "none"}`
1273
- );
1274
- }
1275
- return found;
1276
- };
1277
- var runPrepare = async (video, config, graph, input, options = {}) => {
1278
- const prepareFile = resolve12(video.file, "..", "prepare.ts");
1279
- let prepare;
1280
- try {
1281
- const module = await import(pathToFileURL2(prepareFile).href);
1282
- prepare = module.prepare ?? module.default;
1283
- } catch (error) {
1284
- if (error.code === "ERR_MODULE_NOT_FOUND") return void 0;
1285
- throw error;
1286
- }
1287
- if (!prepare) return void 0;
1288
- const key = {
1289
- videoId: video.entry.metadata.id,
1290
- sourceHash: graph.sourceHash,
1291
- input,
1292
- version: prepare.version ?? "1"
1293
- };
1294
- if (!options.refresh) {
1295
- const cached = await readPrepareCache(config, key);
1296
- if (cached.hit) return cached.value;
1297
- }
1298
- const memo = /* @__PURE__ */ new Map();
1299
- const value = await prepare.run({
1300
- input,
1301
- assets: {
1302
- resolve: async (reference) => {
1303
- const asset = (config.assets ?? []).find((item) => item.reference === reference);
1304
- if (!asset) throw new Error(`Unknown asset reference: ${reference}`);
1305
- return asset.url;
1306
- }
1307
- },
1308
- cache: {
1309
- getOrSet: async (cacheKey, factory) => {
1310
- if (!memo.has(cacheKey)) memo.set(cacheKey, await factory());
1311
- return memo.get(cacheKey);
1312
- }
1313
- }
1314
- });
1315
- await writePrepareCache(config, key, value);
1316
- return value;
1317
- };
1318
- var freezeManifest = async (video, graph, config, rawInput, options = {}) => {
1319
- const layout = resolveEntryLayout(video.entry);
1320
- const merged = { ...video.entry.metadata.defaultProps, ...rawInput };
1321
- const input = video.entry.metadata.schema ? video.entry.metadata.schema.parse(merged) : merged;
1322
- const prepared = await runPrepare(video, config, graph, input, { refresh: options.refreshPrepare });
1323
- const integrity = await createIntegrityResolver(config);
1324
- const assets = await Promise.all(
1325
- (config.assets ?? []).map(async (asset) => ({ ...asset, integrity: await integrity.resolve(asset.url) }))
1326
- );
1327
- const audio = await Promise.all(
1328
- (options.audio ?? []).map(async (cue) => ({ ...cue, integrity: await integrity.resolve(cue.src) }))
1329
- );
1330
- const fonts = await Promise.all(
1331
- layout.brand.fonts.map(async (font) => ({
1332
- family: font.family,
1333
- url: font.url,
1334
- integrity: await integrity.resolve(font.url)
1335
- }))
1336
- );
1337
- await integrity.flush();
1338
- for (const cue of audio) {
1339
- if (cue.integrity === "unresolved") log.warn(`Audio source could not be resolved for hashing: ${cue.src}`);
1340
- }
1341
- const manifest = createRenderManifest({
1342
- entry: video.entry,
1343
- layout,
1344
- input,
1345
- prepared,
1346
- sourceHash: graph.sourceHash,
1347
- durationInFrames: video.durationInFrames,
1348
- scenes: options.scenes ?? [],
1349
- audio,
1350
- assets,
1351
- fonts,
1352
- // Which Chrome drew it and which FFmpeg encoded it. Two files that differ
1353
- // are then a question with an answer rather than a mystery.
1354
- toolchain: await renderToolchain(config),
1355
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
1356
- });
1357
- return { manifest, input, prepared };
1358
- };
1195
+ var signaturesMatch = (recorded, observed) => recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
1359
1196
 
1360
1197
  // src/formats.ts
1361
1198
  var QUALITIES = ["studio", "social", "web"];
@@ -1471,314 +1308,42 @@ var resolveFormat = (requested, output) => {
1471
1308
  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;
1472
1309
 
1473
1310
  // src/render.ts
1474
- import { spawn as spawn2 } from "child_process";
1475
- import { copyFile as copyFile2, mkdir as mkdir12, rm as rm4, writeFile as writeFile13 } from "fs/promises";
1476
- import { cpus } from "os";
1477
- import { dirname as dirname5, join as join6, resolve as resolve16 } from "path";
1478
- import { chromium } from "playwright-core";
1479
-
1480
- // src/audio-mix.ts
1481
- import { existsSync as existsSync13 } from "fs";
1482
- import { resolve as resolve14 } from "path";
1483
- import { duckEnvelope, envelopeAtFrame } from "odori";
1484
-
1485
- // src/cues.ts
1486
- import { existsSync as existsSync12, statSync } from "fs";
1487
- import { mkdir as mkdir10, writeFile as writeFile11 } from "fs/promises";
1488
- import { basename, resolve as resolve13 } from "path";
1489
- import { pathToFileURL as pathToFileURL3 } from "url";
1490
- import {
1491
- SAMPLE_RATE,
1492
- cueSamples,
1493
- cueUrl,
1494
- defaultLayout,
1495
- encodeWav,
1496
- isCueDefinition,
1497
- resolveEntryLayout as resolveEntryLayout2
1498
- } from "odori";
1499
- var cueCacheDir = (config) => resolve13(config.root, config.outDir, "cues");
1500
- var cueFile = (config, url) => resolve13(cueCacheDir(config), basename(url));
1501
- var materializeCues = async (config, brands, fps) => {
1502
- const seen = /* @__PURE__ */ new Map();
1503
- for (const brand of brands) {
1504
- for (const value of Object.values(brand.audio.cues)) {
1505
- if (isCueDefinition(value)) seen.set(cueUrl(value), value);
1506
- }
1507
- }
1508
- if (seen.size === 0) return [];
1509
- await mkdir10(cueCacheDir(config), { recursive: true });
1510
- const written = [];
1511
- for (const [url, cue] of seen) {
1512
- const file = cueFile(config, url);
1513
- if (existsSync12(file)) {
1514
- written.push({ cue, file, rendered: false });
1515
- continue;
1516
- }
1517
- const samples = cueSamples(cue, fps);
1518
- const signal = cue.render({ samples, sampleRate: SAMPLE_RATE });
1519
- await writeFile11(file, encodeWav(signal));
1520
- written.push({ cue, file, rendered: true });
1521
- }
1522
- return written;
1311
+ var encodeParam = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
1312
+ var renderUrl = (origin, target, frame) => {
1313
+ const params = new URLSearchParams({ render: "1", video: target.videoId, frame: String(frame) });
1314
+ if (target.input) params.set("input", encodeParam(target.input));
1315
+ if (target.prepared !== void 0) params.set("prepared", encodeParam(target.prepared));
1316
+ return `${origin}/?${params.toString()}`;
1523
1317
  };
1524
- var known = /* @__PURE__ */ new Map();
1525
- var rendered = /* @__PURE__ */ new Map();
1526
- var registerCues = (brands, fps) => {
1527
- for (const brand of brands) {
1528
- for (const value of Object.values(brand.audio.cues)) {
1529
- if (isCueDefinition(value)) known.set(cueUrl(value), { cue: value, fps });
1530
- }
1318
+ var DEFAULT_GRAPHICS = "software";
1319
+ var browserArgs = (graphics = DEFAULT_GRAPHICS) => graphics === "gpu" ? ["--use-gl=angle", "--use-angle=default"] : ["--enable-unsafe-swiftshader"];
1320
+ var openRenderPage = async (origin, target, config, graphics = DEFAULT_GRAPHICS) => {
1321
+ const executablePath = await browserExecutable(config);
1322
+ const browser = await chromium.launch({ executablePath, headless: true, args: browserArgs(graphics) });
1323
+ const page = await browser.newPage({
1324
+ viewport: { width: target.width, height: target.height },
1325
+ deviceScaleFactor: 1
1326
+ });
1327
+ const errors = [];
1328
+ page.on("pageerror", (error) => errors.push(error.message));
1329
+ await page.goto(renderUrl(origin, target, 0), { waitUntil: "networkidle" });
1330
+ try {
1331
+ await page.locator('[data-odori-frame="0"]').waitFor({ timeout: 2e4 });
1332
+ } catch {
1333
+ await browser.close();
1334
+ throw new Error(`The video did not mount.${errors.length ? ` ${errors.join(" ")}` : ""}`);
1531
1335
  }
1532
- return known.size;
1336
+ await page.evaluate(() => document.fonts.ready);
1337
+ return { browser, page, errors };
1533
1338
  };
1534
- var renderedCue = (url) => {
1535
- const cached = rendered.get(url);
1536
- if (cached) return cached;
1537
- const entry = known.get(url);
1538
- if (!entry) return null;
1539
- const signal = entry.cue.render({ samples: cueSamples(entry.cue, entry.fps), sampleRate: SAMPLE_RATE });
1540
- const wav = encodeWav(signal);
1541
- rendered.set(url, wav);
1542
- return wav;
1543
- };
1544
- var isBrand = (value) => typeof value === "object" && value !== null && value.kind === "odori-brand";
1545
- var isLayout = (value) => typeof value === "object" && value !== null && value.kind === "odori-layout";
1546
- var importFresh = async (file) => await import(`${pathToFileURL3(file).href}?odori=${statSync(file).mtimeMs}`);
1547
- var registerProjectCues = async (graph, load = importFresh) => {
1548
- for (const discovered of graph.brands) {
1549
- try {
1550
- const values = Object.values(await load(discovered.file));
1551
- registerCues(values.filter(isBrand), defaultLayout.format.fps);
1552
- for (const layout of values.filter(isLayout)) registerCues([layout.brand], layout.format.fps);
1553
- } catch (error) {
1554
- log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
1555
- }
1556
- }
1557
- for (const video of graph.videos) {
1558
- try {
1559
- const module = await load(video.file);
1560
- if (!module.default || !module.metadata) continue;
1561
- const entry = { component: module.default, metadata: module.metadata };
1562
- const layout = resolveEntryLayout2(entry);
1563
- registerCues([layout.brand], layout.format.fps);
1564
- } catch (error) {
1565
- log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
1566
- }
1567
- }
1568
- return known.size;
1569
- };
1570
- var message = (error) => error instanceof Error ? error.message : String(error);
1571
-
1572
- // src/audio-mix.ts
1573
- var resolveCueFile = (config, src) => {
1574
- if (/^https?:\/\//.test(src)) return null;
1575
- if (src.startsWith("/__odori/cue/")) {
1576
- const generated = cueFile(config, src);
1577
- return existsSync13(generated) ? generated : null;
1578
- }
1579
- const candidates = [
1580
- resolve14(config.root, "public", src.replace(/^\//, "")),
1581
- resolve14(config.root, src.replace(/^\//, ""))
1582
- ];
1583
- return candidates.find((candidate) => existsSync13(candidate)) ?? null;
1584
- };
1585
- var volumeFilter = (cue, cues, fps) => {
1586
- const authored = cue.gainPoints ?? [];
1587
- const frames = [
1588
- .../* @__PURE__ */ new Set([
1589
- ...duckEnvelope(cue, cues).map((point) => point.frame),
1590
- ...authored.map((point) => point.frame)
1591
- ])
1592
- ].sort(
1593
- (left, right) => left - right
1594
- );
1595
- const duck = duckEnvelope(cue, cues);
1596
- const points = frames.map((frame) => ({
1597
- seconds: (frame - cue.fromFrame) / fps,
1598
- value: envelopeAtFrame(duck, frame) * (authored.length > 0 ? envelopeAtFrame(authored, frame) : 1) * cue.gain
1599
- }));
1600
- const constant = points.every((point) => point.value === points[0].value);
1601
- if (constant) return `volume=${(points[0]?.value ?? cue.gain).toFixed(4)}`;
1602
- let expression = points[points.length - 1].value.toFixed(4);
1603
- for (let index = points.length - 1; index > 0; index -= 1) {
1604
- const previous = points[index - 1];
1605
- const current = points[index];
1606
- const span = current.seconds - previous.seconds;
1607
- const segment = span <= 0 ? current.value.toFixed(4) : `${previous.value.toFixed(4)}+${(current.value - previous.value).toFixed(4)}*(t-${previous.seconds.toFixed(
1608
- 4
1609
- )})/${span.toFixed(4)}`;
1610
- expression = `if(lt(t,${current.seconds.toFixed(4)}),${segment},${expression})`;
1611
- }
1612
- return `volume=volume='${expression}':eval=frame`;
1613
- };
1614
- var buildAudioFilter = (inputs, options) => {
1615
- const { fps, durationInFrames, targetLufs } = options;
1616
- const totalSeconds = durationInFrames / fps;
1617
- const cues = inputs.map(({ cue }) => cue);
1618
- const parts = [];
1619
- const labels = [];
1620
- inputs.forEach(({ cue }, index) => {
1621
- const start = cue.trimStartSeconds;
1622
- const length2 = cue.durationInFrames / fps;
1623
- const delay = Math.round(cue.fromFrame / fps * 1e3);
1624
- const volume = volumeFilter(cue, cues, fps);
1625
- const label = `a${index}`;
1626
- const chain = [
1627
- // Index 1 is the video input, so audio inputs start at 1.
1628
- cue.loop ? `aloop=loop=-1:size=2147483647` : null,
1629
- // Every input is brought to one format before anything else touches it.
1630
- // Cues are mono, files are usually stereo, and a graph that leaves the
1631
- // difference to be inferred works on the encoder that happens to be
1632
- // installed and fails on the pinned one.
1633
- "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo",
1634
- `atrim=start=${start.toFixed(4)}:duration=${length2.toFixed(4)}`,
1635
- "asetpts=PTS-STARTPTS",
1636
- cue.fadeInFrames > 0 ? `afade=t=in:st=0:d=${(cue.fadeInFrames / fps).toFixed(4)}` : null,
1637
- cue.fadeOutFrames > 0 ? `afade=t=out:st=${Math.max(0, length2 - cue.fadeOutFrames / fps).toFixed(4)}:d=${(cue.fadeOutFrames / fps).toFixed(4)}` : null,
1638
- volume,
1639
- delay > 0 ? `adelay=${delay}|${delay}` : null,
1640
- `apad=whole_dur=${totalSeconds.toFixed(4)}`,
1641
- `atrim=duration=${totalSeconds.toFixed(4)}`
1642
- ].filter(Boolean).join(",");
1643
- parts.push(`[${index + 1}:a]${chain}[${label}]`);
1644
- labels.push(`[${label}]`);
1645
- });
1646
- parts.push(
1647
- `${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[mixed]`,
1648
- // loudnorm resamples to its own rate and can drop the layout on the way
1649
- // out, so the last link states the output format rather than negotiating
1650
- // it with whatever encoder is downstream.
1651
- `[mixed]loudnorm=I=${targetLufs}:TP=-1.5:LRA=11,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[audio]`
1652
- );
1653
- return { filter: parts.join(";"), label: "[audio]" };
1654
- };
1655
-
1656
- // src/chunks.ts
1657
- var length = (chunk) => chunk.end - chunk.start + 1;
1658
- var planChunks = ({
1659
- durationInFrames,
1660
- scenes = [],
1661
- concurrency,
1662
- maxChunkFrames = 120
1663
- }) => {
1664
- if (durationInFrames <= 0) return { chunks: [], lanes: [] };
1665
- const bounded = Math.max(1, Math.min(concurrency, durationInFrames));
1666
- const ordered = [...scenes].filter((scene) => scene.durationInFrames > 0).sort((left, right) => left.start - right.start);
1667
- const spans = [];
1668
- let cursor = 0;
1669
- for (const scene of ordered) {
1670
- if (scene.start > cursor) spans.push({ start: cursor, end: scene.start - 1 });
1671
- const end = Math.min(durationInFrames - 1, scene.start + scene.durationInFrames - 1);
1672
- if (end >= scene.start) spans.push({ start: scene.start, end, sceneId: scene.id });
1673
- cursor = end + 1;
1674
- }
1675
- if (cursor < durationInFrames) spans.push({ start: cursor, end: durationInFrames - 1 });
1676
- const chunks = [];
1677
- for (const span of spans) {
1678
- const target = Math.max(1, Math.min(maxChunkFrames, Math.ceil(durationInFrames / bounded)));
1679
- const total = span.end - span.start + 1;
1680
- const pieces = Math.max(1, Math.ceil(total / target));
1681
- const size = Math.ceil(total / pieces);
1682
- for (let piece = 0; piece < pieces; piece += 1) {
1683
- const start = span.start + piece * size;
1684
- const end = Math.min(span.end, start + size - 1);
1685
- if (start > end) continue;
1686
- chunks.push({ index: chunks.length, start, end, sceneId: span.sceneId });
1687
- }
1688
- }
1689
- const lanes = Array.from({ length: bounded }, () => []);
1690
- const loads = new Array(bounded).fill(0);
1691
- for (const chunk of [...chunks].sort((left, right) => length(right) - length(left))) {
1692
- let lane = 0;
1693
- for (let index = 1; index < bounded; index += 1) if (loads[index] < loads[lane]) lane = index;
1694
- lanes[lane].push(chunk);
1695
- loads[lane] += length(chunk);
1696
- }
1697
- for (const lane of lanes) lane.sort((left, right) => left.start - right.start);
1698
- return { chunks, lanes: lanes.filter((lane) => lane.length > 0) };
1699
- };
1700
- var chunkFrames = (chunk) => Array.from({ length: length(chunk) }, (_, offset) => chunk.start + offset);
1701
-
1702
- // src/chunk-cache.ts
1703
- import { existsSync as existsSync14 } from "fs";
1704
- import { copyFile, mkdir as mkdir11, readFile as readFile11, writeFile as writeFile12 } from "fs/promises";
1705
- import { join as join5, resolve as resolve15 } from "path";
1706
- import { hashValue as hashValue2 } from "odori";
1707
- var cacheDir2 = (config) => resolve15(config.root, config.outDir, "cache", "chunks");
1708
- var chunkKey = (identity) => hashValue2({
1709
- videoId: identity.videoId,
1710
- // The browser that drew the frames is part of what the frames are. Without
1711
- // it, upgrading Chrome silently reuses pixels the new build would not have
1712
- // produced, which is the exact drift the pinned toolchain exists to stop.
1713
- renderer: identity.renderer ?? null,
1714
- // A chunk is an encoded file, not a bag of frames: H.264 chunks cannot be
1715
- // copied into a WebM, so a cache that ignored the codec would hand the
1716
- // muxer streams it cannot write.
1717
- format: identity.format ?? null,
1718
- sceneId: identity.chunk.sceneId ?? null,
1719
- start: identity.chunk.start,
1720
- end: identity.chunk.end,
1721
- width: identity.width,
1722
- height: identity.height,
1723
- fps: identity.fps,
1724
- preset: identity.preset,
1725
- quality: identity.quality ?? null,
1726
- scale: identity.scale ?? null,
1727
- input: identity.input ?? null
1728
- });
1729
- var readChunkRecord = async (config, key) => {
1730
- const meta = join5(cacheDir2(config), `${key}.json`);
1731
- const media = join5(cacheDir2(config), `${key}.mp4`);
1732
- if (!existsSync14(meta) || !existsSync14(media)) return null;
1733
- try {
1734
- return JSON.parse(await readFile11(meta, "utf8"));
1735
- } catch {
1736
- return null;
1737
- }
1738
- };
1739
- var useChunkRecord = async (config, key, destination) => {
1740
- await copyFile(join5(cacheDir2(config), `${key}.mp4`), destination);
1741
- };
1742
- var writeChunkRecord = async (config, key, signatures, file) => {
1743
- const directory2 = cacheDir2(config);
1744
- await mkdir11(directory2, { recursive: true });
1745
- await copyFile(file, join5(directory2, `${key}.mp4`));
1746
- const record = { key, signatures, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1747
- await writeFile12(join5(directory2, `${key}.json`), `${JSON.stringify(record)}
1748
- `, "utf8");
1749
- };
1750
- var signaturesMatch = (recorded, observed) => recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
1751
-
1752
- // src/render.ts
1753
- var encodeParam = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
1754
- var renderUrl = (origin, target, frame) => {
1755
- const params = new URLSearchParams({ render: "1", video: target.videoId, frame: String(frame) });
1756
- if (target.input) params.set("input", encodeParam(target.input));
1757
- if (target.prepared !== void 0) params.set("prepared", encodeParam(target.prepared));
1758
- return `${origin}/?${params.toString()}`;
1759
- };
1760
- var openRenderPage = async (origin, target, config) => {
1761
- const executablePath = await browserExecutable(config);
1762
- const browser = await chromium.launch({ executablePath, headless: true });
1763
- const page = await browser.newPage({
1764
- viewport: { width: target.width, height: target.height },
1765
- deviceScaleFactor: 1
1766
- });
1767
- const errors = [];
1768
- page.on("pageerror", (error) => errors.push(error.message));
1769
- await page.goto(renderUrl(origin, target, 0), { waitUntil: "networkidle" });
1770
- try {
1771
- await page.locator('[data-odori-frame="0"]').waitFor({ timeout: 2e4 });
1772
- } catch {
1773
- await browser.close();
1774
- throw new Error(`The video did not mount.${errors.length ? ` ${errors.join(" ")}` : ""}`);
1775
- }
1776
- await page.evaluate(() => document.fonts.ready);
1777
- return { browser, page, errors };
1778
- };
1779
- var seekTo = async (page, frame) => {
1780
- await page.evaluate((next) => window.__ODORI_SET_FRAME__?.(next), frame);
1781
- await page.locator(`[data-odori-frame="${frame}"]`).waitFor({ timeout: 2e4 });
1339
+ var seekTo = async (page, frame, errors) => {
1340
+ await page.evaluate((next) => window.__ODORI_SET_FRAME__?.(next), frame);
1341
+ try {
1342
+ await page.locator(`[data-odori-frame="${frame}"]`).waitFor({ timeout: 2e4 });
1343
+ } catch (error) {
1344
+ const reported = errors?.length ? ` ${[...new Set(errors)].join(" ")}` : "";
1345
+ throw new Error(`Frame ${frame} never became ready.${reported}`, { cause: error });
1346
+ }
1782
1347
  };
1783
1348
  var readTimeline = async (page) => page.evaluate(() => window.__ODORI_TIMELINE__ ?? { scenes: [], durationInFrames: 0 });
1784
1349
  var readAudio = async (page) => page.evaluate(() => window.__ODORI_AUDIO__ ?? { cues: [], durationInFrames: 0 });
@@ -1889,11 +1454,11 @@ Run "odori install" when you have a connection, or set ffmpegPath in odori.confi
1889
1454
  var ensureFfmpeg = async (config) => {
1890
1455
  await ffmpegExecutable(config);
1891
1456
  };
1892
- var renderStill = async (origin, target, frame, output, config) => {
1893
- const { browser, page, errors } = await openRenderPage(origin, target, config);
1457
+ var renderStill = async (origin, target, frame, output, config, graphics = DEFAULT_GRAPHICS) => {
1458
+ const { browser, page, errors } = await openRenderPage(origin, target, config, graphics);
1894
1459
  try {
1895
- await mkdir12(dirname5(output), { recursive: true });
1896
- await seekTo(page, frame);
1460
+ await mkdir9(dirname5(output), { recursive: true });
1461
+ await seekTo(page, frame, errors);
1897
1462
  await page.screenshot({ path: output });
1898
1463
  if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
1899
1464
  return output;
@@ -1914,7 +1479,7 @@ var sequencePattern = (output) => {
1914
1479
  const dot = output.lastIndexOf(".");
1915
1480
  const stem = dot > 0 ? output.slice(0, dot) : output;
1916
1481
  const extension = dot > 0 ? output.slice(dot) : ".png";
1917
- return join6(stem, `%05d${extension}`);
1482
+ return join4(stem, `%05d${extension}`);
1918
1483
  };
1919
1484
  var LOSSLESS = {
1920
1485
  name: "lossless",
@@ -1949,11 +1514,11 @@ var openChunkEncoder = (ffmpeg, file, fps, encode, format, signal) => {
1949
1514
  return { child, done };
1950
1515
  };
1951
1516
  var captureLane = async (origin, target, config, lane, stats, options) => {
1952
- let session = await openRenderPage(origin, target, config);
1517
+ let session = await openRenderPage(origin, target, config, options.graphics);
1953
1518
  const errors = session.errors;
1954
1519
  const reopen = async () => {
1955
1520
  await session.browser.close().catch(() => void 0);
1956
- session = await openRenderPage(origin, target, config);
1521
+ session = await openRenderPage(origin, target, config, options.graphics);
1957
1522
  session.errors.push(...errors);
1958
1523
  };
1959
1524
  const signatureOf = async () => await session.page.evaluate(SIGNATURE_SCRIPT);
@@ -1988,7 +1553,7 @@ var captureLane = async (origin, target, config, lane, stats, options) => {
1988
1553
  if (options.signal?.aborted) throw new Error("Render cancelled.");
1989
1554
  for (let attempt = 0; ; attempt += 1) {
1990
1555
  try {
1991
- await seekTo(session.page, frame);
1556
+ await seekTo(session.page, frame, session.errors);
1992
1557
  const signature = await signatureOf();
1993
1558
  let image;
1994
1559
  if (options.skipUnchanged && previousFrame && signature === previousSignature) {
@@ -2032,147 +1597,816 @@ var captureLane = async (origin, target, config, lane, stats, options) => {
2032
1597
  throw new Error(`The page reported an error during capture: ${session.errors.slice(0, 3).join(" ")}`);
2033
1598
  }
2034
1599
  }
2035
- };
2036
- var renderMovie = async (origin, target, output, config, onProgress, options = {}) => {
2037
- const ffmpeg = await ffmpegExecutable(config);
2038
- const browserPath = await browserExecutable(config);
2039
- const renderer = (await resolveBrowser(config))?.version ?? browserPath;
2040
- const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
2041
- const encode = {
2042
- preset: options.preset ?? config.preset ?? "medium",
2043
- quality: options.quality ?? "studio",
2044
- scale: options.scale ?? 1
1600
+ };
1601
+ var renderMovie = async (origin, target, output, config, onProgress, options = {}) => {
1602
+ const ffmpeg = await ffmpegExecutable(config);
1603
+ const browserPath = await browserExecutable(config);
1604
+ const graphics = options.graphics ?? DEFAULT_GRAPHICS;
1605
+ const renderer = `${(await resolveBrowser(config))?.version ?? browserPath}:${graphics}`;
1606
+ const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
1607
+ const encode = {
1608
+ preset: options.preset ?? config.preset ?? "medium",
1609
+ quality: options.quality ?? "studio",
1610
+ scale: options.scale ?? 1
1611
+ };
1612
+ const format = options.format ?? FORMATS.mp4;
1613
+ const chunkable = format.chunked;
1614
+ const chunkFormat = chunkable ? format : LOSSLESS;
1615
+ const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
1616
+ const cache = options.cache ?? config.cacheChunks ?? true;
1617
+ const work = options.workDir ?? resolve11(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
1618
+ await mkdir9(work, { recursive: true });
1619
+ const concurrency = chunkable ? requested : 1;
1620
+ const { chunks, lanes } = planChunks({
1621
+ durationInFrames: target.durationInFrames,
1622
+ scenes: target.scenes,
1623
+ concurrency
1624
+ });
1625
+ const chunkFile = (chunk) => join4(work, `chunk-${String(chunk.index).padStart(4, "0")}${chunkable ? format.extension : ".mkv"}`);
1626
+ const stats = { captured: 0, reused: 0, cachedChunks: 0 };
1627
+ let succeeded = false;
1628
+ try {
1629
+ await mkdir9(dirname5(output), { recursive: true });
1630
+ const captureStart = performance.now();
1631
+ await Promise.all(
1632
+ lanes.map(
1633
+ (lane) => captureLane(origin, target, config, lane, stats, {
1634
+ skipUnchanged,
1635
+ graphics,
1636
+ signal: options.signal,
1637
+ encode,
1638
+ format: chunkFormat,
1639
+ ffmpeg,
1640
+ workDir: work,
1641
+ chunkFile,
1642
+ cacheIdentity: cache ? (chunk) => ({
1643
+ videoId: target.videoId,
1644
+ chunk,
1645
+ renderer,
1646
+ format: chunkFormat.name,
1647
+ width: target.width,
1648
+ height: target.height,
1649
+ fps: target.fps,
1650
+ preset: encode.preset,
1651
+ // Both change the encoded bytes, so both are part of what a
1652
+ // chunk is: a half-size chunk must never answer for a full
1653
+ // one. A lossless intermediate is the exception by design —
1654
+ // the final pass applies them, so one capture serves all.
1655
+ quality: chunkable ? encode.quality : void 0,
1656
+ scale: chunkable ? encode.scale : void 0,
1657
+ input: target.input
1658
+ }) : void 0,
1659
+ onFrame: () => onProgress?.(stats.captured / Math.max(1, target.durationInFrames), "rendering")
1660
+ })
1661
+ )
1662
+ );
1663
+ const captureMs = performance.now() - captureStart;
1664
+ onProgress?.(1, "encoding");
1665
+ const mixInputs = (options.audio === false ? [] : target.audio ?? []).map((cue) => {
1666
+ const file = resolveCueFile(config, cue.src);
1667
+ if (!file) log.warn(`Skipping audio cue ${cue.src}: only project-local files can be encoded.`);
1668
+ return file ? { file, cue } : null;
1669
+ }).filter((item) => item !== null);
1670
+ const muxStart = performance.now();
1671
+ const ordered = chunks.map(chunkFile);
1672
+ const silent = join4(work, `video${chunkable ? format.extension : ".mkv"}`);
1673
+ if (ordered.length === 1) {
1674
+ await copyFile2(ordered[0], silent);
1675
+ } else {
1676
+ const list = join4(work, "chunks.txt");
1677
+ await writeFile10(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
1678
+ await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
1679
+ }
1680
+ if (!chunkable) {
1681
+ const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
1682
+ if (destination !== output) await mkdir9(dirname5(destination), { recursive: true });
1683
+ await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
1684
+ if (mixInputs.length > 0) {
1685
+ log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
1686
+ }
1687
+ } else if (mixInputs.length === 0 || !format.audio) {
1688
+ const faststart = format.extension === ".mp4" || format.extension === ".mov";
1689
+ await run(
1690
+ ffmpeg,
1691
+ ["-y", "-i", silent, "-c", "copy", ...faststart ? ["-movflags", "+faststart"] : [], output],
1692
+ options.signal
1693
+ );
1694
+ } else {
1695
+ const { filter, label } = buildAudioFilter(mixInputs, {
1696
+ fps: target.fps,
1697
+ durationInFrames: target.durationInFrames,
1698
+ targetLufs: target.targetLufs ?? -14
1699
+ });
1700
+ const args = ["-y", "-i", silent];
1701
+ for (const { cue, file } of mixInputs) args.push(...cue.loop ? ["-stream_loop", "-1"] : [], "-i", file);
1702
+ args.push(
1703
+ "-filter_complex",
1704
+ filter,
1705
+ "-map",
1706
+ "0:v",
1707
+ "-map",
1708
+ label,
1709
+ "-c:v",
1710
+ "copy",
1711
+ // WebM cannot carry AAC; every other container here can.
1712
+ "-c:a",
1713
+ format.extension === ".webm" ? "libopus" : "aac",
1714
+ "-b:a",
1715
+ "192k",
1716
+ "-ar",
1717
+ "48000",
1718
+ "-shortest",
1719
+ ...format.extension === ".mp4" || format.extension === ".mov" ? ["-movflags", "+faststart"] : [],
1720
+ output
1721
+ );
1722
+ await run(ffmpeg, args, options.signal);
1723
+ }
1724
+ options.onTimings?.({
1725
+ captureMs: Math.round(captureMs),
1726
+ encodeMs: Math.round(performance.now() - muxStart),
1727
+ frames: target.durationInFrames,
1728
+ reusedFrames: stats.reused,
1729
+ cachedChunks: stats.cachedChunks,
1730
+ chunks: chunks.length,
1731
+ concurrency: lanes.length
1732
+ });
1733
+ succeeded = true;
1734
+ return output;
1735
+ } finally {
1736
+ if (succeeded && !options.workDir) await rm4(work, { recursive: true, force: true });
1737
+ else if (!succeeded) log.detail(`Chunks left for inspection in ${work}`);
1738
+ }
1739
+ };
1740
+
1741
+ // src/commands/bed.ts
1742
+ var STEM_LUFS = -20;
1743
+ var STEM_PEAK = -1.5;
1744
+ var run2 = (command2, args) => new Promise((resolveRun, rejectRun) => {
1745
+ const child = spawn3(command2, args, { stdio: ["ignore", "ignore", "pipe"] });
1746
+ let stderr = "";
1747
+ child.stderr?.on("data", (chunk) => {
1748
+ stderr += chunk.toString();
1749
+ });
1750
+ child.on("error", rejectRun);
1751
+ child.on("exit", (code) => {
1752
+ if (code === 0) resolveRun(stderr);
1753
+ else rejectRun(new Error(`ffmpeg exited with ${code}: ${stderr.slice(-800)}`));
1754
+ });
1755
+ });
1756
+ var measure = async (ffmpeg, file) => {
1757
+ const stderr = await run2(ffmpeg, [
1758
+ "-i",
1759
+ file,
1760
+ "-af",
1761
+ `loudnorm=I=${STEM_LUFS}:TP=${STEM_PEAK}:print_format=json`,
1762
+ "-f",
1763
+ "null",
1764
+ "-"
1765
+ ]);
1766
+ const start = stderr.lastIndexOf("{");
1767
+ const end = stderr.lastIndexOf("}");
1768
+ if (start === -1 || end === -1) throw new Error(`Could not read loudness from ${basename2(file)}.`);
1769
+ return JSON.parse(stderr.slice(start, end + 1));
1770
+ };
1771
+ var round = (value) => Number(value).toFixed(1);
1772
+ var generateSource = async (config, prompt, options) => {
1773
+ const provider = resolveMusicProvider(options.provider ?? config.generation?.music ?? "elevenlabs");
1774
+ const apiKey = await resolveKey(provider);
1775
+ if (!apiKey) {
1776
+ throw new Error(
1777
+ `Generating with ${provider.name} needs a key: set ${provider.keyVariable} in the environment,
1778
+ or paste one once into Studio\u2019s integrations page ("odori dev", then Integrations).
1779
+ Either way it is sent only to the provider and never stored in the project.`
1780
+ );
1781
+ }
1782
+ const seconds = options.seconds ?? 60;
1783
+ log.detail(`Generating ${seconds}s with ${provider.name}`);
1784
+ const { bytes, extension } = await provider.generate({ prompt, seconds, apiKey });
1785
+ const stem = options.output ? basename2(options.output, extname(options.output)) : prompt.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "generated";
1786
+ const directory2 = options.output ? dirname6(resolve12(config.root, options.output)) : join5(config.root, "public", "audio");
1787
+ await mkdir10(directory2, { recursive: true });
1788
+ const source = join5(directory2, `${stem}-source.${extension}`);
1789
+ await writeFile11(source, bytes);
1790
+ log.detail(` kept the original at ${basename2(source)} (${(bytes.length / 1024).toFixed(0)} KB)`);
1791
+ return source;
1792
+ };
1793
+ var prepareBed = async (config, input, options = {}) => {
1794
+ const source = options.generate ? await generateSource(config, input, options) : resolve12(config.root, input);
1795
+ if (!options.generate) {
1796
+ await stat(source).catch(() => {
1797
+ throw new Error(`No file at ${input}.`);
1798
+ });
1799
+ }
1800
+ const ffmpeg = await ffmpegExecutable(config);
1801
+ const target = options.target ?? STEM_LUFS;
1802
+ const name = basename2(source, extname(source)).replace(/-source$/, "");
1803
+ const destination = options.output ? resolve12(config.root, options.output) : join5(config.root, "public", "audio", `${name}.m4a`);
1804
+ await mkdir10(dirname6(destination), { recursive: true });
1805
+ log.detail(`Measuring ${basename2(source)}`);
1806
+ const measured = await measure(ffmpeg, source);
1807
+ await run2(ffmpeg, [
1808
+ "-y",
1809
+ "-i",
1810
+ source,
1811
+ "-af",
1812
+ `loudnorm=I=${target}:TP=${STEM_PEAK}:measured_I=${measured.input_i}:measured_TP=${measured.input_tp}:measured_LRA=${measured.input_lra}:measured_thresh=${measured.input_thresh}:linear=true`,
1813
+ "-c:a",
1814
+ "aac",
1815
+ "-b:a",
1816
+ "192k",
1817
+ destination
1818
+ ]);
1819
+ const after = await measure(ffmpeg, destination);
1820
+ const bytes = (await stat(destination)).size;
1821
+ const before = { lufs: Number(measured.input_i), peak: Number(measured.input_tp), range: Number(measured.input_lra) };
1822
+ const warnings = [];
1823
+ if (before.range < 4) {
1824
+ warnings.push(
1825
+ `A range of ${round(before.range)} LU is very compressed. Mastered music sits around 5 to 8, and film score higher. Levelling cannot restore range a recording does not have; this bed will sit flat under the cut.`
1826
+ );
1827
+ }
1828
+ if (before.peak > 0) {
1829
+ warnings.push(`The source peaked at ${round(before.peak)} dBTP, which is above full scale.`);
1830
+ }
1831
+ const role = options.role ?? `bed.${name.replace(/^bed[-.]?/, "") || "main"}`;
1832
+ const publicDir = join5(config.root, "public");
1833
+ const relativeToPublic = relative6(publicDir, destination);
1834
+ const url = relativeToPublic.startsWith("..") ? null : "/" + relativeToPublic.split("\\").join("/");
1835
+ const registered = url ? await registerCueInBrand(config, { name: role, url }, name) : null;
1836
+ return { source, destination, role, url, registered, before, after: { lufs: Number(after.input_i), peak: Number(after.input_tp) }, bytes, warnings };
1837
+ };
1838
+ var bedCommand = async (input, options = {}) => {
1839
+ const config = await loadConfig(process.cwd());
1840
+ const report = await prepareBed(config, input, options);
1841
+ log.info(`Prepared ${basename2(report.destination)}`);
1842
+ log.detail(` loudness ${round(report.before.lufs)} \u2192 ${round(report.after.lufs)} LUFS`);
1843
+ log.detail(` peak ${round(report.before.peak)} \u2192 ${round(report.after.peak)} dBTP`);
1844
+ log.detail(` range ${round(report.before.range)} LU`);
1845
+ log.detail(` size ${(report.bytes / 1024).toFixed(0)} KB`);
1846
+ for (const warning of report.warnings) {
1847
+ log.detail("");
1848
+ log.detail(` ${warning}`);
1849
+ }
1850
+ log.detail("");
1851
+ if (report.registered?.already) {
1852
+ log.detail(` "${report.role}" is already registered in ${report.registered.file}`);
1853
+ } else if (report.registered) {
1854
+ log.detail(` registered "${report.role}" in ${report.registered.file}`);
1855
+ } else if (report.url) {
1856
+ log.detail(" No brand with an audio cues block was found. Register it by hand:");
1857
+ log.detail(` audio: {cues: {"${report.role}": "${report.url}"}}`);
1858
+ } else {
1859
+ log.detail(" The output is outside public/, so it cannot be registered or served.");
1860
+ }
1861
+ log.detail(" Place it in a video:");
1862
+ log.detail(` <Audio src="${report.role}" fadeIn="1s" fadeOut="1.5s" duckUnder />`);
1863
+ };
1864
+
1865
+ // src/jobs.ts
1866
+ import { mkdir as mkdir11, readFile as readFile9, readdir as readdir3, rename, writeFile as writeFile12 } from "fs/promises";
1867
+ import { existsSync as existsSync12 } from "fs";
1868
+ import { join as join6, resolve as resolve13 } from "path";
1869
+ var buildsDir = (config) => resolve13(config.root, config.outDir, "builds");
1870
+ var jobFile = (config, id) => join6(buildsDir(config), `${id}.json`);
1871
+ var createJob = async (config, manifest, output, render) => {
1872
+ await mkdir11(buildsDir(config), { recursive: true });
1873
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1874
+ const job = {
1875
+ id: `job-${manifest.manifestHash.slice(0, 10)}-${Date.now().toString(36)}`,
1876
+ videoId: manifest.videoId,
1877
+ manifestHash: manifest.manifestHash,
1878
+ status: "queued",
1879
+ progress: 0,
1880
+ attempts: 0,
1881
+ logs: [],
1882
+ createdAt: now,
1883
+ updatedAt: now
1884
+ };
1885
+ const record = { job, manifest, output, ...render ? { render } : {} };
1886
+ await writeFile12(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
1887
+ `, "utf8");
1888
+ return record;
1889
+ };
1890
+ var readJob = async (config, id) => {
1891
+ const file = jobFile(config, id);
1892
+ if (!existsSync12(file)) throw new Error(`Unknown job "${id}". Run odori jobs to list them.`);
1893
+ return JSON.parse(await readFile9(file, "utf8"));
1894
+ };
1895
+ var writeLocks = /* @__PURE__ */ new Map();
1896
+ var withJobLock = (id, task) => {
1897
+ const previous = writeLocks.get(id) ?? Promise.resolve();
1898
+ const next = previous.then(task, task);
1899
+ writeLocks.set(
1900
+ id,
1901
+ next.catch(() => void 0)
1902
+ );
1903
+ return next;
1904
+ };
1905
+ var writeRecord = async (config, record) => {
1906
+ const file = jobFile(config, record.job.id);
1907
+ const temporary = `${file}.${process.pid}.tmp`;
1908
+ await writeFile12(temporary, `${JSON.stringify(record, null, 2)}
1909
+ `, "utf8");
1910
+ await rename(temporary, file);
1911
+ };
1912
+ var updateJob = async (config, job) => withJobLock(job.id, async () => {
1913
+ const record = await readJob(config, job.id);
1914
+ const next = { ...job, logs: record.job.logs, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1915
+ await writeRecord(config, { ...record, job: next });
1916
+ return next;
1917
+ });
1918
+ var appendJobLog = async (config, id, message2) => withJobLock(id, async () => {
1919
+ const record = await readJob(config, id);
1920
+ const next = {
1921
+ ...record.job,
1922
+ logs: [...record.job.logs.slice(-49), `${(/* @__PURE__ */ new Date()).toISOString()} ${message2}`],
1923
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1924
+ };
1925
+ await writeRecord(config, { ...record, job: next });
1926
+ return next;
1927
+ });
1928
+ var alive = (pid) => {
1929
+ if (!pid) return false;
1930
+ try {
1931
+ process.kill(pid, 0);
1932
+ return true;
1933
+ } catch {
1934
+ return false;
1935
+ }
1936
+ };
1937
+ var reconcileJobs = async (config) => {
1938
+ const stale = (await listJobs(config, { reconcile: false })).filter(
1939
+ (job) => (job.status === "rendering" || job.status === "encoding") && !alive(job.pid)
1940
+ );
1941
+ for (const job of stale) {
1942
+ await updateJob(config, {
1943
+ ...job,
1944
+ status: "failed",
1945
+ error: "The render process exited before the job finished."
1946
+ });
1947
+ }
1948
+ return stale.length;
1949
+ };
1950
+ var listJobs = async (config, options = {}) => {
1951
+ if (!existsSync12(buildsDir(config))) return [];
1952
+ if (options.reconcile !== false) await reconcileJobs(config);
1953
+ const files = (await readdir3(buildsDir(config))).filter((file) => file.endsWith(".json"));
1954
+ const jobs = [];
1955
+ for (const file of files) {
1956
+ try {
1957
+ const raw = await readFile9(join6(buildsDir(config), file), "utf8");
1958
+ jobs.push(JSON.parse(raw).job);
1959
+ } catch {
1960
+ continue;
1961
+ }
1962
+ }
1963
+ return jobs.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
1964
+ };
1965
+ var JobQueue = class {
1966
+ chain = Promise.resolve();
1967
+ enqueue(task) {
1968
+ const result = this.chain.then(task, task);
1969
+ this.chain = result.catch(() => void 0);
1970
+ return result;
1971
+ }
1972
+ };
1973
+
1974
+ // src/discovery.ts
1975
+ import { mkdir as mkdir12, readdir as readdir4, readFile as readFile10, stat as stat2, writeFile as writeFile13 } from "fs/promises";
1976
+ import { existsSync as existsSync13 } from "fs";
1977
+ import { join as join7, relative as relative7, resolve as resolve14, sep as sep2 } from "path";
1978
+ import { hashString as hashString3 } from "odori";
1979
+ var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
1980
+ var walk = async (directory2, files = []) => {
1981
+ const entries = await readdir4(directory2, { withFileTypes: true });
1982
+ for (const entry of entries) {
1983
+ if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
1984
+ const full = join7(directory2, entry.name);
1985
+ if (entry.isDirectory()) await walk(full, files);
1986
+ else files.push(full);
1987
+ }
1988
+ return files;
1989
+ };
1990
+ var toIdentifier = (value, prefix) => {
1991
+ const cleaned = value.replace(
1992
+ /[^a-zA-Z0-9]+(.)?/g,
1993
+ (_, character) => character ? character.toUpperCase() : ""
1994
+ );
1995
+ return `${prefix}${cleaned.charAt(0).toUpperCase()}${cleaned.slice(1)}`;
1996
+ };
1997
+ var AUDIO_EXTENSIONS = /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i;
1998
+ var discoverAudio = async (config) => {
1999
+ const root = resolve14(config.root, config.audioDir);
2000
+ if (!existsSync13(root)) return [];
2001
+ const publicRoot = resolve14(config.root, "public");
2002
+ const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
2003
+ return Promise.all(
2004
+ files.map(async (file) => ({
2005
+ name: relative7(root, file).replace(AUDIO_EXTENSIONS, "").split(sep2).join("/"),
2006
+ url: file.startsWith(`${publicRoot}${sep2}`) ? `/${relative7(publicRoot, file).split(sep2).join("/")}` : `/${relative7(config.root, file).split(sep2).join("/")}`,
2007
+ relativeFile: relative7(config.root, file),
2008
+ bytes: (await stat2(file)).size
2009
+ }))
2010
+ );
2011
+ };
2012
+ var discoverProject = async (config) => {
2013
+ const videosRoot = resolve14(config.root, config.videosDir);
2014
+ if (!existsSync13(videosRoot)) {
2015
+ throw new Error(`No ${config.videosDir}/ directory found in ${config.root}. Run "odori init" first.`);
2016
+ }
2017
+ const files = (await walk(videosRoot)).sort();
2018
+ const audio = await discoverAudio(config);
2019
+ const videos = [];
2020
+ const previews = [];
2021
+ const brands = [];
2022
+ const categories = [];
2023
+ const hashParts = [];
2024
+ const importedBy = {};
2025
+ const componentsRoot = resolve14(config.root, config.componentsDir);
2026
+ for (const file of files) {
2027
+ const relativeFile = relative7(config.root, file);
2028
+ let contents = "";
2029
+ if (/\.(tsx|ts|css|json)$/.test(file)) {
2030
+ contents = await readFile10(file, "utf8");
2031
+ hashParts.push(`${relativeFile}:${hashString3(contents)}`);
2032
+ }
2033
+ const base = file.split(sep2).pop() ?? "";
2034
+ if (base === "category.json") {
2035
+ const path = relative7(componentsRoot, resolve14(file, "..")).split(sep2).join("/");
2036
+ if (!path.startsWith("..")) {
2037
+ try {
2038
+ const declared = JSON.parse(contents);
2039
+ categories.push({
2040
+ path,
2041
+ ...typeof declared.name === "string" ? { name: declared.name } : {},
2042
+ ...typeof declared.order === "number" ? { order: declared.order } : {}
2043
+ });
2044
+ } catch (error) {
2045
+ log.warn(
2046
+ `${relativeFile} is not valid JSON, so that directory names itself: ${error instanceof Error ? error.message : String(error)}`
2047
+ );
2048
+ }
2049
+ }
2050
+ }
2051
+ if (base === "video.tsx") {
2052
+ for (const match of contents.matchAll(/from\s+["'][^"']*\/components\/([^/"']+)\//g)) {
2053
+ (importedBy[match[1]] ??= /* @__PURE__ */ new Set()).add(relative7(videosRoot, file).replace(/\/?video\.tsx$/, "") || "video");
2054
+ }
2055
+ }
2056
+ if (base === "video.tsx") {
2057
+ const slug = relative7(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
2058
+ videos.push({
2059
+ slug,
2060
+ file,
2061
+ relativeFile,
2062
+ importPath: file,
2063
+ identifier: toIdentifier(slug, "video")
2064
+ });
2065
+ } else if (
2066
+ // A brand module is recognized by what it does, not where it sits. The
2067
+ // scaffold defines its brand in videos/layout.tsx, so a directory-name
2068
+ // rule alone left the default project's brand invisible to everything
2069
+ // that reads this list — most visibly the dev server's cue registry,
2070
+ // which then answered new cue URLs with 404s until a restart. Installed
2071
+ // component source is excluded the way brand-file.ts excludes it: a
2072
+ // component may mention defineBrand without being where a brand lives.
2073
+ /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx") && !file.startsWith(componentsRoot + sep2) && (file.split(sep2).includes("brands") || contents.includes("defineBrand("))
2074
+ ) {
2075
+ const name = base.replace(/\.tsx?$/, "");
2076
+ brands.push({
2077
+ name,
2078
+ file,
2079
+ relativeFile,
2080
+ // From the whole relative path, like previews: basenames repeat
2081
+ // (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
2082
+ identifier: toIdentifier(
2083
+ `${relative7(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
2084
+ "brands"
2085
+ )
2086
+ });
2087
+ } else if (base.endsWith(".preview.tsx")) {
2088
+ const name = base.replace(/\.preview\.tsx$/, "");
2089
+ previews.push({
2090
+ name,
2091
+ file,
2092
+ relativeFile,
2093
+ importPath: file,
2094
+ identifier: toIdentifier(`${relative7(videosRoot, file).split(sep2).join("-")}`, "preview")
2095
+ });
2096
+ }
2097
+ }
2098
+ for (const preview of previews) {
2099
+ const users = importedBy[preview.name];
2100
+ if (users) preview.usedBy = [...users].sort();
2101
+ }
2102
+ return { videos, previews, brands, audio, categories, sourceHash: hashString3(hashParts.join("|")) };
2103
+ };
2104
+ var generateImports = (graph, outDir) => {
2105
+ const importPath = (file) => {
2106
+ const relativePath = relative7(outDir, file).split(sep2).join("/");
2107
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
2108
+ };
2109
+ const lines = [
2110
+ "// Generated by odori. Do not edit.",
2111
+ 'import type {VideoEntry} from "odori";',
2112
+ "",
2113
+ ...graph.videos.map(
2114
+ (video) => `import ${video.identifier}, {metadata as ${video.identifier}Metadata} from "${importPath(video.file)}";`
2115
+ ),
2116
+ ...graph.previews.map((preview) => `import ${preview.identifier} from "${importPath(preview.file)}";`),
2117
+ ...graph.brands.map((brand) => `import * as ${brand.identifier} from "${importPath(brand.file)}";`),
2118
+ "",
2119
+ "export const videos: VideoEntry[] = [",
2120
+ ...graph.videos.map(
2121
+ (video) => ` {component: ${video.identifier}, metadata: {...${video.identifier}Metadata, id: ${video.identifier}Metadata.id || ${JSON.stringify(video.slug)}}},`
2122
+ ),
2123
+ "];",
2124
+ "",
2125
+ "export const componentPreviews = [",
2126
+ ...graph.previews.map((preview) => ` {id: ${JSON.stringify(preview.name)}, preview: ${preview.identifier}},`),
2127
+ "];",
2128
+ "",
2129
+ "export const brands = [",
2130
+ ...graph.brands.map(
2131
+ (brand) => ` ...Object.values(${brand.identifier}).filter((value) => (value as {kind?: string})?.kind === "odori-brand"),`
2132
+ ),
2133
+ "];",
2134
+ ""
2135
+ ];
2136
+ return lines.join("\n");
2137
+ };
2138
+ var writeGenerated = async (config, graph) => {
2139
+ const outDir = resolve14(config.root, config.outDir);
2140
+ await mkdir12(outDir, { recursive: true });
2141
+ const target = join7(outDir, "imports.generated.ts");
2142
+ await writeFile13(target, generateImports(graph, outDir), "utf8");
2143
+ await writeFile13(
2144
+ join7(outDir, "catalog.json"),
2145
+ `${JSON.stringify(
2146
+ {
2147
+ sourceHash: graph.sourceHash,
2148
+ videos: graph.videos.map((video) => ({ slug: video.slug, file: video.relativeFile })),
2149
+ previews: graph.previews.map((preview) => ({
2150
+ name: preview.name,
2151
+ file: preview.relativeFile,
2152
+ usedBy: preview.usedBy ?? []
2153
+ })),
2154
+ brands: graph.brands.map((brand) => ({ name: brand.name, file: brand.relativeFile })),
2155
+ audio: graph.audio.map((entry) => ({ name: entry.name, url: entry.url, file: entry.relativeFile }))
2156
+ },
2157
+ null,
2158
+ 2
2159
+ )}
2160
+ `,
2161
+ "utf8"
2162
+ );
2163
+ return target;
2164
+ };
2165
+
2166
+ // src/project.ts
2167
+ import { resolve as resolve17 } from "path";
2168
+ import { pathToFileURL as pathToFileURL3 } from "url";
2169
+ import {
2170
+ createRenderManifest,
2171
+ entryDurationInFrames,
2172
+ resolveEntryLayout as resolveEntryLayout2,
2173
+ resolveVideoId
2174
+ } from "odori";
2175
+
2176
+ // src/integrity.ts
2177
+ import { createHash as createHash3 } from "crypto";
2178
+ import { existsSync as existsSync14 } from "fs";
2179
+ import { mkdir as mkdir13, readFile as readFile11, writeFile as writeFile14 } from "fs/promises";
2180
+ import { dirname as dirname7, resolve as resolve15 } from "path";
2181
+ var cacheFile = (config) => resolve15(config.root, config.outDir, "cache", "integrity.json");
2182
+ var readCache = async (config) => {
2183
+ const file = cacheFile(config);
2184
+ if (!existsSync14(file)) return {};
2185
+ try {
2186
+ return JSON.parse(await readFile11(file, "utf8"));
2187
+ } catch {
2188
+ return {};
2189
+ }
2190
+ };
2191
+ var writeCache = async (config, cache) => {
2192
+ const file = cacheFile(config);
2193
+ await mkdir13(dirname7(file), { recursive: true });
2194
+ await writeFile14(file, `${JSON.stringify(cache, null, 2)}
2195
+ `, "utf8");
2196
+ };
2197
+ var sha256 = (bytes) => `sha256-${createHash3("sha256").update(bytes).digest("base64")}`;
2198
+ var localCandidates = (config, url) => [
2199
+ resolve15(config.root, "public", url.replace(/^\//, "")),
2200
+ resolve15(config.root, url.replace(/^\//, ""))
2201
+ ];
2202
+ var isServed = (config, file) => file.startsWith(resolve15(config.root, "public") + "/");
2203
+ var createIntegrityResolver = async (config) => {
2204
+ const cache = await readCache(config);
2205
+ const warned = /* @__PURE__ */ new Set();
2206
+ let dirty = false;
2207
+ const resolveIntegrity = async (url) => {
2208
+ if (url.startsWith("/__odori/cue/")) return `cue-${url.slice(url.lastIndexOf("-") + 1).replace(/\.wav$/, "")}`;
2209
+ const local = localCandidates(config, url).find((candidate) => existsSync14(candidate));
2210
+ if (local) {
2211
+ if (!isServed(config, local) && !warned.has(url)) {
2212
+ warned.add(url);
2213
+ log.warn(`${url} resolves to ${local}, which is outside public/ and will not be served. Move it into public/.`);
2214
+ }
2215
+ const bytes = await readFile11(local);
2216
+ const { mtimeMs } = await import("fs/promises").then((fs) => fs.stat(local));
2217
+ const hit = cache[url];
2218
+ if (hit && hit.mtimeMs === mtimeMs && hit.size === bytes.byteLength) return hit.integrity;
2219
+ const integrity = sha256(bytes);
2220
+ cache[url] = { integrity, size: bytes.byteLength, mtimeMs };
2221
+ dirty = true;
2222
+ return integrity;
2223
+ }
2224
+ if (!/^https?:\/\//.test(url)) return "unresolved";
2225
+ if (cache[url]) return cache[url].integrity;
2226
+ try {
2227
+ const response = await fetch(url, { signal: AbortSignal.timeout(1e4) });
2228
+ if (!response.ok) return "unresolved";
2229
+ const bytes = new Uint8Array(await response.arrayBuffer());
2230
+ const integrity = sha256(bytes);
2231
+ cache[url] = { integrity, size: bytes.byteLength };
2232
+ dirty = true;
2233
+ return integrity;
2234
+ } catch {
2235
+ return "unresolved";
2236
+ }
2237
+ };
2238
+ return {
2239
+ resolve: resolveIntegrity,
2240
+ flush: async () => {
2241
+ if (dirty) await writeCache(config, cache);
2242
+ }
2243
+ };
2244
+ };
2245
+
2246
+ // src/prepare-cache.ts
2247
+ import { existsSync as existsSync15 } from "fs";
2248
+ import { mkdir as mkdir14, readFile as readFile12, readdir as readdir5, rm as rm5, writeFile as writeFile15 } from "fs/promises";
2249
+ import { join as join8, resolve as resolve16 } from "path";
2250
+ import { hashValue as hashValue2 } from "odori";
2251
+
2252
+ // src/paths.ts
2253
+ var outputName = (id) => id.split("/").join("-");
2254
+ var fileKey = (id) => id.split("/").join("+");
2255
+
2256
+ // src/prepare-cache.ts
2257
+ var directory = (config) => resolve16(config.root, config.outDir, "cache", "prepare");
2258
+ var prepareCacheKey = (key) => `${fileKey(key.videoId)}__${hashValue2(key)}`;
2259
+ var readPrepareCache = async (config, key) => {
2260
+ const file = join8(directory(config), `${prepareCacheKey(key)}.json`);
2261
+ if (!existsSync15(file)) return { hit: false, value: void 0 };
2262
+ try {
2263
+ const entry = JSON.parse(await readFile12(file, "utf8"));
2264
+ return { hit: true, value: entry.value };
2265
+ } catch {
2266
+ return { hit: false, value: void 0 };
2267
+ }
2268
+ };
2269
+ var writePrepareCache = async (config, key, value) => {
2270
+ if (value === void 0) return;
2271
+ const target = directory(config);
2272
+ await mkdir14(target, { recursive: true });
2273
+ const entry = { key, value, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
2274
+ await writeFile15(join8(target, `${prepareCacheKey(key)}.json`), `${JSON.stringify(entry, null, 2)}
2275
+ `, "utf8");
2276
+ };
2277
+ var clearPrepareCache = async (config, videoId) => {
2278
+ const target = directory(config);
2279
+ if (!existsSync15(target)) return 0;
2280
+ const files = await readdir5(target);
2281
+ const matches = files.filter((file) => videoId ? file.startsWith(`${fileKey(videoId)}__`) : file.endsWith(".json"));
2282
+ await Promise.all(matches.map((file) => rm5(join8(target, file), { force: true })));
2283
+ return matches.length;
2284
+ };
2285
+
2286
+ // src/project.ts
2287
+ var loadVideos = async (graph) => {
2288
+ const loaded = [];
2289
+ for (const discovered of graph.videos) {
2290
+ const module = await import(pathToFileURL3(discovered.file).href);
2291
+ if (!module.default || !module.metadata) {
2292
+ throw new Error(`${discovered.relativeFile} must export metadata and a default React component.`);
2293
+ }
2294
+ const entry = {
2295
+ component: module.default,
2296
+ metadata: { ...module.metadata, id: resolveVideoId(module.metadata.id, discovered.slug) }
2297
+ };
2298
+ loaded.push({
2299
+ entry,
2300
+ file: discovered.file,
2301
+ relativeFile: discovered.relativeFile,
2302
+ durationInFrames: entryDurationInFrames(entry, resolveEntryLayout2(entry))
2303
+ });
2304
+ }
2305
+ const byId = /* @__PURE__ */ new Map();
2306
+ for (const video of loaded) {
2307
+ const id = video.entry.metadata.id;
2308
+ const first = byId.get(id);
2309
+ if (first) throw new Error(`Duplicate video id "${id}":
2310
+ ${first}
2311
+ ${video.relativeFile}`);
2312
+ byId.set(id, video.relativeFile);
2313
+ }
2314
+ return loaded;
2315
+ };
2316
+ var findVideo = (videos, id) => {
2317
+ const found = videos.find((video) => video.entry.metadata.id === id);
2318
+ if (!found) {
2319
+ throw new Error(
2320
+ `Unknown video "${id}". Known videos: ${videos.map((video) => video.entry.metadata.id).join(", ") || "none"}`
2321
+ );
2322
+ }
2323
+ return found;
2324
+ };
2325
+ var runPrepare = async (video, config, graph, input, options = {}) => {
2326
+ const prepareFile = resolve17(video.file, "..", "prepare.ts");
2327
+ let prepare;
2328
+ try {
2329
+ const module = await import(pathToFileURL3(prepareFile).href);
2330
+ prepare = module.prepare ?? module.default;
2331
+ } catch (error) {
2332
+ if (error.code === "ERR_MODULE_NOT_FOUND") return void 0;
2333
+ throw error;
2334
+ }
2335
+ if (!prepare) return void 0;
2336
+ const key = {
2337
+ videoId: video.entry.metadata.id,
2338
+ sourceHash: graph.sourceHash,
2339
+ input,
2340
+ version: prepare.version ?? "1"
2045
2341
  };
2046
- const format = options.format ?? FORMATS.mp4;
2047
- const chunkable = format.chunked;
2048
- const chunkFormat = chunkable ? format : LOSSLESS;
2049
- const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
2050
- const cache = options.cache ?? config.cacheChunks ?? true;
2051
- const work = options.workDir ?? resolve16(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
2052
- await mkdir12(work, { recursive: true });
2053
- const concurrency = chunkable ? requested : 1;
2054
- const { chunks, lanes } = planChunks({
2055
- durationInFrames: target.durationInFrames,
2056
- scenes: target.scenes,
2057
- concurrency
2058
- });
2059
- const chunkFile = (chunk) => join6(work, `chunk-${String(chunk.index).padStart(4, "0")}${chunkable ? format.extension : ".mkv"}`);
2060
- const stats = { captured: 0, reused: 0, cachedChunks: 0 };
2061
- let succeeded = false;
2062
- try {
2063
- await mkdir12(dirname5(output), { recursive: true });
2064
- const captureStart = performance.now();
2065
- await Promise.all(
2066
- lanes.map(
2067
- (lane) => captureLane(origin, target, config, lane, stats, {
2068
- skipUnchanged,
2069
- signal: options.signal,
2070
- encode,
2071
- format: chunkFormat,
2072
- ffmpeg,
2073
- workDir: work,
2074
- chunkFile,
2075
- cacheIdentity: cache ? (chunk) => ({
2076
- videoId: target.videoId,
2077
- chunk,
2078
- renderer,
2079
- format: chunkFormat.name,
2080
- width: target.width,
2081
- height: target.height,
2082
- fps: target.fps,
2083
- preset: encode.preset,
2084
- // Both change the encoded bytes, so both are part of what a
2085
- // chunk is: a half-size chunk must never answer for a full
2086
- // one. A lossless intermediate is the exception by design —
2087
- // the final pass applies them, so one capture serves all.
2088
- quality: chunkable ? encode.quality : void 0,
2089
- scale: chunkable ? encode.scale : void 0,
2090
- input: target.input
2091
- }) : void 0,
2092
- onFrame: () => onProgress?.(stats.captured / Math.max(1, target.durationInFrames), "rendering")
2093
- })
2094
- )
2095
- );
2096
- const captureMs = performance.now() - captureStart;
2097
- onProgress?.(1, "encoding");
2098
- const mixInputs = (options.audio === false ? [] : target.audio ?? []).map((cue) => {
2099
- const file = resolveCueFile(config, cue.src);
2100
- if (!file) log.warn(`Skipping audio cue ${cue.src}: only project-local files can be encoded.`);
2101
- return file ? { file, cue } : null;
2102
- }).filter((item) => item !== null);
2103
- const muxStart = performance.now();
2104
- const ordered = chunks.map(chunkFile);
2105
- const silent = join6(work, `video${chunkable ? format.extension : ".mkv"}`);
2106
- if (ordered.length === 1) {
2107
- await copyFile2(ordered[0], silent);
2108
- } else {
2109
- const list = join6(work, "chunks.txt");
2110
- await writeFile13(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
2111
- await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
2112
- }
2113
- if (!chunkable) {
2114
- const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
2115
- if (destination !== output) await mkdir12(dirname5(destination), { recursive: true });
2116
- await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
2117
- if (mixInputs.length > 0) {
2118
- log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
2342
+ if (!options.refresh) {
2343
+ const cached = await readPrepareCache(config, key);
2344
+ if (cached.hit) return cached.value;
2345
+ }
2346
+ const memo = /* @__PURE__ */ new Map();
2347
+ const value = await prepare.run({
2348
+ input,
2349
+ assets: {
2350
+ resolve: async (reference) => {
2351
+ const asset = (config.assets ?? []).find((item) => item.reference === reference);
2352
+ if (!asset) throw new Error(`Unknown asset reference: ${reference}`);
2353
+ return asset.url;
2354
+ }
2355
+ },
2356
+ cache: {
2357
+ getOrSet: async (cacheKey, factory) => {
2358
+ if (!memo.has(cacheKey)) memo.set(cacheKey, await factory());
2359
+ return memo.get(cacheKey);
2119
2360
  }
2120
- } else if (mixInputs.length === 0 || !format.audio) {
2121
- const faststart = format.extension === ".mp4" || format.extension === ".mov";
2122
- await run(
2123
- ffmpeg,
2124
- ["-y", "-i", silent, "-c", "copy", ...faststart ? ["-movflags", "+faststart"] : [], output],
2125
- options.signal
2126
- );
2127
- } else {
2128
- const { filter, label } = buildAudioFilter(mixInputs, {
2129
- fps: target.fps,
2130
- durationInFrames: target.durationInFrames,
2131
- targetLufs: target.targetLufs ?? -14
2132
- });
2133
- const args = ["-y", "-i", silent];
2134
- for (const { cue, file } of mixInputs) args.push(...cue.loop ? ["-stream_loop", "-1"] : [], "-i", file);
2135
- args.push(
2136
- "-filter_complex",
2137
- filter,
2138
- "-map",
2139
- "0:v",
2140
- "-map",
2141
- label,
2142
- "-c:v",
2143
- "copy",
2144
- // WebM cannot carry AAC; every other container here can.
2145
- "-c:a",
2146
- format.extension === ".webm" ? "libopus" : "aac",
2147
- "-b:a",
2148
- "192k",
2149
- "-ar",
2150
- "48000",
2151
- "-shortest",
2152
- ...format.extension === ".mp4" || format.extension === ".mov" ? ["-movflags", "+faststart"] : [],
2153
- output
2154
- );
2155
- await run(ffmpeg, args, options.signal);
2156
2361
  }
2157
- options.onTimings?.({
2158
- captureMs: Math.round(captureMs),
2159
- encodeMs: Math.round(performance.now() - muxStart),
2160
- frames: target.durationInFrames,
2161
- reusedFrames: stats.reused,
2162
- cachedChunks: stats.cachedChunks,
2163
- chunks: chunks.length,
2164
- concurrency: lanes.length
2165
- });
2166
- succeeded = true;
2167
- return output;
2168
- } finally {
2169
- if (succeeded && !options.workDir) await rm4(work, { recursive: true, force: true });
2170
- else if (!succeeded) log.detail(`Chunks left for inspection in ${work}`);
2362
+ });
2363
+ await writePrepareCache(config, key, value);
2364
+ return value;
2365
+ };
2366
+ var freezeManifest = async (video, graph, config, rawInput, options = {}) => {
2367
+ const layout = resolveEntryLayout2(video.entry);
2368
+ const merged = { ...video.entry.metadata.defaultProps, ...rawInput };
2369
+ const input = video.entry.metadata.schema ? video.entry.metadata.schema.parse(merged) : merged;
2370
+ const prepared = await runPrepare(video, config, graph, input, { refresh: options.refreshPrepare });
2371
+ const integrity = await createIntegrityResolver(config);
2372
+ const assets = await Promise.all(
2373
+ (config.assets ?? []).map(async (asset) => ({ ...asset, integrity: await integrity.resolve(asset.url) }))
2374
+ );
2375
+ const audio = await Promise.all(
2376
+ (options.audio ?? []).map(async (cue) => ({ ...cue, integrity: await integrity.resolve(cue.src) }))
2377
+ );
2378
+ const fonts = await Promise.all(
2379
+ layout.brand.fonts.map(async (font) => ({
2380
+ family: font.family,
2381
+ url: font.url,
2382
+ integrity: await integrity.resolve(font.url)
2383
+ }))
2384
+ );
2385
+ await integrity.flush();
2386
+ for (const cue of audio) {
2387
+ if (cue.integrity === "unresolved") log.warn(`Audio source could not be resolved for hashing: ${cue.src}`);
2171
2388
  }
2389
+ const manifest = createRenderManifest({
2390
+ entry: video.entry,
2391
+ layout,
2392
+ input,
2393
+ prepared,
2394
+ sourceHash: graph.sourceHash,
2395
+ durationInFrames: video.durationInFrames,
2396
+ scenes: options.scenes ?? [],
2397
+ audio,
2398
+ assets,
2399
+ fonts,
2400
+ // Which Chrome drew it and which FFmpeg encoded it. Two files that differ
2401
+ // are then a question with an answer rather than a mystery.
2402
+ toolchain: await renderToolchain(config),
2403
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2404
+ });
2405
+ return { manifest, input, prepared };
2172
2406
  };
2173
2407
 
2174
2408
  // src/open.ts
2175
- import { spawn as spawn3 } from "child_process";
2409
+ import { spawn as spawn4 } from "child_process";
2176
2410
  var command = () => {
2177
2411
  if (process.platform === "darwin") return { bin: "open", args: [] };
2178
2412
  if (process.platform === "win32") return { bin: "cmd", args: ["/c", "start", ""] };
@@ -2189,7 +2423,7 @@ var openInBrowser = (url) => {
2189
2423
  const resolved = command();
2190
2424
  if (!resolved) return;
2191
2425
  try {
2192
- const child = spawn3(resolved.bin, [...resolved.args, url], { stdio: "ignore", detached: true });
2426
+ const child = spawn4(resolved.bin, [...resolved.args, url], { stdio: "ignore", detached: true });
2193
2427
  child.on("error", () => {
2194
2428
  });
2195
2429
  child.unref();
@@ -2198,25 +2432,25 @@ var openInBrowser = (url) => {
2198
2432
  };
2199
2433
 
2200
2434
  // src/server.ts
2201
- import { existsSync as existsSync15 } from "fs";
2435
+ import { existsSync as existsSync16 } from "fs";
2202
2436
  import { createRequire as createRequire2 } from "module";
2203
2437
  import { fileURLToPath } from "url";
2204
2438
  import { createServer } from "vite";
2205
2439
  import react from "@vitejs/plugin-react";
2206
- import { readFile as readFile12 } from "fs/promises";
2207
- import { dirname as dirname6, resolve as resolve17, sep as sep3 } from "path";
2208
- var cliRoot = resolve17(dirname6(fileURLToPath(import.meta.url)), "..");
2209
- var studioRoot = resolve17(cliRoot, "studio");
2210
- var studioEntry = resolve17(studioRoot, "index.html");
2211
- var installRoot = resolve17(cliRoot, "..", "..");
2440
+ import { readFile as readFile13 } from "fs/promises";
2441
+ import { dirname as dirname8, resolve as resolve18, sep as sep3 } from "path";
2442
+ var cliRoot = resolve18(dirname8(fileURLToPath(import.meta.url)), "..");
2443
+ var studioRoot = resolve18(cliRoot, "studio");
2444
+ var studioEntry = resolve18(studioRoot, "index.html");
2445
+ var installRoot = resolve18(cliRoot, "..", "..");
2212
2446
  var VIRTUAL_ID = "virtual:odori-project";
2213
2447
  var RESOLVED_ID = `\0${VIRTUAL_ID}`;
2214
2448
  var runtimeSource = (root) => {
2215
- for (const from of [resolve17(root, "package.json"), import.meta.url]) {
2449
+ for (const from of [resolve18(root, "package.json"), import.meta.url]) {
2216
2450
  try {
2217
2451
  const manifest = createRequire2(from).resolve("odori/package.json");
2218
- const src = resolve17(manifest, "..", "src");
2219
- if (existsSync15(resolve17(src, "index.tsx"))) return src;
2452
+ const src = resolve18(manifest, "..", "src");
2453
+ if (existsSync16(resolve18(src, "index.tsx"))) return src;
2220
2454
  } catch {
2221
2455
  }
2222
2456
  }
@@ -2339,15 +2573,15 @@ var startStudioServer = async (initialConfig, options = {}) => {
2339
2573
  ],
2340
2574
  // The project's public/ directory is served at the root, so brand fonts,
2341
2575
  // logos, and footage resolve identically in preview and render.
2342
- publicDir: existsSync15(resolve17(config.root, "public")) ? resolve17(config.root, "public") : false,
2576
+ publicDir: existsSync16(resolve18(config.root, "public")) ? resolve18(config.root, "public") : false,
2343
2577
  resolve: {
2344
2578
  dedupe: ["react", "react-dom", "odori"],
2345
2579
  // Only when the runtime is present as source. A consumer resolves the
2346
2580
  // published package through its exports map instead.
2347
2581
  alias: odoriSrc ? [
2348
- { find: /^odori\/preview$/, replacement: resolve17(odoriSrc, "preview.ts") },
2349
- { find: /^odori\/manifest$/, replacement: resolve17(odoriSrc, "manifest.ts") },
2350
- { find: /^odori$/, replacement: resolve17(odoriSrc, "index.tsx") }
2582
+ { find: /^odori\/preview$/, replacement: resolve18(odoriSrc, "preview.ts") },
2583
+ { find: /^odori\/manifest$/, replacement: resolve18(odoriSrc, "manifest.ts") },
2584
+ { find: /^odori$/, replacement: resolve18(odoriSrc, "index.tsx") }
2351
2585
  ] : []
2352
2586
  },
2353
2587
  server: {
@@ -2377,7 +2611,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2377
2611
  }, 150);
2378
2612
  };
2379
2613
  const rediscover = async (file) => {
2380
- if (!file.startsWith(resolve17(config.root, config.videosDir))) return;
2614
+ if (!file.startsWith(resolve18(config.root, config.videosDir))) return;
2381
2615
  const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep3}brands${sep3}`);
2382
2616
  if (!isEntry) return;
2383
2617
  try {
@@ -2394,7 +2628,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2394
2628
  };
2395
2629
  vite.watcher.on("add", (file) => void rediscover(file));
2396
2630
  vite.watcher.on("unlink", (file) => void rediscover(file));
2397
- vite.watcher.add(resolve17(config.root, config.videosDir));
2631
+ vite.watcher.add(resolve18(config.root, config.videosDir));
2398
2632
  const reloadConfig = async (file) => {
2399
2633
  if (!/odori\.config\.(?:ts|mjs|js)$/.test(file)) return;
2400
2634
  try {
@@ -2411,14 +2645,14 @@ var startStudioServer = async (initialConfig, options = {}) => {
2411
2645
  vite.ws.send({ type: "full-reload" });
2412
2646
  };
2413
2647
  const refreshCues = (file) => {
2414
- if (!file.startsWith(resolve17(config.root, config.videosDir) + sep3)) return;
2648
+ if (!file.startsWith(resolve18(config.root, config.videosDir) + sep3)) return;
2415
2649
  if (!/\.tsx?$/.test(file)) return;
2416
2650
  scheduleCueRefresh();
2417
2651
  };
2418
2652
  vite.watcher.on("change", (file) => refreshCues(file));
2419
2653
  vite.watcher.on("change", (file) => void reloadConfig(file));
2420
2654
  for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
2421
- vite.watcher.add(resolve17(config.root, name));
2655
+ vite.watcher.add(resolve18(config.root, name));
2422
2656
  }
2423
2657
  vite.middlewares.use(async (request, response, next) => {
2424
2658
  const url = (request.url ?? "/").split("?")[0];
@@ -2428,7 +2662,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2428
2662
  return;
2429
2663
  }
2430
2664
  try {
2431
- const html = await readFile12(studioEntry, "utf8");
2665
+ const html = await readFile13(studioEntry, "utf8");
2432
2666
  response.statusCode = 200;
2433
2667
  response.setHeader("content-type", "text/html");
2434
2668
  response.end(await vite.transformIndexHtml(url, html));
@@ -2449,7 +2683,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2449
2683
  };
2450
2684
 
2451
2685
  // src/commands/exportVideo.ts
2452
- import { resolve as resolve18 } from "path";
2686
+ import { resolve as resolve19 } from "path";
2453
2687
  import { resolveEntryLayout as resolveEntryLayout4 } from "odori";
2454
2688
 
2455
2689
  // src/commands/shared.ts
@@ -2565,6 +2799,7 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
2565
2799
  scale: options.scale ?? record.render?.scale,
2566
2800
  format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : void 0),
2567
2801
  audio: options.audio ?? record.render?.audio,
2802
+ graphics: options.graphics ?? record.render?.graphics,
2568
2803
  skipUnchangedFrames: options.skipUnchangedFrames,
2569
2804
  signal: controller.signal,
2570
2805
  onTimings: (timings) => {
@@ -2619,7 +2854,7 @@ var exportCommand = async (id, options = {}) => {
2619
2854
  options.input ?? {},
2620
2855
  { scenes: compiled.scenes, audio: compiled.audio }
2621
2856
  );
2622
- const output = resolve18(
2857
+ const output = resolve19(
2623
2858
  config.root,
2624
2859
  options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
2625
2860
  );
@@ -2628,11 +2863,16 @@ var exportCommand = async (id, options = {}) => {
2628
2863
  quality,
2629
2864
  scale,
2630
2865
  audio: options.audio !== false,
2866
+ graphics: options.fast ? "gpu" : "software",
2631
2867
  ...options.preset ? { preset: options.preset } : {}
2632
2868
  });
2633
2869
  })();
2634
2870
  const video = findVideo(videos, record.manifest.videoId);
2635
2871
  log.detail(`job ${record.job.id} manifest ${record.manifest.manifestHash}`);
2872
+ const backend = options.fast ? "gpu" : record.render?.graphics ?? "software";
2873
+ log.detail(
2874
+ backend === "gpu" ? "graphics: this machine's GPU. Faster, and the pixels are specific to it." : "graphics: software, reproducible on any machine"
2875
+ );
2636
2876
  if (options.retry) log.detail(`retrying attempt ${record.job.attempts + 1} from the frozen manifest`);
2637
2877
  if (record.manifest.audio.length > 0) {
2638
2878
  log.detail(`${record.manifest.audio.length} audio cue(s) at ${record.manifest.format.durationInFrames} frames`);
@@ -2645,6 +2885,7 @@ var exportCommand = async (id, options = {}) => {
2645
2885
  scale: options.retry && options.scale === void 0 ? void 0 : scale,
2646
2886
  format: options.retry ? void 0 : format,
2647
2887
  audio: options.audio,
2888
+ graphics: options.fast ? "gpu" : void 0,
2648
2889
  skipUnchangedFrames: options.skipUnchangedFrames,
2649
2890
  onProgress: (next) => {
2650
2891
  if (next.status === "rendering" || next.status === "encoding") {
@@ -2687,8 +2928,8 @@ var json = (response, status, payload) => {
2687
2928
  response.end(JSON.stringify(payload));
2688
2929
  };
2689
2930
  var exportDestination = (config) => {
2690
- const downloads = resolve19(homedir2(), "Downloads");
2691
- return existsSync16(downloads) ? downloads : resolve19(config.root, config.exportDir);
2931
+ const downloads = resolve20(homedir3(), "Downloads");
2932
+ return existsSync17(downloads) ? downloads : resolve20(config.root, config.exportDir);
2692
2933
  };
2693
2934
  var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
2694
2935
  var hostOf = (value) => {
@@ -2747,7 +2988,7 @@ var devCommand = async (options = {}) => {
2747
2988
  );
2748
2989
  const frame = Number(body.frame ?? 0);
2749
2990
  const inline = body.inline === true;
2750
- const file = inline ? resolve19(config.root, config.outDir, `${outputName(video.entry.metadata.id)}-${frame}.png`) : resolve19(exportDestination(config), `${outputName(video.entry.metadata.id)}-${frame}.png`);
2991
+ const file = inline ? resolve20(config.root, config.outDir, `${outputName(video.entry.metadata.id)}-${frame}.png`) : resolve20(exportDestination(config), `${outputName(video.entry.metadata.id)}-${frame}.png`);
2751
2992
  await renderStill(
2752
2993
  origin,
2753
2994
  targetFor(
@@ -2763,7 +3004,7 @@ var devCommand = async (options = {}) => {
2763
3004
  if (inline) {
2764
3005
  response.statusCode = 200;
2765
3006
  response.setHeader("content-type", "image/png");
2766
- response.end(await readFile13(file));
3007
+ response.end(await readFile14(file));
2767
3008
  return;
2768
3009
  }
2769
3010
  json(response, 200, { id: "still", status: "ready", progress: 1, output: file });
@@ -2785,7 +3026,7 @@ var devCommand = async (options = {}) => {
2785
3026
  input,
2786
3027
  { scenes: compiled.scenes, audio: compiled.audio }
2787
3028
  );
2788
- const output = resolve19(
3029
+ const output = resolve20(
2789
3030
  exportDestination(config),
2790
3031
  `${outputName(video.entry.metadata.id)}${format.extension}`
2791
3032
  );
@@ -2821,8 +3062,8 @@ var devCommand = async (options = {}) => {
2821
3062
  }
2822
3063
  if (request.method === "GET" && url.startsWith("/source")) {
2823
3064
  const asked = new URL(url, "http://localhost").searchParams.get("file") ?? "";
2824
- const file = resolve19(config.root, asked);
2825
- const inside = relative7(config.root, file);
3065
+ const file = resolve20(config.root, asked);
3066
+ const inside = relative8(config.root, file);
2826
3067
  const readable = /\.(tsx?|jsx?|css|json|md)$/.test(file);
2827
3068
  if (!inside || inside.startsWith("..") || !readable) {
2828
3069
  json(response, 400, { error: `Refusing to read ${asked}.` });
@@ -2831,12 +3072,75 @@ var devCommand = async (options = {}) => {
2831
3072
  try {
2832
3073
  response.statusCode = 200;
2833
3074
  response.setHeader("content-type", "text/plain; charset=utf-8");
2834
- response.end(await readFile13(file, "utf8"));
3075
+ response.end(await readFile14(file, "utf8"));
2835
3076
  } catch {
2836
3077
  json(response, 404, { error: `${asked} is not there.` });
2837
3078
  }
2838
3079
  return;
2839
3080
  }
3081
+ if (request.method === "POST" && url.startsWith("/generate")) {
3082
+ const body = await readBody(request);
3083
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
3084
+ if (!prompt) {
3085
+ json(response, 400, { error: "A prompt is required." });
3086
+ return;
3087
+ }
3088
+ const report = await prepareBed(config, prompt, {
3089
+ generate: true,
3090
+ seconds: typeof body.seconds === "number" ? Math.min(300, Math.max(5, body.seconds)) : void 0,
3091
+ role: typeof body.role === "string" && body.role.trim() ? body.role.trim() : void 0,
3092
+ provider: typeof body.provider === "string" ? body.provider : void 0
3093
+ });
3094
+ json(response, 200, {
3095
+ ...report,
3096
+ source: relative8(config.root, report.source),
3097
+ destination: relative8(config.root, report.destination)
3098
+ });
3099
+ return;
3100
+ }
3101
+ if (url === "/integrations" || url === "/integrations/") {
3102
+ if (request.method === "GET") {
3103
+ const providers = await Promise.all(
3104
+ Object.values(musicProviders).map(async (provider) => ({
3105
+ name: provider.name,
3106
+ title: provider.title,
3107
+ kind: "music",
3108
+ keyVariable: provider.keyVariable,
3109
+ docsUrl: provider.docsUrl,
3110
+ source: await keySource(provider.keyVariable)
3111
+ }))
3112
+ );
3113
+ json(response, 200, { providers });
3114
+ return;
3115
+ }
3116
+ if (request.method === "POST") {
3117
+ const body = await readBody(request);
3118
+ const provider = musicProviders[String(body.provider ?? "")];
3119
+ if (!provider) {
3120
+ json(response, 400, { error: `No provider named ${JSON.stringify(body.provider)}.` });
3121
+ return;
3122
+ }
3123
+ const key = typeof body.key === "string" ? body.key.trim() : "";
3124
+ if (!key) {
3125
+ await clearStoredKey(provider.keyVariable);
3126
+ json(response, 200, { source: await keySource(provider.keyVariable), verified: null });
3127
+ return;
3128
+ }
3129
+ let verified = null;
3130
+ try {
3131
+ verified = await provider.verifyKey(key);
3132
+ } catch {
3133
+ verified = null;
3134
+ }
3135
+ if (verified === false) {
3136
+ json(response, 400, { error: `${provider.title} rejected that key.` });
3137
+ return;
3138
+ }
3139
+ await setStoredKey(provider.keyVariable, key);
3140
+ json(response, 200, { source: await keySource(provider.keyVariable), verified });
3141
+ return;
3142
+ }
3143
+ }
2840
3144
  if (request.method === "GET" && url.startsWith("/jobs")) {
2841
3145
  json(response, 200, await listJobs(config));
2842
3146
  return;
@@ -2861,16 +3165,16 @@ var devCommand = async (options = {}) => {
2861
3165
 
2862
3166
  // src/commands/doctor.ts
2863
3167
  import { constants } from "fs";
2864
- import { access, mkdir as mkdir13, readFile as readFile14, rm as rm5, writeFile as writeFile14 } from "fs/promises";
2865
- import { existsSync as existsSync17 } from "fs";
3168
+ import { access, mkdir as mkdir15, readFile as readFile15, rm as rm6, writeFile as writeFile16 } from "fs/promises";
3169
+ import { existsSync as existsSync18 } from "fs";
2866
3170
  import { createRequire as createRequire3 } from "module";
2867
- import { relative as relative8, resolve as resolve20 } from "path";
3171
+ import { relative as relative9, resolve as resolve21 } from "path";
2868
3172
  var MINIMUM_NODE = 20;
2869
3173
  var version = (value) => value.replace(/^v/, "").split(".").map(Number);
2870
3174
  var runChecks = async (root) => {
2871
3175
  const checks = [];
2872
3176
  const config = await loadConfig(root);
2873
- const require2 = createRequire3(resolve20(root, "package.json"));
3177
+ const require2 = createRequire3(resolve21(root, "package.json"));
2874
3178
  const [major] = version(process.version);
2875
3179
  checks.push({
2876
3180
  name: "Node",
@@ -2881,7 +3185,7 @@ var runChecks = async (root) => {
2881
3185
  let react2 = "not found";
2882
3186
  let reactOk = false;
2883
3187
  try {
2884
- const manifest = JSON.parse(await readFile14(require2.resolve("react/package.json"), "utf8"));
3188
+ const manifest = JSON.parse(await readFile15(require2.resolve("react/package.json"), "utf8"));
2885
3189
  react2 = manifest.version;
2886
3190
  reactOk = version(react2)[0] >= 19;
2887
3191
  } catch {
@@ -2893,16 +3197,16 @@ var runChecks = async (root) => {
2893
3197
  ok: reactOk,
2894
3198
  fix: "The runtime needs React 19. Install it: npm install react@19 react-dom@19"
2895
3199
  });
2896
- const videosDir = resolve20(config.root, config.videosDir);
3200
+ const videosDir = resolve21(config.root, config.videosDir);
2897
3201
  checks.push({
2898
3202
  name: "Source root",
2899
- detail: existsSync17(videosDir) ? relative8(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2900
- ok: existsSync17(videosDir),
3203
+ detail: existsSync18(videosDir) ? relative9(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
3204
+ ok: existsSync18(videosDir),
2901
3205
  fix: 'Run "odori init" to add the videos source root.'
2902
3206
  });
2903
3207
  checks.push({
2904
3208
  name: "Config",
2905
- detail: config.configPath ? relative8(config.root, config.configPath) : "defaults (no odori.config.ts)",
3209
+ detail: config.configPath ? relative9(config.root, config.configPath) : "defaults (no odori.config.ts)",
2906
3210
  // Loading got this far, so a config that exists also parsed.
2907
3211
  ok: true
2908
3212
  });
@@ -2928,25 +3232,25 @@ var runChecks = async (root) => {
2928
3232
  detail: unpinned.length === 0 ? `pinned binaries from ${cacheRoot()}` : `${unpinned.length} of 2 from the host; frames may differ from another machine`,
2929
3233
  ok: true
2930
3234
  });
2931
- const generated = resolve20(config.root, ".odori");
3235
+ const generated = resolve21(config.root, ".odori");
2932
3236
  let writable = false;
2933
3237
  try {
2934
- await mkdir13(generated, { recursive: true });
2935
- const probe = resolve20(generated, ".doctor");
2936
- await writeFile14(probe, "", "utf8");
3238
+ await mkdir15(generated, { recursive: true });
3239
+ const probe = resolve21(generated, ".doctor");
3240
+ await writeFile16(probe, "", "utf8");
2937
3241
  await access(probe, constants.W_OK);
2938
- await rm5(probe, { force: true });
3242
+ await rm6(probe, { force: true });
2939
3243
  writable = true;
2940
3244
  } catch {
2941
3245
  writable = false;
2942
3246
  }
2943
- const componentsRoot = resolve20(root, config.componentsDir);
3247
+ const componentsRoot = resolve21(root, config.componentsDir);
2944
3248
  const orphans = [];
2945
- if (existsSync17(componentsRoot)) {
3249
+ if (existsSync18(componentsRoot)) {
2946
3250
  const { readdir: readdir9 } = await import("fs/promises");
2947
3251
  for (const entry of await readdir9(componentsRoot, { withFileTypes: true })) {
2948
3252
  if (!entry.isDirectory()) continue;
2949
- const files = await readdir9(resolve20(componentsRoot, entry.name));
3253
+ const files = await readdir9(resolve21(componentsRoot, entry.name));
2950
3254
  const source = files.some((file) => /\.tsx$/.test(file) && !file.endsWith(".preview.tsx"));
2951
3255
  const fixture = files.some((file) => file.endsWith(".preview.tsx"));
2952
3256
  if (source && !fixture) orphans.push(entry.name);
@@ -2959,11 +3263,21 @@ var runChecks = async (root) => {
2959
3263
  warn: orphans.length > 0,
2960
3264
  fix: `Add a sibling <name>.preview.tsx with defineComponentPreview so Studio can play it on its own. A component with no fixture only ever renders inside a video.`
2961
3265
  });
3266
+ for (const provider of Object.values(musicProviders)) {
3267
+ const source = await keySource(provider.keyVariable);
3268
+ checks.push({
3269
+ name: `Provider: ${provider.name}`,
3270
+ detail: source === "environment" ? `connected (${provider.keyVariable})` : source === "stored" ? "connected (key stored on this machine)" : "not configured",
3271
+ ok: true,
3272
+ warn: source === null,
3273
+ fix: `Optional. To generate with ${provider.title}: set ${provider.keyVariable}, or paste a key in Studio's integrations page.`
3274
+ });
3275
+ }
2962
3276
  checks.push({
2963
3277
  name: "Generated cache",
2964
3278
  detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
2965
3279
  ok: writable,
2966
- fix: `Odori writes its import graph and render cache to ${relative8(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
3280
+ fix: `Odori writes its import graph and render cache to ${relative9(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
2967
3281
  });
2968
3282
  return checks;
2969
3283
  };
@@ -2990,14 +3304,14 @@ var doctorCommand = async (root = process.cwd()) => {
2990
3304
  };
2991
3305
 
2992
3306
  // src/commands/init.ts
2993
- import { mkdir as mkdir15, readFile as readFile15, writeFile as writeFile16 } from "fs/promises";
2994
- import { existsSync as existsSync19 } from "fs";
2995
- import { relative as relative10, resolve as resolve22 } from "path";
3307
+ import { mkdir as mkdir17, readFile as readFile16, writeFile as writeFile18 } from "fs/promises";
3308
+ import { existsSync as existsSync20 } from "fs";
3309
+ import { relative as relative11, resolve as resolve23 } from "path";
2996
3310
 
2997
3311
  // src/commands/new.ts
2998
- import { mkdir as mkdir14, readdir as readdir6, writeFile as writeFile15 } from "fs/promises";
2999
- import { existsSync as existsSync18 } from "fs";
3000
- import { relative as relative9, resolve as resolve21 } from "path";
3312
+ import { mkdir as mkdir16, readdir as readdir6, writeFile as writeFile17 } from "fs/promises";
3313
+ import { existsSync as existsSync19 } from "fs";
3314
+ import { relative as relative10, resolve as resolve22 } from "path";
3001
3315
  var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
3002
3316
  var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
3003
3317
  var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
@@ -3055,27 +3369,27 @@ ${closing}
3055
3369
  `;
3056
3370
  };
3057
3371
  var installedParts = async (config) => {
3058
- const componentsDir = resolve21(config.root, config.componentsDir);
3059
- if (!existsSync18(componentsDir)) return { title: false, end: false };
3372
+ const componentsDir = resolve22(config.root, config.componentsDir);
3373
+ if (!existsSync19(componentsDir)) return { title: false, end: false };
3060
3374
  const entries = (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
3061
3375
  return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
3062
3376
  };
3063
3377
  var newCommand = async (name, options = {}) => {
3064
3378
  if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("Use a lowercase, dash separated name.");
3065
3379
  const config = await loadConfig(process.cwd());
3066
- const directory2 = resolve21(config.root, config.videosDir, name);
3067
- const file = resolve21(directory2, "video.tsx");
3068
- if (existsSync18(file)) throw new Error(`${relative9(config.root, file)} already exists.`);
3069
- const hasLayout = existsSync18(resolve21(config.root, config.videosDir, "layout.tsx"));
3380
+ const directory2 = resolve22(config.root, config.videosDir, name);
3381
+ const file = resolve22(directory2, "video.tsx");
3382
+ if (existsSync19(file)) throw new Error(`${relative10(config.root, file)} already exists.`);
3383
+ const hasLayout = existsSync19(resolve22(config.root, config.videosDir, "layout.tsx"));
3070
3384
  const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
3071
3385
  const composed = parts.title || parts.end;
3072
- await mkdir14(directory2, { recursive: true });
3073
- await writeFile15(
3386
+ await mkdir16(directory2, { recursive: true });
3387
+ await writeFile17(
3074
3388
  file,
3075
3389
  composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
3076
3390
  "utf8"
3077
3391
  );
3078
- log.success(`Created ${relative9(config.root, file)}`);
3392
+ log.success(`Created ${relative10(config.root, file)}`);
3079
3393
  if (composed) log.detail("Composed from the components this project has installed.");
3080
3394
  else if (options.blank !== true) {
3081
3395
  log.detail("No registry components installed yet: odori add title-reveal end-card");
@@ -3109,45 +3423,58 @@ export const productLayout = defineVideoLayout({
3109
3423
  });
3110
3424
  `;
3111
3425
  var initCommand = async (root = process.cwd()) => {
3112
- const videosDir = resolve22(root, defaultConfig.videosDir);
3113
- await mkdir15(resolve22(videosDir, "components"), { recursive: true });
3426
+ const videosDir = resolve23(root, defaultConfig.videosDir);
3427
+ await mkdir17(resolve23(videosDir, "components"), { recursive: true });
3114
3428
  const files = [
3115
- [resolve22(root, "odori.config.ts"), CONFIG_TEMPLATE],
3116
- [resolve22(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
3117
- [resolve22(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
3429
+ [resolve23(root, "odori.config.ts"), CONFIG_TEMPLATE],
3430
+ [resolve23(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
3431
+ [resolve23(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
3118
3432
  ];
3119
3433
  for (const [file, contents] of files) {
3120
- if (existsSync19(file)) {
3121
- log.detail(`Kept existing ${relative10(root, file)}`);
3434
+ if (existsSync20(file)) {
3435
+ log.detail(`Kept existing ${relative11(root, file)}`);
3122
3436
  continue;
3123
3437
  }
3124
- await mkdir15(resolve22(file, ".."), { recursive: true });
3125
- await writeFile16(file, contents, "utf8");
3126
- log.success(`Created ${relative10(root, file)}`);
3438
+ await mkdir17(resolve23(file, ".."), { recursive: true });
3439
+ await writeFile18(file, contents, "utf8");
3440
+ log.success(`Created ${relative11(root, file)}`);
3127
3441
  }
3128
3442
  await ensureModuleType(root);
3129
3443
  log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
3130
3444
  };
3131
3445
  var ensureModuleType = async (root) => {
3132
- const file = resolve22(root, "package.json");
3133
- if (!existsSync19(file)) {
3446
+ const file = resolve23(root, "package.json");
3447
+ if (!existsSync20(file)) {
3134
3448
  log.warn('No package.json here. Odori needs an ESM package: run npm init, then add "type": "module".');
3135
3449
  return;
3136
3450
  }
3137
3451
  let manifest;
3138
3452
  try {
3139
- manifest = JSON.parse(await readFile15(file, "utf8"));
3453
+ manifest = JSON.parse(await readFile16(file, "utf8"));
3140
3454
  } catch {
3141
3455
  log.warn('package.json is not readable JSON, so "type": "module" was not set. Odori needs it.');
3142
3456
  return;
3143
3457
  }
3144
3458
  if (manifest.type === "module") return;
3145
3459
  manifest.type = "module";
3146
- await writeFile16(file, `${JSON.stringify(manifest, null, 2)}
3460
+ await writeFile18(file, `${JSON.stringify(manifest, null, 2)}
3147
3461
  `, "utf8");
3148
3462
  log.success('Set "type": "module" in package.json');
3149
3463
  };
3150
3464
 
3465
+ // src/commands/integrations.ts
3466
+ var integrationsCommand = async () => {
3467
+ log.title("Integrations");
3468
+ for (const provider of Object.values(musicProviders)) {
3469
+ const source = await keySource(provider.keyVariable);
3470
+ const status = source === "environment" ? `connected (${provider.keyVariable})` : source === "stored" ? "connected (stored on this machine)" : `not configured \u2014 set ${provider.keyVariable}, or paste a key in Studio`;
3471
+ log.info(` ${provider.name.padEnd(14)} music ${status}`);
3472
+ }
3473
+ log.detail("");
3474
+ log.detail(' Generate through a task command: odori bed "warm ambient, no drums" --generate');
3475
+ log.detail(" Keys are read from the environment first, then ~/.config/odori. Never the project.");
3476
+ };
3477
+
3151
3478
  // src/commands/inspect.ts
3152
3479
  import { isOdoriSchema, resolveEntryLayout as resolveEntryLayout5 } from "odori";
3153
3480
  var inspectCommand = async (id, options = {}) => {
@@ -3235,7 +3562,7 @@ var listCommand = async () => {
3235
3562
  };
3236
3563
 
3237
3564
  // src/commands/frame.ts
3238
- import { resolve as resolve23 } from "path";
3565
+ import { resolve as resolve24 } from "path";
3239
3566
  import { framesFromOffset, resolveEntryLayout as resolveEntryLayout7 } from "odori";
3240
3567
  var frameCommand = async (id, options = {}) => {
3241
3568
  const at = options.at ?? 0;
@@ -3257,7 +3584,7 @@ var frameCommand = async (id, options = {}) => {
3257
3584
  throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
3258
3585
  }
3259
3586
  const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
3260
- const file = resolve23(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3587
+ const file = resolve24(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3261
3588
  return renderStill(server.url, target, frame, file, config);
3262
3589
  });
3263
3590
  log.success(`Frame ${frame} written to ${output}`);
@@ -3268,9 +3595,9 @@ var frameCommand = async (id, options = {}) => {
3268
3595
  import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout9 } from "odori";
3269
3596
 
3270
3597
  // src/contracts.ts
3271
- import { existsSync as existsSync20 } from "fs";
3598
+ import { existsSync as existsSync21 } from "fs";
3272
3599
  import { readdir as readdir7 } from "fs/promises";
3273
- import { resolve as resolve24 } from "path";
3600
+ import { resolve as resolve25 } from "path";
3274
3601
  import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3275
3602
  var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
3276
3603
  var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
@@ -3337,8 +3664,8 @@ var checkAudioWindows = (cues, brand, videoId) => {
3337
3664
  return failures;
3338
3665
  };
3339
3666
  var checkInstalledContracts = async (config, videos) => {
3340
- const componentsDir = resolve24(config.root, config.componentsDir);
3341
- const onDisk = existsSync20(componentsDir) ? (await readdir7(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
3667
+ const componentsDir = resolve25(config.root, config.componentsDir);
3668
+ const onDisk = existsSync21(componentsDir) ? (await readdir7(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
3342
3669
  const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
3343
3670
  if (names.size === 0) return [];
3344
3671
  const { items } = await resolveRegistry(config, { allowNetwork: false });
@@ -3359,9 +3686,9 @@ var checkInstalledContracts = async (config, videos) => {
3359
3686
  };
3360
3687
 
3361
3688
  // src/determinism.ts
3362
- import { readdir as readdir8, readFile as readFile16 } from "fs/promises";
3363
- import { existsSync as existsSync21 } from "fs";
3364
- import { join as join7, relative as relative11, resolve as resolve25 } from "path";
3689
+ import { readdir as readdir8, readFile as readFile17 } from "fs/promises";
3690
+ import { existsSync as existsSync22 } from "fs";
3691
+ import { join as join9, relative as relative12, resolve as resolve26 } from "path";
3365
3692
  var FORBIDDEN = [
3366
3693
  {
3367
3694
  pattern: /\bMath\.random\s*\(/,
@@ -3395,18 +3722,18 @@ var scanSource = (source, file) => {
3395
3722
  var walk2 = async (directory2, files = []) => {
3396
3723
  for (const entry of await readdir8(directory2, { withFileTypes: true })) {
3397
3724
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
3398
- const full = join7(directory2, entry.name);
3725
+ const full = join9(directory2, entry.name);
3399
3726
  if (entry.isDirectory()) await walk2(full, files);
3400
3727
  else if (/\.(tsx|ts|jsx|js)$/.test(entry.name) && !/\.preview\.(tsx|jsx)$/.test(entry.name)) files.push(full);
3401
3728
  }
3402
3729
  return files;
3403
3730
  };
3404
3731
  var checkDeterminism = async (config) => {
3405
- const root = resolve25(config.root, config.videosDir);
3406
- if (!existsSync21(root)) return [];
3732
+ const root = resolve26(config.root, config.videosDir);
3733
+ if (!existsSync22(root)) return [];
3407
3734
  const files = await walk2(root);
3408
3735
  const findings = await Promise.all(
3409
- files.map(async (file) => scanSource(await readFile16(file, "utf8"), relative11(config.root, file)))
3736
+ files.map(async (file) => scanSource(await readFile17(file, "utf8"), relative12(config.root, file)))
3410
3737
  );
3411
3738
  return findings.flat();
3412
3739
  };
@@ -3427,15 +3754,36 @@ var CANVAS_SCRIPT = `(() => {
3427
3754
  });
3428
3755
 
3429
3756
  return canvases.map(function (canvas, index) {
3430
- var context = canvas.getContext("2d");
3431
- if (!context || canvas.width === 0 || canvas.height === 0) {
3757
+ if (canvas.width === 0 || canvas.height === 0) {
3432
3758
  return {index: index, hash: "no-context", blank: true};
3433
3759
  }
3434
3760
  var data;
3435
- try {
3436
- data = context.getImageData(0, 0, canvas.width, canvas.height).data;
3437
- } catch (error) {
3438
- return {index: index, hash: "tainted", blank: false};
3761
+ var isGl = false;
3762
+ var context = canvas.getContext("2d");
3763
+ if (context) {
3764
+ try {
3765
+ data = context.getImageData(0, 0, canvas.width, canvas.height).data;
3766
+ } catch (error) {
3767
+ return {index: index, hash: "tainted", blank: false};
3768
+ }
3769
+ } else {
3770
+ /*
3771
+ * A canvas that already holds a GL context returns null for "2d", and
3772
+ * reading that as an empty canvas reported every shader video as having
3773
+ * drawn nothing while it was drawing correctly. Pixels come back through
3774
+ * readPixels instead, which is the only way to see a GL surface.
3775
+ *
3776
+ * Worth knowing when this comes back empty: a drawing buffer is cleared
3777
+ * once it has been composited unless the context was asked for with
3778
+ * preserveDrawingBuffer, so an author who has not set that gets a real
3779
+ * frame on screen and nothing here.
3780
+ */
3781
+ var gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
3782
+ if (!gl) return {index: index, hash: "no-context", blank: true};
3783
+ var pixels4 = new Uint8Array(canvas.width * canvas.height * 4);
3784
+ gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels4);
3785
+ data = pixels4;
3786
+ isGl = true;
3439
3787
  }
3440
3788
 
3441
3789
  // A prime stride over the whole buffer rather than a coarse grid: a grid
@@ -3451,25 +3799,46 @@ var CANVAS_SCRIPT = `(() => {
3451
3799
  hash =
3452
3800
  ((hash << 5) + hash + data[offset] + data[offset + 1] * 3 + data[offset + 2] * 7 + data[offset + 3] * 11) | 0;
3453
3801
  }
3454
- return {index: index, hash: String(hash), blank: opaque === 0};
3802
+ return {index: index, hash: String(hash), blank: opaque === 0, gl: isGl};
3455
3803
  });
3456
3804
  })()`;
3457
3805
  var FRAME_SCRIPT = `(() => {
3458
3806
  var root = document.querySelector("[data-odori-video]");
3459
- if (!root) return {overflow: [], small: [], empty: true, painted: 0};
3807
+ if (!root) return {overflow: [], small: [], empty: true, painted: 0, faded: 0};
3460
3808
 
3461
3809
  var bounds = root.getBoundingClientRect();
3462
3810
  var overflow = [];
3463
3811
  var small = [];
3464
3812
  var painted = 0;
3813
+ var faded = 0;
3465
3814
  var nodes = Array.prototype.slice.call(root.querySelectorAll("*"));
3466
3815
 
3467
3816
  for (var index = 0; index < nodes.length; index += 1) {
3468
3817
  var node = nodes[index];
3818
+ /* The subtree an effect surface is photographing is deliberately parked
3819
+ outside the frame. It is the input to a canvas, not something a viewer
3820
+ ever sees, so judging its position or its type size is judging the
3821
+ wrong thing. */
3822
+ if (node.closest("[data-odori-capture]")) continue;
3469
3823
  var box = node.getBoundingClientRect();
3470
3824
  if (box.width === 0 || box.height === 0) continue;
3471
3825
  var style = getComputedStyle(node);
3472
- if (style.visibility === "hidden" || Number(style.opacity) < 0.02) continue;
3826
+ if (style.visibility === "hidden") continue;
3827
+ /* Opacity inherits down the tree in effect even though it does not
3828
+ inherit as a property: a parent faded to nothing takes its children
3829
+ with it, while each child still computes its own opacity as 1. Reading
3830
+ one node's value therefore judges text nobody can see. A scene that has
3831
+ faded out is the common case, and it was reporting its hidden dialogue
3832
+ as unreadable. */
3833
+ var effective = 1;
3834
+ for (var up = node; up && up !== root.parentElement; up = up.parentElement) {
3835
+ effective *= Number(getComputedStyle(up).opacity);
3836
+ if (effective < 0.02) break;
3837
+ }
3838
+ if (effective < 0.02) {
3839
+ faded += 1;
3840
+ continue;
3841
+ }
3473
3842
  painted += 1;
3474
3843
 
3475
3844
  var media = ["IMG", "SVG", "CANVAS", "VIDEO"].indexOf(node.tagName) >= 0;
@@ -3494,12 +3863,27 @@ var FRAME_SCRIPT = `(() => {
3494
3863
  * content the video is actually about, which is what stays checked.
3495
3864
  */
3496
3865
  var chrome = node.closest("[data-odori-chrome]") !== null;
3497
- if (
3498
- box.right > bounds.right + 1 ||
3499
- box.left < bounds.left - 1 ||
3500
- box.bottom > bounds.bottom + 1 ||
3501
- box.top < bounds.top - 1
3502
- ) {
3866
+
3867
+ /*
3868
+ * Crossing the frame edge is not a fault. Film bleeds: a surface runs past
3869
+ * the corner, a push-in takes a headline wider than the shot, a full-frame
3870
+ * image is cropped rather than letterboxed. The old rule flagged any box
3871
+ * that crossed by a pixel, which is a rule about a document, not about a
3872
+ * cut, and the only reason the recreations passed it is that five sampled
3873
+ * frames happened to miss their own bleeds.
3874
+ *
3875
+ * What is worth reporting is content with no intersection at all: nothing
3876
+ * of it is on screen at the frame that was sampled, which is what a layout
3877
+ * mistake looks like. Deliberate overscan does that too, and says so with
3878
+ * data-odori-bleed.
3879
+ */
3880
+ var bleed = node.closest("[data-odori-bleed]") !== null;
3881
+ var offCanvas =
3882
+ box.right <= bounds.left ||
3883
+ box.left >= bounds.right ||
3884
+ box.bottom <= bounds.top ||
3885
+ box.top >= bounds.bottom;
3886
+ if (offCanvas && !bleed) {
3503
3887
  if (overflow.indexOf(label) < 0) overflow.push(label);
3504
3888
  }
3505
3889
 
@@ -3523,7 +3907,7 @@ var FRAME_SCRIPT = `(() => {
3523
3907
  }
3524
3908
  }
3525
3909
 
3526
- return {overflow: overflow.slice(0, 5), small: small.slice(0, 5), empty: false, painted: painted};
3910
+ return {overflow: overflow.slice(0, 5), small: small.slice(0, 5), empty: false, painted: painted, faded: faded};
3527
3911
  })()`;
3528
3912
  var testVideo = async (origin, video, config, failures, quiet = false) => {
3529
3913
  const id = video.entry.metadata.id;
@@ -3532,7 +3916,7 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
3532
3916
  const result = video.entry.metadata.schema.safeParse(video.entry.metadata.defaultProps ?? {});
3533
3917
  if (!result.success) failures.push({ video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}` });
3534
3918
  }
3535
- const { browser, page } = await openRenderPage(origin, targetFor(video), config);
3919
+ const { browser, page, errors } = await openRenderPage(origin, targetFor(video), config);
3536
3920
  try {
3537
3921
  const timeline = await readTimeline(page);
3538
3922
  failures.push(...checkAudioWindows((await readAudio(page)).cues, layout.brand, id));
@@ -3549,16 +3933,16 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
3549
3933
  }
3550
3934
  const samples = [0, Math.floor(total / 4), Math.floor(total / 2), Math.floor(total * 3 / 4), total - 1];
3551
3935
  for (const frame of [...new Set(samples)]) {
3552
- await seekTo(page, frame);
3936
+ await seekTo(page, frame, errors);
3553
3937
  const result = await page.evaluate(FRAME_SCRIPT);
3554
3938
  if (result.empty) failures.push({ video: id, message: `Frame ${frame} rendered no video root.` });
3555
- if (!result.empty && result.painted < 2) {
3939
+ if (!result.empty && result.painted < 2 && result.faded < 2) {
3556
3940
  failures.push({ video: id, message: `Frame ${frame} is blank.` });
3557
3941
  }
3558
3942
  for (const item of result.overflow) {
3559
3943
  failures.push({
3560
3944
  video: id,
3561
- message: `Frame ${frame}: ${item} escapes the ${layout.format.width}x${layout.format.height} canvas.`
3945
+ message: `Frame ${frame}: ${item} is entirely outside the ${layout.format.width}x${layout.format.height} canvas.`
3562
3946
  });
3563
3947
  }
3564
3948
  for (const item of result.small) {
@@ -3566,13 +3950,16 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
3566
3950
  }
3567
3951
  const canvases = await page.evaluate(CANVAS_SCRIPT);
3568
3952
  if (canvases.length > 0) {
3569
- await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1);
3570
- await seekTo(page, frame);
3953
+ await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1, errors);
3954
+ await seekTo(page, frame, errors);
3571
3955
  const again = await page.evaluate(CANVAS_SCRIPT);
3572
3956
  for (const canvas of canvases) {
3573
3957
  const second = again.find((item) => item.index === canvas.index);
3574
3958
  if (canvas.blank) {
3575
- failures.push({ video: id, message: `Frame ${frame}: canvas ${canvas.index} drew nothing.` });
3959
+ failures.push({
3960
+ video: id,
3961
+ message: canvas.gl ? `Frame ${frame}: canvas ${canvas.index} read back empty. A WebGL drawing buffer is cleared once it has been composited, so ask for the context with {preserveDrawingBuffer: true}.` : `Frame ${frame}: canvas ${canvas.index} drew nothing.`
3962
+ });
3576
3963
  continue;
3577
3964
  }
3578
3965
  if (canvas.hash === "tainted") {
@@ -3636,7 +4023,7 @@ var testCommand = async (id, options = {}) => {
3636
4023
  };
3637
4024
 
3638
4025
  // src/cli.ts
3639
- var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["force", "dry-run", "json", "no-audio", "no-frame-skip", "no-open", "open", "help", "version"]);
4026
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["force", "dry-run", "json", "no-audio", "fast", "no-frame-skip", "no-open", "open", "help", "version"]);
3640
4027
  var RENAMED = { still: "frame" };
3641
4028
  var parseArgs = (argv) => {
3642
4029
  const [command2 = "help", ...rest] = argv;
@@ -3690,13 +4077,15 @@ var COMMAND_FLAGS = {
3690
4077
  new: ["blank"],
3691
4078
  add: ["force", "dry-run"],
3692
4079
  registry: [],
4080
+ integrations: [],
3693
4081
  diff: ["full"],
3694
4082
  update: ["force"],
3695
4083
  list: [],
3696
4084
  inspect: ["json", "input"],
3697
4085
  frame: ["at", "output", "input"],
4086
+ bed: ["role", "output", "target", "generate", "provider", "seconds"],
3698
4087
  test: ["json"],
3699
- export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "no-frame-skip", "retry"],
4088
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
3700
4089
  jobs: [],
3701
4090
  help: []
3702
4091
  };
@@ -3735,6 +4124,16 @@ var USAGE = {
3735
4124
  Discover project resources and start Studio.`,
3736
4125
  init: `odori init
3737
4126
  Add videos/ and odori.config.ts to a project.`,
4127
+ bed: `odori bed <file> [--role <name>] [--target <lufs>] [--output <path>]
4128
+ Prepare an audio file to sit under a video: measure it, level it to the stem
4129
+ target every other bed is prepared to, and write it where audio is served.
4130
+ With --generate the positional is a prompt instead of a path: the track is
4131
+ generated with a provider (--provider, default elevenlabs, key from
4132
+ ELEVENLABS_API_KEY), then prepared identically. --seconds sets its length.`,
4133
+ integrations: `odori integrations
4134
+ List generation providers and whether each is connected. Configuration
4135
+ lives in the environment or Studio's integrations page; generation happens
4136
+ in task commands like "odori bed --generate".`,
3738
4137
  doctor: `odori doctor
3739
4138
  Check Node, React, the source root, Chrome, FFmpeg, and the generated cache.`,
3740
4139
  install: `odori install
@@ -3765,11 +4164,15 @@ var USAGE = {
3765
4164
  check, for CI.`,
3766
4165
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
3767
4166
  [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
3768
- [--no-audio] [--no-frame-skip] [--retry <job>]
4167
+ [--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
3769
4168
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
3770
4169
  or png; without it the output's extension decides, and mp4 is the default.
3771
4170
  --quality is studio, social, or web. --scale multiplies the output size,
3772
- 0.25 to 2. --no-audio writes the picture with no sound. A retry keeps the
4171
+ 0.25 to 2. --no-audio writes the picture with no sound.
4172
+ --fast draws on this machine's GPU rather than the reproducible software
4173
+ backend. Measured here: about five percent on ordinary post-processing, and
4174
+ fourteen times on a shader that is genuinely per-pixel expensive. The pixels
4175
+ it makes belong to this machine, so keep it for iterating. A retry keeps the
3773
4176
  settings its job was created with.`,
3774
4177
  jobs: `odori jobs
3775
4178
  List export jobs and their status.`
@@ -3817,7 +4220,7 @@ var cliVersion = () => {
3817
4220
  return "unknown";
3818
4221
  }
3819
4222
  };
3820
- var run2 = async (argv) => {
4223
+ var run3 = async (argv) => {
3821
4224
  const { command: command2, positionals, flags } = parseArgs(argv);
3822
4225
  try {
3823
4226
  if (command2 === "--version" || command2 === "-v" || command2 === "version") {
@@ -3847,6 +4250,9 @@ var run2 = async (argv) => {
3847
4250
  case "init":
3848
4251
  await initCommand();
3849
4252
  return 0;
4253
+ case "integrations":
4254
+ await integrationsCommand();
4255
+ return 0;
3850
4256
  case "doctor":
3851
4257
  return await doctorCommand();
3852
4258
  case "install":
@@ -3872,6 +4278,17 @@ var run2 = async (argv) => {
3872
4278
  case "inspect":
3873
4279
  await inspectCommand(positionals[0] ?? "", { json: flags.json === true, input: parseInput(flags) });
3874
4280
  return 0;
4281
+ case "bed":
4282
+ if (!positionals[0]) throw new Error('Which file? "odori bed ./track.mp3".');
4283
+ await bedCommand(positionals[0], {
4284
+ role: typeof flags.role === "string" ? flags.role : void 0,
4285
+ output: typeof flags.output === "string" ? flags.output : void 0,
4286
+ target: numberFlag(flags, "target"),
4287
+ generate: flags.generate === true,
4288
+ provider: typeof flags.provider === "string" ? flags.provider : void 0,
4289
+ seconds: numberFlag(flags, "seconds")
4290
+ });
4291
+ return 0;
3875
4292
  case "frame":
3876
4293
  await frameCommand(positionals[0] ?? "", {
3877
4294
  // A duration, so "4s" and "120f" both work; a bare number is
@@ -3894,6 +4311,7 @@ var run2 = async (argv) => {
3894
4311
  scale: numberFlag(flags, "scale"),
3895
4312
  format: typeof flags.format === "string" ? flags.format : void 0,
3896
4313
  audio: flags["no-audio"] === true ? false : void 0,
4314
+ fast: flags.fast === true,
3897
4315
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
3898
4316
  retry: typeof flags.retry === "string" ? flags.retry : void 0
3899
4317
  });
@@ -4002,5 +4420,5 @@ export {
4002
4420
  initCommand,
4003
4421
  parseArgs,
4004
4422
  checkFlags,
4005
- run2 as run
4423
+ run3 as run
4006
4424
  };