@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.
Files changed (70) hide show
  1. package/LICENSE +22 -0
  2. package/bin/odori.mjs +39 -0
  3. package/dist/chunk-7XJL2BYO.js +3552 -0
  4. package/dist/cli.d.ts +10 -0
  5. package/dist/cli.js +10 -0
  6. package/dist/index.d.ts +622 -0
  7. package/dist/index.js +156 -0
  8. package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
  9. package/package.json +50 -0
  10. package/src/audio-mix.ts +133 -0
  11. package/src/binaries.ts +241 -0
  12. package/src/brand-file.ts +94 -0
  13. package/src/chunk-cache.ts +85 -0
  14. package/src/chunks.ts +78 -0
  15. package/src/cli.ts +319 -0
  16. package/src/commands/add.ts +151 -0
  17. package/src/commands/dev.ts +160 -0
  18. package/src/commands/doctor.ts +162 -0
  19. package/src/commands/exportVideo.ts +198 -0
  20. package/src/commands/init.ts +56 -0
  21. package/src/commands/inspect.ts +72 -0
  22. package/src/commands/list.ts +22 -0
  23. package/src/commands/new.ts +126 -0
  24. package/src/commands/shared.ts +96 -0
  25. package/src/commands/still.ts +40 -0
  26. package/src/commands/test.ts +265 -0
  27. package/src/commands/update.ts +183 -0
  28. package/src/config.ts +84 -0
  29. package/src/contracts.ts +159 -0
  30. package/src/cues.ts +141 -0
  31. package/src/determinism.ts +82 -0
  32. package/src/diff.ts +71 -0
  33. package/src/discovery.ts +216 -0
  34. package/src/formats.ts +119 -0
  35. package/src/index.ts +58 -0
  36. package/src/integrity.ts +101 -0
  37. package/src/jobs.ts +151 -0
  38. package/src/log.ts +17 -0
  39. package/src/open.ts +32 -0
  40. package/src/paths.ts +12 -0
  41. package/src/prepare-cache.ts +58 -0
  42. package/src/project.ts +196 -0
  43. package/src/registry-snapshot.json +3431 -0
  44. package/src/registry-source.ts +269 -0
  45. package/src/render.ts +627 -0
  46. package/src/server.ts +307 -0
  47. package/studio/index.html +41 -0
  48. package/studio/src/Studio.tsx +192 -0
  49. package/studio/src/components/AudioClip.tsx +64 -0
  50. package/studio/src/components/CanvasStage.tsx +79 -0
  51. package/studio/src/components/CommandPalette.tsx +129 -0
  52. package/studio/src/components/Diagnostics.tsx +93 -0
  53. package/studio/src/components/ExportPanel.tsx +234 -0
  54. package/studio/src/components/InputControls.tsx +110 -0
  55. package/studio/src/components/Thumbnail.tsx +71 -0
  56. package/studio/src/components/Transport.tsx +237 -0
  57. package/studio/src/components/Waveform.tsx +114 -0
  58. package/studio/src/components/Wordmark.tsx +449 -0
  59. package/studio/src/components/ui.tsx +138 -0
  60. package/studio/src/lib/mix-loudness.ts +52 -0
  61. package/studio/src/main.tsx +34 -0
  62. package/studio/src/shortcuts.ts +27 -0
  63. package/studio/src/studio.css +1232 -0
  64. package/studio/src/theme.ts +61 -0
  65. package/studio/src/views/AssetsView.tsx +111 -0
  66. package/studio/src/views/BrandsView.tsx +139 -0
  67. package/studio/src/views/ComponentsView.tsx +285 -0
  68. package/studio/src/views/HomeView.tsx +122 -0
  69. package/studio/src/views/VideosView.tsx +343 -0
  70. package/studio/src/virtual.d.ts +25 -0
@@ -0,0 +1,3552 @@
1
+ // src/cli.ts
2
+ import { createRequire as createRequire4 } from "module";
3
+
4
+ // src/log.ts
5
+ var ESC = String.fromCharCode(27);
6
+ var wrap = (code, value) => `${ESC}[${code}m${value}${ESC}[0m`;
7
+ var log = {
8
+ info: (message2) => console.log(message2),
9
+ detail: (message2) => console.log(wrap("2", message2)),
10
+ title: (message2) => console.log(wrap("1", message2)),
11
+ success: (message2) => console.log(`${wrap("32", "ok")} ${message2}`),
12
+ warn: (message2) => console.warn(`${wrap("33", "!")} ${message2}`),
13
+ error: (message2) => console.error(`${wrap("31", "x")} ${message2}`),
14
+ progress: (message2) => {
15
+ if (process.stdout.isTTY) process.stdout.write(`\r${wrap("2", message2)}${ESC}[K`);
16
+ },
17
+ progressDone: () => {
18
+ if (process.stdout.isTTY) process.stdout.write(`\r${ESC}[K`);
19
+ }
20
+ };
21
+
22
+ // src/commands/add.ts
23
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
24
+ import { existsSync as existsSync6 } from "fs";
25
+ import { relative as relative3, resolve as resolve6 } from "path";
26
+ import { hashString as hashString2 } from "odori";
27
+
28
+ // src/config.ts
29
+ import { existsSync } from "fs";
30
+ import { resolve } from "path";
31
+ import { pathToFileURL } from "url";
32
+ var defaultConfig = {
33
+ videosDir: "videos",
34
+ outDir: ".odori",
35
+ exportDir: "out",
36
+ componentsDir: "videos/components",
37
+ audioDir: "public/audio",
38
+ port: 4300,
39
+ docsUrl: "https://odori.dev/docs"
40
+ };
41
+ var CHROME_CANDIDATES = [
42
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
43
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
44
+ "/usr/bin/google-chrome",
45
+ "/usr/bin/chromium",
46
+ "/usr/bin/chromium-browser"
47
+ ];
48
+ var resolveChromePath = (configured) => {
49
+ const candidates = [configured, process.env.ODORI_CHROME, ...CHROME_CANDIDATES].filter(Boolean);
50
+ return candidates.find((candidate) => existsSync(candidate));
51
+ };
52
+ var loadConfig = async (root) => {
53
+ for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
54
+ const configPath = resolve(root, name);
55
+ if (!existsSync(configPath)) continue;
56
+ const loaded = await import(pathToFileURL(configPath).href);
57
+ return { ...defaultConfig, ...loaded.default ?? {}, root, configPath };
58
+ }
59
+ return { ...defaultConfig, root };
60
+ };
61
+ var defineConfig = (config) => config;
62
+
63
+ // src/brand-file.ts
64
+ import { readFile, readdir, writeFile } from "fs/promises";
65
+ import { existsSync as existsSync2 } from "fs";
66
+ import { join, relative, resolve as resolve2 } from "path";
67
+ var SOURCE = /\.tsx?$/;
68
+ var brandFiles = async (config) => {
69
+ const root = resolve2(config.root, config.videosDir);
70
+ if (!existsSync2(root)) return [];
71
+ const found = [];
72
+ const walk3 = async (directory2, depth) => {
73
+ for (const entry of await readdir(directory2, { withFileTypes: true })) {
74
+ const path = join(directory2, entry.name);
75
+ if (entry.isDirectory()) {
76
+ if (path === resolve2(config.root, config.componentsDir)) continue;
77
+ await walk3(path, depth + 1);
78
+ continue;
79
+ }
80
+ if (!SOURCE.test(entry.name)) continue;
81
+ const source = await readFile(path, "utf8");
82
+ if (source.includes("defineBrand(")) found.push({ file: path, depth });
83
+ }
84
+ };
85
+ await walk3(root, 0);
86
+ return found.sort((left, right) => left.depth - right.depth).map((entry) => entry.file);
87
+ };
88
+ var registerCueInBrand = async (config, cue, componentName) => {
89
+ for (const file of await brandFiles(config)) {
90
+ const source = await readFile(file, "utf8");
91
+ if (source.includes(`"${cue.name}"`) || source.includes(`'${cue.name}'`)) {
92
+ return { file: relative(config.root, file), already: true };
93
+ }
94
+ const block = source.match(/(audio:\s*\{[\s\S]*?cues:\s*\{)([\s\S]*?)(\n(\s*)\},)/);
95
+ const inline = source.match(/(audio:\s*\{[^\n}]*cues:\s*\{)([^\n{}]*)(\})/);
96
+ let withCue;
97
+ if (block) {
98
+ const indent = `${block[4]} `;
99
+ const entry = `
100
+ ${indent}"${cue.name}": ${cue.export}(),`;
101
+ withCue = source.replace(block[0], `${block[1]}${block[2]}${entry}${block[3]}`);
102
+ } else if (inline) {
103
+ const existing = inline[2].trim();
104
+ const entry = `"${cue.name}": ${cue.export}()`;
105
+ withCue = source.replace(inline[0], `${inline[1]}${existing ? `${existing.replace(/,$/, "")}, ` : ""}${entry}${inline[3]}`);
106
+ } else {
107
+ continue;
108
+ }
109
+ const from = resolve2(config.root, config.componentsDir, componentName, componentName);
110
+ const specifier = relative(resolve2(file, ".."), from).split("\\").join("/");
111
+ const importLine = `import {${cue.export}} from "${specifier.startsWith(".") ? specifier : `./${specifier}`}";`;
112
+ const withImport = withCue.includes(importLine) ? withCue : withCue.replace(/^(import [\s\S]*?;\n)/, `$1${importLine}
113
+ `);
114
+ await writeFile(file, withImport, "utf8");
115
+ return { file: relative(config.root, file), already: false };
116
+ }
117
+ return null;
118
+ };
119
+
120
+ // src/registry-source.ts
121
+ import { createHash } from "crypto";
122
+ import { existsSync as existsSync4 } from "fs";
123
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
124
+ import { dirname as dirname2, resolve as resolve4 } from "path";
125
+
126
+ // src/binaries.ts
127
+ import { spawn } from "child_process";
128
+ import { chmod, mkdir, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
129
+ import { existsSync as existsSync3 } from "fs";
130
+ import { createRequire } from "module";
131
+ import { homedir } from "os";
132
+ import { dirname, resolve as resolve3 } from "path";
133
+ import { install, computeExecutablePath, Browser, resolveBuildId, detectBrowserPlatform } from "@puppeteer/browsers";
134
+ var CHROME_BUILD = "131.0.6778.204";
135
+ var FFMPEG_PACKAGE = "ffmpeg-static";
136
+ var cacheRoot = () => process.env.ODORI_CACHE ?? resolve3(process.env.XDG_CACHE_HOME ?? resolve3(homedir(), ".cache"), "odori");
137
+ var browserCache = () => resolve3(cacheRoot(), "browsers");
138
+ var ffmpegCache = () => resolve3(cacheRoot(), "ffmpeg", "5.3.0");
139
+ var onPath = (command2, args) => new Promise((done) => {
140
+ const child = spawn(command2, args, { stdio: "ignore" });
141
+ child.on("error", () => done(false));
142
+ child.on("close", (code) => done(code === 0));
143
+ });
144
+ var managedBrowserPath = () => {
145
+ const platform = detectBrowserPlatform();
146
+ if (!platform) return null;
147
+ return computeExecutablePath({
148
+ browser: Browser.CHROMEHEADLESSSHELL,
149
+ buildId: CHROME_BUILD,
150
+ cacheDir: browserCache(),
151
+ platform
152
+ });
153
+ };
154
+ var resolveBrowser = async (config) => {
155
+ if (config.chromePath && existsSync3(config.chromePath)) {
156
+ return { path: config.chromePath, origin: "configured", version: "unknown" };
157
+ }
158
+ if (process.env.ODORI_CHROME && existsSync3(process.env.ODORI_CHROME)) {
159
+ return { path: process.env.ODORI_CHROME, origin: "environment", version: "unknown" };
160
+ }
161
+ const managed = managedBrowserPath();
162
+ if (managed && existsSync3(managed)) return { path: managed, origin: "managed", version: CHROME_BUILD };
163
+ const system = resolveChromePath();
164
+ return system ? { path: system, origin: "system", version: "unknown" } : null;
165
+ };
166
+ var packagedFfmpeg = () => {
167
+ try {
168
+ const require2 = createRequire(import.meta.url);
169
+ const path = require2(FFMPEG_PACKAGE);
170
+ return path && existsSync3(path) ? path : null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ };
175
+ var managedFfmpegPath = () => resolve3(ffmpegCache(), process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
176
+ var resolveFfmpeg = async (config) => {
177
+ if (config.ffmpegPath && existsSync3(config.ffmpegPath)) {
178
+ return { path: config.ffmpegPath, origin: "configured", version: "unknown" };
179
+ }
180
+ if (process.env.ODORI_FFMPEG && existsSync3(process.env.ODORI_FFMPEG)) {
181
+ return { path: process.env.ODORI_FFMPEG, origin: "environment", version: "unknown" };
182
+ }
183
+ const managed = managedFfmpegPath();
184
+ if (existsSync3(managed)) return { path: managed, origin: "managed", version: "5.3.0" };
185
+ const packaged = packagedFfmpeg();
186
+ if (packaged) return { path: packaged, origin: "package", version: "5.3.0" };
187
+ if (await onPath("ffmpeg", ["-version"])) return { path: "ffmpeg", origin: "system", version: "unknown" };
188
+ return null;
189
+ };
190
+ var installBrowser = async (options = {}) => {
191
+ const platform = detectBrowserPlatform();
192
+ if (!platform) {
193
+ throw new Error(`No managed Chrome build for ${process.platform}/${process.arch}. Set chromePath in odori.config.ts.`);
194
+ }
195
+ const existing = managedBrowserPath();
196
+ if (existing && existsSync3(existing)) return existing;
197
+ const buildId = await resolveBuildId(Browser.CHROMEHEADLESSSHELL, platform, CHROME_BUILD).catch(() => CHROME_BUILD);
198
+ const installed = await install({
199
+ browser: Browser.CHROMEHEADLESSSHELL,
200
+ buildId,
201
+ cacheDir: browserCache(),
202
+ platform,
203
+ downloadProgressCallback: (downloaded, total) => options.onProgress?.(total ? downloaded / total : 0)
204
+ });
205
+ return installed.executablePath;
206
+ };
207
+ var installFfmpeg = async () => {
208
+ const managed = managedFfmpegPath();
209
+ if (existsSync3(managed)) return managed;
210
+ const packaged = packagedFfmpeg();
211
+ if (packaged) {
212
+ await mkdir(dirname(managed), { recursive: true });
213
+ await writeFile2(managed, await readFile2(packaged));
214
+ await chmod(managed, 493);
215
+ return managed;
216
+ }
217
+ const require2 = createRequire(import.meta.url);
218
+ const installer = require2.resolve(`${FFMPEG_PACKAGE}/install.js`);
219
+ await mkdir(dirname(managed), { recursive: true });
220
+ await new Promise((done, fail) => {
221
+ const child = spawn(process.execPath, [installer], {
222
+ // The package writes to its own directory, which is where it also looks.
223
+ cwd: dirname(installer),
224
+ env: { ...process.env, FFMPEG_BIN_PATH: managed },
225
+ stdio: "inherit"
226
+ });
227
+ child.on("error", fail);
228
+ child.on("close", (code) => code === 0 ? done() : fail(new Error(`FFmpeg download exited with ${code}`)));
229
+ });
230
+ if (!existsSync3(managed)) {
231
+ const unpacked = packagedFfmpeg();
232
+ if (!unpacked) throw new Error("FFmpeg downloaded but no binary was produced.");
233
+ await writeFile2(managed, await readFile2(unpacked));
234
+ await chmod(managed, 493);
235
+ }
236
+ return managed;
237
+ };
238
+ var renderToolchain = async (config) => {
239
+ const [browser, ffmpeg] = await Promise.all([resolveBrowser(config), resolveFfmpeg(config)]);
240
+ return {
241
+ chrome: browser?.version ?? "missing",
242
+ chromeOrigin: browser?.origin ?? "missing",
243
+ ffmpeg: ffmpeg?.version ?? "missing",
244
+ ffmpegOrigin: ffmpeg?.origin ?? "missing"
245
+ };
246
+ };
247
+ var installCommand = async () => {
248
+ log.title("odori install");
249
+ let lastReported = -1;
250
+ const chrome = await installBrowser({
251
+ onProgress: (fraction) => {
252
+ const percent = Math.floor(fraction * 100);
253
+ if (percent >= lastReported + 10) {
254
+ lastReported = percent;
255
+ log.progress(`Chrome ${CHROME_BUILD}: ${percent}%`);
256
+ }
257
+ }
258
+ });
259
+ log.progressDone();
260
+ log.success(`Chrome ${CHROME_BUILD}`);
261
+ log.detail(chrome);
262
+ const ffmpeg = await installFfmpeg();
263
+ log.success("FFmpeg 5.3.0");
264
+ log.detail(ffmpeg);
265
+ log.detail(`Cached in ${cacheRoot()}. Set ODORI_CACHE to move it.`);
266
+ return 0;
267
+ };
268
+
269
+ // src/registry-source.ts
270
+ var normalizeComponentName = (name) => name.replace(/^@odori\//, "");
271
+ var DEFAULT_URL = "https://odori.dev/r/v1";
272
+ var registryUrl = (config) => (config.registryUrl ?? process.env.ODORI_REGISTRY ?? DEFAULT_URL).replace(/\/$/, "");
273
+ var cacheDir = (url) => resolve4(cacheRoot(), "registry", createHash("sha256").update(url).digest("hex").slice(0, 16));
274
+ var toComponent = (item) => ({
275
+ name: item.name,
276
+ namespaced: item.meta?.namespaced ?? `@odori/${item.name}`,
277
+ kind: item.meta?.kind ?? "component",
278
+ ...item.meta?.cue ? { cue: item.meta.cue } : {},
279
+ family: item.meta?.family ?? "Uncategorized",
280
+ description: item.description ?? "",
281
+ files: item.files.map((file) => file.path.split("/").pop() ?? file.path),
282
+ registryDependencies: item.registryDependencies ?? [],
283
+ contract: item.meta?.contract
284
+ });
285
+ var snapshotItems = async () => {
286
+ try {
287
+ const loaded = await import("./registry-snapshot-NIH2JMQ6.js");
288
+ return loaded.default.items;
289
+ } catch {
290
+ throw new Error(
291
+ "No registry available: the network and the cache both failed, and this CLI has no snapshot built into it. Run `pnpm snapshot` in packages/odori-cli, or set registryUrl to a reachable registry."
292
+ );
293
+ }
294
+ };
295
+ var bundled = async () => (await snapshotItems()).map(toComponent);
296
+ var fetchJson = async (url, timeoutMs = 8e3) => {
297
+ const controller = new AbortController();
298
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
299
+ try {
300
+ const response = await fetch(url, { signal: controller.signal, headers: { accept: "application/json" } });
301
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
302
+ return await response.json();
303
+ } finally {
304
+ clearTimeout(timer);
305
+ }
306
+ };
307
+ var resolveRegistry = async (config, options = {}) => {
308
+ const url = registryUrl(config);
309
+ const cache = resolve4(cacheDir(url), "registry.json");
310
+ if (options.allowNetwork !== false) {
311
+ try {
312
+ const index = await fetchJson(`${url}/registry.json`);
313
+ if (!Array.isArray(index.items)) throw new Error("the index has no items array");
314
+ await mkdir2(dirname2(cache), { recursive: true });
315
+ await writeFile3(cache, JSON.stringify(index), "utf8");
316
+ return { items: index.items.map(toComponent), origin: "network", detail: url };
317
+ } catch {
318
+ }
319
+ }
320
+ if (existsSync4(cache)) {
321
+ try {
322
+ const index = JSON.parse(await readFile3(cache, "utf8"));
323
+ return { items: index.items.map(toComponent), origin: "cache", detail: cache };
324
+ } catch {
325
+ }
326
+ }
327
+ return { items: await bundled(), origin: "bundled", detail: "the copy built into this CLI" };
328
+ };
329
+ var resolveItem = async (config, name, options = {}) => {
330
+ const url = registryUrl(config);
331
+ const cache = resolve4(cacheDir(url), `${name}.json`);
332
+ if (options.allowNetwork !== false) {
333
+ try {
334
+ const item2 = await fetchJson(`${url}/${name}.json`);
335
+ if (item2?.name !== name) throw new Error(`the document at ${url}/${name}.json is for "${item2?.name}"`);
336
+ await mkdir2(dirname2(cache), { recursive: true });
337
+ await writeFile3(cache, JSON.stringify(item2), "utf8");
338
+ return { item: item2, origin: "network" };
339
+ } catch {
340
+ }
341
+ }
342
+ if (existsSync4(cache)) {
343
+ try {
344
+ return { item: JSON.parse(await readFile3(cache, "utf8")), origin: "cache" };
345
+ } catch {
346
+ }
347
+ }
348
+ const item = (await snapshotItems()).find((entry) => entry.name === name);
349
+ if (!item) throw new Error(`No component named "${name}" in the registry at ${url}, in the cache, or in this CLI.`);
350
+ return { item, origin: "bundled" };
351
+ };
352
+ var verifyIntegrity = (item) => {
353
+ const expected = item.meta?.integrity;
354
+ if (!expected) return;
355
+ const hash = createHash("sha256");
356
+ for (const file of [...item.files].sort((left, right) => left.path.localeCompare(right.path))) {
357
+ hash.update(file.path);
358
+ hash.update("\0");
359
+ hash.update(file.content);
360
+ hash.update("\0");
361
+ }
362
+ const actual = `sha256-${hash.digest("base64")}`;
363
+ if (actual === expected) return;
364
+ throw new Error(
365
+ `The files for "${item.name}" do not match the hash the registry published.
366
+ expected ${expected}
367
+ received ${actual}
368
+ Nothing was written. This is a truncated download, a stale proxy, or a tampered document.`
369
+ );
370
+ };
371
+
372
+ // src/commands/update.ts
373
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
374
+ import { existsSync as existsSync5 } from "fs";
375
+ import { relative as relative2, resolve as resolve5 } from "path";
376
+ import { hashString } from "odori";
377
+
378
+ // src/diff.ts
379
+ var lcs = (left, right) => {
380
+ const table = Array.from({ length: left.length + 1 }, () => new Array(right.length + 1).fill(0));
381
+ for (let i = left.length - 1; i >= 0; i -= 1) {
382
+ for (let j = right.length - 1; j >= 0; j -= 1) {
383
+ table[i][j] = left[i] === right[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
384
+ }
385
+ }
386
+ return table;
387
+ };
388
+ var diffLines = (before, after) => {
389
+ const left = before.split("\n");
390
+ const right = after.split("\n");
391
+ const table = lcs(left, right);
392
+ const output = [];
393
+ let i = 0;
394
+ let j = 0;
395
+ while (i < left.length && j < right.length) {
396
+ if (left[i] === right[j]) {
397
+ output.push({ type: "context", text: left[i] });
398
+ i += 1;
399
+ j += 1;
400
+ } else if (table[i + 1][j] >= table[i][j + 1]) {
401
+ output.push({ type: "remove", text: left[i] });
402
+ i += 1;
403
+ } else {
404
+ output.push({ type: "add", text: right[j] });
405
+ j += 1;
406
+ }
407
+ }
408
+ while (i < left.length) output.push({ type: "remove", text: left[i++] });
409
+ while (j < right.length) output.push({ type: "add", text: right[j++] });
410
+ return output;
411
+ };
412
+ var countChanges = (lines) => ({
413
+ added: lines.filter((line) => line.type === "add").length,
414
+ removed: lines.filter((line) => line.type === "remove").length
415
+ });
416
+ var formatDiff = (lines, context = 2) => {
417
+ const keep = /* @__PURE__ */ new Set();
418
+ lines.forEach((line, index) => {
419
+ if (line.type === "context") return;
420
+ for (let offset = -context; offset <= context; offset += 1) keep.add(index + offset);
421
+ });
422
+ const output = [];
423
+ let skipping = false;
424
+ lines.forEach((line, index) => {
425
+ if (!keep.has(index)) {
426
+ if (!skipping) output.push(" ...");
427
+ skipping = true;
428
+ return;
429
+ }
430
+ skipping = false;
431
+ const marker = line.type === "add" ? "+" : line.type === "remove" ? "-" : " ";
432
+ output.push(`${marker} ${line.text}`);
433
+ });
434
+ return output;
435
+ };
436
+
437
+ // src/commands/update.ts
438
+ var provenanceFile = (config) => resolve5(config.root, config.outDir, "components.json");
439
+ var readProvenance = async (config) => {
440
+ const file = provenanceFile(config);
441
+ if (!existsSync5(file)) return {};
442
+ return JSON.parse(await readFile4(file, "utf8"));
443
+ };
444
+ var writeProvenance = async (config, provenance) => {
445
+ await mkdir3(resolve5(config.root, config.outDir), { recursive: true });
446
+ await writeFile4(provenanceFile(config), `${JSON.stringify(provenance, null, 2)}
447
+ `, "utf8");
448
+ };
449
+ var componentStatus = async (config, only) => {
450
+ const { items: registry } = await resolveRegistry(config);
451
+ const provenance = await readProvenance(config);
452
+ const wanted = only?.map(normalizeComponentName);
453
+ const names = Object.keys(provenance).filter((name) => !wanted || wanted.includes(name));
454
+ const statuses = [];
455
+ for (const name of names) {
456
+ const component = registry.find((item2) => item2.name === name);
457
+ if (!component) continue;
458
+ const { item } = await resolveItem(config, name);
459
+ const upstreamFiles = new Map(item.files.map((file) => [file.path.split("/").pop() ?? file.path, file.content]));
460
+ const files = await Promise.all(
461
+ component.files.map(async (file) => {
462
+ const localPath = resolve5(config.root, config.componentsDir, name, file);
463
+ const content = upstreamFiles.get(file);
464
+ if (content === void 0) throw new Error(`The registry document for "${name}" has no file named ${file}.`);
465
+ const local = existsSync5(localPath) ? hashString(await readFile4(localPath, "utf8")) : null;
466
+ return {
467
+ file,
468
+ localPath,
469
+ content,
470
+ local,
471
+ installed: provenance[name]?.hashes[file] ?? null,
472
+ upstream: hashString(content)
473
+ };
474
+ })
475
+ );
476
+ const missing = files.some((file) => file.local === null);
477
+ const modified = files.some((file) => file.local !== null && file.local !== file.installed);
478
+ const outdated = files.some((file) => file.installed !== file.upstream);
479
+ const state = missing ? "missing" : modified && outdated ? "diverged" : modified ? "modified" : outdated ? "outdated" : "pristine";
480
+ statuses.push({ name, state, files });
481
+ }
482
+ return statuses.sort((left, right) => left.name.localeCompare(right.name));
483
+ };
484
+ var LABELS = {
485
+ pristine: "up to date",
486
+ modified: "modified locally",
487
+ outdated: "update available",
488
+ diverged: "modified locally and updated upstream",
489
+ missing: "files missing"
490
+ };
491
+ var diffCommand = async (names, options = {}) => {
492
+ const config = await loadConfig(process.cwd());
493
+ const statuses = await componentStatus(config, names.length > 0 ? names : void 0);
494
+ if (statuses.length === 0) {
495
+ log.detail("No registry components are installed yet. Run odori add first.");
496
+ return;
497
+ }
498
+ for (const status of statuses) {
499
+ log.title(`@odori/${status.name} ${LABELS[status.state]}`);
500
+ for (const file of status.files) {
501
+ if (file.local === null) {
502
+ log.error(` ${file.file} is missing from ${relative2(config.root, resolve5(file.localPath, ".."))}`);
503
+ continue;
504
+ }
505
+ if (file.local === file.upstream) {
506
+ log.detail(` ${file.file} identical to upstream`);
507
+ continue;
508
+ }
509
+ const lines = diffLines(await readFile4(file.localPath, "utf8"), file.content);
510
+ const { added, removed } = countChanges(lines);
511
+ log.info(` ${file.file} +${added} -${removed} against upstream`);
512
+ if (options.full) for (const line of formatDiff(lines)) log.detail(` ${line}`);
513
+ }
514
+ }
515
+ if (!options.full) log.detail("Pass --full to print the diff.");
516
+ };
517
+ var updateCommand = async (names, options = {}) => {
518
+ const config = await loadConfig(process.cwd());
519
+ const statuses = await componentStatus(config, names.length > 0 ? names : void 0);
520
+ if (statuses.length === 0) {
521
+ log.detail("No registry components are installed yet. Run odori add first.");
522
+ return;
523
+ }
524
+ const provenance = await readProvenance(config);
525
+ let updated = 0;
526
+ let skipped = 0;
527
+ for (const status of statuses) {
528
+ if (status.state === "pristine") {
529
+ log.detail(`@odori/${status.name} is up to date`);
530
+ continue;
531
+ }
532
+ if (status.state === "modified") {
533
+ log.detail(`@odori/${status.name} is modified locally, and upstream has not changed`);
534
+ continue;
535
+ }
536
+ if ((status.state === "diverged" || status.state === "missing") && !options.force) {
537
+ log.warn(`@odori/${status.name}: ${LABELS[status.state]}. Review with odori diff ${status.name}, then use --force.`);
538
+ skipped += 1;
539
+ continue;
540
+ }
541
+ for (const file of status.files) {
542
+ await mkdir3(resolve5(file.localPath, ".."), { recursive: true });
543
+ await writeFile4(file.localPath, file.content, "utf8");
544
+ }
545
+ provenance[status.name] = {
546
+ source: `@odori/${status.name}`,
547
+ version: "0.1.0",
548
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
549
+ hashes: Object.fromEntries(status.files.map((file) => [file.file, file.upstream]))
550
+ };
551
+ updated += 1;
552
+ log.success(`@odori/${status.name} updated`);
553
+ }
554
+ await writeProvenance(config, provenance);
555
+ log.detail(`${updated} updated, ${skipped} left for review.`);
556
+ };
557
+
558
+ // src/commands/add.ts
559
+ var addCommand = async (names, options = {}) => {
560
+ if (names.length === 0) throw new Error("Name at least one component, for example @odori/title-reveal.");
561
+ const config = await loadConfig(process.cwd());
562
+ const source = await resolveRegistry(config);
563
+ const registry = source.items;
564
+ const provenance = await readProvenance(config);
565
+ if (source.origin === "network") log.detail(`registry: ${source.detail}`);
566
+ else if (source.origin === "cache") log.detail(`registry: cached copy of ${registryUrl(config)} (offline)`);
567
+ else log.warn(`registry: the copy built into this CLI. It may be older than ${registryUrl(config)}.`);
568
+ const queue = [...names.map(normalizeComponentName)];
569
+ const installed = [];
570
+ while (queue.length > 0) {
571
+ const name = queue.shift();
572
+ if (installed.includes(name)) continue;
573
+ const component = registry.find((item2) => item2.name === name);
574
+ if (!component) {
575
+ throw new Error(`Unknown component "${name}". Known: ${registry.map((item2) => item2.namespaced).join(", ")}`);
576
+ }
577
+ queue.push(...component.registryDependencies.map(normalizeComponentName));
578
+ for (const cue of component.contract.requires.audio) {
579
+ const provider = registry.find((item2) => item2.cue?.name === cue);
580
+ if (provider) queue.push(provider.name);
581
+ else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
582
+ }
583
+ const { item } = await resolveItem(config, component.name);
584
+ verifyIntegrity(item);
585
+ const target = resolve6(config.root, config.componentsDir, component.name);
586
+ const hashes = {};
587
+ for (const file of item.files) {
588
+ const destination = resolve6(config.root, file.target);
589
+ const exists = existsSync6(destination);
590
+ log.detail(` ${exists ? "replace" : "create "} ${relative3(config.root, destination)}`);
591
+ }
592
+ if (options.dryRun) {
593
+ installed.push(component.name);
594
+ continue;
595
+ }
596
+ await mkdir4(target, { recursive: true });
597
+ for (const file of item.files) {
598
+ const name2 = file.path.split("/").pop() ?? file.path;
599
+ const destination = resolve6(config.root, file.target);
600
+ hashes[name2] = hashString2(file.content);
601
+ if (existsSync6(destination) && !options.force) {
602
+ const current = hashString2(await readFile5(destination, "utf8"));
603
+ const recorded = provenance[component.name]?.hashes[name2];
604
+ if (current !== recorded) {
605
+ log.warn(`${relative3(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
606
+ continue;
607
+ }
608
+ }
609
+ await mkdir4(resolve6(destination, ".."), { recursive: true });
610
+ await writeFile5(destination, file.content, "utf8");
611
+ }
612
+ provenance[component.name] = {
613
+ source: component.namespaced,
614
+ version: "0.1.0",
615
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
616
+ hashes
617
+ };
618
+ installed.push(component.name);
619
+ log.success(`${component.namespaced} to ${relative3(config.root, target)}/`);
620
+ if (component.kind === "cue" && component.cue) {
621
+ log.detail(
622
+ ` ${component.family} \xB7 ${component.contract.recommendedDurationInFrames} frames \xB7 registers "${component.cue.name}"`
623
+ );
624
+ const registered = await registerCueInBrand(config, component.cue, component.name);
625
+ if (registered?.already) {
626
+ log.detail(` "${component.cue.name}" is already registered in ${registered.file}`);
627
+ } else if (registered) {
628
+ log.detail(` registered "${component.cue.name}" in ${registered.file}`);
629
+ } else {
630
+ log.warn(` No brand with an audio.cues block found. Add it yourself:`);
631
+ log.detail(` import {${component.cue.export}} from "./components/${component.name}/${component.name}";`);
632
+ log.detail(` audio: {cues: {"${component.cue.name}": ${component.cue.export}()}}`);
633
+ }
634
+ } else {
635
+ log.detail(
636
+ ` ${component.family} \xB7 recommended ${component.contract.recommendedDurationInFrames} frames \xB7 ${component.contract.aspectRatios.join(", ")}`
637
+ );
638
+ }
639
+ }
640
+ if (options.dryRun) {
641
+ log.detail("Nothing was written. Drop --dry-run to install.");
642
+ return;
643
+ }
644
+ await writeProvenance(config, provenance);
645
+ log.detail("Run odori dev to preview the installed component fixtures.");
646
+ };
647
+ var registryCommand = async () => {
648
+ const config = await loadConfig(process.cwd());
649
+ const source = await resolveRegistry(config);
650
+ const registry = source.items;
651
+ const families = [...new Set(registry.map((component) => component.family))];
652
+ for (const family of families) {
653
+ log.title(family);
654
+ for (const component of registry.filter((item) => item.family === family)) {
655
+ log.info(` ${component.namespaced.padEnd(26)} ${component.description}`);
656
+ log.detail(
657
+ component.kind === "cue" && component.cue ? ` sound \xB7 ${component.contract.recommendedDurationInFrames} frames \xB7 registers "${component.cue.name}"` : ` ${component.contract.aspectRatios.join(", ")} \xB7 min ${component.contract.minimumDurationInFrames} frames \xB7 reduced motion: ${component.contract.reducedMotion}`
658
+ );
659
+ }
660
+ }
661
+ log.detail(
662
+ source.origin === "network" ? `${registry.length} entries from ${source.detail}` : source.origin === "cache" ? `${registry.length} entries from the cache (offline). Latest is at ${registryUrl(config)}.` : `${registry.length} entries from the copy built into this CLI. Latest is at ${registryUrl(config)}.`
663
+ );
664
+ };
665
+
666
+ // src/commands/dev.ts
667
+ import { resolve as resolve18 } from "path";
668
+ import { readFile as readFile12 } from "fs/promises";
669
+
670
+ // src/jobs.ts
671
+ import { mkdir as mkdir5, readFile as readFile6, readdir as readdir2, rename, writeFile as writeFile6 } from "fs/promises";
672
+ import { existsSync as existsSync7 } from "fs";
673
+ import { join as join2, resolve as resolve7 } from "path";
674
+ var buildsDir = (config) => resolve7(config.root, config.outDir, "builds");
675
+ var jobFile = (config, id) => join2(buildsDir(config), `${id}.json`);
676
+ var createJob = async (config, manifest, output) => {
677
+ await mkdir5(buildsDir(config), { recursive: true });
678
+ const now = (/* @__PURE__ */ new Date()).toISOString();
679
+ const job = {
680
+ id: `job-${manifest.manifestHash.slice(0, 10)}-${Date.now().toString(36)}`,
681
+ videoId: manifest.videoId,
682
+ manifestHash: manifest.manifestHash,
683
+ status: "queued",
684
+ progress: 0,
685
+ attempts: 0,
686
+ logs: [],
687
+ createdAt: now,
688
+ updatedAt: now
689
+ };
690
+ const record = { job, manifest, output };
691
+ await writeFile6(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}
692
+ `, "utf8");
693
+ return record;
694
+ };
695
+ var readJob = async (config, id) => {
696
+ const file = jobFile(config, id);
697
+ if (!existsSync7(file)) throw new Error(`Unknown job "${id}". Run odori jobs to list them.`);
698
+ return JSON.parse(await readFile6(file, "utf8"));
699
+ };
700
+ var writeLocks = /* @__PURE__ */ new Map();
701
+ var withJobLock = (id, task) => {
702
+ const previous = writeLocks.get(id) ?? Promise.resolve();
703
+ const next = previous.then(task, task);
704
+ writeLocks.set(
705
+ id,
706
+ next.catch(() => void 0)
707
+ );
708
+ return next;
709
+ };
710
+ var writeRecord = async (config, record) => {
711
+ const file = jobFile(config, record.job.id);
712
+ const temporary = `${file}.${process.pid}.tmp`;
713
+ await writeFile6(temporary, `${JSON.stringify(record, null, 2)}
714
+ `, "utf8");
715
+ await rename(temporary, file);
716
+ };
717
+ var updateJob = async (config, job) => withJobLock(job.id, async () => {
718
+ const record = await readJob(config, job.id);
719
+ const next = { ...job, logs: record.job.logs, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
720
+ await writeRecord(config, { ...record, job: next });
721
+ return next;
722
+ });
723
+ var appendJobLog = async (config, id, message2) => withJobLock(id, async () => {
724
+ const record = await readJob(config, id);
725
+ const next = {
726
+ ...record.job,
727
+ logs: [...record.job.logs.slice(-49), `${(/* @__PURE__ */ new Date()).toISOString()} ${message2}`],
728
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
729
+ };
730
+ await writeRecord(config, { ...record, job: next });
731
+ return next;
732
+ });
733
+ var alive = (pid) => {
734
+ if (!pid) return false;
735
+ try {
736
+ process.kill(pid, 0);
737
+ return true;
738
+ } catch {
739
+ return false;
740
+ }
741
+ };
742
+ var reconcileJobs = async (config) => {
743
+ const stale = (await listJobs(config, { reconcile: false })).filter(
744
+ (job) => (job.status === "rendering" || job.status === "encoding") && !alive(job.pid)
745
+ );
746
+ for (const job of stale) {
747
+ await updateJob(config, {
748
+ ...job,
749
+ status: "failed",
750
+ error: "The render process exited before the job finished."
751
+ });
752
+ }
753
+ return stale.length;
754
+ };
755
+ var listJobs = async (config, options = {}) => {
756
+ if (!existsSync7(buildsDir(config))) return [];
757
+ if (options.reconcile !== false) await reconcileJobs(config);
758
+ const files = (await readdir2(buildsDir(config))).filter((file) => file.endsWith(".json"));
759
+ const jobs = [];
760
+ for (const file of files) {
761
+ try {
762
+ const raw = await readFile6(join2(buildsDir(config), file), "utf8");
763
+ jobs.push(JSON.parse(raw).job);
764
+ } catch {
765
+ continue;
766
+ }
767
+ }
768
+ return jobs.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
769
+ };
770
+ var JobQueue = class {
771
+ chain = Promise.resolve();
772
+ enqueue(task) {
773
+ const result = this.chain.then(task, task);
774
+ this.chain = result.catch(() => void 0);
775
+ return result;
776
+ }
777
+ };
778
+
779
+ // src/discovery.ts
780
+ import { mkdir as mkdir6, readdir as readdir3, readFile as readFile7, stat, writeFile as writeFile7 } from "fs/promises";
781
+ import { existsSync as existsSync8 } from "fs";
782
+ import { join as join3, relative as relative4, resolve as resolve8, sep } from "path";
783
+ import { hashString as hashString3 } from "odori";
784
+ var IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".odori", "out", "dist", ".next"]);
785
+ var walk = async (directory2, files = []) => {
786
+ const entries = await readdir3(directory2, { withFileTypes: true });
787
+ for (const entry of entries) {
788
+ if (entry.name.startsWith(".") || IGNORED.has(entry.name)) continue;
789
+ const full = join3(directory2, entry.name);
790
+ if (entry.isDirectory()) await walk(full, files);
791
+ else files.push(full);
792
+ }
793
+ return files;
794
+ };
795
+ var toIdentifier = (value, prefix) => {
796
+ const cleaned = value.replace(
797
+ /[^a-zA-Z0-9]+(.)?/g,
798
+ (_, character) => character ? character.toUpperCase() : ""
799
+ );
800
+ return `${prefix}${cleaned.charAt(0).toUpperCase()}${cleaned.slice(1)}`;
801
+ };
802
+ var AUDIO_EXTENSIONS = /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i;
803
+ var discoverAudio = async (config) => {
804
+ const root = resolve8(config.root, config.audioDir);
805
+ if (!existsSync8(root)) return [];
806
+ const publicRoot = resolve8(config.root, "public");
807
+ const files = (await walk(root)).filter((file) => AUDIO_EXTENSIONS.test(file)).sort();
808
+ return Promise.all(
809
+ files.map(async (file) => ({
810
+ name: relative4(root, file).replace(AUDIO_EXTENSIONS, "").split(sep).join("/"),
811
+ url: file.startsWith(`${publicRoot}${sep}`) ? `/${relative4(publicRoot, file).split(sep).join("/")}` : `/${relative4(config.root, file).split(sep).join("/")}`,
812
+ relativeFile: relative4(config.root, file),
813
+ bytes: (await stat(file)).size
814
+ }))
815
+ );
816
+ };
817
+ var discoverProject = async (config) => {
818
+ const videosRoot = resolve8(config.root, config.videosDir);
819
+ if (!existsSync8(videosRoot)) {
820
+ throw new Error(`No ${config.videosDir}/ directory found in ${config.root}. Run "odori init" first.`);
821
+ }
822
+ const files = (await walk(videosRoot)).sort();
823
+ const audio = await discoverAudio(config);
824
+ const videos = [];
825
+ const previews = [];
826
+ const brands = [];
827
+ const hashParts = [];
828
+ for (const file of files) {
829
+ const relativeFile = relative4(config.root, file);
830
+ if (/\.(tsx|ts|css|json)$/.test(file)) {
831
+ const contents = await readFile7(file, "utf8");
832
+ hashParts.push(`${relativeFile}:${hashString3(contents)}`);
833
+ }
834
+ const base = file.split(sep).pop() ?? "";
835
+ if (base === "video.tsx") {
836
+ const slug = relative4(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep).join("/") || "video";
837
+ videos.push({
838
+ slug,
839
+ file,
840
+ relativeFile,
841
+ importPath: file,
842
+ identifier: toIdentifier(slug, "video")
843
+ });
844
+ } else if (file.split(sep).includes("brands") && /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx")) {
845
+ const name = base.replace(/\.tsx?$/, "");
846
+ brands.push({
847
+ name,
848
+ file,
849
+ relativeFile,
850
+ identifier: toIdentifier(`${name}-module`, "brands")
851
+ });
852
+ } else if (base.endsWith(".preview.tsx")) {
853
+ const name = base.replace(/\.preview\.tsx$/, "");
854
+ previews.push({
855
+ name,
856
+ file,
857
+ relativeFile,
858
+ importPath: file,
859
+ identifier: toIdentifier(`${relative4(videosRoot, file).split(sep).join("-")}`, "preview")
860
+ });
861
+ }
862
+ }
863
+ return { videos, previews, brands, audio, sourceHash: hashString3(hashParts.join("|")) };
864
+ };
865
+ var generateImports = (graph, outDir) => {
866
+ const importPath = (file) => {
867
+ const relativePath = relative4(outDir, file).split(sep).join("/");
868
+ return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
869
+ };
870
+ const lines = [
871
+ "// Generated by odori. Do not edit.",
872
+ 'import type {VideoEntry} from "odori";',
873
+ "",
874
+ ...graph.videos.map(
875
+ (video) => `import ${video.identifier}, {metadata as ${video.identifier}Metadata} from "${importPath(video.file)}";`
876
+ ),
877
+ ...graph.previews.map((preview) => `import ${preview.identifier} from "${importPath(preview.file)}";`),
878
+ ...graph.brands.map((brand) => `import * as ${brand.identifier} from "${importPath(brand.file)}";`),
879
+ "",
880
+ "export const videos: VideoEntry[] = [",
881
+ ...graph.videos.map(
882
+ (video) => ` {component: ${video.identifier}, metadata: {...${video.identifier}Metadata, id: ${video.identifier}Metadata.id || ${JSON.stringify(video.slug)}}},`
883
+ ),
884
+ "];",
885
+ "",
886
+ "export const componentPreviews = [",
887
+ ...graph.previews.map((preview) => ` {id: ${JSON.stringify(preview.name)}, preview: ${preview.identifier}},`),
888
+ "];",
889
+ "",
890
+ "export const brands = [",
891
+ ...graph.brands.map(
892
+ (brand) => ` ...Object.values(${brand.identifier}).filter((value) => (value as {kind?: string})?.kind === "odori-brand"),`
893
+ ),
894
+ "];",
895
+ ""
896
+ ];
897
+ return lines.join("\n");
898
+ };
899
+ var writeGenerated = async (config, graph) => {
900
+ const outDir = resolve8(config.root, config.outDir);
901
+ await mkdir6(outDir, { recursive: true });
902
+ const target = join3(outDir, "imports.generated.ts");
903
+ await writeFile7(target, generateImports(graph, outDir), "utf8");
904
+ await writeFile7(
905
+ join3(outDir, "catalog.json"),
906
+ `${JSON.stringify(
907
+ {
908
+ sourceHash: graph.sourceHash,
909
+ videos: graph.videos.map((video) => ({ slug: video.slug, file: video.relativeFile })),
910
+ previews: graph.previews.map((preview) => ({ name: preview.name, file: preview.relativeFile })),
911
+ brands: graph.brands.map((brand) => ({ name: brand.name, file: brand.relativeFile })),
912
+ audio: graph.audio.map((entry) => ({ name: entry.name, url: entry.url, file: entry.relativeFile }))
913
+ },
914
+ null,
915
+ 2
916
+ )}
917
+ `,
918
+ "utf8"
919
+ );
920
+ return target;
921
+ };
922
+
923
+ // src/project.ts
924
+ import { resolve as resolve11 } from "path";
925
+ import { pathToFileURL as pathToFileURL2 } from "url";
926
+ import {
927
+ createRenderManifest,
928
+ entryDurationInFrames,
929
+ resolveEntryLayout,
930
+ resolveVideoId
931
+ } from "odori";
932
+
933
+ // src/integrity.ts
934
+ import { createHash as createHash2 } from "crypto";
935
+ import { existsSync as existsSync9 } from "fs";
936
+ import { mkdir as mkdir7, readFile as readFile8, writeFile as writeFile8 } from "fs/promises";
937
+ import { dirname as dirname3, resolve as resolve9 } from "path";
938
+ var cacheFile = (config) => resolve9(config.root, config.outDir, "cache", "integrity.json");
939
+ var readCache = async (config) => {
940
+ const file = cacheFile(config);
941
+ if (!existsSync9(file)) return {};
942
+ try {
943
+ return JSON.parse(await readFile8(file, "utf8"));
944
+ } catch {
945
+ return {};
946
+ }
947
+ };
948
+ var writeCache = async (config, cache) => {
949
+ const file = cacheFile(config);
950
+ await mkdir7(dirname3(file), { recursive: true });
951
+ await writeFile8(file, `${JSON.stringify(cache, null, 2)}
952
+ `, "utf8");
953
+ };
954
+ var sha256 = (bytes) => `sha256-${createHash2("sha256").update(bytes).digest("base64")}`;
955
+ var localCandidates = (config, url) => [
956
+ resolve9(config.root, "public", url.replace(/^\//, "")),
957
+ resolve9(config.root, url.replace(/^\//, ""))
958
+ ];
959
+ var isServed = (config, file) => file.startsWith(resolve9(config.root, "public") + "/");
960
+ var createIntegrityResolver = async (config) => {
961
+ const cache = await readCache(config);
962
+ const warned = /* @__PURE__ */ new Set();
963
+ let dirty = false;
964
+ const resolveIntegrity = async (url) => {
965
+ if (url.startsWith("/__odori/cue/")) return `cue-${url.slice(url.lastIndexOf("-") + 1).replace(/\.wav$/, "")}`;
966
+ const local = localCandidates(config, url).find((candidate) => existsSync9(candidate));
967
+ if (local) {
968
+ if (!isServed(config, local) && !warned.has(url)) {
969
+ warned.add(url);
970
+ log.warn(`${url} resolves to ${local}, which is outside public/ and will not be served. Move it into public/.`);
971
+ }
972
+ const bytes = await readFile8(local);
973
+ const { mtimeMs } = await import("fs/promises").then((fs) => fs.stat(local));
974
+ const hit = cache[url];
975
+ if (hit && hit.mtimeMs === mtimeMs && hit.size === bytes.byteLength) return hit.integrity;
976
+ const integrity = sha256(bytes);
977
+ cache[url] = { integrity, size: bytes.byteLength, mtimeMs };
978
+ dirty = true;
979
+ return integrity;
980
+ }
981
+ if (!/^https?:\/\//.test(url)) return "unresolved";
982
+ if (cache[url]) return cache[url].integrity;
983
+ try {
984
+ const response = await fetch(url, { signal: AbortSignal.timeout(1e4) });
985
+ if (!response.ok) return "unresolved";
986
+ const bytes = new Uint8Array(await response.arrayBuffer());
987
+ const integrity = sha256(bytes);
988
+ cache[url] = { integrity, size: bytes.byteLength };
989
+ dirty = true;
990
+ return integrity;
991
+ } catch {
992
+ return "unresolved";
993
+ }
994
+ };
995
+ return {
996
+ resolve: resolveIntegrity,
997
+ flush: async () => {
998
+ if (dirty) await writeCache(config, cache);
999
+ }
1000
+ };
1001
+ };
1002
+
1003
+ // src/prepare-cache.ts
1004
+ import { existsSync as existsSync10 } from "fs";
1005
+ import { mkdir as mkdir8, readFile as readFile9, readdir as readdir4, rm, writeFile as writeFile9 } from "fs/promises";
1006
+ import { join as join4, resolve as resolve10 } from "path";
1007
+ import { hashValue } from "odori";
1008
+
1009
+ // src/paths.ts
1010
+ var outputName = (id) => id.split("/").join("-");
1011
+ var fileKey = (id) => id.split("/").join("+");
1012
+
1013
+ // src/prepare-cache.ts
1014
+ var directory = (config) => resolve10(config.root, config.outDir, "cache", "prepare");
1015
+ var prepareCacheKey = (key) => `${fileKey(key.videoId)}__${hashValue(key)}`;
1016
+ var readPrepareCache = async (config, key) => {
1017
+ const file = join4(directory(config), `${prepareCacheKey(key)}.json`);
1018
+ if (!existsSync10(file)) return { hit: false, value: void 0 };
1019
+ try {
1020
+ const entry = JSON.parse(await readFile9(file, "utf8"));
1021
+ return { hit: true, value: entry.value };
1022
+ } catch {
1023
+ return { hit: false, value: void 0 };
1024
+ }
1025
+ };
1026
+ var writePrepareCache = async (config, key, value) => {
1027
+ if (value === void 0) return;
1028
+ const target = directory(config);
1029
+ await mkdir8(target, { recursive: true });
1030
+ const entry = { key, value, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1031
+ await writeFile9(join4(target, `${prepareCacheKey(key)}.json`), `${JSON.stringify(entry, null, 2)}
1032
+ `, "utf8");
1033
+ };
1034
+ var clearPrepareCache = async (config, videoId) => {
1035
+ const target = directory(config);
1036
+ if (!existsSync10(target)) return 0;
1037
+ const files = await readdir4(target);
1038
+ const matches = files.filter((file) => videoId ? file.startsWith(`${fileKey(videoId)}__`) : file.endsWith(".json"));
1039
+ await Promise.all(matches.map((file) => rm(join4(target, file), { force: true })));
1040
+ return matches.length;
1041
+ };
1042
+
1043
+ // src/project.ts
1044
+ var loadVideos = async (graph) => {
1045
+ const loaded = [];
1046
+ for (const discovered of graph.videos) {
1047
+ const module = await import(pathToFileURL2(discovered.file).href);
1048
+ if (!module.default || !module.metadata) {
1049
+ throw new Error(`${discovered.relativeFile} must export metadata and a default React component.`);
1050
+ }
1051
+ const entry = {
1052
+ component: module.default,
1053
+ metadata: { ...module.metadata, id: resolveVideoId(module.metadata.id, discovered.slug) }
1054
+ };
1055
+ loaded.push({
1056
+ entry,
1057
+ file: discovered.file,
1058
+ relativeFile: discovered.relativeFile,
1059
+ durationInFrames: entryDurationInFrames(entry, resolveEntryLayout(entry))
1060
+ });
1061
+ }
1062
+ const byId = /* @__PURE__ */ new Map();
1063
+ for (const video of loaded) {
1064
+ const id = video.entry.metadata.id;
1065
+ const first = byId.get(id);
1066
+ if (first) throw new Error(`Duplicate video id "${id}":
1067
+ ${first}
1068
+ ${video.relativeFile}`);
1069
+ byId.set(id, video.relativeFile);
1070
+ }
1071
+ return loaded;
1072
+ };
1073
+ var findVideo = (videos, id) => {
1074
+ const found = videos.find((video) => video.entry.metadata.id === id);
1075
+ if (!found) {
1076
+ throw new Error(
1077
+ `Unknown video "${id}". Known videos: ${videos.map((video) => video.entry.metadata.id).join(", ") || "none"}`
1078
+ );
1079
+ }
1080
+ return found;
1081
+ };
1082
+ var runPrepare = async (video, config, graph, input, options = {}) => {
1083
+ const prepareFile = resolve11(video.file, "..", "prepare.ts");
1084
+ let prepare;
1085
+ try {
1086
+ const module = await import(pathToFileURL2(prepareFile).href);
1087
+ prepare = module.prepare ?? module.default;
1088
+ } catch (error) {
1089
+ if (error.code === "ERR_MODULE_NOT_FOUND") return void 0;
1090
+ throw error;
1091
+ }
1092
+ if (!prepare) return void 0;
1093
+ const key = {
1094
+ videoId: video.entry.metadata.id,
1095
+ sourceHash: graph.sourceHash,
1096
+ input,
1097
+ version: prepare.version ?? "1"
1098
+ };
1099
+ if (!options.refresh) {
1100
+ const cached = await readPrepareCache(config, key);
1101
+ if (cached.hit) return cached.value;
1102
+ }
1103
+ const memo = /* @__PURE__ */ new Map();
1104
+ const value = await prepare.run({
1105
+ input,
1106
+ assets: {
1107
+ resolve: async (reference) => {
1108
+ const asset = (config.assets ?? []).find((item) => item.reference === reference);
1109
+ if (!asset) throw new Error(`Unknown asset reference: ${reference}`);
1110
+ return asset.url;
1111
+ }
1112
+ },
1113
+ cache: {
1114
+ getOrSet: async (cacheKey, factory) => {
1115
+ if (!memo.has(cacheKey)) memo.set(cacheKey, await factory());
1116
+ return memo.get(cacheKey);
1117
+ }
1118
+ }
1119
+ });
1120
+ await writePrepareCache(config, key, value);
1121
+ return value;
1122
+ };
1123
+ var freezeManifest = async (video, graph, config, rawInput, options = {}) => {
1124
+ const layout = resolveEntryLayout(video.entry);
1125
+ const merged = { ...video.entry.metadata.defaultProps, ...rawInput };
1126
+ const input = video.entry.metadata.schema ? video.entry.metadata.schema.parse(merged) : merged;
1127
+ const prepared = await runPrepare(video, config, graph, input, { refresh: options.refreshPrepare });
1128
+ const integrity = await createIntegrityResolver(config);
1129
+ const assets = await Promise.all(
1130
+ (config.assets ?? []).map(async (asset) => ({ ...asset, integrity: await integrity.resolve(asset.url) }))
1131
+ );
1132
+ const audio = await Promise.all(
1133
+ (options.audio ?? []).map(async (cue) => ({ ...cue, integrity: await integrity.resolve(cue.src) }))
1134
+ );
1135
+ const fonts = await Promise.all(
1136
+ layout.brand.fonts.map(async (font) => ({
1137
+ family: font.family,
1138
+ url: font.url,
1139
+ integrity: await integrity.resolve(font.url)
1140
+ }))
1141
+ );
1142
+ await integrity.flush();
1143
+ for (const cue of audio) {
1144
+ if (cue.integrity === "unresolved") log.warn(`Audio source could not be resolved for hashing: ${cue.src}`);
1145
+ }
1146
+ const manifest = createRenderManifest({
1147
+ entry: video.entry,
1148
+ layout,
1149
+ input,
1150
+ prepared,
1151
+ sourceHash: graph.sourceHash,
1152
+ durationInFrames: video.durationInFrames,
1153
+ scenes: options.scenes ?? [],
1154
+ audio,
1155
+ assets,
1156
+ fonts,
1157
+ // Which Chrome drew it and which FFmpeg encoded it. Two files that differ
1158
+ // are then a question with an answer rather than a mystery.
1159
+ toolchain: await renderToolchain(config),
1160
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
1161
+ });
1162
+ return { manifest, input, prepared };
1163
+ };
1164
+
1165
+ // src/render.ts
1166
+ import { spawn as spawn2 } from "child_process";
1167
+ import { copyFile as copyFile2, mkdir as mkdir11, rm as rm2, writeFile as writeFile12 } from "fs/promises";
1168
+ import { cpus } from "os";
1169
+ import { dirname as dirname4, join as join6, resolve as resolve15 } from "path";
1170
+ import { chromium } from "playwright-core";
1171
+
1172
+ // src/audio-mix.ts
1173
+ import { existsSync as existsSync12 } from "fs";
1174
+ import { resolve as resolve13 } from "path";
1175
+ import { duckEnvelope, envelopeAtFrame } from "odori";
1176
+
1177
+ // src/cues.ts
1178
+ import { existsSync as existsSync11, statSync } from "fs";
1179
+ import { mkdir as mkdir9, writeFile as writeFile10 } from "fs/promises";
1180
+ import { basename, resolve as resolve12 } from "path";
1181
+ import { pathToFileURL as pathToFileURL3 } from "url";
1182
+ import {
1183
+ SAMPLE_RATE,
1184
+ cueSamples,
1185
+ cueUrl,
1186
+ defaultLayout,
1187
+ encodeWav,
1188
+ isCueDefinition,
1189
+ resolveEntryLayout as resolveEntryLayout2
1190
+ } from "odori";
1191
+ var cueCacheDir = (config) => resolve12(config.root, config.outDir, "cues");
1192
+ var cueFile = (config, url) => resolve12(cueCacheDir(config), basename(url));
1193
+ var materializeCues = async (config, brands, fps) => {
1194
+ const seen = /* @__PURE__ */ new Map();
1195
+ for (const brand of brands) {
1196
+ for (const value of Object.values(brand.audio.cues)) {
1197
+ if (isCueDefinition(value)) seen.set(cueUrl(value), value);
1198
+ }
1199
+ }
1200
+ if (seen.size === 0) return [];
1201
+ await mkdir9(cueCacheDir(config), { recursive: true });
1202
+ const written = [];
1203
+ for (const [url, cue] of seen) {
1204
+ const file = cueFile(config, url);
1205
+ if (existsSync11(file)) {
1206
+ written.push({ cue, file, rendered: false });
1207
+ continue;
1208
+ }
1209
+ const samples = cueSamples(cue, fps);
1210
+ const signal = cue.render({ samples, sampleRate: SAMPLE_RATE });
1211
+ await writeFile10(file, encodeWav(signal));
1212
+ written.push({ cue, file, rendered: true });
1213
+ }
1214
+ return written;
1215
+ };
1216
+ var known = /* @__PURE__ */ new Map();
1217
+ var rendered = /* @__PURE__ */ new Map();
1218
+ var registerCues = (brands, fps) => {
1219
+ for (const brand of brands) {
1220
+ for (const value of Object.values(brand.audio.cues)) {
1221
+ if (isCueDefinition(value)) known.set(cueUrl(value), { cue: value, fps });
1222
+ }
1223
+ }
1224
+ return known.size;
1225
+ };
1226
+ var renderedCue = (url) => {
1227
+ const cached = rendered.get(url);
1228
+ if (cached) return cached;
1229
+ const entry = known.get(url);
1230
+ if (!entry) return null;
1231
+ const signal = entry.cue.render({ samples: cueSamples(entry.cue, entry.fps), sampleRate: SAMPLE_RATE });
1232
+ const wav = encodeWav(signal);
1233
+ rendered.set(url, wav);
1234
+ return wav;
1235
+ };
1236
+ var isBrand = (value) => typeof value === "object" && value !== null && value.kind === "odori-brand";
1237
+ var importFresh = async (file) => await import(`${pathToFileURL3(file).href}?odori=${statSync(file).mtimeMs}`);
1238
+ var registerProjectCues = async (graph) => {
1239
+ for (const discovered of graph.brands) {
1240
+ try {
1241
+ const module = await importFresh(discovered.file);
1242
+ registerCues(Object.values(module).filter(isBrand), defaultLayout.format.fps);
1243
+ } catch (error) {
1244
+ log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
1245
+ }
1246
+ }
1247
+ try {
1248
+ for (const video of await loadVideos(graph)) {
1249
+ const layout = resolveEntryLayout2(video.entry);
1250
+ registerCues([layout.brand], layout.format.fps);
1251
+ }
1252
+ } catch (error) {
1253
+ log.warn(`[odori] generated cues may use the default frame rate: ${message(error)}`);
1254
+ }
1255
+ return known.size;
1256
+ };
1257
+ var message = (error) => error instanceof Error ? error.message : String(error);
1258
+
1259
+ // src/audio-mix.ts
1260
+ var resolveCueFile = (config, src) => {
1261
+ if (/^https?:\/\//.test(src)) return null;
1262
+ if (src.startsWith("/__odori/cue/")) {
1263
+ const generated = cueFile(config, src);
1264
+ return existsSync12(generated) ? generated : null;
1265
+ }
1266
+ const candidates = [
1267
+ resolve13(config.root, "public", src.replace(/^\//, "")),
1268
+ resolve13(config.root, src.replace(/^\//, ""))
1269
+ ];
1270
+ return candidates.find((candidate) => existsSync12(candidate)) ?? null;
1271
+ };
1272
+ var volumeFilter = (cue, cues, fps) => {
1273
+ const authored = cue.gainPoints ?? [];
1274
+ const frames = [
1275
+ .../* @__PURE__ */ new Set([
1276
+ ...duckEnvelope(cue, cues).map((point) => point.frame),
1277
+ ...authored.map((point) => point.frame)
1278
+ ])
1279
+ ].sort(
1280
+ (left, right) => left - right
1281
+ );
1282
+ const duck = duckEnvelope(cue, cues);
1283
+ const points = frames.map((frame) => ({
1284
+ seconds: (frame - cue.fromFrame) / fps,
1285
+ value: envelopeAtFrame(duck, frame) * (authored.length > 0 ? envelopeAtFrame(authored, frame) : 1) * cue.gain
1286
+ }));
1287
+ const constant = points.every((point) => point.value === points[0].value);
1288
+ if (constant) return `volume=${(points[0]?.value ?? cue.gain).toFixed(4)}`;
1289
+ let expression = points[points.length - 1].value.toFixed(4);
1290
+ for (let index = points.length - 1; index > 0; index -= 1) {
1291
+ const previous = points[index - 1];
1292
+ const current = points[index];
1293
+ const span = current.seconds - previous.seconds;
1294
+ const segment = span <= 0 ? current.value.toFixed(4) : `${previous.value.toFixed(4)}+${(current.value - previous.value).toFixed(4)}*(t-${previous.seconds.toFixed(
1295
+ 4
1296
+ )})/${span.toFixed(4)}`;
1297
+ expression = `if(lt(t,${current.seconds.toFixed(4)}),${segment},${expression})`;
1298
+ }
1299
+ return `volume=volume='${expression}':eval=frame`;
1300
+ };
1301
+ var buildAudioFilter = (inputs, options) => {
1302
+ const { fps, durationInFrames, targetLufs } = options;
1303
+ const totalSeconds = durationInFrames / fps;
1304
+ const cues = inputs.map(({ cue }) => cue);
1305
+ const parts = [];
1306
+ const labels = [];
1307
+ inputs.forEach(({ cue }, index) => {
1308
+ const start = cue.trimStartSeconds;
1309
+ const length2 = cue.durationInFrames / fps;
1310
+ const delay = Math.round(cue.fromFrame / fps * 1e3);
1311
+ const volume = volumeFilter(cue, cues, fps);
1312
+ const label = `a${index}`;
1313
+ const chain = [
1314
+ // Index 1 is the video input, so audio inputs start at 1.
1315
+ cue.loop ? `aloop=loop=-1:size=2147483647` : null,
1316
+ // Every input is brought to one format before anything else touches it.
1317
+ // Cues are mono, files are usually stereo, and a graph that leaves the
1318
+ // difference to be inferred works on the encoder that happens to be
1319
+ // installed and fails on the pinned one.
1320
+ "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo",
1321
+ `atrim=start=${start.toFixed(4)}:duration=${length2.toFixed(4)}`,
1322
+ "asetpts=PTS-STARTPTS",
1323
+ cue.fadeInFrames > 0 ? `afade=t=in:st=0:d=${(cue.fadeInFrames / fps).toFixed(4)}` : null,
1324
+ cue.fadeOutFrames > 0 ? `afade=t=out:st=${Math.max(0, length2 - cue.fadeOutFrames / fps).toFixed(4)}:d=${(cue.fadeOutFrames / fps).toFixed(4)}` : null,
1325
+ volume,
1326
+ delay > 0 ? `adelay=${delay}|${delay}` : null,
1327
+ `apad=whole_dur=${totalSeconds.toFixed(4)}`,
1328
+ `atrim=duration=${totalSeconds.toFixed(4)}`
1329
+ ].filter(Boolean).join(",");
1330
+ parts.push(`[${index + 1}:a]${chain}[${label}]`);
1331
+ labels.push(`[${label}]`);
1332
+ });
1333
+ parts.push(
1334
+ `${labels.join("")}amix=inputs=${labels.length}:normalize=0:dropout_transition=0[mixed]`,
1335
+ // loudnorm resamples to its own rate and can drop the layout on the way
1336
+ // out, so the last link states the output format rather than negotiating
1337
+ // it with whatever encoder is downstream.
1338
+ `[mixed]loudnorm=I=${targetLufs}:TP=-1.5:LRA=11,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[audio]`
1339
+ );
1340
+ return { filter: parts.join(";"), label: "[audio]" };
1341
+ };
1342
+
1343
+ // src/chunks.ts
1344
+ var length = (chunk) => chunk.end - chunk.start + 1;
1345
+ var planChunks = ({
1346
+ durationInFrames,
1347
+ scenes = [],
1348
+ concurrency,
1349
+ maxChunkFrames = 120
1350
+ }) => {
1351
+ if (durationInFrames <= 0) return { chunks: [], lanes: [] };
1352
+ const bounded = Math.max(1, Math.min(concurrency, durationInFrames));
1353
+ const ordered = [...scenes].filter((scene) => scene.durationInFrames > 0).sort((left, right) => left.start - right.start);
1354
+ const spans = [];
1355
+ let cursor = 0;
1356
+ for (const scene of ordered) {
1357
+ if (scene.start > cursor) spans.push({ start: cursor, end: scene.start - 1 });
1358
+ const end = Math.min(durationInFrames - 1, scene.start + scene.durationInFrames - 1);
1359
+ if (end >= scene.start) spans.push({ start: scene.start, end, sceneId: scene.id });
1360
+ cursor = end + 1;
1361
+ }
1362
+ if (cursor < durationInFrames) spans.push({ start: cursor, end: durationInFrames - 1 });
1363
+ const chunks = [];
1364
+ for (const span of spans) {
1365
+ const target = Math.max(1, Math.min(maxChunkFrames, Math.ceil(durationInFrames / bounded)));
1366
+ const total = span.end - span.start + 1;
1367
+ const pieces = Math.max(1, Math.ceil(total / target));
1368
+ const size = Math.ceil(total / pieces);
1369
+ for (let piece = 0; piece < pieces; piece += 1) {
1370
+ const start = span.start + piece * size;
1371
+ const end = Math.min(span.end, start + size - 1);
1372
+ if (start > end) continue;
1373
+ chunks.push({ index: chunks.length, start, end, sceneId: span.sceneId });
1374
+ }
1375
+ }
1376
+ const lanes = Array.from({ length: bounded }, () => []);
1377
+ const loads = new Array(bounded).fill(0);
1378
+ for (const chunk of [...chunks].sort((left, right) => length(right) - length(left))) {
1379
+ let lane = 0;
1380
+ for (let index = 1; index < bounded; index += 1) if (loads[index] < loads[lane]) lane = index;
1381
+ lanes[lane].push(chunk);
1382
+ loads[lane] += length(chunk);
1383
+ }
1384
+ for (const lane of lanes) lane.sort((left, right) => left.start - right.start);
1385
+ return { chunks, lanes: lanes.filter((lane) => lane.length > 0) };
1386
+ };
1387
+ var chunkFrames = (chunk) => Array.from({ length: length(chunk) }, (_, offset) => chunk.start + offset);
1388
+
1389
+ // src/chunk-cache.ts
1390
+ import { existsSync as existsSync13 } from "fs";
1391
+ import { copyFile, mkdir as mkdir10, readFile as readFile10, writeFile as writeFile11 } from "fs/promises";
1392
+ import { join as join5, resolve as resolve14 } from "path";
1393
+ import { hashValue as hashValue2 } from "odori";
1394
+ var cacheDir2 = (config) => resolve14(config.root, config.outDir, "cache", "chunks");
1395
+ var chunkKey = (identity) => hashValue2({
1396
+ videoId: identity.videoId,
1397
+ // The browser that drew the frames is part of what the frames are. Without
1398
+ // it, upgrading Chrome silently reuses pixels the new build would not have
1399
+ // produced, which is the exact drift the pinned toolchain exists to stop.
1400
+ renderer: identity.renderer ?? null,
1401
+ // A chunk is an encoded file, not a bag of frames: H.264 chunks cannot be
1402
+ // copied into a WebM, so a cache that ignored the codec would hand the
1403
+ // muxer streams it cannot write.
1404
+ format: identity.format ?? null,
1405
+ sceneId: identity.chunk.sceneId ?? null,
1406
+ start: identity.chunk.start,
1407
+ end: identity.chunk.end,
1408
+ width: identity.width,
1409
+ height: identity.height,
1410
+ fps: identity.fps,
1411
+ preset: identity.preset,
1412
+ input: identity.input ?? null
1413
+ });
1414
+ var readChunkRecord = async (config, key) => {
1415
+ const meta = join5(cacheDir2(config), `${key}.json`);
1416
+ const media = join5(cacheDir2(config), `${key}.mp4`);
1417
+ if (!existsSync13(meta) || !existsSync13(media)) return null;
1418
+ try {
1419
+ return JSON.parse(await readFile10(meta, "utf8"));
1420
+ } catch {
1421
+ return null;
1422
+ }
1423
+ };
1424
+ var useChunkRecord = async (config, key, destination) => {
1425
+ await copyFile(join5(cacheDir2(config), `${key}.mp4`), destination);
1426
+ };
1427
+ var writeChunkRecord = async (config, key, signatures, file) => {
1428
+ const directory2 = cacheDir2(config);
1429
+ await mkdir10(directory2, { recursive: true });
1430
+ await copyFile(file, join5(directory2, `${key}.mp4`));
1431
+ const record = { key, signatures, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
1432
+ await writeFile11(join5(directory2, `${key}.json`), `${JSON.stringify(record)}
1433
+ `, "utf8");
1434
+ };
1435
+ var signaturesMatch = (recorded, observed) => recorded.length === observed.length && recorded.every((signature, index) => signature === observed[index]);
1436
+
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
+ // src/render.ts
1512
+ var encodeParam = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
1513
+ var renderUrl = (origin, target, frame) => {
1514
+ const params = new URLSearchParams({ render: "1", video: target.videoId, frame: String(frame) });
1515
+ if (target.input) params.set("input", encodeParam(target.input));
1516
+ if (target.prepared !== void 0) params.set("prepared", encodeParam(target.prepared));
1517
+ return `${origin}/?${params.toString()}`;
1518
+ };
1519
+ var openRenderPage = async (origin, target, config) => {
1520
+ const executablePath = await browserExecutable(config);
1521
+ const browser = await chromium.launch({ executablePath, headless: true });
1522
+ const page = await browser.newPage({
1523
+ viewport: { width: target.width, height: target.height },
1524
+ deviceScaleFactor: 1
1525
+ });
1526
+ const errors = [];
1527
+ page.on("pageerror", (error) => errors.push(error.message));
1528
+ await page.goto(renderUrl(origin, target, 0), { waitUntil: "networkidle" });
1529
+ try {
1530
+ await page.locator('[data-odori-frame="0"]').waitFor({ timeout: 2e4 });
1531
+ } catch {
1532
+ await browser.close();
1533
+ throw new Error(`The video did not mount.${errors.length ? ` ${errors.join(" ")}` : ""}`);
1534
+ }
1535
+ await page.evaluate(() => document.fonts.ready);
1536
+ return { browser, page, errors };
1537
+ };
1538
+ var seekTo = async (page, frame) => {
1539
+ await page.evaluate((next) => window.__ODORI_SET_FRAME__?.(next), frame);
1540
+ await page.locator(`[data-odori-frame="${frame}"]`).waitFor({ timeout: 2e4 });
1541
+ };
1542
+ var readTimeline = async (page) => page.evaluate(() => window.__ODORI_TIMELINE__ ?? { scenes: [], durationInFrames: 0 });
1543
+ var readAudio = async (page) => page.evaluate(() => window.__ODORI_AUDIO__ ?? { cues: [], durationInFrames: 0 });
1544
+ var SIGNATURE_SCRIPT = `(() => {
1545
+ var root = document.querySelector("[data-odori-video]");
1546
+ if (!root) return "";
1547
+ var markup = root.outerHTML;
1548
+ var hash = 2166136261;
1549
+ for (var index = 0; index < markup.length; index += 1) {
1550
+ hash ^= markup.charCodeAt(index);
1551
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
1552
+ }
1553
+ return hash.toString(16) + ":" + markup.length;
1554
+ })()`;
1555
+ var PROBE_SCRIPT = `(async (frames) => {
1556
+ var signature = function () {
1557
+ var root = document.querySelector("[data-odori-video]");
1558
+ if (!root) return "";
1559
+ var markup = root.outerHTML;
1560
+ var hash = 2166136261;
1561
+ for (var index = 0; index < markup.length; index += 1) {
1562
+ hash ^= markup.charCodeAt(index);
1563
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
1564
+ }
1565
+ return hash.toString(16) + ":" + markup.length;
1566
+ };
1567
+ var settled = function (frame) {
1568
+ return new Promise(function (done, fail) {
1569
+ var started = Date.now();
1570
+ var check = function () {
1571
+ var root = document.querySelector("[data-odori-frame]");
1572
+ if (root && root.getAttribute("data-odori-frame") === String(frame)) {
1573
+ done(undefined);
1574
+ return;
1575
+ }
1576
+ if (Date.now() - started > 20000) {
1577
+ fail(new Error("Timed out waiting for frame " + frame));
1578
+ return;
1579
+ }
1580
+ requestAnimationFrame(check);
1581
+ };
1582
+ requestAnimationFrame(check);
1583
+ });
1584
+ };
1585
+ var out = [];
1586
+ for (var index = 0; index < frames.length; index += 1) {
1587
+ window.__ODORI_SET_FRAME__(frames[index]);
1588
+ await settled(frames[index]);
1589
+ out.push(signature());
1590
+ }
1591
+ return out;
1592
+ })`;
1593
+ var probeSignatures = async (page, frames) => {
1594
+ const signatures = [];
1595
+ for (let index = 0; index < frames.length; index += 60) {
1596
+ const batch = frames.slice(index, index + 60);
1597
+ const result = await page.evaluate(`(${PROBE_SCRIPT})(${JSON.stringify(batch)})`);
1598
+ signatures.push(...result);
1599
+ }
1600
+ return signatures;
1601
+ };
1602
+ var run = (command2, args, signal) => new Promise((resolveRun, rejectRun) => {
1603
+ if (signal?.aborted) {
1604
+ rejectRun(new Error("Render cancelled."));
1605
+ return;
1606
+ }
1607
+ const child = spawn2(command2, args, { stdio: ["ignore", "ignore", "pipe"] });
1608
+ let stderr = "";
1609
+ child.stderr?.on("data", (chunk) => {
1610
+ stderr += chunk.toString();
1611
+ });
1612
+ const onAbort = () => child.kill("SIGTERM");
1613
+ signal?.addEventListener("abort", onAbort, { once: true });
1614
+ child.on("error", rejectRun);
1615
+ child.on("exit", (code) => {
1616
+ signal?.removeEventListener("abort", onAbort);
1617
+ if (signal?.aborted) rejectRun(new Error("Render cancelled."));
1618
+ else if (code === 0) resolveRun();
1619
+ else rejectRun(new Error(`${command2} exited with ${code}: ${stderr.slice(-800)}`));
1620
+ });
1621
+ });
1622
+ var browserExecutable = async (config) => {
1623
+ const resolved = await resolveBrowser(config);
1624
+ if (resolved) return resolved.path;
1625
+ log.detail("Downloading the pinned Chrome build. This happens once per machine.");
1626
+ try {
1627
+ return await installBrowser();
1628
+ } catch (error) {
1629
+ throw new Error(
1630
+ `No Chrome available and the managed build could not be downloaded: ${error.message}
1631
+ Run "odori install" when you have a connection, or set chromePath in odori.config.ts.`
1632
+ );
1633
+ }
1634
+ };
1635
+ var ffmpegExecutable = async (config) => {
1636
+ const resolved = await resolveFfmpeg(config);
1637
+ if (resolved) return resolved.path;
1638
+ log.detail("Downloading the pinned FFmpeg build. This happens once per machine.");
1639
+ try {
1640
+ return await installFfmpeg();
1641
+ } catch (error) {
1642
+ throw new Error(
1643
+ `No FFmpeg available and the managed build could not be downloaded: ${error.message}
1644
+ Run "odori install" when you have a connection, or set ffmpegPath in odori.config.ts.`
1645
+ );
1646
+ }
1647
+ };
1648
+ var ensureFfmpeg = async (config) => {
1649
+ await ffmpegExecutable(config);
1650
+ };
1651
+ var renderStill = async (origin, target, frame, output, config) => {
1652
+ const { browser, page, errors } = await openRenderPage(origin, target, config);
1653
+ try {
1654
+ await mkdir11(dirname4(output), { recursive: true });
1655
+ await seekTo(page, frame);
1656
+ await page.screenshot({ path: output });
1657
+ if (errors.length > 0) log.warn(`The page reported an error while rendering: ${errors[0]}`);
1658
+ return output;
1659
+ } finally {
1660
+ await browser.close();
1661
+ }
1662
+ };
1663
+ var defaultConcurrency = () => Math.max(1, Math.min(4, cpus().length - 2));
1664
+ var writeFrame = (stdin, frame) => new Promise((resolveWrite, rejectWrite) => {
1665
+ if (stdin.write(frame)) {
1666
+ resolveWrite();
1667
+ return;
1668
+ }
1669
+ stdin.once("drain", resolveWrite);
1670
+ stdin.once("error", rejectWrite);
1671
+ });
1672
+ var sequencePattern = (output) => {
1673
+ const dot = output.lastIndexOf(".");
1674
+ const stem = dot > 0 ? output.slice(0, dot) : output;
1675
+ const extension = dot > 0 ? output.slice(dot) : ".png";
1676
+ return join6(stem, `%05d${extension}`);
1677
+ };
1678
+ var openChunkEncoder = (ffmpeg, file, fps, preset, format, signal) => {
1679
+ const child = spawn2(
1680
+ ffmpeg,
1681
+ ["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(preset), file],
1682
+ { stdio: ["pipe", "ignore", "pipe"] }
1683
+ );
1684
+ let stderr = "";
1685
+ child.stderr?.on("data", (chunk) => {
1686
+ stderr += chunk.toString();
1687
+ });
1688
+ const onAbort = () => child.kill("SIGTERM");
1689
+ signal?.addEventListener("abort", onAbort, { once: true });
1690
+ const done = new Promise((resolveDone, rejectDone) => {
1691
+ child.on("error", rejectDone);
1692
+ child.on("exit", (code) => {
1693
+ signal?.removeEventListener("abort", onAbort);
1694
+ if (signal?.aborted) rejectDone(new Error("Render cancelled."));
1695
+ else if (code === 0) resolveDone();
1696
+ else rejectDone(new Error(`ffmpeg exited with ${code}: ${stderr.slice(-800)}`));
1697
+ });
1698
+ });
1699
+ return { child, done };
1700
+ };
1701
+ var captureLane = async (origin, target, config, lane, stats, options) => {
1702
+ let session = await openRenderPage(origin, target, config);
1703
+ const errors = session.errors;
1704
+ const reopen = async () => {
1705
+ await session.browser.close().catch(() => void 0);
1706
+ session = await openRenderPage(origin, target, config);
1707
+ session.errors.push(...errors);
1708
+ };
1709
+ const signatureOf = async () => await session.page.evaluate(SIGNATURE_SCRIPT);
1710
+ try {
1711
+ for (const chunk of lane) {
1712
+ if (options.signal?.aborted) throw new Error("Render cancelled.");
1713
+ const frames = chunkFrames(chunk);
1714
+ const identity = options.cacheIdentity?.(chunk);
1715
+ const key = identity ? chunkKey(identity) : null;
1716
+ if (key) {
1717
+ const record = await readChunkRecord(config, key);
1718
+ if (record) {
1719
+ const observed = await probeSignatures(session.page, frames);
1720
+ if (signaturesMatch(record.signatures, observed)) {
1721
+ await useChunkRecord(config, key, options.chunkFile(chunk));
1722
+ stats.cachedChunks += 1;
1723
+ stats.captured += frames.length;
1724
+ stats.reused += frames.length;
1725
+ for (const _frame of frames) options.onFrame();
1726
+ continue;
1727
+ }
1728
+ }
1729
+ }
1730
+ const file = options.chunkFile(chunk);
1731
+ const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.preset, options.format, options.signal);
1732
+ const signatures = [];
1733
+ let previousSignature = null;
1734
+ let previousFrame = null;
1735
+ let written = 0;
1736
+ try {
1737
+ for (const frame of frames) {
1738
+ if (options.signal?.aborted) throw new Error("Render cancelled.");
1739
+ for (let attempt = 0; ; attempt += 1) {
1740
+ try {
1741
+ await seekTo(session.page, frame);
1742
+ const signature = await signatureOf();
1743
+ let image;
1744
+ if (options.skipUnchanged && previousFrame && signature === previousSignature) {
1745
+ image = previousFrame;
1746
+ stats.reused += 1;
1747
+ } else {
1748
+ image = await session.page.screenshot();
1749
+ }
1750
+ await writeFrame(encoder.child.stdin, image);
1751
+ signatures.push(signature);
1752
+ previousSignature = signature;
1753
+ previousFrame = image;
1754
+ written += 1;
1755
+ break;
1756
+ } catch (error) {
1757
+ if (options.signal?.aborted) throw new Error("Render cancelled.");
1758
+ if (attempt >= 1) throw error;
1759
+ log.detail(`Frame ${frame} failed, retrying on a fresh page.`);
1760
+ await reopen();
1761
+ previousSignature = null;
1762
+ previousFrame = null;
1763
+ }
1764
+ }
1765
+ stats.captured += 1;
1766
+ options.onFrame();
1767
+ }
1768
+ encoder.child.stdin?.end();
1769
+ await encoder.done;
1770
+ if (written !== frames.length) {
1771
+ throw new Error(`Chunk ${chunk.index} wrote ${written} of ${frames.length} frames.`);
1772
+ }
1773
+ if (key) await writeChunkRecord(config, key, signatures, file);
1774
+ } catch (error) {
1775
+ encoder.child.kill("SIGTERM");
1776
+ throw error;
1777
+ }
1778
+ }
1779
+ } finally {
1780
+ await session.browser.close().catch(() => void 0);
1781
+ if (session.errors.length > 0) {
1782
+ throw new Error(`The page reported an error during capture: ${session.errors.slice(0, 3).join(" ")}`);
1783
+ }
1784
+ }
1785
+ };
1786
+ var renderMovie = async (origin, target, output, config, onProgress, options = {}) => {
1787
+ const ffmpeg = await ffmpegExecutable(config);
1788
+ const browserPath = await browserExecutable(config);
1789
+ const renderer = (await resolveBrowser(config))?.version ?? browserPath;
1790
+ const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
1791
+ const preset = options.preset ?? config.preset ?? "medium";
1792
+ const format = options.format ?? FORMATS.mp4;
1793
+ const chunkable = format.chunked;
1794
+ const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
1795
+ const cache = options.cache ?? config.cacheChunks ?? true;
1796
+ const work = options.workDir ?? resolve15(config.root, config.outDir, "frames", `${target.videoId.split("/").join("-")}-${Date.now().toString(36)}`);
1797
+ await mkdir11(work, { recursive: true });
1798
+ const concurrency = chunkable ? requested : 1;
1799
+ const { chunks, lanes } = planChunks({
1800
+ durationInFrames: target.durationInFrames,
1801
+ scenes: target.scenes,
1802
+ concurrency
1803
+ });
1804
+ const chunkFile = (chunk) => join6(work, `chunk-${String(chunk.index).padStart(4, "0")}${chunkable ? format.extension : ".mkv"}`);
1805
+ const stats = { captured: 0, reused: 0, cachedChunks: 0 };
1806
+ let succeeded = false;
1807
+ try {
1808
+ await mkdir11(dirname4(output), { recursive: true });
1809
+ const captureStart = performance.now();
1810
+ await Promise.all(
1811
+ lanes.map(
1812
+ (lane) => captureLane(origin, target, config, lane, stats, {
1813
+ skipUnchanged,
1814
+ signal: options.signal,
1815
+ preset,
1816
+ format,
1817
+ ffmpeg,
1818
+ workDir: work,
1819
+ chunkFile,
1820
+ cacheIdentity: cache ? (chunk) => ({
1821
+ videoId: target.videoId,
1822
+ chunk,
1823
+ renderer,
1824
+ format: format.name,
1825
+ width: target.width,
1826
+ height: target.height,
1827
+ fps: target.fps,
1828
+ preset,
1829
+ input: target.input
1830
+ }) : void 0,
1831
+ onFrame: () => onProgress?.(stats.captured / Math.max(1, target.durationInFrames), "rendering")
1832
+ })
1833
+ )
1834
+ );
1835
+ const captureMs = performance.now() - captureStart;
1836
+ onProgress?.(1, "encoding");
1837
+ const mixInputs = (target.audio ?? []).map((cue) => {
1838
+ const file = resolveCueFile(config, cue.src);
1839
+ if (!file) log.warn(`Skipping audio cue ${cue.src}: only project-local files can be encoded.`);
1840
+ return file ? { file, cue } : null;
1841
+ }).filter((item) => item !== null);
1842
+ const muxStart = performance.now();
1843
+ const ordered = chunks.map(chunkFile);
1844
+ const silent = join6(work, `video${chunkable ? format.extension : ".mkv"}`);
1845
+ if (ordered.length === 1) {
1846
+ await copyFile2(ordered[0], silent);
1847
+ } else {
1848
+ const list = join6(work, "chunks.txt");
1849
+ await writeFile12(list, ordered.map((file) => `file '${file.split("'").join("'\\''")}'`).join("\n"), "utf8");
1850
+ await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
1851
+ }
1852
+ if (!chunkable) {
1853
+ const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
1854
+ if (destination !== output) await mkdir11(dirname4(destination), { recursive: true });
1855
+ await run(ffmpeg, ["-y", "-i", silent, ...format.args(preset), destination], options.signal);
1856
+ if (mixInputs.length > 0) {
1857
+ log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
1858
+ }
1859
+ } else if (mixInputs.length === 0 || !format.audio) {
1860
+ const faststart = format.extension === ".mp4" || format.extension === ".mov";
1861
+ await run(
1862
+ ffmpeg,
1863
+ ["-y", "-i", silent, "-c", "copy", ...faststart ? ["-movflags", "+faststart"] : [], output],
1864
+ options.signal
1865
+ );
1866
+ } else {
1867
+ const { filter, label } = buildAudioFilter(mixInputs, {
1868
+ fps: target.fps,
1869
+ durationInFrames: target.durationInFrames,
1870
+ targetLufs: target.targetLufs ?? -14
1871
+ });
1872
+ const args = ["-y", "-i", silent];
1873
+ for (const { cue, file } of mixInputs) args.push(...cue.loop ? ["-stream_loop", "-1"] : [], "-i", file);
1874
+ args.push(
1875
+ "-filter_complex",
1876
+ filter,
1877
+ "-map",
1878
+ "0:v",
1879
+ "-map",
1880
+ label,
1881
+ "-c:v",
1882
+ "copy",
1883
+ // WebM cannot carry AAC; every other container here can.
1884
+ "-c:a",
1885
+ format.extension === ".webm" ? "libopus" : "aac",
1886
+ "-b:a",
1887
+ "192k",
1888
+ "-ar",
1889
+ "48000",
1890
+ "-shortest",
1891
+ ...format.extension === ".mp4" || format.extension === ".mov" ? ["-movflags", "+faststart"] : [],
1892
+ output
1893
+ );
1894
+ await run(ffmpeg, args, options.signal);
1895
+ }
1896
+ options.onTimings?.({
1897
+ captureMs: Math.round(captureMs),
1898
+ encodeMs: Math.round(performance.now() - muxStart),
1899
+ frames: target.durationInFrames,
1900
+ reusedFrames: stats.reused,
1901
+ cachedChunks: stats.cachedChunks,
1902
+ chunks: chunks.length,
1903
+ concurrency: lanes.length
1904
+ });
1905
+ succeeded = true;
1906
+ return output;
1907
+ } finally {
1908
+ if (succeeded && !options.workDir) await rm2(work, { recursive: true, force: true });
1909
+ else if (!succeeded) log.detail(`Chunks left for inspection in ${work}`);
1910
+ }
1911
+ };
1912
+
1913
+ // src/open.ts
1914
+ import { spawn as spawn3 } from "child_process";
1915
+ var command = () => {
1916
+ if (process.platform === "darwin") return { bin: "open", args: [] };
1917
+ if (process.platform === "win32") return { bin: "cmd", args: ["/c", "start", ""] };
1918
+ if (process.platform === "linux") return { bin: "xdg-open", args: [] };
1919
+ return void 0;
1920
+ };
1921
+ var shouldOpenBrowser = (flag) => {
1922
+ if (flag !== void 0) return flag;
1923
+ if (process.env.ODORI_OPEN === "0" || process.env.ODORI_OPEN === "false") return false;
1924
+ if (process.env.CI) return false;
1925
+ return process.stdout.isTTY === true;
1926
+ };
1927
+ var openInBrowser = (url) => {
1928
+ const resolved = command();
1929
+ if (!resolved) return;
1930
+ try {
1931
+ const child = spawn3(resolved.bin, [...resolved.args, url], { stdio: "ignore", detached: true });
1932
+ child.on("error", () => {
1933
+ });
1934
+ child.unref();
1935
+ } catch {
1936
+ }
1937
+ };
1938
+
1939
+ // src/server.ts
1940
+ import { existsSync as existsSync14 } from "fs";
1941
+ import { createRequire as createRequire2 } from "module";
1942
+ import { fileURLToPath } from "url";
1943
+ import { createServer } from "vite";
1944
+ import react from "@vitejs/plugin-react";
1945
+ import { readFile as readFile11 } from "fs/promises";
1946
+ import { dirname as dirname5, resolve as resolve16, sep as sep2 } from "path";
1947
+ var cliRoot = resolve16(dirname5(fileURLToPath(import.meta.url)), "..");
1948
+ var studioRoot = resolve16(cliRoot, "studio");
1949
+ var studioEntry = resolve16(studioRoot, "index.html");
1950
+ var installRoot = resolve16(cliRoot, "..", "..");
1951
+ var VIRTUAL_ID = "virtual:odori-project";
1952
+ var RESOLVED_ID = `\0${VIRTUAL_ID}`;
1953
+ var runtimeSource = (root) => {
1954
+ for (const from of [resolve16(root, "package.json"), import.meta.url]) {
1955
+ try {
1956
+ const manifest = createRequire2(from).resolve("odori/package.json");
1957
+ const src = resolve16(manifest, "..", "src");
1958
+ if (existsSync14(resolve16(src, "index.tsx"))) return src;
1959
+ } catch {
1960
+ }
1961
+ }
1962
+ return null;
1963
+ };
1964
+ var odoriProjectPlugin = (config, getGraph) => ({
1965
+ name: "odori:project",
1966
+ resolveId(id) {
1967
+ return id === VIRTUAL_ID ? RESOLVED_ID : null;
1968
+ },
1969
+ load(id) {
1970
+ if (id !== RESOLVED_ID) return null;
1971
+ const graph = getGraph();
1972
+ const fsPath = (file) => JSON.stringify(`/@fs${file}`);
1973
+ return [
1974
+ ...graph.videos.map(
1975
+ (video) => `import ${video.identifier}, {metadata as ${video.identifier}Metadata} from ${fsPath(video.file)};`
1976
+ ),
1977
+ ...graph.previews.map((preview) => `import ${preview.identifier} from ${fsPath(preview.file)};`),
1978
+ ...graph.brands.map((brand) => `import * as ${brand.identifier} from ${fsPath(brand.file)};`),
1979
+ "export const videos = [",
1980
+ ...graph.videos.map(
1981
+ (video) => ` {component: ${video.identifier}, metadata: {...${video.identifier}Metadata, id: ${video.identifier}Metadata.id || ${JSON.stringify(video.slug)}}},`
1982
+ ),
1983
+ "];",
1984
+ "export const componentPreviews = [",
1985
+ ...graph.previews.map(
1986
+ (preview) => ` {id: ${JSON.stringify(preview.name)}, preview: ${preview.identifier}.default ?? ${preview.identifier}},`
1987
+ ),
1988
+ "];",
1989
+ "export const brands = [",
1990
+ ...graph.brands.map(
1991
+ (brand) => ` ...Object.values(${brand.identifier}).filter((value) => value?.kind === "odori-brand"),`
1992
+ ),
1993
+ "];",
1994
+ `export const project = ${JSON.stringify({
1995
+ root: config.root,
1996
+ videosDir: config.videosDir,
1997
+ exportDir: config.exportDir,
1998
+ audioDir: config.audioDir,
1999
+ docsUrl: config.docsUrl,
2000
+ audio: graph.audio,
2001
+ sourceHash: graph.sourceHash,
2002
+ assets: config.assets ?? [],
2003
+ files: {
2004
+ videos: graph.videos.map((video) => ({ id: video.slug, file: video.relativeFile })),
2005
+ previews: graph.previews.map((preview) => ({ id: preview.name, file: preview.relativeFile })),
2006
+ brands: graph.brands.map((brand) => ({ id: brand.name, file: brand.relativeFile }))
2007
+ }
2008
+ })};`
2009
+ ].join("\n");
2010
+ }
2011
+ });
2012
+ var startStudioServer = async (initialConfig, options = {}) => {
2013
+ let config = initialConfig;
2014
+ const odoriSrc = runtimeSource(config.root);
2015
+ let graph = await discoverProject(config);
2016
+ await writeGenerated(config, graph);
2017
+ await registerProjectCues(graph);
2018
+ const vite = await createServer({
2019
+ root: studioRoot,
2020
+ configFile: false,
2021
+ // Studio owns the fallback so an unknown asset can 404 instead of being
2022
+ // answered with the app's HTML, which would only fail later at decode.
2023
+ appType: "custom",
2024
+ logLevel: "warn",
2025
+ // The API middleware is installed through a plugin so it runs before
2026
+ // Vite's own history fallback, which would otherwise answer with HTML.
2027
+ plugins: [
2028
+ react(),
2029
+ odoriProjectPlugin(config, () => graph),
2030
+ { name: "odori:api", configureServer: (server) => options.middleware?.(server) },
2031
+ {
2032
+ // Generated cues are rendered on demand and served from memory, so a
2033
+ // preview hears the same bytes the encoder will mix. The URL carries a
2034
+ // hash of the score, which makes it safe to cache forever.
2035
+ name: "odori:generated-cues",
2036
+ configureServer: (server) => () => {
2037
+ server.middlewares.use((request, response, next) => {
2038
+ const path = (request.url ?? "").split("?")[0];
2039
+ if (!path.startsWith("/__odori/cue/")) return next();
2040
+ const wav = renderedCue(path);
2041
+ if (!wav) {
2042
+ response.statusCode = 404;
2043
+ response.setHeader("content-type", "text/plain");
2044
+ response.end(`odori: no generated cue for ${path}`);
2045
+ return;
2046
+ }
2047
+ response.statusCode = 200;
2048
+ response.setHeader("content-type", "audio/wav");
2049
+ response.setHeader("content-length", String(wav.byteLength));
2050
+ response.setHeader("cache-control", "public, max-age=31536000, immutable");
2051
+ response.end(Buffer.from(wav));
2052
+ });
2053
+ }
2054
+ },
2055
+ {
2056
+ // A cue pointing at a file that is not there must fail as a missing
2057
+ // file. Vite's history fallback would answer with the Studio's HTML,
2058
+ // which an <audio> element accepts and then silently fails to decode,
2059
+ // turning a typo in a brand's audio map into a preview with no sound
2060
+ // and no error.
2061
+ name: "odori:asset-404",
2062
+ configureServer: (server) => () => {
2063
+ server.middlewares.use((request, response, next) => {
2064
+ const path = (request.url ?? "").split("?")[0];
2065
+ const isAsset = /\.(?:mp3|m4a|wav|ogg|aac|flac|mp4|webm|mov|png|jpe?g|gif|webp|avif|svg|woff2?|ttf|otf)$/i.test(path);
2066
+ if (!isAsset) return next();
2067
+ response.statusCode = 404;
2068
+ response.setHeader("content-type", "text/plain");
2069
+ response.end(`odori: ${path} is not in public/`);
2070
+ });
2071
+ }
2072
+ }
2073
+ ],
2074
+ // The project's public/ directory is served at the root, so brand fonts,
2075
+ // logos, and footage resolve identically in preview and render.
2076
+ publicDir: existsSync14(resolve16(config.root, "public")) ? resolve16(config.root, "public") : false,
2077
+ resolve: {
2078
+ dedupe: ["react", "react-dom", "odori"],
2079
+ // Only when the runtime is present as source. A consumer resolves the
2080
+ // published package through its exports map instead.
2081
+ alias: odoriSrc ? [
2082
+ { find: /^odori\/preview$/, replacement: resolve16(odoriSrc, "preview.ts") },
2083
+ { find: /^odori\/manifest$/, replacement: resolve16(odoriSrc, "manifest.ts") },
2084
+ { find: /^odori$/, replacement: resolve16(odoriSrc, "index.tsx") }
2085
+ ] : []
2086
+ },
2087
+ server: {
2088
+ host: "127.0.0.1",
2089
+ port: options.port ?? config.port,
2090
+ strictPort: options.strictPort ?? false,
2091
+ fs: { allow: [studioRoot, config.root, installRoot] },
2092
+ watch: { ignored: [`${config.root}/${config.outDir}/**`] }
2093
+ },
2094
+ optimizeDeps: { include: ["react", "react-dom", "react/jsx-dev-runtime"] }
2095
+ });
2096
+ const rediscover = async (file) => {
2097
+ if (!file.startsWith(resolve16(config.root, config.videosDir))) return;
2098
+ const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep2}brands${sep2}`);
2099
+ if (!isEntry) return;
2100
+ try {
2101
+ graph = await discoverProject(config);
2102
+ await writeGenerated(config, graph);
2103
+ await registerProjectCues(graph);
2104
+ } catch (error) {
2105
+ vite.config.logger.warn(`[odori] rediscovery skipped: ${error instanceof Error ? error.message : String(error)}`);
2106
+ return;
2107
+ }
2108
+ const module = vite.moduleGraph.getModuleById(RESOLVED_ID);
2109
+ if (module) vite.moduleGraph.invalidateModule(module);
2110
+ vite.ws.send({ type: "full-reload" });
2111
+ };
2112
+ vite.watcher.on("add", (file) => void rediscover(file));
2113
+ vite.watcher.on("unlink", (file) => void rediscover(file));
2114
+ vite.watcher.add(resolve16(config.root, config.videosDir));
2115
+ const reloadConfig = async (file) => {
2116
+ if (!/odori\.config\.(?:ts|mjs|js)$/.test(file)) return;
2117
+ try {
2118
+ config = await loadConfig(config.root);
2119
+ graph = await discoverProject(config);
2120
+ await writeGenerated(config, graph);
2121
+ await registerProjectCues(graph);
2122
+ } catch (error) {
2123
+ vite.config.logger.warn(`[odori] config reload skipped: ${error instanceof Error ? error.message : String(error)}`);
2124
+ return;
2125
+ }
2126
+ const module = vite.moduleGraph.getModuleById(RESOLVED_ID);
2127
+ if (module) vite.moduleGraph.invalidateModule(module);
2128
+ vite.ws.send({ type: "full-reload" });
2129
+ };
2130
+ const refreshCues = async (file) => {
2131
+ if (!file.startsWith(resolve16(config.root, config.videosDir)) || !file.split(sep2).includes("brands")) return;
2132
+ await registerProjectCues(graph);
2133
+ };
2134
+ vite.watcher.on("change", (file) => void refreshCues(file));
2135
+ vite.watcher.on("change", (file) => void reloadConfig(file));
2136
+ for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
2137
+ vite.watcher.add(resolve16(config.root, name));
2138
+ }
2139
+ vite.middlewares.use(async (request, response, next) => {
2140
+ const url = (request.url ?? "/").split("?")[0];
2141
+ const routable = request.method === "GET" && !url.startsWith("/@") && !url.startsWith("/__odori") && !/\.[a-zA-Z0-9]+$/.test(url);
2142
+ if (!routable) {
2143
+ next();
2144
+ return;
2145
+ }
2146
+ try {
2147
+ const html = await readFile11(studioEntry, "utf8");
2148
+ response.statusCode = 200;
2149
+ response.setHeader("content-type", "text/html");
2150
+ response.end(await vite.transformIndexHtml(url, html));
2151
+ } catch (error) {
2152
+ next(error);
2153
+ }
2154
+ });
2155
+ await vite.listen();
2156
+ const address = vite.resolvedUrls?.local[0] ?? `http://127.0.0.1:${options.port ?? config.port}`;
2157
+ return {
2158
+ vite,
2159
+ url: address.replace(/\/$/, ""),
2160
+ graph,
2161
+ close: async () => {
2162
+ await vite.close();
2163
+ }
2164
+ };
2165
+ };
2166
+
2167
+ // src/commands/exportVideo.ts
2168
+ import { resolve as resolve17 } from "path";
2169
+ import { resolveEntryLayout as resolveEntryLayout4 } from "odori";
2170
+
2171
+ // src/commands/shared.ts
2172
+ import { resolveEntryLayout as resolveEntryLayout3 } from "odori";
2173
+ var createContext = async (root = process.cwd()) => {
2174
+ const config = await loadConfig(root);
2175
+ const graph = await discoverProject(config);
2176
+ const videos = await loadVideos(graph);
2177
+ const brands = /* @__PURE__ */ new Map();
2178
+ for (const video of videos) {
2179
+ const layout = resolveEntryLayout3(video.entry);
2180
+ brands.set(layout.brand.name, layout.brand);
2181
+ registerCues([layout.brand], layout.format.fps);
2182
+ }
2183
+ return { config, graph, videos };
2184
+ };
2185
+ var targetFor = (video, input, prepared, audio, scenes) => {
2186
+ const layout = resolveEntryLayout3(video.entry);
2187
+ return {
2188
+ videoId: video.entry.metadata.id,
2189
+ width: layout.format.width,
2190
+ height: layout.format.height,
2191
+ fps: layout.format.fps,
2192
+ durationInFrames: video.durationInFrames,
2193
+ input,
2194
+ prepared,
2195
+ audio,
2196
+ scenes,
2197
+ targetLufs: layout.audio.targetLufs
2198
+ };
2199
+ };
2200
+ var compileInBrowser = async (origin, target, config) => {
2201
+ const { browser, page } = await openRenderPage(origin, target, config);
2202
+ try {
2203
+ const timeline = await readTimeline(page);
2204
+ const track = await readAudio(page);
2205
+ return {
2206
+ durationInFrames: target.durationInFrames || timeline.durationInFrames,
2207
+ scenes: timeline.scenes.map((scene) => ({
2208
+ id: scene.id,
2209
+ start: scene.start,
2210
+ durationInFrames: scene.durationInFrames
2211
+ })),
2212
+ audio: track.cues
2213
+ };
2214
+ } finally {
2215
+ await browser.close();
2216
+ }
2217
+ };
2218
+ var withServer = async (config, handler) => {
2219
+ const server = await startStudioServer(config, { port: 0 });
2220
+ try {
2221
+ return await handler(server);
2222
+ } finally {
2223
+ await server.close();
2224
+ }
2225
+ };
2226
+
2227
+ // src/commands/exportVideo.ts
2228
+ var exportQueue = new JobQueue();
2229
+ var running = /* @__PURE__ */ new Map();
2230
+ var cancelJob = (id) => {
2231
+ const controller = running.get(id);
2232
+ if (!controller) return false;
2233
+ controller.abort();
2234
+ return true;
2235
+ };
2236
+ var runJob = async (config, origin, record, video, options = {}) => exportQueue.enqueue(async () => {
2237
+ const { manifest, output } = record;
2238
+ const controller = new AbortController();
2239
+ running.set(record.job.id, controller);
2240
+ if (options.signal) options.signal.addEventListener("abort", () => controller.abort(), { once: true });
2241
+ let current = await updateJob(config, {
2242
+ ...record.job,
2243
+ status: "rendering",
2244
+ progress: 0,
2245
+ attempts: record.job.attempts + 1,
2246
+ pid: process.pid,
2247
+ error: void 0
2248
+ });
2249
+ options.onProgress?.(current);
2250
+ await appendJobLog(config, current.id, `attempt ${current.attempts} started`);
2251
+ try {
2252
+ const cues = await materializeCues(config, [resolveEntryLayout4(video.entry).brand], manifest.format.fps);
2253
+ const rendered2 = cues.filter((entry) => entry.rendered).length;
2254
+ if (rendered2 > 0) await appendJobLog(config, current.id, `rendered ${rendered2} generated cue(s)`);
2255
+ await renderMovie(
2256
+ origin,
2257
+ // The frozen scene list drives chunking, so chunks follow scene cuts.
2258
+ targetFor(
2259
+ { ...video, durationInFrames: manifest.format.durationInFrames },
2260
+ manifest.input,
2261
+ manifest.prepared,
2262
+ manifest.audio,
2263
+ manifest.scenes
2264
+ ),
2265
+ output,
2266
+ config,
2267
+ (progress, stage) => {
2268
+ const rounded = Math.round(progress * 100);
2269
+ if (rounded % 5 !== 0 && progress < 1) return;
2270
+ void updateJob(config, { ...current, status: stage, progress }).then((next) => {
2271
+ current = next;
2272
+ options.onProgress?.(next);
2273
+ });
2274
+ },
2275
+ {
2276
+ concurrency: options.concurrency,
2277
+ preset: options.preset,
2278
+ format: options.format,
2279
+ skipUnchangedFrames: options.skipUnchangedFrames,
2280
+ signal: controller.signal,
2281
+ onTimings: (timings) => {
2282
+ log.progressDone();
2283
+ const reused = timings.reusedFrames > 0 ? `, ${timings.reusedFrames} reused` : "";
2284
+ const cached = timings.cachedChunks > 0 ? `, ${timings.cachedChunks} chunks from cache` : "";
2285
+ log.detail(
2286
+ `captured ${timings.frames} frames in ${(timings.captureMs / 1e3).toFixed(1)}s on ${timings.concurrency} workers across ${timings.chunks} chunks${reused}${cached}, joined in ${(timings.encodeMs / 1e3).toFixed(1)}s`
2287
+ );
2288
+ }
2289
+ }
2290
+ );
2291
+ current = await updateJob(config, { ...current, status: "ready", progress: 1, pid: void 0, output });
2292
+ await appendJobLog(config, current.id, `ready: ${output}`);
2293
+ options.onProgress?.(current);
2294
+ return current;
2295
+ } catch (error) {
2296
+ const message2 = error instanceof Error ? error.message : String(error);
2297
+ current = await updateJob(config, { ...current, status: "failed", pid: void 0, error: message2 });
2298
+ await appendJobLog(config, current.id, `failed: ${message2}`);
2299
+ options.onProgress?.(current);
2300
+ throw error;
2301
+ } finally {
2302
+ running.delete(record.job.id);
2303
+ }
2304
+ });
2305
+ var exportCommand = async (id, options = {}) => {
2306
+ const { config, graph, videos } = await createContext();
2307
+ const format = resolveFormat(options.format ?? config.format, options.output);
2308
+ return withServer(config, async (server) => {
2309
+ const record = options.retry ? await readJob(config, options.retry) : await (async () => {
2310
+ const video2 = findVideo(videos, id);
2311
+ const compiled = await compileInBrowser(server.url, targetFor(video2, options.input), config);
2312
+ const { manifest } = await freezeManifest(
2313
+ { ...video2, durationInFrames: compiled.durationInFrames },
2314
+ graph,
2315
+ config,
2316
+ options.input ?? {},
2317
+ { scenes: compiled.scenes, audio: compiled.audio }
2318
+ );
2319
+ const output = resolve17(
2320
+ config.root,
2321
+ options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
2322
+ );
2323
+ return createJob(config, manifest, output);
2324
+ })();
2325
+ const video = findVideo(videos, record.manifest.videoId);
2326
+ log.detail(`job ${record.job.id} manifest ${record.manifest.manifestHash}`);
2327
+ if (options.retry) log.detail(`retrying attempt ${record.job.attempts + 1} from the frozen manifest`);
2328
+ if (record.manifest.audio.length > 0) {
2329
+ log.detail(`${record.manifest.audio.length} audio cue(s) at ${record.manifest.format.durationInFrames} frames`);
2330
+ }
2331
+ const job = await runJob(config, server.url, record, video, {
2332
+ concurrency: options.concurrency,
2333
+ preset: options.preset,
2334
+ format,
2335
+ skipUnchangedFrames: options.skipUnchangedFrames,
2336
+ onProgress: (next) => {
2337
+ if (next.status === "rendering" || next.status === "encoding") {
2338
+ log.progress(`${next.status} ${Math.round(next.progress * 100)}%`);
2339
+ }
2340
+ }
2341
+ });
2342
+ log.progressDone();
2343
+ log.success(`Exported ${job.videoId} to ${record.output}`);
2344
+ return job;
2345
+ });
2346
+ };
2347
+ var jobsCommand = async () => {
2348
+ const { config } = await createContext();
2349
+ const jobs = await listJobs(config);
2350
+ if (jobs.length === 0) {
2351
+ log.detail("No export jobs recorded yet.");
2352
+ return;
2353
+ }
2354
+ log.title(`${jobs.length} export job${jobs.length === 1 ? "" : "s"}`);
2355
+ for (const job of jobs) {
2356
+ const detail = job.status === "ready" ? job.output : job.error ?? `${Math.round(job.progress * 100)}%`;
2357
+ log.info(
2358
+ ` ${job.id} ${job.videoId.padEnd(20)} ${job.status.padEnd(9)} attempts ${job.attempts} ${detail ?? ""}`
2359
+ );
2360
+ }
2361
+ log.detail("Retry a failed job with: odori export --retry <job id>");
2362
+ };
2363
+
2364
+ // src/commands/dev.ts
2365
+ var readBody = async (request) => {
2366
+ const chunks = [];
2367
+ for await (const chunk of request) chunks.push(chunk);
2368
+ if (chunks.length === 0) return {};
2369
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
2370
+ };
2371
+ var json = (response, status, payload) => {
2372
+ response.statusCode = status;
2373
+ response.setHeader("content-type", "application/json");
2374
+ response.end(JSON.stringify(payload));
2375
+ };
2376
+ var devCommand = async (options = {}) => {
2377
+ const config = await loadConfig(options.root ?? process.cwd());
2378
+ const context = async () => {
2379
+ const graph = await discoverProject(config);
2380
+ return { graph, videos: await loadVideos(graph) };
2381
+ };
2382
+ const server = await startStudioServer(config, {
2383
+ port: options.port ?? config.port,
2384
+ middleware: (vite) => {
2385
+ vite.middlewares.use("/__odori", (request, response, next) => {
2386
+ const url = request.url ?? "/";
2387
+ void (async () => {
2388
+ try {
2389
+ if (request.method === "POST" && url.startsWith("/still")) {
2390
+ const body = await readBody(request);
2391
+ const { graph, videos } = await context();
2392
+ const video = findVideo(videos, String(body.videoId));
2393
+ const input = body.input ?? {};
2394
+ const compiled = await compileInBrowser(origin, targetFor(video, input), config);
2395
+ const { manifest, prepared } = await freezeManifest(
2396
+ { ...video, durationInFrames: compiled.durationInFrames },
2397
+ graph,
2398
+ config,
2399
+ input,
2400
+ { scenes: compiled.scenes, audio: compiled.audio }
2401
+ );
2402
+ const frame = Number(body.frame ?? 0);
2403
+ const inline = body.inline === true;
2404
+ const directory2 = inline ? config.outDir : config.exportDir;
2405
+ const file = resolve18(config.root, `${directory2}/${outputName(video.entry.metadata.id)}-${frame}.png`);
2406
+ await renderStill(
2407
+ origin,
2408
+ targetFor(
2409
+ { ...video, durationInFrames: manifest.format.durationInFrames },
2410
+ manifest.input,
2411
+ prepared,
2412
+ manifest.audio
2413
+ ),
2414
+ frame,
2415
+ file,
2416
+ config
2417
+ );
2418
+ if (inline) {
2419
+ response.statusCode = 200;
2420
+ response.setHeader("content-type", "image/png");
2421
+ response.end(await readFile12(file));
2422
+ return;
2423
+ }
2424
+ json(response, 200, { id: "still", status: "ready", progress: 1, output: file });
2425
+ return;
2426
+ }
2427
+ if (request.method === "POST" && url.startsWith("/exports")) {
2428
+ const body = await readBody(request);
2429
+ const { graph, videos } = await context();
2430
+ const video = findVideo(videos, String(body.videoId));
2431
+ const input = body.input ?? {};
2432
+ const compiled = await compileInBrowser(origin, targetFor(video, input), config);
2433
+ const { manifest } = await freezeManifest(
2434
+ { ...video, durationInFrames: compiled.durationInFrames },
2435
+ graph,
2436
+ config,
2437
+ input,
2438
+ { scenes: compiled.scenes, audio: compiled.audio }
2439
+ );
2440
+ const output = resolve18(config.root, `${config.exportDir}/${outputName(video.entry.metadata.id)}.mp4`);
2441
+ const record = await createJob(config, manifest, output);
2442
+ json(response, 202, record.job);
2443
+ void runJob(config, origin, record, video).catch((error) => {
2444
+ log.error(`Export failed: ${error instanceof Error ? error.message : String(error)}`);
2445
+ });
2446
+ return;
2447
+ }
2448
+ if (request.method === "POST" && url.startsWith("/retry/")) {
2449
+ const id = url.replace("/retry/", "").split("?")[0];
2450
+ const record = await readJob(config, id);
2451
+ const { videos } = await context();
2452
+ const video = findVideo(videos, record.manifest.videoId);
2453
+ json(response, 202, record.job);
2454
+ void runJob(config, origin, record, video).catch((error) => {
2455
+ log.error(`Retry failed: ${error instanceof Error ? error.message : String(error)}`);
2456
+ });
2457
+ return;
2458
+ }
2459
+ if (request.method === "POST" && url.startsWith("/cancel/")) {
2460
+ const id = url.replace("/cancel/", "").split("?")[0];
2461
+ const cancelled = cancelJob(id);
2462
+ json(response, cancelled ? 202 : 404, { id, cancelled });
2463
+ return;
2464
+ }
2465
+ if (request.method === "GET" && url.startsWith("/jobs/")) {
2466
+ const id = url.replace("/jobs/", "").split("?")[0];
2467
+ json(response, 200, (await readJob(config, id)).job);
2468
+ return;
2469
+ }
2470
+ if (request.method === "GET" && url.startsWith("/jobs")) {
2471
+ json(response, 200, await listJobs(config));
2472
+ return;
2473
+ }
2474
+ next();
2475
+ } catch (error) {
2476
+ json(response, 500, { status: "failed", error: error instanceof Error ? error.message : String(error) });
2477
+ }
2478
+ })();
2479
+ });
2480
+ }
2481
+ });
2482
+ const origin = server.url;
2483
+ const entry = `${origin}/videos`;
2484
+ log.title("Odori Studio");
2485
+ log.info(` ${entry}`);
2486
+ log.detail(` ${server.graph.videos.length} videos, ${server.graph.previews.length} component previews`);
2487
+ log.detail(` watching ${config.videosDir}/`);
2488
+ if (shouldOpenBrowser(options.open ?? config.open)) openInBrowser(entry);
2489
+ return server;
2490
+ };
2491
+
2492
+ // src/commands/doctor.ts
2493
+ import { constants } from "fs";
2494
+ import { access, mkdir as mkdir12, readFile as readFile13, rm as rm3, writeFile as writeFile13 } from "fs/promises";
2495
+ import { existsSync as existsSync15 } from "fs";
2496
+ import { createRequire as createRequire3 } from "module";
2497
+ import { relative as relative5, resolve as resolve19 } from "path";
2498
+ var MINIMUM_NODE = 20;
2499
+ var version = (value) => value.replace(/^v/, "").split(".").map(Number);
2500
+ var runChecks = async (root) => {
2501
+ const checks = [];
2502
+ const config = await loadConfig(root);
2503
+ const require2 = createRequire3(resolve19(root, "package.json"));
2504
+ const [major] = version(process.version);
2505
+ checks.push({
2506
+ name: "Node",
2507
+ detail: process.version,
2508
+ ok: major >= MINIMUM_NODE,
2509
+ fix: `Odori needs Node ${MINIMUM_NODE} or newer. Install it, for example with: nvm install ${MINIMUM_NODE}`
2510
+ });
2511
+ let react2 = "not found";
2512
+ let reactOk = false;
2513
+ try {
2514
+ const manifest = JSON.parse(await readFile13(require2.resolve("react/package.json"), "utf8"));
2515
+ react2 = manifest.version;
2516
+ reactOk = version(react2)[0] >= 19;
2517
+ } catch {
2518
+ react2 = "not installed";
2519
+ }
2520
+ checks.push({
2521
+ name: "React",
2522
+ detail: react2,
2523
+ ok: reactOk,
2524
+ fix: "The runtime needs React 19. Install it: npm install react@19 react-dom@19"
2525
+ });
2526
+ const videosDir = resolve19(config.root, config.videosDir);
2527
+ checks.push({
2528
+ name: "Source root",
2529
+ detail: existsSync15(videosDir) ? relative5(config.root, videosDir) + "/" : `no ${config.videosDir}/`,
2530
+ ok: existsSync15(videosDir),
2531
+ fix: 'Run "odori init" to add the videos source root.'
2532
+ });
2533
+ checks.push({
2534
+ name: "Config",
2535
+ detail: config.configPath ? relative5(config.root, config.configPath) : "defaults (no odori.config.ts)",
2536
+ // Loading got this far, so a config that exists also parsed.
2537
+ ok: true
2538
+ });
2539
+ const chrome = await resolveBrowser(config);
2540
+ checks.push({
2541
+ name: "Chrome",
2542
+ detail: chrome ? `${chrome.origin === "managed" ? `pinned ${CHROME_BUILD}` : `${chrome.origin}, version unpinned`} \xB7 ${chrome.path}` : "not found",
2543
+ ok: Boolean(chrome),
2544
+ fix: 'Run "odori install" to download the pinned build, or set chromePath in odori.config.ts.'
2545
+ });
2546
+ const ffmpeg = await resolveFfmpeg(config);
2547
+ checks.push({
2548
+ name: "FFmpeg",
2549
+ detail: ffmpeg ? `${ffmpeg.origin === "managed" || ffmpeg.origin === "package" ? "pinned 5.3.0" : `${ffmpeg.origin}, version unpinned`} \xB7 ${ffmpeg.path}` : "not found",
2550
+ ok: Boolean(ffmpeg),
2551
+ fix: 'Run "odori install" to download the pinned build, or set ffmpegPath in odori.config.ts.'
2552
+ });
2553
+ const unpinned = [chrome, ffmpeg].filter(
2554
+ (binary) => binary && binary.origin !== "managed" && binary.origin !== "package"
2555
+ );
2556
+ checks.push({
2557
+ name: "Reproducible",
2558
+ detail: unpinned.length === 0 ? `pinned binaries from ${cacheRoot()}` : `${unpinned.length} of 2 from the host; frames may differ from another machine`,
2559
+ ok: true
2560
+ });
2561
+ const generated = resolve19(config.root, ".odori");
2562
+ let writable = false;
2563
+ try {
2564
+ await mkdir12(generated, { recursive: true });
2565
+ const probe = resolve19(generated, ".doctor");
2566
+ await writeFile13(probe, "", "utf8");
2567
+ await access(probe, constants.W_OK);
2568
+ await rm3(probe, { force: true });
2569
+ writable = true;
2570
+ } catch {
2571
+ writable = false;
2572
+ }
2573
+ checks.push({
2574
+ name: "Generated cache",
2575
+ detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
2576
+ ok: writable,
2577
+ fix: `Odori writes its import graph and render cache to ${relative5(process.cwd(), generated) || ".odori"}. Check the directory's permissions.`
2578
+ });
2579
+ return checks;
2580
+ };
2581
+ var doctorCommand = async (root = process.cwd()) => {
2582
+ const checks = await runChecks(root);
2583
+ const width = Math.max(...checks.map((check) => check.name.length));
2584
+ log.title("odori doctor");
2585
+ for (const check of checks) {
2586
+ const label = check.name.padEnd(width);
2587
+ if (check.ok) log.success(`${label} ${check.detail}`);
2588
+ else log.error(`${label} ${check.detail}`);
2589
+ }
2590
+ const failed = checks.filter((check) => !check.ok);
2591
+ if (failed.length === 0) {
2592
+ log.detail("Everything a render needs is present.");
2593
+ return 0;
2594
+ }
2595
+ log.info("");
2596
+ for (const check of failed) log.warn(`${check.name}: ${check.fix}`);
2597
+ return 1;
2598
+ };
2599
+
2600
+ // src/commands/init.ts
2601
+ import { mkdir as mkdir14, writeFile as writeFile15 } from "fs/promises";
2602
+ import { existsSync as existsSync17 } from "fs";
2603
+ import { relative as relative7, resolve as resolve21 } from "path";
2604
+
2605
+ // src/commands/new.ts
2606
+ import { mkdir as mkdir13, readdir as readdir5, writeFile as writeFile14 } from "fs/promises";
2607
+ import { existsSync as existsSync16 } from "fs";
2608
+ import { relative as relative6, resolve as resolve20 } from "path";
2609
+ var titleCase = (value) => value.split(/[-_\s]+/).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2610
+ var pascalCase = (value) => titleCase(value).replace(/\s+/g, "");
2611
+ var videoTemplate = (name, hasLayout) => `import {Scene, Video, defineVideoMetadata} from "odori";
2612
+ ${hasLayout ? 'import {productLayout} from "../layout";\n' : ""}
2613
+ export const metadata = defineVideoMetadata({
2614
+ id: "${name}",
2615
+ title: "${titleCase(name)}",
2616
+ ${hasLayout ? " layout: productLayout,\n" : ""} duration: "8s",
2617
+ });
2618
+
2619
+ export default function ${pascalCase(name)}() {
2620
+ return (
2621
+ <Video>
2622
+ <Scene id="opening" duration="5s">
2623
+ <h1 style={{fontSize: 112, fontWeight: 650, letterSpacing: "-0.045em", margin: "auto", textAlign: "center"}}>
2624
+ ${titleCase(name)}
2625
+ </h1>
2626
+ </Scene>
2627
+ <Scene id="end" duration="3s">
2628
+ <p style={{fontSize: 48, margin: "auto", opacity: 0.7}}>Built with odori</p>
2629
+ </Scene>
2630
+ </Video>
2631
+ );
2632
+ }
2633
+ `;
2634
+ var composedTemplate = (name, hasLayout, parts) => {
2635
+ const imports = [
2636
+ 'import {Scene, Video, defineVideoMetadata} from "odori";',
2637
+ parts.title ? 'import {TitleReveal} from "../components/title-reveal/title-reveal";' : null,
2638
+ parts.end ? 'import {EndCard} from "../components/end-card/end-card";' : null,
2639
+ hasLayout ? 'import {productLayout} from "../layout";' : null
2640
+ ].filter(Boolean);
2641
+ const opening = parts.title ? ` <TitleReveal title="${titleCase(name)}" detail="Written in ${name}/video.tsx" />` : ` <h1 style={{fontSize: 112, fontWeight: 650, margin: "auto", textAlign: "center"}}>${titleCase(name)}</h1>`;
2642
+ const closing = parts.end ? ` <EndCard title="Ship it" detail="odori export ${name}" />` : ` <p style={{fontSize: 48, margin: "auto", opacity: 0.7}}>Built with odori</p>`;
2643
+ return `${imports.join("\n")}
2644
+
2645
+ export const metadata = defineVideoMetadata({
2646
+ id: "${name}",
2647
+ title: "${titleCase(name)}",
2648
+ ${hasLayout ? " layout: productLayout,\n" : ""} duration: "8s",
2649
+ });
2650
+
2651
+ export default function ${pascalCase(name)}() {
2652
+ return (
2653
+ <Video>
2654
+ <Scene id="opening" duration="5s">
2655
+ ${opening}
2656
+ </Scene>
2657
+ <Scene id="end" duration="3s">
2658
+ ${closing}
2659
+ </Scene>
2660
+ </Video>
2661
+ );
2662
+ }
2663
+ `;
2664
+ };
2665
+ var installedParts = async (config) => {
2666
+ const componentsDir = resolve20(config.root, config.componentsDir);
2667
+ if (!existsSync16(componentsDir)) return { title: false, end: false };
2668
+ const entries = (await readdir5(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2669
+ return { title: entries.includes("title-reveal"), end: entries.includes("end-card") };
2670
+ };
2671
+ var newCommand = async (name, options = {}) => {
2672
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) throw new Error("Use a lowercase, dash separated name.");
2673
+ const config = await loadConfig(process.cwd());
2674
+ const directory2 = resolve20(config.root, config.videosDir, name);
2675
+ const file = resolve20(directory2, "video.tsx");
2676
+ if (existsSync16(file)) throw new Error(`${relative6(config.root, file)} already exists.`);
2677
+ const hasLayout = existsSync16(resolve20(config.root, config.videosDir, "layout.tsx"));
2678
+ const parts = options.blank === true ? { title: false, end: false } : await installedParts(config);
2679
+ const composed = parts.title || parts.end;
2680
+ await mkdir13(directory2, { recursive: true });
2681
+ await writeFile14(
2682
+ file,
2683
+ composed ? composedTemplate(name, hasLayout, parts) : videoTemplate(name, hasLayout),
2684
+ "utf8"
2685
+ );
2686
+ log.success(`Created ${relative6(config.root, file)}`);
2687
+ if (composed) log.detail("Composed from the components this project has installed.");
2688
+ else if (options.blank !== true) {
2689
+ log.detail("No registry components installed yet: odori add @odori/title-reveal @odori/end-card");
2690
+ }
2691
+ log.detail("Run odori dev to preview it.");
2692
+ };
2693
+
2694
+ // src/commands/init.ts
2695
+ var CONFIG_TEMPLATE = `import {defineConfig} from "@odori/cli";
2696
+
2697
+ export default defineConfig({
2698
+ videosDir: "videos",
2699
+ exportDir: "out",
2700
+ port: 4300,
2701
+ });
2702
+ `;
2703
+ var LAYOUT_TEMPLATE = `import {defineBrand, defineVideoLayout} from "odori";
2704
+
2705
+ export const productBrand = defineBrand({
2706
+ name: "product",
2707
+ colors: {background: "#08090b", surface: "#111318", foreground: "#f7f8fa", accent: "#7c8cff"},
2708
+ // Empty on purpose: "odori add" writes installed cues into this block,
2709
+ // and without it the only thing it can do is print the lines to paste.
2710
+ audio: {cues: {}, targetLufs: -14},
2711
+ });
2712
+
2713
+ export const productLayout = defineVideoLayout({
2714
+ format: {width: 1920, height: 1080, fps: 30},
2715
+ brand: productBrand,
2716
+ safeArea: {x: 96, y: 72},
2717
+ });
2718
+ `;
2719
+ var initCommand = async (root = process.cwd()) => {
2720
+ const videosDir = resolve21(root, defaultConfig.videosDir);
2721
+ await mkdir14(resolve21(videosDir, "components"), { recursive: true });
2722
+ const files = [
2723
+ [resolve21(root, "odori.config.ts"), CONFIG_TEMPLATE],
2724
+ [resolve21(videosDir, "layout.tsx"), LAYOUT_TEMPLATE],
2725
+ [resolve21(videosDir, "launch", "video.tsx"), videoTemplate("launch", true)]
2726
+ ];
2727
+ for (const [file, contents] of files) {
2728
+ if (existsSync17(file)) {
2729
+ log.detail(`Kept existing ${relative7(root, file)}`);
2730
+ continue;
2731
+ }
2732
+ await mkdir14(resolve21(file, ".."), { recursive: true });
2733
+ await writeFile15(file, contents, "utf8");
2734
+ log.success(`Created ${relative7(root, file)}`);
2735
+ }
2736
+ log.detail("Next: odori doctor, then odori add @odori/title-reveal @odori/end-card, then odori dev.");
2737
+ };
2738
+
2739
+ // src/commands/inspect.ts
2740
+ import { isOdoriSchema, resolveEntryLayout as resolveEntryLayout5 } from "odori";
2741
+ var inspectCommand = async (id, options = {}) => {
2742
+ const { config, graph, videos } = await createContext();
2743
+ const video = findVideo(videos, id);
2744
+ const layout = resolveEntryLayout5(video.entry);
2745
+ const { durationInFrames, scenes, audio } = await withServer(
2746
+ config,
2747
+ (server) => compileInBrowser(server.url, targetFor(video, options.input), config)
2748
+ );
2749
+ const { manifest, input, prepared } = await freezeManifest(
2750
+ { ...video, durationInFrames },
2751
+ graph,
2752
+ config,
2753
+ options.input ?? {},
2754
+ { scenes, audio }
2755
+ );
2756
+ if (options.json) {
2757
+ log.info(JSON.stringify({ metadata: { ...video.entry.metadata, layout: void 0, schema: void 0 }, layout, manifest }, null, 2));
2758
+ return;
2759
+ }
2760
+ log.title(video.entry.metadata.title);
2761
+ log.info(` id ${video.entry.metadata.id}`);
2762
+ log.info(` source ${video.relativeFile}`);
2763
+ log.info(` format ${layout.format.width}x${layout.format.height} at ${layout.format.fps} fps`);
2764
+ log.info(` duration ${durationInFrames} frames (${(durationInFrames / layout.format.fps).toFixed(2)}s)`);
2765
+ log.info(` brand ${layout.brand.name}`);
2766
+ log.info(` safe area ${layout.safeArea.x} x ${layout.safeArea.y}`);
2767
+ log.info(` manifest ${manifest.manifestHash}`);
2768
+ log.info(` source hash ${manifest.sourceHash}`);
2769
+ log.title("Scenes");
2770
+ for (const scene of scenes) {
2771
+ log.info(
2772
+ ` ${scene.id.padEnd(16)} ${String(scene.start).padStart(5)} to ${String(
2773
+ scene.start + scene.durationInFrames - 1
2774
+ ).padStart(5)} (${scene.durationInFrames} frames)`
2775
+ );
2776
+ }
2777
+ if (scenes.length === 0) log.detail(" No structured scenes. The composition drives motion directly.");
2778
+ log.title("Inputs");
2779
+ if (isOdoriSchema(video.entry.metadata.schema)) {
2780
+ for (const [name, field] of Object.entries(video.entry.metadata.schema.describe())) {
2781
+ log.info(` ${name.padEnd(16)} ${field.type.padEnd(8)} ${JSON.stringify(input[name])}`);
2782
+ }
2783
+ } else {
2784
+ log.detail(` ${JSON.stringify(input)}`);
2785
+ }
2786
+ if (prepared !== void 0) {
2787
+ log.title("Prepared");
2788
+ log.detail(` ${JSON.stringify(prepared).slice(0, 400)}`);
2789
+ }
2790
+ log.title("Audio");
2791
+ if (manifest.audio.length === 0) log.detail(' Silent. Add <Audio src="/audio/bed.mp3" /> to score it.');
2792
+ for (const cue of manifest.audio) {
2793
+ log.info(
2794
+ ` ${cue.src.padEnd(28)} ${String(cue.fromFrame).padStart(5)} to ${String(
2795
+ cue.fromFrame + cue.durationInFrames - 1
2796
+ ).padStart(5)} gain ${cue.gain}${cue.duckUnder ? " (ducked)" : ""}`
2797
+ );
2798
+ log.detail(` ${cue.integrity}`);
2799
+ }
2800
+ log.title("Assets");
2801
+ if (manifest.assets.length === 0 && manifest.fonts.length === 0) log.detail(" None declared.");
2802
+ for (const asset of manifest.assets) log.info(` ${asset.reference ?? asset.url}`);
2803
+ for (const font of manifest.fonts) log.info(` font ${font.family} ${font.url} ${font.integrity}`);
2804
+ };
2805
+
2806
+ // src/commands/list.ts
2807
+ import { resolveEntryLayout as resolveEntryLayout6 } from "odori";
2808
+ var listCommand = async () => {
2809
+ const { videos, graph, config } = await createContext();
2810
+ log.title(`${videos.length} video${videos.length === 1 ? "" : "s"} in ${config.videosDir}/`);
2811
+ for (const video of videos) {
2812
+ const layout = resolveEntryLayout6(video.entry);
2813
+ const seconds = video.durationInFrames / layout.format.fps;
2814
+ log.info(
2815
+ ` ${video.entry.metadata.id} ${layout.format.width}x${layout.format.height} ${layout.format.fps}fps ${video.durationInFrames ? `${seconds.toFixed(1)}s` : "duration from scenes"}`
2816
+ );
2817
+ log.detail(` ${video.relativeFile}`);
2818
+ }
2819
+ if (graph.previews.length > 0) {
2820
+ log.title(`${graph.previews.length} component preview${graph.previews.length === 1 ? "" : "s"}`);
2821
+ for (const preview of graph.previews) log.info(` ${preview.name} ${preview.relativeFile}`);
2822
+ }
2823
+ };
2824
+
2825
+ // src/commands/still.ts
2826
+ import { resolve as resolve22 } from "path";
2827
+ var stillCommand = async (id, options = {}) => {
2828
+ const frame = options.frame ?? 0;
2829
+ if (!Number.isInteger(frame) || frame < 0) {
2830
+ throw new Error(`Frame must be a whole number of frames from the start, got ${String(frame)}.`);
2831
+ }
2832
+ const { config, graph, videos } = await createContext();
2833
+ const video = findVideo(videos, id);
2834
+ const output = await withServer(config, async (server) => {
2835
+ const { durationInFrames, scenes, audio } = await compileInBrowser(server.url, targetFor(video, options.input), config);
2836
+ const { manifest, input, prepared } = await freezeManifest(
2837
+ { ...video, durationInFrames },
2838
+ graph,
2839
+ config,
2840
+ options.input ?? {},
2841
+ { scenes, audio }
2842
+ );
2843
+ if (frame >= manifest.format.durationInFrames) {
2844
+ throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
2845
+ }
2846
+ const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
2847
+ const file = resolve22(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
2848
+ return renderStill(server.url, target, frame, file, config);
2849
+ });
2850
+ log.success(`Still frame ${frame} written to ${output}`);
2851
+ return output;
2852
+ };
2853
+
2854
+ // src/commands/test.ts
2855
+ import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
2856
+
2857
+ // src/contracts.ts
2858
+ import { existsSync as existsSync18 } from "fs";
2859
+ import { readdir as readdir6 } from "fs/promises";
2860
+ import { resolve as resolve23 } from "path";
2861
+ import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout7 } from "odori";
2862
+ var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
2863
+ var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
2864
+ "system-ui",
2865
+ "ui-sans-serif",
2866
+ "ui-monospace",
2867
+ "ui-serif",
2868
+ "ui-rounded",
2869
+ "sans-serif",
2870
+ "serif",
2871
+ "monospace",
2872
+ "cursive",
2873
+ "fantasy",
2874
+ "-apple-system",
2875
+ "BlinkMacSystemFont"
2876
+ ]);
2877
+ var isSystemFamily = (family) => SYSTEM_FAMILIES.has(family);
2878
+ var checkComponentRequirements = (installed, brand, videoId) => {
2879
+ const failures = [];
2880
+ for (const component of installed) {
2881
+ for (const cue of component.contract.requires.audio) {
2882
+ if (brand.audio.cues[cue] === void 0) {
2883
+ failures.push({
2884
+ video: videoId,
2885
+ message: `${component.namespaced} needs an audio cue named "${cue}". Add it to brand.audio.cues, or remove the component.`
2886
+ });
2887
+ }
2888
+ }
2889
+ for (const role of component.contract.requires.fonts) {
2890
+ const stack = brand.typography[role];
2891
+ if (!stack) {
2892
+ failures.push({
2893
+ video: videoId,
2894
+ message: `${component.namespaced} needs a "${role}" font. Add brand.typography.${role}.`
2895
+ });
2896
+ continue;
2897
+ }
2898
+ const family = primaryFamily(stack);
2899
+ if (family && !isSystemFamily(family) && !brand.fonts.some((font) => font.family === family)) {
2900
+ failures.push({
2901
+ video: videoId,
2902
+ message: `${component.namespaced} uses the "${role}" font "${family}", which has no file in brand.fonts. Preview and export will fall back to a system face.`
2903
+ });
2904
+ }
2905
+ }
2906
+ }
2907
+ return failures;
2908
+ };
2909
+ var checkAudioWindows = (cues, brand, videoId) => {
2910
+ const generated = /* @__PURE__ */ new Map();
2911
+ for (const [name, value] of Object.entries(brand.audio.cues)) {
2912
+ if (isCueDefinition2(value)) generated.set(cueUrl2(value), { name, definition: value });
2913
+ }
2914
+ const failures = [];
2915
+ for (const cue of cues) {
2916
+ const match = generated.get(cue.src);
2917
+ if (!match || cue.loop) continue;
2918
+ if (cue.durationInFrames <= match.definition.durationInFrames) continue;
2919
+ failures.push({
2920
+ video: videoId,
2921
+ message: `"${match.name}" is ${match.definition.durationInFrames} frames long but is placed over ${cue.durationInFrames}. The rest is silence: declare loops on the cue, pass loop to <Audio>, or shorten the window with duration.`
2922
+ });
2923
+ }
2924
+ return failures;
2925
+ };
2926
+ var checkInstalledContracts = async (config, videos) => {
2927
+ const componentsDir = resolve23(config.root, config.componentsDir);
2928
+ const onDisk = existsSync18(componentsDir) ? (await readdir6(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
2929
+ const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
2930
+ if (names.size === 0) return [];
2931
+ const { items } = await resolveRegistry(config, { allowNetwork: false });
2932
+ const installed = items.filter((component) => names.has(component.name));
2933
+ if (installed.length === 0) return [];
2934
+ const seen = /* @__PURE__ */ new Set();
2935
+ const failures = [];
2936
+ for (const video of videos) {
2937
+ const { brand } = resolveEntryLayout7(video.entry);
2938
+ for (const failure of checkComponentRequirements(installed, brand, video.entry.metadata.id)) {
2939
+ const key = `${brand.name}:${failure.message}`;
2940
+ if (seen.has(key)) continue;
2941
+ seen.add(key);
2942
+ failures.push(failure);
2943
+ }
2944
+ }
2945
+ return failures;
2946
+ };
2947
+
2948
+ // src/determinism.ts
2949
+ import { readdir as readdir7, readFile as readFile14 } from "fs/promises";
2950
+ import { existsSync as existsSync19 } from "fs";
2951
+ import { join as join7, relative as relative8, resolve as resolve24 } from "path";
2952
+ var FORBIDDEN = [
2953
+ {
2954
+ pattern: /\bMath\.random\s*\(/,
2955
+ message: 'Math.random() differs per render worker. Use random("a-seed") from odori, which is stable for a seed.'
2956
+ },
2957
+ {
2958
+ pattern: /\bDate\.now\s*\(/,
2959
+ message: "Date.now() makes the frame depend on when it rendered. Derive time from useFrame(), or pass it through the video's input schema."
2960
+ },
2961
+ {
2962
+ pattern: /\bnew\s+Date\s*\(\s*\)/,
2963
+ message: "new Date() with no argument reads the wall clock. Pass the date through the video's input schema so a re-render produces the same frame."
2964
+ },
2965
+ {
2966
+ pattern: /\bperformance\.now\s*\(/,
2967
+ message: "performance.now() is wall clock. A frame's timing comes from useFrame() and the video's fps."
2968
+ }
2969
+ ];
2970
+ var isComment = (line) => /^\s*(\/\/|\*|\/\*)/.test(line);
2971
+ var scanSource = (source, file) => {
2972
+ const findings = [];
2973
+ source.split("\n").forEach((text, index) => {
2974
+ if (isComment(text)) return;
2975
+ if (/odori-allow-nondeterminism/.test(text)) return;
2976
+ for (const { pattern, message: message2 } of FORBIDDEN) {
2977
+ if (pattern.test(text)) findings.push({ file, line: index + 1, source: text.trim(), message: message2 });
2978
+ }
2979
+ });
2980
+ return findings;
2981
+ };
2982
+ var walk2 = async (directory2, files = []) => {
2983
+ for (const entry of await readdir7(directory2, { withFileTypes: true })) {
2984
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
2985
+ const full = join7(directory2, entry.name);
2986
+ if (entry.isDirectory()) await walk2(full, files);
2987
+ else if (/\.(tsx|ts|jsx|js)$/.test(entry.name) && !/\.preview\.(tsx|jsx)$/.test(entry.name)) files.push(full);
2988
+ }
2989
+ return files;
2990
+ };
2991
+ var checkDeterminism = async (config) => {
2992
+ const root = resolve24(config.root, config.videosDir);
2993
+ if (!existsSync19(root)) return [];
2994
+ const files = await walk2(root);
2995
+ const findings = await Promise.all(
2996
+ files.map(async (file) => scanSource(await readFile14(file, "utf8"), relative8(config.root, file)))
2997
+ );
2998
+ return findings.flat();
2999
+ };
3000
+
3001
+ // src/commands/test.ts
3002
+ var CANVAS_SCRIPT = `(() => {
3003
+ var root = document.querySelector("[data-odori-video]");
3004
+ if (!root) return [];
3005
+
3006
+ // Only canvases that are actually on screen. The runtime mounts every scene
3007
+ // a second time, hidden, to collect audio cues, and a hidden copy is not
3008
+ // what the export captures.
3009
+ var canvases = Array.prototype.slice.call(root.querySelectorAll("canvas")).filter(function (canvas) {
3010
+ var box = canvas.getBoundingClientRect();
3011
+ if (box.width === 0 || box.height === 0) return false;
3012
+ var style = getComputedStyle(canvas);
3013
+ return style.visibility !== "hidden" && Number(style.opacity) > 0.02;
3014
+ });
3015
+
3016
+ return canvases.map(function (canvas, index) {
3017
+ var context = canvas.getContext("2d");
3018
+ if (!context || canvas.width === 0 || canvas.height === 0) {
3019
+ return {index: index, hash: "no-context", blank: true};
3020
+ }
3021
+ var data;
3022
+ try {
3023
+ data = context.getImageData(0, 0, canvas.width, canvas.height).data;
3024
+ } catch (error) {
3025
+ return {index: index, hash: "tainted", blank: false};
3026
+ }
3027
+
3028
+ // A prime stride over the whole buffer rather than a coarse grid: a grid
3029
+ // steps over thin content and calls a drawn canvas blank, which is a
3030
+ // failure report about the checker rather than about the video.
3031
+ var pixels = canvas.width * canvas.height;
3032
+ var stride = 97;
3033
+ var hash = 5381;
3034
+ var opaque = 0;
3035
+ for (var pixel = 0; pixel < pixels; pixel += stride) {
3036
+ var offset = pixel * 4;
3037
+ if (data[offset + 3] > 8) opaque += 1;
3038
+ hash =
3039
+ ((hash << 5) + hash + data[offset] + data[offset + 1] * 3 + data[offset + 2] * 7 + data[offset + 3] * 11) | 0;
3040
+ }
3041
+ return {index: index, hash: String(hash), blank: opaque === 0};
3042
+ });
3043
+ })()`;
3044
+ var FRAME_SCRIPT = `(() => {
3045
+ var root = document.querySelector("[data-odori-video]");
3046
+ if (!root) return {overflow: [], small: [], empty: true, painted: 0};
3047
+
3048
+ var bounds = root.getBoundingClientRect();
3049
+ var overflow = [];
3050
+ var small = [];
3051
+ var painted = 0;
3052
+ var nodes = Array.prototype.slice.call(root.querySelectorAll("*"));
3053
+
3054
+ for (var index = 0; index < nodes.length; index += 1) {
3055
+ var node = nodes[index];
3056
+ var box = node.getBoundingClientRect();
3057
+ if (box.width === 0 || box.height === 0) continue;
3058
+ var style = getComputedStyle(node);
3059
+ if (style.visibility === "hidden" || Number(style.opacity) < 0.02) continue;
3060
+ painted += 1;
3061
+
3062
+ var media = ["IMG", "SVG", "CANVAS", "VIDEO"].indexOf(node.tagName) >= 0;
3063
+ var text = "";
3064
+ for (var child = 0; child < node.childNodes.length; child += 1) {
3065
+ var childNode = node.childNodes[child];
3066
+ if (childNode.nodeType === 3) text += childNode.textContent || "";
3067
+ }
3068
+ text = text.trim();
3069
+ if (!media && text.length === 0) continue;
3070
+
3071
+ var label = text.length > 0 ? '"' + text.slice(0, 32) + '"' : "<" + node.tagName.toLowerCase() + ">";
3072
+ if (
3073
+ box.right > bounds.right + 1 ||
3074
+ box.left < bounds.left - 1 ||
3075
+ box.bottom > bounds.bottom + 1 ||
3076
+ box.top < bounds.top - 1
3077
+ ) {
3078
+ if (overflow.indexOf(label) < 0) overflow.push(label);
3079
+ }
3080
+
3081
+ // Normalize against the shorter side, the same reference useDesignScale
3082
+ // uses, so a vertical cut is not judged as if it were letterboxed.
3083
+ var reference = Math.min(bounds.width, bounds.height);
3084
+ var relative = (parseFloat(style.fontSize) / reference) * 1080;
3085
+ if (text.length > 0 && relative > 0 && relative < 20) {
3086
+ var note = label + " at " + Math.round(relative) + "px";
3087
+ if (small.indexOf(note) < 0) small.push(note);
3088
+ }
3089
+ }
3090
+
3091
+ return {overflow: overflow.slice(0, 5), small: small.slice(0, 5), empty: false, painted: painted};
3092
+ })()`;
3093
+ var testVideo = async (origin, video, config, failures, quiet = false) => {
3094
+ const id = video.entry.metadata.id;
3095
+ const layout = resolveEntryLayout8(video.entry);
3096
+ if (isOdoriSchema2(video.entry.metadata.schema)) {
3097
+ const result = video.entry.metadata.schema.safeParse(video.entry.metadata.defaultProps ?? {});
3098
+ if (!result.success) failures.push({ video: id, message: `defaultProps fail the schema: ${result.issues.join("; ")}` });
3099
+ }
3100
+ const { browser, page } = await openRenderPage(origin, targetFor(video), config);
3101
+ try {
3102
+ const timeline = await readTimeline(page);
3103
+ failures.push(...checkAudioWindows((await readAudio(page)).cues, layout.brand, id));
3104
+ const total = video.durationInFrames || timeline.durationInFrames;
3105
+ if (!total) {
3106
+ failures.push({ video: id, message: "No duration could be resolved." });
3107
+ return;
3108
+ }
3109
+ if (video.durationInFrames && timeline.durationInFrames && video.durationInFrames !== timeline.durationInFrames) {
3110
+ failures.push({
3111
+ video: id,
3112
+ message: `metadata.duration is ${video.durationInFrames} frames but scenes total ${timeline.durationInFrames}.`
3113
+ });
3114
+ }
3115
+ const samples = [0, Math.floor(total / 4), Math.floor(total / 2), Math.floor(total * 3 / 4), total - 1];
3116
+ for (const frame of [...new Set(samples)]) {
3117
+ await seekTo(page, frame);
3118
+ const result = await page.evaluate(FRAME_SCRIPT);
3119
+ if (result.empty) failures.push({ video: id, message: `Frame ${frame} rendered no video root.` });
3120
+ if (!result.empty && result.painted < 2) {
3121
+ failures.push({ video: id, message: `Frame ${frame} is blank.` });
3122
+ }
3123
+ for (const item of result.overflow) {
3124
+ failures.push({
3125
+ video: id,
3126
+ message: `Frame ${frame}: ${item} escapes the ${layout.format.width}x${layout.format.height} canvas.`
3127
+ });
3128
+ }
3129
+ for (const item of result.small) {
3130
+ failures.push({ video: id, message: `Frame ${frame}: ${item} is too small to read at 1080p.` });
3131
+ }
3132
+ const canvases = await page.evaluate(CANVAS_SCRIPT);
3133
+ if (canvases.length > 0) {
3134
+ await seekTo(page, frame === 0 ? Math.min(total - 1, frame + 1) : frame - 1);
3135
+ await seekTo(page, frame);
3136
+ const again = await page.evaluate(CANVAS_SCRIPT);
3137
+ for (const canvas of canvases) {
3138
+ const second = again.find((item) => item.index === canvas.index);
3139
+ if (canvas.blank) {
3140
+ failures.push({ video: id, message: `Frame ${frame}: canvas ${canvas.index} drew nothing.` });
3141
+ continue;
3142
+ }
3143
+ if (canvas.hash === "tainted") {
3144
+ failures.push({
3145
+ video: id,
3146
+ message: `Frame ${frame}: canvas ${canvas.index} is tainted by a cross-origin draw, so the export cannot read it. Serve the image from public/ or inline it as a data URL.`
3147
+ });
3148
+ continue;
3149
+ }
3150
+ if (second && second.hash !== canvas.hash) {
3151
+ failures.push({
3152
+ video: id,
3153
+ message: `Frame ${frame}: canvas ${canvas.index} drew differently the second time. A canvas must be a function of the frame: draw from useFrame() through useCanvas(), not from requestAnimationFrame or a clock.`
3154
+ });
3155
+ }
3156
+ }
3157
+ }
3158
+ }
3159
+ if (!quiet) log.success(`${id}: ${[...new Set(samples)].length} frames sampled across ${total} frames`);
3160
+ } finally {
3161
+ await browser.close();
3162
+ }
3163
+ };
3164
+ var testCommand = async (id, options = {}) => {
3165
+ const { config, videos } = await createContext();
3166
+ const selected = id ? videos.filter((video) => video.entry.metadata.id === id) : videos;
3167
+ if (selected.length === 0) throw new Error(id ? `Unknown video "${id}".` : "No videos discovered.");
3168
+ const failures = [];
3169
+ failures.push(...await checkInstalledContracts(config, selected));
3170
+ for (const finding of await checkDeterminism(config)) {
3171
+ failures.push({
3172
+ video: `${finding.file}:${finding.line}`,
3173
+ message: `${finding.message}
3174
+ ${finding.source}`
3175
+ });
3176
+ }
3177
+ await withServer(config, async (server) => {
3178
+ for (const video of selected) await testVideo(server.url, video, config, failures, options.json === true);
3179
+ });
3180
+ if (options.json === true) {
3181
+ process.stdout.write(
3182
+ `${JSON.stringify(
3183
+ {
3184
+ ok: failures.length === 0,
3185
+ videos: selected.map((video) => video.entry.metadata.id),
3186
+ failures
3187
+ },
3188
+ null,
3189
+ 2
3190
+ )}
3191
+ `
3192
+ );
3193
+ if (failures.length > 0) throw new Error(`${failures.length} check${failures.length === 1 ? "" : "s"} failed.`);
3194
+ return;
3195
+ }
3196
+ if (failures.length > 0) {
3197
+ for (const failure of failures) log.error(`${failure.video}: ${failure.message}`);
3198
+ throw new Error(`${failures.length} check${failures.length === 1 ? "" : "s"} failed.`);
3199
+ }
3200
+ log.success(`All checks passed for ${selected.length} video${selected.length === 1 ? "" : "s"}.`);
3201
+ };
3202
+
3203
+ // src/cli.ts
3204
+ var parseArgs = (argv) => {
3205
+ const [command2 = "help", ...rest] = argv;
3206
+ const positionals = [];
3207
+ const flags = {};
3208
+ for (let index = 0; index < rest.length; index += 1) {
3209
+ const token = rest[index];
3210
+ if (token.startsWith("--")) {
3211
+ const equals = token.indexOf("=");
3212
+ if (equals > 2) {
3213
+ flags[token.slice(2, equals)] = token.slice(equals + 1);
3214
+ continue;
3215
+ }
3216
+ const name = token.slice(2);
3217
+ const next = rest[index + 1];
3218
+ if (next === void 0 || next.startsWith("--")) flags[name] = true;
3219
+ else {
3220
+ flags[name] = next;
3221
+ index += 1;
3222
+ }
3223
+ } else {
3224
+ positionals.push(token);
3225
+ }
3226
+ }
3227
+ return { command: command2, positionals, flags };
3228
+ };
3229
+ var numberFlag = (flags, name) => {
3230
+ const value = flags[name];
3231
+ if (value === void 0) return void 0;
3232
+ const parsed = typeof value === "string" ? Number(value) : Number.NaN;
3233
+ if (!Number.isFinite(parsed)) throw new Error(`--${name} needs a number, got ${String(value)}`);
3234
+ return parsed;
3235
+ };
3236
+ var parseInput = (flags) => {
3237
+ if (typeof flags.input !== "string") return void 0;
3238
+ try {
3239
+ return JSON.parse(flags.input);
3240
+ } catch (error) {
3241
+ const preview = flags.input.length > 40 ? `${flags.input.slice(0, 40)}\u2026` : flags.input;
3242
+ throw new Error(
3243
+ `--input is not valid JSON: ${error.message}. Received: ${preview}
3244
+ A shell eats double quotes, so wrap the whole value in single quotes: --input '{"headline":"Ship it"}'`
3245
+ );
3246
+ }
3247
+ };
3248
+ var COMMAND_FLAGS = {
3249
+ dev: ["port", "open", "no-open"],
3250
+ init: [],
3251
+ doctor: [],
3252
+ install: [],
3253
+ new: ["blank"],
3254
+ add: ["force", "dry-run"],
3255
+ registry: [],
3256
+ diff: ["full"],
3257
+ update: ["force"],
3258
+ list: [],
3259
+ inspect: ["json", "input"],
3260
+ still: ["frame", "output", "input"],
3261
+ test: ["json"],
3262
+ export: ["output", "input", "concurrency", "preset", "format", "no-frame-skip", "retry"],
3263
+ jobs: [],
3264
+ help: []
3265
+ };
3266
+ var distance = (left, right) => {
3267
+ const rows = Array.from({ length: left.length + 1 }, (_, index) => [index, ...Array(right.length).fill(0)]);
3268
+ for (let column = 0; column <= right.length; column += 1) rows[0][column] = column;
3269
+ for (let row = 1; row <= left.length; row += 1) {
3270
+ for (let column = 1; column <= right.length; column += 1) {
3271
+ const cost = left[row - 1] === right[column - 1] ? 0 : 1;
3272
+ rows[row][column] = Math.min(
3273
+ rows[row - 1][column] + 1,
3274
+ rows[row][column - 1] + 1,
3275
+ rows[row - 1][column - 1] + cost
3276
+ );
3277
+ }
3278
+ }
3279
+ return rows[left.length][right.length];
3280
+ };
3281
+ var nearest = (value, candidates) => {
3282
+ const ranked = candidates.map((candidate) => ({ candidate, score: distance(value, candidate) })).sort((left, right) => left.score - right.score)[0];
3283
+ return ranked && ranked.score <= 2 ? ranked.candidate : void 0;
3284
+ };
3285
+ var checkFlags = (command2, flags) => {
3286
+ const allowed = COMMAND_FLAGS[command2];
3287
+ if (!allowed) return;
3288
+ for (const name of Object.keys(flags)) {
3289
+ if (allowed.includes(name) || name === "help") continue;
3290
+ const suggestion = nearest(name, allowed);
3291
+ throw new Error(
3292
+ `Unknown flag --${name} for "odori ${command2}".` + (suggestion ? ` Did you mean --${suggestion}?` : allowed.length > 0 ? ` It accepts: ${allowed.map((item) => `--${item}`).join(", ")}` : " It takes no flags.")
3293
+ );
3294
+ }
3295
+ };
3296
+ var USAGE = {
3297
+ dev: `odori dev [--port <n>] [--no-open]
3298
+ Discover project resources and start Studio.`,
3299
+ init: `odori init
3300
+ Add videos/ and odori.config.ts to a project.`,
3301
+ doctor: `odori doctor
3302
+ Check Node, React, the source root, Chrome, FFmpeg, and the generated cache.`,
3303
+ install: `odori install
3304
+ Download the pinned Chrome and FFmpeg into the shared cache, so a render
3305
+ never waits and every machine encodes with the same build. Run it in a
3306
+ Dockerfile layer or a CI setup step.`,
3307
+ new: `odori new <name> [--blank]
3308
+ Generate a video.tsx entry. --blank writes plain markup instead of composing
3309
+ the components the project has installed.`,
3310
+ add: `odori add <components...> [--force] [--dry-run]
3311
+ Install editable component source, fetched from the registry and cached.
3312
+ --force replaces local edits. --dry-run lists the files and writes nothing.`,
3313
+ registry: `odori registry
3314
+ List available registry components and cues.`,
3315
+ diff: `odori diff [components] [--full]
3316
+ Compare installed components with upstream.`,
3317
+ update: `odori update [components] [--force]
3318
+ Apply upstream component changes.`,
3319
+ list: `odori list
3320
+ Print discovered video ids and formats.`,
3321
+ inspect: `odori inspect <id> [--json] [--input <json>]
3322
+ Show resolved layout, inputs, scenes, and assets.`,
3323
+ still: `odori still <id> --frame <n> [--output <path>] [--input <json>]
3324
+ Render one deterministic frame.`,
3325
+ test: `odori test [id] [--json]
3326
+ Validate contracts and representative frames. --json emits one object per
3327
+ check, for CI.`,
3328
+ export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
3329
+ [--preset <name>] [--format <name>] [--no-frame-skip] [--retry <job>]
3330
+ 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.`,
3332
+ jobs: `odori jobs
3333
+ List export jobs and their status.`
3334
+ };
3335
+ var HELP = `odori - build videos like applications
3336
+
3337
+ Usage
3338
+ odori dev [--port 4300] Discover project resources and start Studio
3339
+ odori init Add videos/ and odori.config.ts to a project
3340
+ odori doctor Check everything a render and an encode need
3341
+ odori install Download the pinned Chrome and FFmpeg
3342
+ odori new <name> Generate a video.tsx entry
3343
+ odori add <components...> Install editable component source
3344
+ odori registry List available registry components
3345
+ odori diff [components] Compare installed components with upstream
3346
+ odori update [components] Apply upstream component changes
3347
+ odori list Print discovered video ids and formats
3348
+ odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
3349
+ odori still <id> --frame 120 Render one deterministic frame
3350
+ odori test [id] [--json] Validate contracts and representative frames
3351
+ odori export <id> [--output f] Render and encode a distributable file
3352
+ odori jobs List export jobs and their status
3353
+
3354
+ Options
3355
+ --input '{"headline":"..."}' Serializable input for the video schema
3356
+ --output <path> Output path for still and export
3357
+ --force Replace locally modified component source
3358
+ --concurrency <n> Parallel render workers for export
3359
+ --preset <name> x264 preset for export, default medium
3360
+ --format <name> mp4, webm, prores, gif, or png
3361
+ --no-frame-skip Capture every frame, even unchanged ones
3362
+ --retry <job id> Re-run a recorded job from its frozen manifest
3363
+ --no-open Start dev without opening Studio in a browser
3364
+ --json Machine readable output, for inspect and test
3365
+
3366
+ Run "odori <command> --help" for one command, or "odori doctor" to check setup.
3367
+ `;
3368
+ var cliVersion = () => {
3369
+ try {
3370
+ const require2 = createRequire4(import.meta.url);
3371
+ return require2("../package.json").version;
3372
+ } catch {
3373
+ return "unknown";
3374
+ }
3375
+ };
3376
+ var run2 = async (argv) => {
3377
+ const { command: command2, positionals, flags } = parseArgs(argv);
3378
+ try {
3379
+ if (command2 === "--version" || command2 === "-v" || command2 === "version") {
3380
+ log.info(`odori ${cliVersion()} (node ${process.version})`);
3381
+ return 0;
3382
+ }
3383
+ if (flags.help === true && USAGE[command2]) {
3384
+ log.info(USAGE[command2]);
3385
+ return 0;
3386
+ }
3387
+ checkFlags(command2, flags);
3388
+ switch (command2) {
3389
+ case "dev": {
3390
+ const server = await devCommand({
3391
+ port: numberFlag(flags, "port"),
3392
+ open: flags["no-open"] === true ? false : flags.open === true ? true : void 0
3393
+ });
3394
+ await new Promise((resolveDev) => {
3395
+ const stop = () => {
3396
+ void server.close().then(() => resolveDev());
3397
+ };
3398
+ process.on("SIGINT", stop);
3399
+ process.on("SIGTERM", stop);
3400
+ });
3401
+ return 0;
3402
+ }
3403
+ case "init":
3404
+ await initCommand();
3405
+ return 0;
3406
+ case "doctor":
3407
+ return await doctorCommand();
3408
+ case "install":
3409
+ return await installCommand();
3410
+ case "new":
3411
+ await newCommand(positionals[0] ?? "", { blank: flags.blank === true });
3412
+ return 0;
3413
+ case "add":
3414
+ await addCommand(positionals, { force: flags.force === true, dryRun: flags["dry-run"] === true });
3415
+ return 0;
3416
+ case "registry":
3417
+ await registryCommand();
3418
+ return 0;
3419
+ case "diff":
3420
+ await diffCommand(positionals, { full: flags.full === true });
3421
+ return 0;
3422
+ case "update":
3423
+ await updateCommand(positionals, { force: flags.force === true });
3424
+ return 0;
3425
+ case "list":
3426
+ await listCommand();
3427
+ return 0;
3428
+ case "inspect":
3429
+ await inspectCommand(positionals[0] ?? "", { json: flags.json === true, input: parseInput(flags) });
3430
+ return 0;
3431
+ case "still":
3432
+ await stillCommand(positionals[0] ?? "", {
3433
+ frame: numberFlag(flags, "frame") ?? 0,
3434
+ output: typeof flags.output === "string" ? flags.output : void 0,
3435
+ input: parseInput(flags)
3436
+ });
3437
+ return 0;
3438
+ case "test":
3439
+ await testCommand(positionals[0], { json: flags.json === true });
3440
+ return 0;
3441
+ case "export":
3442
+ await exportCommand(positionals[0] ?? "", {
3443
+ output: typeof flags.output === "string" ? flags.output : void 0,
3444
+ input: parseInput(flags),
3445
+ concurrency: numberFlag(flags, "concurrency"),
3446
+ preset: typeof flags.preset === "string" ? flags.preset : void 0,
3447
+ format: typeof flags.format === "string" ? flags.format : void 0,
3448
+ skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
3449
+ retry: typeof flags.retry === "string" ? flags.retry : void 0
3450
+ });
3451
+ return 0;
3452
+ case "jobs":
3453
+ await jobsCommand();
3454
+ return 0;
3455
+ case "help":
3456
+ case "--help":
3457
+ case "-h":
3458
+ log.info(HELP);
3459
+ return 0;
3460
+ default: {
3461
+ const commands = Object.keys(COMMAND_FLAGS).filter((name) => name !== "help");
3462
+ const suggestion = nearest(command2, commands);
3463
+ log.error(`Unknown command "${command2}".${suggestion ? ` Did you mean "odori ${suggestion}"?` : ""}`);
3464
+ log.info(HELP);
3465
+ return 1;
3466
+ }
3467
+ }
3468
+ } catch (error) {
3469
+ log.error(error instanceof Error ? error.message : String(error));
3470
+ return 1;
3471
+ }
3472
+ };
3473
+
3474
+ export {
3475
+ resolveChromePath,
3476
+ loadConfig,
3477
+ defineConfig,
3478
+ discoverProject,
3479
+ generateImports,
3480
+ writeGenerated,
3481
+ localCandidates,
3482
+ createIntegrityResolver,
3483
+ outputName,
3484
+ fileKey,
3485
+ prepareCacheKey,
3486
+ readPrepareCache,
3487
+ writePrepareCache,
3488
+ clearPrepareCache,
3489
+ CHROME_BUILD,
3490
+ cacheRoot,
3491
+ resolveBrowser,
3492
+ resolveFfmpeg,
3493
+ installBrowser,
3494
+ installFfmpeg,
3495
+ renderToolchain,
3496
+ loadVideos,
3497
+ findVideo,
3498
+ runPrepare,
3499
+ freezeManifest,
3500
+ startStudioServer,
3501
+ resolveCueFile,
3502
+ buildAudioFilter,
3503
+ planChunks,
3504
+ chunkFrames,
3505
+ FORMATS,
3506
+ formatNames,
3507
+ resolveFormat,
3508
+ alphaWarning,
3509
+ openRenderPage,
3510
+ seekTo,
3511
+ readTimeline,
3512
+ readAudio,
3513
+ probeSignatures,
3514
+ ensureFfmpeg,
3515
+ renderStill,
3516
+ defaultConcurrency,
3517
+ renderMovie,
3518
+ createContext,
3519
+ targetFor,
3520
+ compileInBrowser,
3521
+ withServer,
3522
+ createJob,
3523
+ readJob,
3524
+ updateJob,
3525
+ appendJobLog,
3526
+ reconcileJobs,
3527
+ listJobs,
3528
+ JobQueue,
3529
+ checkDeterminism,
3530
+ exportQueue,
3531
+ cancelJob,
3532
+ runJob,
3533
+ exportCommand,
3534
+ jobsCommand,
3535
+ devCommand,
3536
+ stillCommand,
3537
+ diffLines,
3538
+ countChanges,
3539
+ formatDiff,
3540
+ componentStatus,
3541
+ diffCommand,
3542
+ updateCommand,
3543
+ testCommand,
3544
+ listCommand,
3545
+ inspectCommand,
3546
+ addCommand,
3547
+ newCommand,
3548
+ initCommand,
3549
+ parseArgs,
3550
+ checkFlags,
3551
+ run2 as run
3552
+ };