@odori/cli 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/bin/odori.mjs +39 -0
- package/dist/chunk-7XJL2BYO.js +3552 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +10 -0
- package/dist/index.d.ts +622 -0
- package/dist/index.js +156 -0
- package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
- package/package.json +50 -0
- package/src/audio-mix.ts +133 -0
- package/src/binaries.ts +241 -0
- package/src/brand-file.ts +94 -0
- package/src/chunk-cache.ts +85 -0
- package/src/chunks.ts +78 -0
- package/src/cli.ts +319 -0
- package/src/commands/add.ts +151 -0
- package/src/commands/dev.ts +160 -0
- package/src/commands/doctor.ts +162 -0
- package/src/commands/exportVideo.ts +198 -0
- package/src/commands/init.ts +56 -0
- package/src/commands/inspect.ts +72 -0
- package/src/commands/list.ts +22 -0
- package/src/commands/new.ts +126 -0
- package/src/commands/shared.ts +96 -0
- package/src/commands/still.ts +40 -0
- package/src/commands/test.ts +265 -0
- package/src/commands/update.ts +183 -0
- package/src/config.ts +84 -0
- package/src/contracts.ts +159 -0
- package/src/cues.ts +141 -0
- package/src/determinism.ts +82 -0
- package/src/diff.ts +71 -0
- package/src/discovery.ts +216 -0
- package/src/formats.ts +119 -0
- package/src/index.ts +58 -0
- package/src/integrity.ts +101 -0
- package/src/jobs.ts +151 -0
- package/src/log.ts +17 -0
- package/src/open.ts +32 -0
- package/src/paths.ts +12 -0
- package/src/prepare-cache.ts +58 -0
- package/src/project.ts +196 -0
- package/src/registry-snapshot.json +3431 -0
- package/src/registry-source.ts +269 -0
- package/src/render.ts +627 -0
- package/src/server.ts +307 -0
- package/studio/index.html +41 -0
- package/studio/src/Studio.tsx +192 -0
- package/studio/src/components/AudioClip.tsx +64 -0
- package/studio/src/components/CanvasStage.tsx +79 -0
- package/studio/src/components/CommandPalette.tsx +129 -0
- package/studio/src/components/Diagnostics.tsx +93 -0
- package/studio/src/components/ExportPanel.tsx +234 -0
- package/studio/src/components/InputControls.tsx +110 -0
- package/studio/src/components/Thumbnail.tsx +71 -0
- package/studio/src/components/Transport.tsx +237 -0
- package/studio/src/components/Waveform.tsx +114 -0
- package/studio/src/components/Wordmark.tsx +449 -0
- package/studio/src/components/ui.tsx +138 -0
- package/studio/src/lib/mix-loudness.ts +52 -0
- package/studio/src/main.tsx +34 -0
- package/studio/src/shortcuts.ts +27 -0
- package/studio/src/studio.css +1232 -0
- package/studio/src/theme.ts +61 -0
- package/studio/src/views/AssetsView.tsx +111 -0
- package/studio/src/views/BrandsView.tsx +139 -0
- package/studio/src/views/ComponentsView.tsx +285 -0
- package/studio/src/views/HomeView.tsx +122 -0
- package/studio/src/views/VideosView.tsx +343 -0
- package/studio/src/virtual.d.ts +25 -0
package/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
|
+
};
|