@odori/cli 0.0.2 → 0.0.4
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-NYXWEZU2.js} +717 -348
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +63 -8
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-MSH2EA36.js +4867 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +22 -12
- package/src/commands/add.ts +33 -8
- package/src/commands/dev.ts +114 -12
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +85 -5
- package/src/formats.ts +67 -8
- package/src/index.ts +1 -1
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +1530 -328
- package/src/registry-source.ts +87 -5
- package/src/render.ts +48 -13
- package/src/server.ts +55 -13
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +221 -0
- package/studio/src/components/Navigator.tsx +145 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/studio.css +435 -20
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +74 -26
- package/studio/src/views/ComponentsView.tsx +206 -55
- package/studio/src/views/HomeView.tsx +44 -19
- package/studio/src/views/VideosView.tsx +53 -48
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
|
@@ -20,9 +20,9 @@ var log = {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
// src/commands/add.ts
|
|
23
|
-
import { mkdir as
|
|
24
|
-
import { existsSync as
|
|
25
|
-
import { relative as
|
|
23
|
+
import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile6 } from "fs/promises";
|
|
24
|
+
import { existsSync as existsSync7 } from "fs";
|
|
25
|
+
import { relative as relative5, resolve as resolve7 } from "path";
|
|
26
26
|
import { hashString as hashString2 } from "odori";
|
|
27
27
|
|
|
28
28
|
// src/config.ts
|
|
@@ -86,6 +86,7 @@ var brandFiles = async (config) => {
|
|
|
86
86
|
return found.sort((left, right) => left.depth - right.depth).map((entry) => entry.file);
|
|
87
87
|
};
|
|
88
88
|
var registerCueInBrand = async (config, cue, componentName) => {
|
|
89
|
+
const url = "url" in cue ? cue.url : null;
|
|
89
90
|
for (const file of await brandFiles(config)) {
|
|
90
91
|
const source = await readFile(file, "utf8");
|
|
91
92
|
if (source.includes(`"${cue.name}"`) || source.includes(`'${cue.name}'`)) {
|
|
@@ -93,19 +94,24 @@ var registerCueInBrand = async (config, cue, componentName) => {
|
|
|
93
94
|
}
|
|
94
95
|
const block = source.match(/(audio:\s*\{[\s\S]*?cues:\s*\{)([\s\S]*?)(\n(\s*)\},)/);
|
|
95
96
|
const inline = source.match(/(audio:\s*\{[^\n}]*cues:\s*\{)([^\n{}]*)(\})/);
|
|
97
|
+
const value = url ? JSON.stringify(url) : `${cue.export}()`;
|
|
96
98
|
let withCue;
|
|
97
99
|
if (block) {
|
|
98
100
|
const indent = `${block[4]} `;
|
|
99
101
|
const entry = `
|
|
100
|
-
${indent}"${cue.name}": ${
|
|
102
|
+
${indent}"${cue.name}": ${value},`;
|
|
101
103
|
withCue = source.replace(block[0], `${block[1]}${block[2]}${entry}${block[3]}`);
|
|
102
104
|
} else if (inline) {
|
|
103
105
|
const existing = inline[2].trim();
|
|
104
|
-
const entry = `"${cue.name}": ${
|
|
106
|
+
const entry = `"${cue.name}": ${value}`;
|
|
105
107
|
withCue = source.replace(inline[0], `${inline[1]}${existing ? `${existing.replace(/,$/, "")}, ` : ""}${entry}${inline[3]}`);
|
|
106
108
|
} else {
|
|
107
109
|
continue;
|
|
108
110
|
}
|
|
111
|
+
if (url) {
|
|
112
|
+
await writeFile(file, withCue, "utf8");
|
|
113
|
+
return { file: relative(config.root, file), already: false };
|
|
114
|
+
}
|
|
109
115
|
const from = resolve2(config.root, config.componentsDir, componentName, componentName);
|
|
110
116
|
const specifier = relative(resolve2(file, ".."), from).split("\\").join("/");
|
|
111
117
|
const importLine = `import {${cue.export}} from "${specifier.startsWith(".") ? specifier : `./${specifier}`}";`;
|
|
@@ -121,7 +127,7 @@ ${indent}"${cue.name}": ${cue.export}(),`;
|
|
|
121
127
|
import { createHash } from "crypto";
|
|
122
128
|
import { existsSync as existsSync4 } from "fs";
|
|
123
129
|
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
124
|
-
import { dirname as dirname2, resolve as resolve4 } from "path";
|
|
130
|
+
import { dirname as dirname2, isAbsolute, relative as relative2, resolve as resolve4, sep } from "path";
|
|
125
131
|
|
|
126
132
|
// src/binaries.ts
|
|
127
133
|
import { spawn } from "child_process";
|
|
@@ -268,14 +274,41 @@ var installCommand = async () => {
|
|
|
268
274
|
|
|
269
275
|
// src/registry-source.ts
|
|
270
276
|
var normalizeComponentName = (name) => name.replace(/^@odori\//, "");
|
|
277
|
+
var assertSafeName = (name) => {
|
|
278
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`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.`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return name;
|
|
284
|
+
};
|
|
285
|
+
var resolveWithinRoot = (root, target) => {
|
|
286
|
+
const destination = resolve4(root, target);
|
|
287
|
+
const rel = relative2(root, destination);
|
|
288
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Refusing to write ${JSON.stringify(target)}: it resolves outside the project. Nothing was written.`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return destination;
|
|
294
|
+
};
|
|
271
295
|
var DEFAULT_URL = "https://odori.dev/r/v1";
|
|
272
296
|
var registryUrl = (config) => (config.registryUrl ?? process.env.ODORI_REGISTRY ?? DEFAULT_URL).replace(/\/$/, "");
|
|
297
|
+
var registryOrigin = (config) => {
|
|
298
|
+
const url = registryUrl(config);
|
|
299
|
+
try {
|
|
300
|
+
return new URL(url).origin;
|
|
301
|
+
} catch {
|
|
302
|
+
return url.replace(/\/r(\/v\d+)?$/, "");
|
|
303
|
+
}
|
|
304
|
+
};
|
|
273
305
|
var cacheDir = (url) => resolve4(cacheRoot(), "registry", createHash("sha256").update(url).digest("hex").slice(0, 16));
|
|
274
306
|
var toComponent = (item) => ({
|
|
275
307
|
name: item.name,
|
|
276
308
|
namespaced: item.meta?.namespaced ?? `@odori/${item.name}`,
|
|
277
309
|
kind: item.meta?.kind ?? "component",
|
|
278
310
|
...item.meta?.cue ? { cue: item.meta.cue } : {},
|
|
311
|
+
...item.meta?.asset ? { asset: item.meta.asset } : {},
|
|
279
312
|
family: item.meta?.family ?? "Uncategorized",
|
|
280
313
|
description: item.description ?? "",
|
|
281
314
|
files: item.files.map((file) => file.path.split("/").pop() ?? file.path),
|
|
@@ -284,7 +317,7 @@ var toComponent = (item) => ({
|
|
|
284
317
|
});
|
|
285
318
|
var snapshotItems = async () => {
|
|
286
319
|
try {
|
|
287
|
-
const loaded = await import("./registry-snapshot-
|
|
320
|
+
const loaded = await import("./registry-snapshot-MSH2EA36.js");
|
|
288
321
|
return loaded.default.items;
|
|
289
322
|
} catch {
|
|
290
323
|
throw new Error(
|
|
@@ -327,6 +360,7 @@ var resolveRegistry = async (config, options = {}) => {
|
|
|
327
360
|
return { items: await bundled(), origin: "bundled", detail: "the copy built into this CLI" };
|
|
328
361
|
};
|
|
329
362
|
var resolveItem = async (config, name, options = {}) => {
|
|
363
|
+
assertSafeName(name);
|
|
330
364
|
const url = registryUrl(config);
|
|
331
365
|
const cache = resolve4(cacheDir(url), `${name}.json`);
|
|
332
366
|
if (options.allowNetwork !== false) {
|
|
@@ -349,9 +383,14 @@ var resolveItem = async (config, name, options = {}) => {
|
|
|
349
383
|
if (!item) throw new Error(`No component named "${name}" in the registry at ${url}, in the cache, or in this CLI.`);
|
|
350
384
|
return { item, origin: "bundled" };
|
|
351
385
|
};
|
|
352
|
-
var verifyIntegrity = (item) => {
|
|
386
|
+
var verifyIntegrity = (item, origin = "network") => {
|
|
353
387
|
const expected = item.meta?.integrity;
|
|
354
|
-
if (!expected)
|
|
388
|
+
if (!expected) {
|
|
389
|
+
if (origin === "bundled") return;
|
|
390
|
+
throw new Error(
|
|
391
|
+
`The registry document for "${item.name}" carries no integrity hash. A fetched item must be verifiable; refusing to write it. Nothing was written.`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
355
394
|
const hash = createHash("sha256");
|
|
356
395
|
for (const file of [...item.files].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
357
396
|
hash.update(file.path);
|
|
@@ -369,10 +408,66 @@ Nothing was written. This is a truncated download, a stale proxy, or a tampered
|
|
|
369
408
|
);
|
|
370
409
|
};
|
|
371
410
|
|
|
372
|
-
// src/
|
|
373
|
-
import {
|
|
411
|
+
// src/assets.ts
|
|
412
|
+
import { createHash as createHash2 } from "crypto";
|
|
374
413
|
import { existsSync as existsSync5 } from "fs";
|
|
375
|
-
import {
|
|
414
|
+
import { mkdir as mkdir3, readFile as readFile4, rm, writeFile as writeFile4 } from "fs/promises";
|
|
415
|
+
import { dirname as dirname3, relative as relative3, resolve as resolve5 } from "path";
|
|
416
|
+
var assetCache = (integrity) => resolve5(cacheRoot(), "assets", createHash2("sha256").update(integrity).digest("hex").slice(0, 16));
|
|
417
|
+
var verify = (bytes, expected, source) => {
|
|
418
|
+
const actual = `sha256-${createHash2("sha256").update(bytes).digest("base64")}`;
|
|
419
|
+
if (actual === expected) return;
|
|
420
|
+
throw new Error(
|
|
421
|
+
`The bytes at ${source} do not match the hash the registry published.
|
|
422
|
+
expected ${expected}
|
|
423
|
+
received ${actual}
|
|
424
|
+
Nothing was written. This is a truncated download, a stale proxy, or a tampered file.`
|
|
425
|
+
);
|
|
426
|
+
};
|
|
427
|
+
var installAsset = async (config, component, options = {}) => {
|
|
428
|
+
const asset = component.asset;
|
|
429
|
+
if (!asset) return false;
|
|
430
|
+
const destination = resolveWithinRoot(config.root, asset.target);
|
|
431
|
+
const exists = existsSync5(destination);
|
|
432
|
+
log.detail(` ${exists ? "replace" : "create "} ${relative3(config.root, destination)}`);
|
|
433
|
+
if (options.dryRun) return false;
|
|
434
|
+
if (exists && !options.force) {
|
|
435
|
+
log.warn(`${relative3(config.root, destination)} already exists. Keeping it. Use --force to replace it.`);
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
const cached = assetCache(asset.integrity);
|
|
439
|
+
let bytes = null;
|
|
440
|
+
if (existsSync5(cached)) {
|
|
441
|
+
const stored = await readFile4(cached);
|
|
442
|
+
try {
|
|
443
|
+
verify(stored, asset.integrity, cached);
|
|
444
|
+
bytes = stored;
|
|
445
|
+
} catch {
|
|
446
|
+
log.detail(" the cached copy did not verify; downloading it again");
|
|
447
|
+
await rm(cached, { force: true });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!bytes) {
|
|
451
|
+
const url = new URL(asset.url, `${registryOrigin(config)}/`).toString();
|
|
452
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(3e4) });
|
|
453
|
+
if (!response.ok) throw new Error(`Could not download ${url}: ${response.status} ${response.statusText}`);
|
|
454
|
+
const downloaded = new Uint8Array(await response.arrayBuffer());
|
|
455
|
+
verify(downloaded, asset.integrity, url);
|
|
456
|
+
await mkdir3(dirname3(cached), { recursive: true });
|
|
457
|
+
await writeFile4(cached, downloaded);
|
|
458
|
+
bytes = downloaded;
|
|
459
|
+
}
|
|
460
|
+
await mkdir3(dirname3(destination), { recursive: true });
|
|
461
|
+
await writeFile4(destination, bytes);
|
|
462
|
+
log.success(`${component.namespaced} to ${relative3(config.root, destination)}`);
|
|
463
|
+
log.detail(` ${component.family} \xB7 ${Math.round(asset.bytes / 1024)} KB \xB7 answers to "${asset.cue}"`);
|
|
464
|
+
return true;
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
// src/commands/update.ts
|
|
468
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
|
|
469
|
+
import { existsSync as existsSync6 } from "fs";
|
|
470
|
+
import { relative as relative4, resolve as resolve6 } from "path";
|
|
376
471
|
import { hashString } from "odori";
|
|
377
472
|
|
|
378
473
|
// src/diff.ts
|
|
@@ -435,15 +530,15 @@ var formatDiff = (lines, context = 2) => {
|
|
|
435
530
|
};
|
|
436
531
|
|
|
437
532
|
// src/commands/update.ts
|
|
438
|
-
var provenanceFile = (config) =>
|
|
533
|
+
var provenanceFile = (config) => resolve6(config.root, config.outDir, "components.json");
|
|
439
534
|
var readProvenance = async (config) => {
|
|
440
535
|
const file = provenanceFile(config);
|
|
441
|
-
if (!
|
|
442
|
-
return JSON.parse(await
|
|
536
|
+
if (!existsSync6(file)) return {};
|
|
537
|
+
return JSON.parse(await readFile5(file, "utf8"));
|
|
443
538
|
};
|
|
444
539
|
var writeProvenance = async (config, provenance) => {
|
|
445
|
-
await
|
|
446
|
-
await
|
|
540
|
+
await mkdir4(resolve6(config.root, config.outDir), { recursive: true });
|
|
541
|
+
await writeFile5(provenanceFile(config), `${JSON.stringify(provenance, null, 2)}
|
|
447
542
|
`, "utf8");
|
|
448
543
|
};
|
|
449
544
|
var componentStatus = async (config, only) => {
|
|
@@ -459,10 +554,10 @@ var componentStatus = async (config, only) => {
|
|
|
459
554
|
const upstreamFiles = new Map(item.files.map((file) => [file.path.split("/").pop() ?? file.path, file.content]));
|
|
460
555
|
const files = await Promise.all(
|
|
461
556
|
component.files.map(async (file) => {
|
|
462
|
-
const localPath =
|
|
557
|
+
const localPath = resolve6(config.root, config.componentsDir, name, file);
|
|
463
558
|
const content = upstreamFiles.get(file);
|
|
464
559
|
if (content === void 0) throw new Error(`The registry document for "${name}" has no file named ${file}.`);
|
|
465
|
-
const local =
|
|
560
|
+
const local = existsSync6(localPath) ? hashString(await readFile5(localPath, "utf8")) : null;
|
|
466
561
|
return {
|
|
467
562
|
file,
|
|
468
563
|
localPath,
|
|
@@ -499,14 +594,14 @@ var diffCommand = async (names, options = {}) => {
|
|
|
499
594
|
log.title(`@odori/${status.name} ${LABELS[status.state]}`);
|
|
500
595
|
for (const file of status.files) {
|
|
501
596
|
if (file.local === null) {
|
|
502
|
-
log.error(` ${file.file} is missing from ${
|
|
597
|
+
log.error(` ${file.file} is missing from ${relative4(config.root, resolve6(file.localPath, ".."))}`);
|
|
503
598
|
continue;
|
|
504
599
|
}
|
|
505
600
|
if (file.local === file.upstream) {
|
|
506
601
|
log.detail(` ${file.file} identical to upstream`);
|
|
507
602
|
continue;
|
|
508
603
|
}
|
|
509
|
-
const lines = diffLines(await
|
|
604
|
+
const lines = diffLines(await readFile5(file.localPath, "utf8"), file.content);
|
|
510
605
|
const { added, removed } = countChanges(lines);
|
|
511
606
|
log.info(` ${file.file} +${added} -${removed} against upstream`);
|
|
512
607
|
if (options.full) for (const line of formatDiff(lines)) log.detail(` ${line}`);
|
|
@@ -539,8 +634,8 @@ var updateCommand = async (names, options = {}) => {
|
|
|
539
634
|
continue;
|
|
540
635
|
}
|
|
541
636
|
for (const file of status.files) {
|
|
542
|
-
await
|
|
543
|
-
await
|
|
637
|
+
await mkdir4(resolve6(file.localPath, ".."), { recursive: true });
|
|
638
|
+
await writeFile5(file.localPath, file.content, "utf8");
|
|
544
639
|
}
|
|
545
640
|
provenance[status.name] = {
|
|
546
641
|
source: `@odori/${status.name}`,
|
|
@@ -557,7 +652,7 @@ var updateCommand = async (names, options = {}) => {
|
|
|
557
652
|
|
|
558
653
|
// src/commands/add.ts
|
|
559
654
|
var addCommand = async (names, options = {}) => {
|
|
560
|
-
if (names.length === 0) throw new Error("Name at least one component, for example
|
|
655
|
+
if (names.length === 0) throw new Error("Name at least one component, for example title-reveal.");
|
|
561
656
|
const config = await loadConfig(process.cwd());
|
|
562
657
|
const source = await resolveRegistry(config);
|
|
563
658
|
const registry = source.items;
|
|
@@ -580,34 +675,54 @@ var addCommand = async (names, options = {}) => {
|
|
|
580
675
|
if (provider) queue.push(provider.name);
|
|
581
676
|
else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
|
|
582
677
|
}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
678
|
+
if (component.kind === "asset" && component.asset) {
|
|
679
|
+
const written = await installAsset(config, component, { dryRun: options.dryRun, force: options.force });
|
|
680
|
+
if (!options.dryRun && written) {
|
|
681
|
+
installed.push(component.name);
|
|
682
|
+
const registered = await registerCueInBrand(
|
|
683
|
+
config,
|
|
684
|
+
{ name: component.asset.cue, url: component.asset.url },
|
|
685
|
+
component.name
|
|
686
|
+
);
|
|
687
|
+
if (registered?.already) {
|
|
688
|
+
log.detail(` "${component.asset.cue}" is already registered in ${registered.file}`);
|
|
689
|
+
} else if (registered) {
|
|
690
|
+
log.detail(` registered "${component.asset.cue}" in ${registered.file}`);
|
|
691
|
+
} else {
|
|
692
|
+
log.warn(` No brand with an audio.cues block found. Add it yourself:`);
|
|
693
|
+
log.detail(` audio: {cues: {"${component.asset.cue}": "${component.asset.url}"}}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const { item, origin } = await resolveItem(config, component.name);
|
|
699
|
+
verifyIntegrity(item, origin);
|
|
700
|
+
const target = resolve7(config.root, config.componentsDir, assertSafeName(component.name));
|
|
586
701
|
const hashes = {};
|
|
587
702
|
for (const file of item.files) {
|
|
588
|
-
const destination =
|
|
589
|
-
const exists =
|
|
590
|
-
log.detail(` ${exists ? "replace" : "create "} ${
|
|
703
|
+
const destination = resolveWithinRoot(config.root, file.target);
|
|
704
|
+
const exists = existsSync7(destination);
|
|
705
|
+
log.detail(` ${exists ? "replace" : "create "} ${relative5(config.root, destination)}`);
|
|
591
706
|
}
|
|
592
707
|
if (options.dryRun) {
|
|
593
708
|
installed.push(component.name);
|
|
594
709
|
continue;
|
|
595
710
|
}
|
|
596
|
-
await
|
|
711
|
+
await mkdir5(target, { recursive: true });
|
|
597
712
|
for (const file of item.files) {
|
|
598
713
|
const name2 = file.path.split("/").pop() ?? file.path;
|
|
599
|
-
const destination =
|
|
714
|
+
const destination = resolveWithinRoot(config.root, file.target);
|
|
600
715
|
hashes[name2] = hashString2(file.content);
|
|
601
|
-
if (
|
|
602
|
-
const current = hashString2(await
|
|
716
|
+
if (existsSync7(destination) && !options.force) {
|
|
717
|
+
const current = hashString2(await readFile6(destination, "utf8"));
|
|
603
718
|
const recorded = provenance[component.name]?.hashes[name2];
|
|
604
719
|
if (current !== recorded) {
|
|
605
|
-
log.warn(`${
|
|
720
|
+
log.warn(`${relative5(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
|
|
606
721
|
continue;
|
|
607
722
|
}
|
|
608
723
|
}
|
|
609
|
-
await
|
|
610
|
-
await
|
|
724
|
+
await mkdir5(resolve7(destination, ".."), { recursive: true });
|
|
725
|
+
await writeFile6(destination, file.content, "utf8");
|
|
611
726
|
}
|
|
612
727
|
provenance[component.name] = {
|
|
613
728
|
source: component.namespaced,
|
|
@@ -616,7 +731,7 @@ var addCommand = async (names, options = {}) => {
|
|
|
616
731
|
hashes
|
|
617
732
|
};
|
|
618
733
|
installed.push(component.name);
|
|
619
|
-
log.success(`${component.namespaced} to ${
|
|
734
|
+
log.success(`${component.namespaced} to ${relative5(config.root, target)}/`);
|
|
620
735
|
if (component.kind === "cue" && component.cue) {
|
|
621
736
|
log.detail(
|
|
622
737
|
` ${component.family} \xB7 ${component.contract.recommendedDurationInFrames} frames \xB7 registers "${component.cue.name}"`
|
|
@@ -664,17 +779,19 @@ var registryCommand = async () => {
|
|
|
664
779
|
};
|
|
665
780
|
|
|
666
781
|
// src/commands/dev.ts
|
|
667
|
-
import { resolve as
|
|
668
|
-
import {
|
|
782
|
+
import { relative as relative7, resolve as resolve19 } from "path";
|
|
783
|
+
import { homedir as homedir2 } from "os";
|
|
784
|
+
import { existsSync as existsSync16 } from "fs";
|
|
785
|
+
import { readFile as readFile13 } from "fs/promises";
|
|
669
786
|
|
|
670
787
|
// src/jobs.ts
|
|
671
|
-
import { mkdir as
|
|
672
|
-
import { existsSync as
|
|
673
|
-
import { join as join2, resolve as
|
|
674
|
-
var buildsDir = (config) =>
|
|
788
|
+
import { mkdir as mkdir6, readFile as readFile7, readdir as readdir2, rename, writeFile as writeFile7 } from "fs/promises";
|
|
789
|
+
import { existsSync as existsSync8 } from "fs";
|
|
790
|
+
import { join as join2, resolve as resolve8 } from "path";
|
|
791
|
+
var buildsDir = (config) => resolve8(config.root, config.outDir, "builds");
|
|
675
792
|
var jobFile = (config, id) => join2(buildsDir(config), `${id}.json`);
|
|
676
|
-
var createJob = async (config, manifest, output) => {
|
|
677
|
-
await
|
|
793
|
+
var createJob = async (config, manifest, output, render) => {
|
|
794
|
+
await mkdir6(buildsDir(config), { recursive: true });
|
|
678
795
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
679
796
|
const job = {
|
|
680
797
|
id: `job-${manifest.manifestHash.slice(0, 10)}-${Date.now().toString(36)}`,
|
|
@@ -687,15 +804,15 @@ var createJob = async (config, manifest, output) => {
|
|
|
687
804
|
createdAt: now,
|
|
688
805
|
updatedAt: now
|
|
689
806
|
};
|
|
690
|
-
const record = { job, manifest, output };
|
|
691
|
-
await
|
|
807
|
+
const record = { job, manifest, output, ...render ? { render } : {} };
|
|
808
|
+
await writeFile7(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
|
|
692
809
|
`, "utf8");
|
|
693
810
|
return record;
|
|
694
811
|
};
|
|
695
812
|
var readJob = async (config, id) => {
|
|
696
813
|
const file = jobFile(config, id);
|
|
697
|
-
if (!
|
|
698
|
-
return JSON.parse(await
|
|
814
|
+
if (!existsSync8(file)) throw new Error(`Unknown job "${id}". Run odori jobs to list them.`);
|
|
815
|
+
return JSON.parse(await readFile7(file, "utf8"));
|
|
699
816
|
};
|
|
700
817
|
var writeLocks = /* @__PURE__ */ new Map();
|
|
701
818
|
var withJobLock = (id, task) => {
|
|
@@ -710,7 +827,7 @@ var withJobLock = (id, task) => {
|
|
|
710
827
|
var writeRecord = async (config, record) => {
|
|
711
828
|
const file = jobFile(config, record.job.id);
|
|
712
829
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
713
|
-
await
|
|
830
|
+
await writeFile7(temporary, `${JSON.stringify(record, null, 2)}
|
|
714
831
|
`, "utf8");
|
|
715
832
|
await rename(temporary, file);
|
|
716
833
|
};
|
|
@@ -753,13 +870,13 @@ var reconcileJobs = async (config) => {
|
|
|
753
870
|
return stale.length;
|
|
754
871
|
};
|
|
755
872
|
var listJobs = async (config, options = {}) => {
|
|
756
|
-
if (!
|
|
873
|
+
if (!existsSync8(buildsDir(config))) return [];
|
|
757
874
|
if (options.reconcile !== false) await reconcileJobs(config);
|
|
758
875
|
const files = (await readdir2(buildsDir(config))).filter((file) => file.endsWith(".json"));
|
|
759
876
|
const jobs = [];
|
|
760
877
|
for (const file of files) {
|
|
761
878
|
try {
|
|
762
|
-
const raw = await
|
|
879
|
+
const raw = await readFile7(join2(buildsDir(config), file), "utf8");
|
|
763
880
|
jobs.push(JSON.parse(raw).job);
|
|
764
881
|
} catch {
|
|
765
882
|
continue;
|
|
@@ -777,9 +894,9 @@ var JobQueue = class {
|
|
|
777
894
|
};
|
|
778
895
|
|
|
779
896
|
// src/discovery.ts
|
|
780
|
-
import { mkdir as
|
|
781
|
-
import { existsSync as
|
|
782
|
-
import { join as join3, relative as
|
|
897
|
+
import { mkdir as mkdir7, readdir as readdir3, readFile as readFile8, stat, writeFile as writeFile8 } from "fs/promises";
|
|
898
|
+
import { existsSync as existsSync9 } from "fs";
|
|
899
|
+
import { join as join3, relative as relative6, resolve as resolve9, sep as sep2 } from "path";
|
|
783
900
|
import { hashString as hashString3 } from "odori";
|
|
784
901
|
var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
|
|
785
902
|
var walk = async (directory2, files = []) => {
|
|
@@ -801,22 +918,22 @@ var toIdentifier = (value, prefix) => {
|
|
|
801
918
|
};
|
|
802
919
|
var AUDIO_EXTENSIONS = /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i;
|
|
803
920
|
var discoverAudio = async (config) => {
|
|
804
|
-
const root =
|
|
805
|
-
if (!
|
|
806
|
-
const publicRoot =
|
|
921
|
+
const root = resolve9(config.root, config.audioDir);
|
|
922
|
+
if (!existsSync9(root)) return [];
|
|
923
|
+
const publicRoot = resolve9(config.root, "public");
|
|
807
924
|
const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
|
|
808
925
|
return Promise.all(
|
|
809
926
|
files.map(async (file) => ({
|
|
810
|
-
name:
|
|
811
|
-
url: file.startsWith(`${publicRoot}${
|
|
812
|
-
relativeFile:
|
|
927
|
+
name: relative6(root, file).replace(AUDIO_EXTENSIONS, "").split(sep2).join("/"),
|
|
928
|
+
url: file.startsWith(`${publicRoot}${sep2}`) ? `/${relative6(publicRoot, file).split(sep2).join("/")}` : `/${relative6(config.root, file).split(sep2).join("/")}`,
|
|
929
|
+
relativeFile: relative6(config.root, file),
|
|
813
930
|
bytes: (await stat(file)).size
|
|
814
931
|
}))
|
|
815
932
|
);
|
|
816
933
|
};
|
|
817
934
|
var discoverProject = async (config) => {
|
|
818
|
-
const videosRoot =
|
|
819
|
-
if (!
|
|
935
|
+
const videosRoot = resolve9(config.root, config.videosDir);
|
|
936
|
+
if (!existsSync9(videosRoot)) {
|
|
820
937
|
throw new Error(`No ${config.videosDir}/ directory found in ${config.root}. Run "odori init" first.`);
|
|
821
938
|
}
|
|
822
939
|
const files = (await walk(videosRoot)).sort();
|
|
@@ -824,16 +941,42 @@ var discoverProject = async (config) => {
|
|
|
824
941
|
const videos = [];
|
|
825
942
|
const previews = [];
|
|
826
943
|
const brands = [];
|
|
944
|
+
const categories = [];
|
|
827
945
|
const hashParts = [];
|
|
946
|
+
const importedBy = {};
|
|
947
|
+
const componentsRoot = resolve9(config.root, config.componentsDir);
|
|
828
948
|
for (const file of files) {
|
|
829
|
-
const relativeFile =
|
|
949
|
+
const relativeFile = relative6(config.root, file);
|
|
950
|
+
let contents = "";
|
|
830
951
|
if (/\.(tsx|ts|css|json)$/.test(file)) {
|
|
831
|
-
|
|
952
|
+
contents = await readFile8(file, "utf8");
|
|
832
953
|
hashParts.push(`${relativeFile}:${hashString3(contents)}`);
|
|
833
954
|
}
|
|
834
|
-
const base = file.split(
|
|
955
|
+
const base = file.split(sep2).pop() ?? "";
|
|
956
|
+
if (base === "category.json") {
|
|
957
|
+
const path = relative6(componentsRoot, resolve9(file, "..")).split(sep2).join("/");
|
|
958
|
+
if (!path.startsWith("..")) {
|
|
959
|
+
try {
|
|
960
|
+
const declared = JSON.parse(contents);
|
|
961
|
+
categories.push({
|
|
962
|
+
path,
|
|
963
|
+
...typeof declared.name === "string" ? { name: declared.name } : {},
|
|
964
|
+
...typeof declared.order === "number" ? { order: declared.order } : {}
|
|
965
|
+
});
|
|
966
|
+
} catch (error) {
|
|
967
|
+
log.warn(
|
|
968
|
+
`${relativeFile} is not valid JSON, so that directory names itself: ${error instanceof Error ? error.message : String(error)}`
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (base === "video.tsx") {
|
|
974
|
+
for (const match of contents.matchAll(/from\s+["'][^"']*\/components\/([^/"']+)\//g)) {
|
|
975
|
+
(importedBy[match[1]] ??= /* @__PURE__ */ new Set()).add(relative6(videosRoot, file).replace(/\/?video\.tsx$/, "") || "video");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
835
978
|
if (base === "video.tsx") {
|
|
836
|
-
const slug =
|
|
979
|
+
const slug = relative6(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep2).join("/") || "video";
|
|
837
980
|
videos.push({
|
|
838
981
|
slug,
|
|
839
982
|
file,
|
|
@@ -841,13 +984,27 @@ var discoverProject = async (config) => {
|
|
|
841
984
|
importPath: file,
|
|
842
985
|
identifier: toIdentifier(slug, "video")
|
|
843
986
|
});
|
|
844
|
-
} else if (
|
|
987
|
+
} else if (
|
|
988
|
+
// A brand module is recognized by what it does, not where it sits. The
|
|
989
|
+
// scaffold defines its brand in videos/layout.tsx, so a directory-name
|
|
990
|
+
// rule alone left the default project's brand invisible to everything
|
|
991
|
+
// that reads this list — most visibly the dev server's cue registry,
|
|
992
|
+
// which then answered new cue URLs with 404s until a restart. Installed
|
|
993
|
+
// component source is excluded the way brand-file.ts excludes it: a
|
|
994
|
+
// component may mention defineBrand without being where a brand lives.
|
|
995
|
+
/\.tsx?$/.test(base) && !base.endsWith(".preview.tsx") && !file.startsWith(componentsRoot + sep2) && (file.split(sep2).includes("brands") || contents.includes("defineBrand("))
|
|
996
|
+
) {
|
|
845
997
|
const name = base.replace(/\.tsx?$/, "");
|
|
846
998
|
brands.push({
|
|
847
999
|
name,
|
|
848
1000
|
file,
|
|
849
1001
|
relativeFile,
|
|
850
|
-
|
|
1002
|
+
// From the whole relative path, like previews: basenames repeat
|
|
1003
|
+
// (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
|
|
1004
|
+
identifier: toIdentifier(
|
|
1005
|
+
`${relative6(videosRoot, file).replace(/\.tsx?$/, "").split(sep2).join("-")}-module`,
|
|
1006
|
+
"brands"
|
|
1007
|
+
)
|
|
851
1008
|
});
|
|
852
1009
|
} else if (base.endsWith(".preview.tsx")) {
|
|
853
1010
|
const name = base.replace(/\.preview\.tsx$/, "");
|
|
@@ -856,15 +1013,19 @@ var discoverProject = async (config) => {
|
|
|
856
1013
|
file,
|
|
857
1014
|
relativeFile,
|
|
858
1015
|
importPath: file,
|
|
859
|
-
identifier: toIdentifier(`${
|
|
1016
|
+
identifier: toIdentifier(`${relative6(videosRoot, file).split(sep2).join("-")}`, "preview")
|
|
860
1017
|
});
|
|
861
1018
|
}
|
|
862
1019
|
}
|
|
863
|
-
|
|
1020
|
+
for (const preview of previews) {
|
|
1021
|
+
const users = importedBy[preview.name];
|
|
1022
|
+
if (users) preview.usedBy = [...users].sort();
|
|
1023
|
+
}
|
|
1024
|
+
return { videos, previews, brands, audio, categories, sourceHash: hashString3(hashParts.join("|")) };
|
|
864
1025
|
};
|
|
865
1026
|
var generateImports = (graph, outDir) => {
|
|
866
1027
|
const importPath = (file) => {
|
|
867
|
-
const relativePath =
|
|
1028
|
+
const relativePath = relative6(outDir, file).split(sep2).join("/");
|
|
868
1029
|
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
869
1030
|
};
|
|
870
1031
|
const lines = [
|
|
@@ -897,17 +1058,21 @@ var generateImports = (graph, outDir) => {
|
|
|
897
1058
|
return lines.join("\n");
|
|
898
1059
|
};
|
|
899
1060
|
var writeGenerated = async (config, graph) => {
|
|
900
|
-
const outDir =
|
|
901
|
-
await
|
|
1061
|
+
const outDir = resolve9(config.root, config.outDir);
|
|
1062
|
+
await mkdir7(outDir, { recursive: true });
|
|
902
1063
|
const target = join3(outDir, "imports.generated.ts");
|
|
903
|
-
await
|
|
904
|
-
await
|
|
1064
|
+
await writeFile8(target, generateImports(graph, outDir), "utf8");
|
|
1065
|
+
await writeFile8(
|
|
905
1066
|
join3(outDir, "catalog.json"),
|
|
906
1067
|
`${JSON.stringify(
|
|
907
1068
|
{
|
|
908
1069
|
sourceHash: graph.sourceHash,
|
|
909
1070
|
videos: graph.videos.map((video) => ({ slug: video.slug, file: video.relativeFile })),
|
|
910
|
-
previews: graph.previews.map((preview) => ({
|
|
1071
|
+
previews: graph.previews.map((preview) => ({
|
|
1072
|
+
name: preview.name,
|
|
1073
|
+
file: preview.relativeFile,
|
|
1074
|
+
usedBy: preview.usedBy ?? []
|
|
1075
|
+
})),
|
|
911
1076
|
brands: graph.brands.map((brand) => ({ name: brand.name, file: brand.relativeFile })),
|
|
912
1077
|
audio: graph.audio.map((entry) => ({ name: entry.name, url: entry.url, file: entry.relativeFile }))
|
|
913
1078
|
},
|
|
@@ -921,7 +1086,7 @@ var writeGenerated = async (config, graph) => {
|
|
|
921
1086
|
};
|
|
922
1087
|
|
|
923
1088
|
// src/project.ts
|
|
924
|
-
import { resolve as
|
|
1089
|
+
import { resolve as resolve12 } from "path";
|
|
925
1090
|
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
926
1091
|
import {
|
|
927
1092
|
createRenderManifest,
|
|
@@ -931,45 +1096,45 @@ import {
|
|
|
931
1096
|
} from "odori";
|
|
932
1097
|
|
|
933
1098
|
// src/integrity.ts
|
|
934
|
-
import { createHash as
|
|
935
|
-
import { existsSync as
|
|
936
|
-
import { mkdir as
|
|
937
|
-
import { dirname as
|
|
938
|
-
var cacheFile = (config) =>
|
|
1099
|
+
import { createHash as createHash3 } from "crypto";
|
|
1100
|
+
import { existsSync as existsSync10 } from "fs";
|
|
1101
|
+
import { mkdir as mkdir8, readFile as readFile9, writeFile as writeFile9 } from "fs/promises";
|
|
1102
|
+
import { dirname as dirname4, resolve as resolve10 } from "path";
|
|
1103
|
+
var cacheFile = (config) => resolve10(config.root, config.outDir, "cache", "integrity.json");
|
|
939
1104
|
var readCache = async (config) => {
|
|
940
1105
|
const file = cacheFile(config);
|
|
941
|
-
if (!
|
|
1106
|
+
if (!existsSync10(file)) return {};
|
|
942
1107
|
try {
|
|
943
|
-
return JSON.parse(await
|
|
1108
|
+
return JSON.parse(await readFile9(file, "utf8"));
|
|
944
1109
|
} catch {
|
|
945
1110
|
return {};
|
|
946
1111
|
}
|
|
947
1112
|
};
|
|
948
1113
|
var writeCache = async (config, cache) => {
|
|
949
1114
|
const file = cacheFile(config);
|
|
950
|
-
await
|
|
951
|
-
await
|
|
1115
|
+
await mkdir8(dirname4(file), { recursive: true });
|
|
1116
|
+
await writeFile9(file, `${JSON.stringify(cache, null, 2)}
|
|
952
1117
|
`, "utf8");
|
|
953
1118
|
};
|
|
954
|
-
var sha256 = (bytes) => `sha256-${
|
|
1119
|
+
var sha256 = (bytes) => `sha256-${createHash3("sha256").update(bytes).digest("base64")}`;
|
|
955
1120
|
var localCandidates = (config, url) => [
|
|
956
|
-
|
|
957
|
-
|
|
1121
|
+
resolve10(config.root, "public", url.replace(/^\//, "")),
|
|
1122
|
+
resolve10(config.root, url.replace(/^\//, ""))
|
|
958
1123
|
];
|
|
959
|
-
var isServed = (config, file) => file.startsWith(
|
|
1124
|
+
var isServed = (config, file) => file.startsWith(resolve10(config.root, "public") + "/");
|
|
960
1125
|
var createIntegrityResolver = async (config) => {
|
|
961
1126
|
const cache = await readCache(config);
|
|
962
1127
|
const warned = /* @__PURE__ */ new Set();
|
|
963
1128
|
let dirty = false;
|
|
964
1129
|
const resolveIntegrity = async (url) => {
|
|
965
1130
|
if (url.startsWith("/__odori/cue/")) return `cue-${url.slice(url.lastIndexOf("-") + 1).replace(/\.wav$/, "")}`;
|
|
966
|
-
const local = localCandidates(config, url).find((candidate) =>
|
|
1131
|
+
const local = localCandidates(config, url).find((candidate) => existsSync10(candidate));
|
|
967
1132
|
if (local) {
|
|
968
1133
|
if (!isServed(config, local) && !warned.has(url)) {
|
|
969
1134
|
warned.add(url);
|
|
970
1135
|
log.warn(`${url} resolves to ${local}, which is outside public/ and will not be served. Move it into public/.`);
|
|
971
1136
|
}
|
|
972
|
-
const bytes = await
|
|
1137
|
+
const bytes = await readFile9(local);
|
|
973
1138
|
const { mtimeMs } = await import("fs/promises").then((fs) => fs.stat(local));
|
|
974
1139
|
const hit = cache[url];
|
|
975
1140
|
if (hit && hit.mtimeMs === mtimeMs && hit.size === bytes.byteLength) return hit.integrity;
|
|
@@ -1001,9 +1166,9 @@ var createIntegrityResolver = async (config) => {
|
|
|
1001
1166
|
};
|
|
1002
1167
|
|
|
1003
1168
|
// src/prepare-cache.ts
|
|
1004
|
-
import { existsSync as
|
|
1005
|
-
import { mkdir as
|
|
1006
|
-
import { join as join4, resolve as
|
|
1169
|
+
import { existsSync as existsSync11 } from "fs";
|
|
1170
|
+
import { mkdir as mkdir9, readFile as readFile10, readdir as readdir4, rm as rm2, writeFile as writeFile10 } from "fs/promises";
|
|
1171
|
+
import { join as join4, resolve as resolve11 } from "path";
|
|
1007
1172
|
import { hashValue } from "odori";
|
|
1008
1173
|
|
|
1009
1174
|
// src/paths.ts
|
|
@@ -1011,13 +1176,13 @@ var outputName = (id) => id.split("/").join("-");
|
|
|
1011
1176
|
var fileKey = (id) => id.split("/").join("+");
|
|
1012
1177
|
|
|
1013
1178
|
// src/prepare-cache.ts
|
|
1014
|
-
var directory = (config) =>
|
|
1179
|
+
var directory = (config) => resolve11(config.root, config.outDir, "cache", "prepare");
|
|
1015
1180
|
var prepareCacheKey = (key) => `${fileKey(key.videoId)}__${hashValue(key)}`;
|
|
1016
1181
|
var readPrepareCache = async (config, key) => {
|
|
1017
1182
|
const file = join4(directory(config), `${prepareCacheKey(key)}.json`);
|
|
1018
|
-
if (!
|
|
1183
|
+
if (!existsSync11(file)) return { hit: false, value: void 0 };
|
|
1019
1184
|
try {
|
|
1020
|
-
const entry = JSON.parse(await
|
|
1185
|
+
const entry = JSON.parse(await readFile10(file, "utf8"));
|
|
1021
1186
|
return { hit: true, value: entry.value };
|
|
1022
1187
|
} catch {
|
|
1023
1188
|
return { hit: false, value: void 0 };
|
|
@@ -1026,17 +1191,17 @@ var readPrepareCache = async (config, key) => {
|
|
|
1026
1191
|
var writePrepareCache = async (config, key, value) => {
|
|
1027
1192
|
if (value === void 0) return;
|
|
1028
1193
|
const target = directory(config);
|
|
1029
|
-
await
|
|
1194
|
+
await mkdir9(target, { recursive: true });
|
|
1030
1195
|
const entry = { key, value, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1031
|
-
await
|
|
1196
|
+
await writeFile10(join4(target, `${prepareCacheKey(key)}.json`), `${JSON.stringify(entry, null, 2)}
|
|
1032
1197
|
`, "utf8");
|
|
1033
1198
|
};
|
|
1034
1199
|
var clearPrepareCache = async (config, videoId) => {
|
|
1035
1200
|
const target = directory(config);
|
|
1036
|
-
if (!
|
|
1201
|
+
if (!existsSync11(target)) return 0;
|
|
1037
1202
|
const files = await readdir4(target);
|
|
1038
1203
|
const matches = files.filter((file) => videoId ? file.startsWith(`${fileKey(videoId)}__`) : file.endsWith(".json"));
|
|
1039
|
-
await Promise.all(matches.map((file) =>
|
|
1204
|
+
await Promise.all(matches.map((file) => rm2(join4(target, file), { force: true })));
|
|
1040
1205
|
return matches.length;
|
|
1041
1206
|
};
|
|
1042
1207
|
|
|
@@ -1080,7 +1245,7 @@ var findVideo = (videos, id) => {
|
|
|
1080
1245
|
return found;
|
|
1081
1246
|
};
|
|
1082
1247
|
var runPrepare = async (video, config, graph, input, options = {}) => {
|
|
1083
|
-
const prepareFile =
|
|
1248
|
+
const prepareFile = resolve12(video.file, "..", "prepare.ts");
|
|
1084
1249
|
let prepare;
|
|
1085
1250
|
try {
|
|
1086
1251
|
const module = await import(pathToFileURL2(prepareFile).href);
|
|
@@ -1162,22 +1327,135 @@ var freezeManifest = async (video, graph, config, rawInput, options = {}) => {
|
|
|
1162
1327
|
return { manifest, input, prepared };
|
|
1163
1328
|
};
|
|
1164
1329
|
|
|
1330
|
+
// src/formats.ts
|
|
1331
|
+
var QUALITIES = ["studio", "social", "web"];
|
|
1332
|
+
var scaleFilter = (scale) => `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2:flags=lanczos`;
|
|
1333
|
+
var scaleArgs = (scale) => scale === 1 ? [] : ["-vf", scaleFilter(scale)];
|
|
1334
|
+
var FORMATS = {
|
|
1335
|
+
mp4: {
|
|
1336
|
+
name: "mp4",
|
|
1337
|
+
extension: ".mp4",
|
|
1338
|
+
alpha: false,
|
|
1339
|
+
chunked: true,
|
|
1340
|
+
audio: true,
|
|
1341
|
+
description: "H.264 in MP4. Plays everywhere; the default.",
|
|
1342
|
+
args: ({ preset, quality, scale }) => [
|
|
1343
|
+
...scaleArgs(scale),
|
|
1344
|
+
"-c:v",
|
|
1345
|
+
"libx264",
|
|
1346
|
+
"-crf",
|
|
1347
|
+
{ studio: "17", social: "21", web: "27" }[quality],
|
|
1348
|
+
"-preset",
|
|
1349
|
+
preset,
|
|
1350
|
+
"-pix_fmt",
|
|
1351
|
+
"yuv420p"
|
|
1352
|
+
]
|
|
1353
|
+
},
|
|
1354
|
+
webm: {
|
|
1355
|
+
name: "webm",
|
|
1356
|
+
extension: ".webm",
|
|
1357
|
+
alpha: true,
|
|
1358
|
+
// VP9 in WebM concatenates cleanly through the demuxer, same as H.264.
|
|
1359
|
+
chunked: true,
|
|
1360
|
+
audio: true,
|
|
1361
|
+
description: "VP9 in WebM, with alpha. For the web, and for overlays.",
|
|
1362
|
+
args: ({ quality, scale }) => [
|
|
1363
|
+
...scaleArgs(scale),
|
|
1364
|
+
"-c:v",
|
|
1365
|
+
"libvpx-vp9",
|
|
1366
|
+
"-crf",
|
|
1367
|
+
{ studio: "24", social: "31", web: "38" }[quality],
|
|
1368
|
+
"-b:v",
|
|
1369
|
+
"0",
|
|
1370
|
+
"-pix_fmt",
|
|
1371
|
+
"yuva420p",
|
|
1372
|
+
"-row-mt",
|
|
1373
|
+
"1"
|
|
1374
|
+
]
|
|
1375
|
+
},
|
|
1376
|
+
prores: {
|
|
1377
|
+
name: "prores",
|
|
1378
|
+
extension: ".mov",
|
|
1379
|
+
alpha: true,
|
|
1380
|
+
chunked: true,
|
|
1381
|
+
audio: true,
|
|
1382
|
+
description: "ProRes 4444 in MOV, with alpha. For handing to an editor.",
|
|
1383
|
+
// Quality is the profile here, and the profile is the point: an editor
|
|
1384
|
+
// format that quietly compressed would defeat its own reason to exist.
|
|
1385
|
+
args: ({ scale }) => [
|
|
1386
|
+
...scaleArgs(scale),
|
|
1387
|
+
"-c:v",
|
|
1388
|
+
"prores_ks",
|
|
1389
|
+
"-profile:v",
|
|
1390
|
+
"4444",
|
|
1391
|
+
"-pix_fmt",
|
|
1392
|
+
"yuva444p10le",
|
|
1393
|
+
"-alpha_bits",
|
|
1394
|
+
"8"
|
|
1395
|
+
]
|
|
1396
|
+
},
|
|
1397
|
+
gif: {
|
|
1398
|
+
name: "gif",
|
|
1399
|
+
extension: ".gif",
|
|
1400
|
+
alpha: false,
|
|
1401
|
+
// A GIF's palette is computed across the whole animation, so chunks would
|
|
1402
|
+
// each invent their own and the result would flicker between them.
|
|
1403
|
+
chunked: false,
|
|
1404
|
+
audio: false,
|
|
1405
|
+
description: "An animated GIF, palette optimised. Silent, by the format.",
|
|
1406
|
+
// The palette pass is a filter graph already, so scaling joins it rather
|
|
1407
|
+
// than adding a second -vf that ffmpeg would silently drop.
|
|
1408
|
+
args: ({ scale }) => [
|
|
1409
|
+
"-vf",
|
|
1410
|
+
`${scale === 1 ? "" : `${scaleFilter(scale)},`}split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3`,
|
|
1411
|
+
"-loop",
|
|
1412
|
+
"0"
|
|
1413
|
+
]
|
|
1414
|
+
},
|
|
1415
|
+
png: {
|
|
1416
|
+
name: "png",
|
|
1417
|
+
extension: ".png",
|
|
1418
|
+
alpha: true,
|
|
1419
|
+
chunked: false,
|
|
1420
|
+
audio: false,
|
|
1421
|
+
description: "A numbered PNG sequence, with alpha. For a compositor.",
|
|
1422
|
+
args: ({ scale }) => [...scaleArgs(scale), "-c:v", "png", "-pix_fmt", "rgba"]
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
var formatNames = () => Object.keys(FORMATS);
|
|
1426
|
+
var resolveFormat = (requested, output) => {
|
|
1427
|
+
if (requested) {
|
|
1428
|
+
const format = FORMATS[requested.toLowerCase()];
|
|
1429
|
+
if (!format) {
|
|
1430
|
+
throw new Error(`Unknown format "${requested}". Available: ${formatNames().join(", ")}`);
|
|
1431
|
+
}
|
|
1432
|
+
return format;
|
|
1433
|
+
}
|
|
1434
|
+
if (output) {
|
|
1435
|
+
const extension = output.slice(output.lastIndexOf(".")).toLowerCase();
|
|
1436
|
+
const matched = Object.values(FORMATS).find((format) => format.extension === extension);
|
|
1437
|
+
if (matched) return matched;
|
|
1438
|
+
}
|
|
1439
|
+
return FORMATS.mp4;
|
|
1440
|
+
};
|
|
1441
|
+
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;
|
|
1442
|
+
|
|
1165
1443
|
// src/render.ts
|
|
1166
1444
|
import { spawn as spawn2 } from "child_process";
|
|
1167
|
-
import { copyFile as copyFile2, mkdir as
|
|
1445
|
+
import { copyFile as copyFile2, mkdir as mkdir12, rm as rm3, writeFile as writeFile13 } from "fs/promises";
|
|
1168
1446
|
import { cpus } from "os";
|
|
1169
|
-
import { dirname as
|
|
1447
|
+
import { dirname as dirname5, join as join6, resolve as resolve16 } from "path";
|
|
1170
1448
|
import { chromium } from "playwright-core";
|
|
1171
1449
|
|
|
1172
1450
|
// src/audio-mix.ts
|
|
1173
|
-
import { existsSync as
|
|
1174
|
-
import { resolve as
|
|
1451
|
+
import { existsSync as existsSync13 } from "fs";
|
|
1452
|
+
import { resolve as resolve14 } from "path";
|
|
1175
1453
|
import { duckEnvelope, envelopeAtFrame } from "odori";
|
|
1176
1454
|
|
|
1177
1455
|
// src/cues.ts
|
|
1178
|
-
import { existsSync as
|
|
1179
|
-
import { mkdir as
|
|
1180
|
-
import { basename, resolve as
|
|
1456
|
+
import { existsSync as existsSync12, statSync } from "fs";
|
|
1457
|
+
import { mkdir as mkdir10, writeFile as writeFile11 } from "fs/promises";
|
|
1458
|
+
import { basename, resolve as resolve13 } from "path";
|
|
1181
1459
|
import { pathToFileURL as pathToFileURL3 } from "url";
|
|
1182
1460
|
import {
|
|
1183
1461
|
SAMPLE_RATE,
|
|
@@ -1188,8 +1466,8 @@ import {
|
|
|
1188
1466
|
isCueDefinition,
|
|
1189
1467
|
resolveEntryLayout as resolveEntryLayout2
|
|
1190
1468
|
} from "odori";
|
|
1191
|
-
var cueCacheDir = (config) =>
|
|
1192
|
-
var cueFile = (config, url) =>
|
|
1469
|
+
var cueCacheDir = (config) => resolve13(config.root, config.outDir, "cues");
|
|
1470
|
+
var cueFile = (config, url) => resolve13(cueCacheDir(config), basename(url));
|
|
1193
1471
|
var materializeCues = async (config, brands, fps) => {
|
|
1194
1472
|
const seen = /* @__PURE__ */ new Map();
|
|
1195
1473
|
for (const brand of brands) {
|
|
@@ -1198,17 +1476,17 @@ var materializeCues = async (config, brands, fps) => {
|
|
|
1198
1476
|
}
|
|
1199
1477
|
}
|
|
1200
1478
|
if (seen.size === 0) return [];
|
|
1201
|
-
await
|
|
1479
|
+
await mkdir10(cueCacheDir(config), { recursive: true });
|
|
1202
1480
|
const written = [];
|
|
1203
1481
|
for (const [url, cue] of seen) {
|
|
1204
1482
|
const file = cueFile(config, url);
|
|
1205
|
-
if (
|
|
1483
|
+
if (existsSync12(file)) {
|
|
1206
1484
|
written.push({ cue, file, rendered: false });
|
|
1207
1485
|
continue;
|
|
1208
1486
|
}
|
|
1209
1487
|
const samples = cueSamples(cue, fps);
|
|
1210
1488
|
const signal = cue.render({ samples, sampleRate: SAMPLE_RATE });
|
|
1211
|
-
await
|
|
1489
|
+
await writeFile11(file, encodeWav(signal));
|
|
1212
1490
|
written.push({ cue, file, rendered: true });
|
|
1213
1491
|
}
|
|
1214
1492
|
return written;
|
|
@@ -1234,23 +1512,28 @@ var renderedCue = (url) => {
|
|
|
1234
1512
|
return wav;
|
|
1235
1513
|
};
|
|
1236
1514
|
var isBrand = (value) => typeof value === "object" && value !== null && value.kind === "odori-brand";
|
|
1515
|
+
var isLayout = (value) => typeof value === "object" && value !== null && value.kind === "odori-layout";
|
|
1237
1516
|
var importFresh = async (file) => await import(`${pathToFileURL3(file).href}?odori=${statSync(file).mtimeMs}`);
|
|
1238
|
-
var registerProjectCues = async (graph) => {
|
|
1517
|
+
var registerProjectCues = async (graph, load = importFresh) => {
|
|
1239
1518
|
for (const discovered of graph.brands) {
|
|
1240
1519
|
try {
|
|
1241
|
-
const
|
|
1242
|
-
registerCues(
|
|
1520
|
+
const values = Object.values(await load(discovered.file));
|
|
1521
|
+
registerCues(values.filter(isBrand), defaultLayout.format.fps);
|
|
1522
|
+
for (const layout of values.filter(isLayout)) registerCues([layout.brand], layout.format.fps);
|
|
1243
1523
|
} catch (error) {
|
|
1244
1524
|
log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
|
|
1245
1525
|
}
|
|
1246
1526
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
const
|
|
1527
|
+
for (const video of graph.videos) {
|
|
1528
|
+
try {
|
|
1529
|
+
const module = await load(video.file);
|
|
1530
|
+
if (!module.default || !module.metadata) continue;
|
|
1531
|
+
const entry = { component: module.default, metadata: module.metadata };
|
|
1532
|
+
const layout = resolveEntryLayout2(entry);
|
|
1250
1533
|
registerCues([layout.brand], layout.format.fps);
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
|
|
1251
1536
|
}
|
|
1252
|
-
} catch (error) {
|
|
1253
|
-
log.warn(`[odori] generated cues may use the default frame rate: ${message(error)}`);
|
|
1254
1537
|
}
|
|
1255
1538
|
return known.size;
|
|
1256
1539
|
};
|
|
@@ -1261,13 +1544,13 @@ var resolveCueFile = (config, src) => {
|
|
|
1261
1544
|
if (/^https?:\/\//.test(src)) return null;
|
|
1262
1545
|
if (src.startsWith("/__odori/cue/")) {
|
|
1263
1546
|
const generated = cueFile(config, src);
|
|
1264
|
-
return
|
|
1547
|
+
return existsSync13(generated) ? generated : null;
|
|
1265
1548
|
}
|
|
1266
1549
|
const candidates = [
|
|
1267
|
-
|
|
1268
|
-
|
|
1550
|
+
resolve14(config.root, "public", src.replace(/^\//, "")),
|
|
1551
|
+
resolve14(config.root, src.replace(/^\//, ""))
|
|
1269
1552
|
];
|
|
1270
|
-
return candidates.find((candidate) =>
|
|
1553
|
+
return candidates.find((candidate) => existsSync13(candidate)) ?? null;
|
|
1271
1554
|
};
|
|
1272
1555
|
var volumeFilter = (cue, cues, fps) => {
|
|
1273
1556
|
const authored = cue.gainPoints ?? [];
|
|
@@ -1387,11 +1670,11 @@ var planChunks = ({
|
|
|
1387
1670
|
var chunkFrames = (chunk) => Array.from({ length: length(chunk) }, (_, offset) => chunk.start + offset);
|
|
1388
1671
|
|
|
1389
1672
|
// src/chunk-cache.ts
|
|
1390
|
-
import { existsSync as
|
|
1391
|
-
import { copyFile, mkdir as
|
|
1392
|
-
import { join as join5, resolve as
|
|
1673
|
+
import { existsSync as existsSync14 } from "fs";
|
|
1674
|
+
import { copyFile, mkdir as mkdir11, readFile as readFile11, writeFile as writeFile12 } from "fs/promises";
|
|
1675
|
+
import { join as join5, resolve as resolve15 } from "path";
|
|
1393
1676
|
import { hashValue as hashValue2 } from "odori";
|
|
1394
|
-
var cacheDir2 = (config) =>
|
|
1677
|
+
var cacheDir2 = (config) => resolve15(config.root, config.outDir, "cache", "chunks");
|
|
1395
1678
|
var chunkKey = (identity) => hashValue2({
|
|
1396
1679
|
videoId: identity.videoId,
|
|
1397
1680
|
// The browser that drew the frames is part of what the frames are. Without
|
|
@@ -1409,14 +1692,16 @@ var chunkKey = (identity) => hashValue2({
|
|
|
1409
1692
|
height: identity.height,
|
|
1410
1693
|
fps: identity.fps,
|
|
1411
1694
|
preset: identity.preset,
|
|
1695
|
+
quality: identity.quality ?? null,
|
|
1696
|
+
scale: identity.scale ?? null,
|
|
1412
1697
|
input: identity.input ?? null
|
|
1413
1698
|
});
|
|
1414
1699
|
var readChunkRecord = async (config, key) => {
|
|
1415
1700
|
const meta = join5(cacheDir2(config), `${key}.json`);
|
|
1416
1701
|
const media = join5(cacheDir2(config), `${key}.mp4`);
|
|
1417
|
-
if (!
|
|
1702
|
+
if (!existsSync14(meta) || !existsSync14(media)) return null;
|
|
1418
1703
|
try {
|
|
1419
|
-
return JSON.parse(await
|
|
1704
|
+
return JSON.parse(await readFile11(meta, "utf8"));
|
|
1420
1705
|
} catch {
|
|
1421
1706
|
return null;
|
|
1422
1707
|
}
|
|
@@ -1426,88 +1711,14 @@ var useChunkRecord = async (config, key, destination) => {
|
|
|
1426
1711
|
};
|
|
1427
1712
|
var writeChunkRecord = async (config, key, signatures, file) => {
|
|
1428
1713
|
const directory2 = cacheDir2(config);
|
|
1429
|
-
await
|
|
1714
|
+
await mkdir11(directory2, { recursive: true });
|
|
1430
1715
|
await copyFile(file, join5(directory2, `${key}.mp4`));
|
|
1431
1716
|
const record = { key, signatures, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1432
|
-
await
|
|
1717
|
+
await writeFile12(join5(directory2, `${key}.json`), `${JSON.stringify(record)}
|
|
1433
1718
|
`, "utf8");
|
|
1434
1719
|
};
|
|
1435
1720
|
var signaturesMatch = (recorded, observed) => recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
|
|
1436
1721
|
|
|
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
1722
|
// src/render.ts
|
|
1512
1723
|
var encodeParam = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
|
1513
1724
|
var renderUrl = (origin, target, frame) => {
|
|
@@ -1651,7 +1862,7 @@ var ensureFfmpeg = async (config) => {
|
|
|
1651
1862
|
var renderStill = async (origin, target, frame, output, config) => {
|
|
1652
1863
|
const { browser, page, errors } = await openRenderPage(origin, target, config);
|
|
1653
1864
|
try {
|
|
1654
|
-
await
|
|
1865
|
+
await mkdir12(dirname5(output), { recursive: true });
|
|
1655
1866
|
await seekTo(page, frame);
|
|
1656
1867
|
await page.screenshot({ path: output });
|
|
1657
1868
|
if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
|
|
@@ -1675,10 +1886,19 @@ var sequencePattern = (output) => {
|
|
|
1675
1886
|
const extension = dot > 0 ? output.slice(dot) : ".png";
|
|
1676
1887
|
return join6(stem, `%05d${extension}`);
|
|
1677
1888
|
};
|
|
1678
|
-
var
|
|
1889
|
+
var LOSSLESS = {
|
|
1890
|
+
name: "lossless",
|
|
1891
|
+
extension: ".mkv",
|
|
1892
|
+
alpha: true,
|
|
1893
|
+
chunked: true,
|
|
1894
|
+
audio: false,
|
|
1895
|
+
description: "FFV1 in MKV, the lossless intermediate for one-pass formats.",
|
|
1896
|
+
args: () => ["-c:v", "ffv1", "-level", "3"]
|
|
1897
|
+
};
|
|
1898
|
+
var openChunkEncoder = (ffmpeg, file, fps, encode, format, signal) => {
|
|
1679
1899
|
const child = spawn2(
|
|
1680
1900
|
ffmpeg,
|
|
1681
|
-
["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(
|
|
1901
|
+
["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(encode), file],
|
|
1682
1902
|
{ stdio: ["pipe", "ignore", "pipe"] }
|
|
1683
1903
|
);
|
|
1684
1904
|
let stderr = "";
|
|
@@ -1728,7 +1948,7 @@ var captureLane = async (origin, target, config, lane, stats, options) => {
|
|
|
1728
1948
|
}
|
|
1729
1949
|
}
|
|
1730
1950
|
const file = options.chunkFile(chunk);
|
|
1731
|
-
const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.
|
|
1951
|
+
const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.encode, options.format, options.signal);
|
|
1732
1952
|
const signatures = [];
|
|
1733
1953
|
let previousSignature = null;
|
|
1734
1954
|
let previousFrame = null;
|
|
@@ -1788,13 +2008,18 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
|
|
|
1788
2008
|
const browserPath = await browserExecutable(config);
|
|
1789
2009
|
const renderer = (await resolveBrowser(config))?.version ?? browserPath;
|
|
1790
2010
|
const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
|
|
1791
|
-
const
|
|
2011
|
+
const encode = {
|
|
2012
|
+
preset: options.preset ?? config.preset ?? "medium",
|
|
2013
|
+
quality: options.quality ?? "studio",
|
|
2014
|
+
scale: options.scale ?? 1
|
|
2015
|
+
};
|
|
1792
2016
|
const format = options.format ?? FORMATS.mp4;
|
|
1793
2017
|
const chunkable = format.chunked;
|
|
2018
|
+
const chunkFormat = chunkable ? format : LOSSLESS;
|
|
1794
2019
|
const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
|
|
1795
2020
|
const cache = options.cache ?? config.cacheChunks ?? true;
|
|
1796
|
-
const work = options.workDir ??
|
|
1797
|
-
await
|
|
2021
|
+
const work = options.workDir ?? resolve16(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
|
|
2022
|
+
await mkdir12(work, { recursive: true });
|
|
1798
2023
|
const concurrency = chunkable ? requested : 1;
|
|
1799
2024
|
const { chunks, lanes } = planChunks({
|
|
1800
2025
|
durationInFrames: target.durationInFrames,
|
|
@@ -1805,15 +2030,15 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
|
|
|
1805
2030
|
const stats = { captured: 0, reused: 0, cachedChunks: 0 };
|
|
1806
2031
|
let succeeded = false;
|
|
1807
2032
|
try {
|
|
1808
|
-
await
|
|
2033
|
+
await mkdir12(dirname5(output), { recursive: true });
|
|
1809
2034
|
const captureStart = performance.now();
|
|
1810
2035
|
await Promise.all(
|
|
1811
2036
|
lanes.map(
|
|
1812
2037
|
(lane) => captureLane(origin, target, config, lane, stats, {
|
|
1813
2038
|
skipUnchanged,
|
|
1814
2039
|
signal: options.signal,
|
|
1815
|
-
|
|
1816
|
-
format,
|
|
2040
|
+
encode,
|
|
2041
|
+
format: chunkFormat,
|
|
1817
2042
|
ffmpeg,
|
|
1818
2043
|
workDir: work,
|
|
1819
2044
|
chunkFile,
|
|
@@ -1821,11 +2046,17 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
|
|
|
1821
2046
|
videoId: target.videoId,
|
|
1822
2047
|
chunk,
|
|
1823
2048
|
renderer,
|
|
1824
|
-
format:
|
|
2049
|
+
format: chunkFormat.name,
|
|
1825
2050
|
width: target.width,
|
|
1826
2051
|
height: target.height,
|
|
1827
2052
|
fps: target.fps,
|
|
1828
|
-
preset,
|
|
2053
|
+
preset: encode.preset,
|
|
2054
|
+
// Both change the encoded bytes, so both are part of what a
|
|
2055
|
+
// chunk is: a half-size chunk must never answer for a full
|
|
2056
|
+
// one. A lossless intermediate is the exception by design —
|
|
2057
|
+
// the final pass applies them, so one capture serves all.
|
|
2058
|
+
quality: chunkable ? encode.quality : void 0,
|
|
2059
|
+
scale: chunkable ? encode.scale : void 0,
|
|
1829
2060
|
input: target.input
|
|
1830
2061
|
}) : void 0,
|
|
1831
2062
|
onFrame: () => onProgress?.(stats.captured / Math.max(1, target.durationInFrames), "rendering")
|
|
@@ -1846,13 +2077,13 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
|
|
|
1846
2077
|
await copyFile2(ordered[0], silent);
|
|
1847
2078
|
} else {
|
|
1848
2079
|
const list = join6(work, "chunks.txt");
|
|
1849
|
-
await
|
|
2080
|
+
await writeFile13(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
|
|
1850
2081
|
await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
|
|
1851
2082
|
}
|
|
1852
2083
|
if (!chunkable) {
|
|
1853
2084
|
const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
|
|
1854
|
-
if (destination !== output) await
|
|
1855
|
-
await run(ffmpeg, ["-y", "-i", silent, ...format.args(
|
|
2085
|
+
if (destination !== output) await mkdir12(dirname5(destination), { recursive: true });
|
|
2086
|
+
await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
|
|
1856
2087
|
if (mixInputs.length > 0) {
|
|
1857
2088
|
log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
|
|
1858
2089
|
}
|
|
@@ -1905,7 +2136,7 @@ var renderMovie = async (origin, target, output, config, onProgress, options = {
|
|
|
1905
2136
|
succeeded = true;
|
|
1906
2137
|
return output;
|
|
1907
2138
|
} finally {
|
|
1908
|
-
if (succeeded && !options.workDir) await
|
|
2139
|
+
if (succeeded && !options.workDir) await rm3(work, { recursive: true, force: true });
|
|
1909
2140
|
else if (!succeeded) log.detail(`Chunks left for inspection in ${work}`);
|
|
1910
2141
|
}
|
|
1911
2142
|
};
|
|
@@ -1937,25 +2168,25 @@ var openInBrowser = (url) => {
|
|
|
1937
2168
|
};
|
|
1938
2169
|
|
|
1939
2170
|
// src/server.ts
|
|
1940
|
-
import { existsSync as
|
|
2171
|
+
import { existsSync as existsSync15 } from "fs";
|
|
1941
2172
|
import { createRequire as createRequire2 } from "module";
|
|
1942
2173
|
import { fileURLToPath } from "url";
|
|
1943
2174
|
import { createServer } from "vite";
|
|
1944
2175
|
import react from "@vitejs/plugin-react";
|
|
1945
|
-
import { readFile as
|
|
1946
|
-
import { dirname as
|
|
1947
|
-
var cliRoot =
|
|
1948
|
-
var studioRoot =
|
|
1949
|
-
var studioEntry =
|
|
1950
|
-
var installRoot =
|
|
2176
|
+
import { readFile as readFile12 } from "fs/promises";
|
|
2177
|
+
import { dirname as dirname6, resolve as resolve17, sep as sep3 } from "path";
|
|
2178
|
+
var cliRoot = resolve17(dirname6(fileURLToPath(import.meta.url)), "..");
|
|
2179
|
+
var studioRoot = resolve17(cliRoot, "studio");
|
|
2180
|
+
var studioEntry = resolve17(studioRoot, "index.html");
|
|
2181
|
+
var installRoot = resolve17(cliRoot, "..", "..");
|
|
1951
2182
|
var VIRTUAL_ID = "virtual:odori-project";
|
|
1952
2183
|
var RESOLVED_ID = `\0${VIRTUAL_ID}`;
|
|
1953
2184
|
var runtimeSource = (root) => {
|
|
1954
|
-
for (const from of [
|
|
2185
|
+
for (const from of [resolve17(root, "package.json"), import.meta.url]) {
|
|
1955
2186
|
try {
|
|
1956
2187
|
const manifest = createRequire2(from).resolve("odori/package.json");
|
|
1957
|
-
const src =
|
|
1958
|
-
if (
|
|
2188
|
+
const src = resolve17(manifest, "..", "src");
|
|
2189
|
+
if (existsSync15(resolve17(src, "index.tsx"))) return src;
|
|
1959
2190
|
} catch {
|
|
1960
2191
|
}
|
|
1961
2192
|
}
|
|
@@ -1994,6 +2225,8 @@ var odoriProjectPlugin = (config, getGraph) => ({
|
|
|
1994
2225
|
`export const project = ${JSON.stringify({
|
|
1995
2226
|
root: config.root,
|
|
1996
2227
|
videosDir: config.videosDir,
|
|
2228
|
+
componentsDir: config.componentsDir,
|
|
2229
|
+
categories: graph.categories,
|
|
1997
2230
|
exportDir: config.exportDir,
|
|
1998
2231
|
audioDir: config.audioDir,
|
|
1999
2232
|
docsUrl: config.docsUrl,
|
|
@@ -2002,7 +2235,11 @@ var odoriProjectPlugin = (config, getGraph) => ({
|
|
|
2002
2235
|
assets: config.assets ?? [],
|
|
2003
2236
|
files: {
|
|
2004
2237
|
videos: graph.videos.map((video) => ({ id: video.slug, file: video.relativeFile })),
|
|
2005
|
-
previews: graph.previews.map((preview) => ({
|
|
2238
|
+
previews: graph.previews.map((preview) => ({
|
|
2239
|
+
id: preview.name,
|
|
2240
|
+
file: preview.relativeFile,
|
|
2241
|
+
usedBy: preview.usedBy ?? []
|
|
2242
|
+
})),
|
|
2006
2243
|
brands: graph.brands.map((brand) => ({ id: brand.name, file: brand.relativeFile }))
|
|
2007
2244
|
}
|
|
2008
2245
|
})};`
|
|
@@ -2014,7 +2251,6 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2014
2251
|
const odoriSrc = runtimeSource(config.root);
|
|
2015
2252
|
let graph = await discoverProject(config);
|
|
2016
2253
|
await writeGenerated(config, graph);
|
|
2017
|
-
await registerProjectCues(graph);
|
|
2018
2254
|
const vite = await createServer({
|
|
2019
2255
|
root: studioRoot,
|
|
2020
2256
|
configFile: false,
|
|
@@ -2073,15 +2309,15 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2073
2309
|
],
|
|
2074
2310
|
// The project's public/ directory is served at the root, so brand fonts,
|
|
2075
2311
|
// logos, and footage resolve identically in preview and render.
|
|
2076
|
-
publicDir:
|
|
2312
|
+
publicDir: existsSync15(resolve17(config.root, "public")) ? resolve17(config.root, "public") : false,
|
|
2077
2313
|
resolve: {
|
|
2078
2314
|
dedupe: ["react", "react-dom", "odori"],
|
|
2079
2315
|
// Only when the runtime is present as source. A consumer resolves the
|
|
2080
2316
|
// published package through its exports map instead.
|
|
2081
2317
|
alias: odoriSrc ? [
|
|
2082
|
-
{ find: /^odori\/preview$/, replacement:
|
|
2083
|
-
{ find: /^odori\/manifest$/, replacement:
|
|
2084
|
-
{ find: /^odori$/, replacement:
|
|
2318
|
+
{ find: /^odori\/preview$/, replacement: resolve17(odoriSrc, "preview.ts") },
|
|
2319
|
+
{ find: /^odori\/manifest$/, replacement: resolve17(odoriSrc, "manifest.ts") },
|
|
2320
|
+
{ find: /^odori$/, replacement: resolve17(odoriSrc, "index.tsx") }
|
|
2085
2321
|
] : []
|
|
2086
2322
|
},
|
|
2087
2323
|
server: {
|
|
@@ -2093,14 +2329,31 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2093
2329
|
},
|
|
2094
2330
|
optimizeDeps: { include: ["react", "react-dom", "react/jsx-dev-runtime"] }
|
|
2095
2331
|
});
|
|
2332
|
+
const loadModule = (file) => vite.ssrLoadModule(`/@fs${file}`);
|
|
2333
|
+
await registerProjectCues(graph, loadModule);
|
|
2334
|
+
let refreshTimer;
|
|
2335
|
+
let refreshChain = Promise.resolve();
|
|
2336
|
+
const enqueueRefresh = (task) => {
|
|
2337
|
+
const next = refreshChain.then(task, task);
|
|
2338
|
+
refreshChain = next.catch(() => void 0);
|
|
2339
|
+
return next;
|
|
2340
|
+
};
|
|
2341
|
+
const scheduleCueRefresh = () => {
|
|
2342
|
+
clearTimeout(refreshTimer);
|
|
2343
|
+
refreshTimer = setTimeout(() => {
|
|
2344
|
+
void enqueueRefresh(() => registerProjectCues(graph, loadModule)).catch((error) => {
|
|
2345
|
+
vite.config.logger.warn(`[odori] cue refresh skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
2346
|
+
});
|
|
2347
|
+
}, 150);
|
|
2348
|
+
};
|
|
2096
2349
|
const rediscover = async (file) => {
|
|
2097
|
-
if (!file.startsWith(
|
|
2098
|
-
const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${
|
|
2350
|
+
if (!file.startsWith(resolve17(config.root, config.videosDir))) return;
|
|
2351
|
+
const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep3}brands${sep3}`);
|
|
2099
2352
|
if (!isEntry) return;
|
|
2100
2353
|
try {
|
|
2101
2354
|
graph = await discoverProject(config);
|
|
2102
2355
|
await writeGenerated(config, graph);
|
|
2103
|
-
await registerProjectCues(graph);
|
|
2356
|
+
await enqueueRefresh(() => registerProjectCues(graph, loadModule));
|
|
2104
2357
|
} catch (error) {
|
|
2105
2358
|
vite.config.logger.warn(`[odori] rediscovery skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
2106
2359
|
return;
|
|
@@ -2111,14 +2364,14 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2111
2364
|
};
|
|
2112
2365
|
vite.watcher.on("add", (file) => void rediscover(file));
|
|
2113
2366
|
vite.watcher.on("unlink", (file) => void rediscover(file));
|
|
2114
|
-
vite.watcher.add(
|
|
2367
|
+
vite.watcher.add(resolve17(config.root, config.videosDir));
|
|
2115
2368
|
const reloadConfig = async (file) => {
|
|
2116
2369
|
if (!/odori\.config\.(?:ts|mjs|js)$/.test(file)) return;
|
|
2117
2370
|
try {
|
|
2118
2371
|
config = await loadConfig(config.root);
|
|
2119
2372
|
graph = await discoverProject(config);
|
|
2120
2373
|
await writeGenerated(config, graph);
|
|
2121
|
-
await registerProjectCues(graph);
|
|
2374
|
+
await enqueueRefresh(() => registerProjectCues(graph, loadModule));
|
|
2122
2375
|
} catch (error) {
|
|
2123
2376
|
vite.config.logger.warn(`[odori] config reload skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
2124
2377
|
return;
|
|
@@ -2127,14 +2380,15 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2127
2380
|
if (module) vite.moduleGraph.invalidateModule(module);
|
|
2128
2381
|
vite.ws.send({ type: "full-reload" });
|
|
2129
2382
|
};
|
|
2130
|
-
const refreshCues =
|
|
2131
|
-
if (!file.startsWith(
|
|
2132
|
-
|
|
2383
|
+
const refreshCues = (file) => {
|
|
2384
|
+
if (!file.startsWith(resolve17(config.root, config.videosDir) + sep3)) return;
|
|
2385
|
+
if (!/\.tsx?$/.test(file)) return;
|
|
2386
|
+
scheduleCueRefresh();
|
|
2133
2387
|
};
|
|
2134
|
-
vite.watcher.on("change", (file) =>
|
|
2388
|
+
vite.watcher.on("change", (file) => refreshCues(file));
|
|
2135
2389
|
vite.watcher.on("change", (file) => void reloadConfig(file));
|
|
2136
2390
|
for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
|
|
2137
|
-
vite.watcher.add(
|
|
2391
|
+
vite.watcher.add(resolve17(config.root, name));
|
|
2138
2392
|
}
|
|
2139
2393
|
vite.middlewares.use(async (request, response, next) => {
|
|
2140
2394
|
const url = (request.url ?? "/").split("?")[0];
|
|
@@ -2144,7 +2398,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2144
2398
|
return;
|
|
2145
2399
|
}
|
|
2146
2400
|
try {
|
|
2147
|
-
const html = await
|
|
2401
|
+
const html = await readFile12(studioEntry, "utf8");
|
|
2148
2402
|
response.statusCode = 200;
|
|
2149
2403
|
response.setHeader("content-type", "text/html");
|
|
2150
2404
|
response.end(await vite.transformIndexHtml(url, html));
|
|
@@ -2165,7 +2419,7 @@ var startStudioServer = async (initialConfig, options = {}) => {
|
|
|
2165
2419
|
};
|
|
2166
2420
|
|
|
2167
2421
|
// src/commands/exportVideo.ts
|
|
2168
|
-
import { resolve as
|
|
2422
|
+
import { resolve as resolve18 } from "path";
|
|
2169
2423
|
import { resolveEntryLayout as resolveEntryLayout4 } from "odori";
|
|
2170
2424
|
|
|
2171
2425
|
// src/commands/shared.ts
|
|
@@ -2274,8 +2528,12 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
|
|
|
2274
2528
|
},
|
|
2275
2529
|
{
|
|
2276
2530
|
concurrency: options.concurrency,
|
|
2277
|
-
|
|
2278
|
-
|
|
2531
|
+
// The record freezes how it should be encoded alongside what, so a
|
|
2532
|
+
// retry from any surface produces the same file the first run would.
|
|
2533
|
+
preset: options.preset ?? record.render?.preset,
|
|
2534
|
+
quality: options.quality ?? record.render?.quality,
|
|
2535
|
+
scale: options.scale ?? record.render?.scale,
|
|
2536
|
+
format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : void 0),
|
|
2279
2537
|
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
2280
2538
|
signal: controller.signal,
|
|
2281
2539
|
onTimings: (timings) => {
|
|
@@ -2302,9 +2560,23 @@ var runJob = async (config, origin, record, video, options = {}) => exportQueue.
|
|
|
2302
2560
|
running.delete(record.job.id);
|
|
2303
2561
|
}
|
|
2304
2562
|
});
|
|
2563
|
+
var resolveQuality = (requested) => {
|
|
2564
|
+
if (!requested) return "studio";
|
|
2565
|
+
if (QUALITIES.includes(requested)) return requested;
|
|
2566
|
+
throw new Error(`Unknown quality "${requested}". Available: ${QUALITIES.join(", ")}`);
|
|
2567
|
+
};
|
|
2568
|
+
var resolveScale = (requested) => {
|
|
2569
|
+
if (requested === void 0) return 1;
|
|
2570
|
+
if (!Number.isFinite(requested) || requested < 0.25 || requested > 2) {
|
|
2571
|
+
throw new Error(`Scale ${requested} is out of range. Use a value between 0.25 and 2.`);
|
|
2572
|
+
}
|
|
2573
|
+
return requested;
|
|
2574
|
+
};
|
|
2305
2575
|
var exportCommand = async (id, options = {}) => {
|
|
2306
2576
|
const { config, graph, videos } = await createContext();
|
|
2307
2577
|
const format = resolveFormat(options.format ?? config.format, options.output);
|
|
2578
|
+
const quality = resolveQuality(options.quality);
|
|
2579
|
+
const scale = resolveScale(options.scale);
|
|
2308
2580
|
return withServer(config, async (server) => {
|
|
2309
2581
|
const record = options.retry ? await readJob(config, options.retry) : await (async () => {
|
|
2310
2582
|
const video2 = findVideo(videos, id);
|
|
@@ -2316,11 +2588,16 @@ var exportCommand = async (id, options = {}) => {
|
|
|
2316
2588
|
options.input ?? {},
|
|
2317
2589
|
{ scenes: compiled.scenes, audio: compiled.audio }
|
|
2318
2590
|
);
|
|
2319
|
-
const output =
|
|
2591
|
+
const output = resolve18(
|
|
2320
2592
|
config.root,
|
|
2321
2593
|
options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
|
|
2322
2594
|
);
|
|
2323
|
-
return createJob(config, manifest, output
|
|
2595
|
+
return createJob(config, manifest, output, {
|
|
2596
|
+
format: format.name,
|
|
2597
|
+
quality,
|
|
2598
|
+
scale,
|
|
2599
|
+
...options.preset ? { preset: options.preset } : {}
|
|
2600
|
+
});
|
|
2324
2601
|
})();
|
|
2325
2602
|
const video = findVideo(videos, record.manifest.videoId);
|
|
2326
2603
|
log.detail(`job ${record.job.id} manifest ${record.manifest.manifestHash}`);
|
|
@@ -2331,7 +2608,10 @@ var exportCommand = async (id, options = {}) => {
|
|
|
2331
2608
|
const job = await runJob(config, server.url, record, video, {
|
|
2332
2609
|
concurrency: options.concurrency,
|
|
2333
2610
|
preset: options.preset,
|
|
2334
|
-
|
|
2611
|
+
// A retry keeps what its record froze; an explicit flag still wins.
|
|
2612
|
+
quality: options.retry && options.quality === void 0 ? void 0 : quality,
|
|
2613
|
+
scale: options.retry && options.scale === void 0 ? void 0 : scale,
|
|
2614
|
+
format: options.retry ? void 0 : format,
|
|
2335
2615
|
skipUnchangedFrames: options.skipUnchangedFrames,
|
|
2336
2616
|
onProgress: (next) => {
|
|
2337
2617
|
if (next.status === "rendering" || next.status === "encoding") {
|
|
@@ -2373,6 +2653,35 @@ var json = (response, status, payload) => {
|
|
|
2373
2653
|
response.setHeader("content-type", "application/json");
|
|
2374
2654
|
response.end(JSON.stringify(payload));
|
|
2375
2655
|
};
|
|
2656
|
+
var exportDestination = (config) => {
|
|
2657
|
+
const downloads = resolve19(homedir2(), "Downloads");
|
|
2658
|
+
return existsSync16(downloads) ? downloads : resolve19(config.root, config.exportDir);
|
|
2659
|
+
};
|
|
2660
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
2661
|
+
var hostOf = (value) => {
|
|
2662
|
+
if (!value) return null;
|
|
2663
|
+
const withoutPort = value.startsWith("[") ? value.slice(0, value.indexOf("]") + 1) : value.split(":")[0];
|
|
2664
|
+
return withoutPort || null;
|
|
2665
|
+
};
|
|
2666
|
+
var isLocalRequest = (request) => {
|
|
2667
|
+
const host = hostOf(request.headers.host);
|
|
2668
|
+
if (!host || !LOOPBACK.has(host)) return false;
|
|
2669
|
+
const origin = request.headers.origin;
|
|
2670
|
+
if (origin) {
|
|
2671
|
+
try {
|
|
2672
|
+
if (!LOOPBACK.has(hostOf(new URL(origin).host) ?? "")) return false;
|
|
2673
|
+
} catch {
|
|
2674
|
+
return false;
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
return true;
|
|
2678
|
+
};
|
|
2679
|
+
var safeJobId = (id) => {
|
|
2680
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(id) || id.includes("..")) {
|
|
2681
|
+
throw new Error(`Invalid job id ${JSON.stringify(id)}.`);
|
|
2682
|
+
}
|
|
2683
|
+
return id;
|
|
2684
|
+
};
|
|
2376
2685
|
var devCommand = async (options = {}) => {
|
|
2377
2686
|
const config = await loadConfig(options.root ?? process.cwd());
|
|
2378
2687
|
const context = async () => {
|
|
@@ -2384,6 +2693,10 @@ var devCommand = async (options = {}) => {
|
|
|
2384
2693
|
middleware: (vite) => {
|
|
2385
2694
|
vite.middlewares.use("/__odori", (request, response, next) => {
|
|
2386
2695
|
const url = request.url ?? "/";
|
|
2696
|
+
if (!isLocalRequest(request)) {
|
|
2697
|
+
json(response, 403, { error: "This endpoint only answers same-origin requests from Studio on localhost." });
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2387
2700
|
void (async () => {
|
|
2388
2701
|
try {
|
|
2389
2702
|
if (request.method === "POST" && url.startsWith("/still")) {
|
|
@@ -2401,8 +2714,7 @@ var devCommand = async (options = {}) => {
|
|
|
2401
2714
|
);
|
|
2402
2715
|
const frame = Number(body.frame ?? 0);
|
|
2403
2716
|
const inline = body.inline === true;
|
|
2404
|
-
const
|
|
2405
|
-
const file = resolve18(config.root, `${directory2}/${outputName(video.entry.metadata.id)}-${frame}.png`);
|
|
2717
|
+
const file = inline ? resolve19(config.root, config.outDir, `${outputName(video.entry.metadata.id)}-${frame}.png`) : resolve19(exportDestination(config), `${outputName(video.entry.metadata.id)}-${frame}.png`);
|
|
2406
2718
|
await renderStill(
|
|
2407
2719
|
origin,
|
|
2408
2720
|
targetFor(
|
|
@@ -2418,7 +2730,7 @@ var devCommand = async (options = {}) => {
|
|
|
2418
2730
|
if (inline) {
|
|
2419
2731
|
response.statusCode = 200;
|
|
2420
2732
|
response.setHeader("content-type", "image/png");
|
|
2421
|
-
response.end(await
|
|
2733
|
+
response.end(await readFile13(file));
|
|
2422
2734
|
return;
|
|
2423
2735
|
}
|
|
2424
2736
|
json(response, 200, { id: "still", status: "ready", progress: 1, output: file });
|
|
@@ -2426,6 +2738,9 @@ var devCommand = async (options = {}) => {
|
|
|
2426
2738
|
}
|
|
2427
2739
|
if (request.method === "POST" && url.startsWith("/exports")) {
|
|
2428
2740
|
const body = await readBody(request);
|
|
2741
|
+
const format = resolveFormat(typeof body.format === "string" ? body.format : void 0, void 0);
|
|
2742
|
+
const quality = resolveQuality(typeof body.quality === "string" ? body.quality : void 0);
|
|
2743
|
+
const scale = resolveScale(typeof body.scale === "number" ? body.scale : void 0);
|
|
2429
2744
|
const { graph, videos } = await context();
|
|
2430
2745
|
const video = findVideo(videos, String(body.videoId));
|
|
2431
2746
|
const input = body.input ?? {};
|
|
@@ -2437,8 +2752,11 @@ var devCommand = async (options = {}) => {
|
|
|
2437
2752
|
input,
|
|
2438
2753
|
{ scenes: compiled.scenes, audio: compiled.audio }
|
|
2439
2754
|
);
|
|
2440
|
-
const output =
|
|
2441
|
-
|
|
2755
|
+
const output = resolve19(
|
|
2756
|
+
exportDestination(config),
|
|
2757
|
+
`${outputName(video.entry.metadata.id)}${format.extension}`
|
|
2758
|
+
);
|
|
2759
|
+
const record = await createJob(config, manifest, output, { format: format.name, quality, scale });
|
|
2442
2760
|
json(response, 202, record.job);
|
|
2443
2761
|
void runJob(config, origin, record, video).catch((error) => {
|
|
2444
2762
|
log.error(`Export failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -2446,7 +2764,7 @@ var devCommand = async (options = {}) => {
|
|
|
2446
2764
|
return;
|
|
2447
2765
|
}
|
|
2448
2766
|
if (request.method === "POST" && url.startsWith("/retry/")) {
|
|
2449
|
-
const id = url.replace("/retry/", "").split("?")[0];
|
|
2767
|
+
const id = safeJobId(url.replace("/retry/", "").split("?")[0]);
|
|
2450
2768
|
const record = await readJob(config, id);
|
|
2451
2769
|
const { videos } = await context();
|
|
2452
2770
|
const video = findVideo(videos, record.manifest.videoId);
|
|
@@ -2457,16 +2775,34 @@ var devCommand = async (options = {}) => {
|
|
|
2457
2775
|
return;
|
|
2458
2776
|
}
|
|
2459
2777
|
if (request.method === "POST" && url.startsWith("/cancel/")) {
|
|
2460
|
-
const id = url.replace("/cancel/", "").split("?")[0];
|
|
2778
|
+
const id = safeJobId(url.replace("/cancel/", "").split("?")[0]);
|
|
2461
2779
|
const cancelled = cancelJob(id);
|
|
2462
2780
|
json(response, cancelled ? 202 : 404, { id, cancelled });
|
|
2463
2781
|
return;
|
|
2464
2782
|
}
|
|
2465
2783
|
if (request.method === "GET" && url.startsWith("/jobs/")) {
|
|
2466
|
-
const id = url.replace("/jobs/", "").split("?")[0];
|
|
2784
|
+
const id = safeJobId(url.replace("/jobs/", "").split("?")[0]);
|
|
2467
2785
|
json(response, 200, (await readJob(config, id)).job);
|
|
2468
2786
|
return;
|
|
2469
2787
|
}
|
|
2788
|
+
if (request.method === "GET" && url.startsWith("/source")) {
|
|
2789
|
+
const asked = new URL(url, "http://localhost").searchParams.get("file") ?? "";
|
|
2790
|
+
const file = resolve19(config.root, asked);
|
|
2791
|
+
const inside = relative7(config.root, file);
|
|
2792
|
+
const readable = /\.(tsx?|jsx?|css|json|md)$/.test(file);
|
|
2793
|
+
if (!inside || inside.startsWith("..") || !readable) {
|
|
2794
|
+
json(response, 400, { error: `Refusing to read ${asked}.` });
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
try {
|
|
2798
|
+
response.statusCode = 200;
|
|
2799
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
2800
|
+
response.end(await readFile13(file, "utf8"));
|
|
2801
|
+
} catch {
|
|
2802
|
+
json(response, 404, { error: `${asked} is not there.` });
|
|
2803
|
+
}
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2470
2806
|
if (request.method === "GET" && url.startsWith("/jobs")) {
|
|
2471
2807
|
json(response, 200, await listJobs(config));
|
|
2472
2808
|
return;
|
|
@@ -2480,7 +2816,7 @@ var devCommand = async (options = {}) => {
|
|
|
2480
2816
|
}
|
|
2481
2817
|
});
|
|
2482
2818
|
const origin = server.url;
|
|
2483
|
-
const entry = `${origin}
|
|
2819
|
+
const entry = `${origin}/`;
|
|
2484
2820
|
log.title("Odori Studio");
|
|
2485
2821
|
log.info(` ${entry}`);
|
|
2486
2822
|
log.detail(` ${server.graph.videos.length} videos, ${server.graph.previews.length} component previews`);
|
|
@@ -2491,16 +2827,16 @@ var devCommand = async (options = {}) => {
|
|
|
2491
2827
|
|
|
2492
2828
|
// src/commands/doctor.ts
|
|
2493
2829
|
import { constants } from "fs";
|
|
2494
|
-
import { access, mkdir as
|
|
2495
|
-
import { existsSync as
|
|
2830
|
+
import { access, mkdir as mkdir13, readFile as readFile14, rm as rm4, writeFile as writeFile14 } from "fs/promises";
|
|
2831
|
+
import { existsSync as existsSync17 } from "fs";
|
|
2496
2832
|
import { createRequire as createRequire3 } from "module";
|
|
2497
|
-
import { relative as
|
|
2833
|
+
import { relative as relative8, resolve as resolve20 } from "path";
|
|
2498
2834
|
var MINIMUM_NODE = 20;
|
|
2499
2835
|
var version = (value) => value.replace(/^v/, "").split(".").map(Number);
|
|
2500
2836
|
var runChecks = async (root) => {
|
|
2501
2837
|
const checks = [];
|
|
2502
2838
|
const config = await loadConfig(root);
|
|
2503
|
-
const require2 = createRequire3(
|
|
2839
|
+
const require2 = createRequire3(resolve20(root, "package.json"));
|
|
2504
2840
|
const [major] = version(process.version);
|
|
2505
2841
|
checks.push({
|
|
2506
2842
|
name: "Node",
|
|
@@ -2511,7 +2847,7 @@ var runChecks = async (root) => {
|
|
|
2511
2847
|
let react2 = "not found";
|
|
2512
2848
|
let reactOk = false;
|
|
2513
2849
|
try {
|
|
2514
|
-
const manifest = JSON.parse(await
|
|
2850
|
+
const manifest = JSON.parse(await readFile14(require2.resolve("react/package.json"), "utf8"));
|
|
2515
2851
|
react2 = manifest.version;
|
|
2516
2852
|
reactOk = version(react2)[0] >= 19;
|
|
2517
2853
|
} catch {
|
|
@@ -2523,16 +2859,16 @@ var runChecks = async (root) => {
|
|
|
2523
2859
|
ok: reactOk,
|
|
2524
2860
|
fix: "The runtime needs React 19. Install it: npm install react@19 react-dom@19"
|
|
2525
2861
|
});
|
|
2526
|
-
const videosDir =
|
|
2862
|
+
const videosDir = resolve20(config.root, config.videosDir);
|
|
2527
2863
|
checks.push({
|
|
2528
2864
|
name: "Source root",
|
|
2529
|
-
detail:
|
|
2530
|
-
ok:
|
|
2865
|
+
detail: existsSync17(videosDir) ? relative8(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
|
|
2866
|
+
ok: existsSync17(videosDir),
|
|
2531
2867
|
fix: 'Run "odori init" to add the videos source root.'
|
|
2532
2868
|
});
|
|
2533
2869
|
checks.push({
|
|
2534
2870
|
name: "Config",
|
|
2535
|
-
detail: config.configPath ?
|
|
2871
|
+
detail: config.configPath ? relative8(config.root, config.configPath) : "defaults (no odori.config.ts)",
|
|
2536
2872
|
// Loading got this far, so a config that exists also parsed.
|
|
2537
2873
|
ok: true
|
|
2538
2874
|
});
|
|
@@ -2558,23 +2894,42 @@ var runChecks = async (root) => {
|
|
|
2558
2894
|
detail: unpinned.length === 0 ? `pinned binaries from ${cacheRoot()}` : `${unpinned.length} of 2 from the host; frames may differ from another machine`,
|
|
2559
2895
|
ok: true
|
|
2560
2896
|
});
|
|
2561
|
-
const generated =
|
|
2897
|
+
const generated = resolve20(config.root, ".odori");
|
|
2562
2898
|
let writable = false;
|
|
2563
2899
|
try {
|
|
2564
|
-
await
|
|
2565
|
-
const probe =
|
|
2566
|
-
await
|
|
2900
|
+
await mkdir13(generated, { recursive: true });
|
|
2901
|
+
const probe = resolve20(generated, ".doctor");
|
|
2902
|
+
await writeFile14(probe, "", "utf8");
|
|
2567
2903
|
await access(probe, constants.W_OK);
|
|
2568
|
-
await
|
|
2904
|
+
await rm4(probe, { force: true });
|
|
2569
2905
|
writable = true;
|
|
2570
2906
|
} catch {
|
|
2571
2907
|
writable = false;
|
|
2572
2908
|
}
|
|
2909
|
+
const componentsRoot = resolve20(root, config.componentsDir);
|
|
2910
|
+
const orphans = [];
|
|
2911
|
+
if (existsSync17(componentsRoot)) {
|
|
2912
|
+
const { readdir: readdir8 } = await import("fs/promises");
|
|
2913
|
+
for (const entry of await readdir8(componentsRoot, { withFileTypes: true })) {
|
|
2914
|
+
if (!entry.isDirectory()) continue;
|
|
2915
|
+
const files = await readdir8(resolve20(componentsRoot, entry.name));
|
|
2916
|
+
const source = files.some((file) => /\.tsx$/.test(file) && !file.endsWith(".preview.tsx"));
|
|
2917
|
+
const fixture = files.some((file) => file.endsWith(".preview.tsx"));
|
|
2918
|
+
if (source && !fixture) orphans.push(entry.name);
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
checks.push({
|
|
2922
|
+
name: "Component previews",
|
|
2923
|
+
detail: orphans.length === 0 ? "every component has a fixture" : `${orphans.length} without a fixture: ${orphans.slice(0, 4).join(", ")}${orphans.length > 4 ? ", \u2026" : ""}`,
|
|
2924
|
+
ok: true,
|
|
2925
|
+
warn: orphans.length > 0,
|
|
2926
|
+
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.`
|
|
2927
|
+
});
|
|
2573
2928
|
checks.push({
|
|
2574
2929
|
name: "Generated cache",
|
|
2575
2930
|
detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
|
|
2576
2931
|
ok: writable,
|
|
2577
|
-
fix: `Odori writes its import graph and render cache to ${
|
|
2932
|
+
fix: `Odori writes its import graph and render cache to ${relative8(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
|
|
2578
2933
|
});
|
|
2579
2934
|
return checks;
|
|
2580
2935
|
};
|
|
@@ -2584,12 +2939,15 @@ var doctorCommand = async (root = process.cwd()) => {
|
|
|
2584
2939
|
log.title("odori doctor");
|
|
2585
2940
|
for (const check of checks) {
|
|
2586
2941
|
const label = check.name.padEnd(width);
|
|
2587
|
-
if (check.ok) log.
|
|
2942
|
+
if (check.ok && check.warn) log.warn(`${label} ${check.detail}`);
|
|
2943
|
+
else if (check.ok) log.success(`${label} ${check.detail}`);
|
|
2588
2944
|
else log.error(`${label} ${check.detail}`);
|
|
2589
2945
|
}
|
|
2946
|
+
const warned = checks.filter((check) => check.ok && check.warn);
|
|
2590
2947
|
const failed = checks.filter((check) => !check.ok);
|
|
2591
2948
|
if (failed.length === 0) {
|
|
2592
2949
|
log.detail("Everything a render needs is present.");
|
|
2950
|
+
for (const check of warned) log.detail(` ${check.fix}`);
|
|
2593
2951
|
return 0;
|
|
2594
2952
|
}
|
|
2595
2953
|
log.info("");
|
|
@@ -2598,14 +2956,14 @@ var doctorCommand = async (root = process.cwd()) => {
|
|
|
2598
2956
|
};
|
|
2599
2957
|
|
|
2600
2958
|
// src/commands/init.ts
|
|
2601
|
-
import { mkdir as
|
|
2602
|
-
import { existsSync as
|
|
2603
|
-
import { relative as
|
|
2959
|
+
import { mkdir as mkdir15, writeFile as writeFile16 } from "fs/promises";
|
|
2960
|
+
import { existsSync as existsSync19 } from "fs";
|
|
2961
|
+
import { relative as relative10, resolve as resolve22 } from "path";
|
|
2604
2962
|
|
|
2605
2963
|
// src/commands/new.ts
|
|
2606
|
-
import { mkdir as
|
|
2607
|
-
import { existsSync as
|
|
2608
|
-
import { relative as
|
|
2964
|
+
import { mkdir as mkdir14, readdir as readdir5, writeFile as writeFile15 } from "fs/promises";
|
|
2965
|
+
import { existsSync as existsSync18 } from "fs";
|
|
2966
|
+
import { relative as relative9, resolve as resolve21 } from "path";
|
|
2609
2967
|
var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
2610
2968
|
var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
|
|
2611
2969
|
var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
|
|
@@ -2663,30 +3021,30 @@ ${closing}
|
|
|
2663
3021
|
`;
|
|
2664
3022
|
};
|
|
2665
3023
|
var installedParts = async (config) => {
|
|
2666
|
-
const componentsDir =
|
|
2667
|
-
if (!
|
|
3024
|
+
const componentsDir = resolve21(config.root, config.componentsDir);
|
|
3025
|
+
if (!existsSync18(componentsDir)) return { title: false, end: false };
|
|
2668
3026
|
const entries = (await readdir5(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
2669
3027
|
return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
|
|
2670
3028
|
};
|
|
2671
3029
|
var newCommand = async (name, options = {}) => {
|
|
2672
3030
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("Use a lowercase, dash separated name.");
|
|
2673
3031
|
const config = await loadConfig(process.cwd());
|
|
2674
|
-
const directory2 =
|
|
2675
|
-
const file =
|
|
2676
|
-
if (
|
|
2677
|
-
const hasLayout =
|
|
3032
|
+
const directory2 = resolve21(config.root, config.videosDir, name);
|
|
3033
|
+
const file = resolve21(directory2, "video.tsx");
|
|
3034
|
+
if (existsSync18(file)) throw new Error(`${relative9(config.root, file)} already exists.`);
|
|
3035
|
+
const hasLayout = existsSync18(resolve21(config.root, config.videosDir, "layout.tsx"));
|
|
2678
3036
|
const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
|
|
2679
3037
|
const composed = parts.title || parts.end;
|
|
2680
|
-
await
|
|
2681
|
-
await
|
|
3038
|
+
await mkdir14(directory2, { recursive: true });
|
|
3039
|
+
await writeFile15(
|
|
2682
3040
|
file,
|
|
2683
3041
|
composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
|
|
2684
3042
|
"utf8"
|
|
2685
3043
|
);
|
|
2686
|
-
log.success(`Created ${
|
|
3044
|
+
log.success(`Created ${relative9(config.root, file)}`);
|
|
2687
3045
|
if (composed) log.detail("Composed from the components this project has installed.");
|
|
2688
3046
|
else if (options.blank !== true) {
|
|
2689
|
-
log.detail("No registry components installed yet: odori add
|
|
3047
|
+
log.detail("No registry components installed yet: odori add title-reveal end-card");
|
|
2690
3048
|
}
|
|
2691
3049
|
log.detail("Run odori dev to preview it.");
|
|
2692
3050
|
};
|
|
@@ -2717,23 +3075,23 @@ export const productLayout = defineVideoLayout({
|
|
|
2717
3075
|
});
|
|
2718
3076
|
`;
|
|
2719
3077
|
var initCommand = async (root = process.cwd()) => {
|
|
2720
|
-
const videosDir =
|
|
2721
|
-
await
|
|
3078
|
+
const videosDir = resolve22(root, defaultConfig.videosDir);
|
|
3079
|
+
await mkdir15(resolve22(videosDir, "components"), { recursive: true });
|
|
2722
3080
|
const files = [
|
|
2723
|
-
[
|
|
2724
|
-
[
|
|
2725
|
-
[
|
|
3081
|
+
[resolve22(root, "odori.config.ts"), CONFIG_TEMPLATE],
|
|
3082
|
+
[resolve22(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
|
|
3083
|
+
[resolve22(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
|
|
2726
3084
|
];
|
|
2727
3085
|
for (const [file, contents] of files) {
|
|
2728
|
-
if (
|
|
2729
|
-
log.detail(`Kept existing ${
|
|
3086
|
+
if (existsSync19(file)) {
|
|
3087
|
+
log.detail(`Kept existing ${relative10(root, file)}`);
|
|
2730
3088
|
continue;
|
|
2731
3089
|
}
|
|
2732
|
-
await
|
|
2733
|
-
await
|
|
2734
|
-
log.success(`Created ${
|
|
3090
|
+
await mkdir15(resolve22(file, ".."), { recursive: true });
|
|
3091
|
+
await writeFile16(file, contents, "utf8");
|
|
3092
|
+
log.success(`Created ${relative10(root, file)}`);
|
|
2735
3093
|
}
|
|
2736
|
-
log.detail("Next: odori doctor, then odori add
|
|
3094
|
+
log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
|
|
2737
3095
|
};
|
|
2738
3096
|
|
|
2739
3097
|
// src/commands/inspect.ts
|
|
@@ -2822,15 +3180,16 @@ var listCommand = async () => {
|
|
|
2822
3180
|
}
|
|
2823
3181
|
};
|
|
2824
3182
|
|
|
2825
|
-
// src/commands/
|
|
2826
|
-
import { resolve as
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
}
|
|
3183
|
+
// src/commands/frame.ts
|
|
3184
|
+
import { resolve as resolve23 } from "path";
|
|
3185
|
+
import { framesFromOffset, resolveEntryLayout as resolveEntryLayout7 } from "odori";
|
|
3186
|
+
var frameCommand = async (id, options = {}) => {
|
|
3187
|
+
const at = options.at ?? 0;
|
|
3188
|
+
framesFromOffset(at, 30);
|
|
2832
3189
|
const { config, graph, videos } = await createContext();
|
|
2833
3190
|
const video = findVideo(videos, id);
|
|
3191
|
+
const fps = resolveEntryLayout7(video.entry).format.fps;
|
|
3192
|
+
const frame = framesFromOffset(at, fps);
|
|
2834
3193
|
const output = await withServer(config, async (server) => {
|
|
2835
3194
|
const { durationInFrames, scenes, audio } = await compileInBrowser(server.url, targetFor(video, options.input), config);
|
|
2836
3195
|
const { manifest, input, prepared } = await freezeManifest(
|
|
@@ -2844,21 +3203,21 @@ var stillCommand = async (id, options = {}) => {
|
|
|
2844
3203
|
throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
|
|
2845
3204
|
}
|
|
2846
3205
|
const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
|
|
2847
|
-
const file =
|
|
3206
|
+
const file = resolve23(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
|
|
2848
3207
|
return renderStill(server.url, target, frame, file, config);
|
|
2849
3208
|
});
|
|
2850
|
-
log.success(`
|
|
3209
|
+
log.success(`Frame ${frame} written to ${output}`);
|
|
2851
3210
|
return output;
|
|
2852
3211
|
};
|
|
2853
3212
|
|
|
2854
3213
|
// src/commands/test.ts
|
|
2855
|
-
import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as
|
|
3214
|
+
import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout9 } from "odori";
|
|
2856
3215
|
|
|
2857
3216
|
// src/contracts.ts
|
|
2858
|
-
import { existsSync as
|
|
3217
|
+
import { existsSync as existsSync20 } from "fs";
|
|
2859
3218
|
import { readdir as readdir6 } from "fs/promises";
|
|
2860
|
-
import { resolve as
|
|
2861
|
-
import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as
|
|
3219
|
+
import { resolve as resolve24 } from "path";
|
|
3220
|
+
import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
|
|
2862
3221
|
var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
2863
3222
|
var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
|
|
2864
3223
|
"system-ui",
|
|
@@ -2924,8 +3283,8 @@ var checkAudioWindows = (cues, brand, videoId) => {
|
|
|
2924
3283
|
return failures;
|
|
2925
3284
|
};
|
|
2926
3285
|
var checkInstalledContracts = async (config, videos) => {
|
|
2927
|
-
const componentsDir =
|
|
2928
|
-
const onDisk =
|
|
3286
|
+
const componentsDir = resolve24(config.root, config.componentsDir);
|
|
3287
|
+
const onDisk = existsSync20(componentsDir) ? (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
|
|
2929
3288
|
const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
|
|
2930
3289
|
if (names.size === 0) return [];
|
|
2931
3290
|
const { items } = await resolveRegistry(config, { allowNetwork: false });
|
|
@@ -2934,7 +3293,7 @@ var checkInstalledContracts = async (config, videos) => {
|
|
|
2934
3293
|
const seen = /* @__PURE__ */ new Set();
|
|
2935
3294
|
const failures = [];
|
|
2936
3295
|
for (const video of videos) {
|
|
2937
|
-
const { brand } =
|
|
3296
|
+
const { brand } = resolveEntryLayout8(video.entry);
|
|
2938
3297
|
for (const failure of checkComponentRequirements(installed, brand, video.entry.metadata.id)) {
|
|
2939
3298
|
const key = `${brand.name}:${failure.message}`;
|
|
2940
3299
|
if (seen.has(key)) continue;
|
|
@@ -2946,9 +3305,9 @@ var checkInstalledContracts = async (config, videos) => {
|
|
|
2946
3305
|
};
|
|
2947
3306
|
|
|
2948
3307
|
// src/determinism.ts
|
|
2949
|
-
import { readdir as readdir7, readFile as
|
|
2950
|
-
import { existsSync as
|
|
2951
|
-
import { join as join7, relative as
|
|
3308
|
+
import { readdir as readdir7, readFile as readFile15 } from "fs/promises";
|
|
3309
|
+
import { existsSync as existsSync21 } from "fs";
|
|
3310
|
+
import { join as join7, relative as relative11, resolve as resolve25 } from "path";
|
|
2952
3311
|
var FORBIDDEN = [
|
|
2953
3312
|
{
|
|
2954
3313
|
pattern: /\bMath\.random\s*\(/,
|
|
@@ -2989,11 +3348,11 @@ var walk2 = async (directory2, files = []) => {
|
|
|
2989
3348
|
return files;
|
|
2990
3349
|
};
|
|
2991
3350
|
var checkDeterminism = async (config) => {
|
|
2992
|
-
const root =
|
|
2993
|
-
if (!
|
|
3351
|
+
const root = resolve25(config.root, config.videosDir);
|
|
3352
|
+
if (!existsSync21(root)) return [];
|
|
2994
3353
|
const files = await walk2(root);
|
|
2995
3354
|
const findings = await Promise.all(
|
|
2996
|
-
files.map(async (file) => scanSource(await
|
|
3355
|
+
files.map(async (file) => scanSource(await readFile15(file, "utf8"), relative11(config.root, file)))
|
|
2997
3356
|
);
|
|
2998
3357
|
return findings.flat();
|
|
2999
3358
|
};
|
|
@@ -3092,7 +3451,7 @@ var FRAME_SCRIPT = `(() => {
|
|
|
3092
3451
|
})()`;
|
|
3093
3452
|
var testVideo = async (origin, video, config, failures, quiet = false) => {
|
|
3094
3453
|
const id = video.entry.metadata.id;
|
|
3095
|
-
const layout =
|
|
3454
|
+
const layout = resolveEntryLayout9(video.entry);
|
|
3096
3455
|
if (isOdoriSchema2(video.entry.metadata.schema)) {
|
|
3097
3456
|
const result = video.entry.metadata.schema.safeParse(video.entry.metadata.defaultProps ?? {});
|
|
3098
3457
|
if (!result.success) failures.push({ video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}` });
|
|
@@ -3257,9 +3616,9 @@ var COMMAND_FLAGS = {
|
|
|
3257
3616
|
update: ["force"],
|
|
3258
3617
|
list: [],
|
|
3259
3618
|
inspect: ["json", "input"],
|
|
3260
|
-
|
|
3619
|
+
frame: ["at", "output", "input"],
|
|
3261
3620
|
test: ["json"],
|
|
3262
|
-
export: ["output", "input", "concurrency", "preset", "format", "no-frame-skip", "retry"],
|
|
3621
|
+
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
|
|
3263
3622
|
jobs: [],
|
|
3264
3623
|
help: []
|
|
3265
3624
|
};
|
|
@@ -3320,15 +3679,19 @@ var USAGE = {
|
|
|
3320
3679
|
Print discovered video ids and formats.`,
|
|
3321
3680
|
inspect: `odori inspect <id> [--json] [--input <json>]
|
|
3322
3681
|
Show resolved layout, inputs, scenes, and assets.`,
|
|
3323
|
-
|
|
3324
|
-
Render one deterministic frame
|
|
3682
|
+
frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
|
|
3683
|
+
Render one deterministic frame to a PNG. --at is a duration: 4s, 500ms, or
|
|
3684
|
+
120f for frame 120. A bare number is seconds.`,
|
|
3325
3685
|
test: `odori test [id] [--json]
|
|
3326
3686
|
Validate contracts and representative frames. --json emits one object per
|
|
3327
3687
|
check, for CI.`,
|
|
3328
3688
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
3329
|
-
[--preset <name>] [--format <name>] [--
|
|
3689
|
+
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
3690
|
+
[--no-frame-skip] [--retry <job>]
|
|
3330
3691
|
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
|
|
3692
|
+
or png; without it the output's extension decides, and mp4 is the default.
|
|
3693
|
+
--quality is studio, social, or web. --scale multiplies the output size,
|
|
3694
|
+
0.25 to 2. A retry keeps the settings its job was created with.`,
|
|
3332
3695
|
jobs: `odori jobs
|
|
3333
3696
|
List export jobs and their status.`
|
|
3334
3697
|
};
|
|
@@ -3346,18 +3709,20 @@ Usage
|
|
|
3346
3709
|
odori update [components] Apply upstream component changes
|
|
3347
3710
|
odori list Print discovered video ids and formats
|
|
3348
3711
|
odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
|
|
3349
|
-
odori
|
|
3712
|
+
odori frame <id> --at 4s Render one deterministic frame to a PNG
|
|
3350
3713
|
odori test [id] [--json] Validate contracts and representative frames
|
|
3351
3714
|
odori export <id> [--output f] Render and encode a distributable file
|
|
3352
3715
|
odori jobs List export jobs and their status
|
|
3353
3716
|
|
|
3354
3717
|
Options
|
|
3355
3718
|
--input '{"headline":"..."}' Serializable input for the video schema
|
|
3356
|
-
--output <path> Output path for
|
|
3719
|
+
--output <path> Output path for frame and export
|
|
3357
3720
|
--force Replace locally modified component source
|
|
3358
3721
|
--concurrency <n> Parallel render workers for export
|
|
3359
3722
|
--preset <name> x264 preset for export, default medium
|
|
3360
3723
|
--format <name> mp4, webm, prores, gif, or png
|
|
3724
|
+
--quality <tier> studio, social, or web compression
|
|
3725
|
+
--scale <n> Output size multiplier, 0.25 to 2
|
|
3361
3726
|
--no-frame-skip Capture every frame, even unchanged ones
|
|
3362
3727
|
--retry <job id> Re-run a recorded job from its frozen manifest
|
|
3363
3728
|
--no-open Start dev without opening Studio in a browser
|
|
@@ -3428,9 +3793,11 @@ var run2 = async (argv) => {
|
|
|
3428
3793
|
case "inspect":
|
|
3429
3794
|
await inspectCommand(positionals[0] ?? "", { json: flags.json === true, input: parseInput(flags) });
|
|
3430
3795
|
return 0;
|
|
3431
|
-
case "
|
|
3432
|
-
await
|
|
3433
|
-
|
|
3796
|
+
case "frame":
|
|
3797
|
+
await frameCommand(positionals[0] ?? "", {
|
|
3798
|
+
// A duration, so "4s" and "120f" both work; a bare number is
|
|
3799
|
+
// seconds, the way every other time value in Odori reads.
|
|
3800
|
+
at: typeof flags.at === "string" ? flags.at : numberFlag(flags, "at") ?? 0,
|
|
3434
3801
|
output: typeof flags.output === "string" ? flags.output : void 0,
|
|
3435
3802
|
input: parseInput(flags)
|
|
3436
3803
|
});
|
|
@@ -3444,6 +3811,8 @@ var run2 = async (argv) => {
|
|
|
3444
3811
|
input: parseInput(flags),
|
|
3445
3812
|
concurrency: numberFlag(flags, "concurrency"),
|
|
3446
3813
|
preset: typeof flags.preset === "string" ? flags.preset : void 0,
|
|
3814
|
+
quality: typeof flags.quality === "string" ? flags.quality : void 0,
|
|
3815
|
+
scale: numberFlag(flags, "scale"),
|
|
3447
3816
|
format: typeof flags.format === "string" ? flags.format : void 0,
|
|
3448
3817
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
|
|
3449
3818
|
retry: typeof flags.retry === "string" ? flags.retry : void 0
|
|
@@ -3478,6 +3847,7 @@ export {
|
|
|
3478
3847
|
discoverProject,
|
|
3479
3848
|
generateImports,
|
|
3480
3849
|
writeGenerated,
|
|
3850
|
+
startStudioServer,
|
|
3481
3851
|
localCandidates,
|
|
3482
3852
|
createIntegrityResolver,
|
|
3483
3853
|
outputName,
|
|
@@ -3497,7 +3867,6 @@ export {
|
|
|
3497
3867
|
findVideo,
|
|
3498
3868
|
runPrepare,
|
|
3499
3869
|
freezeManifest,
|
|
3500
|
-
startStudioServer,
|
|
3501
3870
|
resolveCueFile,
|
|
3502
3871
|
buildAudioFilter,
|
|
3503
3872
|
planChunks,
|
|
@@ -3533,7 +3902,7 @@ export {
|
|
|
3533
3902
|
exportCommand,
|
|
3534
3903
|
jobsCommand,
|
|
3535
3904
|
devCommand,
|
|
3536
|
-
|
|
3905
|
+
frameCommand,
|
|
3537
3906
|
diffLines,
|
|
3538
3907
|
countChanges,
|
|
3539
3908
|
formatDiff,
|