@odori/cli 0.0.7 → 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,315 +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 BROWSER_ARGS = ["--enable-unsafe-swiftshader"];
1761
- var openRenderPage = async (origin, target, config) => {
1762
- const executablePath = await browserExecutable(config);
1763
- const browser = await chromium.launch({ executablePath, headless: true, args: BROWSER_ARGS });
1764
- const page = await browser.newPage({
1765
- viewport: { width: target.width, height: target.height },
1766
- deviceScaleFactor: 1
1767
- });
1768
- const errors = [];
1769
- page.on("pageerror", (error) => errors.push(error.message));
1770
- await page.goto(renderUrl(origin, target, 0), { waitUntil: "networkidle" });
1771
- try {
1772
- await page.locator('[data-odori-frame="0"]').waitFor({ timeout: 2e4 });
1773
- } catch {
1774
- await browser.close();
1775
- throw new Error(`The video did not mount.${errors.length ? ` ${errors.join(" ")}` : ""}`);
1776
- }
1777
- await page.evaluate(() => document.fonts.ready);
1778
- return { browser, page, errors };
1779
- };
1780
- var seekTo = async (page, frame) => {
1781
- await page.evaluate((next) => window.__ODORI_SET_FRAME__?.(next), frame);
1782
- 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
+ }
1783
1347
  };
1784
1348
  var readTimeline = async (page) => page.evaluate(() => window.__ODORI_TIMELINE__ ?? { scenes: [], durationInFrames: 0 });
1785
1349
  var readAudio = async (page) => page.evaluate(() => window.__ODORI_AUDIO__ ?? { cues: [], durationInFrames: 0 });
@@ -1890,11 +1454,11 @@ Run "odori install" when you have a connection, or set ffmpegPath in odori.confi
1890
1454
  var ensureFfmpeg = async (config) => {
1891
1455
  await ffmpegExecutable(config);
1892
1456
  };
1893
- var renderStill = async (origin, target, frame, output, config) => {
1894
- 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);
1895
1459
  try {
1896
- await mkdir12(dirname5(output), { recursive: true });
1897
- await seekTo(page, frame);
1460
+ await mkdir9(dirname5(output), { recursive: true });
1461
+ await seekTo(page, frame, errors);
1898
1462
  await page.screenshot({ path: output });
1899
1463
  if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
1900
1464
  return output;
@@ -1915,7 +1479,7 @@ var sequencePattern = (output) => {
1915
1479
  const dot = output.lastIndexOf(".");
1916
1480
  const stem = dot > 0 ? output.slice(0, dot) : output;
1917
1481
  const extension = dot > 0 ? output.slice(dot) : ".png";
1918
- return join6(stem, `%05d${extension}`);
1482
+ return join4(stem, `%05d${extension}`);
1919
1483
  };
1920
1484
  var LOSSLESS = {
1921
1485
  name: "lossless",
@@ -1950,11 +1514,11 @@ var openChunkEncoder = (ffmpeg, file, fps, encode, format, signal) => {
1950
1514
  return { child, done };
1951
1515
  };
1952
1516
  var captureLane = async (origin, target, config, lane, stats, options) => {
1953
- let session = await openRenderPage(origin, target, config);
1517
+ let session = await openRenderPage(origin, target, config, options.graphics);
1954
1518
  const errors = session.errors;
1955
1519
  const reopen = async () => {
1956
1520
  await session.browser.close().catch(() => void 0);
1957
- session = await openRenderPage(origin, target, config);
1521
+ session = await openRenderPage(origin, target, config, options.graphics);
1958
1522
  session.errors.push(...errors);
1959
1523
  };
1960
1524
  const signatureOf = async () => await session.page.evaluate(SIGNATURE_SCRIPT);
@@ -1989,7 +1553,7 @@ var captureLane = async (origin, target, config, lane, stats, options) => {
1989
1553
  if (options.signal?.aborted) throw new Error("Render cancelled.");
1990
1554
  for (let attempt = 0; ; attempt += 1) {
1991
1555
  try {
1992
- await seekTo(session.page, frame);
1556
+ await seekTo(session.page, frame, session.errors);
1993
1557
  const signature = await signatureOf();
1994
1558
  let image;
1995
1559
  if (options.skipUnchanged && previousFrame && signature === previousSignature) {
@@ -2034,146 +1598,815 @@ var captureLane = async (origin, target, config, lane, stats, options) => {
2034
1598
  }
2035
1599
  }
2036
1600
  };
2037
- var renderMovie = async (origin, target, output, config, onProgress, options = {}) => {
2038
- const ffmpeg = await ffmpegExecutable(config);
2039
- const browserPath = await browserExecutable(config);
2040
- const renderer = (await resolveBrowser(config))?.version ?? browserPath;
2041
- const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
2042
- const encode = {
2043
- preset: options.preset ?? config.preset ?? "medium",
2044
- quality: options.quality ?? "studio",
2045
- scale: options.scale ?? 1
2046
- };
2047
- const format = options.format ?? FORMATS.mp4;
2048
- const chunkable = format.chunked;
2049
- const chunkFormat = chunkable ? format : LOSSLESS;
2050
- const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
2051
- const cache = options.cache ?? config.cacheChunks ?? true;
2052
- const work = options.workDir ?? resolve16(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
2053
- await mkdir12(work, { recursive: true });
2054
- const concurrency = chunkable ? requested : 1;
2055
- const { chunks, lanes } = planChunks({
2056
- durationInFrames: target.durationInFrames,
2057
- scenes: target.scenes,
2058
- concurrency
2059
- });
2060
- const chunkFile = (chunk) => join6(work, `chunk-${String(chunk.index).padStart(4, "0")}${chunkable ? format.extension : ".mkv"}`);
2061
- const stats = { captured: 0, reused: 0, cachedChunks: 0 };
2062
- let succeeded = false;
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;
2063
2328
  try {
2064
- await mkdir12(dirname5(output), { recursive: true });
2065
- const captureStart = performance.now();
2066
- await Promise.all(
2067
- lanes.map(
2068
- (lane) => captureLane(origin, target, config, lane, stats, {
2069
- skipUnchanged,
2070
- signal: options.signal,
2071
- encode,
2072
- format: chunkFormat,
2073
- ffmpeg,
2074
- workDir: work,
2075
- chunkFile,
2076
- cacheIdentity: cache ? (chunk) => ({
2077
- videoId: target.videoId,
2078
- chunk,
2079
- renderer,
2080
- format: chunkFormat.name,
2081
- width: target.width,
2082
- height: target.height,
2083
- fps: target.fps,
2084
- preset: encode.preset,
2085
- // Both change the encoded bytes, so both are part of what a
2086
- // chunk is: a half-size chunk must never answer for a full
2087
- // one. A lossless intermediate is the exception by design —
2088
- // the final pass applies them, so one capture serves all.
2089
- quality: chunkable ? encode.quality : void 0,
2090
- scale: chunkable ? encode.scale : void 0,
2091
- input: target.input
2092
- }) : void 0,
2093
- onFrame: () => onProgress?.(stats.captured / Math.max(1, target.durationInFrames), "rendering")
2094
- })
2095
- )
2096
- );
2097
- const captureMs = performance.now() - captureStart;
2098
- onProgress?.(1, "encoding");
2099
- const mixInputs = (options.audio === false ? [] : target.audio ?? []).map((cue) => {
2100
- const file = resolveCueFile(config, cue.src);
2101
- if (!file) log.warn(`Skipping audio cue ${cue.src}: only project-local files can be encoded.`);
2102
- return file ? { file, cue } : null;
2103
- }).filter((item) => item !== null);
2104
- const muxStart = performance.now();
2105
- const ordered = chunks.map(chunkFile);
2106
- const silent = join6(work, `video${chunkable ? format.extension : ".mkv"}`);
2107
- if (ordered.length === 1) {
2108
- await copyFile2(ordered[0], silent);
2109
- } else {
2110
- const list = join6(work, "chunks.txt");
2111
- await writeFile13(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
2112
- await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
2113
- }
2114
- if (!chunkable) {
2115
- const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
2116
- if (destination !== output) await mkdir12(dirname5(destination), { recursive: true });
2117
- await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
2118
- if (mixInputs.length > 0) {
2119
- log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
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"
2341
+ };
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);
2120
2360
  }
2121
- } else if (mixInputs.length === 0 || !format.audio) {
2122
- const faststart = format.extension === ".mp4" || format.extension === ".mov";
2123
- await run(
2124
- ffmpeg,
2125
- ["-y", "-i", silent, "-c", "copy", ...faststart ? ["-movflags", "+faststart"] : [], output],
2126
- options.signal
2127
- );
2128
- } else {
2129
- const { filter, label } = buildAudioFilter(mixInputs, {
2130
- fps: target.fps,
2131
- durationInFrames: target.durationInFrames,
2132
- targetLufs: target.targetLufs ?? -14
2133
- });
2134
- const args = ["-y", "-i", silent];
2135
- for (const { cue, file } of mixInputs) args.push(...cue.loop ? ["-stream_loop", "-1"] : [], "-i", file);
2136
- args.push(
2137
- "-filter_complex",
2138
- filter,
2139
- "-map",
2140
- "0:v",
2141
- "-map",
2142
- label,
2143
- "-c:v",
2144
- "copy",
2145
- // WebM cannot carry AAC; every other container here can.
2146
- "-c:a",
2147
- format.extension === ".webm" ? "libopus" : "aac",
2148
- "-b:a",
2149
- "192k",
2150
- "-ar",
2151
- "48000",
2152
- "-shortest",
2153
- ...format.extension === ".mp4" || format.extension === ".mov" ? ["-movflags", "+faststart"] : [],
2154
- output
2155
- );
2156
- await run(ffmpeg, args, options.signal);
2157
2361
  }
2158
- options.onTimings?.({
2159
- captureMs: Math.round(captureMs),
2160
- encodeMs: Math.round(performance.now() - muxStart),
2161
- frames: target.durationInFrames,
2162
- reusedFrames: stats.reused,
2163
- cachedChunks: stats.cachedChunks,
2164
- chunks: chunks.length,
2165
- concurrency: lanes.length
2166
- });
2167
- succeeded = true;
2168
- return output;
2169
- } finally {
2170
- if (succeeded && !options.workDir) await rm4(work, { recursive: true, force: true });
2171
- 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}`);
2172
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 };
2173
2406
  };
2174
2407
 
2175
2408
  // src/open.ts
2176
- import { spawn as spawn3 } from "child_process";
2409
+ import { spawn as spawn4 } from "child_process";
2177
2410
  var command = () => {
2178
2411
  if (process.platform === "darwin") return { bin: "open", args: [] };
2179
2412
  if (process.platform === "win32") return { bin: "cmd", args: ["/c", "start", ""] };
@@ -2190,7 +2423,7 @@ var openInBrowser = (url) => {
2190
2423
  const resolved = command();
2191
2424
  if (!resolved) return;
2192
2425
  try {
2193
- 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 });
2194
2427
  child.on("error", () => {
2195
2428
  });
2196
2429
  child.unref();
@@ -2199,25 +2432,25 @@ var openInBrowser = (url) => {
2199
2432
  };
2200
2433
 
2201
2434
  // src/server.ts
2202
- import { existsSync as existsSync15 } from "fs";
2435
+ import { existsSync as existsSync16 } from "fs";
2203
2436
  import { createRequire as createRequire2 } from "module";
2204
2437
  import { fileURLToPath } from "url";
2205
2438
  import { createServer } from "vite";
2206
2439
  import react from "@vitejs/plugin-react";
2207
- import { readFile as readFile12 } from "fs/promises";
2208
- import { dirname as dirname6, resolve as resolve17, sep as sep3 } from "path";
2209
- var cliRoot = resolve17(dirname6(fileURLToPath(import.meta.url)), "..");
2210
- var studioRoot = resolve17(cliRoot, "studio");
2211
- var studioEntry = resolve17(studioRoot, "index.html");
2212
- 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, "..", "..");
2213
2446
  var VIRTUAL_ID = "virtual:odori-project";
2214
2447
  var RESOLVED_ID = `\0${VIRTUAL_ID}`;
2215
2448
  var runtimeSource = (root) => {
2216
- for (const from of [resolve17(root, "package.json"), import.meta.url]) {
2449
+ for (const from of [resolve18(root, "package.json"), import.meta.url]) {
2217
2450
  try {
2218
2451
  const manifest = createRequire2(from).resolve("odori/package.json");
2219
- const src = resolve17(manifest, "..", "src");
2220
- if (existsSync15(resolve17(src, "index.tsx"))) return src;
2452
+ const src = resolve18(manifest, "..", "src");
2453
+ if (existsSync16(resolve18(src, "index.tsx"))) return src;
2221
2454
  } catch {
2222
2455
  }
2223
2456
  }
@@ -2340,15 +2573,15 @@ var startStudioServer = async (initialConfig, options = {}) => {
2340
2573
  ],
2341
2574
  // The project's public/ directory is served at the root, so brand fonts,
2342
2575
  // logos, and footage resolve identically in preview and render.
2343
- publicDir: existsSync15(resolve17(config.root, "public")) ? resolve17(config.root, "public") : false,
2576
+ publicDir: existsSync16(resolve18(config.root, "public")) ? resolve18(config.root, "public") : false,
2344
2577
  resolve: {
2345
2578
  dedupe: ["react", "react-dom", "odori"],
2346
2579
  // Only when the runtime is present as source. A consumer resolves the
2347
2580
  // published package through its exports map instead.
2348
2581
  alias: odoriSrc ? [
2349
- { find: /^odori\/preview$/, replacement: resolve17(odoriSrc, "preview.ts") },
2350
- { find: /^odori\/manifest$/, replacement: resolve17(odoriSrc, "manifest.ts") },
2351
- { 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") }
2352
2585
  ] : []
2353
2586
  },
2354
2587
  server: {
@@ -2378,7 +2611,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2378
2611
  }, 150);
2379
2612
  };
2380
2613
  const rediscover = async (file) => {
2381
- if (!file.startsWith(resolve17(config.root, config.videosDir))) return;
2614
+ if (!file.startsWith(resolve18(config.root, config.videosDir))) return;
2382
2615
  const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep3}brands${sep3}`);
2383
2616
  if (!isEntry) return;
2384
2617
  try {
@@ -2395,7 +2628,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2395
2628
  };
2396
2629
  vite.watcher.on("add", (file) => void rediscover(file));
2397
2630
  vite.watcher.on("unlink", (file) => void rediscover(file));
2398
- vite.watcher.add(resolve17(config.root, config.videosDir));
2631
+ vite.watcher.add(resolve18(config.root, config.videosDir));
2399
2632
  const reloadConfig = async (file) => {
2400
2633
  if (!/odori\.config\.(?:ts|mjs|js)$/.test(file)) return;
2401
2634
  try {
@@ -2412,14 +2645,14 @@ var startStudioServer = async (initialConfig, options = {}) => {
2412
2645
  vite.ws.send({ type: "full-reload" });
2413
2646
  };
2414
2647
  const refreshCues = (file) => {
2415
- if (!file.startsWith(resolve17(config.root, config.videosDir) + sep3)) return;
2648
+ if (!file.startsWith(resolve18(config.root, config.videosDir) + sep3)) return;
2416
2649
  if (!/\.tsx?$/.test(file)) return;
2417
2650
  scheduleCueRefresh();
2418
2651
  };
2419
2652
  vite.watcher.on("change", (file) => refreshCues(file));
2420
2653
  vite.watcher.on("change", (file) => void reloadConfig(file));
2421
2654
  for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
2422
- vite.watcher.add(resolve17(config.root, name));
2655
+ vite.watcher.add(resolve18(config.root, name));
2423
2656
  }
2424
2657
  vite.middlewares.use(async (request, response, next) => {
2425
2658
  const url = (request.url ?? "/").split("?")[0];
@@ -2429,7 +2662,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2429
2662
  return;
2430
2663
  }
2431
2664
  try {
2432
- const html = await readFile12(studioEntry, "utf8");
2665
+ const html = await readFile13(studioEntry, "utf8");
2433
2666
  response.statusCode = 200;
2434
2667
  response.setHeader("content-type", "text/html");
2435
2668
  response.end(await vite.transformIndexHtml(url, html));
@@ -2450,7 +2683,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2450
2683
  };
2451
2684
 
2452
2685
  // src/commands/exportVideo.ts
2453
- import { resolve as resolve18 } from "path";
2686
+ import { resolve as resolve19 } from "path";
2454
2687
  import { resolveEntryLayout as resolveEntryLayout4 } from "odori";
2455
2688
 
2456
2689
  // src/commands/shared.ts
@@ -2566,6 +2799,7 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
2566
2799
  scale: options.scale ?? record.render?.scale,
2567
2800
  format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : void 0),
2568
2801
  audio: options.audio ?? record.render?.audio,
2802
+ graphics: options.graphics ?? record.render?.graphics,
2569
2803
  skipUnchangedFrames: options.skipUnchangedFrames,
2570
2804
  signal: controller.signal,
2571
2805
  onTimings: (timings) => {
@@ -2620,7 +2854,7 @@ var exportCommand = async (id, options = {}) => {
2620
2854
  options.input ?? {},
2621
2855
  { scenes: compiled.scenes, audio: compiled.audio }
2622
2856
  );
2623
- const output = resolve18(
2857
+ const output = resolve19(
2624
2858
  config.root,
2625
2859
  options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
2626
2860
  );
@@ -2629,11 +2863,16 @@ var exportCommand = async (id, options = {}) => {
2629
2863
  quality,
2630
2864
  scale,
2631
2865
  audio: options.audio !== false,
2866
+ graphics: options.fast ? "gpu" : "software",
2632
2867
  ...options.preset ? { preset: options.preset } : {}
2633
2868
  });
2634
2869
  })();
2635
2870
  const video = findVideo(videos, record.manifest.videoId);
2636
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
+ );
2637
2876
  if (options.retry) log.detail(`retrying attempt ${record.job.attempts + 1} from the frozen manifest`);
2638
2877
  if (record.manifest.audio.length > 0) {
2639
2878
  log.detail(`${record.manifest.audio.length} audio cue(s) at ${record.manifest.format.durationInFrames} frames`);
@@ -2646,6 +2885,7 @@ var exportCommand = async (id, options = {}) => {
2646
2885
  scale: options.retry && options.scale === void 0 ? void 0 : scale,
2647
2886
  format: options.retry ? void 0 : format,
2648
2887
  audio: options.audio,
2888
+ graphics: options.fast ? "gpu" : void 0,
2649
2889
  skipUnchangedFrames: options.skipUnchangedFrames,
2650
2890
  onProgress: (next) => {
2651
2891
  if (next.status === "rendering" || next.status === "encoding") {
@@ -2688,8 +2928,8 @@ var json = (response, status, payload) => {
2688
2928
  response.end(JSON.stringify(payload));
2689
2929
  };
2690
2930
  var exportDestination = (config) => {
2691
- const downloads = resolve19(homedir2(), "Downloads");
2692
- 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);
2693
2933
  };
2694
2934
  var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
2695
2935
  var hostOf = (value) => {
@@ -2748,7 +2988,7 @@ var devCommand = async (options = {}) => {
2748
2988
  );
2749
2989
  const frame = Number(body.frame ?? 0);
2750
2990
  const inline = body.inline === true;
2751
- 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`);
2752
2992
  await renderStill(
2753
2993
  origin,
2754
2994
  targetFor(
@@ -2764,7 +3004,7 @@ var devCommand = async (options = {}) => {
2764
3004
  if (inline) {
2765
3005
  response.statusCode = 200;
2766
3006
  response.setHeader("content-type", "image/png");
2767
- response.end(await readFile13(file));
3007
+ response.end(await readFile14(file));
2768
3008
  return;
2769
3009
  }
2770
3010
  json(response, 200, { id: "still", status: "ready", progress: 1, output: file });
@@ -2786,7 +3026,7 @@ var devCommand = async (options = {}) => {
2786
3026
  input,
2787
3027
  { scenes: compiled.scenes, audio: compiled.audio }
2788
3028
  );
2789
- const output = resolve19(
3029
+ const output = resolve20(
2790
3030
  exportDestination(config),
2791
3031
  `${outputName(video.entry.metadata.id)}${format.extension}`
2792
3032
  );
@@ -2822,8 +3062,8 @@ var devCommand = async (options = {}) => {
2822
3062
  }
2823
3063
  if (request.method === "GET" && url.startsWith("/source")) {
2824
3064
  const asked = new URL(url, "http://localhost").searchParams.get("file") ?? "";
2825
- const file = resolve19(config.root, asked);
2826
- const inside = relative7(config.root, file);
3065
+ const file = resolve20(config.root, asked);
3066
+ const inside = relative8(config.root, file);
2827
3067
  const readable = /\.(tsx?|jsx?|css|json|md)$/.test(file);
2828
3068
  if (!inside || inside.startsWith("..") || !readable) {
2829
3069
  json(response, 400, { error: `Refusing to read ${asked}.` });
@@ -2832,12 +3072,75 @@ var devCommand = async (options = {}) => {
2832
3072
  try {
2833
3073
  response.statusCode = 200;
2834
3074
  response.setHeader("content-type", "text/plain; charset=utf-8");
2835
- response.end(await readFile13(file, "utf8"));
3075
+ response.end(await readFile14(file, "utf8"));
2836
3076
  } catch {
2837
3077
  json(response, 404, { error: `${asked} is not there.` });
2838
3078
  }
2839
3079
  return;
2840
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
+ }
2841
3144
  if (request.method === "GET" && url.startsWith("/jobs")) {
2842
3145
  json(response, 200, await listJobs(config));
2843
3146
  return;
@@ -2862,16 +3165,16 @@ var devCommand = async (options = {}) => {
2862
3165
 
2863
3166
  // src/commands/doctor.ts
2864
3167
  import { constants } from "fs";
2865
- import { access, mkdir as mkdir13, readFile as readFile14, rm as rm5, writeFile as writeFile14 } from "fs/promises";
2866
- 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";
2867
3170
  import { createRequire as createRequire3 } from "module";
2868
- import { relative as relative8, resolve as resolve20 } from "path";
3171
+ import { relative as relative9, resolve as resolve21 } from "path";
2869
3172
  var MINIMUM_NODE = 20;
2870
3173
  var version = (value) => value.replace(/^v/, "").split(".").map(Number);
2871
3174
  var runChecks = async (root) => {
2872
3175
  const checks = [];
2873
3176
  const config = await loadConfig(root);
2874
- const require2 = createRequire3(resolve20(root, "package.json"));
3177
+ const require2 = createRequire3(resolve21(root, "package.json"));
2875
3178
  const [major] = version(process.version);
2876
3179
  checks.push({
2877
3180
  name: "Node",
@@ -2882,7 +3185,7 @@ var runChecks = async (root) => {
2882
3185
  let react2 = "not found";
2883
3186
  let reactOk = false;
2884
3187
  try {
2885
- 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"));
2886
3189
  react2 = manifest.version;
2887
3190
  reactOk = version(react2)[0] >= 19;
2888
3191
  } catch {
@@ -2894,16 +3197,16 @@ var runChecks = async (root) => {
2894
3197
  ok: reactOk,
2895
3198
  fix: "The runtime needs React 19. Install it: npm install react@19 react-dom@19"
2896
3199
  });
2897
- const videosDir = resolve20(config.root, config.videosDir);
3200
+ const videosDir = resolve21(config.root, config.videosDir);
2898
3201
  checks.push({
2899
3202
  name: "Source root",
2900
- detail: existsSync17(videosDir) ? relative8(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2901
- ok: existsSync17(videosDir),
3203
+ detail: existsSync18(videosDir) ? relative9(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
3204
+ ok: existsSync18(videosDir),
2902
3205
  fix: 'Run "odori init" to add the videos source root.'
2903
3206
  });
2904
3207
  checks.push({
2905
3208
  name: "Config",
2906
- 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)",
2907
3210
  // Loading got this far, so a config that exists also parsed.
2908
3211
  ok: true
2909
3212
  });
@@ -2929,25 +3232,25 @@ var runChecks = async (root) => {
2929
3232
  detail: unpinned.length === 0 ? `pinned binaries from ${cacheRoot()}` : `${unpinned.length} of 2 from the host; frames may differ from another machine`,
2930
3233
  ok: true
2931
3234
  });
2932
- const generated = resolve20(config.root, ".odori");
3235
+ const generated = resolve21(config.root, ".odori");
2933
3236
  let writable = false;
2934
3237
  try {
2935
- await mkdir13(generated, { recursive: true });
2936
- const probe = resolve20(generated, ".doctor");
2937
- await writeFile14(probe, "", "utf8");
3238
+ await mkdir15(generated, { recursive: true });
3239
+ const probe = resolve21(generated, ".doctor");
3240
+ await writeFile16(probe, "", "utf8");
2938
3241
  await access(probe, constants.W_OK);
2939
- await rm5(probe, { force: true });
3242
+ await rm6(probe, { force: true });
2940
3243
  writable = true;
2941
3244
  } catch {
2942
3245
  writable = false;
2943
3246
  }
2944
- const componentsRoot = resolve20(root, config.componentsDir);
3247
+ const componentsRoot = resolve21(root, config.componentsDir);
2945
3248
  const orphans = [];
2946
- if (existsSync17(componentsRoot)) {
3249
+ if (existsSync18(componentsRoot)) {
2947
3250
  const { readdir: readdir9 } = await import("fs/promises");
2948
3251
  for (const entry of await readdir9(componentsRoot, { withFileTypes: true })) {
2949
3252
  if (!entry.isDirectory()) continue;
2950
- const files = await readdir9(resolve20(componentsRoot, entry.name));
3253
+ const files = await readdir9(resolve21(componentsRoot, entry.name));
2951
3254
  const source = files.some((file) => /\.tsx$/.test(file) && !file.endsWith(".preview.tsx"));
2952
3255
  const fixture = files.some((file) => file.endsWith(".preview.tsx"));
2953
3256
  if (source && !fixture) orphans.push(entry.name);
@@ -2960,11 +3263,21 @@ var runChecks = async (root) => {
2960
3263
  warn: orphans.length > 0,
2961
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.`
2962
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
+ }
2963
3276
  checks.push({
2964
3277
  name: "Generated cache",
2965
3278
  detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
2966
3279
  ok: writable,
2967
- 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.`
2968
3281
  });
2969
3282
  return checks;
2970
3283
  };
@@ -2991,14 +3304,14 @@ var doctorCommand = async (root = process.cwd()) => {
2991
3304
  };
2992
3305
 
2993
3306
  // src/commands/init.ts
2994
- import { mkdir as mkdir15, readFile as readFile15, writeFile as writeFile16 } from "fs/promises";
2995
- import { existsSync as existsSync19 } from "fs";
2996
- 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";
2997
3310
 
2998
3311
  // src/commands/new.ts
2999
- import { mkdir as mkdir14, readdir as readdir6, writeFile as writeFile15 } from "fs/promises";
3000
- import { existsSync as existsSync18 } from "fs";
3001
- 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";
3002
3315
  var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
3003
3316
  var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
3004
3317
  var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
@@ -3056,27 +3369,27 @@ ${closing}
3056
3369
  `;
3057
3370
  };
3058
3371
  var installedParts = async (config) => {
3059
- const componentsDir = resolve21(config.root, config.componentsDir);
3060
- 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 };
3061
3374
  const entries = (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
3062
3375
  return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
3063
3376
  };
3064
3377
  var newCommand = async (name, options = {}) => {
3065
3378
  if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("Use a lowercase, dash separated name.");
3066
3379
  const config = await loadConfig(process.cwd());
3067
- const directory2 = resolve21(config.root, config.videosDir, name);
3068
- const file = resolve21(directory2, "video.tsx");
3069
- if (existsSync18(file)) throw new Error(`${relative9(config.root, file)} already exists.`);
3070
- 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"));
3071
3384
  const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
3072
3385
  const composed = parts.title || parts.end;
3073
- await mkdir14(directory2, { recursive: true });
3074
- await writeFile15(
3386
+ await mkdir16(directory2, { recursive: true });
3387
+ await writeFile17(
3075
3388
  file,
3076
3389
  composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
3077
3390
  "utf8"
3078
3391
  );
3079
- log.success(`Created ${relative9(config.root, file)}`);
3392
+ log.success(`Created ${relative10(config.root, file)}`);
3080
3393
  if (composed) log.detail("Composed from the components this project has installed.");
3081
3394
  else if (options.blank !== true) {
3082
3395
  log.detail("No registry components installed yet: odori add title-reveal end-card");
@@ -3110,45 +3423,58 @@ export const productLayout = defineVideoLayout({
3110
3423
  });
3111
3424
  `;
3112
3425
  var initCommand = async (root = process.cwd()) => {
3113
- const videosDir = resolve22(root, defaultConfig.videosDir);
3114
- await mkdir15(resolve22(videosDir, "components"), { recursive: true });
3426
+ const videosDir = resolve23(root, defaultConfig.videosDir);
3427
+ await mkdir17(resolve23(videosDir, "components"), { recursive: true });
3115
3428
  const files = [
3116
- [resolve22(root, "odori.config.ts"), CONFIG_TEMPLATE],
3117
- [resolve22(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
3118
- [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)]
3119
3432
  ];
3120
3433
  for (const [file, contents] of files) {
3121
- if (existsSync19(file)) {
3122
- log.detail(`Kept existing ${relative10(root, file)}`);
3434
+ if (existsSync20(file)) {
3435
+ log.detail(`Kept existing ${relative11(root, file)}`);
3123
3436
  continue;
3124
3437
  }
3125
- await mkdir15(resolve22(file, ".."), { recursive: true });
3126
- await writeFile16(file, contents, "utf8");
3127
- 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)}`);
3128
3441
  }
3129
3442
  await ensureModuleType(root);
3130
3443
  log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
3131
3444
  };
3132
3445
  var ensureModuleType = async (root) => {
3133
- const file = resolve22(root, "package.json");
3134
- if (!existsSync19(file)) {
3446
+ const file = resolve23(root, "package.json");
3447
+ if (!existsSync20(file)) {
3135
3448
  log.warn('No package.json here. Odori needs an ESM package: run npm init, then add "type": "module".');
3136
3449
  return;
3137
3450
  }
3138
3451
  let manifest;
3139
3452
  try {
3140
- manifest = JSON.parse(await readFile15(file, "utf8"));
3453
+ manifest = JSON.parse(await readFile16(file, "utf8"));
3141
3454
  } catch {
3142
3455
  log.warn('package.json is not readable JSON, so "type": "module" was not set. Odori needs it.');
3143
3456
  return;
3144
3457
  }
3145
3458
  if (manifest.type === "module") return;
3146
3459
  manifest.type = "module";
3147
- await writeFile16(file, `${JSON.stringify(manifest, null, 2)}
3460
+ await writeFile18(file, `${JSON.stringify(manifest, null, 2)}
3148
3461
  `, "utf8");
3149
3462
  log.success('Set "type": "module" in package.json');
3150
3463
  };
3151
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
+
3152
3478
  // src/commands/inspect.ts
3153
3479
  import { isOdoriSchema, resolveEntryLayout as resolveEntryLayout5 } from "odori";
3154
3480
  var inspectCommand = async (id, options = {}) => {
@@ -3236,7 +3562,7 @@ var listCommand = async () => {
3236
3562
  };
3237
3563
 
3238
3564
  // src/commands/frame.ts
3239
- import { resolve as resolve23 } from "path";
3565
+ import { resolve as resolve24 } from "path";
3240
3566
  import { framesFromOffset, resolveEntryLayout as resolveEntryLayout7 } from "odori";
3241
3567
  var frameCommand = async (id, options = {}) => {
3242
3568
  const at = options.at ?? 0;
@@ -3258,7 +3584,7 @@ var frameCommand = async (id, options = {}) => {
3258
3584
  throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
3259
3585
  }
3260
3586
  const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
3261
- 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`);
3262
3588
  return renderStill(server.url, target, frame, file, config);
3263
3589
  });
3264
3590
  log.success(`Frame ${frame} written to ${output}`);
@@ -3269,9 +3595,9 @@ var frameCommand = async (id, options = {}) => {
3269
3595
  import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout9 } from "odori";
3270
3596
 
3271
3597
  // src/contracts.ts
3272
- import { existsSync as existsSync20 } from "fs";
3598
+ import { existsSync as existsSync21 } from "fs";
3273
3599
  import { readdir as readdir7 } from "fs/promises";
3274
- import { resolve as resolve24 } from "path";
3600
+ import { resolve as resolve25 } from "path";
3275
3601
  import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3276
3602
  var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
3277
3603
  var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
@@ -3338,8 +3664,8 @@ var checkAudioWindows = (cues, brand, videoId) => {
3338
3664
  return failures;
3339
3665
  };
3340
3666
  var checkInstalledContracts = async (config, videos) => {
3341
- const componentsDir = resolve24(config.root, config.componentsDir);
3342
- 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) : [];
3343
3669
  const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
3344
3670
  if (names.size === 0) return [];
3345
3671
  const { items } = await resolveRegistry(config, { allowNetwork: false });
@@ -3360,9 +3686,9 @@ var checkInstalledContracts = async (config, videos) => {
3360
3686
  };
3361
3687
 
3362
3688
  // src/determinism.ts
3363
- import { readdir as readdir8, readFile as readFile16 } from "fs/promises";
3364
- import { existsSync as existsSync21 } from "fs";
3365
- 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";
3366
3692
  var FORBIDDEN = [
3367
3693
  {
3368
3694
  pattern: /\bMath\.random\s*\(/,
@@ -3396,18 +3722,18 @@ var scanSource = (source, file) => {
3396
3722
  var walk2 = async (directory2, files = []) => {
3397
3723
  for (const entry of await readdir8(directory2, { withFileTypes: true })) {
3398
3724
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
3399
- const full = join7(directory2, entry.name);
3725
+ const full = join9(directory2, entry.name);
3400
3726
  if (entry.isDirectory()) await walk2(full, files);
3401
3727
  else if (/\.(tsx|ts|jsx|js)$/.test(entry.name) && !/\.preview\.(tsx|jsx)$/.test(entry.name)) files.push(full);
3402
3728
  }
3403
3729
  return files;
3404
3730
  };
3405
3731
  var checkDeterminism = async (config) => {
3406
- const root = resolve25(config.root, config.videosDir);
3407
- if (!existsSync21(root)) return [];
3732
+ const root = resolve26(config.root, config.videosDir);
3733
+ if (!existsSync22(root)) return [];
3408
3734
  const files = await walk2(root);
3409
3735
  const findings = await Promise.all(
3410
- 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)))
3411
3737
  );
3412
3738
  return findings.flat();
3413
3739
  };
@@ -3478,12 +3804,13 @@ var CANVAS_SCRIPT = `(() => {
3478
3804
  })()`;
3479
3805
  var FRAME_SCRIPT = `(() => {
3480
3806
  var root = document.querySelector("[data-odori-video]");
3481
- if (!root) return {overflow: [], small: [], empty: true, painted: 0};
3807
+ if (!root) return {overflow: [], small: [], empty: true, painted: 0, faded: 0};
3482
3808
 
3483
3809
  var bounds = root.getBoundingClientRect();
3484
3810
  var overflow = [];
3485
3811
  var small = [];
3486
3812
  var painted = 0;
3813
+ var faded = 0;
3487
3814
  var nodes = Array.prototype.slice.call(root.querySelectorAll("*"));
3488
3815
 
3489
3816
  for (var index = 0; index < nodes.length; index += 1) {
@@ -3496,7 +3823,22 @@ var FRAME_SCRIPT = `(() => {
3496
3823
  var box = node.getBoundingClientRect();
3497
3824
  if (box.width === 0 || box.height === 0) continue;
3498
3825
  var style = getComputedStyle(node);
3499
- 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
+ }
3500
3842
  painted += 1;
3501
3843
 
3502
3844
  var media = ["IMG", "SVG", "CANVAS", "VIDEO"].indexOf(node.tagName) >= 0;
@@ -3521,12 +3863,27 @@ var FRAME_SCRIPT = `(() => {
3521
3863
  * content the video is actually about, which is what stays checked.
3522
3864
  */
3523
3865
  var chrome = node.closest("[data-odori-chrome]") !== null;
3524
- if (
3525
- box.right > bounds.right + 1 ||
3526
- box.left < bounds.left - 1 ||
3527
- box.bottom > bounds.bottom + 1 ||
3528
- box.top < bounds.top - 1
3529
- ) {
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) {
3530
3887
  if (overflow.indexOf(label) < 0) overflow.push(label);
3531
3888
  }
3532
3889
 
@@ -3550,7 +3907,7 @@ var FRAME_SCRIPT = `(() => {
3550
3907
  }
3551
3908
  }
3552
3909
 
3553
- 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};
3554
3911
  })()`;
3555
3912
  var testVideo = async (origin, video, config, failures, quiet = false) => {
3556
3913
  const id = video.entry.metadata.id;
@@ -3559,7 +3916,7 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
3559
3916
  const result = video.entry.metadata.schema.safeParse(video.entry.metadata.defaultProps ?? {});
3560
3917
  if (!result.success) failures.push({ video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}` });
3561
3918
  }
3562
- const { browser, page } = await openRenderPage(origin, targetFor(video), config);
3919
+ const { browser, page, errors } = await openRenderPage(origin, targetFor(video), config);
3563
3920
  try {
3564
3921
  const timeline = await readTimeline(page);
3565
3922
  failures.push(...checkAudioWindows((await readAudio(page)).cues, layout.brand, id));
@@ -3576,16 +3933,16 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
3576
3933
  }
3577
3934
  const samples = [0, Math.floor(total / 4), Math.floor(total / 2), Math.floor(total * 3 / 4), total - 1];
3578
3935
  for (const frame of [...new Set(samples)]) {
3579
- await seekTo(page, frame);
3936
+ await seekTo(page, frame, errors);
3580
3937
  const result = await page.evaluate(FRAME_SCRIPT);
3581
3938
  if (result.empty) failures.push({ video: id, message: `Frame ${frame} rendered no video root.` });
3582
- if (!result.empty && result.painted < 2) {
3939
+ if (!result.empty && result.painted < 2 && result.faded < 2) {
3583
3940
  failures.push({ video: id, message: `Frame ${frame} is blank.` });
3584
3941
  }
3585
3942
  for (const item of result.overflow) {
3586
3943
  failures.push({
3587
3944
  video: id,
3588
- 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.`
3589
3946
  });
3590
3947
  }
3591
3948
  for (const item of result.small) {
@@ -3593,8 +3950,8 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
3593
3950
  }
3594
3951
  const canvases = await page.evaluate(CANVAS_SCRIPT);
3595
3952
  if (canvases.length > 0) {
3596
- await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1);
3597
- 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);
3598
3955
  const again = await page.evaluate(CANVAS_SCRIPT);
3599
3956
  for (const canvas of canvases) {
3600
3957
  const second = again.find((item) => item.index === canvas.index);
@@ -3666,7 +4023,7 @@ var testCommand = async (id, options = {}) => {
3666
4023
  };
3667
4024
 
3668
4025
  // src/cli.ts
3669
- 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"]);
3670
4027
  var RENAMED = { still: "frame" };
3671
4028
  var parseArgs = (argv) => {
3672
4029
  const [command2 = "help", ...rest] = argv;
@@ -3720,13 +4077,15 @@ var COMMAND_FLAGS = {
3720
4077
  new: ["blank"],
3721
4078
  add: ["force", "dry-run"],
3722
4079
  registry: [],
4080
+ integrations: [],
3723
4081
  diff: ["full"],
3724
4082
  update: ["force"],
3725
4083
  list: [],
3726
4084
  inspect: ["json", "input"],
3727
4085
  frame: ["at", "output", "input"],
4086
+ bed: ["role", "output", "target", "generate", "provider", "seconds"],
3728
4087
  test: ["json"],
3729
- 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"],
3730
4089
  jobs: [],
3731
4090
  help: []
3732
4091
  };
@@ -3765,6 +4124,16 @@ var USAGE = {
3765
4124
  Discover project resources and start Studio.`,
3766
4125
  init: `odori init
3767
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".`,
3768
4137
  doctor: `odori doctor
3769
4138
  Check Node, React, the source root, Chrome, FFmpeg, and the generated cache.`,
3770
4139
  install: `odori install
@@ -3795,11 +4164,15 @@ var USAGE = {
3795
4164
  check, for CI.`,
3796
4165
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
3797
4166
  [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
3798
- [--no-audio] [--no-frame-skip] [--retry <job>]
4167
+ [--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
3799
4168
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
3800
4169
  or png; without it the output's extension decides, and mp4 is the default.
3801
4170
  --quality is studio, social, or web. --scale multiplies the output size,
3802
- 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
3803
4176
  settings its job was created with.`,
3804
4177
  jobs: `odori jobs
3805
4178
  List export jobs and their status.`
@@ -3847,7 +4220,7 @@ var cliVersion = () => {
3847
4220
  return "unknown";
3848
4221
  }
3849
4222
  };
3850
- var run2 = async (argv) => {
4223
+ var run3 = async (argv) => {
3851
4224
  const { command: command2, positionals, flags } = parseArgs(argv);
3852
4225
  try {
3853
4226
  if (command2 === "--version" || command2 === "-v" || command2 === "version") {
@@ -3877,6 +4250,9 @@ var run2 = async (argv) => {
3877
4250
  case "init":
3878
4251
  await initCommand();
3879
4252
  return 0;
4253
+ case "integrations":
4254
+ await integrationsCommand();
4255
+ return 0;
3880
4256
  case "doctor":
3881
4257
  return await doctorCommand();
3882
4258
  case "install":
@@ -3902,6 +4278,17 @@ var run2 = async (argv) => {
3902
4278
  case "inspect":
3903
4279
  await inspectCommand(positionals[0] ?? "", { json: flags.json === true, input: parseInput(flags) });
3904
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;
3905
4292
  case "frame":
3906
4293
  await frameCommand(positionals[0] ?? "", {
3907
4294
  // A duration, so "4s" and "120f" both work; a bare number is
@@ -3924,6 +4311,7 @@ var run2 = async (argv) => {
3924
4311
  scale: numberFlag(flags, "scale"),
3925
4312
  format: typeof flags.format === "string" ? flags.format : void 0,
3926
4313
  audio: flags["no-audio"] === true ? false : void 0,
4314
+ fast: flags.fast === true,
3927
4315
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
3928
4316
  retry: typeof flags.retry === "string" ? flags.retry : void 0
3929
4317
  });
@@ -4032,5 +4420,5 @@ export {
4032
4420
  initCommand,
4033
4421
  parseArgs,
4034
4422
  checkFlags,
4035
- run2 as run
4423
+ run3 as run
4036
4424
  };