@odori/cli 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/{chunk-RXLB2CXH.js → chunk-RHG23EWW.js} +457 -241
  2. package/dist/cli.js +1 -1
  3. package/dist/index.d.ts +45 -5
  4. package/dist/index.js +3 -3
  5. package/dist/registry-snapshot-JEVXYGS2.js +4868 -0
  6. package/package.json +3 -3
  7. package/src/assets.ts +90 -0
  8. package/src/brand-file.ts +16 -4
  9. package/src/cli.ts +38 -13
  10. package/src/commands/add.ts +37 -1
  11. package/src/commands/dev.ts +32 -2
  12. package/src/commands/doctor.ts +47 -2
  13. package/src/commands/exportVideo.ts +6 -0
  14. package/src/commands/{still.ts → frame.ts} +23 -9
  15. package/src/commands/update.ts +58 -7
  16. package/src/discovery.ts +63 -2
  17. package/src/index.ts +1 -1
  18. package/src/jobs.ts +1 -1
  19. package/src/registry-snapshot.json +1529 -327
  20. package/src/registry-source.ts +37 -2
  21. package/src/render.ts +8 -1
  22. package/src/server.ts +7 -1
  23. package/studio/src/Studio.tsx +6 -22
  24. package/studio/src/components/ExportPanel.tsx +90 -6
  25. package/studio/src/components/Inspector.tsx +101 -1
  26. package/studio/src/components/Navigator.tsx +149 -0
  27. package/studio/src/components/Settings.tsx +109 -0
  28. package/studio/src/components/Transport.tsx +98 -55
  29. package/studio/src/components/ui.tsx +16 -1
  30. package/studio/src/lib/highlight.ts +85 -0
  31. package/studio/src/settings.ts +87 -0
  32. package/studio/src/studio.css +350 -8
  33. package/studio/src/views/BrandsView.tsx +18 -1
  34. package/studio/src/views/ComponentsView.tsx +191 -26
  35. package/studio/src/views/HomeView.tsx +7 -4
  36. package/studio/src/views/VideosView.tsx +33 -6
  37. package/studio/src/virtual.d.ts +4 -1
  38. package/dist/registry-snapshot-BDP6PVYB.js +0 -3559
@@ -1,5 +1,6 @@
1
- import {mkdir, readFile, writeFile} from "node:fs/promises";
1
+ import {mkdir, readFile, rm, writeFile} from "node:fs/promises";
2
2
  import {existsSync} from "node:fs";
3
+ import {readdir} from "node:fs/promises";
3
4
  import {relative, resolve} from "node:path";
4
5
  import {hashString} from "odori";
5
6
  import {loadConfig, type ResolvedConfig} from "../config";
@@ -28,17 +29,45 @@ export type ComponentStatus = {
28
29
  }>;
29
30
  };
30
31
 
31
- const provenanceFile = (config: ResolvedConfig) => resolve(config.root, config.outDir, "components.json");
32
+ /**
33
+ * The record of what was installed, and at which version.
34
+ *
35
+ * It sits beside the config and is meant to be committed. It used to live in
36
+ * `outDir`, which is generated and gitignored, so it did not survive a clone
37
+ * or a cleared render cache: `odori update` and `odori diff` would report an
38
+ * empty project while the components sat right there in the tree, and the
39
+ * suggested fix - run `odori add` - was the one thing that had already been
40
+ * done. A lockfile is only useful to the next person if it is in the repo.
41
+ */
42
+ export const LOCKFILE = "odori.lock.json";
43
+
44
+ const provenanceFile = (config: ResolvedConfig) => resolve(config.root, LOCKFILE);
45
+
46
+ /** Where it used to live, read once so an existing project keeps its history. */
47
+ const legacyProvenanceFile = (config: ResolvedConfig) =>
48
+ resolve(config.root, config.outDir, "components.json");
32
49
 
33
50
  export const readProvenance = async (config: ResolvedConfig): Promise<Provenance> => {
34
- const file = provenanceFile(config);
51
+ const file = existsSync(provenanceFile(config))
52
+ ? provenanceFile(config)
53
+ : legacyProvenanceFile(config);
35
54
  if (!existsSync(file)) return {};
36
- return JSON.parse(await readFile(file, "utf8")) as Provenance;
55
+ try {
56
+ return JSON.parse(await readFile(file, "utf8")) as Provenance;
57
+ } catch {
58
+ // A truncated or hand-mangled lockfile should cost you the update, not the
59
+ // command: treat it as unknown and let the next install rewrite it.
60
+ log.warn(`${relative(config.root, file)} is not readable JSON. Ignoring it.`);
61
+ return {};
62
+ }
37
63
  };
38
64
 
39
65
  export const writeProvenance = async (config: ResolvedConfig, provenance: Provenance) => {
40
- await mkdir(resolve(config.root, config.outDir), {recursive: true});
66
+ await mkdir(config.root, {recursive: true});
41
67
  await writeFile(provenanceFile(config), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
68
+ // Moved, not copied: leaving the old one behind means the next reader has to
69
+ // guess which of the two is current.
70
+ await rm(legacyProvenanceFile(config), {force: true});
42
71
  };
43
72
 
44
73
  /**
@@ -109,11 +138,33 @@ const LABELS: Record<ComponentState, string> = {
109
138
  missing: "files missing",
110
139
  };
111
140
 
141
+ /**
142
+ * What to say when there is nothing to compare against.
143
+ *
144
+ * "Run odori add first" is the wrong advice if the components are already on
145
+ * disk: the missing piece is the lockfile, not the install, and telling
146
+ * somebody to do the thing they just did is how a tool loses their trust.
147
+ */
148
+ const explainEmpty = async (config: ResolvedConfig, named: string[]) => {
149
+ if (named.length > 0) {
150
+ log.detail(`${named.join(", ")} ${named.length === 1 ? "is" : "are"} not recorded in ${LOCKFILE}.`);
151
+ return;
152
+ }
153
+ const components = resolve(config.root, config.componentsDir);
154
+ const installed = existsSync(components) ? (await readdir(components)).filter((e) => !e.startsWith(".")) : [];
155
+ if (installed.length === 0) {
156
+ log.detail("No registry components are installed yet. Run odori add first.");
157
+ return;
158
+ }
159
+ log.warn(`${installed.length} components are in ${config.componentsDir} but none are recorded in ${LOCKFILE}.`);
160
+ log.detail("Run odori add <name> to re-record them, or commit the lockfile if a teammate has one.");
161
+ };
162
+
112
163
  export const diffCommand = async (names: string[], options: {full?: boolean} = {}) => {
113
164
  const config = await loadConfig(process.cwd());
114
165
  const statuses = await componentStatus(config, names.length > 0 ? names : undefined);
115
166
  if (statuses.length === 0) {
116
- log.detail("No registry components are installed yet. Run odori add first.");
167
+ await explainEmpty(config, names);
117
168
  return;
118
169
  }
119
170
 
@@ -141,7 +192,7 @@ export const updateCommand = async (names: string[], options: {force?: boolean}
141
192
  const config = await loadConfig(process.cwd());
142
193
  const statuses = await componentStatus(config, names.length > 0 ? names : undefined);
143
194
  if (statuses.length === 0) {
144
- log.detail("No registry components are installed yet. Run odori add first.");
195
+ await explainEmpty(config, names);
145
196
  return;
146
197
  }
147
198
 
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";
package/src/jobs.ts CHANGED
@@ -5,7 +5,7 @@ import type {ExportJob, RenderManifest} from "odori";
5
5
  import type {ResolvedConfig} from "./config";
6
6
 
7
7
  /** How a job should be encoded, frozen with it so a retry cannot drift. */
8
- export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string};
8
+ export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string; audio?: boolean};
9
9
 
10
10
  export type JobRecord = {job: ExportJob; manifest: RenderManifest; output: string; render?: JobRender};
11
11