@odori/cli 0.0.7 → 0.0.9

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-TKH2KAC3.js");
321
321
  return loaded.default.items;
322
322
  } catch {
323
323
  throw new Error(
@@ -809,553 +809,438 @@ 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."
834
+ var writeStore = async (store) => {
835
+ const path = storePath();
836
+ if (Object.keys(store).length === 0) {
837
+ await rm3(path, { force: true });
838
+ return;
839
+ }
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;
859
+ };
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)
898
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
+ );
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 };
899
894
  }
900
- return stale.length;
901
895
  };
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;
896
+ var elevenlabsVoice = {
897
+ name: "elevenlabs",
898
+ title: "ElevenLabs Speech",
899
+ docsUrl: "https://elevenlabs.io/docs/api-reference/text-to-speech",
900
+ keyVariable: "ELEVENLABS_API_KEY",
901
+ // Rachel, the provider's most neutral narrator. --voice overrides.
902
+ defaultVoice: "21m00Tcm4TlvDq8ikWAM",
903
+ verifyKey: (apiKey) => elevenlabs.verifyKey(apiKey),
904
+ async speak({ script, voice, apiKey }) {
905
+ const response = await fetch(
906
+ `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}/with-timestamps?output_format=mp3_44100_128`,
907
+ {
908
+ method: "POST",
909
+ headers: { "xi-api-key": apiKey, "content-type": "application/json" },
910
+ body: JSON.stringify({ text: script, model_id: "eleven_multilingual_v2" }),
911
+ signal: AbortSignal.timeout(3e5)
912
+ }
913
+ );
914
+ if (!response.ok) {
915
+ const detail = await response.text().catch(() => "");
916
+ throw new Error(
917
+ `ElevenLabs returned ${response.status} ${response.statusText}.` + (detail ? `
918
+ ${detail.slice(0, 400)}` : "") + (response.status === 401 ? `
919
+ Is ${elevenlabsVoice.keyVariable} a current key?` : "")
920
+ );
913
921
  }
922
+ const payload = await response.json();
923
+ return {
924
+ bytes: Uint8Array.from(Buffer.from(payload.audio_base64, "base64")),
925
+ extension: "mp3",
926
+ alignment: {
927
+ characters: payload.alignment.characters,
928
+ startSeconds: payload.alignment.character_start_times_seconds,
929
+ endSeconds: payload.alignment.character_end_times_seconds
930
+ }
931
+ };
914
932
  }
915
- return jobs.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
916
933
  };
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;
934
+ var voiceProviders = { elevenlabs: elevenlabsVoice };
935
+ var resolveVoiceProvider = (name) => {
936
+ const provider = voiceProviders[name];
937
+ if (!provider) {
938
+ throw new Error(
939
+ `No voice provider named "${name}". Available: ${Object.keys(voiceProviders).join(", ")}.`
940
+ );
941
+ }
942
+ return provider;
943
+ };
944
+ var musicProviders = { elevenlabs };
945
+ var resolveMusicProvider = (name) => {
946
+ const provider = musicProviders[name];
947
+ if (!provider) {
948
+ throw new Error(
949
+ `No music provider named "${name}". Available: ${Object.keys(musicProviders).join(", ")}.`
950
+ );
923
951
  }
952
+ return provider;
924
953
  };
954
+ var resolveKey = async (provider) => process.env[provider.keyVariable] ?? await storedKey(provider.keyVariable);
925
955
 
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);
956
+ // src/commands/bed.ts
957
+ import { spawn as spawn3 } from "child_process";
958
+ import { mkdir as mkdir10, stat, writeFile as writeFile11 } from "fs/promises";
959
+ import { basename as basename2, dirname as dirname6, extname, join as join5, relative as relative6, resolve as resolve12 } from "path";
960
+
961
+ // src/render.ts
962
+ import { spawn as spawn2 } from "child_process";
963
+ import { copyFile as copyFile2, mkdir as mkdir9, rm as rm4, writeFile as writeFile10 } from "fs/promises";
964
+ import { cpus } from "os";
965
+ import { dirname as dirname5, join as join4, resolve as resolve11 } from "path";
966
+ import { chromium } from "playwright-core";
967
+
968
+ // src/audio-mix.ts
969
+ import { existsSync as existsSync10 } from "fs";
970
+ import { resolve as resolve9 } from "path";
971
+ import { duckEnvelope, envelopeAtFrame } from "odori";
972
+
973
+ // src/cues.ts
974
+ import { existsSync as existsSync9, statSync } from "fs";
975
+ import { mkdir as mkdir7, writeFile as writeFile8 } from "fs/promises";
976
+ import { basename, resolve as resolve8 } from "path";
977
+ import { pathToFileURL as pathToFileURL2 } from "url";
978
+ import {
979
+ SAMPLE_RATE,
980
+ cueSamples,
981
+ cueUrl,
982
+ defaultLayout,
983
+ encodeWav,
984
+ isCueDefinition,
985
+ resolveEntryLayout
986
+ } from "odori";
987
+ var cueCacheDir = (config) => resolve8(config.root, config.outDir, "cues");
988
+ var cueFile = (config, url) => resolve8(cueCacheDir(config), basename(url));
989
+ var materializeCues = async (config, brands, fps) => {
990
+ const seen = /* @__PURE__ */ new Map();
991
+ for (const brand of brands) {
992
+ for (const value of Object.values(brand.audio.cues)) {
993
+ if (isCueDefinition(value)) seen.set(cueUrl(value), value);
994
+ }
939
995
  }
940
- return files;
996
+ if (seen.size === 0) return [];
997
+ await mkdir7(cueCacheDir(config), { recursive: true });
998
+ const written = [];
999
+ for (const [url, cue] of seen) {
1000
+ const file = cueFile(config, url);
1001
+ if (existsSync9(file)) {
1002
+ written.push({ cue, file, rendered: false });
1003
+ continue;
1004
+ }
1005
+ const samples = cueSamples(cue, fps);
1006
+ const signal = cue.render({ samples, sampleRate: SAMPLE_RATE });
1007
+ await writeFile8(file, encodeWav(signal));
1008
+ written.push({ cue, file, rendered: true });
1009
+ }
1010
+ return written;
941
1011
  };
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)}`;
1012
+ var known = /* @__PURE__ */ new Map();
1013
+ var rendered = /* @__PURE__ */ new Map();
1014
+ var registerCues = (brands, fps) => {
1015
+ for (const brand of brands) {
1016
+ for (const value of Object.values(brand.audio.cues)) {
1017
+ if (isCueDefinition(value)) known.set(cueUrl(value), { cue: value, fps });
1018
+ }
1019
+ }
1020
+ return known.size;
948
1021
  };
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
- );
1022
+ var renderedCue = (url) => {
1023
+ const cached = rendered.get(url);
1024
+ if (cached) return cached;
1025
+ const entry = known.get(url);
1026
+ if (!entry) return null;
1027
+ const signal = entry.cue.render({ samples: cueSamples(entry.cue, entry.fps), sampleRate: SAMPLE_RATE });
1028
+ const wav = encodeWav(signal);
1029
+ rendered.set(url, wav);
1030
+ return wav;
963
1031
  };
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
- }
1032
+ var isBrand = (value) => typeof value === "object" && value !== null && value.kind === "odori-brand";
1033
+ var isLayout = (value) => typeof value === "object" && value !== null && value.kind === "odori-layout";
1034
+ var importFresh = async (file) => await import(`${pathToFileURL2(file).href}?odori=${statSync(file).mtimeMs}`);
1035
+ var registerProjectCues = async (graph, load = importFresh) => {
1036
+ for (const discovered of graph.brands) {
1037
+ try {
1038
+ const values = Object.values(await load(discovered.file));
1039
+ registerCues(values.filter(isBrand), defaultLayout.format.fps);
1040
+ for (const layout of values.filter(isLayout)) registerCues([layout.brand], layout.format.fps);
1041
+ } catch (error) {
1042
+ log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
1002
1043
  }
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
- }
1044
+ }
1045
+ for (const video of graph.videos) {
1046
+ try {
1047
+ const module = await load(video.file);
1048
+ if (!module.default || !module.metadata) continue;
1049
+ const entry = { component: module.default, metadata: module.metadata };
1050
+ const layout = resolveEntryLayout(entry);
1051
+ registerCues([layout.brand], layout.format.fps);
1052
+ } catch (error) {
1053
+ log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
1007
1054
  }
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
1055
  }
1054
- return { videos, previews, brands, audio, categories, sourceHash: hashString3(hashParts.join("|")) };
1056
+ return known.size;
1055
1057
  };
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
- ""
1058
+ var message = (error) => error instanceof Error ? error.message : String(error);
1059
+
1060
+ // src/audio-mix.ts
1061
+ var resolveCueFile = (config, src) => {
1062
+ if (/^https?:\/\//.test(src)) return null;
1063
+ if (src.startsWith("/__odori/cue/")) {
1064
+ const generated = cueFile(config, src);
1065
+ return existsSync10(generated) ? generated : null;
1066
+ }
1067
+ const candidates = [
1068
+ resolve9(config.root, "public", src.replace(/^\//, "")),
1069
+ resolve9(config.root, src.replace(/^\//, ""))
1087
1070
  ];
1088
- return lines.join("\n");
1071
+ return candidates.find((candidate) => existsSync10(candidate)) ?? null;
1089
1072
  };
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"
1073
+ var volumeFilter = (cue, cues, fps) => {
1074
+ const authored = cue.gainPoints ?? [];
1075
+ const frames = [
1076
+ .../* @__PURE__ */ new Set([
1077
+ ...duckEnvelope(cue, cues).map((point) => point.frame),
1078
+ ...authored.map((point) => point.frame)
1079
+ ])
1080
+ ].sort(
1081
+ (left, right) => left - right
1114
1082
  );
1115
- return target;
1083
+ const duck = duckEnvelope(cue, cues);
1084
+ const points = frames.map((frame) => ({
1085
+ seconds: (frame - cue.fromFrame) / fps,
1086
+ value: envelopeAtFrame(duck, frame) * (authored.length > 0 ? envelopeAtFrame(authored, frame) : 1) * cue.gain
1087
+ }));
1088
+ const constant = points.every((point) => point.value === points[0].value);
1089
+ if (constant) return `volume=${(points[0]?.value ?? cue.gain).toFixed(4)}`;
1090
+ let expression = points[points.length - 1].value.toFixed(4);
1091
+ for (let index = points.length - 1; index > 0; index -= 1) {
1092
+ const previous = points[index - 1];
1093
+ const current = points[index];
1094
+ const span = current.seconds - previous.seconds;
1095
+ const segment = span <= 0 ? current.value.toFixed(4) : `${previous.value.toFixed(4)}+${(current.value - previous.value).toFixed(4)}*(t-${previous.seconds.toFixed(
1096
+ 4
1097
+ )})/${span.toFixed(4)}`;
1098
+ expression = `if(lt(t,${current.seconds.toFixed(4)}),${segment},${expression})`;
1099
+ }
1100
+ return `volume=volume='${expression}':eval=frame`;
1101
+ };
1102
+ var buildAudioFilter = (inputs, options) => {
1103
+ const { fps, durationInFrames, targetLufs } = options;
1104
+ const totalSeconds = durationInFrames / fps;
1105
+ const cues = inputs.map(({ cue }) => cue);
1106
+ const parts = [];
1107
+ const labels = [];
1108
+ inputs.forEach(({ cue }, index) => {
1109
+ const start = cue.trimStartSeconds;
1110
+ const length2 = cue.durationInFrames / fps;
1111
+ const delay = Math.round(cue.fromFrame / fps * 1e3);
1112
+ const volume = volumeFilter(cue, cues, fps);
1113
+ const label = `a${index}`;
1114
+ const chain = [
1115
+ // Index 1 is the video input, so audio inputs start at 1.
1116
+ cue.loop ? `aloop=loop=-1:size=2147483647` : null,
1117
+ // Every input is brought to one format before anything else touches it.
1118
+ // Cues are mono, files are usually stereo, and a graph that leaves the
1119
+ // difference to be inferred works on the encoder that happens to be
1120
+ // installed and fails on the pinned one.
1121
+ "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo",
1122
+ `atrim=start=${start.toFixed(4)}:duration=${length2.toFixed(4)}`,
1123
+ "asetpts=PTS-STARTPTS",
1124
+ cue.fadeInFrames > 0 ? `afade=t=in:st=0:d=${(cue.fadeInFrames / fps).toFixed(4)}` : null,
1125
+ cue.fadeOutFrames > 0 ? `afade=t=out:st=${Math.max(0, length2 - cue.fadeOutFrames / fps).toFixed(4)}:d=${(cue.fadeOutFrames / fps).toFixed(4)}` : null,
1126
+ volume,
1127
+ delay > 0 ? `adelay=${delay}|${delay}` : null,
1128
+ `apad=whole_dur=${totalSeconds.toFixed(4)}`,
1129
+ `atrim=duration=${totalSeconds.toFixed(4)}`
1130
+ ].filter(Boolean).join(",");
1131
+ parts.push(`[${index + 1}:a]${chain}[${label}]`);
1132
+ labels.push(`[${label}]`);
1133
+ });
1134
+ parts.push(
1135
+ `${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[mixed]`,
1136
+ // loudnorm resamples to its own rate and can drop the layout on the way
1137
+ // out, so the last link states the output format rather than negotiating
1138
+ // it with whatever encoder is downstream.
1139
+ /* No LRA target: a mix cannot be given a loudness range it does not have,
1140
+ so asking for one states an intent the filter cannot honour. Measured
1141
+ against the beds here it changes nothing either way, which is the point
1142
+ — the range comes from the material, and asking for eleven from a bed
1143
+ mastered to two only hides that. */
1144
+ `[mixed]loudnorm=I=${targetLufs}:TP=-1.5,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[audio]`
1145
+ );
1146
+ return { filter: parts.join(";"), label: "[audio]" };
1116
1147
  };
1117
1148
 
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";
1149
+ // src/chunks.ts
1150
+ var length = (chunk) => chunk.end - chunk.start + 1;
1151
+ var planChunks = ({
1152
+ durationInFrames,
1153
+ scenes = [],
1154
+ concurrency,
1155
+ maxChunkFrames = 120
1156
+ }) => {
1157
+ if (durationInFrames <= 0) return { chunks: [], lanes: [] };
1158
+ const bounded = Math.max(1, Math.min(concurrency, durationInFrames));
1159
+ const ordered = [...scenes].filter((scene) => scene.durationInFrames > 0).sort((left, right) => left.start - right.start);
1160
+ const spans = [];
1161
+ let cursor = 0;
1162
+ for (const scene of ordered) {
1163
+ if (scene.start > cursor) spans.push({ start: cursor, end: scene.start - 1 });
1164
+ const end = Math.min(durationInFrames - 1, scene.start + scene.durationInFrames - 1);
1165
+ if (end >= scene.start) spans.push({ start: scene.start, end, sceneId: scene.id });
1166
+ cursor = end + 1;
1167
+ }
1168
+ if (cursor < durationInFrames) spans.push({ start: cursor, end: durationInFrames - 1 });
1169
+ const chunks = [];
1170
+ for (const span of spans) {
1171
+ const target = Math.max(1, Math.min(maxChunkFrames, Math.ceil(durationInFrames / bounded)));
1172
+ const total = span.end - span.start + 1;
1173
+ const pieces = Math.max(1, Math.ceil(total / target));
1174
+ const size = Math.ceil(total / pieces);
1175
+ for (let piece = 0; piece < pieces; piece += 1) {
1176
+ const start = span.start + piece * size;
1177
+ const end = Math.min(span.end, start + size - 1);
1178
+ if (start > end) continue;
1179
+ chunks.push({ index: chunks.length, start, end, sceneId: span.sceneId });
1180
+ }
1181
+ }
1182
+ const lanes = Array.from({ length: bounded }, () => []);
1183
+ const loads = new Array(bounded).fill(0);
1184
+ for (const chunk of [...chunks].sort((left, right) => length(right) - length(left))) {
1185
+ let lane = 0;
1186
+ for (let index = 1; index < bounded; index += 1) if (loads[index] < loads[lane]) lane = index;
1187
+ lanes[lane].push(chunk);
1188
+ loads[lane] += length(chunk);
1189
+ }
1190
+ for (const lane of lanes) lane.sort((left, right) => left.start - right.start);
1191
+ return { chunks, lanes: lanes.filter((lane) => lane.length > 0) };
1192
+ };
1193
+ var chunkFrames = (chunk) => Array.from({ length: length(chunk) }, (_, offset) => chunk.start + offset);
1127
1194
 
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 {};
1195
+ // src/chunk-cache.ts
1196
+ import { existsSync as existsSync11 } from "fs";
1197
+ import { copyFile, mkdir as mkdir8, readFile as readFile8, writeFile as writeFile9 } from "fs/promises";
1198
+ import { join as join3, resolve as resolve10 } from "path";
1199
+ import { hashValue } from "odori";
1200
+ var cacheDir2 = (config) => resolve10(config.root, config.outDir, "cache", "chunks");
1201
+ var chunkKey = (identity) => hashValue({
1202
+ videoId: identity.videoId,
1203
+ // The browser that drew the frames is part of what the frames are. Without
1204
+ // it, upgrading Chrome silently reuses pixels the new build would not have
1205
+ // produced, which is the exact drift the pinned toolchain exists to stop.
1206
+ renderer: identity.renderer ?? null,
1207
+ // A chunk is an encoded file, not a bag of frames: H.264 chunks cannot be
1208
+ // copied into a WebM, so a cache that ignored the codec would hand the
1209
+ // muxer streams it cannot write.
1210
+ format: identity.format ?? null,
1211
+ sceneId: identity.chunk.sceneId ?? null,
1212
+ start: identity.chunk.start,
1213
+ end: identity.chunk.end,
1214
+ width: identity.width,
1215
+ height: identity.height,
1216
+ fps: identity.fps,
1217
+ preset: identity.preset,
1218
+ quality: identity.quality ?? null,
1219
+ scale: identity.scale ?? null,
1220
+ input: identity.input ?? null
1221
+ });
1222
+ var readChunkRecord = async (config, key) => {
1223
+ const meta = join3(cacheDir2(config), `${key}.json`);
1224
+ const media = join3(cacheDir2(config), `${key}.mp4`);
1225
+ if (!existsSync11(meta) || !existsSync11(media)) return null;
1137
1226
  try {
1138
- return JSON.parse(await readFile9(file, "utf8"));
1227
+ return JSON.parse(await readFile8(meta, "utf8"));
1139
1228
  } catch {
1140
- return {};
1229
+ return null;
1141
1230
  }
1142
1231
  };
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)}
1232
+ var useChunkRecord = async (config, key, destination) => {
1233
+ await copyFile(join3(cacheDir2(config), `${key}.mp4`), destination);
1234
+ };
1235
+ var writeChunkRecord = async (config, key, signatures, file) => {
1236
+ const directory2 = cacheDir2(config);
1237
+ await mkdir8(directory2, { recursive: true });
1238
+ await copyFile(file, join3(directory2, `${key}.mp4`));
1239
+ const record = { key, signatures, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1240
+ await writeFile9(join3(directory2, `${key}.json`), `${JSON.stringify(record)}
1147
1241
  `, "utf8");
1148
1242
  };
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
- };
1243
+ var signaturesMatch = (recorded, observed) => recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
1359
1244
 
1360
1245
  // src/formats.ts
1361
1246
  var QUALITIES = ["studio", "social", "web"];
@@ -1471,315 +1356,42 @@ var resolveFormat = (requested, output) => {
1471
1356
  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
1357
 
1473
1358
  // 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 });
1359
+ var encodeParam = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
1360
+ var renderUrl = (origin, target, frame) => {
1361
+ const params = new URLSearchParams({ render: "1", video: target.videoId, frame: String(frame) });
1362
+ if (target.input) params.set("input", encodeParam(target.input));
1363
+ if (target.prepared !== void 0) params.set("prepared", encodeParam(target.prepared));
1364
+ return `${origin}/?${params.toString()}`;
1365
+ };
1366
+ var DEFAULT_GRAPHICS = "software";
1367
+ var browserArgs = (graphics = DEFAULT_GRAPHICS) => graphics === "gpu" ? ["--use-gl=angle", "--use-angle=default"] : ["--enable-unsafe-swiftshader"];
1368
+ var openRenderPage = async (origin, target, config, graphics = DEFAULT_GRAPHICS) => {
1369
+ const executablePath = await browserExecutable(config);
1370
+ const browser = await chromium.launch({ executablePath, headless: true, args: browserArgs(graphics) });
1371
+ const page = await browser.newPage({
1372
+ viewport: { width: target.width, height: target.height },
1373
+ deviceScaleFactor: 1
1374
+ });
1375
+ const errors = [];
1376
+ page.on("pageerror", (error) => errors.push(error.message));
1377
+ await page.goto(renderUrl(origin, target, 0), { waitUntil: "networkidle" });
1378
+ try {
1379
+ await page.locator('[data-odori-frame="0"]').waitFor({ timeout: 2e4 });
1380
+ } catch {
1381
+ await browser.close();
1382
+ throw new Error(`The video did not mount.${errors.length ? ` ${errors.join(" ")}` : ""}`);
1521
1383
  }
1522
- return written;
1384
+ await page.evaluate(() => document.fonts.ready);
1385
+ return { browser, page, errors };
1523
1386
  };
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
- }
1387
+ var seekTo = async (page, frame, errors) => {
1388
+ await page.evaluate((next) => window.__ODORI_SET_FRAME__?.(next), frame);
1389
+ try {
1390
+ await page.locator(`[data-odori-frame="${frame}"]`).waitFor({ timeout: 2e4 });
1391
+ } catch (error) {
1392
+ const reported = errors?.length ? ` ${[...new Set(errors)].join(" ")}` : "";
1393
+ throw new Error(`Frame ${frame} never became ready.${reported}`, { cause: error });
1531
1394
  }
1532
- return known.size;
1533
- };
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 });
1783
1395
  };
1784
1396
  var readTimeline = async (page) => page.evaluate(() => window.__ODORI_TIMELINE__ ?? { scenes: [], durationInFrames: 0 });
1785
1397
  var readAudio = async (page) => page.evaluate(() => window.__ODORI_AUDIO__ ?? { cues: [], durationInFrames: 0 });
@@ -1890,11 +1502,11 @@ Run "odori install" when you have a connection, or set ffmpegPath in odori.confi
1890
1502
  var ensureFfmpeg = async (config) => {
1891
1503
  await ffmpegExecutable(config);
1892
1504
  };
1893
- var renderStill = async (origin, target, frame, output, config) => {
1894
- const { browser, page, errors } = await openRenderPage(origin, target, config);
1505
+ var renderStill = async (origin, target, frame, output, config, graphics = DEFAULT_GRAPHICS) => {
1506
+ const { browser, page, errors } = await openRenderPage(origin, target, config, graphics);
1895
1507
  try {
1896
- await mkdir12(dirname5(output), { recursive: true });
1897
- await seekTo(page, frame);
1508
+ await mkdir9(dirname5(output), { recursive: true });
1509
+ await seekTo(page, frame, errors);
1898
1510
  await page.screenshot({ path: output });
1899
1511
  if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
1900
1512
  return output;
@@ -1915,7 +1527,7 @@ var sequencePattern = (output) => {
1915
1527
  const dot = output.lastIndexOf(".");
1916
1528
  const stem = dot > 0 ? output.slice(0, dot) : output;
1917
1529
  const extension = dot > 0 ? output.slice(dot) : ".png";
1918
- return join6(stem, `%05d${extension}`);
1530
+ return join4(stem, `%05d${extension}`);
1919
1531
  };
1920
1532
  var LOSSLESS = {
1921
1533
  name: "lossless",
@@ -1950,11 +1562,11 @@ var openChunkEncoder = (ffmpeg, file, fps, encode, format, signal) => {
1950
1562
  return { child, done };
1951
1563
  };
1952
1564
  var captureLane = async (origin, target, config, lane, stats, options) => {
1953
- let session = await openRenderPage(origin, target, config);
1565
+ let session = await openRenderPage(origin, target, config, options.graphics);
1954
1566
  const errors = session.errors;
1955
1567
  const reopen = async () => {
1956
1568
  await session.browser.close().catch(() => void 0);
1957
- session = await openRenderPage(origin, target, config);
1569
+ session = await openRenderPage(origin, target, config, options.graphics);
1958
1570
  session.errors.push(...errors);
1959
1571
  };
1960
1572
  const signatureOf = async () => await session.page.evaluate(SIGNATURE_SCRIPT);
@@ -1989,7 +1601,7 @@ var captureLane = async (origin, target, config, lane, stats, options) => {
1989
1601
  if (options.signal?.aborted) throw new Error("Render cancelled.");
1990
1602
  for (let attempt = 0; ; attempt += 1) {
1991
1603
  try {
1992
- await seekTo(session.page, frame);
1604
+ await seekTo(session.page, frame, session.errors);
1993
1605
  const signature = await signatureOf();
1994
1606
  let image;
1995
1607
  if (options.skipUnchanged && previousFrame && signature === previousSignature) {
@@ -2033,147 +1645,816 @@ var captureLane = async (origin, target, config, lane, stats, options) => {
2033
1645
  throw new Error(`The page reported an error during capture: ${session.errors.slice(0, 3).join(" ")}`);
2034
1646
  }
2035
1647
  }
2036
- };
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
1648
+ };
1649
+ var renderMovie = async (origin, target, output, config, onProgress, options = {}) => {
1650
+ const ffmpeg = await ffmpegExecutable(config);
1651
+ const browserPath = await browserExecutable(config);
1652
+ const graphics = options.graphics ?? DEFAULT_GRAPHICS;
1653
+ const renderer = `${(await resolveBrowser(config))?.version ?? browserPath}:${graphics}`;
1654
+ const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
1655
+ const encode = {
1656
+ preset: options.preset ?? config.preset ?? "medium",
1657
+ quality: options.quality ?? "studio",
1658
+ scale: options.scale ?? 1
1659
+ };
1660
+ const format = options.format ?? FORMATS.mp4;
1661
+ const chunkable = format.chunked;
1662
+ const chunkFormat = chunkable ? format : LOSSLESS;
1663
+ const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
1664
+ const cache = options.cache ?? config.cacheChunks ?? true;
1665
+ const work = options.workDir ?? resolve11(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
1666
+ await mkdir9(work, { recursive: true });
1667
+ const concurrency = chunkable ? requested : 1;
1668
+ const { chunks, lanes } = planChunks({
1669
+ durationInFrames: target.durationInFrames,
1670
+ scenes: target.scenes,
1671
+ concurrency
1672
+ });
1673
+ const chunkFile = (chunk) => join4(work, `chunk-${String(chunk.index).padStart(4, "0")}${chunkable ? format.extension : ".mkv"}`);
1674
+ const stats = { captured: 0, reused: 0, cachedChunks: 0 };
1675
+ let succeeded = false;
1676
+ try {
1677
+ await mkdir9(dirname5(output), { recursive: true });
1678
+ const captureStart = performance.now();
1679
+ await Promise.all(
1680
+ lanes.map(
1681
+ (lane) => captureLane(origin, target, config, lane, stats, {
1682
+ skipUnchanged,
1683
+ graphics,
1684
+ signal: options.signal,
1685
+ encode,
1686
+ format: chunkFormat,
1687
+ ffmpeg,
1688
+ workDir: work,
1689
+ chunkFile,
1690
+ cacheIdentity: cache ? (chunk) => ({
1691
+ videoId: target.videoId,
1692
+ chunk,
1693
+ renderer,
1694
+ format: chunkFormat.name,
1695
+ width: target.width,
1696
+ height: target.height,
1697
+ fps: target.fps,
1698
+ preset: encode.preset,
1699
+ // Both change the encoded bytes, so both are part of what a
1700
+ // chunk is: a half-size chunk must never answer for a full
1701
+ // one. A lossless intermediate is the exception by design —
1702
+ // the final pass applies them, so one capture serves all.
1703
+ quality: chunkable ? encode.quality : void 0,
1704
+ scale: chunkable ? encode.scale : void 0,
1705
+ input: target.input
1706
+ }) : void 0,
1707
+ onFrame: () => onProgress?.(stats.captured / Math.max(1, target.durationInFrames), "rendering")
1708
+ })
1709
+ )
1710
+ );
1711
+ const captureMs = performance.now() - captureStart;
1712
+ onProgress?.(1, "encoding");
1713
+ const mixInputs = (options.audio === false ? [] : target.audio ?? []).map((cue) => {
1714
+ const file = resolveCueFile(config, cue.src);
1715
+ if (!file) log.warn(`Skipping audio cue ${cue.src}: only project-local files can be encoded.`);
1716
+ return file ? { file, cue } : null;
1717
+ }).filter((item) => item !== null);
1718
+ const muxStart = performance.now();
1719
+ const ordered = chunks.map(chunkFile);
1720
+ const silent = join4(work, `video${chunkable ? format.extension : ".mkv"}`);
1721
+ if (ordered.length === 1) {
1722
+ await copyFile2(ordered[0], silent);
1723
+ } else {
1724
+ const list = join4(work, "chunks.txt");
1725
+ await writeFile10(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
1726
+ await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
1727
+ }
1728
+ if (!chunkable) {
1729
+ const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
1730
+ if (destination !== output) await mkdir9(dirname5(destination), { recursive: true });
1731
+ await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
1732
+ if (mixInputs.length > 0) {
1733
+ log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
1734
+ }
1735
+ } else if (mixInputs.length === 0 || !format.audio) {
1736
+ const faststart = format.extension === ".mp4" || format.extension === ".mov";
1737
+ await run(
1738
+ ffmpeg,
1739
+ ["-y", "-i", silent, "-c", "copy", ...faststart ? ["-movflags", "+faststart"] : [], output],
1740
+ options.signal
1741
+ );
1742
+ } else {
1743
+ const { filter, label } = buildAudioFilter(mixInputs, {
1744
+ fps: target.fps,
1745
+ durationInFrames: target.durationInFrames,
1746
+ targetLufs: target.targetLufs ?? -14
1747
+ });
1748
+ const args = ["-y", "-i", silent];
1749
+ for (const { cue, file } of mixInputs) args.push(...cue.loop ? ["-stream_loop", "-1"] : [], "-i", file);
1750
+ args.push(
1751
+ "-filter_complex",
1752
+ filter,
1753
+ "-map",
1754
+ "0:v",
1755
+ "-map",
1756
+ label,
1757
+ "-c:v",
1758
+ "copy",
1759
+ // WebM cannot carry AAC; every other container here can.
1760
+ "-c:a",
1761
+ format.extension === ".webm" ? "libopus" : "aac",
1762
+ "-b:a",
1763
+ "192k",
1764
+ "-ar",
1765
+ "48000",
1766
+ "-shortest",
1767
+ ...format.extension === ".mp4" || format.extension === ".mov" ? ["-movflags", "+faststart"] : [],
1768
+ output
1769
+ );
1770
+ await run(ffmpeg, args, options.signal);
1771
+ }
1772
+ options.onTimings?.({
1773
+ captureMs: Math.round(captureMs),
1774
+ encodeMs: Math.round(performance.now() - muxStart),
1775
+ frames: target.durationInFrames,
1776
+ reusedFrames: stats.reused,
1777
+ cachedChunks: stats.cachedChunks,
1778
+ chunks: chunks.length,
1779
+ concurrency: lanes.length
1780
+ });
1781
+ succeeded = true;
1782
+ return output;
1783
+ } finally {
1784
+ if (succeeded && !options.workDir) await rm4(work, { recursive: true, force: true });
1785
+ else if (!succeeded) log.detail(`Chunks left for inspection in ${work}`);
1786
+ }
1787
+ };
1788
+
1789
+ // src/commands/bed.ts
1790
+ var STEM_LUFS = -20;
1791
+ var STEM_PEAK = -1.5;
1792
+ var run2 = (command2, args) => new Promise((resolveRun, rejectRun) => {
1793
+ const child = spawn3(command2, args, { stdio: ["ignore", "ignore", "pipe"] });
1794
+ let stderr = "";
1795
+ child.stderr?.on("data", (chunk) => {
1796
+ stderr += chunk.toString();
1797
+ });
1798
+ child.on("error", rejectRun);
1799
+ child.on("exit", (code) => {
1800
+ if (code === 0) resolveRun(stderr);
1801
+ else rejectRun(new Error(`ffmpeg exited with ${code}: ${stderr.slice(-800)}`));
1802
+ });
1803
+ });
1804
+ var measure = async (ffmpeg, file) => {
1805
+ const stderr = await run2(ffmpeg, [
1806
+ "-i",
1807
+ file,
1808
+ "-af",
1809
+ `loudnorm=I=${STEM_LUFS}:TP=${STEM_PEAK}:print_format=json`,
1810
+ "-f",
1811
+ "null",
1812
+ "-"
1813
+ ]);
1814
+ const start = stderr.lastIndexOf("{");
1815
+ const end = stderr.lastIndexOf("}");
1816
+ if (start === -1 || end === -1) throw new Error(`Could not read loudness from ${basename2(file)}.`);
1817
+ return JSON.parse(stderr.slice(start, end + 1));
1818
+ };
1819
+ var round = (value) => Number(value).toFixed(1);
1820
+ var generateSource = async (config, prompt, options) => {
1821
+ const provider = resolveMusicProvider(options.provider ?? config.generation?.music ?? "elevenlabs");
1822
+ const apiKey = await resolveKey(provider);
1823
+ if (!apiKey) {
1824
+ throw new Error(
1825
+ `Generating with ${provider.name} needs a key: set ${provider.keyVariable} in the environment,
1826
+ or paste one once into Studio\u2019s integrations page ("odori dev", then Integrations).
1827
+ Either way it is sent only to the provider and never stored in the project.`
1828
+ );
1829
+ }
1830
+ const seconds = options.seconds ?? 60;
1831
+ log.detail(`Generating ${seconds}s with ${provider.name}`);
1832
+ const { bytes, extension } = await provider.generate({ prompt, seconds, apiKey });
1833
+ const stem = options.output ? basename2(options.output, extname(options.output)) : prompt.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "generated";
1834
+ const directory2 = options.output ? dirname6(resolve12(config.root, options.output)) : join5(config.root, "public", "audio");
1835
+ await mkdir10(directory2, { recursive: true });
1836
+ const source = join5(directory2, `${stem}-source.${extension}`);
1837
+ await writeFile11(source, bytes);
1838
+ log.detail(` kept the original at ${basename2(source)} (${(bytes.length / 1024).toFixed(0)} KB)`);
1839
+ return source;
1840
+ };
1841
+ var prepareBed = async (config, input, options = {}) => {
1842
+ const source = options.generate ? await generateSource(config, input, options) : resolve12(config.root, input);
1843
+ if (!options.generate) {
1844
+ await stat(source).catch(() => {
1845
+ throw new Error(`No file at ${input}.`);
1846
+ });
1847
+ }
1848
+ const ffmpeg = await ffmpegExecutable(config);
1849
+ const target = options.target ?? STEM_LUFS;
1850
+ const name = basename2(source, extname(source)).replace(/-source$/, "");
1851
+ const destination = options.output ? resolve12(config.root, options.output) : join5(config.root, "public", "audio", `${name}.m4a`);
1852
+ await mkdir10(dirname6(destination), { recursive: true });
1853
+ log.detail(`Measuring ${basename2(source)}`);
1854
+ const measured = await measure(ffmpeg, source);
1855
+ await run2(ffmpeg, [
1856
+ "-y",
1857
+ "-i",
1858
+ source,
1859
+ "-af",
1860
+ `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`,
1861
+ "-c:a",
1862
+ "aac",
1863
+ "-b:a",
1864
+ "192k",
1865
+ destination
1866
+ ]);
1867
+ const after = await measure(ffmpeg, destination);
1868
+ const bytes = (await stat(destination)).size;
1869
+ const before = { lufs: Number(measured.input_i), peak: Number(measured.input_tp), range: Number(measured.input_lra) };
1870
+ const warnings = [];
1871
+ if (before.range < 4) {
1872
+ warnings.push(
1873
+ `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.`
1874
+ );
1875
+ }
1876
+ if (before.peak > 0) {
1877
+ warnings.push(`The source peaked at ${round(before.peak)} dBTP, which is above full scale.`);
1878
+ }
1879
+ const role = options.role ?? `bed.${name.replace(/^bed[-.]?/, "") || "main"}`;
1880
+ const publicDir = join5(config.root, "public");
1881
+ const relativeToPublic = relative6(publicDir, destination);
1882
+ const url = relativeToPublic.startsWith("..") ? null : "/" + relativeToPublic.split("\\").join("/");
1883
+ const registered = url ? await registerCueInBrand(config, { name: role, url }, name) : null;
1884
+ return { source, destination, role, url, registered, before, after: { lufs: Number(after.input_i), peak: Number(after.input_tp) }, bytes, warnings };
1885
+ };
1886
+ var bedCommand = async (input, options = {}) => {
1887
+ const config = await loadConfig(process.cwd());
1888
+ const report = await prepareBed(config, input, options);
1889
+ log.info(`Prepared ${basename2(report.destination)}`);
1890
+ log.detail(` loudness ${round(report.before.lufs)} \u2192 ${round(report.after.lufs)} LUFS`);
1891
+ log.detail(` peak ${round(report.before.peak)} \u2192 ${round(report.after.peak)} dBTP`);
1892
+ log.detail(` range ${round(report.before.range)} LU`);
1893
+ log.detail(` size ${(report.bytes / 1024).toFixed(0)} KB`);
1894
+ for (const warning of report.warnings) {
1895
+ log.detail("");
1896
+ log.detail(` ${warning}`);
1897
+ }
1898
+ log.detail("");
1899
+ if (report.registered?.already) {
1900
+ log.detail(` "${report.role}" is already registered in ${report.registered.file}`);
1901
+ } else if (report.registered) {
1902
+ log.detail(` registered "${report.role}" in ${report.registered.file}`);
1903
+ } else if (report.url) {
1904
+ log.detail(" No brand with an audio cues block was found. Register it by hand:");
1905
+ log.detail(` audio: {cues: {"${report.role}": "${report.url}"}}`);
1906
+ } else {
1907
+ log.detail(" The output is outside public/, so it cannot be registered or served.");
1908
+ }
1909
+ log.detail(" Place it in a video:");
1910
+ log.detail(` <Audio src="${report.role}" fadeIn="1s" fadeOut="1.5s" duckUnder />`);
1911
+ };
1912
+
1913
+ // src/jobs.ts
1914
+ import { mkdir as mkdir11, readFile as readFile9, readdir as readdir3, rename, writeFile as writeFile12 } from "fs/promises";
1915
+ import { existsSync as existsSync12 } from "fs";
1916
+ import { join as join6, resolve as resolve13 } from "path";
1917
+ var buildsDir = (config) => resolve13(config.root, config.outDir, "builds");
1918
+ var jobFile = (config, id) => join6(buildsDir(config), `${id}.json`);
1919
+ var createJob = async (config, manifest, output, render) => {
1920
+ await mkdir11(buildsDir(config), { recursive: true });
1921
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1922
+ const job = {
1923
+ id: `job-${manifest.manifestHash.slice(0, 10)}-${Date.now().toString(36)}`,
1924
+ videoId: manifest.videoId,
1925
+ manifestHash: manifest.manifestHash,
1926
+ status: "queued",
1927
+ progress: 0,
1928
+ attempts: 0,
1929
+ logs: [],
1930
+ createdAt: now,
1931
+ updatedAt: now
1932
+ };
1933
+ const record = { job, manifest, output, ...render ? { render } : {} };
1934
+ await writeFile12(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
1935
+ `, "utf8");
1936
+ return record;
1937
+ };
1938
+ var readJob = async (config, id) => {
1939
+ const file = jobFile(config, id);
1940
+ if (!existsSync12(file)) throw new Error(`Unknown job "${id}". Run odori jobs to list them.`);
1941
+ return JSON.parse(await readFile9(file, "utf8"));
1942
+ };
1943
+ var writeLocks = /* @__PURE__ */ new Map();
1944
+ var withJobLock = (id, task) => {
1945
+ const previous = writeLocks.get(id) ?? Promise.resolve();
1946
+ const next = previous.then(task, task);
1947
+ writeLocks.set(
1948
+ id,
1949
+ next.catch(() => void 0)
1950
+ );
1951
+ return next;
1952
+ };
1953
+ var writeRecord = async (config, record) => {
1954
+ const file = jobFile(config, record.job.id);
1955
+ const temporary = `${file}.${process.pid}.tmp`;
1956
+ await writeFile12(temporary, `${JSON.stringify(record, null, 2)}
1957
+ `, "utf8");
1958
+ await rename(temporary, file);
1959
+ };
1960
+ var updateJob = async (config, job) => withJobLock(job.id, async () => {
1961
+ const record = await readJob(config, job.id);
1962
+ const next = { ...job, logs: record.job.logs, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1963
+ await writeRecord(config, { ...record, job: next });
1964
+ return next;
1965
+ });
1966
+ var appendJobLog = async (config, id, message2) => withJobLock(id, async () => {
1967
+ const record = await readJob(config, id);
1968
+ const next = {
1969
+ ...record.job,
1970
+ logs: [...record.job.logs.slice(-49), `${(/* @__PURE__ */ new Date()).toISOString()} ${message2}`],
1971
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1972
+ };
1973
+ await writeRecord(config, { ...record, job: next });
1974
+ return next;
1975
+ });
1976
+ var alive = (pid) => {
1977
+ if (!pid) return false;
1978
+ try {
1979
+ process.kill(pid, 0);
1980
+ return true;
1981
+ } catch {
1982
+ return false;
1983
+ }
1984
+ };
1985
+ var reconcileJobs = async (config) => {
1986
+ const stale = (await listJobs(config, { reconcile: false })).filter(
1987
+ (job) => (job.status === "rendering" || job.status === "encoding") && !alive(job.pid)
1988
+ );
1989
+ for (const job of stale) {
1990
+ await updateJob(config, {
1991
+ ...job,
1992
+ status: "failed",
1993
+ error: "The render process exited before the job finished."
1994
+ });
1995
+ }
1996
+ return stale.length;
1997
+ };
1998
+ var listJobs = async (config, options = {}) => {
1999
+ if (!existsSync12(buildsDir(config))) return [];
2000
+ if (options.reconcile !== false) await reconcileJobs(config);
2001
+ const files = (await readdir3(buildsDir(config))).filter((file) => file.endsWith(".json"));
2002
+ const jobs = [];
2003
+ for (const file of files) {
2004
+ try {
2005
+ const raw = await readFile9(join6(buildsDir(config), file), "utf8");
2006
+ jobs.push(JSON.parse(raw).job);
2007
+ } catch {
2008
+ continue;
2009
+ }
2010
+ }
2011
+ return jobs.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
2012
+ };
2013
+ var JobQueue = class {
2014
+ chain = Promise.resolve();
2015
+ enqueue(task) {
2016
+ const result = this.chain.then(task, task);
2017
+ this.chain = result.catch(() => void 0);
2018
+ return result;
2019
+ }
2020
+ };
2021
+
2022
+ // src/discovery.ts
2023
+ import { mkdir as mkdir12, readdir as readdir4, readFile as readFile10, stat as stat2, writeFile as writeFile13 } from "fs/promises";
2024
+ import { existsSync as existsSync13 } from "fs";
2025
+ import { join as join7, relative as relative7, resolve as resolve14, sep as sep2 } from "path";
2026
+ import { hashString as hashString3 } from "odori";
2027
+ var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
2028
+ var walk = async (directory2, files = []) => {
2029
+ const entries = await readdir4(directory2, { withFileTypes: true });
2030
+ for (const entry of entries) {
2031
+ if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
2032
+ const full = join7(directory2, entry.name);
2033
+ if (entry.isDirectory()) await walk(full, files);
2034
+ else files.push(full);
2035
+ }
2036
+ return files;
2037
+ };
2038
+ var toIdentifier = (value, prefix) => {
2039
+ const cleaned = value.replace(
2040
+ /[^a-zA-Z0-9]+(.)?/g,
2041
+ (_, character) => character ? character.toUpperCase() : ""
2042
+ );
2043
+ return `${prefix}${cleaned.charAt(0).toUpperCase()}${cleaned.slice(1)}`;
2044
+ };
2045
+ var AUDIO_EXTENSIONS = /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i;
2046
+ var discoverAudio = async (config) => {
2047
+ const root = resolve14(config.root, config.audioDir);
2048
+ if (!existsSync13(root)) return [];
2049
+ const publicRoot = resolve14(config.root, "public");
2050
+ const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
2051
+ return Promise.all(
2052
+ files.map(async (file) => ({
2053
+ name: relative7(root, file).replace(AUDIO_EXTENSIONS, "").split(sep2).join("/"),
2054
+ url: file.startsWith(`${publicRoot}${sep2}`) ? `/${relative7(publicRoot, file).split(sep2).join("/")}` : `/${relative7(config.root, file).split(sep2).join("/")}`,
2055
+ relativeFile: relative7(config.root, file),
2056
+ bytes: (await stat2(file)).size
2057
+ }))
2058
+ );
2059
+ };
2060
+ var discoverProject = async (config) => {
2061
+ const videosRoot = resolve14(config.root, config.videosDir);
2062
+ if (!existsSync13(videosRoot)) {
2063
+ throw new Error(`No ${config.videosDir}/ directory found in ${config.root}. Run "odori init" first.`);
2064
+ }
2065
+ const files = (await walk(videosRoot)).sort();
2066
+ const audio = await discoverAudio(config);
2067
+ const videos = [];
2068
+ const previews = [];
2069
+ const brands = [];
2070
+ const categories = [];
2071
+ const hashParts = [];
2072
+ const importedBy = {};
2073
+ const componentsRoot = resolve14(config.root, config.componentsDir);
2074
+ for (const file of files) {
2075
+ const relativeFile = relative7(config.root, file);
2076
+ let contents = "";
2077
+ if (/\.(tsx|ts|css|json)$/.test(file)) {
2078
+ contents = await readFile10(file, "utf8");
2079
+ hashParts.push(`${relativeFile}:${hashString3(contents)}`);
2080
+ }
2081
+ const base = file.split(sep2).pop() ?? "";
2082
+ if (base === "category.json") {
2083
+ const path = relative7(componentsRoot, resolve14(file, "..")).split(sep2).join("/");
2084
+ if (!path.startsWith("..")) {
2085
+ try {
2086
+ const declared = JSON.parse(contents);
2087
+ categories.push({
2088
+ path,
2089
+ ...typeof declared.name === "string" ? { name: declared.name } : {},
2090
+ ...typeof declared.order === "number" ? { order: declared.order } : {}
2091
+ });
2092
+ } catch (error) {
2093
+ log.warn(
2094
+ `${relativeFile} is not valid JSON, so that directory names itself: ${error instanceof Error ? error.message : String(error)}`
2095
+ );
2096
+ }
2097
+ }
2098
+ }
2099
+ if (base === "video.tsx") {
2100
+ for (const match of contents.matchAll(/from\s+["'][^"']*\/components\/([^/"']+)\//g)) {
2101
+ (importedBy[match[1]] ??= /* @__PURE__ */ new Set()).add(relative7(videosRoot, file).replace(/\/?video\.tsx$/, "") || "video");
2102
+ }
2103
+ }
2104
+ if (base === "video.tsx") {
2105
+ const slug = relative7(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
2106
+ videos.push({
2107
+ slug,
2108
+ file,
2109
+ relativeFile,
2110
+ importPath: file,
2111
+ identifier: toIdentifier(slug, "video")
2112
+ });
2113
+ } else if (
2114
+ // A brand module is recognized by what it does, not where it sits. The
2115
+ // scaffold defines its brand in videos/layout.tsx, so a directory-name
2116
+ // rule alone left the default project's brand invisible to everything
2117
+ // that reads this list — most visibly the dev server's cue registry,
2118
+ // which then answered new cue URLs with 404s until a restart. Installed
2119
+ // component source is excluded the way brand-file.ts excludes it: a
2120
+ // component may mention defineBrand without being where a brand lives.
2121
+ /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx") && !file.startsWith(componentsRoot + sep2) && (file.split(sep2).includes("brands") || contents.includes("defineBrand("))
2122
+ ) {
2123
+ const name = base.replace(/\.tsx?$/, "");
2124
+ brands.push({
2125
+ name,
2126
+ file,
2127
+ relativeFile,
2128
+ // From the whole relative path, like previews: basenames repeat
2129
+ // (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
2130
+ identifier: toIdentifier(
2131
+ `${relative7(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
2132
+ "brands"
2133
+ )
2134
+ });
2135
+ } else if (base.endsWith(".preview.tsx")) {
2136
+ const name = base.replace(/\.preview\.tsx$/, "");
2137
+ previews.push({
2138
+ name,
2139
+ file,
2140
+ relativeFile,
2141
+ importPath: file,
2142
+ identifier: toIdentifier(`${relative7(videosRoot, file).split(sep2).join("-")}`, "preview")
2143
+ });
2144
+ }
2145
+ }
2146
+ for (const preview of previews) {
2147
+ const users = importedBy[preview.name];
2148
+ if (users) preview.usedBy = [...users].sort();
2149
+ }
2150
+ return { videos, previews, brands, audio, categories, sourceHash: hashString3(hashParts.join("|")) };
2151
+ };
2152
+ var generateImports = (graph, outDir) => {
2153
+ const importPath = (file) => {
2154
+ const relativePath = relative7(outDir, file).split(sep2).join("/");
2155
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
2156
+ };
2157
+ const lines = [
2158
+ "// Generated by odori. Do not edit.",
2159
+ 'import type {VideoEntry} from "odori";',
2160
+ "",
2161
+ ...graph.videos.map(
2162
+ (video) => `import ${video.identifier}, {metadata as ${video.identifier}Metadata} from "${importPath(video.file)}";`
2163
+ ),
2164
+ ...graph.previews.map((preview) => `import ${preview.identifier} from "${importPath(preview.file)}";`),
2165
+ ...graph.brands.map((brand) => `import * as ${brand.identifier} from "${importPath(brand.file)}";`),
2166
+ "",
2167
+ "export const videos: VideoEntry[] = [",
2168
+ ...graph.videos.map(
2169
+ (video) => ` {component: ${video.identifier}, metadata: {...${video.identifier}Metadata, id: ${video.identifier}Metadata.id || ${JSON.stringify(video.slug)}}},`
2170
+ ),
2171
+ "];",
2172
+ "",
2173
+ "export const componentPreviews = [",
2174
+ ...graph.previews.map((preview) => ` {id: ${JSON.stringify(preview.name)}, preview: ${preview.identifier}},`),
2175
+ "];",
2176
+ "",
2177
+ "export const brands = [",
2178
+ ...graph.brands.map(
2179
+ (brand) => ` ...Object.values(${brand.identifier}).filter((value) => (value as {kind?: string})?.kind === "odori-brand"),`
2180
+ ),
2181
+ "];",
2182
+ ""
2183
+ ];
2184
+ return lines.join("\n");
2185
+ };
2186
+ var writeGenerated = async (config, graph) => {
2187
+ const outDir = resolve14(config.root, config.outDir);
2188
+ await mkdir12(outDir, { recursive: true });
2189
+ const target = join7(outDir, "imports.generated.ts");
2190
+ await writeFile13(target, generateImports(graph, outDir), "utf8");
2191
+ await writeFile13(
2192
+ join7(outDir, "catalog.json"),
2193
+ `${JSON.stringify(
2194
+ {
2195
+ sourceHash: graph.sourceHash,
2196
+ videos: graph.videos.map((video) => ({ slug: video.slug, file: video.relativeFile })),
2197
+ previews: graph.previews.map((preview) => ({
2198
+ name: preview.name,
2199
+ file: preview.relativeFile,
2200
+ usedBy: preview.usedBy ?? []
2201
+ })),
2202
+ brands: graph.brands.map((brand) => ({ name: brand.name, file: brand.relativeFile })),
2203
+ audio: graph.audio.map((entry) => ({ name: entry.name, url: entry.url, file: entry.relativeFile }))
2204
+ },
2205
+ null,
2206
+ 2
2207
+ )}
2208
+ `,
2209
+ "utf8"
2210
+ );
2211
+ return target;
2212
+ };
2213
+
2214
+ // src/project.ts
2215
+ import { resolve as resolve17 } from "path";
2216
+ import { pathToFileURL as pathToFileURL3 } from "url";
2217
+ import {
2218
+ createRenderManifest,
2219
+ entryDurationInFrames,
2220
+ resolveEntryLayout as resolveEntryLayout2,
2221
+ resolveVideoId
2222
+ } from "odori";
2223
+
2224
+ // src/integrity.ts
2225
+ import { createHash as createHash3 } from "crypto";
2226
+ import { existsSync as existsSync14 } from "fs";
2227
+ import { mkdir as mkdir13, readFile as readFile11, writeFile as writeFile14 } from "fs/promises";
2228
+ import { dirname as dirname7, resolve as resolve15 } from "path";
2229
+ var cacheFile = (config) => resolve15(config.root, config.outDir, "cache", "integrity.json");
2230
+ var readCache = async (config) => {
2231
+ const file = cacheFile(config);
2232
+ if (!existsSync14(file)) return {};
2233
+ try {
2234
+ return JSON.parse(await readFile11(file, "utf8"));
2235
+ } catch {
2236
+ return {};
2237
+ }
2238
+ };
2239
+ var writeCache = async (config, cache) => {
2240
+ const file = cacheFile(config);
2241
+ await mkdir13(dirname7(file), { recursive: true });
2242
+ await writeFile14(file, `${JSON.stringify(cache, null, 2)}
2243
+ `, "utf8");
2244
+ };
2245
+ var sha256 = (bytes) => `sha256-${createHash3("sha256").update(bytes).digest("base64")}`;
2246
+ var localCandidates = (config, url) => [
2247
+ resolve15(config.root, "public", url.replace(/^\//, "")),
2248
+ resolve15(config.root, url.replace(/^\//, ""))
2249
+ ];
2250
+ var isServed = (config, file) => file.startsWith(resolve15(config.root, "public") + "/");
2251
+ var createIntegrityResolver = async (config) => {
2252
+ const cache = await readCache(config);
2253
+ const warned = /* @__PURE__ */ new Set();
2254
+ let dirty = false;
2255
+ const resolveIntegrity = async (url) => {
2256
+ if (url.startsWith("/__odori/cue/")) return `cue-${url.slice(url.lastIndexOf("-") + 1).replace(/\.wav$/, "")}`;
2257
+ const local = localCandidates(config, url).find((candidate) => existsSync14(candidate));
2258
+ if (local) {
2259
+ if (!isServed(config, local) && !warned.has(url)) {
2260
+ warned.add(url);
2261
+ log.warn(`${url} resolves to ${local}, which is outside public/ and will not be served. Move it into public/.`);
2262
+ }
2263
+ const bytes = await readFile11(local);
2264
+ const { mtimeMs } = await import("fs/promises").then((fs) => fs.stat(local));
2265
+ const hit = cache[url];
2266
+ if (hit && hit.mtimeMs === mtimeMs && hit.size === bytes.byteLength) return hit.integrity;
2267
+ const integrity = sha256(bytes);
2268
+ cache[url] = { integrity, size: bytes.byteLength, mtimeMs };
2269
+ dirty = true;
2270
+ return integrity;
2271
+ }
2272
+ if (!/^https?:\/\//.test(url)) return "unresolved";
2273
+ if (cache[url]) return cache[url].integrity;
2274
+ try {
2275
+ const response = await fetch(url, { signal: AbortSignal.timeout(1e4) });
2276
+ if (!response.ok) return "unresolved";
2277
+ const bytes = new Uint8Array(await response.arrayBuffer());
2278
+ const integrity = sha256(bytes);
2279
+ cache[url] = { integrity, size: bytes.byteLength };
2280
+ dirty = true;
2281
+ return integrity;
2282
+ } catch {
2283
+ return "unresolved";
2284
+ }
2285
+ };
2286
+ return {
2287
+ resolve: resolveIntegrity,
2288
+ flush: async () => {
2289
+ if (dirty) await writeCache(config, cache);
2290
+ }
2291
+ };
2292
+ };
2293
+
2294
+ // src/prepare-cache.ts
2295
+ import { existsSync as existsSync15 } from "fs";
2296
+ import { mkdir as mkdir14, readFile as readFile12, readdir as readdir5, rm as rm5, writeFile as writeFile15 } from "fs/promises";
2297
+ import { join as join8, resolve as resolve16 } from "path";
2298
+ import { hashValue as hashValue2 } from "odori";
2299
+
2300
+ // src/paths.ts
2301
+ var outputName = (id) => id.split("/").join("-");
2302
+ var fileKey = (id) => id.split("/").join("+");
2303
+
2304
+ // src/prepare-cache.ts
2305
+ var directory = (config) => resolve16(config.root, config.outDir, "cache", "prepare");
2306
+ var prepareCacheKey = (key) => `${fileKey(key.videoId)}__${hashValue2(key)}`;
2307
+ var readPrepareCache = async (config, key) => {
2308
+ const file = join8(directory(config), `${prepareCacheKey(key)}.json`);
2309
+ if (!existsSync15(file)) return { hit: false, value: void 0 };
2310
+ try {
2311
+ const entry = JSON.parse(await readFile12(file, "utf8"));
2312
+ return { hit: true, value: entry.value };
2313
+ } catch {
2314
+ return { hit: false, value: void 0 };
2315
+ }
2316
+ };
2317
+ var writePrepareCache = async (config, key, value) => {
2318
+ if (value === void 0) return;
2319
+ const target = directory(config);
2320
+ await mkdir14(target, { recursive: true });
2321
+ const entry = { key, value, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
2322
+ await writeFile15(join8(target, `${prepareCacheKey(key)}.json`), `${JSON.stringify(entry, null, 2)}
2323
+ `, "utf8");
2324
+ };
2325
+ var clearPrepareCache = async (config, videoId) => {
2326
+ const target = directory(config);
2327
+ if (!existsSync15(target)) return 0;
2328
+ const files = await readdir5(target);
2329
+ const matches = files.filter((file) => videoId ? file.startsWith(`${fileKey(videoId)}__`) : file.endsWith(".json"));
2330
+ await Promise.all(matches.map((file) => rm5(join8(target, file), { force: true })));
2331
+ return matches.length;
2332
+ };
2333
+
2334
+ // src/project.ts
2335
+ var loadVideos = async (graph) => {
2336
+ const loaded = [];
2337
+ for (const discovered of graph.videos) {
2338
+ const module = await import(pathToFileURL3(discovered.file).href);
2339
+ if (!module.default || !module.metadata) {
2340
+ throw new Error(`${discovered.relativeFile} must export metadata and a default React component.`);
2341
+ }
2342
+ const entry = {
2343
+ component: module.default,
2344
+ metadata: { ...module.metadata, id: resolveVideoId(module.metadata.id, discovered.slug) }
2345
+ };
2346
+ loaded.push({
2347
+ entry,
2348
+ file: discovered.file,
2349
+ relativeFile: discovered.relativeFile,
2350
+ durationInFrames: entryDurationInFrames(entry, resolveEntryLayout2(entry))
2351
+ });
2352
+ }
2353
+ const byId = /* @__PURE__ */ new Map();
2354
+ for (const video of loaded) {
2355
+ const id = video.entry.metadata.id;
2356
+ const first = byId.get(id);
2357
+ if (first) throw new Error(`Duplicate video id "${id}":
2358
+ ${first}
2359
+ ${video.relativeFile}`);
2360
+ byId.set(id, video.relativeFile);
2361
+ }
2362
+ return loaded;
2363
+ };
2364
+ var findVideo = (videos, id) => {
2365
+ const found = videos.find((video) => video.entry.metadata.id === id);
2366
+ if (!found) {
2367
+ throw new Error(
2368
+ `Unknown video "${id}". Known videos: ${videos.map((video) => video.entry.metadata.id).join(", ") || "none"}`
2369
+ );
2370
+ }
2371
+ return found;
2372
+ };
2373
+ var runPrepare = async (video, config, graph, input, options = {}) => {
2374
+ const prepareFile = resolve17(video.file, "..", "prepare.ts");
2375
+ let prepare;
2376
+ try {
2377
+ const module = await import(pathToFileURL3(prepareFile).href);
2378
+ prepare = module.prepare ?? module.default;
2379
+ } catch (error) {
2380
+ if (error.code === "ERR_MODULE_NOT_FOUND") return void 0;
2381
+ throw error;
2382
+ }
2383
+ if (!prepare) return void 0;
2384
+ const key = {
2385
+ videoId: video.entry.metadata.id,
2386
+ sourceHash: graph.sourceHash,
2387
+ input,
2388
+ version: prepare.version ?? "1"
2046
2389
  };
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;
2063
- 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.`);
2390
+ if (!options.refresh) {
2391
+ const cached = await readPrepareCache(config, key);
2392
+ if (cached.hit) return cached.value;
2393
+ }
2394
+ const memo = /* @__PURE__ */ new Map();
2395
+ const value = await prepare.run({
2396
+ input,
2397
+ assets: {
2398
+ resolve: async (reference) => {
2399
+ const asset = (config.assets ?? []).find((item) => item.reference === reference);
2400
+ if (!asset) throw new Error(`Unknown asset reference: ${reference}`);
2401
+ return asset.url;
2402
+ }
2403
+ },
2404
+ cache: {
2405
+ getOrSet: async (cacheKey, factory) => {
2406
+ if (!memo.has(cacheKey)) memo.set(cacheKey, await factory());
2407
+ return memo.get(cacheKey);
2120
2408
  }
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
2409
  }
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}`);
2410
+ });
2411
+ await writePrepareCache(config, key, value);
2412
+ return value;
2413
+ };
2414
+ var freezeManifest = async (video, graph, config, rawInput, options = {}) => {
2415
+ const layout = resolveEntryLayout2(video.entry);
2416
+ const merged = { ...video.entry.metadata.defaultProps, ...rawInput };
2417
+ const input = video.entry.metadata.schema ? video.entry.metadata.schema.parse(merged) : merged;
2418
+ const prepared = await runPrepare(video, config, graph, input, { refresh: options.refreshPrepare });
2419
+ const integrity = await createIntegrityResolver(config);
2420
+ const assets = await Promise.all(
2421
+ (config.assets ?? []).map(async (asset) => ({ ...asset, integrity: await integrity.resolve(asset.url) }))
2422
+ );
2423
+ const audio = await Promise.all(
2424
+ (options.audio ?? []).map(async (cue) => ({ ...cue, integrity: await integrity.resolve(cue.src) }))
2425
+ );
2426
+ const fonts = await Promise.all(
2427
+ layout.brand.fonts.map(async (font) => ({
2428
+ family: font.family,
2429
+ url: font.url,
2430
+ integrity: await integrity.resolve(font.url)
2431
+ }))
2432
+ );
2433
+ await integrity.flush();
2434
+ for (const cue of audio) {
2435
+ if (cue.integrity === "unresolved") log.warn(`Audio source could not be resolved for hashing: ${cue.src}`);
2172
2436
  }
2437
+ const manifest = createRenderManifest({
2438
+ entry: video.entry,
2439
+ layout,
2440
+ input,
2441
+ prepared,
2442
+ sourceHash: graph.sourceHash,
2443
+ durationInFrames: video.durationInFrames,
2444
+ scenes: options.scenes ?? [],
2445
+ audio,
2446
+ assets,
2447
+ fonts,
2448
+ // Which Chrome drew it and which FFmpeg encoded it. Two files that differ
2449
+ // are then a question with an answer rather than a mystery.
2450
+ toolchain: await renderToolchain(config),
2451
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2452
+ });
2453
+ return { manifest, input, prepared };
2173
2454
  };
2174
2455
 
2175
2456
  // src/open.ts
2176
- import { spawn as spawn3 } from "child_process";
2457
+ import { spawn as spawn4 } from "child_process";
2177
2458
  var command = () => {
2178
2459
  if (process.platform === "darwin") return { bin: "open", args: [] };
2179
2460
  if (process.platform === "win32") return { bin: "cmd", args: ["/c", "start", ""] };
@@ -2190,7 +2471,7 @@ var openInBrowser = (url) => {
2190
2471
  const resolved = command();
2191
2472
  if (!resolved) return;
2192
2473
  try {
2193
- const child = spawn3(resolved.bin, [...resolved.args, url], { stdio: "ignore", detached: true });
2474
+ const child = spawn4(resolved.bin, [...resolved.args, url], { stdio: "ignore", detached: true });
2194
2475
  child.on("error", () => {
2195
2476
  });
2196
2477
  child.unref();
@@ -2199,25 +2480,32 @@ var openInBrowser = (url) => {
2199
2480
  };
2200
2481
 
2201
2482
  // src/server.ts
2202
- import { existsSync as existsSync15 } from "fs";
2483
+ import { existsSync as existsSync16 } from "fs";
2203
2484
  import { createRequire as createRequire2 } from "module";
2204
2485
  import { fileURLToPath } from "url";
2205
2486
  import { createServer } from "vite";
2206
2487
  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, "..", "..");
2488
+ import { readFile as readFile13 } from "fs/promises";
2489
+ import { dirname as dirname8, resolve as resolve18, sep as sep3 } from "path";
2490
+ var cliRoot = resolve18(dirname8(fileURLToPath(import.meta.url)), "..");
2491
+ var studioRoot = resolve18(cliRoot, "studio");
2492
+ var studioEntry = resolve18(studioRoot, "index.html");
2493
+ var installRoot = resolve18(cliRoot, "..", "..");
2213
2494
  var VIRTUAL_ID = "virtual:odori-project";
2214
2495
  var RESOLVED_ID = `\0${VIRTUAL_ID}`;
2496
+ var cliVersion = () => {
2497
+ try {
2498
+ return createRequire2(import.meta.url)("../package.json").version;
2499
+ } catch {
2500
+ return "dev";
2501
+ }
2502
+ };
2215
2503
  var runtimeSource = (root) => {
2216
- for (const from of [resolve17(root, "package.json"), import.meta.url]) {
2504
+ for (const from of [resolve18(root, "package.json"), import.meta.url]) {
2217
2505
  try {
2218
2506
  const manifest = createRequire2(from).resolve("odori/package.json");
2219
- const src = resolve17(manifest, "..", "src");
2220
- if (existsSync15(resolve17(src, "index.tsx"))) return src;
2507
+ const src = resolve18(manifest, "..", "src");
2508
+ if (existsSync16(resolve18(src, "index.tsx"))) return src;
2221
2509
  } catch {
2222
2510
  }
2223
2511
  }
@@ -2262,6 +2550,7 @@ var odoriProjectPlugin = (config, getGraph) => ({
2262
2550
  audioDir: config.audioDir,
2263
2551
  docsUrl: config.docsUrl,
2264
2552
  audio: graph.audio,
2553
+ version: cliVersion(),
2265
2554
  sourceHash: graph.sourceHash,
2266
2555
  assets: config.assets ?? [],
2267
2556
  files: {
@@ -2340,15 +2629,15 @@ var startStudioServer = async (initialConfig, options = {}) => {
2340
2629
  ],
2341
2630
  // The project's public/ directory is served at the root, so brand fonts,
2342
2631
  // logos, and footage resolve identically in preview and render.
2343
- publicDir: existsSync15(resolve17(config.root, "public")) ? resolve17(config.root, "public") : false,
2632
+ publicDir: existsSync16(resolve18(config.root, "public")) ? resolve18(config.root, "public") : false,
2344
2633
  resolve: {
2345
2634
  dedupe: ["react", "react-dom", "odori"],
2346
2635
  // Only when the runtime is present as source. A consumer resolves the
2347
2636
  // published package through its exports map instead.
2348
2637
  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") }
2638
+ { find: /^odori\/preview$/, replacement: resolve18(odoriSrc, "preview.ts") },
2639
+ { find: /^odori\/manifest$/, replacement: resolve18(odoriSrc, "manifest.ts") },
2640
+ { find: /^odori$/, replacement: resolve18(odoriSrc, "index.tsx") }
2352
2641
  ] : []
2353
2642
  },
2354
2643
  server: {
@@ -2378,7 +2667,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2378
2667
  }, 150);
2379
2668
  };
2380
2669
  const rediscover = async (file) => {
2381
- if (!file.startsWith(resolve17(config.root, config.videosDir))) return;
2670
+ if (!file.startsWith(resolve18(config.root, config.videosDir))) return;
2382
2671
  const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep3}brands${sep3}`);
2383
2672
  if (!isEntry) return;
2384
2673
  try {
@@ -2395,7 +2684,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2395
2684
  };
2396
2685
  vite.watcher.on("add", (file) => void rediscover(file));
2397
2686
  vite.watcher.on("unlink", (file) => void rediscover(file));
2398
- vite.watcher.add(resolve17(config.root, config.videosDir));
2687
+ vite.watcher.add(resolve18(config.root, config.videosDir));
2399
2688
  const reloadConfig = async (file) => {
2400
2689
  if (!/odori\.config\.(?:ts|mjs|js)$/.test(file)) return;
2401
2690
  try {
@@ -2412,14 +2701,14 @@ var startStudioServer = async (initialConfig, options = {}) => {
2412
2701
  vite.ws.send({ type: "full-reload" });
2413
2702
  };
2414
2703
  const refreshCues = (file) => {
2415
- if (!file.startsWith(resolve17(config.root, config.videosDir) + sep3)) return;
2704
+ if (!file.startsWith(resolve18(config.root, config.videosDir) + sep3)) return;
2416
2705
  if (!/\.tsx?$/.test(file)) return;
2417
2706
  scheduleCueRefresh();
2418
2707
  };
2419
2708
  vite.watcher.on("change", (file) => refreshCues(file));
2420
2709
  vite.watcher.on("change", (file) => void reloadConfig(file));
2421
2710
  for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
2422
- vite.watcher.add(resolve17(config.root, name));
2711
+ vite.watcher.add(resolve18(config.root, name));
2423
2712
  }
2424
2713
  vite.middlewares.use(async (request, response, next) => {
2425
2714
  const url = (request.url ?? "/").split("?")[0];
@@ -2429,7 +2718,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2429
2718
  return;
2430
2719
  }
2431
2720
  try {
2432
- const html = await readFile12(studioEntry, "utf8");
2721
+ const html = await readFile13(studioEntry, "utf8");
2433
2722
  response.statusCode = 200;
2434
2723
  response.setHeader("content-type", "text/html");
2435
2724
  response.end(await vite.transformIndexHtml(url, html));
@@ -2450,7 +2739,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
2450
2739
  };
2451
2740
 
2452
2741
  // src/commands/exportVideo.ts
2453
- import { resolve as resolve18 } from "path";
2742
+ import { resolve as resolve19 } from "path";
2454
2743
  import { resolveEntryLayout as resolveEntryLayout4 } from "odori";
2455
2744
 
2456
2745
  // src/commands/shared.ts
@@ -2566,6 +2855,7 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
2566
2855
  scale: options.scale ?? record.render?.scale,
2567
2856
  format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : void 0),
2568
2857
  audio: options.audio ?? record.render?.audio,
2858
+ graphics: options.graphics ?? record.render?.graphics,
2569
2859
  skipUnchangedFrames: options.skipUnchangedFrames,
2570
2860
  signal: controller.signal,
2571
2861
  onTimings: (timings) => {
@@ -2620,7 +2910,7 @@ var exportCommand = async (id, options = {}) => {
2620
2910
  options.input ?? {},
2621
2911
  { scenes: compiled.scenes, audio: compiled.audio }
2622
2912
  );
2623
- const output = resolve18(
2913
+ const output = resolve19(
2624
2914
  config.root,
2625
2915
  options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
2626
2916
  );
@@ -2629,11 +2919,16 @@ var exportCommand = async (id, options = {}) => {
2629
2919
  quality,
2630
2920
  scale,
2631
2921
  audio: options.audio !== false,
2922
+ graphics: options.fast ? "gpu" : "software",
2632
2923
  ...options.preset ? { preset: options.preset } : {}
2633
2924
  });
2634
2925
  })();
2635
2926
  const video = findVideo(videos, record.manifest.videoId);
2636
2927
  log.detail(`job ${record.job.id} manifest ${record.manifest.manifestHash}`);
2928
+ const backend = options.fast ? "gpu" : record.render?.graphics ?? "software";
2929
+ log.detail(
2930
+ backend === "gpu" ? "graphics: this machine's GPU. Faster, and the pixels are specific to it." : "graphics: software, reproducible on any machine"
2931
+ );
2637
2932
  if (options.retry) log.detail(`retrying attempt ${record.job.attempts + 1} from the frozen manifest`);
2638
2933
  if (record.manifest.audio.length > 0) {
2639
2934
  log.detail(`${record.manifest.audio.length} audio cue(s) at ${record.manifest.format.durationInFrames} frames`);
@@ -2646,6 +2941,7 @@ var exportCommand = async (id, options = {}) => {
2646
2941
  scale: options.retry && options.scale === void 0 ? void 0 : scale,
2647
2942
  format: options.retry ? void 0 : format,
2648
2943
  audio: options.audio,
2944
+ graphics: options.fast ? "gpu" : void 0,
2649
2945
  skipUnchangedFrames: options.skipUnchangedFrames,
2650
2946
  onProgress: (next) => {
2651
2947
  if (next.status === "rendering" || next.status === "encoding") {
@@ -2688,8 +2984,8 @@ var json = (response, status, payload) => {
2688
2984
  response.end(JSON.stringify(payload));
2689
2985
  };
2690
2986
  var exportDestination = (config) => {
2691
- const downloads = resolve19(homedir2(), "Downloads");
2692
- return existsSync16(downloads) ? downloads : resolve19(config.root, config.exportDir);
2987
+ const downloads = resolve20(homedir3(), "Downloads");
2988
+ return existsSync17(downloads) ? downloads : resolve20(config.root, config.exportDir);
2693
2989
  };
2694
2990
  var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
2695
2991
  var hostOf = (value) => {
@@ -2748,7 +3044,7 @@ var devCommand = async (options = {}) => {
2748
3044
  );
2749
3045
  const frame = Number(body.frame ?? 0);
2750
3046
  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`);
3047
+ 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
3048
  await renderStill(
2753
3049
  origin,
2754
3050
  targetFor(
@@ -2764,7 +3060,7 @@ var devCommand = async (options = {}) => {
2764
3060
  if (inline) {
2765
3061
  response.statusCode = 200;
2766
3062
  response.setHeader("content-type", "image/png");
2767
- response.end(await readFile13(file));
3063
+ response.end(await readFile14(file));
2768
3064
  return;
2769
3065
  }
2770
3066
  json(response, 200, { id: "still", status: "ready", progress: 1, output: file });
@@ -2786,7 +3082,7 @@ var devCommand = async (options = {}) => {
2786
3082
  input,
2787
3083
  { scenes: compiled.scenes, audio: compiled.audio }
2788
3084
  );
2789
- const output = resolve19(
3085
+ const output = resolve20(
2790
3086
  exportDestination(config),
2791
3087
  `${outputName(video.entry.metadata.id)}${format.extension}`
2792
3088
  );
@@ -2822,8 +3118,8 @@ var devCommand = async (options = {}) => {
2822
3118
  }
2823
3119
  if (request.method === "GET" && url.startsWith("/source")) {
2824
3120
  const asked = new URL(url, "http://localhost").searchParams.get("file") ?? "";
2825
- const file = resolve19(config.root, asked);
2826
- const inside = relative7(config.root, file);
3121
+ const file = resolve20(config.root, asked);
3122
+ const inside = relative8(config.root, file);
2827
3123
  const readable = /\.(tsx?|jsx?|css|json|md)$/.test(file);
2828
3124
  if (!inside || inside.startsWith("..") || !readable) {
2829
3125
  json(response, 400, { error: `Refusing to read ${asked}.` });
@@ -2832,12 +3128,75 @@ var devCommand = async (options = {}) => {
2832
3128
  try {
2833
3129
  response.statusCode = 200;
2834
3130
  response.setHeader("content-type", "text/plain; charset=utf-8");
2835
- response.end(await readFile13(file, "utf8"));
3131
+ response.end(await readFile14(file, "utf8"));
2836
3132
  } catch {
2837
3133
  json(response, 404, { error: `${asked} is not there.` });
2838
3134
  }
2839
3135
  return;
2840
3136
  }
3137
+ if (request.method === "POST" && url.startsWith("/generate")) {
3138
+ const body = await readBody(request);
3139
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
3140
+ if (!prompt) {
3141
+ json(response, 400, { error: "A prompt is required." });
3142
+ return;
3143
+ }
3144
+ const report = await prepareBed(config, prompt, {
3145
+ generate: true,
3146
+ seconds: typeof body.seconds === "number" ? Math.min(300, Math.max(5, body.seconds)) : void 0,
3147
+ role: typeof body.role === "string" && body.role.trim() ? body.role.trim() : void 0,
3148
+ provider: typeof body.provider === "string" ? body.provider : void 0
3149
+ });
3150
+ json(response, 200, {
3151
+ ...report,
3152
+ source: relative8(config.root, report.source),
3153
+ destination: relative8(config.root, report.destination)
3154
+ });
3155
+ return;
3156
+ }
3157
+ if (url === "/integrations" || url === "/integrations/") {
3158
+ if (request.method === "GET") {
3159
+ const providers = await Promise.all(
3160
+ Object.values(musicProviders).map(async (provider) => ({
3161
+ name: provider.name,
3162
+ title: provider.title,
3163
+ kind: "music",
3164
+ keyVariable: provider.keyVariable,
3165
+ docsUrl: provider.docsUrl,
3166
+ source: await keySource(provider.keyVariable)
3167
+ }))
3168
+ );
3169
+ json(response, 200, { providers });
3170
+ return;
3171
+ }
3172
+ if (request.method === "POST") {
3173
+ const body = await readBody(request);
3174
+ const provider = musicProviders[String(body.provider ?? "")];
3175
+ if (!provider) {
3176
+ json(response, 400, { error: `No provider named ${JSON.stringify(body.provider)}.` });
3177
+ return;
3178
+ }
3179
+ const key = typeof body.key === "string" ? body.key.trim() : "";
3180
+ if (!key) {
3181
+ await clearStoredKey(provider.keyVariable);
3182
+ json(response, 200, { source: await keySource(provider.keyVariable), verified: null });
3183
+ return;
3184
+ }
3185
+ let verified = null;
3186
+ try {
3187
+ verified = await provider.verifyKey(key);
3188
+ } catch {
3189
+ verified = null;
3190
+ }
3191
+ if (verified === false) {
3192
+ json(response, 400, { error: `${provider.title} rejected that key.` });
3193
+ return;
3194
+ }
3195
+ await setStoredKey(provider.keyVariable, key);
3196
+ json(response, 200, { source: await keySource(provider.keyVariable), verified });
3197
+ return;
3198
+ }
3199
+ }
2841
3200
  if (request.method === "GET" && url.startsWith("/jobs")) {
2842
3201
  json(response, 200, await listJobs(config));
2843
3202
  return;
@@ -2862,16 +3221,16 @@ var devCommand = async (options = {}) => {
2862
3221
 
2863
3222
  // src/commands/doctor.ts
2864
3223
  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";
3224
+ import { access, mkdir as mkdir15, readFile as readFile15, rm as rm6, writeFile as writeFile16 } from "fs/promises";
3225
+ import { existsSync as existsSync18 } from "fs";
2867
3226
  import { createRequire as createRequire3 } from "module";
2868
- import { relative as relative8, resolve as resolve20 } from "path";
3227
+ import { relative as relative9, resolve as resolve21 } from "path";
2869
3228
  var MINIMUM_NODE = 20;
2870
3229
  var version = (value) => value.replace(/^v/, "").split(".").map(Number);
2871
3230
  var runChecks = async (root) => {
2872
3231
  const checks = [];
2873
3232
  const config = await loadConfig(root);
2874
- const require2 = createRequire3(resolve20(root, "package.json"));
3233
+ const require2 = createRequire3(resolve21(root, "package.json"));
2875
3234
  const [major] = version(process.version);
2876
3235
  checks.push({
2877
3236
  name: "Node",
@@ -2882,7 +3241,7 @@ var runChecks = async (root) => {
2882
3241
  let react2 = "not found";
2883
3242
  let reactOk = false;
2884
3243
  try {
2885
- const manifest = JSON.parse(await readFile14(require2.resolve("react/package.json"), "utf8"));
3244
+ const manifest = JSON.parse(await readFile15(require2.resolve("react/package.json"), "utf8"));
2886
3245
  react2 = manifest.version;
2887
3246
  reactOk = version(react2)[0] >= 19;
2888
3247
  } catch {
@@ -2894,16 +3253,16 @@ var runChecks = async (root) => {
2894
3253
  ok: reactOk,
2895
3254
  fix: "The runtime needs React 19. Install it: npm install react@19 react-dom@19"
2896
3255
  });
2897
- const videosDir = resolve20(config.root, config.videosDir);
3256
+ const videosDir = resolve21(config.root, config.videosDir);
2898
3257
  checks.push({
2899
3258
  name: "Source root",
2900
- detail: existsSync17(videosDir) ? relative8(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2901
- ok: existsSync17(videosDir),
3259
+ detail: existsSync18(videosDir) ? relative9(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
3260
+ ok: existsSync18(videosDir),
2902
3261
  fix: 'Run "odori init" to add the videos source root.'
2903
3262
  });
2904
3263
  checks.push({
2905
3264
  name: "Config",
2906
- detail: config.configPath ? relative8(config.root, config.configPath) : "defaults (no odori.config.ts)",
3265
+ detail: config.configPath ? relative9(config.root, config.configPath) : "defaults (no odori.config.ts)",
2907
3266
  // Loading got this far, so a config that exists also parsed.
2908
3267
  ok: true
2909
3268
  });
@@ -2929,25 +3288,25 @@ var runChecks = async (root) => {
2929
3288
  detail: unpinned.length === 0 ? `pinned binaries from ${cacheRoot()}` : `${unpinned.length} of 2 from the host; frames may differ from another machine`,
2930
3289
  ok: true
2931
3290
  });
2932
- const generated = resolve20(config.root, ".odori");
3291
+ const generated = resolve21(config.root, ".odori");
2933
3292
  let writable = false;
2934
3293
  try {
2935
- await mkdir13(generated, { recursive: true });
2936
- const probe = resolve20(generated, ".doctor");
2937
- await writeFile14(probe, "", "utf8");
3294
+ await mkdir15(generated, { recursive: true });
3295
+ const probe = resolve21(generated, ".doctor");
3296
+ await writeFile16(probe, "", "utf8");
2938
3297
  await access(probe, constants.W_OK);
2939
- await rm5(probe, { force: true });
3298
+ await rm6(probe, { force: true });
2940
3299
  writable = true;
2941
3300
  } catch {
2942
3301
  writable = false;
2943
3302
  }
2944
- const componentsRoot = resolve20(root, config.componentsDir);
3303
+ const componentsRoot = resolve21(root, config.componentsDir);
2945
3304
  const orphans = [];
2946
- if (existsSync17(componentsRoot)) {
3305
+ if (existsSync18(componentsRoot)) {
2947
3306
  const { readdir: readdir9 } = await import("fs/promises");
2948
3307
  for (const entry of await readdir9(componentsRoot, { withFileTypes: true })) {
2949
3308
  if (!entry.isDirectory()) continue;
2950
- const files = await readdir9(resolve20(componentsRoot, entry.name));
3309
+ const files = await readdir9(resolve21(componentsRoot, entry.name));
2951
3310
  const source = files.some((file) => /\.tsx$/.test(file) && !file.endsWith(".preview.tsx"));
2952
3311
  const fixture = files.some((file) => file.endsWith(".preview.tsx"));
2953
3312
  if (source && !fixture) orphans.push(entry.name);
@@ -2960,11 +3319,21 @@ var runChecks = async (root) => {
2960
3319
  warn: orphans.length > 0,
2961
3320
  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
3321
  });
3322
+ for (const provider of Object.values(musicProviders)) {
3323
+ const source = await keySource(provider.keyVariable);
3324
+ checks.push({
3325
+ name: `Provider: ${provider.name}`,
3326
+ detail: source === "environment" ? `connected (${provider.keyVariable})` : source === "stored" ? "connected (key stored on this machine)" : "not configured",
3327
+ ok: true,
3328
+ warn: source === null,
3329
+ fix: `Optional. To generate with ${provider.title}: set ${provider.keyVariable}, or paste a key in Studio's integrations page.`
3330
+ });
3331
+ }
2963
3332
  checks.push({
2964
3333
  name: "Generated cache",
2965
3334
  detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
2966
3335
  ok: writable,
2967
- fix: `Odori writes its import graph and render cache to ${relative8(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
3336
+ fix: `Odori writes its import graph and render cache to ${relative9(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
2968
3337
  });
2969
3338
  return checks;
2970
3339
  };
@@ -2991,14 +3360,14 @@ var doctorCommand = async (root = process.cwd()) => {
2991
3360
  };
2992
3361
 
2993
3362
  // 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";
3363
+ import { mkdir as mkdir17, readFile as readFile16, writeFile as writeFile18 } from "fs/promises";
3364
+ import { existsSync as existsSync20 } from "fs";
3365
+ import { relative as relative11, resolve as resolve23 } from "path";
2997
3366
 
2998
3367
  // 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";
3368
+ import { mkdir as mkdir16, readdir as readdir6, writeFile as writeFile17 } from "fs/promises";
3369
+ import { existsSync as existsSync19 } from "fs";
3370
+ import { relative as relative10, resolve as resolve22 } from "path";
3002
3371
  var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
3003
3372
  var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
3004
3373
  var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
@@ -3056,27 +3425,27 @@ ${closing}
3056
3425
  `;
3057
3426
  };
3058
3427
  var installedParts = async (config) => {
3059
- const componentsDir = resolve21(config.root, config.componentsDir);
3060
- if (!existsSync18(componentsDir)) return { title: false, end: false };
3428
+ const componentsDir = resolve22(config.root, config.componentsDir);
3429
+ if (!existsSync19(componentsDir)) return { title: false, end: false };
3061
3430
  const entries = (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
3062
3431
  return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
3063
3432
  };
3064
3433
  var newCommand = async (name, options = {}) => {
3065
3434
  if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("Use a lowercase, dash separated name.");
3066
3435
  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"));
3436
+ const directory2 = resolve22(config.root, config.videosDir, name);
3437
+ const file = resolve22(directory2, "video.tsx");
3438
+ if (existsSync19(file)) throw new Error(`${relative10(config.root, file)} already exists.`);
3439
+ const hasLayout = existsSync19(resolve22(config.root, config.videosDir, "layout.tsx"));
3071
3440
  const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
3072
3441
  const composed = parts.title || parts.end;
3073
- await mkdir14(directory2, { recursive: true });
3074
- await writeFile15(
3442
+ await mkdir16(directory2, { recursive: true });
3443
+ await writeFile17(
3075
3444
  file,
3076
3445
  composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
3077
3446
  "utf8"
3078
3447
  );
3079
- log.success(`Created ${relative9(config.root, file)}`);
3448
+ log.success(`Created ${relative10(config.root, file)}`);
3080
3449
  if (composed) log.detail("Composed from the components this project has installed.");
3081
3450
  else if (options.blank !== true) {
3082
3451
  log.detail("No registry components installed yet: odori add title-reveal end-card");
@@ -3110,45 +3479,58 @@ export const productLayout = defineVideoLayout({
3110
3479
  });
3111
3480
  `;
3112
3481
  var initCommand = async (root = process.cwd()) => {
3113
- const videosDir = resolve22(root, defaultConfig.videosDir);
3114
- await mkdir15(resolve22(videosDir, "components"), { recursive: true });
3482
+ const videosDir = resolve23(root, defaultConfig.videosDir);
3483
+ await mkdir17(resolve23(videosDir, "components"), { recursive: true });
3115
3484
  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)]
3485
+ [resolve23(root, "odori.config.ts"), CONFIG_TEMPLATE],
3486
+ [resolve23(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
3487
+ [resolve23(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
3119
3488
  ];
3120
3489
  for (const [file, contents] of files) {
3121
- if (existsSync19(file)) {
3122
- log.detail(`Kept existing ${relative10(root, file)}`);
3490
+ if (existsSync20(file)) {
3491
+ log.detail(`Kept existing ${relative11(root, file)}`);
3123
3492
  continue;
3124
3493
  }
3125
- await mkdir15(resolve22(file, ".."), { recursive: true });
3126
- await writeFile16(file, contents, "utf8");
3127
- log.success(`Created ${relative10(root, file)}`);
3494
+ await mkdir17(resolve23(file, ".."), { recursive: true });
3495
+ await writeFile18(file, contents, "utf8");
3496
+ log.success(`Created ${relative11(root, file)}`);
3128
3497
  }
3129
3498
  await ensureModuleType(root);
3130
3499
  log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
3131
3500
  };
3132
3501
  var ensureModuleType = async (root) => {
3133
- const file = resolve22(root, "package.json");
3134
- if (!existsSync19(file)) {
3502
+ const file = resolve23(root, "package.json");
3503
+ if (!existsSync20(file)) {
3135
3504
  log.warn('No package.json here. Odori needs an ESM package: run npm init, then add "type": "module".');
3136
3505
  return;
3137
3506
  }
3138
3507
  let manifest;
3139
3508
  try {
3140
- manifest = JSON.parse(await readFile15(file, "utf8"));
3509
+ manifest = JSON.parse(await readFile16(file, "utf8"));
3141
3510
  } catch {
3142
3511
  log.warn('package.json is not readable JSON, so "type": "module" was not set. Odori needs it.');
3143
3512
  return;
3144
3513
  }
3145
3514
  if (manifest.type === "module") return;
3146
3515
  manifest.type = "module";
3147
- await writeFile16(file, `${JSON.stringify(manifest, null, 2)}
3516
+ await writeFile18(file, `${JSON.stringify(manifest, null, 2)}
3148
3517
  `, "utf8");
3149
3518
  log.success('Set "type": "module" in package.json');
3150
3519
  };
3151
3520
 
3521
+ // src/commands/integrations.ts
3522
+ var integrationsCommand = async () => {
3523
+ log.title("Integrations");
3524
+ for (const provider of Object.values(musicProviders)) {
3525
+ const source = await keySource(provider.keyVariable);
3526
+ 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`;
3527
+ log.info(` ${provider.name.padEnd(14)} music ${status}`);
3528
+ }
3529
+ log.detail("");
3530
+ log.detail(' Generate through a task command: odori bed "warm ambient, no drums" --generate');
3531
+ log.detail(" Keys are read from the environment first, then ~/.config/odori. Never the project.");
3532
+ };
3533
+
3152
3534
  // src/commands/inspect.ts
3153
3535
  import { isOdoriSchema, resolveEntryLayout as resolveEntryLayout5 } from "odori";
3154
3536
  var inspectCommand = async (id, options = {}) => {
@@ -3235,8 +3617,50 @@ var listCommand = async () => {
3235
3617
  }
3236
3618
  };
3237
3619
 
3620
+ // src/commands/narrate.ts
3621
+ import { mkdir as mkdir18, writeFile as writeFile19 } from "fs/promises";
3622
+ import { basename as basename3, dirname as dirname9, extname as extname2, join as join9, resolve as resolve24 } from "path";
3623
+ import { wordsFromCharacters } from "odori";
3624
+ var narrateCommand = async (script, options = {}) => {
3625
+ if (!script.trim()) throw new Error('Give the script to read, for example: odori narrate "One definition. Every render."');
3626
+ const config = await loadConfig(process.cwd());
3627
+ const provider = resolveVoiceProvider(options.provider ?? "elevenlabs");
3628
+ const apiKey = await resolveKey(provider);
3629
+ if (!apiKey) {
3630
+ throw new Error(
3631
+ `Narrating with ${provider.name} needs a key: set ${provider.keyVariable} in the environment,
3632
+ or paste one once into Studio\u2019s integrations page ("odori dev", then Integrations).
3633
+ Either way it is sent only to the provider and never stored in the project.`
3634
+ );
3635
+ }
3636
+ const voice = options.voice ?? provider.defaultVoice;
3637
+ log.detail(`Recording ${script.split(/\s+/).length} words with ${provider.title}`);
3638
+ const { bytes, extension, alignment } = await provider.speak({ script, voice, apiKey });
3639
+ const words = wordsFromCharacters(alignment);
3640
+ if (words.length === 0) throw new Error("The provider returned no word timings, so captions cannot be derived. Nothing was written.");
3641
+ const stem = options.output ? basename3(options.output, extname2(options.output)) : script.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "narration";
3642
+ const directory2 = options.output ? dirname9(resolve24(config.root, options.output)) : join9(config.root, "public", "audio");
3643
+ await mkdir18(directory2, { recursive: true });
3644
+ const audioFile = join9(directory2, `${stem}.${extension}`);
3645
+ await writeFile19(audioFile, bytes);
3646
+ const role = options.role ?? "voice.narration";
3647
+ const url = `/audio/${basename3(audioFile)}`;
3648
+ const narration = { script, provider: provider.name, voice, audio: role, words };
3649
+ const timingFile = join9(directory2, `${stem}.narration.json`);
3650
+ await writeFile19(timingFile, `${JSON.stringify(narration, null, 2)}
3651
+ `, "utf8");
3652
+ const registered = await registerCueInBrand(config, { name: role, url }, stem);
3653
+ const seconds = words[words.length - 1].endSeconds;
3654
+ log.success(`Recorded ${seconds.toFixed(1)}s to ${basename3(audioFile)} (${(bytes.length / 1024).toFixed(0)} KB)`);
3655
+ log.success(`Word timings in ${basename3(timingFile)}`);
3656
+ if (registered) log.detail(`Registered "${role}" in the brand`);
3657
+ log.detail("In a video:");
3658
+ log.detail(` <Audio src="${role}" />`);
3659
+ log.detail(` <Captions cues={captionCues(narration, fps)} /> // import narration from the json`);
3660
+ };
3661
+
3238
3662
  // src/commands/frame.ts
3239
- import { resolve as resolve23 } from "path";
3663
+ import { resolve as resolve25 } from "path";
3240
3664
  import { framesFromOffset, resolveEntryLayout as resolveEntryLayout7 } from "odori";
3241
3665
  var frameCommand = async (id, options = {}) => {
3242
3666
  const at = options.at ?? 0;
@@ -3258,7 +3682,7 @@ var frameCommand = async (id, options = {}) => {
3258
3682
  throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
3259
3683
  }
3260
3684
  const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
3261
- const file = resolve23(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3685
+ const file = resolve25(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
3262
3686
  return renderStill(server.url, target, frame, file, config);
3263
3687
  });
3264
3688
  log.success(`Frame ${frame} written to ${output}`);
@@ -3269,9 +3693,9 @@ var frameCommand = async (id, options = {}) => {
3269
3693
  import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout9 } from "odori";
3270
3694
 
3271
3695
  // src/contracts.ts
3272
- import { existsSync as existsSync20 } from "fs";
3696
+ import { existsSync as existsSync21 } from "fs";
3273
3697
  import { readdir as readdir7 } from "fs/promises";
3274
- import { resolve as resolve24 } from "path";
3698
+ import { resolve as resolve26 } from "path";
3275
3699
  import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
3276
3700
  var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
3277
3701
  var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
@@ -3338,8 +3762,8 @@ var checkAudioWindows = (cues, brand, videoId) => {
3338
3762
  return failures;
3339
3763
  };
3340
3764
  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) : [];
3765
+ const componentsDir = resolve26(config.root, config.componentsDir);
3766
+ const onDisk = existsSync21(componentsDir) ? (await readdir7(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
3343
3767
  const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
3344
3768
  if (names.size === 0) return [];
3345
3769
  const { items } = await resolveRegistry(config, { allowNetwork: false });
@@ -3360,9 +3784,9 @@ var checkInstalledContracts = async (config, videos) => {
3360
3784
  };
3361
3785
 
3362
3786
  // 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";
3787
+ import { readdir as readdir8, readFile as readFile17 } from "fs/promises";
3788
+ import { existsSync as existsSync22 } from "fs";
3789
+ import { join as join10, relative as relative12, resolve as resolve27 } from "path";
3366
3790
  var FORBIDDEN = [
3367
3791
  {
3368
3792
  pattern: /\bMath\.random\s*\(/,
@@ -3396,18 +3820,18 @@ var scanSource = (source, file) => {
3396
3820
  var walk2 = async (directory2, files = []) => {
3397
3821
  for (const entry of await readdir8(directory2, { withFileTypes: true })) {
3398
3822
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
3399
- const full = join7(directory2, entry.name);
3823
+ const full = join10(directory2, entry.name);
3400
3824
  if (entry.isDirectory()) await walk2(full, files);
3401
3825
  else if (/\.(tsx|ts|jsx|js)$/.test(entry.name) && !/\.preview\.(tsx|jsx)$/.test(entry.name)) files.push(full);
3402
3826
  }
3403
3827
  return files;
3404
3828
  };
3405
3829
  var checkDeterminism = async (config) => {
3406
- const root = resolve25(config.root, config.videosDir);
3407
- if (!existsSync21(root)) return [];
3830
+ const root = resolve27(config.root, config.videosDir);
3831
+ if (!existsSync22(root)) return [];
3408
3832
  const files = await walk2(root);
3409
3833
  const findings = await Promise.all(
3410
- files.map(async (file) => scanSource(await readFile16(file, "utf8"), relative11(config.root, file)))
3834
+ files.map(async (file) => scanSource(await readFile17(file, "utf8"), relative12(config.root, file)))
3411
3835
  );
3412
3836
  return findings.flat();
3413
3837
  };
@@ -3478,12 +3902,13 @@ var CANVAS_SCRIPT = `(() => {
3478
3902
  })()`;
3479
3903
  var FRAME_SCRIPT = `(() => {
3480
3904
  var root = document.querySelector("[data-odori-video]");
3481
- if (!root) return {overflow: [], small: [], empty: true, painted: 0};
3905
+ if (!root) return {overflow: [], small: [], empty: true, painted: 0, faded: 0};
3482
3906
 
3483
3907
  var bounds = root.getBoundingClientRect();
3484
3908
  var overflow = [];
3485
3909
  var small = [];
3486
3910
  var painted = 0;
3911
+ var faded = 0;
3487
3912
  var nodes = Array.prototype.slice.call(root.querySelectorAll("*"));
3488
3913
 
3489
3914
  for (var index = 0; index < nodes.length; index += 1) {
@@ -3496,7 +3921,22 @@ var FRAME_SCRIPT = `(() => {
3496
3921
  var box = node.getBoundingClientRect();
3497
3922
  if (box.width === 0 || box.height === 0) continue;
3498
3923
  var style = getComputedStyle(node);
3499
- if (style.visibility === "hidden" || Number(style.opacity) < 0.02) continue;
3924
+ if (style.visibility === "hidden") continue;
3925
+ /* Opacity inherits down the tree in effect even though it does not
3926
+ inherit as a property: a parent faded to nothing takes its children
3927
+ with it, while each child still computes its own opacity as 1. Reading
3928
+ one node's value therefore judges text nobody can see. A scene that has
3929
+ faded out is the common case, and it was reporting its hidden dialogue
3930
+ as unreadable. */
3931
+ var effective = 1;
3932
+ for (var up = node; up && up !== root.parentElement; up = up.parentElement) {
3933
+ effective *= Number(getComputedStyle(up).opacity);
3934
+ if (effective < 0.02) break;
3935
+ }
3936
+ if (effective < 0.02) {
3937
+ faded += 1;
3938
+ continue;
3939
+ }
3500
3940
  painted += 1;
3501
3941
 
3502
3942
  var media = ["IMG", "SVG", "CANVAS", "VIDEO"].indexOf(node.tagName) >= 0;
@@ -3521,12 +3961,27 @@ var FRAME_SCRIPT = `(() => {
3521
3961
  * content the video is actually about, which is what stays checked.
3522
3962
  */
3523
3963
  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
- ) {
3964
+
3965
+ /*
3966
+ * Crossing the frame edge is not a fault. Film bleeds: a surface runs past
3967
+ * the corner, a push-in takes a headline wider than the shot, a full-frame
3968
+ * image is cropped rather than letterboxed. The old rule flagged any box
3969
+ * that crossed by a pixel, which is a rule about a document, not about a
3970
+ * cut, and the only reason the recreations passed it is that five sampled
3971
+ * frames happened to miss their own bleeds.
3972
+ *
3973
+ * What is worth reporting is content with no intersection at all: nothing
3974
+ * of it is on screen at the frame that was sampled, which is what a layout
3975
+ * mistake looks like. Deliberate overscan does that too, and says so with
3976
+ * data-odori-bleed.
3977
+ */
3978
+ var bleed = node.closest("[data-odori-bleed]") !== null;
3979
+ var offCanvas =
3980
+ box.right <= bounds.left ||
3981
+ box.left >= bounds.right ||
3982
+ box.bottom <= bounds.top ||
3983
+ box.top >= bounds.bottom;
3984
+ if (offCanvas && !bleed) {
3530
3985
  if (overflow.indexOf(label) < 0) overflow.push(label);
3531
3986
  }
3532
3987
 
@@ -3550,7 +4005,7 @@ var FRAME_SCRIPT = `(() => {
3550
4005
  }
3551
4006
  }
3552
4007
 
3553
- return {overflow: overflow.slice(0, 5), small: small.slice(0, 5), empty: false, painted: painted};
4008
+ return {overflow: overflow.slice(0, 5), small: small.slice(0, 5), empty: false, painted: painted, faded: faded};
3554
4009
  })()`;
3555
4010
  var testVideo = async (origin, video, config, failures, quiet = false) => {
3556
4011
  const id = video.entry.metadata.id;
@@ -3559,7 +4014,7 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
3559
4014
  const result = video.entry.metadata.schema.safeParse(video.entry.metadata.defaultProps ?? {});
3560
4015
  if (!result.success) failures.push({ video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}` });
3561
4016
  }
3562
- const { browser, page } = await openRenderPage(origin, targetFor(video), config);
4017
+ const { browser, page, errors } = await openRenderPage(origin, targetFor(video), config);
3563
4018
  try {
3564
4019
  const timeline = await readTimeline(page);
3565
4020
  failures.push(...checkAudioWindows((await readAudio(page)).cues, layout.brand, id));
@@ -3576,16 +4031,16 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
3576
4031
  }
3577
4032
  const samples = [0, Math.floor(total / 4), Math.floor(total / 2), Math.floor(total * 3 / 4), total - 1];
3578
4033
  for (const frame of [...new Set(samples)]) {
3579
- await seekTo(page, frame);
4034
+ await seekTo(page, frame, errors);
3580
4035
  const result = await page.evaluate(FRAME_SCRIPT);
3581
4036
  if (result.empty) failures.push({ video: id, message: `Frame ${frame} rendered no video root.` });
3582
- if (!result.empty && result.painted < 2) {
4037
+ if (!result.empty && result.painted < 2 && result.faded < 2) {
3583
4038
  failures.push({ video: id, message: `Frame ${frame} is blank.` });
3584
4039
  }
3585
4040
  for (const item of result.overflow) {
3586
4041
  failures.push({
3587
4042
  video: id,
3588
- message: `Frame ${frame}: ${item} escapes the ${layout.format.width}x${layout.format.height} canvas.`
4043
+ message: `Frame ${frame}: ${item} is entirely outside the ${layout.format.width}x${layout.format.height} canvas.`
3589
4044
  });
3590
4045
  }
3591
4046
  for (const item of result.small) {
@@ -3593,8 +4048,8 @@ var testVideo = async (origin, video, config, failures, quiet = false) => {
3593
4048
  }
3594
4049
  const canvases = await page.evaluate(CANVAS_SCRIPT);
3595
4050
  if (canvases.length > 0) {
3596
- await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1);
3597
- await seekTo(page, frame);
4051
+ await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1, errors);
4052
+ await seekTo(page, frame, errors);
3598
4053
  const again = await page.evaluate(CANVAS_SCRIPT);
3599
4054
  for (const canvas of canvases) {
3600
4055
  const second = again.find((item) => item.index === canvas.index);
@@ -3666,7 +4121,7 @@ var testCommand = async (id, options = {}) => {
3666
4121
  };
3667
4122
 
3668
4123
  // src/cli.ts
3669
- var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["force", "dry-run", "json", "no-audio", "no-frame-skip", "no-open", "open", "help", "version"]);
4124
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["force", "dry-run", "json", "no-audio", "fast", "no-frame-skip", "no-open", "open", "help", "version"]);
3670
4125
  var RENAMED = { still: "frame" };
3671
4126
  var parseArgs = (argv) => {
3672
4127
  const [command2 = "help", ...rest] = argv;
@@ -3720,13 +4175,16 @@ var COMMAND_FLAGS = {
3720
4175
  new: ["blank"],
3721
4176
  add: ["force", "dry-run"],
3722
4177
  registry: [],
4178
+ integrations: [],
3723
4179
  diff: ["full"],
3724
4180
  update: ["force"],
3725
4181
  list: [],
3726
4182
  inspect: ["json", "input"],
3727
4183
  frame: ["at", "output", "input"],
4184
+ bed: ["role", "output", "target", "generate", "provider", "seconds"],
4185
+ narrate: ["output", "voice", "role", "provider"],
3728
4186
  test: ["json"],
3729
- export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "no-frame-skip", "retry"],
4187
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
3730
4188
  jobs: [],
3731
4189
  help: []
3732
4190
  };
@@ -3765,6 +4223,21 @@ var USAGE = {
3765
4223
  Discover project resources and start Studio.`,
3766
4224
  init: `odori init
3767
4225
  Add videos/ and odori.config.ts to a project.`,
4226
+ bed: `odori bed <file> [--role <name>] [--target <lufs>] [--output <path>]
4227
+ Prepare an audio file to sit under a video: measure it, level it to the stem
4228
+ target every other bed is prepared to, and write it where audio is served.
4229
+ With --generate the positional is a prompt instead of a path: the track is
4230
+ generated with a provider (--provider, default elevenlabs, key from
4231
+ ELEVENLABS_API_KEY), then prepared identically. --seconds sets its length.`,
4232
+ narrate: `odori narrate <script> [--output <path>] [--voice <id>] [--role <cue>]
4233
+ Record the script as narration. Writes the audio and a .narration.json with
4234
+ the time every word starts and ends, and registers the cue role (default
4235
+ voice.narration) in the brand. Captions derive from the timings at compose
4236
+ time, so they cannot drift from the voice.`,
4237
+ integrations: `odori integrations
4238
+ List generation providers and whether each is connected. Configuration
4239
+ lives in the environment or Studio's integrations page; generation happens
4240
+ in task commands like "odori bed --generate".`,
3768
4241
  doctor: `odori doctor
3769
4242
  Check Node, React, the source root, Chrome, FFmpeg, and the generated cache.`,
3770
4243
  install: `odori install
@@ -3795,11 +4268,15 @@ var USAGE = {
3795
4268
  check, for CI.`,
3796
4269
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
3797
4270
  [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
3798
- [--no-audio] [--no-frame-skip] [--retry <job>]
4271
+ [--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
3799
4272
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
3800
4273
  or png; without it the output's extension decides, and mp4 is the default.
3801
4274
  --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
4275
+ 0.25 to 2. --no-audio writes the picture with no sound.
4276
+ --fast draws on this machine's GPU rather than the reproducible software
4277
+ backend. Measured here: about five percent on ordinary post-processing, and
4278
+ fourteen times on a shader that is genuinely per-pixel expensive. The pixels
4279
+ it makes belong to this machine, so keep it for iterating. A retry keeps the
3803
4280
  settings its job was created with.`,
3804
4281
  jobs: `odori jobs
3805
4282
  List export jobs and their status.`
@@ -3821,6 +4298,7 @@ Usage
3821
4298
  odori frame <id> --at 4s Render one deterministic frame to a PNG
3822
4299
  odori test [id] [--json] Validate contracts and representative frames
3823
4300
  odori export <id> [--output f] Render and encode a distributable file
4301
+ odori narrate <script> Record narration with word timings
3824
4302
  odori jobs List export jobs and their status
3825
4303
 
3826
4304
  Options
@@ -3839,7 +4317,7 @@ Options
3839
4317
 
3840
4318
  Run "odori <command> --help" for one command, or "odori doctor" to check setup.
3841
4319
  `;
3842
- var cliVersion = () => {
4320
+ var cliVersion2 = () => {
3843
4321
  try {
3844
4322
  const require2 = createRequire4(import.meta.url);
3845
4323
  return require2("../package.json").version;
@@ -3847,11 +4325,11 @@ var cliVersion = () => {
3847
4325
  return "unknown";
3848
4326
  }
3849
4327
  };
3850
- var run2 = async (argv) => {
4328
+ var run3 = async (argv) => {
3851
4329
  const { command: command2, positionals, flags } = parseArgs(argv);
3852
4330
  try {
3853
4331
  if (command2 === "--version" || command2 === "-v" || command2 === "version") {
3854
- log.info(`odori ${cliVersion()} (node ${process.version})`);
4332
+ log.info(`odori ${cliVersion2()} (node ${process.version})`);
3855
4333
  return 0;
3856
4334
  }
3857
4335
  if (flags.help === true && USAGE[command2]) {
@@ -3877,6 +4355,9 @@ var run2 = async (argv) => {
3877
4355
  case "init":
3878
4356
  await initCommand();
3879
4357
  return 0;
4358
+ case "integrations":
4359
+ await integrationsCommand();
4360
+ return 0;
3880
4361
  case "doctor":
3881
4362
  return await doctorCommand();
3882
4363
  case "install":
@@ -3902,6 +4383,25 @@ var run2 = async (argv) => {
3902
4383
  case "inspect":
3903
4384
  await inspectCommand(positionals[0] ?? "", { json: flags.json === true, input: parseInput(flags) });
3904
4385
  return 0;
4386
+ case "bed":
4387
+ if (!positionals[0]) throw new Error('Which file? "odori bed ./track.mp3".');
4388
+ await bedCommand(positionals[0], {
4389
+ role: typeof flags.role === "string" ? flags.role : void 0,
4390
+ output: typeof flags.output === "string" ? flags.output : void 0,
4391
+ target: numberFlag(flags, "target"),
4392
+ generate: flags.generate === true,
4393
+ provider: typeof flags.provider === "string" ? flags.provider : void 0,
4394
+ seconds: numberFlag(flags, "seconds")
4395
+ });
4396
+ return 0;
4397
+ case "narrate":
4398
+ await narrateCommand(positionals.join(" "), {
4399
+ output: typeof flags.output === "string" ? flags.output : void 0,
4400
+ voice: typeof flags.voice === "string" ? flags.voice : void 0,
4401
+ role: typeof flags.role === "string" ? flags.role : void 0,
4402
+ provider: typeof flags.provider === "string" ? flags.provider : void 0
4403
+ });
4404
+ return 0;
3905
4405
  case "frame":
3906
4406
  await frameCommand(positionals[0] ?? "", {
3907
4407
  // A duration, so "4s" and "120f" both work; a bare number is
@@ -3924,6 +4424,7 @@ var run2 = async (argv) => {
3924
4424
  scale: numberFlag(flags, "scale"),
3925
4425
  format: typeof flags.format === "string" ? flags.format : void 0,
3926
4426
  audio: flags["no-audio"] === true ? false : void 0,
4427
+ fast: flags.fast === true,
3927
4428
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
3928
4429
  retry: typeof flags.retry === "string" ? flags.retry : void 0
3929
4430
  });
@@ -4032,5 +4533,5 @@ export {
4032
4533
  initCommand,
4033
4534
  parseArgs,
4034
4535
  checkFlags,
4035
- run2 as run
4536
+ run3 as run
4036
4537
  };