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