@odori/cli 0.0.10 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-AJBK4XRB.js → chunk-FS2BJU5Q.js} +777 -78
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +100 -2
- package/dist/index.js +5 -1
- package/dist/{registry-snapshot-TKH2KAC3.js → registry-snapshot-TSTAA6NT.js} +1815 -245
- package/package.json +5 -4
- package/src/cli.ts +32 -2
- package/src/commands/blocks.ts +188 -0
- package/src/commands/dev.ts +6 -1
- package/src/commands/docs.ts +88 -0
- package/src/commands/doctor.ts +38 -0
- package/src/commands/exportVideo.ts +21 -2
- package/src/commands/graph.ts +141 -0
- package/src/commands/shared.ts +4 -1
- package/src/commands/test.ts +10 -1
- package/src/cues.ts +17 -0
- package/src/discovery.ts +8 -2
- package/src/docs-snapshot.json +130 -0
- package/src/docs.ts +54 -0
- package/src/index.ts +2 -0
- package/src/registry-snapshot.json +1647 -206
- package/src/registry-source.ts +139 -0
- package/src/render.ts +3 -0
- package/src/structure.ts +177 -0
- package/studio/src/components/ExportPanel.tsx +11 -18
- package/studio/src/main.tsx +1 -0
- package/studio/src/studio.css +18 -0
- package/studio/src/views/ComponentsView.tsx +28 -7
- package/studio/src/views/VideosView.tsx +83 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odori/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The odori command line: discovery, Studio, component installation, stills, tests, and export jobs.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"playwright-core": "1.55.0",
|
|
29
29
|
"tsx": "4.20.5",
|
|
30
30
|
"vite": "7.3.0",
|
|
31
|
-
"odori": "0.0.
|
|
31
|
+
"odori": "0.0.12"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "22.19.0",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@types/react-dom": "19.2.3",
|
|
37
37
|
"tsup": "^8.5.1",
|
|
38
38
|
"typescript": "5.9.3",
|
|
39
|
-
"@odori/registry": "0.0.
|
|
39
|
+
"@odori/registry": "0.0.12"
|
|
40
40
|
},
|
|
41
41
|
"publishConfig": {
|
|
42
42
|
"access": "public"
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.studio.json",
|
|
46
46
|
"build": "tsup",
|
|
47
47
|
"snapshot": "tsx scripts/snapshot.ts",
|
|
48
|
-
"verify:package": "tsx scripts/verify-package.ts"
|
|
48
|
+
"verify:package": "tsx scripts/verify-package.ts",
|
|
49
|
+
"snapshot:docs": "tsx scripts/docs-snapshot.ts"
|
|
49
50
|
}
|
|
50
51
|
}
|
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {log} from "./log";
|
|
|
3
3
|
import {addCommand, registryCommand} from "./commands/add";
|
|
4
4
|
import {diffCommand, updateCommand} from "./commands/update";
|
|
5
5
|
import {devCommand} from "./commands/dev";
|
|
6
|
+
import {docsCommand} from "./commands/docs";
|
|
6
7
|
import {doctorCommand} from "./commands/doctor";
|
|
7
8
|
import {installCommand} from "./binaries";
|
|
8
9
|
import {exportCommand, jobsCommand} from "./commands/exportVideo";
|
|
@@ -12,8 +13,10 @@ import {inspectCommand} from "./commands/inspect";
|
|
|
12
13
|
import {listCommand} from "./commands/list";
|
|
13
14
|
import {newCommand} from "./commands/new";
|
|
14
15
|
import {bedCommand} from "./commands/bed";
|
|
16
|
+
import {blocksCommand} from "./commands/blocks";
|
|
15
17
|
import {narrateCommand} from "./commands/narrate";
|
|
16
18
|
import {frameCommand} from "./commands/frame";
|
|
19
|
+
import {graphCommand} from "./commands/graph";
|
|
17
20
|
import {testCommand} from "./commands/test";
|
|
18
21
|
|
|
19
22
|
type Flags = Record<string, string | boolean>;
|
|
@@ -100,12 +103,13 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
100
103
|
diff: ["full"],
|
|
101
104
|
update: ["force"],
|
|
102
105
|
list: [],
|
|
106
|
+
graph: ["json"],
|
|
103
107
|
inspect: ["json", "input"],
|
|
104
108
|
frame: ["at", "output", "input"],
|
|
105
109
|
bed: ["role", "output", "target", "generate", "provider", "seconds"],
|
|
106
110
|
narrate: ["output", "voice", "role", "provider"],
|
|
107
111
|
test: ["json"],
|
|
108
|
-
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
|
|
112
|
+
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "audio-variant", "fast", "no-frame-skip", "retry"],
|
|
109
113
|
jobs: [],
|
|
110
114
|
help: [],
|
|
111
115
|
};
|
|
@@ -184,6 +188,11 @@ const USAGE: Record<string, string> = {
|
|
|
184
188
|
add: `odori add <components...> [--force] [--dry-run]
|
|
185
189
|
Install editable component source, fetched from the registry and cached.
|
|
186
190
|
--force replaces local edits. --dry-run lists the files and writes nothing.`,
|
|
191
|
+
blocks: `odori blocks <ls|show|cat|add> [id] [file] [--out <dir>] [--force] [--dry-run] [--json]
|
|
192
|
+
Whole videos, published like components. ls lists them, show names the files
|
|
193
|
+
and components one is built from, cat prints one of those files, and add
|
|
194
|
+
installs the block and everything it imports. --out installs into another
|
|
195
|
+
directory rather than the one the block was authored in.`,
|
|
187
196
|
registry: `odori registry
|
|
188
197
|
List available registry components and cues.`,
|
|
189
198
|
diff: `odori diff [components] [--full]
|
|
@@ -192,6 +201,11 @@ const USAGE: Record<string, string> = {
|
|
|
192
201
|
Apply upstream component changes.`,
|
|
193
202
|
list: `odori list
|
|
194
203
|
Print discovered video ids and formats.`,
|
|
204
|
+
graph: `odori graph [--json]
|
|
205
|
+
Compile the project into .odori/graph.json: every video with its format,
|
|
206
|
+
duration, brand, tags, audio variants, and components, plus the component
|
|
207
|
+
catalog, the audio library, and structure findings. --json prints the same
|
|
208
|
+
document to stdout. Exits non-zero when the structure has errors.`,
|
|
195
209
|
inspect: `odori inspect <id> [--json] [--input <json>]
|
|
196
210
|
Show resolved layout, inputs, scenes, and assets.`,
|
|
197
211
|
frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
|
|
@@ -202,7 +216,7 @@ const USAGE: Record<string, string> = {
|
|
|
202
216
|
check, for CI.`,
|
|
203
217
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
204
218
|
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
205
|
-
[--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
|
|
219
|
+
[--no-audio] [--audio-variant <name>] [--fast] [--no-frame-skip] [--retry <job>]
|
|
206
220
|
Render and encode a distributable file. --format is mp4, webm, prores, gif,
|
|
207
221
|
or png; without it the output's extension decides, and mp4 is the default.
|
|
208
222
|
--quality is studio, social, or web. --scale multiplies the output size,
|
|
@@ -221,14 +235,17 @@ const HELP = `odori - build videos like applications
|
|
|
221
235
|
Usage
|
|
222
236
|
odori dev [--port 4300] Discover project resources and start Studio
|
|
223
237
|
odori init Add videos/ and odori.config.ts to a project
|
|
238
|
+
odori docs [page|search <text>] Read the documentation offline
|
|
224
239
|
odori doctor Check everything a render and an encode need
|
|
225
240
|
odori install Download the pinned Chrome and FFmpeg
|
|
226
241
|
odori new <name> Generate a video.tsx entry
|
|
227
242
|
odori add <components...> Install editable component source
|
|
243
|
+
odori blocks <ls|show|add> Browse and install whole videos
|
|
228
244
|
odori registry List available registry components
|
|
229
245
|
odori diff [components] Compare installed components with upstream
|
|
230
246
|
odori update [components] Apply upstream component changes
|
|
231
247
|
odori list Print discovered video ids and formats
|
|
248
|
+
odori graph [--json] Compile the project graph and check its shape
|
|
232
249
|
odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
|
|
233
250
|
odori frame <id> --at 4s Render one deterministic frame to a PNG
|
|
234
251
|
odori test [id] [--json] Validate contracts and representative frames
|
|
@@ -299,6 +316,8 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
299
316
|
case "integrations":
|
|
300
317
|
await integrationsCommand();
|
|
301
318
|
return 0;
|
|
319
|
+
case "docs":
|
|
320
|
+
return await docsCommand(positionals, {json: flags.json === true});
|
|
302
321
|
case "doctor":
|
|
303
322
|
return await doctorCommand();
|
|
304
323
|
case "install":
|
|
@@ -309,6 +328,14 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
309
328
|
case "add":
|
|
310
329
|
await addCommand(positionals, {force: flags.force === true, dryRun: flags["dry-run"] === true});
|
|
311
330
|
return 0;
|
|
331
|
+
case "blocks":
|
|
332
|
+
await blocksCommand(positionals, {
|
|
333
|
+
out: typeof flags.out === "string" ? flags.out : undefined,
|
|
334
|
+
force: flags.force === true,
|
|
335
|
+
dryRun: flags["dry-run"] === true,
|
|
336
|
+
json: flags.json === true,
|
|
337
|
+
});
|
|
338
|
+
return 0;
|
|
312
339
|
case "registry":
|
|
313
340
|
await registryCommand();
|
|
314
341
|
return 0;
|
|
@@ -321,6 +348,8 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
321
348
|
case "list":
|
|
322
349
|
await listCommand();
|
|
323
350
|
return 0;
|
|
351
|
+
case "graph":
|
|
352
|
+
return await graphCommand({json: flags.json === true});
|
|
324
353
|
case "inspect":
|
|
325
354
|
await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
|
|
326
355
|
return 0;
|
|
@@ -365,6 +394,7 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
365
394
|
scale: numberFlag(flags, "scale"),
|
|
366
395
|
format: typeof flags.format === "string" ? flags.format : undefined,
|
|
367
396
|
audio: flags["no-audio"] === true ? false : undefined,
|
|
397
|
+
audioVariant: typeof flags["audio-variant"] === "string" ? flags["audio-variant"] : undefined,
|
|
368
398
|
fast: flags.fast === true,
|
|
369
399
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
|
|
370
400
|
retry: typeof flags.retry === "string" ? flags.retry : undefined,
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {existsSync} from "node:fs";
|
|
2
|
+
import {mkdir, writeFile} from "node:fs/promises";
|
|
3
|
+
import {dirname} from "node:path";
|
|
4
|
+
import {loadConfig} from "../config";
|
|
5
|
+
import {log} from "../log";
|
|
6
|
+
import {
|
|
7
|
+
normalizeComponentName,
|
|
8
|
+
resolveBlock,
|
|
9
|
+
resolveBlocks,
|
|
10
|
+
resolveWithinRoot,
|
|
11
|
+
verifyBlockIntegrity,
|
|
12
|
+
type BlockDocument,
|
|
13
|
+
} from "../registry-source";
|
|
14
|
+
import {addCommand} from "./add";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Blocks from the command line.
|
|
18
|
+
*
|
|
19
|
+
* `odori add` installs a component and `create odori` scaffolds a project, so
|
|
20
|
+
* the largest thing the catalog ships was the one thing a reader had to
|
|
21
|
+
* rebuild by hand: open the page, copy each file, paste it in. `add` closes
|
|
22
|
+
* that, and the other three exist so running it is not a leap — see what a
|
|
23
|
+
* block is, read a file, then take it.
|
|
24
|
+
*
|
|
25
|
+
* Everything a block install must get right is already solved for components,
|
|
26
|
+
* so none of it is rewritten here: resolution and its cache, the integrity
|
|
27
|
+
* hash over the exact bytes, the refusal to write outside the project, and
|
|
28
|
+
* the component install itself, which `add` performs by calling the same
|
|
29
|
+
* command a person would.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Where a block's files land, once `--out` has had its say. */
|
|
33
|
+
const rebase = (block: BlockDocument, target: string, out?: string): string => {
|
|
34
|
+
if (!out) return target;
|
|
35
|
+
const home = block.entry.slice(0, block.entry.lastIndexOf("/"));
|
|
36
|
+
// Only the block's own directory moves. A target that somehow sits outside
|
|
37
|
+
// it keeps its place rather than being silently reparented.
|
|
38
|
+
if (!target.startsWith(`${home}/`)) return target;
|
|
39
|
+
return `${out.replace(/\/$/, "")}/${target.slice(home.length + 1)}`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const findBlock = async (config: Awaited<ReturnType<typeof loadConfig>>, id: string) => {
|
|
43
|
+
const index = await resolveBlocks(config);
|
|
44
|
+
const summary = index.items.find((item) => item.id === id);
|
|
45
|
+
if (!summary) {
|
|
46
|
+
const names = index.items.map((item) => item.id).join(", ");
|
|
47
|
+
throw new Error(`No block named ${JSON.stringify(id)}. Available: ${names}`);
|
|
48
|
+
}
|
|
49
|
+
return {summary, index};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const blocksListCommand = async (options: {json?: boolean} = {}) => {
|
|
53
|
+
const config = await loadConfig(process.cwd());
|
|
54
|
+
const {items, origin, detail} = await resolveBlocks(config);
|
|
55
|
+
|
|
56
|
+
if (options.json) {
|
|
57
|
+
log.info(JSON.stringify({origin, items}, null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (origin === "cache") log.detail(`blocks: cached copy (offline)`);
|
|
62
|
+
else log.detail(`blocks: ${detail}`);
|
|
63
|
+
log.title(`${items.length} block${items.length === 1 ? "" : "s"}`);
|
|
64
|
+
for (const item of items) {
|
|
65
|
+
log.info(` ${item.id} ${item.title}`);
|
|
66
|
+
log.detail(` ${item.format} · ${item.tags.join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
log.detail(`\nodori blocks show <id> for its files, odori blocks add <id> to install one.`);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const blocksShowCommand = async (id: string, options: {json?: boolean} = {}) => {
|
|
72
|
+
const config = await loadConfig(process.cwd());
|
|
73
|
+
const {summary} = await findBlock(config, id);
|
|
74
|
+
|
|
75
|
+
if (options.json) {
|
|
76
|
+
log.info(JSON.stringify(summary, null, 2));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
log.title(`${summary.title} (${summary.id})`);
|
|
81
|
+
log.info(` ${summary.description}`);
|
|
82
|
+
log.detail(` ${summary.format} · ${summary.tags.join(", ")}`);
|
|
83
|
+
log.title("files");
|
|
84
|
+
for (const file of summary.files) log.info(` ${file.target}${file.target === summary.entry ? " (entry)" : ""}`);
|
|
85
|
+
log.title("components");
|
|
86
|
+
for (const component of summary.components) log.info(` ${component}`);
|
|
87
|
+
log.detail(`\nodori blocks add ${summary.id}`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const blocksCatCommand = async (id: string, file: string) => {
|
|
91
|
+
const config = await loadConfig(process.cwd());
|
|
92
|
+
await findBlock(config, id);
|
|
93
|
+
const {block} = await resolveBlock(config, id);
|
|
94
|
+
|
|
95
|
+
// A reader types the name they saw, which may be the whole target or just
|
|
96
|
+
// the file at the end of it.
|
|
97
|
+
const found =
|
|
98
|
+
block.files.find((item) => item.target === file) ??
|
|
99
|
+
block.files.find((item) => item.target.endsWith(`/${file}`));
|
|
100
|
+
if (!found) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Block "${id}" has no file ${JSON.stringify(file)}. It ships: ${block.files
|
|
103
|
+
.map((item) => item.target)
|
|
104
|
+
.join(", ")}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
log.info(found.content);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const blocksAddCommand = async (
|
|
111
|
+
id: string,
|
|
112
|
+
options: {out?: string; force?: boolean; dryRun?: boolean} = {},
|
|
113
|
+
) => {
|
|
114
|
+
const config = await loadConfig(process.cwd());
|
|
115
|
+
await findBlock(config, id);
|
|
116
|
+
const {block, origin} = await resolveBlock(config, id);
|
|
117
|
+
if (origin === "cache") log.detail("blocks: cached copy (offline)");
|
|
118
|
+
verifyBlockIntegrity(block);
|
|
119
|
+
|
|
120
|
+
const writes = block.files.map((file) => {
|
|
121
|
+
const target = rebase(block, file.target, options.out);
|
|
122
|
+
return {target, destination: resolveWithinRoot(config.root, target), content: file.content};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Every refusal is collected before anything is written, so a block never
|
|
126
|
+
// lands half-installed on the first file that already existed.
|
|
127
|
+
const clashes = writes.filter((write) => existsSync(write.destination));
|
|
128
|
+
if (clashes.length > 0 && !options.force) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`These files already exist:\n${clashes.map((write) => ` ${write.target}`).join("\n")}\n` +
|
|
131
|
+
"Nothing was written. Pass --out to install beside them, or --force to replace them.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (options.dryRun) {
|
|
136
|
+
log.title(`${block.title} would write ${writes.length} file${writes.length === 1 ? "" : "s"}`);
|
|
137
|
+
for (const write of writes) log.info(` ${write.target}`);
|
|
138
|
+
if (block.components.length > 0) log.detail(` and install ${block.components.join(", ")}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* The components first: a block's files import them, so a project that
|
|
143
|
+
stops halfway should be missing the video rather than the parts. */
|
|
144
|
+
if (block.components.length > 0) {
|
|
145
|
+
await addCommand(block.components.map(normalizeComponentName), {force: options.force});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const write of writes) {
|
|
149
|
+
await mkdir(dirname(write.destination), {recursive: true});
|
|
150
|
+
await writeFile(write.destination, write.content, "utf8");
|
|
151
|
+
log.info(` ${write.target}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const entry = rebase(block, block.entry, options.out);
|
|
155
|
+
log.title(`${block.title} installed`);
|
|
156
|
+
log.detail(` entry: ${entry}`);
|
|
157
|
+
/* The id is written inside the entry, not taken from the directory, so a
|
|
158
|
+
block installed beside itself would be discovered twice under one name.
|
|
159
|
+
Better said here than found as a duplicate-id error on the next run. */
|
|
160
|
+
if (options.out) log.detail(` the video id is set in the entry; change it if this project already has "${block.id}".`);
|
|
161
|
+
log.detail(` odori dev, then open the video to watch it.`);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** The one entry point the CLI dispatches to, so `blocks` reads as one word. */
|
|
165
|
+
export const blocksCommand = async (
|
|
166
|
+
positionals: string[],
|
|
167
|
+
flags: {out?: string; force?: boolean; dryRun?: boolean; json?: boolean} = {},
|
|
168
|
+
) => {
|
|
169
|
+
const [action = "ls", ...rest] = positionals;
|
|
170
|
+
switch (action) {
|
|
171
|
+
case "ls":
|
|
172
|
+
case "list":
|
|
173
|
+
return blocksListCommand({json: flags.json});
|
|
174
|
+
case "show":
|
|
175
|
+
if (!rest[0]) throw new Error("Name a block, for example: odori blocks show launch");
|
|
176
|
+
return blocksShowCommand(rest[0], {json: flags.json});
|
|
177
|
+
case "cat":
|
|
178
|
+
if (!rest[0] || !rest[1]) {
|
|
179
|
+
throw new Error("Name a block and a file, for example: odori blocks cat launch video.tsx");
|
|
180
|
+
}
|
|
181
|
+
return blocksCatCommand(rest[0], rest[1]);
|
|
182
|
+
case "add":
|
|
183
|
+
if (!rest[0]) throw new Error("Name a block, for example: odori blocks add launch");
|
|
184
|
+
return blocksAddCommand(rest[0], {out: flags.out, force: flags.force, dryRun: flags.dryRun});
|
|
185
|
+
default:
|
|
186
|
+
throw new Error(`Unknown blocks command ${JSON.stringify(action)}. Try: ls, show, cat, add.`);
|
|
187
|
+
}
|
|
188
|
+
};
|
package/src/commands/dev.ts
CHANGED
|
@@ -165,7 +165,12 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
165
165
|
const {graph, videos} = await context();
|
|
166
166
|
const video = findVideo(videos, String(body.videoId));
|
|
167
167
|
const input = (body.input ?? {}) as Record<string, unknown>;
|
|
168
|
-
const
|
|
168
|
+
const audioVariant = typeof body.audioVariant === "string" ? body.audioVariant : undefined;
|
|
169
|
+
const compiled = await compileInBrowser(
|
|
170
|
+
origin,
|
|
171
|
+
targetFor(video, input, undefined, undefined, undefined, audioVariant),
|
|
172
|
+
config,
|
|
173
|
+
);
|
|
169
174
|
const {manifest} = await freezeManifest(
|
|
170
175
|
{...video, durationInFrames: compiled.durationInFrames},
|
|
171
176
|
graph,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {docPages, findDoc, searchDocs} from "../docs";
|
|
2
|
+
import {log} from "../log";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The documentation, in the terminal the agent is already in.
|
|
6
|
+
*
|
|
7
|
+
* The pages ship inside this package, so this answers with no network and no
|
|
8
|
+
* browser: `odori docs` lists what there is, `odori docs <page>` prints one,
|
|
9
|
+
* and `odori docs search <text>` finds the line that says it. An agent
|
|
10
|
+
* working in an unfamiliar project can read the framework the same way it
|
|
11
|
+
* reads the project.
|
|
12
|
+
*/
|
|
13
|
+
export const docsCommand = async (positionals: string[], options: {json?: boolean} = {}) => {
|
|
14
|
+
const [first, ...rest] = positionals;
|
|
15
|
+
|
|
16
|
+
if (first === "search") {
|
|
17
|
+
const query = rest.join(" ").trim();
|
|
18
|
+
if (!query) {
|
|
19
|
+
log.warn("Usage: odori docs search <text>");
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
const hits = searchDocs(query);
|
|
23
|
+
if (options.json) {
|
|
24
|
+
log.info(JSON.stringify(hits.map(({page, line, text}) => ({slug: page.slug, line, text})), null, 2));
|
|
25
|
+
return hits.length > 0 ? 0 : 1;
|
|
26
|
+
}
|
|
27
|
+
if (hits.length === 0) {
|
|
28
|
+
log.warn(`No page mentions ${query}.`);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
log.title(`${hits.length} line${hits.length === 1 ? "" : "s"} mentioning ${query}`);
|
|
32
|
+
for (const hit of hits) {
|
|
33
|
+
log.info(` ${hit.page.slug}:${hit.line}`);
|
|
34
|
+
log.detail(` ${hit.text}`);
|
|
35
|
+
}
|
|
36
|
+
log.detail(`Read one with: odori docs ${hits[0].page.slug}`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!first) {
|
|
41
|
+
const pages = docPages();
|
|
42
|
+
if (options.json) {
|
|
43
|
+
log.info(
|
|
44
|
+
JSON.stringify(
|
|
45
|
+
pages.map(({slug, title, description}) => ({slug, title, description})),
|
|
46
|
+
null,
|
|
47
|
+
2,
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
log.title(`${pages.length} pages`);
|
|
53
|
+
/* Grouped the way the site files them, because a flat list of eighteen
|
|
54
|
+
slugs is a list to read rather than a shape to recognise. */
|
|
55
|
+
const sections = new Map<string, typeof pages>();
|
|
56
|
+
for (const page of pages) {
|
|
57
|
+
const key = page.section || "";
|
|
58
|
+
sections.set(key, [...(sections.get(key) ?? []), page]);
|
|
59
|
+
}
|
|
60
|
+
for (const [section, group] of sections) {
|
|
61
|
+
if (section) log.info(` ${section}/`);
|
|
62
|
+
for (const page of group) {
|
|
63
|
+
const name = section ? page.slug.slice(section.length + 1) : page.slug;
|
|
64
|
+
log.info(` ${section ? " " : ""}${name.padEnd(section ? 20 : 22)}${page.title}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
log.detail("Read one with: odori docs <page> · search with: odori docs search <text>");
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const page = findDoc(first);
|
|
72
|
+
if (!page) {
|
|
73
|
+
log.warn(`No page called ${first}.`);
|
|
74
|
+
log.detail("List them with: odori docs");
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (options.json) {
|
|
79
|
+
log.info(JSON.stringify(page, null, 2));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
log.title(page.title);
|
|
84
|
+
if (page.description) log.detail(page.description);
|
|
85
|
+
log.info("");
|
|
86
|
+
log.info(page.body);
|
|
87
|
+
return 0;
|
|
88
|
+
};
|
package/src/commands/doctor.ts
CHANGED
|
@@ -4,6 +4,9 @@ import {existsSync} from "node:fs";
|
|
|
4
4
|
import {createRequire} from "node:module";
|
|
5
5
|
import {relative, resolve} from "node:path";
|
|
6
6
|
import {loadConfig} from "../config";
|
|
7
|
+
import {discoverProject} from "../discovery";
|
|
8
|
+
import {loadVideos} from "../project";
|
|
9
|
+
import {checkStructure} from "../structure";
|
|
7
10
|
import {keySource} from "../keystore";
|
|
8
11
|
import {musicProviders} from "../providers";
|
|
9
12
|
import {CHROME_BUILD, cacheRoot, resolveBrowser, resolveFfmpeg} from "../binaries";
|
|
@@ -167,6 +170,41 @@ export const runChecks = async (root: string): Promise<Check[]> => {
|
|
|
167
170
|
fix: `Add a sibling <name>.preview.tsx with defineComponentPreview so Studio can play it on its own. A component with no fixture only ever renders inside a video.`,
|
|
168
171
|
});
|
|
169
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The filesystem contract, checked the way the graph command checks it. A
|
|
175
|
+
* file one keystroke away from being an entry, a fixture credited to
|
|
176
|
+
* nothing, a sound with no file behind it: each is invisible to discovery
|
|
177
|
+
* by design, which is exactly why doctor has to say it. Loading the videos
|
|
178
|
+
* can itself fail — a missing export, a colliding id — and that failure is
|
|
179
|
+
* this check's answer rather than doctor's crash.
|
|
180
|
+
*/
|
|
181
|
+
if (existsSync(resolve(config.root, config.videosDir))) {
|
|
182
|
+
try {
|
|
183
|
+
const graph = await discoverProject(config);
|
|
184
|
+
const videos = await loadVideos(graph);
|
|
185
|
+
const findings = await checkStructure(config, graph, videos);
|
|
186
|
+
const errors = findings.filter((finding) => finding.level === "error");
|
|
187
|
+
const first = errors[0] ?? findings[0];
|
|
188
|
+
checks.push({
|
|
189
|
+
name: "Structure",
|
|
190
|
+
detail:
|
|
191
|
+
findings.length === 0
|
|
192
|
+
? `${videos.length} video${videos.length === 1 ? "" : "s"}, shape is sound`
|
|
193
|
+
: `${first.file}: ${first.message}${findings.length > 1 ? ` (and ${findings.length - 1} more)` : ""}`,
|
|
194
|
+
ok: errors.length === 0,
|
|
195
|
+
warn: errors.length === 0 && findings.length > 0,
|
|
196
|
+
fix: 'Run "odori graph" for the full report.',
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
checks.push({
|
|
200
|
+
name: "Structure",
|
|
201
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
202
|
+
ok: false,
|
|
203
|
+
fix: "A video entry failed to load. Open the file the message names.",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
170
208
|
// A missing provider key is not a broken project — generation is optional —
|
|
171
209
|
// but doctor is exactly where "why does --generate fail" gets answered.
|
|
172
210
|
for (const provider of Object.values(musicProviders)) {
|
|
@@ -158,6 +158,8 @@ export const exportCommand = async (
|
|
|
158
158
|
format?: string;
|
|
159
159
|
/** False writes the picture with no audio track. */
|
|
160
160
|
audio?: boolean;
|
|
161
|
+
/** A declared audio variant to voice this export with. */
|
|
162
|
+
audioVariant?: string;
|
|
161
163
|
/** True draws on the machine's GPU instead of the reproducible backend. */
|
|
162
164
|
fast?: boolean;
|
|
163
165
|
skipUnchangedFrames?: boolean;
|
|
@@ -176,7 +178,21 @@ export const exportCommand = async (
|
|
|
176
178
|
? await readJob(config, options.retry)
|
|
177
179
|
: await (async () => {
|
|
178
180
|
const video = findVideo(videos, id);
|
|
179
|
-
|
|
181
|
+
if (options.audioVariant) {
|
|
182
|
+
const declared = Object.keys(video.entry.metadata.audio?.variants ?? {});
|
|
183
|
+
if (!declared.includes(options.audioVariant)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
declared.length
|
|
186
|
+
? `No audio variant named "${options.audioVariant}". Declared: ${declared.join(", ")}.`
|
|
187
|
+
: `"${id}" declares no audio variants; drop --audio-variant or add metadata.audio.variants.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const compiled = await compileInBrowser(
|
|
192
|
+
server.url,
|
|
193
|
+
targetFor(video, options.input, undefined, undefined, undefined, options.audioVariant),
|
|
194
|
+
config,
|
|
195
|
+
);
|
|
180
196
|
const {manifest} = await freezeManifest(
|
|
181
197
|
{...video, durationInFrames: compiled.durationInFrames},
|
|
182
198
|
graph,
|
|
@@ -184,9 +200,12 @@ export const exportCommand = async (
|
|
|
184
200
|
options.input ?? {},
|
|
185
201
|
{scenes: compiled.scenes, audio: compiled.audio},
|
|
186
202
|
);
|
|
203
|
+
// The variant names the file, so two voices cannot overwrite each
|
|
204
|
+
// other's exports.
|
|
205
|
+
const suffix = options.audioVariant ? `-${options.audioVariant}` : "";
|
|
187
206
|
const output = resolve(
|
|
188
207
|
config.root,
|
|
189
|
-
options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`,
|
|
208
|
+
options.output ?? `${config.exportDir}/${outputName(id)}${suffix}${format.extension}`,
|
|
190
209
|
);
|
|
191
210
|
return createJob(config, manifest, output, {
|
|
192
211
|
format: format.name,
|