@odori/cli 0.0.2
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/LICENSE +22 -0
- package/bin/odori.mjs +39 -0
- package/dist/chunk-7XJL2BYO.js +3552 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +10 -0
- package/dist/index.d.ts +622 -0
- package/dist/index.js +156 -0
- package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
- package/package.json +50 -0
- package/src/audio-mix.ts +133 -0
- package/src/binaries.ts +241 -0
- package/src/brand-file.ts +94 -0
- package/src/chunk-cache.ts +85 -0
- package/src/chunks.ts +78 -0
- package/src/cli.ts +319 -0
- package/src/commands/add.ts +151 -0
- package/src/commands/dev.ts +160 -0
- package/src/commands/doctor.ts +162 -0
- package/src/commands/exportVideo.ts +198 -0
- package/src/commands/init.ts +56 -0
- package/src/commands/inspect.ts +72 -0
- package/src/commands/list.ts +22 -0
- package/src/commands/new.ts +126 -0
- package/src/commands/shared.ts +96 -0
- package/src/commands/still.ts +40 -0
- package/src/commands/test.ts +265 -0
- package/src/commands/update.ts +183 -0
- package/src/config.ts +84 -0
- package/src/contracts.ts +159 -0
- package/src/cues.ts +141 -0
- package/src/determinism.ts +82 -0
- package/src/diff.ts +71 -0
- package/src/discovery.ts +216 -0
- package/src/formats.ts +119 -0
- package/src/index.ts +58 -0
- package/src/integrity.ts +101 -0
- package/src/jobs.ts +151 -0
- package/src/log.ts +17 -0
- package/src/open.ts +32 -0
- package/src/paths.ts +12 -0
- package/src/prepare-cache.ts +58 -0
- package/src/project.ts +196 -0
- package/src/registry-snapshot.json +3431 -0
- package/src/registry-source.ts +269 -0
- package/src/render.ts +627 -0
- package/src/server.ts +307 -0
- package/studio/index.html +41 -0
- package/studio/src/Studio.tsx +192 -0
- package/studio/src/components/AudioClip.tsx +64 -0
- package/studio/src/components/CanvasStage.tsx +79 -0
- package/studio/src/components/CommandPalette.tsx +129 -0
- package/studio/src/components/Diagnostics.tsx +93 -0
- package/studio/src/components/ExportPanel.tsx +234 -0
- package/studio/src/components/InputControls.tsx +110 -0
- package/studio/src/components/Thumbnail.tsx +71 -0
- package/studio/src/components/Transport.tsx +237 -0
- package/studio/src/components/Waveform.tsx +114 -0
- package/studio/src/components/Wordmark.tsx +449 -0
- package/studio/src/components/ui.tsx +138 -0
- package/studio/src/lib/mix-loudness.ts +52 -0
- package/studio/src/main.tsx +34 -0
- package/studio/src/shortcuts.ts +27 -0
- package/studio/src/studio.css +1232 -0
- package/studio/src/theme.ts +61 -0
- package/studio/src/views/AssetsView.tsx +111 -0
- package/studio/src/views/BrandsView.tsx +139 -0
- package/studio/src/views/ComponentsView.tsx +285 -0
- package/studio/src/views/HomeView.tsx +122 -0
- package/studio/src/views/VideosView.tsx +343 -0
- package/studio/src/virtual.d.ts +25 -0
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@odori/cli",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The odori command line: discovery, Studio, component installation, stills, tests, and export jobs.",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"bin": {
|
|
8
|
+
"odori": "./bin/odori.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin",
|
|
18
|
+
"dist",
|
|
19
|
+
"src",
|
|
20
|
+
"studio"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@puppeteer/browsers": "3.2.0",
|
|
24
|
+
"@vitejs/plugin-react": "5.1.2",
|
|
25
|
+
"ffmpeg-static": "5.3.0",
|
|
26
|
+
"react": "19.2.3",
|
|
27
|
+
"react-dom": "19.2.3",
|
|
28
|
+
"playwright-core": "1.55.0",
|
|
29
|
+
"tsx": "4.20.5",
|
|
30
|
+
"vite": "7.3.0",
|
|
31
|
+
"odori": "0.0.2"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "22.19.0",
|
|
35
|
+
"@types/react": "19.2.7",
|
|
36
|
+
"@types/react-dom": "19.2.3",
|
|
37
|
+
"tsup": "^8.5.1",
|
|
38
|
+
"typescript": "5.9.3",
|
|
39
|
+
"@odori/registry": "0.0.2"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.studio.json",
|
|
46
|
+
"build": "tsup",
|
|
47
|
+
"snapshot": "tsx scripts/snapshot.ts",
|
|
48
|
+
"verify:package": "tsx scripts/verify-package.ts"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/audio-mix.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {existsSync} from "node:fs";
|
|
2
|
+
import {resolve} from "node:path";
|
|
3
|
+
import {duckEnvelope, type ManifestAudioCue, envelopeAtFrame} from "odori";
|
|
4
|
+
import {cueFile} from "./cues";
|
|
5
|
+
import type {ResolvedConfig} from "./config";
|
|
6
|
+
|
|
7
|
+
export type MixInput = {file: string; cue: ManifestAudioCue};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a cue source to a local file. Sources are project-relative or
|
|
11
|
+
* public-relative paths, which is what the dev server and the render worker
|
|
12
|
+
* both serve.
|
|
13
|
+
*/
|
|
14
|
+
export const resolveCueFile = (config: ResolvedConfig, src: string): string | null => {
|
|
15
|
+
if (/^https?:\/\//.test(src)) return null;
|
|
16
|
+
// A generated cue was rendered into the build cache before the mix ran, and
|
|
17
|
+
// its name carries the hash of the score that produced it.
|
|
18
|
+
if (src.startsWith("/__odori/cue/")) {
|
|
19
|
+
const generated = cueFile(config, src);
|
|
20
|
+
return existsSync(generated) ? generated : null;
|
|
21
|
+
}
|
|
22
|
+
const candidates = [
|
|
23
|
+
resolve(config.root, "public", src.replace(/^\//, "")),
|
|
24
|
+
resolve(config.root, src.replace(/^\//, "")),
|
|
25
|
+
];
|
|
26
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the FFmpeg audio graph for a track.
|
|
31
|
+
*
|
|
32
|
+
* Every cue is trimmed, delayed to its frame, faded, and gained exactly as the
|
|
33
|
+
* player computes it, then mixed and normalized to the brand's target
|
|
34
|
+
* loudness. A ducked cue drops only while a non-ducked cue overlaps it, from
|
|
35
|
+
* the same envelope the player evaluates.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Compile a cue's gain and duck envelope into one volume filter.
|
|
39
|
+
*
|
|
40
|
+
* The filter runs before `adelay`, so its clock is cue-local seconds. A cue
|
|
41
|
+
* that is never ducked keeps a constant volume rather than paying for an
|
|
42
|
+
* expression.
|
|
43
|
+
*/
|
|
44
|
+
export const volumeFilter = (cue: ManifestAudioCue, cues: ManifestAudioCue[], fps: number): string => {
|
|
45
|
+
// Duck points and authored points are two envelopes over the same window, so
|
|
46
|
+
// the filter carries every frame either of them turns.
|
|
47
|
+
const authored = cue.gainPoints ?? [];
|
|
48
|
+
const frames = [
|
|
49
|
+
...new Set([
|
|
50
|
+
...duckEnvelope(cue, cues).map((point) => point.frame),
|
|
51
|
+
...authored.map((point: {frame: number}) => point.frame),
|
|
52
|
+
]),
|
|
53
|
+
].sort(
|
|
54
|
+
(left, right) => left - right,
|
|
55
|
+
);
|
|
56
|
+
const duck = duckEnvelope(cue, cues);
|
|
57
|
+
const points: Array<{seconds: number; value: number}> = frames.map((frame) => ({
|
|
58
|
+
seconds: (frame - cue.fromFrame) / fps,
|
|
59
|
+
value: envelopeAtFrame(duck, frame) * (authored.length > 0 ? envelopeAtFrame(authored, frame) : 1) * cue.gain,
|
|
60
|
+
}));
|
|
61
|
+
const constant = points.every((point) => point.value === points[0].value);
|
|
62
|
+
if (constant) return `volume=${(points[0]?.value ?? cue.gain).toFixed(4)}`;
|
|
63
|
+
|
|
64
|
+
// Piecewise linear, innermost segment last, matching envelopeAtFrame().
|
|
65
|
+
let expression = points[points.length - 1].value.toFixed(4);
|
|
66
|
+
for (let index = points.length - 1; index > 0; index -= 1) {
|
|
67
|
+
const previous = points[index - 1];
|
|
68
|
+
const current = points[index];
|
|
69
|
+
const span = current.seconds - previous.seconds;
|
|
70
|
+
const segment =
|
|
71
|
+
span <= 0
|
|
72
|
+
? current.value.toFixed(4)
|
|
73
|
+
: `${previous.value.toFixed(4)}+${(current.value - previous.value).toFixed(4)}*(t-${previous.seconds.toFixed(
|
|
74
|
+
4,
|
|
75
|
+
)})/${span.toFixed(4)}`;
|
|
76
|
+
expression = `if(lt(t,${current.seconds.toFixed(4)}),${segment},${expression})`;
|
|
77
|
+
}
|
|
78
|
+
return `volume=volume='${expression}':eval=frame`;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const buildAudioFilter = (
|
|
82
|
+
inputs: MixInput[],
|
|
83
|
+
options: {fps: number; durationInFrames: number; targetLufs: number},
|
|
84
|
+
): {filter: string; label: string} => {
|
|
85
|
+
const {fps, durationInFrames, targetLufs} = options;
|
|
86
|
+
const totalSeconds = durationInFrames / fps;
|
|
87
|
+
const cues = inputs.map(({cue}) => cue);
|
|
88
|
+
const parts: string[] = [];
|
|
89
|
+
const labels: string[] = [];
|
|
90
|
+
|
|
91
|
+
inputs.forEach(({cue}, index) => {
|
|
92
|
+
const start = cue.trimStartSeconds;
|
|
93
|
+
const length = cue.durationInFrames / fps;
|
|
94
|
+
const delay = Math.round((cue.fromFrame / fps) * 1000);
|
|
95
|
+
const volume = volumeFilter(cue, cues, fps);
|
|
96
|
+
const label = `a${index}`;
|
|
97
|
+
const chain = [
|
|
98
|
+
// Index 1 is the video input, so audio inputs start at 1.
|
|
99
|
+
cue.loop ? `aloop=loop=-1:size=2147483647` : null,
|
|
100
|
+
// Every input is brought to one format before anything else touches it.
|
|
101
|
+
// Cues are mono, files are usually stereo, and a graph that leaves the
|
|
102
|
+
// difference to be inferred works on the encoder that happens to be
|
|
103
|
+
// installed and fails on the pinned one.
|
|
104
|
+
"aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo",
|
|
105
|
+
`atrim=start=${start.toFixed(4)}:duration=${length.toFixed(4)}`,
|
|
106
|
+
"asetpts=PTS-STARTPTS",
|
|
107
|
+
cue.fadeInFrames > 0 ? `afade=t=in:st=0:d=${(cue.fadeInFrames / fps).toFixed(4)}` : null,
|
|
108
|
+
cue.fadeOutFrames > 0
|
|
109
|
+
? `afade=t=out:st=${Math.max(0, length - cue.fadeOutFrames / fps).toFixed(4)}:d=${(
|
|
110
|
+
cue.fadeOutFrames / fps
|
|
111
|
+
).toFixed(4)}`
|
|
112
|
+
: null,
|
|
113
|
+
volume,
|
|
114
|
+
delay > 0 ? `adelay=${delay}|${delay}` : null,
|
|
115
|
+
`apad=whole_dur=${totalSeconds.toFixed(4)}`,
|
|
116
|
+
`atrim=duration=${totalSeconds.toFixed(4)}`,
|
|
117
|
+
]
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.join(",");
|
|
120
|
+
parts.push(`[${index + 1}:a]${chain}[${label}]`);
|
|
121
|
+
labels.push(`[${label}]`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
parts.push(
|
|
125
|
+
`${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[mixed]`,
|
|
126
|
+
// loudnorm resamples to its own rate and can drop the layout on the way
|
|
127
|
+
// out, so the last link states the output format rather than negotiating
|
|
128
|
+
// it with whatever encoder is downstream.
|
|
129
|
+
`[mixed]loudnorm=I=${targetLufs}:TP=-1.5:LRA=11,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[audio]`,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return {filter: parts.join(";"), label: "[audio]"};
|
|
133
|
+
};
|
package/src/binaries.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {spawn} from "node:child_process";
|
|
2
|
+
import {chmod, mkdir, readFile, writeFile} from "node:fs/promises";
|
|
3
|
+
import {existsSync} from "node:fs";
|
|
4
|
+
import {createRequire} from "node:module";
|
|
5
|
+
import {homedir} from "node:os";
|
|
6
|
+
import {dirname, resolve} from "node:path";
|
|
7
|
+
import {install, computeExecutablePath, Browser, resolveBuildId, detectBrowserPlatform} from "@puppeteer/browsers";
|
|
8
|
+
import {resolveChromePath, type ResolvedConfig} from "./config";
|
|
9
|
+
import {log} from "./log";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The two programs a render actually runs.
|
|
13
|
+
*
|
|
14
|
+
* Both are pinned. A video is supposed to be reproducible from its source, and
|
|
15
|
+
* that promise is only as good as the software that draws and encodes it: a
|
|
16
|
+
* different Chrome lays out text a fraction differently, and a different
|
|
17
|
+
* FFmpeg build can be compiled with different defaults. A machine that resolves
|
|
18
|
+
* "whatever is installed" produces frames that are almost the same, which is
|
|
19
|
+
* the worst kind of difference — invisible in review, present in the file.
|
|
20
|
+
*
|
|
21
|
+
* So Odori manages its own copies, keyed by version, in a cache outside the
|
|
22
|
+
* project. Local and cloud converge on the same bytes because they download
|
|
23
|
+
* the same bytes.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Chrome build the runtime is exercised against. Changing it changes frames. */
|
|
27
|
+
export const CHROME_BUILD = "131.0.6778.204";
|
|
28
|
+
|
|
29
|
+
/** The npm package that carries a static FFmpeg per platform. */
|
|
30
|
+
const FFMPEG_PACKAGE = "ffmpeg-static";
|
|
31
|
+
|
|
32
|
+
export type BinaryOrigin = "configured" | "environment" | "managed" | "package" | "system";
|
|
33
|
+
|
|
34
|
+
export type ResolvedBinary = {
|
|
35
|
+
path: string;
|
|
36
|
+
origin: BinaryOrigin;
|
|
37
|
+
/** Pinned version for managed copies; whatever the host has otherwise. */
|
|
38
|
+
version: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where managed binaries live. Outside the project on purpose: one download
|
|
43
|
+
* serves every project on the machine, and a `node_modules` wipe does not cost
|
|
44
|
+
* a 150 MB round trip. `ODORI_CACHE` moves it, which is what a CI cache key or
|
|
45
|
+
* a Docker layer wants.
|
|
46
|
+
*/
|
|
47
|
+
export const cacheRoot = (): string =>
|
|
48
|
+
process.env.ODORI_CACHE ?? resolve(process.env.XDG_CACHE_HOME ?? resolve(homedir(), ".cache"), "odori");
|
|
49
|
+
|
|
50
|
+
const browserCache = () => resolve(cacheRoot(), "browsers");
|
|
51
|
+
const ffmpegCache = () => resolve(cacheRoot(), "ffmpeg", "5.3.0");
|
|
52
|
+
|
|
53
|
+
const onPath = (command: string, args: string[]): Promise<boolean> =>
|
|
54
|
+
new Promise((done) => {
|
|
55
|
+
const child = spawn(command, args, {stdio: "ignore"});
|
|
56
|
+
child.on("error", () => done(false));
|
|
57
|
+
child.on("close", (code) => done(code === 0));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** The managed Chrome for this platform, whether or not it is downloaded. */
|
|
61
|
+
const managedBrowserPath = (): string | null => {
|
|
62
|
+
const platform = detectBrowserPlatform();
|
|
63
|
+
if (!platform) return null;
|
|
64
|
+
return computeExecutablePath({
|
|
65
|
+
browser: Browser.CHROMEHEADLESSSHELL,
|
|
66
|
+
buildId: CHROME_BUILD,
|
|
67
|
+
cacheDir: browserCache(),
|
|
68
|
+
platform,
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Chrome, in the order a reader would guess: what the project configured, what
|
|
74
|
+
* the environment overrode, the managed copy, then whatever the machine has.
|
|
75
|
+
*
|
|
76
|
+
* The system fallback is last and deliberate. It keeps a machine that already
|
|
77
|
+
* has Chrome working with no download, and `odori doctor` says which one was
|
|
78
|
+
* used so an unexplained difference between two renders has somewhere to start.
|
|
79
|
+
*/
|
|
80
|
+
export const resolveBrowser = async (config: ResolvedConfig): Promise<ResolvedBinary | null> => {
|
|
81
|
+
if (config.chromePath && existsSync(config.chromePath)) {
|
|
82
|
+
return {path: config.chromePath, origin: "configured", version: "unknown"};
|
|
83
|
+
}
|
|
84
|
+
if (process.env.ODORI_CHROME && existsSync(process.env.ODORI_CHROME)) {
|
|
85
|
+
return {path: process.env.ODORI_CHROME, origin: "environment", version: "unknown"};
|
|
86
|
+
}
|
|
87
|
+
const managed = managedBrowserPath();
|
|
88
|
+
if (managed && existsSync(managed)) return {path: managed, origin: "managed", version: CHROME_BUILD};
|
|
89
|
+
|
|
90
|
+
const system = resolveChromePath();
|
|
91
|
+
return system ? {path: system, origin: "system", version: "unknown"} : null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The FFmpeg the package manager unpacked, if it ran the package's install. */
|
|
95
|
+
const packagedFfmpeg = (): string | null => {
|
|
96
|
+
try {
|
|
97
|
+
const require = createRequire(import.meta.url);
|
|
98
|
+
const path = require(FFMPEG_PACKAGE) as string | null;
|
|
99
|
+
return path && existsSync(path) ? path : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const managedFfmpegPath = () => resolve(ffmpegCache(), process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
|
|
106
|
+
|
|
107
|
+
export const resolveFfmpeg = async (config: ResolvedConfig): Promise<ResolvedBinary | null> => {
|
|
108
|
+
if (config.ffmpegPath && existsSync(config.ffmpegPath)) {
|
|
109
|
+
return {path: config.ffmpegPath, origin: "configured", version: "unknown"};
|
|
110
|
+
}
|
|
111
|
+
if (process.env.ODORI_FFMPEG && existsSync(process.env.ODORI_FFMPEG)) {
|
|
112
|
+
return {path: process.env.ODORI_FFMPEG, origin: "environment", version: "unknown"};
|
|
113
|
+
}
|
|
114
|
+
const managed = managedFfmpegPath();
|
|
115
|
+
if (existsSync(managed)) return {path: managed, origin: "managed", version: "5.3.0"};
|
|
116
|
+
|
|
117
|
+
const packaged = packagedFfmpeg();
|
|
118
|
+
if (packaged) return {path: packaged, origin: "package", version: "5.3.0"};
|
|
119
|
+
|
|
120
|
+
if (await onPath("ffmpeg", ["-version"])) return {path: "ffmpeg", origin: "system", version: "unknown"};
|
|
121
|
+
return null;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Download the pinned Chrome if it is not already cached.
|
|
126
|
+
*
|
|
127
|
+
* `chrome-headless-shell` rather than full Chrome: it is the build made for
|
|
128
|
+
* exactly this — rendering frames with no window, no profile, and no updater —
|
|
129
|
+
* and it is a third of the download.
|
|
130
|
+
*/
|
|
131
|
+
export const installBrowser = async (options: {onProgress?: (fraction: number) => void} = {}): Promise<string> => {
|
|
132
|
+
const platform = detectBrowserPlatform();
|
|
133
|
+
if (!platform) {
|
|
134
|
+
throw new Error(`No managed Chrome build for ${process.platform}/${process.arch}. Set chromePath in odori.config.ts.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const existing = managedBrowserPath();
|
|
138
|
+
if (existing && existsSync(existing)) return existing;
|
|
139
|
+
|
|
140
|
+
const buildId = await resolveBuildId(Browser.CHROMEHEADLESSSHELL, platform, CHROME_BUILD).catch(() => CHROME_BUILD);
|
|
141
|
+
const installed = await install({
|
|
142
|
+
browser: Browser.CHROMEHEADLESSSHELL,
|
|
143
|
+
buildId,
|
|
144
|
+
cacheDir: browserCache(),
|
|
145
|
+
platform,
|
|
146
|
+
downloadProgressCallback: (downloaded, total) => options.onProgress?.(total ? downloaded / total : 0),
|
|
147
|
+
});
|
|
148
|
+
return installed.executablePath;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Download the pinned FFmpeg if it is not already cached.
|
|
153
|
+
*
|
|
154
|
+
* The static build is fetched through its own package rather than from a URL
|
|
155
|
+
* we maintain, so the platform matrix and the checksums stay somebody else's
|
|
156
|
+
* job. It runs here instead of at install time because package managers now
|
|
157
|
+
* block install scripts by default, and a binary that only arrives when a
|
|
158
|
+
* postinstall is allowed is a binary that is missing on half of all machines.
|
|
159
|
+
*/
|
|
160
|
+
export const installFfmpeg = async (): Promise<string> => {
|
|
161
|
+
const managed = managedFfmpegPath();
|
|
162
|
+
if (existsSync(managed)) return managed;
|
|
163
|
+
|
|
164
|
+
const packaged = packagedFfmpeg();
|
|
165
|
+
if (packaged) {
|
|
166
|
+
// Already unpacked by the package manager: copy it into the cache so the
|
|
167
|
+
// path a manifest records does not depend on a node_modules tree.
|
|
168
|
+
await mkdir(dirname(managed), {recursive: true});
|
|
169
|
+
await writeFile(managed, await readFile(packaged));
|
|
170
|
+
await chmod(managed, 0o755);
|
|
171
|
+
return managed;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const require = createRequire(import.meta.url);
|
|
175
|
+
const installer = require.resolve(`${FFMPEG_PACKAGE}/install.js`);
|
|
176
|
+
await mkdir(dirname(managed), {recursive: true});
|
|
177
|
+
|
|
178
|
+
await new Promise<void>((done, fail) => {
|
|
179
|
+
const child = spawn(process.execPath, [installer], {
|
|
180
|
+
// The package writes to its own directory, which is where it also looks.
|
|
181
|
+
cwd: dirname(installer),
|
|
182
|
+
env: {...process.env, FFMPEG_BIN_PATH: managed},
|
|
183
|
+
stdio: "inherit",
|
|
184
|
+
});
|
|
185
|
+
child.on("error", fail);
|
|
186
|
+
child.on("close", (code) => (code === 0 ? done() : fail(new Error(`FFmpeg download exited with ${code}`))));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (!existsSync(managed)) {
|
|
190
|
+
const unpacked = packagedFfmpeg();
|
|
191
|
+
if (!unpacked) throw new Error("FFmpeg downloaded but no binary was produced.");
|
|
192
|
+
await writeFile(managed, await readFile(unpacked));
|
|
193
|
+
await chmod(managed, 0o755);
|
|
194
|
+
}
|
|
195
|
+
return managed;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* What a render will use, as a line of provenance.
|
|
200
|
+
*
|
|
201
|
+
* This goes in the manifest. Two renders of the same source that differ are
|
|
202
|
+
* either a bug or a different toolchain, and without this there is no way to
|
|
203
|
+
* tell which from the artefacts alone.
|
|
204
|
+
*/
|
|
205
|
+
export const renderToolchain = async (
|
|
206
|
+
config: ResolvedConfig,
|
|
207
|
+
): Promise<{chrome: string; chromeOrigin: BinaryOrigin | "missing"; ffmpeg: string; ffmpegOrigin: BinaryOrigin | "missing"}> => {
|
|
208
|
+
const [browser, ffmpeg] = await Promise.all([resolveBrowser(config), resolveFfmpeg(config)]);
|
|
209
|
+
return {
|
|
210
|
+
chrome: browser?.version ?? "missing",
|
|
211
|
+
chromeOrigin: browser?.origin ?? "missing",
|
|
212
|
+
ffmpeg: ffmpeg?.version ?? "missing",
|
|
213
|
+
ffmpegOrigin: ffmpeg?.origin ?? "missing",
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** Fetch everything a render needs, for a Docker layer or a cold CI runner. */
|
|
218
|
+
export const installCommand = async (): Promise<number> => {
|
|
219
|
+
log.title("odori install");
|
|
220
|
+
|
|
221
|
+
let lastReported = -1;
|
|
222
|
+
const chrome = await installBrowser({
|
|
223
|
+
onProgress: (fraction) => {
|
|
224
|
+
const percent = Math.floor(fraction * 100);
|
|
225
|
+
if (percent >= lastReported + 10) {
|
|
226
|
+
lastReported = percent;
|
|
227
|
+
log.progress(`Chrome ${CHROME_BUILD}: ${percent}%`);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
log.progressDone();
|
|
232
|
+
log.success(`Chrome ${CHROME_BUILD}`);
|
|
233
|
+
log.detail(chrome);
|
|
234
|
+
|
|
235
|
+
const ffmpeg = await installFfmpeg();
|
|
236
|
+
log.success("FFmpeg 5.3.0");
|
|
237
|
+
log.detail(ffmpeg);
|
|
238
|
+
|
|
239
|
+
log.detail(`Cached in ${cacheRoot()}. Set ODORI_CACHE to move it.`);
|
|
240
|
+
return 0;
|
|
241
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {readFile, readdir, writeFile} from "node:fs/promises";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {join, relative, resolve} from "node:path";
|
|
4
|
+
import type {ResolvedConfig} from "./config";
|
|
5
|
+
|
|
6
|
+
export type BrandRegistration = {
|
|
7
|
+
/** The file that was edited, relative to the project root. */
|
|
8
|
+
file: string;
|
|
9
|
+
/** True when the cue was already registered under that name. */
|
|
10
|
+
already: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const SOURCE = /\.tsx?$/;
|
|
14
|
+
|
|
15
|
+
/** Every file under videos/ that defines a brand, nearest the root first. */
|
|
16
|
+
const brandFiles = async (config: ResolvedConfig): Promise<string[]> => {
|
|
17
|
+
const root = resolve(config.root, config.videosDir);
|
|
18
|
+
if (!existsSync(root)) return [];
|
|
19
|
+
|
|
20
|
+
const found: Array<{file: string; depth: number}> = [];
|
|
21
|
+
const walk = async (directory: string, depth: number) => {
|
|
22
|
+
for (const entry of await readdir(directory, {withFileTypes: true})) {
|
|
23
|
+
const path = join(directory, entry.name);
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
// Installed components are not where a brand lives.
|
|
26
|
+
if (path === resolve(config.root, config.componentsDir)) continue;
|
|
27
|
+
await walk(path, depth + 1);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!SOURCE.test(entry.name)) continue;
|
|
31
|
+
const source = await readFile(path, "utf8");
|
|
32
|
+
if (source.includes("defineBrand(")) found.push({file: path, depth});
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
await walk(root, 0);
|
|
37
|
+
return found.sort((left, right) => left.depth - right.depth).map((entry) => entry.file);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Register a generated cue in the project's brand.
|
|
42
|
+
*
|
|
43
|
+
* A cue that is not in the brand is silent, so installing one and leaving the
|
|
44
|
+
* wiring to the reader is installing something that does nothing. The edit is
|
|
45
|
+
* deliberately conservative: it only touches a file that already has an
|
|
46
|
+
* `audio: {cues: {` block, and it reports what it could not do rather than
|
|
47
|
+
* rewriting source it does not understand.
|
|
48
|
+
*/
|
|
49
|
+
export const registerCueInBrand = async (
|
|
50
|
+
config: ResolvedConfig,
|
|
51
|
+
cue: {name: string; export: string},
|
|
52
|
+
componentName: string,
|
|
53
|
+
): Promise<BrandRegistration | null> => {
|
|
54
|
+
for (const file of await brandFiles(config)) {
|
|
55
|
+
const source = await readFile(file, "utf8");
|
|
56
|
+
if (source.includes(`"${cue.name}"`) || source.includes(`'${cue.name}'`)) {
|
|
57
|
+
return {file: relative(config.root, file), already: true};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Two shapes, because both are written by hand and one of them is written
|
|
61
|
+
// by our own scaffold: a block spread over lines, and `cues: {}` inline.
|
|
62
|
+
const block = source.match(/(audio:\s*\{[\s\S]*?cues:\s*\{)([\s\S]*?)(\n(\s*)\},)/);
|
|
63
|
+
const inline = source.match(/(audio:\s*\{[^\n}]*cues:\s*\{)([^\n{}]*)(\})/);
|
|
64
|
+
|
|
65
|
+
let withCue: string;
|
|
66
|
+
if (block) {
|
|
67
|
+
const indent = `${block[4]} `;
|
|
68
|
+
const entry = `\n${indent}"${cue.name}": ${cue.export}(),`;
|
|
69
|
+
withCue = source.replace(block[0], `${block[1]}${block[2]}${entry}${block[3]}`);
|
|
70
|
+
} else if (inline) {
|
|
71
|
+
// An inline block grows in place rather than being reformatted: this
|
|
72
|
+
// edits somebody's source, and reflowing their file is not the job.
|
|
73
|
+
const existing = inline[2].trim();
|
|
74
|
+
const entry = `"${cue.name}": ${cue.export}()`;
|
|
75
|
+
withCue = source.replace(inline[0], `${inline[1]}${existing ? `${existing.replace(/,$/, "")}, ` : ""}${entry}${inline[3]}`);
|
|
76
|
+
} else {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The import path is relative to the brand file, which is usually the
|
|
81
|
+
// layout beside videos/components.
|
|
82
|
+
const from = resolve(config.root, config.componentsDir, componentName, componentName);
|
|
83
|
+
const specifier = relative(resolve(file, ".."), from).split("\\").join("/");
|
|
84
|
+
const importLine = `import {${cue.export}} from "${specifier.startsWith(".") ? specifier : `./${specifier}`}";`;
|
|
85
|
+
const withImport = withCue.includes(importLine)
|
|
86
|
+
? withCue
|
|
87
|
+
: withCue.replace(/^(import [\s\S]*?;\n)/, `$1${importLine}\n`);
|
|
88
|
+
|
|
89
|
+
await writeFile(file, withImport, "utf8");
|
|
90
|
+
return {file: relative(config.root, file), already: false};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {existsSync} from "node:fs";
|
|
2
|
+
import {copyFile, mkdir, readFile, writeFile} from "node:fs/promises";
|
|
3
|
+
import {join, resolve} from "node:path";
|
|
4
|
+
import {hashValue} from "odori";
|
|
5
|
+
import type {ResolvedConfig} from "./config";
|
|
6
|
+
import type {FrameChunk} from "./chunks";
|
|
7
|
+
|
|
8
|
+
export type ChunkIdentity = {
|
|
9
|
+
videoId: string;
|
|
10
|
+
chunk: FrameChunk;
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
fps: number;
|
|
14
|
+
preset: string;
|
|
15
|
+
/** The browser build that captured the frames. See `chunkKey`. */
|
|
16
|
+
renderer?: string;
|
|
17
|
+
/** The codec the chunk was encoded with, which decides what it can join. */
|
|
18
|
+
format?: string;
|
|
19
|
+
input?: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ChunkRecord = {key: string; signatures: string[]; createdAt: string};
|
|
23
|
+
|
|
24
|
+
const cacheDir = (config: ResolvedConfig) => resolve(config.root, config.outDir, "cache", "chunks");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A cache key identifies the slot, not the content.
|
|
28
|
+
*
|
|
29
|
+
* Hashing the project source would invalidate every chunk on any edit, which
|
|
30
|
+
* defeats the purpose. Instead the key covers what decides where a chunk sits
|
|
31
|
+
* and how it is encoded, and the recorded per-frame signatures decide whether
|
|
32
|
+
* the pictures are still the same.
|
|
33
|
+
*/
|
|
34
|
+
export const chunkKey = (identity: ChunkIdentity): string =>
|
|
35
|
+
hashValue({
|
|
36
|
+
videoId: identity.videoId,
|
|
37
|
+
// The browser that drew the frames is part of what the frames are. Without
|
|
38
|
+
// it, upgrading Chrome silently reuses pixels the new build would not have
|
|
39
|
+
// produced, which is the exact drift the pinned toolchain exists to stop.
|
|
40
|
+
renderer: identity.renderer ?? null,
|
|
41
|
+
// A chunk is an encoded file, not a bag of frames: H.264 chunks cannot be
|
|
42
|
+
// copied into a WebM, so a cache that ignored the codec would hand the
|
|
43
|
+
// muxer streams it cannot write.
|
|
44
|
+
format: identity.format ?? null,
|
|
45
|
+
sceneId: identity.chunk.sceneId ?? null,
|
|
46
|
+
start: identity.chunk.start,
|
|
47
|
+
end: identity.chunk.end,
|
|
48
|
+
width: identity.width,
|
|
49
|
+
height: identity.height,
|
|
50
|
+
fps: identity.fps,
|
|
51
|
+
preset: identity.preset,
|
|
52
|
+
input: identity.input ?? null,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const readChunkRecord = async (config: ResolvedConfig, key: string): Promise<ChunkRecord | null> => {
|
|
56
|
+
const meta = join(cacheDir(config), `${key}.json`);
|
|
57
|
+
const media = join(cacheDir(config), `${key}.mp4`);
|
|
58
|
+
if (!existsSync(meta) || !existsSync(media)) return null;
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(await readFile(meta, "utf8")) as ChunkRecord;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Copy a cached chunk into a render's working directory. */
|
|
67
|
+
export const useChunkRecord = async (config: ResolvedConfig, key: string, destination: string): Promise<void> => {
|
|
68
|
+
await copyFile(join(cacheDir(config), `${key}.mp4`), destination);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const writeChunkRecord = async (
|
|
72
|
+
config: ResolvedConfig,
|
|
73
|
+
key: string,
|
|
74
|
+
signatures: string[],
|
|
75
|
+
file: string,
|
|
76
|
+
): Promise<void> => {
|
|
77
|
+
const directory = cacheDir(config);
|
|
78
|
+
await mkdir(directory, {recursive: true});
|
|
79
|
+
await copyFile(file, join(directory, `${key}.mp4`));
|
|
80
|
+
const record: ChunkRecord = {key, signatures, createdAt: new Date().toISOString()};
|
|
81
|
+
await writeFile(join(directory, `${key}.json`), `${JSON.stringify(record)}\n`, "utf8");
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const signaturesMatch = (recorded: string[], observed: string[]): boolean =>
|
|
85
|
+
recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
|
package/src/chunks.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type FrameChunk = {index: number; start: number; end: number; sceneId?: string};
|
|
2
|
+
|
|
3
|
+
export type ChunkPlan = {chunks: FrameChunk[]; lanes: FrameChunk[][]};
|
|
4
|
+
|
|
5
|
+
export type PlanOptions = {
|
|
6
|
+
durationInFrames: number;
|
|
7
|
+
scenes?: Array<{id: string; start: number; durationInFrames: number}>;
|
|
8
|
+
concurrency: number;
|
|
9
|
+
/** Scenes longer than this are split so one long scene cannot own a worker. */
|
|
10
|
+
maxChunkFrames?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const length = (chunk: FrameChunk) => chunk.end - chunk.start + 1;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Split a timeline into contiguous chunks, then deal them out to workers.
|
|
17
|
+
*
|
|
18
|
+
* Chunks follow scene boundaries because a scene is the unit the runtime
|
|
19
|
+
* already names, which is what lets a chunk be encoded on its own, cached, or
|
|
20
|
+
* rendered on another machine. Long scenes are split so a single expensive
|
|
21
|
+
* scene cannot pin one worker while the others idle.
|
|
22
|
+
*/
|
|
23
|
+
export const planChunks = ({
|
|
24
|
+
durationInFrames,
|
|
25
|
+
scenes = [],
|
|
26
|
+
concurrency,
|
|
27
|
+
maxChunkFrames = 120,
|
|
28
|
+
}: PlanOptions): ChunkPlan => {
|
|
29
|
+
if (durationInFrames <= 0) return {chunks: [], lanes: []};
|
|
30
|
+
|
|
31
|
+
const bounded = Math.max(1, Math.min(concurrency, durationInFrames));
|
|
32
|
+
const ordered = [...scenes]
|
|
33
|
+
.filter((scene) => scene.durationInFrames > 0)
|
|
34
|
+
.sort((left, right) => left.start - right.start);
|
|
35
|
+
|
|
36
|
+
const spans: Array<{start: number; end: number; sceneId?: string}> = [];
|
|
37
|
+
let cursor = 0;
|
|
38
|
+
for (const scene of ordered) {
|
|
39
|
+
if (scene.start > cursor) spans.push({start: cursor, end: scene.start - 1});
|
|
40
|
+
const end = Math.min(durationInFrames - 1, scene.start + scene.durationInFrames - 1);
|
|
41
|
+
if (end >= scene.start) spans.push({start: scene.start, end, sceneId: scene.id});
|
|
42
|
+
cursor = end + 1;
|
|
43
|
+
}
|
|
44
|
+
if (cursor < durationInFrames) spans.push({start: cursor, end: durationInFrames - 1});
|
|
45
|
+
|
|
46
|
+
const chunks: FrameChunk[] = [];
|
|
47
|
+
for (const span of spans) {
|
|
48
|
+
// Aim for at least one chunk per worker, so parallelism is not wasted on a
|
|
49
|
+
// timeline made of one long scene.
|
|
50
|
+
const target = Math.max(1, Math.min(maxChunkFrames, Math.ceil(durationInFrames / bounded)));
|
|
51
|
+
const total = span.end - span.start + 1;
|
|
52
|
+
const pieces = Math.max(1, Math.ceil(total / target));
|
|
53
|
+
const size = Math.ceil(total / pieces);
|
|
54
|
+
for (let piece = 0; piece < pieces; piece += 1) {
|
|
55
|
+
const start = span.start + piece * size;
|
|
56
|
+
const end = Math.min(span.end, start + size - 1);
|
|
57
|
+
if (start > end) continue;
|
|
58
|
+
chunks.push({index: chunks.length, start, end, sceneId: span.sceneId});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Longest chunk first into the least loaded lane. Simple, and it keeps the
|
|
63
|
+
// slowest worker close to the average when scene costs differ.
|
|
64
|
+
const lanes: FrameChunk[][] = Array.from({length: bounded}, () => []);
|
|
65
|
+
const loads = new Array<number>(bounded).fill(0);
|
|
66
|
+
for (const chunk of [...chunks].sort((left, right) => length(right) - length(left))) {
|
|
67
|
+
let lane = 0;
|
|
68
|
+
for (let index = 1; index < bounded; index += 1) if (loads[index] < loads[lane]) lane = index;
|
|
69
|
+
lanes[lane].push(chunk);
|
|
70
|
+
loads[lane] += length(chunk);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const lane of lanes) lane.sort((left, right) => left.start - right.start);
|
|
74
|
+
return {chunks, lanes: lanes.filter((lane) => lane.length > 0)};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const chunkFrames = (chunk: FrameChunk): number[] =>
|
|
78
|
+
Array.from({length: length(chunk)}, (_, offset) => chunk.start + offset);
|