@odori/cli 0.0.2 → 0.0.3
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-7XJL2BYO.js → chunk-RXLB2CXH.js} +367 -171
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +29 -3
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-NIH2JMQ6.js → registry-snapshot-BDP6PVYB.js} +2 -2
- package/package.json +3 -3
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +10 -3
- package/src/commands/add.ts +8 -8
- package/src/commands/dev.ts +86 -11
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +22 -3
- package/src/formats.ts +67 -8
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +2 -2
- package/src/registry-source.ts +50 -3
- package/src/render.ts +48 -13
- package/src/server.ts +48 -12
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +121 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/studio.css +194 -26
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +58 -27
- package/studio/src/views/ComponentsView.tsx +23 -37
- package/studio/src/views/HomeView.tsx +39 -17
- package/studio/src/views/VideosView.tsx +34 -49
|
@@ -22,7 +22,7 @@ var log = {
|
|
|
22
22
|
// src/commands/add.ts
|
|
23
23
|
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
|
|
24
24
|
import { existsSync as existsSync6 } from "fs";
|
|
25
|
-
import { relative as
|
|
25
|
+
import { relative as relative4, resolve as resolve6 } from "path";
|
|
26
26
|
import { hashString as hashString2 } from "odori";
|
|
27
27
|
|
|
28
28
|
// src/config.ts
|
|
@@ -121,7 +121,7 @@ ${indent}"${cue.name}": ${cue.export}(),`;
|
|
|
121
121
|
import { createHash } from "crypto";
|
|
122
122
|
import { existsSync as existsSync4 } from "fs";
|
|
123
123
|
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
124
|
-
import { dirname as dirname2, resolve as resolve4 } from "path";
|
|
124
|
+
import { dirname as dirname2, isAbsolute, relative as relative2, resolve as resolve4, sep } from "path";
|
|
125
125
|
|
|
126
126
|
// src/binaries.ts
|
|
127
127
|
import { spawn } from "child_process";
|
|
@@ -268,6 +268,24 @@ var installCommand = async () => {
|
|
|
268
268
|
|
|
269
269
|
// src/registry-source.ts
|
|
270
270
|
var normalizeComponentName = (name) => name.replace(/^@odori\//, "");
|
|
271
|
+
var assertSafeName = (name) => {
|
|
272
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Refusing component name ${JSON.stringify(name)}: a name must be a lowercase slug (letters, digits, and single dashes). This registry is malformed or tampered with.`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return name;
|
|
278
|
+
};
|
|
279
|
+
var resolveWithinRoot = (root, target) => {
|
|
280
|
+
const destination = resolve4(root, target);
|
|
281
|
+
const rel = relative2(root, destination);
|
|
282
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`Refusing to write ${JSON.stringify(target)}: it resolves outside the project. Nothing was written.`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return destination;
|
|
288
|
+
};
|
|
271
289
|
var DEFAULT_URL = "https://odori.dev/r/v1";
|
|
272
290
|
var registryUrl = (config) => (config.registryUrl ?? process.env.ODORI_REGISTRY ?? DEFAULT_URL).replace(/\/$/, "");
|
|
273
291
|
var cacheDir = (url) => resolve4(cacheRoot(), "registry", createHash("sha256").update(url).digest("hex").slice(0, 16));
|
|
@@ -284,7 +302,7 @@ var toComponent = (item) => ({
|
|
|
284
302
|
});
|
|
285
303
|
var snapshotItems = async () => {
|
|
286
304
|
try {
|
|
287
|
-
const loaded = await import("./registry-snapshot-
|
|
305
|
+
const loaded = await import("./registry-snapshot-BDP6PVYB.js");
|
|
288
306
|
return loaded.default.items;
|
|
289
307
|
} catch {
|
|
290
308
|
throw new Error(
|
|
@@ -327,6 +345,7 @@ var resolveRegistry = async (config, options = {}) => {
|
|
|
327
345
|
return { items: await bundled(), origin: "bundled", detail: "the copy built into this CLI" };
|
|
328
346
|
};
|
|
329
347
|
var resolveItem = async (config, name, options = {}) => {
|
|
348
|
+
assertSafeName(name);
|
|
330
349
|
const url = registryUrl(config);
|
|
331
350
|
const cache = resolve4(cacheDir(url), `${name}.json`);
|
|
332
351
|
if (options.allowNetwork !== false) {
|
|
@@ -349,9 +368,14 @@ var resolveItem = async (config, name, options = {}) => {
|
|
|
349
368
|
if (!item) throw new Error(`No component named "${name}" in the registry at ${url}, in the cache, or in this CLI.`);
|
|
350
369
|
return { item, origin: "bundled" };
|
|
351
370
|
};
|
|
352
|
-
var verifyIntegrity = (item) => {
|
|
371
|
+
var verifyIntegrity = (item, origin = "network") => {
|
|
353
372
|
const expected = item.meta?.integrity;
|
|
354
|
-
if (!expected)
|
|
373
|
+
if (!expected) {
|
|
374
|
+
if (origin === "bundled") return;
|
|
375
|
+
throw new Error(
|
|
376
|
+
`The registry document for "${item.name}" carries no integrity hash. A fetched item must be verifiable; refusing to write it. Nothing was written.`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
355
379
|
const hash = createHash("sha256");
|
|
356
380
|
for (const file of [...item.files].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
357
381
|
hash.update(file.path);
|
|
@@ -372,7 +396,7 @@ Nothing was written. This is a truncated download, a stale proxy, or a tampered
|
|
|
372
396
|
// src/commands/update.ts
|
|
373
397
|
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
|
|
374
398
|
import { existsSync as existsSync5 } from "fs";
|
|
375
|
-
import { relative as
|
|
399
|
+
import { relative as relative3, resolve as resolve5 } from "path";
|
|
376
400
|
import { hashString } from "odori";
|
|
377
401
|
|
|
378
402
|
// src/diff.ts
|
|
@@ -499,7 +523,7 @@ var diffCommand = async (names, options = {}) => {
|
|
|
499
523
|
log.title(`@odori/${status.name} ${LABELS[status.state]}`);
|
|
500
524
|
for (const file of status.files) {
|
|
501
525
|
if (file.local === null) {
|
|
502
|
-
log.error(` ${file.file} is missing from ${
|
|
526
|
+
log.error(` ${file.file} is missing from ${relative3(config.root, resolve5(file.localPath, ".."))}`);
|
|
503
527
|
continue;
|
|
504
528
|
}
|
|
505
529
|
if (file.local === file.upstream) {
|
|
@@ -557,7 +581,7 @@ var updateCommand = async (names, options = {}) => {
|
|
|
557
581
|
|
|
558
582
|
// src/commands/add.ts
|
|
559
583
|
var addCommand = async (names, options = {}) => {
|
|
560
|
-
if (names.length === 0) throw new Error("Name at least one component, for example
|
|
584
|
+
if (names.length === 0) throw new Error("Name at least one component, for example title-reveal.");
|
|
561
585
|
const config = await loadConfig(process.cwd());
|
|
562
586
|
const source = await resolveRegistry(config);
|
|
563
587
|
const registry = source.items;
|
|
@@ -580,14 +604,14 @@ var addCommand = async (names, options = {}) => {
|
|
|
580
604
|
if (provider) queue.push(provider.name);
|
|
581
605
|
else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
|
|
582
606
|
}
|
|
583
|
-
const { item } = await resolveItem(config, component.name);
|
|
584
|
-
verifyIntegrity(item);
|
|
585
|
-
const target = resolve6(config.root, config.componentsDir, component.name);
|
|
607
|
+
const { item, origin } = await resolveItem(config, component.name);
|
|
608
|
+
verifyIntegrity(item, origin);
|
|
609
|
+
const target = resolve6(config.root, config.componentsDir, assertSafeName(component.name));
|
|
586
610
|
const hashes = {};
|
|
587
611
|
for (const file of item.files) {
|
|
588
|
-
const destination =
|
|
612
|
+
const destination = resolveWithinRoot(config.root, file.target);
|
|
589
613
|
const exists = existsSync6(destination);
|
|
590
|
-
log.detail(` ${exists ? "replace" : "create "} ${
|
|
614
|
+
log.detail(` ${exists ? "replace" : "create "} ${relative4(config.root, destination)}`);
|
|
591
615
|
}
|
|
592
616
|
if (options.dryRun) {
|
|
593
617
|
installed.push(component.name);
|
|
@@ -596,13 +620,13 @@ var addCommand = async (names, options = {}) => {
|
|
|
596
620
|
await mkdir4(target, { recursive: true });
|
|
597
621
|
for (const file of item.files) {
|
|
598
622
|
const name2 = file.path.split("/").pop() ?? file.path;
|
|
599
|
-
const destination =
|
|
623
|
+
const destination = resolveWithinRoot(config.root, file.target);
|
|
600
624
|
hashes[name2] = hashString2(file.content);
|
|
601
625
|
if (existsSync6(destination) && !options.force) {
|
|
602
626
|
const current = hashString2(await readFile5(destination, "utf8"));
|
|
603
627
|
const recorded = provenance[component.name]?.hashes[name2];
|
|
604
628
|
if (current !== recorded) {
|
|
605
|
-
log.warn(`${
|
|
629
|
+
log.warn(`${relative4(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
|
|
606
630
|
continue;
|
|
607
631
|
}
|
|
608
632
|
}
|
|
@@ -616,7 +640,7 @@ var addCommand = async (names, options = {}) => {
|
|
|
616
640
|
hashes
|
|
617
641
|
};
|
|
618
642
|
installed.push(component.name);
|
|
619
|
-
log.success(`${component.namespaced} to ${
|
|
643
|
+
log.success(`${component.namespaced} to ${relative4(config.root, target)}/`);
|
|
620
644
|
if (component.kind === "cue" && component.cue) {
|
|
621
645
|
log.detail(
|
|
622
646
|
` ${component.family} \xB7 ${component.contract.recommendedDurationInFrames} frames \xB7 registers "${component.cue.name}"`
|
|
@@ -665,6 +689,8 @@ var registryCommand = async () => {
|
|
|
665
689
|
|
|
666
690
|
// src/commands/dev.ts
|
|
667
691
|
import { resolve as resolve18 } from "path";
|
|
692
|
+
import { homedir as homedir2 } from "os";
|
|
693
|
+
import { existsSync as existsSync15 } from "fs";
|
|
668
694
|
import { readFile as readFile12 } from "fs/promises";
|
|
669
695
|
|
|
670
696
|
// src/jobs.ts
|
|
@@ -673,7 +699,7 @@ import { existsSync as existsSync7 } from "fs";
|
|
|
673
699
|
import { join as join2, resolve as resolve7 } from "path";
|
|
674
700
|
var buildsDir = (config) => resolve7(config.root, config.outDir, "builds");
|
|
675
701
|
var jobFile = (config, id) => join2(buildsDir(config), `${id}.json`);
|
|
676
|
-
var createJob = async (config, manifest, output) => {
|
|
702
|
+
var createJob = async (config, manifest, output, render) => {
|
|
677
703
|
await mkdir5(buildsDir(config), { recursive: true });
|
|
678
704
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
679
705
|
const job = {
|
|
@@ -687,7 +713,7 @@ var createJob = async (config, manifest, output) => {
|
|
|
687
713
|
createdAt: now,
|
|
688
714
|
updatedAt: now
|
|
689
715
|
};
|
|
690
|
-
const record = { job, manifest, output };
|
|
716
|
+
const record = { job, manifest, output, ...render ? { render } : {} };
|
|
691
717
|
await writeFile6(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
|
|
692
718
|
`, "utf8");
|
|
693
719
|
return record;
|
|
@@ -779,7 +805,7 @@ var JobQueue = class {
|
|
|
779
805
|
// src/discovery.ts
|
|
780
806
|
import { mkdir as mkdir6, readdir as readdir3, readFile as readFile7, stat, writeFile as writeFile7 } from "fs/promises";
|
|
781
807
|
import { existsSync as existsSync8 } from "fs";
|
|
782
|
-
import { join as join3, relative as
|
|
808
|
+
import { join as join3, relative as relative5, resolve as resolve8, sep as sep2 } from "path";
|
|
783
809
|
import { hashString as hashString3 } from "odori";
|
|
784
810
|
var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
|
|
785
811
|
var walk = async (directory2, files = []) => {
|
|
@@ -807,9 +833,9 @@ var discoverAudio = async (config) => {
|
|
|
807
833
|
const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
|
|
808
834
|
return Promise.all(
|
|
809
835
|
files.map(async (file) => ({
|
|
810
|
-
name:
|
|
811
|
-
url: file.startsWith(`${publicRoot}${
|
|
812
|
-
relativeFile:
|
|
836
|
+
name: relative5(root, file).replace(AUDIO_EXTENSIONS, "").split(sep2).join("/"),
|
|
837
|
+
url: file.startsWith(`${publicRoot}${sep2}`) ? `/${relative5(publicRoot, file).split(sep2).join("/")}` : `/${relative5(config.root, file).split(sep2).join("/")}`,
|
|
838
|
+
relativeFile: relative5(config.root, file),
|
|
813
839
|
bytes: (await stat(file)).size
|
|
814
840
|
}))
|
|
815
841
|
);
|
|
@@ -825,15 +851,17 @@ var discoverProject = async (config) => {
|
|
|
825
851
|
const previews = [];
|
|
826
852
|
const brands = [];
|
|
827
853
|
const hashParts = [];
|
|
854
|
+
const componentsRoot = resolve8(config.root, config.componentsDir);
|
|
828
855
|
for (const file of files) {
|
|
829
|
-
const relativeFile =
|
|
856
|
+
const relativeFile = relative5(config.root, file);
|
|
857
|
+
let contents = "";
|
|
830
858
|
if (/\.(tsx|ts|css|json)$/.test(file)) {
|
|
831
|
-
|
|
859
|
+
contents = await readFile7(file, "utf8");
|
|
832
860
|
hashParts.push(`${relativeFile}:${hashString3(contents)}`);
|
|
833
861
|
}
|
|
834
|
-
const base = file.split(
|
|
862
|
+
const base = file.split(sep2).pop() ?? "";
|
|
835
863
|
if (base === "video.tsx") {
|
|
836
|
-
const slug =
|
|
864
|
+
const slug = relative5(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
|
|
837
865
|
videos.push({
|
|
838
866
|
slug,
|
|
839
867
|
file,
|
|
@@ -841,13 +869,27 @@ var discoverProject = async (config) => {
|
|
|
841
869
|
importPath: file,
|
|
842
870
|
identifier: toIdentifier(slug, "video")
|
|
843
871
|
});
|
|
844
|
-
} else if (
|
|
872
|
+
} else if (
|
|
873
|
+
// A brand module is recognized by what it does, not where it sits. The
|
|
874
|
+
// scaffold defines its brand in videos/layout.tsx, so a directory-name
|
|
875
|
+
// rule alone left the default project's brand invisible to everything
|
|
876
|
+
// that reads this list — most visibly the dev server's cue registry,
|
|
877
|
+
// which then answered new cue URLs with 404s until a restart. Installed
|
|
878
|
+
// component source is excluded the way brand-file.ts excludes it: a
|
|
879
|
+
// component may mention defineBrand without being where a brand lives.
|
|
880
|
+
/\.tsx?$/.test(base) && !base.endsWith(".preview.tsx") && !file.startsWith(componentsRoot + sep2) && (file.split(sep2).includes("brands") || contents.includes("defineBrand("))
|
|
881
|
+
) {
|
|
845
882
|
const name = base.replace(/\.tsx?$/, "");
|
|
846
883
|
brands.push({
|
|
847
884
|
name,
|
|
848
885
|
file,
|
|
849
886
|
relativeFile,
|
|
850
|
-
|
|
887
|
+
// From the whole relative path, like previews: basenames repeat
|
|
888
|
+
// (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
|
|
889
|
+
identifier: toIdentifier(
|
|
890
|
+
`${relative5(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
|
|
891
|
+
"brands"
|
|
892
|
+
)
|
|
851
893
|
});
|
|
852
894
|
} else if (base.endsWith(".preview.tsx")) {
|
|
853
895
|
const name = base.replace(/\.preview\.tsx$/, "");
|
|
@@ -856,7 +898,7 @@ var discoverProject = async (config) => {
|
|
|
856
898
|
file,
|
|
857
899
|
relativeFile,
|
|
858
900
|
importPath: file,
|
|
859
|
-
identifier: toIdentifier(`${
|
|
901
|
+
identifier: toIdentifier(`${relative5(videosRoot, file).split(sep2).join("-")}`, "preview")
|
|
860
902
|
});
|
|
861
903
|
}
|
|
862
904
|
}
|
|
@@ -864,7 +906,7 @@ var discoverProject = async (config) => {
|
|
|
864
906
|
};
|
|
865
907
|
var generateImports = (graph, outDir) => {
|
|
866
908
|
const importPath = (file) => {
|
|
867
|
-
const relativePath =
|
|
909
|
+
const relativePath = relative5(outDir, file).split(sep2).join("/");
|
|
868
910
|
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
869
911
|
};
|
|
870
912
|
const lines = [
|
|
@@ -1162,6 +1204,119 @@ var freezeManifest = async (video, graph, config, rawInput, options = {}) => {
|
|
|
1162
1204
|
return { manifest, input, prepared };
|
|
1163
1205
|
};
|
|
1164
1206
|
|
|
1207
|
+
// src/formats.ts
|
|
1208
|
+
var QUALITIES = ["studio", "social", "web"];
|
|
1209
|
+
var scaleFilter = (scale) => `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2:flags=lanczos`;
|
|
1210
|
+
var scaleArgs = (scale) => scale === 1 ? [] : ["-vf", scaleFilter(scale)];
|
|
1211
|
+
var FORMATS = {
|
|
1212
|
+
mp4: {
|
|
1213
|
+
name: "mp4",
|
|
1214
|
+
extension: ".mp4",
|
|
1215
|
+
alpha: false,
|
|
1216
|
+
chunked: true,
|
|
1217
|
+
audio: true,
|
|
1218
|
+
description: "H.264 in MP4. Plays everywhere; the default.",
|
|
1219
|
+
args: ({ preset, quality, scale }) => [
|
|
1220
|
+
...scaleArgs(scale),
|
|
1221
|
+
"-c:v",
|
|
1222
|
+
"libx264",
|
|
1223
|
+
"-crf",
|
|
1224
|
+
{ studio: "17", social: "21", web: "27" }[quality],
|
|
1225
|
+
"-preset",
|
|
1226
|
+
preset,
|
|
1227
|
+
"-pix_fmt",
|
|
1228
|
+
"yuv420p"
|
|
1229
|
+
]
|
|
1230
|
+
},
|
|
1231
|
+
webm: {
|
|
1232
|
+
name: "webm",
|
|
1233
|
+
extension: ".webm",
|
|
1234
|
+
alpha: true,
|
|
1235
|
+
// VP9 in WebM concatenates cleanly through the demuxer, same as H.264.
|
|
1236
|
+
chunked: true,
|
|
1237
|
+
audio: true,
|
|
1238
|
+
description: "VP9 in WebM, with alpha. For the web, and for overlays.",
|
|
1239
|
+
args: ({ quality, scale }) => [
|
|
1240
|
+
...scaleArgs(scale),
|
|
1241
|
+
"-c:v",
|
|
1242
|
+
"libvpx-vp9",
|
|
1243
|
+
"-crf",
|
|
1244
|
+
{ studio: "24", social: "31", web: "38" }[quality],
|
|
1245
|
+
"-b:v",
|
|
1246
|
+
"0",
|
|
1247
|
+
"-pix_fmt",
|
|
1248
|
+
"yuva420p",
|
|
1249
|
+
"-row-mt",
|
|
1250
|
+
"1"
|
|
1251
|
+
]
|
|
1252
|
+
},
|
|
1253
|
+
prores: {
|
|
1254
|
+
name: "prores",
|
|
1255
|
+
extension: ".mov",
|
|
1256
|
+
alpha: true,
|
|
1257
|
+
chunked: true,
|
|
1258
|
+
audio: true,
|
|
1259
|
+
description: "ProRes 4444 in MOV, with alpha. For handing to an editor.",
|
|
1260
|
+
// Quality is the profile here, and the profile is the point: an editor
|
|
1261
|
+
// format that quietly compressed would defeat its own reason to exist.
|
|
1262
|
+
args: ({ scale }) => [
|
|
1263
|
+
...scaleArgs(scale),
|
|
1264
|
+
"-c:v",
|
|
1265
|
+
"prores_ks",
|
|
1266
|
+
"-profile:v",
|
|
1267
|
+
"4444",
|
|
1268
|
+
"-pix_fmt",
|
|
1269
|
+
"yuva444p10le",
|
|
1270
|
+
"-alpha_bits",
|
|
1271
|
+
"8"
|
|
1272
|
+
]
|
|
1273
|
+
},
|
|
1274
|
+
gif: {
|
|
1275
|
+
name: "gif",
|
|
1276
|
+
extension: ".gif",
|
|
1277
|
+
alpha: false,
|
|
1278
|
+
// A GIF's palette is computed across the whole animation, so chunks would
|
|
1279
|
+
// each invent their own and the result would flicker between them.
|
|
1280
|
+
chunked: false,
|
|
1281
|
+
audio: false,
|
|
1282
|
+
description: "An animated GIF, palette optimised. Silent, by the format.",
|
|
1283
|
+
// The palette pass is a filter graph already, so scaling joins it rather
|
|
1284
|
+
// than adding a second -vf that ffmpeg would silently drop.
|
|
1285
|
+
args: ({ scale }) => [
|
|
1286
|
+
"-vf",
|
|
1287
|
+
`${scale === 1 ? "" : `${scaleFilter(scale)},`}split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3`,
|
|
1288
|
+
"-loop",
|
|
1289
|
+
"0"
|
|
1290
|
+
]
|
|
1291
|
+
},
|
|
1292
|
+
png: {
|
|
1293
|
+
name: "png",
|
|
1294
|
+
extension: ".png",
|
|
1295
|
+
alpha: true,
|
|
1296
|
+
chunked: false,
|
|
1297
|
+
audio: false,
|
|
1298
|
+
description: "A numbered PNG sequence, with alpha. For a compositor.",
|
|
1299
|
+
args: ({ scale }) => [...scaleArgs(scale), "-c:v", "png", "-pix_fmt", "rgba"]
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
var formatNames = () => Object.keys(FORMATS);
|
|
1303
|
+
var resolveFormat = (requested, output) => {
|
|
1304
|
+
if (requested) {
|
|
1305
|
+
const format = FORMATS[requested.toLowerCase()];
|
|
1306
|
+
if (!format) {
|
|
1307
|
+
throw new Error(`Unknown format "${requested}". Available: ${formatNames().join(", ")}`);
|
|
1308
|
+
}
|
|
1309
|
+
return format;
|
|
1310
|
+
}
|
|
1311
|
+
if (output) {
|
|
1312
|
+
const extension = output.slice(output.lastIndexOf(".")).toLowerCase();
|
|
1313
|
+
const matched = Object.values(FORMATS).find((format) => format.extension === extension);
|
|
1314
|
+
if (matched) return matched;
|
|
1315
|
+
}
|
|
1316
|
+
return FORMATS.mp4;
|
|
1317
|
+
};
|
|
1318
|
+
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;
|
|
1319
|
+
|
|
1165
1320
|
// src/render.ts
|
|
1166
1321
|
import { spawn as spawn2 } from "child_process";
|
|
1167
1322
|
import { copyFile as copyFile2, mkdir as mkdir11, rm as rm2, writeFile as writeFile12 } from "fs/promises";
|
|
@@ -1234,23 +1389,28 @@ var renderedCue = (url) => {
|
|
|
1234
1389
|
return wav;
|
|
1235
1390
|
};
|
|
1236
1391
|
var isBrand = (value) => typeof value === "object" && value !== null && value.kind === "odori-brand";
|
|
1392
|
+
var isLayout = (value) => typeof value === "object" && value !== null && value.kind === "odori-layout";
|
|
1237
1393
|
var importFresh = async (file) => await import(`${pathToFileURL3(file).href}?odori=${statSync(file).mtimeMs}`);
|
|
1238
|
-
var registerProjectCues = async (graph) => {
|
|
1394
|
+
var registerProjectCues = async (graph, load = importFresh) => {
|
|
1239
1395
|
for (const discovered of graph.brands) {
|
|
1240
1396
|
try {
|
|
1241
|
-
const
|
|
1242
|
-
registerCues(
|
|
1397
|
+
const values = Object.values(await load(discovered.file));
|
|
1398
|
+
registerCues(values.filter(isBrand), defaultLayout.format.fps);
|
|
1399
|
+
for (const layout of values.filter(isLayout)) registerCues([layout.brand], layout.format.fps);
|
|
1243
1400
|
} catch (error) {
|
|
1244
1401
|
log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
|
|
1245
1402
|
}
|
|
1246
1403
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1404
|
+
for (const video of graph.videos) {
|
|
1405
|
+
try {
|
|
1406
|
+
const module = await load(video.file);
|
|
1407
|
+
if (!module.default || !module.metadata) continue;
|
|
1408
|
+
const entry = { component: module.default, metadata: module.metadata };
|
|
1409
|
+
const layout = resolveEntryLayout2(entry);
|
|
1250
1410
|
registerCues([layout.brand], layout.format.fps);
|
|
1411
|
+
} catch (error) {
|
|
1412
|
+
log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
|
|
1251
1413
|
}
|
|
1252
|
-
} catch (error) {
|
|
1253
|
-
log.warn(`[odori] generated cues may use the default frame rate: ${message(error)}`);
|
|
1254
1414
|
}
|
|
1255
1415
|
return known.size;
|
|
1256
1416
|
};
|
|
@@ -1409,6 +1569,8 @@ var chunkKey = (identity) => hashValue2({
|
|
|
1409
1569
|
height: identity.height,
|
|
1410
1570
|
fps: identity.fps,
|
|
1411
1571
|
preset: identity.preset,
|
|
1572
|
+
quality: identity.quality ?? null,
|
|
1573
|
+
scale: identity.scale ?? null,
|
|
1412
1574
|
input: identity.input ?? null
|
|
1413
1575
|
});
|
|
1414
1576
|
var readChunkRecord = async (config, key) => {
|
|
@@ -1434,80 +1596,6 @@ var writeChunkRecord = async (config, key, signatures, file) => {
|
|
|
1434
1596
|
};
|
|
1435
1597
|
var signaturesMatch = (recorded, observed) => recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
|
|
1436
1598
|
|
|
1437
|
-
// src/formats.ts
|
|
1438
|
-
var FORMATS = {
|
|
1439
|
-
mp4: {
|
|
1440
|
-
name: "mp4",
|
|
1441
|
-
extension: ".mp4",
|
|
1442
|
-
alpha: false,
|
|
1443
|
-
chunked: true,
|
|
1444
|
-
audio: true,
|
|
1445
|
-
description: "H.264 in MP4. Plays everywhere; the default.",
|
|
1446
|
-
args: (preset) => ["-c:v", "libx264", "-crf", "17", "-preset", preset, "-pix_fmt", "yuv420p"]
|
|
1447
|
-
},
|
|
1448
|
-
webm: {
|
|
1449
|
-
name: "webm",
|
|
1450
|
-
extension: ".webm",
|
|
1451
|
-
alpha: true,
|
|
1452
|
-
// VP9 in WebM concatenates cleanly through the demuxer, same as H.264.
|
|
1453
|
-
chunked: true,
|
|
1454
|
-
audio: true,
|
|
1455
|
-
description: "VP9 in WebM, with alpha. For the web, and for overlays.",
|
|
1456
|
-
args: () => ["-c:v", "libvpx-vp9", "-crf", "24", "-b:v", "0", "-pix_fmt", "yuva420p", "-row-mt", "1"]
|
|
1457
|
-
},
|
|
1458
|
-
prores: {
|
|
1459
|
-
name: "prores",
|
|
1460
|
-
extension: ".mov",
|
|
1461
|
-
alpha: true,
|
|
1462
|
-
chunked: true,
|
|
1463
|
-
audio: true,
|
|
1464
|
-
description: "ProRes 4444 in MOV, with alpha. For handing to an editor.",
|
|
1465
|
-
args: () => ["-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le", "-alpha_bits", "8"]
|
|
1466
|
-
},
|
|
1467
|
-
gif: {
|
|
1468
|
-
name: "gif",
|
|
1469
|
-
extension: ".gif",
|
|
1470
|
-
alpha: false,
|
|
1471
|
-
// A GIF's palette is computed across the whole animation, so chunks would
|
|
1472
|
-
// each invent their own and the result would flicker between them.
|
|
1473
|
-
chunked: false,
|
|
1474
|
-
audio: false,
|
|
1475
|
-
description: "An animated GIF, palette optimised. Silent, by the format.",
|
|
1476
|
-
args: () => [
|
|
1477
|
-
"-vf",
|
|
1478
|
-
"split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3",
|
|
1479
|
-
"-loop",
|
|
1480
|
-
"0"
|
|
1481
|
-
]
|
|
1482
|
-
},
|
|
1483
|
-
png: {
|
|
1484
|
-
name: "png",
|
|
1485
|
-
extension: ".png",
|
|
1486
|
-
alpha: true,
|
|
1487
|
-
chunked: false,
|
|
1488
|
-
audio: false,
|
|
1489
|
-
description: "A numbered PNG sequence, with alpha. For a compositor.",
|
|
1490
|
-
args: () => ["-c:v", "png", "-pix_fmt", "rgba"]
|
|
1491
|
-
}
|
|
1492
|
-
};
|
|
1493
|
-
var formatNames = () => Object.keys(FORMATS);
|
|
1494
|
-
var resolveFormat = (requested, output) => {
|
|
1495
|
-
if (requested) {
|
|
1496
|
-
const format = FORMATS[requested.toLowerCase()];
|
|
1497
|
-
if (!format) {
|
|
1498
|
-
throw new Error(`Unknown format "${requested}". Available: ${formatNames().join(", ")}`);
|
|
1499
|
-
}
|
|
1500
|
-
return format;
|
|
1501
|
-
}
|
|
1502
|
-
if (output) {
|
|
1503
|
-
const extension = output.slice(output.lastIndexOf(".")).toLowerCase();
|
|
1504
|
-
const matched = Object.values(FORMATS).find((format) => format.extension === extension);
|
|
1505
|
-
if (matched) return matched;
|
|
1506
|
-
}
|
|
1507
|
-
return FORMATS.mp4;
|
|
1508
|
-
};
|
|
1509
|
-
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;
|
|
1510
|
-
|
|
1511
1599
|
// src/render.ts
|
|
1512
1600
|
var encodeParam = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
|
1513
1601
|
var renderUrl = (origin, target, frame) => {
|
|
@@ -1675,10 +1763,19 @@ var sequencePattern = (output) => {
|
|
|
1675
1763
|
const extension = dot > 0 ? output.slice(dot) : ".png";
|
|
1676
1764
|
return join6(stem, `%05d${extension}`);
|
|
1677
1765
|
};
|
|
1678
|
-
var
|
|
1766
|
+
var LOSSLESS = {
|
|
1767
|
+
name: "lossless",
|
|
1768
|
+
extension: ".mkv",
|
|
1769
|
+
alpha: true,
|
|
1770
|
+
chunked: true,
|
|
1771
|
+
audio: false,
|
|
1772
|
+
description: "FFV1 in MKV, the lossless intermediate for one-pass formats.",
|
|
1773
|
+
args: () => ["-c:v", "ffv1", "-level", "3"]
|
|
1774
|
+
};
|
|
1775
|
+
var openChunkEncoder = (ffmpeg, file, fps, encode, format, signal) => {
|
|
1679
1776
|
const child = spawn2(
|
|
1680
1777
|
ffmpeg,
|
|
1681
|
-
["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(
|
|
1778
|
+
["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(encode), file],
|
|
1682
1779
|
{ stdio: ["pipe", "ignore", "pipe"] }
|
|
1683
1780
|
);
|
|
1684
1781
|
let stderr = "";
|
|
@@ -1728,7 +1825,7 @@ var captureLane = async (origin, target, config, lane, stats, options) => {
|
|
|
1728
1825
|
}
|
|
1729
1826
|
}
|
|
1730
1827
|
const file = options.chunkFile(chunk);
|
|
1731
|
-
const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.
|
|
1828
|
+
const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.encode, options.format, options.signal);
|
|
1732
1829
|
const signatures = [];
|
|
1733
1830
|
let previousSignature = null;
|
|
1734
1831
|
let previousFrame = null;
|
|
@@ -1788,9 +1885,14 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
|
|
|
1788
1885
|
const browserPath = await browserExecutable(config);
|
|
1789
1886
|
const renderer = (await resolveBrowser(config))?.version ?? browserPath;
|
|
1790
1887
|
const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
|
|
1791
|
-
const
|
|
1888
|
+
const encode = {
|
|
1889
|
+
preset: options.preset ?? config.preset ?? "medium",
|
|
1890
|
+
quality: options.quality ?? "studio",
|
|
1891
|
+
scale: options.scale ?? 1
|
|
1892
|
+
};
|
|
1792
1893
|
const format = options.format ?? FORMATS.mp4;
|
|
1793
1894
|
const chunkable = format.chunked;
|
|
1895
|
+
const chunkFormat = chunkable ? format : LOSSLESS;
|
|
1794
1896
|
const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
|
|
1795
1897
|
const cache = options.cache ?? config.cacheChunks ?? true;
|
|
1796
1898
|
const work = options.workDir ?? resolve15(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
|
|
@@ -1812,8 +1914,8 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
|
|
|
1812
1914
|
(lane) => captureLane(origin, target, config, lane, stats, {
|
|
1813
1915
|
skipUnchanged,
|
|
1814
1916
|
signal: options.signal,
|
|
1815
|
-
|
|
1816
|
-
format,
|
|
1917
|
+
encode,
|
|
1918
|
+
format: chunkFormat,
|
|
1817
1919
|
ffmpeg,
|
|
1818
1920
|
workDir: work,
|
|
1819
1921
|
chunkFile,
|
|
@@ -1821,11 +1923,17 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
|
|
|
1821
1923
|
videoId: target.videoId,
|
|
1822
1924
|
chunk,
|
|
1823
1925
|
renderer,
|
|
1824
|
-
format:
|
|
1926
|
+
format: chunkFormat.name,
|
|
1825
1927
|
width: target.width,
|
|
1826
1928
|
height: target.height,
|
|
1827
1929
|
fps: target.fps,
|
|
1828
|
-
preset,
|
|
1930
|
+
preset: encode.preset,
|
|
1931
|
+
// Both change the encoded bytes, so both are part of what a
|
|
1932
|
+
// chunk is: a half-size chunk must never answer for a full
|
|
1933
|
+
// one. A lossless intermediate is the exception by design —
|
|
1934
|
+
// the final pass applies them, so one capture serves all.
|
|
1935
|
+
quality: chunkable ? encode.quality : void 0,
|
|
1936
|
+
scale: chunkable ? encode.scale : void 0,
|
|
1829
1937
|
input: target.input
|
|
1830
1938
|
}) : void 0,
|
|
1831
1939
|
onFrame: () => onProgress?.(stats.captured / Math.max(1, target.durationInFrames), "rendering")
|
|
@@ -1852,7 +1960,7 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
|
|
|
1852
1960
|
if (!chunkable) {
|
|
1853
1961
|
const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
|
|
1854
1962
|
if (destination !== output) await mkdir11(dirname4(destination), { recursive: true });
|
|
1855
|
-
await run(ffmpeg, ["-y", "-i", silent, ...format.args(
|
|
1963
|
+
await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
|
|
1856
1964
|
if (mixInputs.length > 0) {
|
|
1857
1965
|
log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
|
|
1858
1966
|
}
|
|
@@ -1943,7 +2051,7 @@ import { fileURLToPath } from "url";
|
|
|
1943
2051
|
import { createServer } from "vite";
|
|
1944
2052
|
import react from "@vitejs/plugin-react";
|
|
1945
2053
|
import { readFile as readFile11 } from "fs/promises";
|
|
1946
|
-
import { dirname as dirname5, resolve as resolve16, sep as
|
|
2054
|
+
import { dirname as dirname5, resolve as resolve16, sep as sep3 } from "path";
|
|
1947
2055
|
var cliRoot = resolve16(dirname5(fileURLToPath(import.meta.url)), "..");
|
|
1948
2056
|
var studioRoot = resolve16(cliRoot, "studio");
|
|
1949
2057
|
var studioEntry = resolve16(studioRoot, "index.html");
|
|
@@ -2014,7 +2122,6 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2014
2122
|
const odoriSrc = runtimeSource(config.root);
|
|
2015
2123
|
let graph = await discoverProject(config);
|
|
2016
2124
|
await writeGenerated(config, graph);
|
|
2017
|
-
await registerProjectCues(graph);
|
|
2018
2125
|
const vite = await createServer({
|
|
2019
2126
|
root: studioRoot,
|
|
2020
2127
|
configFile: false,
|
|
@@ -2093,14 +2200,31 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2093
2200
|
},
|
|
2094
2201
|
optimizeDeps: { include: ["react", "react-dom", "react/jsx-dev-runtime"] }
|
|
2095
2202
|
});
|
|
2203
|
+
const loadModule = (file) => vite.ssrLoadModule(`/@fs${file}`);
|
|
2204
|
+
await registerProjectCues(graph, loadModule);
|
|
2205
|
+
let refreshTimer;
|
|
2206
|
+
let refreshChain = Promise.resolve();
|
|
2207
|
+
const enqueueRefresh = (task) => {
|
|
2208
|
+
const next = refreshChain.then(task, task);
|
|
2209
|
+
refreshChain = next.catch(() => void 0);
|
|
2210
|
+
return next;
|
|
2211
|
+
};
|
|
2212
|
+
const scheduleCueRefresh = () => {
|
|
2213
|
+
clearTimeout(refreshTimer);
|
|
2214
|
+
refreshTimer = setTimeout(() => {
|
|
2215
|
+
void enqueueRefresh(() => registerProjectCues(graph, loadModule)).catch((error) => {
|
|
2216
|
+
vite.config.logger.warn(`[odori] cue refresh skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
2217
|
+
});
|
|
2218
|
+
}, 150);
|
|
2219
|
+
};
|
|
2096
2220
|
const rediscover = async (file) => {
|
|
2097
2221
|
if (!file.startsWith(resolve16(config.root, config.videosDir))) return;
|
|
2098
|
-
const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${
|
|
2222
|
+
const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep3}brands${sep3}`);
|
|
2099
2223
|
if (!isEntry) return;
|
|
2100
2224
|
try {
|
|
2101
2225
|
graph = await discoverProject(config);
|
|
2102
2226
|
await writeGenerated(config, graph);
|
|
2103
|
-
await registerProjectCues(graph);
|
|
2227
|
+
await enqueueRefresh(() => registerProjectCues(graph, loadModule));
|
|
2104
2228
|
} catch (error) {
|
|
2105
2229
|
vite.config.logger.warn(`[odori] rediscovery skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
2106
2230
|
return;
|
|
@@ -2118,7 +2242,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2118
2242
|
config = await loadConfig(config.root);
|
|
2119
2243
|
graph = await discoverProject(config);
|
|
2120
2244
|
await writeGenerated(config, graph);
|
|
2121
|
-
await registerProjectCues(graph);
|
|
2245
|
+
await enqueueRefresh(() => registerProjectCues(graph, loadModule));
|
|
2122
2246
|
} catch (error) {
|
|
2123
2247
|
vite.config.logger.warn(`[odori] config reload skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
2124
2248
|
return;
|
|
@@ -2127,11 +2251,12 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2127
2251
|
if (module) vite.moduleGraph.invalidateModule(module);
|
|
2128
2252
|
vite.ws.send({ type: "full-reload" });
|
|
2129
2253
|
};
|
|
2130
|
-
const refreshCues =
|
|
2131
|
-
if (!file.startsWith(resolve16(config.root, config.videosDir)
|
|
2132
|
-
|
|
2254
|
+
const refreshCues = (file) => {
|
|
2255
|
+
if (!file.startsWith(resolve16(config.root, config.videosDir) + sep3)) return;
|
|
2256
|
+
if (!/\.tsx?$/.test(file)) return;
|
|
2257
|
+
scheduleCueRefresh();
|
|
2133
2258
|
};
|
|
2134
|
-
vite.watcher.on("change", (file) =>
|
|
2259
|
+
vite.watcher.on("change", (file) => refreshCues(file));
|
|
2135
2260
|
vite.watcher.on("change", (file) => void reloadConfig(file));
|
|
2136
2261
|
for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
|
|
2137
2262
|
vite.watcher.add(resolve16(config.root, name));
|
|
@@ -2274,8 +2399,12 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
|
|
|
2274
2399
|
},
|
|
2275
2400
|
{
|
|
2276
2401
|
concurrency: options.concurrency,
|
|
2277
|
-
|
|
2278
|
-
|
|
2402
|
+
// The record freezes how it should be encoded alongside what, so a
|
|
2403
|
+
// retry from any surface produces the same file the first run would.
|
|
2404
|
+
preset: options.preset ?? record.render?.preset,
|
|
2405
|
+
quality: options.quality ?? record.render?.quality,
|
|
2406
|
+
scale: options.scale ?? record.render?.scale,
|
|
2407
|
+
format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : void 0),
|
|
2279
2408
|
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
2280
2409
|
signal: controller.signal,
|
|
2281
2410
|
onTimings: (timings) => {
|
|
@@ -2302,9 +2431,23 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
|
|
|
2302
2431
|
running.delete(record.job.id);
|
|
2303
2432
|
}
|
|
2304
2433
|
});
|
|
2434
|
+
var resolveQuality = (requested) => {
|
|
2435
|
+
if (!requested) return "studio";
|
|
2436
|
+
if (QUALITIES.includes(requested)) return requested;
|
|
2437
|
+
throw new Error(`Unknown quality "${requested}". Available: ${QUALITIES.join(", ")}`);
|
|
2438
|
+
};
|
|
2439
|
+
var resolveScale = (requested) => {
|
|
2440
|
+
if (requested === void 0) return 1;
|
|
2441
|
+
if (!Number.isFinite(requested) || requested < 0.25 || requested > 2) {
|
|
2442
|
+
throw new Error(`Scale ${requested} is out of range. Use a value between 0.25 and 2.`);
|
|
2443
|
+
}
|
|
2444
|
+
return requested;
|
|
2445
|
+
};
|
|
2305
2446
|
var exportCommand = async (id, options = {}) => {
|
|
2306
2447
|
const { config, graph, videos } = await createContext();
|
|
2307
2448
|
const format = resolveFormat(options.format ?? config.format, options.output);
|
|
2449
|
+
const quality = resolveQuality(options.quality);
|
|
2450
|
+
const scale = resolveScale(options.scale);
|
|
2308
2451
|
return withServer(config, async (server) => {
|
|
2309
2452
|
const record = options.retry ? await readJob(config, options.retry) : await (async () => {
|
|
2310
2453
|
const video2 = findVideo(videos, id);
|
|
@@ -2320,7 +2463,12 @@ var exportCommand = async (id, options = {}) => {
|
|
|
2320
2463
|
config.root,
|
|
2321
2464
|
options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
|
|
2322
2465
|
);
|
|
2323
|
-
return createJob(config, manifest, output
|
|
2466
|
+
return createJob(config, manifest, output, {
|
|
2467
|
+
format: format.name,
|
|
2468
|
+
quality,
|
|
2469
|
+
scale,
|
|
2470
|
+
...options.preset ? { preset: options.preset } : {}
|
|
2471
|
+
});
|
|
2324
2472
|
})();
|
|
2325
2473
|
const video = findVideo(videos, record.manifest.videoId);
|
|
2326
2474
|
log.detail(`job ${record.job.id} manifest ${record.manifest.manifestHash}`);
|
|
@@ -2331,7 +2479,10 @@ var exportCommand = async (id, options = {}) => {
|
|
|
2331
2479
|
const job = await runJob(config, server.url, record, video, {
|
|
2332
2480
|
concurrency: options.concurrency,
|
|
2333
2481
|
preset: options.preset,
|
|
2334
|
-
|
|
2482
|
+
// A retry keeps what its record froze; an explicit flag still wins.
|
|
2483
|
+
quality: options.retry && options.quality === void 0 ? void 0 : quality,
|
|
2484
|
+
scale: options.retry && options.scale === void 0 ? void 0 : scale,
|
|
2485
|
+
format: options.retry ? void 0 : format,
|
|
2335
2486
|
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
2336
2487
|
onProgress: (next) => {
|
|
2337
2488
|
if (next.status === "rendering" || next.status === "encoding") {
|
|
@@ -2373,6 +2524,35 @@ var json = (response, status, payload) => {
|
|
|
2373
2524
|
response.setHeader("content-type", "application/json");
|
|
2374
2525
|
response.end(JSON.stringify(payload));
|
|
2375
2526
|
};
|
|
2527
|
+
var exportDestination = (config) => {
|
|
2528
|
+
const downloads = resolve18(homedir2(), "Downloads");
|
|
2529
|
+
return existsSync15(downloads) ? downloads : resolve18(config.root, config.exportDir);
|
|
2530
|
+
};
|
|
2531
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
2532
|
+
var hostOf = (value) => {
|
|
2533
|
+
if (!value) return null;
|
|
2534
|
+
const withoutPort = value.startsWith("[") ? value.slice(0, value.indexOf("]") + 1) : value.split(":")[0];
|
|
2535
|
+
return withoutPort || null;
|
|
2536
|
+
};
|
|
2537
|
+
var isLocalRequest = (request) => {
|
|
2538
|
+
const host = hostOf(request.headers.host);
|
|
2539
|
+
if (!host || !LOOPBACK.has(host)) return false;
|
|
2540
|
+
const origin = request.headers.origin;
|
|
2541
|
+
if (origin) {
|
|
2542
|
+
try {
|
|
2543
|
+
if (!LOOPBACK.has(hostOf(new URL(origin).host) ?? "")) return false;
|
|
2544
|
+
} catch {
|
|
2545
|
+
return false;
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
return true;
|
|
2549
|
+
};
|
|
2550
|
+
var safeJobId = (id) => {
|
|
2551
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(id) || id.includes("..")) {
|
|
2552
|
+
throw new Error(`Invalid job id ${JSON.stringify(id)}.`);
|
|
2553
|
+
}
|
|
2554
|
+
return id;
|
|
2555
|
+
};
|
|
2376
2556
|
var devCommand = async (options = {}) => {
|
|
2377
2557
|
const config = await loadConfig(options.root ?? process.cwd());
|
|
2378
2558
|
const context = async () => {
|
|
@@ -2384,6 +2564,10 @@ var devCommand = async (options = {}) => {
|
|
|
2384
2564
|
middleware: (vite) => {
|
|
2385
2565
|
vite.middlewares.use("/__odori", (request, response, next) => {
|
|
2386
2566
|
const url = request.url ?? "/";
|
|
2567
|
+
if (!isLocalRequest(request)) {
|
|
2568
|
+
json(response, 403, { error: "This endpoint only answers same-origin requests from Studio on localhost." });
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2387
2571
|
void (async () => {
|
|
2388
2572
|
try {
|
|
2389
2573
|
if (request.method === "POST" && url.startsWith("/still")) {
|
|
@@ -2401,8 +2585,7 @@ var devCommand = async (options = {}) => {
|
|
|
2401
2585
|
);
|
|
2402
2586
|
const frame = Number(body.frame ?? 0);
|
|
2403
2587
|
const inline = body.inline === true;
|
|
2404
|
-
const
|
|
2405
|
-
const file = resolve18(config.root, `${directory2}/${outputName(video.entry.metadata.id)}-${frame}.png`);
|
|
2588
|
+
const file = inline ? resolve18(config.root, config.outDir, `${outputName(video.entry.metadata.id)}-${frame}.png`) : resolve18(exportDestination(config), `${outputName(video.entry.metadata.id)}-${frame}.png`);
|
|
2406
2589
|
await renderStill(
|
|
2407
2590
|
origin,
|
|
2408
2591
|
targetFor(
|
|
@@ -2426,6 +2609,9 @@ var devCommand = async (options = {}) => {
|
|
|
2426
2609
|
}
|
|
2427
2610
|
if (request.method === "POST" && url.startsWith("/exports")) {
|
|
2428
2611
|
const body = await readBody(request);
|
|
2612
|
+
const format = resolveFormat(typeof body.format === "string" ? body.format : void 0, void 0);
|
|
2613
|
+
const quality = resolveQuality(typeof body.quality === "string" ? body.quality : void 0);
|
|
2614
|
+
const scale = resolveScale(typeof body.scale === "number" ? body.scale : void 0);
|
|
2429
2615
|
const { graph, videos } = await context();
|
|
2430
2616
|
const video = findVideo(videos, String(body.videoId));
|
|
2431
2617
|
const input = body.input ?? {};
|
|
@@ -2437,8 +2623,11 @@ var devCommand = async (options = {}) => {
|
|
|
2437
2623
|
input,
|
|
2438
2624
|
{ scenes: compiled.scenes, audio: compiled.audio }
|
|
2439
2625
|
);
|
|
2440
|
-
const output = resolve18(
|
|
2441
|
-
|
|
2626
|
+
const output = resolve18(
|
|
2627
|
+
exportDestination(config),
|
|
2628
|
+
`${outputName(video.entry.metadata.id)}${format.extension}`
|
|
2629
|
+
);
|
|
2630
|
+
const record = await createJob(config, manifest, output, { format: format.name, quality, scale });
|
|
2442
2631
|
json(response, 202, record.job);
|
|
2443
2632
|
void runJob(config, origin, record, video).catch((error) => {
|
|
2444
2633
|
log.error(`Export failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -2446,7 +2635,7 @@ var devCommand = async (options = {}) => {
|
|
|
2446
2635
|
return;
|
|
2447
2636
|
}
|
|
2448
2637
|
if (request.method === "POST" && url.startsWith("/retry/")) {
|
|
2449
|
-
const id = url.replace("/retry/", "").split("?")[0];
|
|
2638
|
+
const id = safeJobId(url.replace("/retry/", "").split("?")[0]);
|
|
2450
2639
|
const record = await readJob(config, id);
|
|
2451
2640
|
const { videos } = await context();
|
|
2452
2641
|
const video = findVideo(videos, record.manifest.videoId);
|
|
@@ -2457,13 +2646,13 @@ var devCommand = async (options = {}) => {
|
|
|
2457
2646
|
return;
|
|
2458
2647
|
}
|
|
2459
2648
|
if (request.method === "POST" && url.startsWith("/cancel/")) {
|
|
2460
|
-
const id = url.replace("/cancel/", "").split("?")[0];
|
|
2649
|
+
const id = safeJobId(url.replace("/cancel/", "").split("?")[0]);
|
|
2461
2650
|
const cancelled = cancelJob(id);
|
|
2462
2651
|
json(response, cancelled ? 202 : 404, { id, cancelled });
|
|
2463
2652
|
return;
|
|
2464
2653
|
}
|
|
2465
2654
|
if (request.method === "GET" && url.startsWith("/jobs/")) {
|
|
2466
|
-
const id = url.replace("/jobs/", "").split("?")[0];
|
|
2655
|
+
const id = safeJobId(url.replace("/jobs/", "").split("?")[0]);
|
|
2467
2656
|
json(response, 200, (await readJob(config, id)).job);
|
|
2468
2657
|
return;
|
|
2469
2658
|
}
|
|
@@ -2480,7 +2669,7 @@ var devCommand = async (options = {}) => {
|
|
|
2480
2669
|
}
|
|
2481
2670
|
});
|
|
2482
2671
|
const origin = server.url;
|
|
2483
|
-
const entry = `${origin}
|
|
2672
|
+
const entry = `${origin}/`;
|
|
2484
2673
|
log.title("Odori Studio");
|
|
2485
2674
|
log.info(` ${entry}`);
|
|
2486
2675
|
log.detail(` ${server.graph.videos.length} videos, ${server.graph.previews.length} component previews`);
|
|
@@ -2492,9 +2681,9 @@ var devCommand = async (options = {}) => {
|
|
|
2492
2681
|
// src/commands/doctor.ts
|
|
2493
2682
|
import { constants } from "fs";
|
|
2494
2683
|
import { access, mkdir as mkdir12, readFile as readFile13, rm as rm3, writeFile as writeFile13 } from "fs/promises";
|
|
2495
|
-
import { existsSync as
|
|
2684
|
+
import { existsSync as existsSync16 } from "fs";
|
|
2496
2685
|
import { createRequire as createRequire3 } from "module";
|
|
2497
|
-
import { relative as
|
|
2686
|
+
import { relative as relative6, resolve as resolve19 } from "path";
|
|
2498
2687
|
var MINIMUM_NODE = 20;
|
|
2499
2688
|
var version = (value) => value.replace(/^v/, "").split(".").map(Number);
|
|
2500
2689
|
var runChecks = async (root) => {
|
|
@@ -2526,13 +2715,13 @@ var runChecks = async (root) => {
|
|
|
2526
2715
|
const videosDir = resolve19(config.root, config.videosDir);
|
|
2527
2716
|
checks.push({
|
|
2528
2717
|
name: "Source root",
|
|
2529
|
-
detail:
|
|
2530
|
-
ok:
|
|
2718
|
+
detail: existsSync16(videosDir) ? relative6(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
|
|
2719
|
+
ok: existsSync16(videosDir),
|
|
2531
2720
|
fix: 'Run "odori init" to add the videos source root.'
|
|
2532
2721
|
});
|
|
2533
2722
|
checks.push({
|
|
2534
2723
|
name: "Config",
|
|
2535
|
-
detail: config.configPath ?
|
|
2724
|
+
detail: config.configPath ? relative6(config.root, config.configPath) : "defaults (no odori.config.ts)",
|
|
2536
2725
|
// Loading got this far, so a config that exists also parsed.
|
|
2537
2726
|
ok: true
|
|
2538
2727
|
});
|
|
@@ -2574,7 +2763,7 @@ var runChecks = async (root) => {
|
|
|
2574
2763
|
name: "Generated cache",
|
|
2575
2764
|
detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
|
|
2576
2765
|
ok: writable,
|
|
2577
|
-
fix: `Odori writes its import graph and render cache to ${
|
|
2766
|
+
fix: `Odori writes its import graph and render cache to ${relative6(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
|
|
2578
2767
|
});
|
|
2579
2768
|
return checks;
|
|
2580
2769
|
};
|
|
@@ -2599,13 +2788,13 @@ var doctorCommand = async (root = process.cwd()) => {
|
|
|
2599
2788
|
|
|
2600
2789
|
// src/commands/init.ts
|
|
2601
2790
|
import { mkdir as mkdir14, writeFile as writeFile15 } from "fs/promises";
|
|
2602
|
-
import { existsSync as
|
|
2603
|
-
import { relative as
|
|
2791
|
+
import { existsSync as existsSync18 } from "fs";
|
|
2792
|
+
import { relative as relative8, resolve as resolve21 } from "path";
|
|
2604
2793
|
|
|
2605
2794
|
// src/commands/new.ts
|
|
2606
2795
|
import { mkdir as mkdir13, readdir as readdir5, writeFile as writeFile14 } from "fs/promises";
|
|
2607
|
-
import { existsSync as
|
|
2608
|
-
import { relative as
|
|
2796
|
+
import { existsSync as existsSync17 } from "fs";
|
|
2797
|
+
import { relative as relative7, resolve as resolve20 } from "path";
|
|
2609
2798
|
var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
2610
2799
|
var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
|
|
2611
2800
|
var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
|
|
@@ -2664,7 +2853,7 @@ ${closing}
|
|
|
2664
2853
|
};
|
|
2665
2854
|
var installedParts = async (config) => {
|
|
2666
2855
|
const componentsDir = resolve20(config.root, config.componentsDir);
|
|
2667
|
-
if (!
|
|
2856
|
+
if (!existsSync17(componentsDir)) return { title: false, end: false };
|
|
2668
2857
|
const entries = (await readdir5(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
2669
2858
|
return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
|
|
2670
2859
|
};
|
|
@@ -2673,8 +2862,8 @@ var newCommand = async (name, options = {}) => {
|
|
|
2673
2862
|
const config = await loadConfig(process.cwd());
|
|
2674
2863
|
const directory2 = resolve20(config.root, config.videosDir, name);
|
|
2675
2864
|
const file = resolve20(directory2, "video.tsx");
|
|
2676
|
-
if (
|
|
2677
|
-
const hasLayout =
|
|
2865
|
+
if (existsSync17(file)) throw new Error(`${relative7(config.root, file)} already exists.`);
|
|
2866
|
+
const hasLayout = existsSync17(resolve20(config.root, config.videosDir, "layout.tsx"));
|
|
2678
2867
|
const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
|
|
2679
2868
|
const composed = parts.title || parts.end;
|
|
2680
2869
|
await mkdir13(directory2, { recursive: true });
|
|
@@ -2683,10 +2872,10 @@ var newCommand = async (name, options = {}) => {
|
|
|
2683
2872
|
composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
|
|
2684
2873
|
"utf8"
|
|
2685
2874
|
);
|
|
2686
|
-
log.success(`Created ${
|
|
2875
|
+
log.success(`Created ${relative7(config.root, file)}`);
|
|
2687
2876
|
if (composed) log.detail("Composed from the components this project has installed.");
|
|
2688
2877
|
else if (options.blank !== true) {
|
|
2689
|
-
log.detail("No registry components installed yet: odori add
|
|
2878
|
+
log.detail("No registry components installed yet: odori add title-reveal end-card");
|
|
2690
2879
|
}
|
|
2691
2880
|
log.detail("Run odori dev to preview it.");
|
|
2692
2881
|
};
|
|
@@ -2725,15 +2914,15 @@ var initCommand = async (root = process.cwd()) => {
|
|
|
2725
2914
|
[resolve21(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
|
|
2726
2915
|
];
|
|
2727
2916
|
for (const [file, contents] of files) {
|
|
2728
|
-
if (
|
|
2729
|
-
log.detail(`Kept existing ${
|
|
2917
|
+
if (existsSync18(file)) {
|
|
2918
|
+
log.detail(`Kept existing ${relative8(root, file)}`);
|
|
2730
2919
|
continue;
|
|
2731
2920
|
}
|
|
2732
2921
|
await mkdir14(resolve21(file, ".."), { recursive: true });
|
|
2733
2922
|
await writeFile15(file, contents, "utf8");
|
|
2734
|
-
log.success(`Created ${
|
|
2923
|
+
log.success(`Created ${relative8(root, file)}`);
|
|
2735
2924
|
}
|
|
2736
|
-
log.detail("Next: odori doctor, then odori add
|
|
2925
|
+
log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
|
|
2737
2926
|
};
|
|
2738
2927
|
|
|
2739
2928
|
// src/commands/inspect.ts
|
|
@@ -2855,7 +3044,7 @@ var stillCommand = async (id, options = {}) => {
|
|
|
2855
3044
|
import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
|
|
2856
3045
|
|
|
2857
3046
|
// src/contracts.ts
|
|
2858
|
-
import { existsSync as
|
|
3047
|
+
import { existsSync as existsSync19 } from "fs";
|
|
2859
3048
|
import { readdir as readdir6 } from "fs/promises";
|
|
2860
3049
|
import { resolve as resolve23 } from "path";
|
|
2861
3050
|
import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout7 } from "odori";
|
|
@@ -2925,7 +3114,7 @@ var checkAudioWindows = (cues, brand, videoId) => {
|
|
|
2925
3114
|
};
|
|
2926
3115
|
var checkInstalledContracts = async (config, videos) => {
|
|
2927
3116
|
const componentsDir = resolve23(config.root, config.componentsDir);
|
|
2928
|
-
const onDisk =
|
|
3117
|
+
const onDisk = existsSync19(componentsDir) ? (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
|
|
2929
3118
|
const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
|
|
2930
3119
|
if (names.size === 0) return [];
|
|
2931
3120
|
const { items } = await resolveRegistry(config, { allowNetwork: false });
|
|
@@ -2947,8 +3136,8 @@ var checkInstalledContracts = async (config, videos) => {
|
|
|
2947
3136
|
|
|
2948
3137
|
// src/determinism.ts
|
|
2949
3138
|
import { readdir as readdir7, readFile as readFile14 } from "fs/promises";
|
|
2950
|
-
import { existsSync as
|
|
2951
|
-
import { join as join7, relative as
|
|
3139
|
+
import { existsSync as existsSync20 } from "fs";
|
|
3140
|
+
import { join as join7, relative as relative9, resolve as resolve24 } from "path";
|
|
2952
3141
|
var FORBIDDEN = [
|
|
2953
3142
|
{
|
|
2954
3143
|
pattern: /\bMath\.random\s*\(/,
|
|
@@ -2990,10 +3179,10 @@ var walk2 = async (directory2, files = []) => {
|
|
|
2990
3179
|
};
|
|
2991
3180
|
var checkDeterminism = async (config) => {
|
|
2992
3181
|
const root = resolve24(config.root, config.videosDir);
|
|
2993
|
-
if (!
|
|
3182
|
+
if (!existsSync20(root)) return [];
|
|
2994
3183
|
const files = await walk2(root);
|
|
2995
3184
|
const findings = await Promise.all(
|
|
2996
|
-
files.map(async (file) => scanSource(await readFile14(file, "utf8"),
|
|
3185
|
+
files.map(async (file) => scanSource(await readFile14(file, "utf8"), relative9(config.root, file)))
|
|
2997
3186
|
);
|
|
2998
3187
|
return findings.flat();
|
|
2999
3188
|
};
|
|
@@ -3259,7 +3448,7 @@ var COMMAND_FLAGS = {
|
|
|
3259
3448
|
inspect: ["json", "input"],
|
|
3260
3449
|
still: ["frame", "output", "input"],
|
|
3261
3450
|
test: ["json"],
|
|
3262
|
-
export: ["output", "input", "concurrency", "preset", "format", "no-frame-skip", "retry"],
|
|
3451
|
+
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
|
|
3263
3452
|
jobs: [],
|
|
3264
3453
|
help: []
|
|
3265
3454
|
};
|
|
@@ -3326,9 +3515,12 @@ var USAGE = {
|
|
|
3326
3515
|
Validate contracts and representative frames. --json emits one object per
|
|
3327
3516
|
check, for CI.`,
|
|
3328
3517
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
3329
|
-
[--preset <name>] [--format <name>] [--
|
|
3518
|
+
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
3519
|
+
[--no-frame-skip] [--retry <job>]
|
|
3330
3520
|
Render and encode a distributable file. --format is mp4, webm, prores, gif,
|
|
3331
|
-
or png; without it the output's extension decides, and mp4 is the default
|
|
3521
|
+
or png; without it the output's extension decides, and mp4 is the default.
|
|
3522
|
+
--quality is studio, social, or web. --scale multiplies the output size,
|
|
3523
|
+
0.25 to 2. A retry keeps the settings its job was created with.`,
|
|
3332
3524
|
jobs: `odori jobs
|
|
3333
3525
|
List export jobs and their status.`
|
|
3334
3526
|
};
|
|
@@ -3358,6 +3550,8 @@ Options
|
|
|
3358
3550
|
--concurrency <n> Parallel render workers for export
|
|
3359
3551
|
--preset <name> x264 preset for export, default medium
|
|
3360
3552
|
--format <name> mp4, webm, prores, gif, or png
|
|
3553
|
+
--quality <tier> studio, social, or web compression
|
|
3554
|
+
--scale <n> Output size multiplier, 0.25 to 2
|
|
3361
3555
|
--no-frame-skip Capture every frame, even unchanged ones
|
|
3362
3556
|
--retry <job id> Re-run a recorded job from its frozen manifest
|
|
3363
3557
|
--no-open Start dev without opening Studio in a browser
|
|
@@ -3444,6 +3638,8 @@ var run2 = async (argv) => {
|
|
|
3444
3638
|
input: parseInput(flags),
|
|
3445
3639
|
concurrency: numberFlag(flags, "concurrency"),
|
|
3446
3640
|
preset: typeof flags.preset === "string" ? flags.preset : void 0,
|
|
3641
|
+
quality: typeof flags.quality === "string" ? flags.quality : void 0,
|
|
3642
|
+
scale: numberFlag(flags, "scale"),
|
|
3447
3643
|
format: typeof flags.format === "string" ? flags.format : void 0,
|
|
3448
3644
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
|
|
3449
3645
|
retry: typeof flags.retry === "string" ? flags.retry : void 0
|
|
@@ -3478,6 +3674,7 @@ export {
|
|
|
3478
3674
|
discoverProject,
|
|
3479
3675
|
generateImports,
|
|
3480
3676
|
writeGenerated,
|
|
3677
|
+
startStudioServer,
|
|
3481
3678
|
localCandidates,
|
|
3482
3679
|
createIntegrityResolver,
|
|
3483
3680
|
outputName,
|
|
@@ -3497,7 +3694,6 @@ export {
|
|
|
3497
3694
|
findVideo,
|
|
3498
3695
|
runPrepare,
|
|
3499
3696
|
freezeManifest,
|
|
3500
|
-
startStudioServer,
|
|
3501
3697
|
resolveCueFile,
|
|
3502
3698
|
buildAudioFilter,
|
|
3503
3699
|
planChunks,
|