@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
package/src/cli.ts ADDED
@@ -0,0 +1,319 @@
1
+ import {createRequire} from "node:module";
2
+ import {log} from "./log";
3
+ import {addCommand, registryCommand} from "./commands/add";
4
+ import {diffCommand, updateCommand} from "./commands/update";
5
+ import {devCommand} from "./commands/dev";
6
+ import {doctorCommand} from "./commands/doctor";
7
+ import {installCommand} from "./binaries";
8
+ import {exportCommand, jobsCommand} from "./commands/exportVideo";
9
+ import {initCommand} from "./commands/init";
10
+ import {inspectCommand} from "./commands/inspect";
11
+ import {listCommand} from "./commands/list";
12
+ import {newCommand} from "./commands/new";
13
+ import {stillCommand} from "./commands/still";
14
+ import {testCommand} from "./commands/test";
15
+
16
+ type Flags = Record<string, string | boolean>;
17
+
18
+ export const parseArgs = (argv: string[]): {command: string; positionals: string[]; flags: Flags} => {
19
+ const [command = "help", ...rest] = argv;
20
+ const positionals: string[] = [];
21
+ const flags: Flags = {};
22
+ for (let index = 0; index < rest.length; index += 1) {
23
+ const token = rest[index];
24
+ if (token.startsWith("--")) {
25
+ // `--name=value` and `--name value` are the same flag written two ways,
26
+ // and a reader who has used any other CLI expects both to work.
27
+ const equals = token.indexOf("=");
28
+ if (equals > 2) {
29
+ flags[token.slice(2, equals)] = token.slice(equals + 1);
30
+ continue;
31
+ }
32
+ const name = token.slice(2);
33
+ const next = rest[index + 1];
34
+ if (next === undefined || next.startsWith("--")) flags[name] = true;
35
+ else {
36
+ flags[name] = next;
37
+ index += 1;
38
+ }
39
+ } else {
40
+ positionals.push(token);
41
+ }
42
+ }
43
+ return {command, positionals, flags};
44
+ };
45
+
46
+ /** A flag that means a count or an index has to actually be one. */
47
+ const numberFlag = (flags: Flags, name: string): number | undefined => {
48
+ const value = flags[name];
49
+ if (value === undefined) return undefined;
50
+ const parsed = typeof value === "string" ? Number(value) : Number.NaN;
51
+ if (!Number.isFinite(parsed)) throw new Error(`--${name} needs a number, got ${String(value)}`);
52
+ return parsed;
53
+ };
54
+
55
+ const parseInput = (flags: Flags): Record<string, unknown> | undefined => {
56
+ if (typeof flags.input !== "string") return undefined;
57
+ try {
58
+ return JSON.parse(flags.input) as Record<string, unknown>;
59
+ } catch (error) {
60
+ // A raw parser message names a position in a string the reader cannot see,
61
+ // and never mentions which flag it came from.
62
+ const preview = flags.input.length > 40 ? `${flags.input.slice(0, 40)}…` : flags.input;
63
+ throw new Error(
64
+ `--input is not valid JSON: ${(error as Error).message}. Received: ${preview}\n` +
65
+ `A shell eats double quotes, so wrap the whole value in single quotes: --input '{"headline":"Ship it"}'`,
66
+ );
67
+ }
68
+ };
69
+
70
+ /**
71
+ * What each command accepts. An unknown flag is a typo or a wrong assumption,
72
+ * and either way ignoring it silently means the command runs as though the
73
+ * flag were never written, which is the most expensive way to find out.
74
+ */
75
+ const COMMAND_FLAGS: Record<string, string[]> = {
76
+ dev: ["port", "open", "no-open"],
77
+ init: [],
78
+ doctor: [],
79
+ install: [],
80
+ new: ["blank"],
81
+ add: ["force", "dry-run"],
82
+ registry: [],
83
+ diff: ["full"],
84
+ update: ["force"],
85
+ list: [],
86
+ inspect: ["json", "input"],
87
+ still: ["frame", "output", "input"],
88
+ test: ["json"],
89
+ export: ["output", "input", "concurrency", "preset", "format", "no-frame-skip", "retry"],
90
+ jobs: [],
91
+ help: [],
92
+ };
93
+
94
+ /** Levenshtein distance, for "did you mean". Short strings, so cost is nothing. */
95
+ const distance = (left: string, right: string): number => {
96
+ const rows = Array.from({length: left.length + 1}, (_, index) => [index, ...(Array(right.length).fill(0) as number[])]);
97
+ for (let column = 0; column <= right.length; column += 1) rows[0][column] = column;
98
+ for (let row = 1; row <= left.length; row += 1) {
99
+ for (let column = 1; column <= right.length; column += 1) {
100
+ const cost = left[row - 1] === right[column - 1] ? 0 : 1;
101
+ rows[row][column] = Math.min(
102
+ rows[row - 1][column] + 1,
103
+ rows[row][column - 1] + 1,
104
+ rows[row - 1][column - 1] + cost,
105
+ );
106
+ }
107
+ }
108
+ return rows[left.length][right.length];
109
+ };
110
+
111
+ const nearest = (value: string, candidates: string[]): string | undefined => {
112
+ const ranked = candidates
113
+ .map((candidate) => ({candidate, score: distance(value, candidate)}))
114
+ .sort((left, right) => left.score - right.score)[0];
115
+ // Two edits is a typo. Further than that is a different word.
116
+ return ranked && ranked.score <= 2 ? ranked.candidate : undefined;
117
+ };
118
+
119
+ export const checkFlags = (command: string, flags: Flags): void => {
120
+ const allowed = COMMAND_FLAGS[command];
121
+ if (!allowed) return;
122
+ for (const name of Object.keys(flags)) {
123
+ if (allowed.includes(name) || name === "help") continue;
124
+ const suggestion = nearest(name, allowed);
125
+ throw new Error(
126
+ `Unknown flag --${name} for "odori ${command}".` +
127
+ (suggestion
128
+ ? ` Did you mean --${suggestion}?`
129
+ : allowed.length > 0
130
+ ? ` It accepts: ${allowed.map((item) => `--${item}`).join(", ")}`
131
+ : " It takes no flags."),
132
+ );
133
+ }
134
+ };
135
+
136
+ const USAGE: Record<string, string> = {
137
+ dev: `odori dev [--port <n>] [--no-open]
138
+ Discover project resources and start Studio.`,
139
+ init: `odori init
140
+ Add videos/ and odori.config.ts to a project.`,
141
+ doctor: `odori doctor
142
+ Check Node, React, the source root, Chrome, FFmpeg, and the generated cache.`,
143
+ install: `odori install
144
+ Download the pinned Chrome and FFmpeg into the shared cache, so a render
145
+ never waits and every machine encodes with the same build. Run it in a
146
+ Dockerfile layer or a CI setup step.`,
147
+ new: `odori new <name> [--blank]
148
+ Generate a video.tsx entry. --blank writes plain markup instead of composing
149
+ the components the project has installed.`,
150
+ add: `odori add <components...> [--force] [--dry-run]
151
+ Install editable component source, fetched from the registry and cached.
152
+ --force replaces local edits. --dry-run lists the files and writes nothing.`,
153
+ registry: `odori registry
154
+ List available registry components and cues.`,
155
+ diff: `odori diff [components] [--full]
156
+ Compare installed components with upstream.`,
157
+ update: `odori update [components] [--force]
158
+ Apply upstream component changes.`,
159
+ list: `odori list
160
+ Print discovered video ids and formats.`,
161
+ inspect: `odori inspect <id> [--json] [--input <json>]
162
+ Show resolved layout, inputs, scenes, and assets.`,
163
+ still: `odori still <id> --frame <n> [--output <path>] [--input <json>]
164
+ Render one deterministic frame.`,
165
+ test: `odori test [id] [--json]
166
+ Validate contracts and representative frames. --json emits one object per
167
+ check, for CI.`,
168
+ export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
169
+ [--preset <name>] [--format <name>] [--no-frame-skip] [--retry <job>]
170
+ Render and encode a distributable file. --format is mp4, webm, prores, gif,
171
+ or png; without it the output's extension decides, and mp4 is the default.`,
172
+ jobs: `odori jobs
173
+ List export jobs and their status.`,
174
+ };
175
+
176
+ const HELP = `odori - build videos like applications
177
+
178
+ Usage
179
+ odori dev [--port 4300] Discover project resources and start Studio
180
+ odori init Add videos/ and odori.config.ts to a project
181
+ odori doctor Check everything a render and an encode need
182
+ odori install Download the pinned Chrome and FFmpeg
183
+ odori new <name> Generate a video.tsx entry
184
+ odori add <components...> Install editable component source
185
+ odori registry List available registry components
186
+ odori diff [components] Compare installed components with upstream
187
+ odori update [components] Apply upstream component changes
188
+ odori list Print discovered video ids and formats
189
+ odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
190
+ odori still <id> --frame 120 Render one deterministic frame
191
+ odori test [id] [--json] Validate contracts and representative frames
192
+ odori export <id> [--output f] Render and encode a distributable file
193
+ odori jobs List export jobs and their status
194
+
195
+ Options
196
+ --input '{"headline":"..."}' Serializable input for the video schema
197
+ --output <path> Output path for still and export
198
+ --force Replace locally modified component source
199
+ --concurrency <n> Parallel render workers for export
200
+ --preset <name> x264 preset for export, default medium
201
+ --format <name> mp4, webm, prores, gif, or png
202
+ --no-frame-skip Capture every frame, even unchanged ones
203
+ --retry <job id> Re-run a recorded job from its frozen manifest
204
+ --no-open Start dev without opening Studio in a browser
205
+ --json Machine readable output, for inspect and test
206
+
207
+ Run "odori <command> --help" for one command, or "odori doctor" to check setup.
208
+ `;
209
+
210
+ /** The published version, so a bug report can say which one. */
211
+ const cliVersion = (): string => {
212
+ try {
213
+ const require = createRequire(import.meta.url);
214
+ return (require("../package.json") as {version: string}).version;
215
+ } catch {
216
+ return "unknown";
217
+ }
218
+ };
219
+
220
+ export const run = async (argv: string[]): Promise<number> => {
221
+ const {command, positionals, flags} = parseArgs(argv);
222
+ try {
223
+ if (command === "--version" || command === "-v" || command === "version") {
224
+ log.info(`odori ${cliVersion()} (node ${process.version})`);
225
+ return 0;
226
+ }
227
+ // Asking a command for help is asking about it, not running it with
228
+ // whatever the defaults happen to be.
229
+ if (flags.help === true && USAGE[command]) {
230
+ log.info(USAGE[command]);
231
+ return 0;
232
+ }
233
+ checkFlags(command, flags);
234
+
235
+ switch (command) {
236
+ case "dev": {
237
+ const server = await devCommand({
238
+ port: numberFlag(flags, "port"),
239
+ open: flags["no-open"] === true ? false : flags.open === true ? true : undefined,
240
+ });
241
+ await new Promise<void>((resolveDev) => {
242
+ const stop = () => {
243
+ void server.close().then(() => resolveDev());
244
+ };
245
+ process.on("SIGINT", stop);
246
+ process.on("SIGTERM", stop);
247
+ });
248
+ return 0;
249
+ }
250
+ case "init":
251
+ await initCommand();
252
+ return 0;
253
+ case "doctor":
254
+ return await doctorCommand();
255
+ case "install":
256
+ return await installCommand();
257
+ case "new":
258
+ await newCommand(positionals[0] ?? "", {blank: flags.blank === true});
259
+ return 0;
260
+ case "add":
261
+ await addCommand(positionals, {force: flags.force === true, dryRun: flags["dry-run"] === true});
262
+ return 0;
263
+ case "registry":
264
+ await registryCommand();
265
+ return 0;
266
+ case "diff":
267
+ await diffCommand(positionals, {full: flags.full === true});
268
+ return 0;
269
+ case "update":
270
+ await updateCommand(positionals, {force: flags.force === true});
271
+ return 0;
272
+ case "list":
273
+ await listCommand();
274
+ return 0;
275
+ case "inspect":
276
+ await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
277
+ return 0;
278
+ case "still":
279
+ await stillCommand(positionals[0] ?? "", {
280
+ frame: numberFlag(flags, "frame") ?? 0,
281
+ output: typeof flags.output === "string" ? flags.output : undefined,
282
+ input: parseInput(flags),
283
+ });
284
+ return 0;
285
+ case "test":
286
+ await testCommand(positionals[0], {json: flags.json === true});
287
+ return 0;
288
+ case "export":
289
+ await exportCommand(positionals[0] ?? "", {
290
+ output: typeof flags.output === "string" ? flags.output : undefined,
291
+ input: parseInput(flags),
292
+ concurrency: numberFlag(flags, "concurrency"),
293
+ preset: typeof flags.preset === "string" ? flags.preset : undefined,
294
+ format: typeof flags.format === "string" ? flags.format : undefined,
295
+ skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
296
+ retry: typeof flags.retry === "string" ? flags.retry : undefined,
297
+ });
298
+ return 0;
299
+ case "jobs":
300
+ await jobsCommand();
301
+ return 0;
302
+ case "help":
303
+ case "--help":
304
+ case "-h":
305
+ log.info(HELP);
306
+ return 0;
307
+ default: {
308
+ const commands = Object.keys(COMMAND_FLAGS).filter((name) => name !== "help");
309
+ const suggestion = nearest(command, commands);
310
+ log.error(`Unknown command "${command}".${suggestion ? ` Did you mean "odori ${suggestion}"?` : ""}`);
311
+ log.info(HELP);
312
+ return 1;
313
+ }
314
+ }
315
+ } catch (error) {
316
+ log.error(error instanceof Error ? error.message : String(error));
317
+ return 1;
318
+ }
319
+ };
@@ -0,0 +1,151 @@
1
+ import {mkdir, readFile, writeFile} from "node:fs/promises";
2
+ import {existsSync} from "node:fs";
3
+ import {relative, resolve} from "node:path";
4
+ import {hashString} from "odori";
5
+ import {loadConfig} from "../config";
6
+ import {log} from "../log";
7
+ import {registerCueInBrand} from "../brand-file";
8
+ import {normalizeComponentName, registryUrl, resolveItem, resolveRegistry, verifyIntegrity} from "../registry-source";
9
+ import {readProvenance, writeProvenance} from "./update";
10
+
11
+ /**
12
+ * Installation copies source into the project. Provenance is recorded so the
13
+ * CLI can report upstream changes, but a locally modified component is never
14
+ * replaced without an explicit decision.
15
+ */
16
+ export const addCommand = async (names: string[], options: {force?: boolean; dryRun?: boolean} = {}) => {
17
+ if (names.length === 0) throw new Error("Name at least one component, for example @odori/title-reveal.");
18
+ const config = await loadConfig(process.cwd());
19
+ const source = await resolveRegistry(config);
20
+ const registry = source.items;
21
+ const provenance = await readProvenance(config);
22
+
23
+ // Say where the source is coming from before writing any of it. A cached or
24
+ // bundled registry installs an older component than the site shows, and that
25
+ // is worth one line rather than a puzzled bug report.
26
+ if (source.origin === "network") log.detail(`registry: ${source.detail}`);
27
+ else if (source.origin === "cache") log.detail(`registry: cached copy of ${registryUrl(config)} (offline)`);
28
+ else log.warn(`registry: the copy built into this CLI. It may be older than ${registryUrl(config)}.`);
29
+
30
+ const queue = [...names.map(normalizeComponentName)];
31
+ const installed: string[] = [];
32
+
33
+ while (queue.length > 0) {
34
+ const name = queue.shift()!;
35
+ if (installed.includes(name)) continue;
36
+ const component = registry.find((item) => item.name === name);
37
+ if (!component) {
38
+ throw new Error(`Unknown component "${name}". Known: ${registry.map((item) => item.namespaced).join(", ")}`);
39
+ }
40
+ queue.push(...component.registryDependencies.map(normalizeComponentName));
41
+ // A contract that names a cue is a dependency like any other: install the
42
+ // component that provides it rather than leaving a silent gap.
43
+ for (const cue of component.contract.requires.audio) {
44
+ const provider = registry.find((item) => item.cue?.name === cue);
45
+ if (provider) queue.push(provider.name);
46
+ else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
47
+ }
48
+
49
+ // The item document carries the file contents; the index does not.
50
+ const {item} = await resolveItem(config, component.name);
51
+ // Before anything is written: the bytes have to be the bytes the registry
52
+ // said it was serving.
53
+ verifyIntegrity(item);
54
+
55
+ const target = resolve(config.root, config.componentsDir, component.name);
56
+ const hashes: Record<string, string> = {};
57
+
58
+ // What will be written, listed before it is. Installing source into
59
+ // somebody's repository should never be the first they hear of a path.
60
+ for (const file of item.files) {
61
+ const destination = resolve(config.root, file.target);
62
+ const exists = existsSync(destination);
63
+ log.detail(` ${exists ? "replace" : "create "} ${relative(config.root, destination)}`);
64
+ }
65
+ if (options.dryRun) {
66
+ installed.push(component.name);
67
+ continue;
68
+ }
69
+
70
+ await mkdir(target, {recursive: true});
71
+
72
+ for (const file of item.files) {
73
+ const name = file.path.split("/").pop() ?? file.path;
74
+ const destination = resolve(config.root, file.target);
75
+ hashes[name] = hashString(file.content);
76
+
77
+ if (existsSync(destination) && !options.force) {
78
+ const current = hashString(await readFile(destination, "utf8"));
79
+ const recorded = provenance[component.name]?.hashes[name];
80
+ if (current !== recorded) {
81
+ log.warn(`${relative(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
82
+ continue;
83
+ }
84
+ }
85
+ await mkdir(resolve(destination, ".."), {recursive: true});
86
+ await writeFile(destination, file.content, "utf8");
87
+ }
88
+
89
+ provenance[component.name] = {
90
+ source: component.namespaced,
91
+ version: "0.1.0",
92
+ installedAt: new Date().toISOString(),
93
+ hashes,
94
+ };
95
+ installed.push(component.name);
96
+ log.success(`${component.namespaced} to ${relative(config.root, target)}/`);
97
+ if (component.kind === "cue" && component.cue) {
98
+ log.detail(
99
+ ` ${component.family} · ${component.contract.recommendedDurationInFrames} frames · registers "${component.cue.name}"`,
100
+ );
101
+ // A cue that the brand does not know is silent, so wire it here rather
102
+ // than leaving an installed component that does nothing.
103
+ const registered = await registerCueInBrand(config, component.cue, component.name);
104
+ if (registered?.already) {
105
+ log.detail(` "${component.cue.name}" is already registered in ${registered.file}`);
106
+ } else if (registered) {
107
+ log.detail(` registered "${component.cue.name}" in ${registered.file}`);
108
+ } else {
109
+ log.warn(` No brand with an audio.cues block found. Add it yourself:`);
110
+ log.detail(` import {${component.cue.export}} from "./components/${component.name}/${component.name}";`);
111
+ log.detail(` audio: {cues: {"${component.cue.name}": ${component.cue.export}()}}`);
112
+ }
113
+ } else {
114
+ log.detail(
115
+ ` ${component.family} · recommended ${component.contract.recommendedDurationInFrames} frames · ${component.contract.aspectRatios.join(", ")}`,
116
+ );
117
+ }
118
+ }
119
+
120
+ if (options.dryRun) {
121
+ log.detail("Nothing was written. Drop --dry-run to install.");
122
+ return;
123
+ }
124
+ await writeProvenance(config, provenance);
125
+ log.detail("Run odori dev to preview the installed component fixtures.");
126
+ };
127
+
128
+ export const registryCommand = async () => {
129
+ const config = await loadConfig(process.cwd());
130
+ const source = await resolveRegistry(config);
131
+ const registry = source.items;
132
+ const families = [...new Set(registry.map((component) => component.family))];
133
+ for (const family of families) {
134
+ log.title(family);
135
+ for (const component of registry.filter((item) => item.family === family)) {
136
+ log.info(` ${component.namespaced.padEnd(26)} ${component.description}`);
137
+ log.detail(
138
+ component.kind === "cue" && component.cue
139
+ ? ` sound · ${component.contract.recommendedDurationInFrames} frames · registers "${component.cue.name}"`
140
+ : ` ${component.contract.aspectRatios.join(", ")} · min ${component.contract.minimumDurationInFrames} frames · reduced motion: ${component.contract.reducedMotion}`,
141
+ );
142
+ }
143
+ }
144
+ log.detail(
145
+ source.origin === "network"
146
+ ? `${registry.length} entries from ${source.detail}`
147
+ : source.origin === "cache"
148
+ ? `${registry.length} entries from the cache (offline). Latest is at ${registryUrl(config)}.`
149
+ : `${registry.length} entries from the copy built into this CLI. Latest is at ${registryUrl(config)}.`,
150
+ );
151
+ };
@@ -0,0 +1,160 @@
1
+ import {resolve} from "node:path";
2
+ import {readFile} from "node:fs/promises";
3
+ import type {IncomingMessage, ServerResponse} from "node:http";
4
+ import {loadConfig} from "../config";
5
+ import {log} from "../log";
6
+ import {createJob, listJobs, readJob} from "../jobs";
7
+ import {discoverProject} from "../discovery";
8
+ import {findVideo, freezeManifest, loadVideos, outputName} from "../project";
9
+ import {renderStill} from "../render";
10
+ import {openInBrowser, shouldOpenBrowser} from "../open";
11
+ import {startStudioServer} from "../server";
12
+ import {cancelJob, runJob} from "./exportVideo";
13
+ import {compileInBrowser, targetFor} from "./shared";
14
+
15
+ const readBody = async (request: IncomingMessage): Promise<Record<string, unknown>> => {
16
+ const chunks: Buffer[] = [];
17
+ for await (const chunk of request) chunks.push(chunk as Buffer);
18
+ if (chunks.length === 0) return {};
19
+ return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record<string, unknown>;
20
+ };
21
+
22
+ const json = (response: ServerResponse, status: number, payload: unknown) => {
23
+ response.statusCode = status;
24
+ response.setHeader("content-type", "application/json");
25
+ response.end(JSON.stringify(payload));
26
+ };
27
+
28
+ /**
29
+ * Studio previews without encoding. These endpoints exist so an explicit
30
+ * export action in the browser reaches the same queue and render worker the
31
+ * CLI uses.
32
+ */
33
+ export const devCommand = async (options: {port?: number; root?: string; open?: boolean} = {}) => {
34
+ const config = await loadConfig(options.root ?? process.cwd());
35
+ const context = async () => {
36
+ const graph = await discoverProject(config);
37
+ return {graph, videos: await loadVideos(graph)};
38
+ };
39
+
40
+ const server = await startStudioServer(config, {
41
+ port: options.port ?? config.port,
42
+ middleware: (vite) => {
43
+ vite.middlewares.use("/__odori", (request, response, next) => {
44
+ const url = request.url ?? "/";
45
+ void (async () => {
46
+ try {
47
+ if (request.method === "POST" && url.startsWith("/still")) {
48
+ const body = await readBody(request);
49
+ const {graph, videos} = await context();
50
+ const video = findVideo(videos, String(body.videoId));
51
+ const input = (body.input ?? {}) as Record<string, unknown>;
52
+ const compiled = await compileInBrowser(origin, targetFor(video, input), config);
53
+ const {manifest, prepared} = await freezeManifest(
54
+ {...video, durationInFrames: compiled.durationInFrames},
55
+ graph,
56
+ config,
57
+ input,
58
+ {scenes: compiled.scenes, audio: compiled.audio},
59
+ );
60
+ const frame = Number(body.frame ?? 0);
61
+ // A clipboard grab is transient, so it renders into the generated
62
+ // directory instead of littering the export directory.
63
+ const inline = body.inline === true;
64
+ const directory = inline ? config.outDir : config.exportDir;
65
+ const file = resolve(config.root, `${directory}/${outputName(video.entry.metadata.id)}-${frame}.png`);
66
+ await renderStill(
67
+ origin,
68
+ targetFor(
69
+ {...video, durationInFrames: manifest.format.durationInFrames},
70
+ manifest.input as Record<string, unknown>,
71
+ prepared,
72
+ manifest.audio,
73
+ ),
74
+ frame,
75
+ file,
76
+ config,
77
+ );
78
+ if (inline) {
79
+ response.statusCode = 200;
80
+ response.setHeader("content-type", "image/png");
81
+ response.end(await readFile(file));
82
+ return;
83
+ }
84
+ json(response, 200, {id: "still", status: "ready", progress: 1, output: file});
85
+ return;
86
+ }
87
+
88
+ if (request.method === "POST" && url.startsWith("/exports")) {
89
+ const body = await readBody(request);
90
+ const {graph, videos} = await context();
91
+ const video = findVideo(videos, String(body.videoId));
92
+ const input = (body.input ?? {}) as Record<string, unknown>;
93
+ const compiled = await compileInBrowser(origin, targetFor(video, input), config);
94
+ const {manifest} = await freezeManifest(
95
+ {...video, durationInFrames: compiled.durationInFrames},
96
+ graph,
97
+ config,
98
+ input,
99
+ {scenes: compiled.scenes, audio: compiled.audio},
100
+ );
101
+ const output = resolve(config.root, `${config.exportDir}/${outputName(video.entry.metadata.id)}.mp4`);
102
+ const record = await createJob(config, manifest, output);
103
+ json(response, 202, record.job);
104
+
105
+ void runJob(config, origin, record, video).catch((error) => {
106
+ log.error(`Export failed: ${error instanceof Error ? error.message : String(error)}`);
107
+ });
108
+ return;
109
+ }
110
+
111
+ if (request.method === "POST" && url.startsWith("/retry/")) {
112
+ const id = url.replace("/retry/", "").split("?")[0];
113
+ const record = await readJob(config, id);
114
+ const {videos} = await context();
115
+ const video = findVideo(videos, record.manifest.videoId);
116
+ json(response, 202, record.job);
117
+ void runJob(config, origin, record, video).catch((error) => {
118
+ log.error(`Retry failed: ${error instanceof Error ? error.message : String(error)}`);
119
+ });
120
+ return;
121
+ }
122
+
123
+ if (request.method === "POST" && url.startsWith("/cancel/")) {
124
+ const id = url.replace("/cancel/", "").split("?")[0];
125
+ const cancelled = cancelJob(id);
126
+ json(response, cancelled ? 202 : 404, {id, cancelled});
127
+ return;
128
+ }
129
+
130
+ if (request.method === "GET" && url.startsWith("/jobs/")) {
131
+ const id = url.replace("/jobs/", "").split("?")[0];
132
+ json(response, 200, (await readJob(config, id)).job);
133
+ return;
134
+ }
135
+
136
+ if (request.method === "GET" && url.startsWith("/jobs")) {
137
+ json(response, 200, await listJobs(config));
138
+ return;
139
+ }
140
+
141
+ next();
142
+ } catch (error) {
143
+ json(response, 500, {status: "failed", error: error instanceof Error ? error.message : String(error)});
144
+ }
145
+ })();
146
+ });
147
+ },
148
+ });
149
+
150
+ const origin = server.url;
151
+ // Views are routable, so name the one Studio starts on rather than leaving a
152
+ // bare origin that silently resolves to it.
153
+ const entry = `${origin}/videos`;
154
+ log.title("Odori Studio");
155
+ log.info(` ${entry}`);
156
+ log.detail(` ${server.graph.videos.length} videos, ${server.graph.previews.length} component previews`);
157
+ log.detail(` watching ${config.videosDir}/`);
158
+ if (shouldOpenBrowser(options.open ?? config.open)) openInBrowser(entry);
159
+ return server;
160
+ };