@odori/cli 0.0.11 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.11",
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",
@@ -8,7 +8,10 @@
8
8
  "odori": "./bin/odori.mjs"
9
9
  },
10
10
  "exports": {
11
- ".": "./src/index.ts"
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
12
15
  },
13
16
  "files": [
14
17
  "bin",
@@ -16,23 +19,16 @@
16
19
  "src",
17
20
  "studio"
18
21
  ],
19
- "scripts": {
20
- "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.studio.json",
21
- "build": "tsup",
22
- "snapshot": "tsx scripts/snapshot.ts",
23
- "verify:package": "tsx scripts/verify-package.ts",
24
- "prepack": "pnpm snapshot && pnpm build && pnpm verify:package"
25
- },
26
22
  "dependencies": {
27
23
  "@puppeteer/browsers": "3.2.0",
28
24
  "@vitejs/plugin-react": "5.1.2",
29
25
  "ffmpeg-static": "5.3.0",
30
- "odori": "workspace:*",
31
26
  "react": "19.2.3",
32
27
  "react-dom": "19.2.3",
33
28
  "playwright-core": "1.55.0",
34
29
  "tsx": "4.20.5",
35
- "vite": "7.3.0"
30
+ "vite": "7.3.0",
31
+ "odori": "0.0.12"
36
32
  },
37
33
  "devDependencies": {
38
34
  "@types/node": "22.19.0",
@@ -40,15 +36,16 @@
40
36
  "@types/react-dom": "19.2.3",
41
37
  "tsup": "^8.5.1",
42
38
  "typescript": "5.9.3",
43
- "@odori/registry": "workspace:*"
39
+ "@odori/registry": "0.0.12"
44
40
  },
45
41
  "publishConfig": {
46
- "access": "public",
47
- "exports": {
48
- ".": {
49
- "types": "./dist/index.d.ts",
50
- "import": "./dist/index.js"
51
- }
52
- }
42
+ "access": "public"
43
+ },
44
+ "scripts": {
45
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.studio.json",
46
+ "build": "tsup",
47
+ "snapshot": "tsx scripts/snapshot.ts",
48
+ "verify:package": "tsx scripts/verify-package.ts",
49
+ "snapshot:docs": "tsx scripts/docs-snapshot.ts"
53
50
  }
54
- }
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,6 +103,7 @@ 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"],
@@ -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>]
@@ -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;
@@ -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
+ };
@@ -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
+ };
@@ -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)) {
@@ -0,0 +1,141 @@
1
+ import {existsSync} from "node:fs";
2
+ import {mkdir, writeFile} from "node:fs/promises";
3
+ import {join, resolve} from "node:path";
4
+ import {resolveEntryLayout} from "odori";
5
+ import {log} from "../log";
6
+ import {writeGenerated} from "../discovery";
7
+ import {checkStructure, type StructureFinding} from "../structure";
8
+ import {createContext} from "./shared";
9
+
10
+ /**
11
+ * The whole project as one JSON document: what the filesystem declares,
12
+ * compiled and answered for. `catalog.json` is what discovery alone can say
13
+ * about the tree; this is what the tree says once every entry has actually
14
+ * been loaded — durations, formats, brands, variants, who uses what — plus
15
+ * every place the filesystem contract is almost met.
16
+ *
17
+ * The point is inspectability without execution. An agent, a CI step, or a
18
+ * teammate's script should be able to ask "what is in this project and is
19
+ * its shape sound" by reading one file, instead of importing modules or
20
+ * driving a browser. The videos themselves stay the source of truth; this is
21
+ * the tree's own testimony, written down where anything can read it.
22
+ */
23
+ export type GraphArtifact = {
24
+ sourceHash: string;
25
+ generatedAt: string;
26
+ videos: Array<{
27
+ id: string;
28
+ title: string;
29
+ description?: string;
30
+ file: string;
31
+ format: {width: number; height: number; fps: number};
32
+ /** 0 when the composition's scenes decide, which only a browser can total. */
33
+ durationInFrames: number;
34
+ seconds: number | null;
35
+ brand: string;
36
+ tags: string[];
37
+ audioVariants: string[];
38
+ components: string[];
39
+ prepare: boolean;
40
+ }>;
41
+ components: Array<{name: string; file: string; usedBy: string[]}>;
42
+ brands: Array<{name: string; file: string}>;
43
+ audio: Array<{name: string; url: string; file: string; bytes: number}>;
44
+ categories: Array<{path: string; name?: string; order?: number}>;
45
+ findings: StructureFinding[];
46
+ };
47
+
48
+ export const buildGraphArtifact = async (root = process.cwd()): Promise<{artifact: GraphArtifact; outDir: string}> => {
49
+ const {config, graph, videos} = await createContext(root);
50
+ // The compiled artifacts stay a set: refreshing the graph refreshes the
51
+ // static imports and the discovery catalog beside it, before the checks
52
+ // run, so this command never reports a staleness it just repaired.
53
+ await writeGenerated(config, graph);
54
+ const findings = await checkStructure(config, graph, videos);
55
+
56
+ const slugOf = new Map(graph.videos.map((video) => [video.file, video.slug]));
57
+ const artifact: GraphArtifact = {
58
+ sourceHash: graph.sourceHash,
59
+ generatedAt: new Date().toISOString(),
60
+ videos: videos.map((video) => {
61
+ const layout = resolveEntryLayout(video.entry);
62
+ const slug = slugOf.get(video.file) ?? video.entry.metadata.id;
63
+ return {
64
+ id: video.entry.metadata.id,
65
+ title: video.entry.metadata.title,
66
+ ...(video.entry.metadata.description ? {description: video.entry.metadata.description} : {}),
67
+ file: video.relativeFile,
68
+ format: {width: layout.format.width, height: layout.format.height, fps: layout.format.fps},
69
+ durationInFrames: video.durationInFrames,
70
+ seconds: video.durationInFrames ? Number((video.durationInFrames / layout.format.fps).toFixed(2)) : null,
71
+ brand: layout.brand.name,
72
+ tags: video.entry.metadata.tags ?? [],
73
+ audioVariants: Object.keys(video.entry.metadata.audio?.variants ?? {}),
74
+ components: graph.previews
75
+ .filter((preview) => preview.usedBy?.includes(slug))
76
+ .map((preview) => preview.name)
77
+ .sort(),
78
+ prepare: existsSync(resolve(video.file, "..", "prepare.ts")),
79
+ };
80
+ }),
81
+ components: graph.previews.map((preview) => ({
82
+ name: preview.name,
83
+ file: preview.relativeFile,
84
+ usedBy: preview.usedBy ?? [],
85
+ })),
86
+ brands: graph.brands.map((brand) => ({name: brand.name, file: brand.relativeFile})),
87
+ audio: graph.audio.map((entry) => ({
88
+ name: entry.name,
89
+ url: entry.url,
90
+ file: entry.relativeFile,
91
+ bytes: entry.bytes,
92
+ })),
93
+ categories: graph.categories,
94
+ findings,
95
+ };
96
+
97
+ const outDir = resolve(config.root, config.outDir);
98
+ await mkdir(outDir, {recursive: true});
99
+ await writeFile(join(outDir, "graph.json"), `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
100
+ return {artifact, outDir};
101
+ };
102
+
103
+ export const graphCommand = async (options: {json?: boolean} = {}): Promise<number> => {
104
+ const {artifact} = await buildGraphArtifact();
105
+ const errors = artifact.findings.filter((finding) => finding.level === "error");
106
+
107
+ if (options.json === true) {
108
+ log.info(JSON.stringify(artifact, null, 2));
109
+ return errors.length === 0 ? 0 : 1;
110
+ }
111
+
112
+ log.title(`${artifact.videos.length} video${artifact.videos.length === 1 ? "" : "s"}`);
113
+ for (const video of artifact.videos) {
114
+ const duration = video.seconds === null ? "duration from scenes" : `${video.seconds}s`;
115
+ const extras = [
116
+ video.audioVariants.length > 0 ? `audio: ${video.audioVariants.join(", ")}` : "",
117
+ video.components.length > 0 ? `components: ${video.components.join(", ")}` : "",
118
+ video.prepare ? "prepare.ts" : "",
119
+ ].filter(Boolean);
120
+ log.info(` ${video.id} ${video.format.width}x${video.format.height} ${duration} ${video.brand}`);
121
+ if (extras.length > 0) log.detail(` ${extras.join(" · ")}`);
122
+ }
123
+
124
+ if (artifact.components.length > 0) {
125
+ log.title(`${artifact.components.length} component${artifact.components.length === 1 ? "" : "s"}`);
126
+ for (const component of artifact.components) {
127
+ log.info(` ${component.name}${component.usedBy.length > 0 ? ` used by ${component.usedBy.join(", ")}` : ""}`);
128
+ }
129
+ }
130
+ if (artifact.audio.length > 0) log.title(`${artifact.audio.length} audio file${artifact.audio.length === 1 ? "" : "s"}`);
131
+ for (const entry of artifact.audio) log.info(` ${entry.url} ${(entry.bytes / 1024).toFixed(0)} KB`);
132
+
133
+ if (artifact.findings.length > 0) log.title("Structure");
134
+ for (const finding of artifact.findings) {
135
+ const say = finding.level === "error" ? log.error : log.warn;
136
+ say(`${finding.file}: ${finding.message}`);
137
+ }
138
+
139
+ log.detail(`Wrote .odori/graph.json (source ${artifact.sourceHash.slice(0, 12)})`);
140
+ return errors.length === 0 ? 0 : 1;
141
+ };
@@ -2,6 +2,7 @@ import {isOdoriSchema, resolveEntryLayout} from "odori";
2
2
  import {log} from "../log";
3
3
  import {openRenderPage, readAudio, readTimeline, seekTo} from "../render";
4
4
  import {checkAudioWindows, checkInstalledContracts} from "../contracts";
5
+ import {checkStructure} from "../structure";
5
6
  import {checkDeterminism} from "../determinism";
6
7
  import {createContext, targetFor, withServer} from "./shared";
7
8
  import type {LoadedVideo} from "../project";
@@ -309,7 +310,7 @@ const testVideo = async (
309
310
  };
310
311
 
311
312
  export const testCommand = async (id?: string, options: {json?: boolean} = {}) => {
312
- const {config, videos} = await createContext();
313
+ const {config, graph, videos} = await createContext();
313
314
  const selected = id ? videos.filter((video) => video.entry.metadata.id === id) : videos;
314
315
  if (selected.length === 0) throw new Error(id ? `Unknown video "${id}".` : "No videos discovered.");
315
316
 
@@ -318,6 +319,14 @@ export const testCommand = async (id?: string, options: {json?: boolean} = {}) =
318
319
  // about the project, not about a frame.
319
320
  failures.push(...(await checkInstalledContracts(config, selected)));
320
321
 
322
+ // The filesystem contract next, still before a browser: a referenced sound
323
+ // with no file behind it fails here as a fact about the tree, instead of
324
+ // surfacing as unexplained silence in an export. Warnings stay doctor's
325
+ // business; a test only fails on a promise the project breaks.
326
+ for (const finding of await checkStructure(config, graph, videos)) {
327
+ if (finding.level === "error") failures.push({video: finding.file, message: finding.message});
328
+ }
329
+
321
330
  // So is a wall clock in a composition. It would render fine here and differ
322
331
  // on the next machine, which is the failure mode a test is for.
323
332
  for (const finding of await checkDeterminism(config)) {