@odori/cli 0.0.3 → 0.0.4

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.3",
3
+ "version": "0.0.4",
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.3"
31
+ "odori": "0.0.4"
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.3"
39
+ "@odori/registry": "0.0.4"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"
package/src/assets.ts ADDED
@@ -0,0 +1,90 @@
1
+ import {createHash} from "node:crypto";
2
+ import {existsSync} from "node:fs";
3
+ import {mkdir, readFile, rm, writeFile} from "node:fs/promises";
4
+ import {dirname, relative, resolve} from "node:path";
5
+ import type {ResolvedConfig} from "./config";
6
+ import {log} from "./log";
7
+ import {cacheRoot} from "./binaries";
8
+ import {registryOrigin, resolveWithinRoot, type RegistryComponent} from "./registry-source";
9
+
10
+ /**
11
+ * Installing a produced file.
12
+ *
13
+ * Most of the registry is source, and source is what `odori add` was built to
14
+ * copy. Some sound cannot be reached from oscillators — a keyboard, a room, a
15
+ * voice — so those ship as recordings instead, and installing one has to mean
16
+ * fetching bytes rather than writing a module. The rules are the same rules:
17
+ * the destination is confined to the project, the bytes are checked against
18
+ * the hash the registry published before anything is written, and a file the
19
+ * project already has is left alone unless replacing it was asked for.
20
+ *
21
+ * The download is cached beside the browser and the encoder, so a second
22
+ * project on the same machine costs no second round trip.
23
+ */
24
+ const assetCache = (integrity: string): string =>
25
+ resolve(cacheRoot(), "assets", createHash("sha256").update(integrity).digest("hex").slice(0, 16));
26
+
27
+ const verify = (bytes: Uint8Array, expected: string, source: string): void => {
28
+ const actual = `sha256-${createHash("sha256").update(bytes).digest("base64")}`;
29
+ if (actual === expected) return;
30
+ throw new Error(
31
+ `The bytes at ${source} do not match the hash the registry published.\n` +
32
+ ` expected ${expected}\n received ${actual}\n` +
33
+ "Nothing was written. This is a truncated download, a stale proxy, or a tampered file.",
34
+ );
35
+ };
36
+
37
+ export const installAsset = async (
38
+ config: ResolvedConfig,
39
+ component: RegistryComponent,
40
+ options: {dryRun?: boolean; force?: boolean} = {},
41
+ ): Promise<boolean> => {
42
+ const asset = component.asset;
43
+ if (!asset) return false;
44
+
45
+ const destination = resolveWithinRoot(config.root, asset.target);
46
+ const exists = existsSync(destination);
47
+ log.detail(` ${exists ? "replace" : "create "} ${relative(config.root, destination)}`);
48
+ if (options.dryRun) return false;
49
+
50
+ if (exists && !options.force) {
51
+ log.warn(`${relative(config.root, destination)} already exists. Keeping it. Use --force to replace it.`);
52
+ return true;
53
+ }
54
+
55
+ const cached = assetCache(asset.integrity);
56
+ let bytes: Uint8Array | null = null;
57
+
58
+ if (existsSync(cached)) {
59
+ const stored = await readFile(cached);
60
+ // A cache that does not verify is a cache miss, not a failure: the bytes
61
+ // on the machine are the one part of this nobody published. Refusing here
62
+ // would strand a project behind a corrupt file it never asked for.
63
+ try {
64
+ verify(stored, asset.integrity, cached);
65
+ bytes = stored;
66
+ } catch {
67
+ log.detail(" the cached copy did not verify; downloading it again");
68
+ await rm(cached, {force: true});
69
+ }
70
+ }
71
+
72
+ if (!bytes) {
73
+ const url = new URL(asset.url, `${registryOrigin(config)}/`).toString();
74
+ const response = await fetch(url, {signal: AbortSignal.timeout(30_000)});
75
+ if (!response.ok) throw new Error(`Could not download ${url}: ${response.status} ${response.statusText}`);
76
+ const downloaded = new Uint8Array(await response.arrayBuffer());
77
+ // A download that does not verify is a failure: this is the check that
78
+ // makes fetching bytes into somebody's repository defensible.
79
+ verify(downloaded, asset.integrity, url);
80
+ await mkdir(dirname(cached), {recursive: true});
81
+ await writeFile(cached, downloaded);
82
+ bytes = downloaded;
83
+ }
84
+
85
+ await mkdir(dirname(destination), {recursive: true});
86
+ await writeFile(destination, bytes);
87
+ log.success(`${component.namespaced} to ${relative(config.root, destination)}`);
88
+ log.detail(` ${component.family} · ${Math.round(asset.bytes / 1024)} KB · answers to "${asset.cue}"`);
89
+ return true;
90
+ };
package/src/brand-file.ts CHANGED
@@ -48,9 +48,13 @@ const brandFiles = async (config: ResolvedConfig): Promise<string[]> => {
48
48
  */
49
49
  export const registerCueInBrand = async (
50
50
  config: ResolvedConfig,
51
- cue: {name: string; export: string},
51
+ cue: {name: string; export: string} | {name: string; url: string},
52
52
  componentName: string,
53
53
  ): Promise<BrandRegistration | null> => {
54
+ // A score is registered by calling its factory, which needs an import. A
55
+ // recording is registered by its URL, which needs nothing: the value is the
56
+ // path the dev server and the render worker both serve it from.
57
+ const url = "url" in cue ? cue.url : null;
54
58
  for (const file of await brandFiles(config)) {
55
59
  const source = await readFile(file, "utf8");
56
60
  if (source.includes(`"${cue.name}"`) || source.includes(`'${cue.name}'`)) {
@@ -62,26 +66,34 @@ export const registerCueInBrand = async (
62
66
  const block = source.match(/(audio:\s*\{[\s\S]*?cues:\s*\{)([\s\S]*?)(\n(\s*)\},)/);
63
67
  const inline = source.match(/(audio:\s*\{[^\n}]*cues:\s*\{)([^\n{}]*)(\})/);
64
68
 
69
+ const value = url ? JSON.stringify(url) : `${(cue as {export: string}).export}()`;
70
+
65
71
  let withCue: string;
66
72
  if (block) {
67
73
  const indent = `${block[4]} `;
68
- const entry = `\n${indent}"${cue.name}": ${cue.export}(),`;
74
+ const entry = `\n${indent}"${cue.name}": ${value},`;
69
75
  withCue = source.replace(block[0], `${block[1]}${block[2]}${entry}${block[3]}`);
70
76
  } else if (inline) {
71
77
  // An inline block grows in place rather than being reformatted: this
72
78
  // edits somebody's source, and reflowing their file is not the job.
73
79
  const existing = inline[2].trim();
74
- const entry = `"${cue.name}": ${cue.export}()`;
80
+ const entry = `"${cue.name}": ${value}`;
75
81
  withCue = source.replace(inline[0], `${inline[1]}${existing ? `${existing.replace(/,$/, "")}, ` : ""}${entry}${inline[3]}`);
76
82
  } else {
77
83
  continue;
78
84
  }
79
85
 
86
+ // A URL needs no import, so the edit is finished.
87
+ if (url) {
88
+ await writeFile(file, withCue, "utf8");
89
+ return {file: relative(config.root, file), already: false};
90
+ }
91
+
80
92
  // The import path is relative to the brand file, which is usually the
81
93
  // layout beside videos/components.
82
94
  const from = resolve(config.root, config.componentsDir, componentName, componentName);
83
95
  const specifier = relative(resolve(file, ".."), from).split("\\").join("/");
84
- const importLine = `import {${cue.export}} from "${specifier.startsWith(".") ? specifier : `./${specifier}`}";`;
96
+ const importLine = `import {${(cue as {export: string}).export}} from "${specifier.startsWith(".") ? specifier : `./${specifier}`}";`;
85
97
  const withImport = withCue.includes(importLine)
86
98
  ? withCue
87
99
  : withCue.replace(/^(import [\s\S]*?;\n)/, `$1${importLine}\n`);
package/src/cli.ts CHANGED
@@ -10,7 +10,7 @@ import {initCommand} from "./commands/init";
10
10
  import {inspectCommand} from "./commands/inspect";
11
11
  import {listCommand} from "./commands/list";
12
12
  import {newCommand} from "./commands/new";
13
- import {stillCommand} from "./commands/still";
13
+ import {frameCommand} from "./commands/frame";
14
14
  import {testCommand} from "./commands/test";
15
15
 
16
16
  type Flags = Record<string, string | boolean>;
@@ -84,7 +84,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
84
84
  update: ["force"],
85
85
  list: [],
86
86
  inspect: ["json", "input"],
87
- still: ["frame", "output", "input"],
87
+ frame: ["at", "output", "input"],
88
88
  test: ["json"],
89
89
  export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
90
90
  jobs: [],
@@ -160,8 +160,9 @@ const USAGE: Record<string, string> = {
160
160
  Print discovered video ids and formats.`,
161
161
  inspect: `odori inspect <id> [--json] [--input <json>]
162
162
  Show resolved layout, inputs, scenes, and assets.`,
163
- still: `odori still <id> --frame <n> [--output <path>] [--input <json>]
164
- Render one deterministic frame.`,
163
+ frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
164
+ Render one deterministic frame to a PNG. --at is a duration: 4s, 500ms, or
165
+ 120f for frame 120. A bare number is seconds.`,
165
166
  test: `odori test [id] [--json]
166
167
  Validate contracts and representative frames. --json emits one object per
167
168
  check, for CI.`,
@@ -190,14 +191,14 @@ Usage
190
191
  odori update [components] Apply upstream component changes
191
192
  odori list Print discovered video ids and formats
192
193
  odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
193
- odori still <id> --frame 120 Render one deterministic frame
194
+ odori frame <id> --at 4s Render one deterministic frame to a PNG
194
195
  odori test [id] [--json] Validate contracts and representative frames
195
196
  odori export <id> [--output f] Render and encode a distributable file
196
197
  odori jobs List export jobs and their status
197
198
 
198
199
  Options
199
200
  --input '{"headline":"..."}' Serializable input for the video schema
200
- --output <path> Output path for still and export
201
+ --output <path> Output path for frame and export
201
202
  --force Replace locally modified component source
202
203
  --concurrency <n> Parallel render workers for export
203
204
  --preset <name> x264 preset for export, default medium
@@ -280,9 +281,11 @@ export const run = async (argv: string[]): Promise<number> => {
280
281
  case "inspect":
281
282
  await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
282
283
  return 0;
283
- case "still":
284
- await stillCommand(positionals[0] ?? "", {
285
- frame: numberFlag(flags, "frame") ?? 0,
284
+ case "frame":
285
+ await frameCommand(positionals[0] ?? "", {
286
+ // A duration, so "4s" and "120f" both work; a bare number is
287
+ // seconds, the way every other time value in Odori reads.
288
+ at: typeof flags.at === "string" ? flags.at : (numberFlag(flags, "at") ?? 0),
286
289
  output: typeof flags.output === "string" ? flags.output : undefined,
287
290
  input: parseInput(flags),
288
291
  });
@@ -6,6 +6,7 @@ import {loadConfig} from "../config";
6
6
  import {log} from "../log";
7
7
  import {registerCueInBrand} from "../brand-file";
8
8
  import {assertSafeName, normalizeComponentName, registryUrl, resolveItem, resolveRegistry, resolveWithinRoot, verifyIntegrity} from "../registry-source";
9
+ import {installAsset} from "../assets";
9
10
  import {readProvenance, writeProvenance} from "./update";
10
11
 
11
12
  /**
@@ -46,6 +47,30 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
46
47
  else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
47
48
  }
48
49
 
50
+ // An asset is bytes, not source: it is fetched, verified, and written into
51
+ // public/, then registered by URL. Nothing about it is a file to edit, so
52
+ // it never goes through the copy-and-record path below.
53
+ if (component.kind === "asset" && component.asset) {
54
+ const written = await installAsset(config, component, {dryRun: options.dryRun, force: options.force});
55
+ if (!options.dryRun && written) {
56
+ installed.push(component.name);
57
+ const registered = await registerCueInBrand(
58
+ config,
59
+ {name: component.asset.cue, url: component.asset.url},
60
+ component.name,
61
+ );
62
+ if (registered?.already) {
63
+ log.detail(` "${component.asset.cue}" is already registered in ${registered.file}`);
64
+ } else if (registered) {
65
+ log.detail(` registered "${component.asset.cue}" in ${registered.file}`);
66
+ } else {
67
+ log.warn(` No brand with an audio.cues block found. Add it yourself:`);
68
+ log.detail(` audio: {cues: {"${component.asset.cue}": "${component.asset.url}"}}`);
69
+ }
70
+ }
71
+ continue;
72
+ }
73
+
49
74
  // The item document carries the file contents; the index does not.
50
75
  const {item, origin} = await resolveItem(config, component.name);
51
76
  // Before anything is written: the bytes have to be the bytes the registry
@@ -1,4 +1,4 @@
1
- import {resolve} from "node:path";
1
+ import {relative, resolve} from "node:path";
2
2
  import {homedir} from "node:os";
3
3
  import {existsSync} from "node:fs";
4
4
  import {readFile} from "node:fs/promises";
@@ -208,6 +208,33 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
208
208
  return;
209
209
  }
210
210
 
211
+ /**
212
+ * The source that produced the frame you are looking at.
213
+ *
214
+ * Studio shows the path already; a path you cannot read is a
215
+ * riddle. Reading is confined to source files inside the project,
216
+ * because this serves whatever a query string asks for and the
217
+ * only safe answer to "../../.ssh/id_rsa" is no.
218
+ */
219
+ if (request.method === "GET" && url.startsWith("/source")) {
220
+ const asked = new URL(url, "http://localhost").searchParams.get("file") ?? "";
221
+ const file = resolve(config.root, asked);
222
+ const inside = relative(config.root, file);
223
+ const readable = /\.(tsx?|jsx?|css|json|md)$/.test(file);
224
+ if (!inside || inside.startsWith("..") || !readable) {
225
+ json(response, 400, {error: `Refusing to read ${asked}.`});
226
+ return;
227
+ }
228
+ try {
229
+ response.statusCode = 200;
230
+ response.setHeader("content-type", "text/plain; charset=utf-8");
231
+ response.end(await readFile(file, "utf8"));
232
+ } catch {
233
+ json(response, 404, {error: `${asked} is not there.`});
234
+ }
235
+ return;
236
+ }
237
+
211
238
  if (request.method === "GET" && url.startsWith("/jobs")) {
212
239
  json(response, 200, await listJobs(config));
213
240
  return;
@@ -12,7 +12,14 @@ export type Check = {
12
12
  /** What was found. Printed on success and on failure alike. */
13
13
  detail: string;
14
14
  ok: boolean;
15
- /** The command or edit that fixes it. Only read when `ok` is false. */
15
+ /**
16
+ * True for something that passes but is worth saying. A project is not
17
+ * broken by it and CI should not fail on it, which is exactly why it needs
18
+ * somewhere to be said: otherwise the only two volumes are silence and
19
+ * failure, and everything that deserves a word in between gets silence.
20
+ */
21
+ warn?: boolean;
22
+ /** The command or edit that fixes it. Only read when `ok` is false or warned. */
16
23
  fix?: string;
17
24
  };
18
25
 
@@ -125,6 +132,39 @@ export const runChecks = async (root: string): Promise<Check[]> => {
125
132
  } catch {
126
133
  writable = false;
127
134
  }
135
+ /**
136
+ * A component nobody can preview.
137
+ *
138
+ * Discovery is filename driven: a sibling `*.preview.tsx` is what makes a
139
+ * component exist to Studio, to the catalog and to the palette. Write one
140
+ * without it and it works perfectly inside its video and is invisible
141
+ * everywhere else, including to the person who has to tune it next week.
142
+ * That is a fine choice for a one-off set piece and an accident the rest of
143
+ * the time, so this counts them rather than ruling on them.
144
+ */
145
+ const componentsRoot = resolve(root, config.componentsDir);
146
+ const orphans: string[] = [];
147
+ if (existsSync(componentsRoot)) {
148
+ const {readdir} = await import("node:fs/promises");
149
+ for (const entry of await readdir(componentsRoot, {withFileTypes: true})) {
150
+ if (!entry.isDirectory()) continue;
151
+ const files = await readdir(resolve(componentsRoot, entry.name));
152
+ const source = files.some((file) => /\.tsx$/.test(file) && !file.endsWith(".preview.tsx"));
153
+ const fixture = files.some((file) => file.endsWith(".preview.tsx"));
154
+ if (source && !fixture) orphans.push(entry.name);
155
+ }
156
+ }
157
+ checks.push({
158
+ name: "Component previews",
159
+ detail:
160
+ orphans.length === 0
161
+ ? "every component has a fixture"
162
+ : `${orphans.length} without a fixture: ${orphans.slice(0, 4).join(", ")}${orphans.length > 4 ? ", …" : ""}`,
163
+ ok: true,
164
+ warn: orphans.length > 0,
165
+ 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.`,
166
+ });
167
+
128
168
  checks.push({
129
169
  name: "Generated cache",
130
170
  detail: writable ? ".odori/ is writable" : ".odori/ cannot be written",
@@ -146,13 +186,18 @@ export const doctorCommand = async (root = process.cwd()): Promise<number> => {
146
186
  log.title("odori doctor");
147
187
  for (const check of checks) {
148
188
  const label = check.name.padEnd(width);
149
- if (check.ok) log.success(`${label} ${check.detail}`);
189
+ if (check.ok && check.warn) log.warn(`${label} ${check.detail}`);
190
+ else if (check.ok) log.success(`${label} ${check.detail}`);
150
191
  else log.error(`${label} ${check.detail}`);
151
192
  }
152
193
 
194
+ const warned = checks.filter((check) => check.ok && check.warn);
153
195
  const failed = checks.filter((check) => !check.ok);
154
196
  if (failed.length === 0) {
155
197
  log.detail("Everything a render needs is present.");
198
+ // A warning is not a failure, so it is said after the all clear rather
199
+ // than instead of it, and it never changes the exit code.
200
+ for (const check of warned) log.detail(` ${check.fix}`);
156
201
  return 0;
157
202
  }
158
203
 
@@ -1,23 +1,37 @@
1
1
  import {resolve} from "node:path";
2
+ import {framesFromOffset, resolveEntryLayout, type Duration} from "odori";
2
3
  import {log} from "../log";
3
4
  import {findVideo, freezeManifest, outputName} from "../project";
4
5
  import {renderStill} from "../render";
5
6
  import {compileInBrowser, createContext, targetFor, withServer} from "./shared";
6
7
 
7
- export const stillCommand = async (
8
+ /**
9
+ * One frame of a video, as a PNG.
10
+ *
11
+ * `at` is a duration like everything else in Odori: a bare number is seconds,
12
+ * and `120f` is frame 120. Which frame that resolves to depends on the
13
+ * video's frame rate, and the frame rate belongs to the video's layout, so
14
+ * the position is checked for shape first and resolved once the video is
15
+ * loaded. A typo still costs nothing: no discovery, no dev server, no
16
+ * browser.
17
+ */
18
+ export const frameCommand = async (
8
19
  id: string,
9
- options: {frame?: number; output?: string; input?: Record<string, unknown>} = {},
20
+ options: {at?: Duration; output?: string; input?: Record<string, unknown>} = {},
10
21
  ) => {
11
- const frame = options.frame ?? 0;
12
- // A frame is an index into the timeline. Checking it first means a typo
13
- // costs nothing: no discovery, no dev server, no browser.
14
- if (!Number.isInteger(frame) || frame < 0) {
15
- throw new Error(`Frame must be a whole number of frames from the start, got ${String(frame)}.`);
16
- }
22
+ const at = options.at ?? 0;
23
+ // A position, not a length: frame zero is the first frame, so this is
24
+ // framesFromOffset rather than framesFromDuration, which floors at one. Any
25
+ // frame rate rejects a malformed duration, so this validates the shape
26
+ // before the work starts; the real rate resolves the number below.
27
+ framesFromOffset(at, 30);
17
28
 
18
29
  const {config, graph, videos} = await createContext();
19
30
  const video = findVideo(videos, id);
20
31
 
32
+ const fps = resolveEntryLayout(video.entry).format.fps;
33
+ const frame = framesFromOffset(at, fps);
34
+
21
35
  const output = await withServer(config, async (server) => {
22
36
  const {durationInFrames, scenes, audio} = await compileInBrowser(server.url, targetFor(video, options.input), config);
23
37
  const {manifest, input, prepared} = await freezeManifest(
@@ -35,6 +49,6 @@ export const stillCommand = async (
35
49
  return renderStill(server.url, target, frame, file, config);
36
50
  });
37
51
 
38
- log.success(`Still frame ${frame} written to ${output}`);
52
+ log.success(`Frame ${frame} written to ${output}`);
39
53
  return output;
40
54
  };
package/src/discovery.ts CHANGED
@@ -3,6 +3,7 @@ import {existsSync} from "node:fs";
3
3
  import {join, relative, resolve, sep} from "node:path";
4
4
  import {hashString} from "odori";
5
5
  import type {ResolvedConfig} from "./config";
6
+ import {log} from "./log";
6
7
 
7
8
  export type DiscoveredVideo = {
8
9
  /** Directory-derived id used before the module is loaded. */
@@ -19,6 +20,8 @@ export type DiscoveredPreview = {
19
20
  relativeFile: string;
20
21
  importPath: string;
21
22
  identifier: string;
23
+ /** Video ids whose composition imports this component. Derived, not declared. */
24
+ usedBy?: string[];
22
25
  };
23
26
 
24
27
  export type DiscoveredBrandModule = {
@@ -37,11 +40,26 @@ export type DiscoveredAudio = {
37
40
  bytes: number;
38
41
  };
39
42
 
43
+ /**
44
+ * A directory naming itself, from a `category.json` beside the components it
45
+ * holds. The path is what the filesystem already says; this is only how that
46
+ * level should read and where it should sit among its siblings.
47
+ */
48
+ export type DiscoveredCategory = {
49
+ /** Directory path under the components directory: `product-ui/forms`. */
50
+ path: string;
51
+ /** What to call it. Without one, the directory name is read as words. */
52
+ name?: string;
53
+ /** Sort position among siblings. Unset sorts after, alphabetically. */
54
+ order?: number;
55
+ };
56
+
40
57
  export type ProjectGraph = {
41
58
  videos: DiscoveredVideo[];
42
59
  previews: DiscoveredPreview[];
43
60
  brands: DiscoveredBrandModule[];
44
61
  audio: DiscoveredAudio[];
62
+ categories: DiscoveredCategory[];
45
63
  sourceHash: string;
46
64
  };
47
65
 
@@ -107,7 +125,9 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
107
125
  const videos: DiscoveredVideo[] = [];
108
126
  const previews: DiscoveredPreview[] = [];
109
127
  const brands: DiscoveredBrandModule[] = [];
128
+ const categories: DiscoveredCategory[] = [];
110
129
  const hashParts: string[] = [];
130
+ const importedBy: Record<string, Set<string>> = {};
111
131
  const componentsRoot = resolve(config.root, config.componentsDir);
112
132
 
113
133
  for (const file of files) {
@@ -119,6 +139,38 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
119
139
  }
120
140
 
121
141
  const base = file.split(sep).pop() ?? "";
142
+ if (base === "category.json") {
143
+ // Beside the components it holds, so a label lives with the thing it
144
+ // labels. A malformed one is reported and skipped: a typo in a display
145
+ // name must not take the project down.
146
+ const path = relative(componentsRoot, resolve(file, "..")).split(sep).join("/");
147
+ if (!path.startsWith("..")) {
148
+ try {
149
+ const declared = JSON.parse(contents) as {name?: unknown; order?: unknown};
150
+ categories.push({
151
+ path,
152
+ ...(typeof declared.name === "string" ? {name: declared.name} : {}),
153
+ ...(typeof declared.order === "number" ? {order: declared.order} : {}),
154
+ });
155
+ } catch (error) {
156
+ log.warn(
157
+ `${relativeFile} is not valid JSON, so that directory names itself: ${
158
+ error instanceof Error ? error.message : String(error)
159
+ }`,
160
+ );
161
+ }
162
+ }
163
+ }
164
+
165
+ if (base === "video.tsx") {
166
+ // The file is already read for the source hash, so the import graph
167
+ // costs nothing to take. A component's audience is a fact about the
168
+ // project rather than something an author should have to restate, and
169
+ // a restated one goes stale the moment a scene is deleted.
170
+ for (const match of contents.matchAll(/from\s+["'][^"']*\/components\/([^/"']+)\//g)) {
171
+ (importedBy[match[1]] ??= new Set()).add(relative(videosRoot, file).replace(/\/?video\.tsx$/, "") || "video");
172
+ }
173
+ }
122
174
  if (base === "video.tsx") {
123
175
  // The path under videos/ is the id, the way a route is a path.
124
176
  const slug = relative(videosRoot, file).replace(/\/?video\.tsx$/, "").split(sep).join("/") || "video";
@@ -166,7 +218,12 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
166
218
  }
167
219
  }
168
220
 
169
- return {videos, previews, brands, audio, sourceHash: hashString(hashParts.join("|"))};
221
+ for (const preview of previews) {
222
+ const users = importedBy[preview.name];
223
+ if (users) preview.usedBy = [...users].sort();
224
+ }
225
+
226
+ return {videos, previews, brands, audio, categories, sourceHash: hashString(hashParts.join("|"))};
170
227
  };
171
228
 
172
229
  /**
@@ -222,7 +279,11 @@ export const writeGenerated = async (config: ResolvedConfig, graph: ProjectGraph
222
279
  {
223
280
  sourceHash: graph.sourceHash,
224
281
  videos: graph.videos.map((video) => ({slug: video.slug, file: video.relativeFile})),
225
- previews: graph.previews.map((preview) => ({name: preview.name, file: preview.relativeFile})),
282
+ previews: graph.previews.map((preview) => ({
283
+ name: preview.name,
284
+ file: preview.relativeFile,
285
+ usedBy: preview.usedBy ?? [],
286
+ })),
226
287
  brands: graph.brands.map((brand) => ({name: brand.name, file: brand.relativeFile})),
227
288
  audio: graph.audio.map((entry) => ({name: entry.name, url: entry.url, file: entry.relativeFile})),
228
289
  },
package/src/index.ts CHANGED
@@ -46,7 +46,7 @@ export {clearPrepareCache, prepareCacheKey, readPrepareCache, writePrepareCache}
46
46
  export {devCommand} from "./commands/dev";
47
47
  export {cancelJob, exportCommand, jobsCommand, runJob, exportQueue} from "./commands/exportVideo";
48
48
  export {chunkFrames, planChunks, type ChunkPlan, type FrameChunk} from "./chunks";
49
- export {stillCommand} from "./commands/still";
49
+ export {frameCommand} from "./commands/frame";
50
50
  export {testCommand} from "./commands/test";
51
51
  export {listCommand} from "./commands/list";
52
52
  export {inspectCommand} from "./commands/inspect";