@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.
- package/dist/{chunk-STVORYOF.js → chunk-TDM65HRW.js} +1575 -1074
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +52 -4
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-ADKSTGDR.js → registry-snapshot-TKH2KAC3.js} +362 -25
- package/package.json +3 -3
- package/src/audio-mix.ts +6 -1
- package/src/cli.ts +53 -4
- package/src/commands/bed.ts +224 -0
- package/src/commands/dev.ts +80 -0
- package/src/commands/doctor.ts +20 -0
- package/src/commands/exportVideo.ts +15 -0
- package/src/commands/integrations.ts +26 -0
- package/src/commands/narrate.ts +74 -0
- package/src/commands/test.ts +55 -16
- package/src/config.ts +5 -0
- package/src/jobs.ts +1 -1
- package/src/keystore.ts +76 -0
- package/src/providers.ts +173 -0
- package/src/registry-snapshot.json +362 -25
- package/src/render.ts +69 -22
- package/src/server.ts +10 -0
- package/studio/src/Studio.tsx +1 -1
- package/studio/src/components/GenerateBed.tsx +148 -0
- package/studio/src/components/Settings.tsx +266 -50
- package/studio/src/components/ui.tsx +11 -1
- package/studio/src/integrations.ts +29 -0
- package/studio/src/studio.css +276 -3
- package/studio/src/views/AssetsView.tsx +6 -0
- package/studio/src/virtual.d.ts +1 -0
|
@@ -317,7 +317,7 @@ var toComponent = (item) => ({
|
|
|
317
317
|
});
|
|
318
318
|
var snapshotItems = async () => {
|
|
319
319
|
try {
|
|
320
|
-
const loaded = await import("./registry-snapshot-
|
|
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
|
|
813
|
-
import { homedir as
|
|
814
|
-
import { existsSync as
|
|
815
|
-
import { readFile as
|
|
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/
|
|
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 {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
var
|
|
824
|
-
|
|
825
|
-
const
|
|
826
|
-
|
|
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
|
-
|
|
884
|
-
return
|
|
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
|
|
831
|
+
return {};
|
|
887
832
|
}
|
|
888
833
|
};
|
|
889
|
-
var
|
|
890
|
-
const
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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/
|
|
927
|
-
import {
|
|
928
|
-
import {
|
|
929
|
-
import {
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
|
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
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
|
1056
|
+
return known.size;
|
|
1055
1057
|
};
|
|
1056
|
-
var
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
|
1071
|
+
return candidates.find((candidate) => existsSync10(candidate)) ?? null;
|
|
1089
1072
|
};
|
|
1090
|
-
var
|
|
1091
|
-
const
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
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
|
-
|
|
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/
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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/
|
|
1129
|
-
import {
|
|
1130
|
-
import {
|
|
1131
|
-
import {
|
|
1132
|
-
import {
|
|
1133
|
-
var
|
|
1134
|
-
var
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
|
1227
|
+
return JSON.parse(await readFile8(meta, "utf8"));
|
|
1139
1228
|
} catch {
|
|
1140
|
-
return
|
|
1229
|
+
return null;
|
|
1141
1230
|
}
|
|
1142
1231
|
};
|
|
1143
|
-
var
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
|
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
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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
|
-
|
|
1384
|
+
await page.evaluate(() => document.fonts.ready);
|
|
1385
|
+
return { browser, page, errors };
|
|
1523
1386
|
};
|
|
1524
|
-
var
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
|
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
|
|
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
|
|
2041
|
-
const
|
|
2042
|
-
const
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
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
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
const
|
|
2052
|
-
const
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
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
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
2208
|
-
import { dirname as
|
|
2209
|
-
var cliRoot =
|
|
2210
|
-
var studioRoot =
|
|
2211
|
-
var studioEntry =
|
|
2212
|
-
var installRoot =
|
|
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 [
|
|
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 =
|
|
2220
|
-
if (
|
|
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:
|
|
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:
|
|
2350
|
-
{ find: /^odori\/manifest$/, replacement:
|
|
2351
|
-
{ find: /^odori$/, replacement:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
2692
|
-
return
|
|
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 ?
|
|
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
|
|
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 =
|
|
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 =
|
|
2826
|
-
const inside =
|
|
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
|
|
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
|
|
2866
|
-
import { existsSync as
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
3256
|
+
const videosDir = resolve21(config.root, config.videosDir);
|
|
2898
3257
|
checks.push({
|
|
2899
3258
|
name: "Source root",
|
|
2900
|
-
detail:
|
|
2901
|
-
ok:
|
|
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 ?
|
|
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 =
|
|
3291
|
+
const generated = resolve21(config.root, ".odori");
|
|
2933
3292
|
let writable = false;
|
|
2934
3293
|
try {
|
|
2935
|
-
await
|
|
2936
|
-
const probe =
|
|
2937
|
-
await
|
|
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
|
|
3298
|
+
await rm6(probe, { force: true });
|
|
2940
3299
|
writable = true;
|
|
2941
3300
|
} catch {
|
|
2942
3301
|
writable = false;
|
|
2943
3302
|
}
|
|
2944
|
-
const componentsRoot =
|
|
3303
|
+
const componentsRoot = resolve21(root, config.componentsDir);
|
|
2945
3304
|
const orphans = [];
|
|
2946
|
-
if (
|
|
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(
|
|
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 ${
|
|
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
|
|
2995
|
-
import { existsSync as
|
|
2996
|
-
import { relative as
|
|
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
|
|
3000
|
-
import { existsSync as
|
|
3001
|
-
import { relative as
|
|
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 =
|
|
3060
|
-
if (!
|
|
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 =
|
|
3068
|
-
const file =
|
|
3069
|
-
if (
|
|
3070
|
-
const hasLayout =
|
|
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
|
|
3074
|
-
await
|
|
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 ${
|
|
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 =
|
|
3114
|
-
await
|
|
3482
|
+
const videosDir = resolve23(root, defaultConfig.videosDir);
|
|
3483
|
+
await mkdir17(resolve23(videosDir, "components"), { recursive: true });
|
|
3115
3484
|
const files = [
|
|
3116
|
-
[
|
|
3117
|
-
[
|
|
3118
|
-
[
|
|
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 (
|
|
3122
|
-
log.detail(`Kept existing ${
|
|
3490
|
+
if (existsSync20(file)) {
|
|
3491
|
+
log.detail(`Kept existing ${relative11(root, file)}`);
|
|
3123
3492
|
continue;
|
|
3124
3493
|
}
|
|
3125
|
-
await
|
|
3126
|
-
await
|
|
3127
|
-
log.success(`Created ${
|
|
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 =
|
|
3134
|
-
if (!
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
3696
|
+
import { existsSync as existsSync21 } from "fs";
|
|
3273
3697
|
import { readdir as readdir7 } from "fs/promises";
|
|
3274
|
-
import { resolve as
|
|
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 =
|
|
3342
|
-
const onDisk =
|
|
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
|
|
3364
|
-
import { existsSync as
|
|
3365
|
-
import { join as
|
|
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 =
|
|
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 =
|
|
3407
|
-
if (!
|
|
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
|
|
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"
|
|
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
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
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}
|
|
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.
|
|
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
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
4536
|
+
run3 as run
|
|
4036
4537
|
};
|