@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
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.5"
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.5"
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,11 +10,24 @@ 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>;
17
17
 
18
+ /**
19
+ * Flags that are on or off, and therefore never take the next word.
20
+ *
21
+ * Without this list `odori add --force title-reveal` reads "force" as the
22
+ * string "title-reveal": the flag does nothing and the component quietly
23
+ * vanishes from the list, which is two failures for the price of one and
24
+ * neither of them says anything. A switch has no value to take.
25
+ */
26
+ const BOOLEAN_FLAGS = new Set(["force", "dry-run", "json", "no-audio", "no-frame-skip", "no-open", "open", "help", "version"]);
27
+
28
+ /** Commands that used to exist under another name. */
29
+ const RENAMED: Record<string, string> = {still: "frame"};
30
+
18
31
  export const parseArgs = (argv: string[]): {command: string; positionals: string[]; flags: Flags} => {
19
32
  const [command = "help", ...rest] = argv;
20
33
  const positionals: string[] = [];
@@ -31,7 +44,7 @@ export const parseArgs = (argv: string[]): {command: string; positionals: string
31
44
  }
32
45
  const name = token.slice(2);
33
46
  const next = rest[index + 1];
34
- if (next === undefined || next.startsWith("--")) flags[name] = true;
47
+ if (BOOLEAN_FLAGS.has(name) || next === undefined || next.startsWith("--")) flags[name] = true;
35
48
  else {
36
49
  flags[name] = next;
37
50
  index += 1;
@@ -84,9 +97,9 @@ const COMMAND_FLAGS: Record<string, string[]> = {
84
97
  update: ["force"],
85
98
  list: [],
86
99
  inspect: ["json", "input"],
87
- still: ["frame", "output", "input"],
100
+ frame: ["at", "output", "input"],
88
101
  test: ["json"],
89
- export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
102
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "no-frame-skip", "retry"],
90
103
  jobs: [],
91
104
  help: [],
92
105
  };
@@ -160,18 +173,20 @@ const USAGE: Record<string, string> = {
160
173
  Print discovered video ids and formats.`,
161
174
  inspect: `odori inspect <id> [--json] [--input <json>]
162
175
  Show resolved layout, inputs, scenes, and assets.`,
163
- still: `odori still <id> --frame <n> [--output <path>] [--input <json>]
164
- Render one deterministic frame.`,
176
+ frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
177
+ Render one deterministic frame to a PNG. --at is a duration: 4s, 500ms, or
178
+ 120f for frame 120. A bare number is seconds.`,
165
179
  test: `odori test [id] [--json]
166
180
  Validate contracts and representative frames. --json emits one object per
167
181
  check, for CI.`,
168
182
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
169
183
  [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
170
- [--no-frame-skip] [--retry <job>]
184
+ [--no-audio] [--no-frame-skip] [--retry <job>]
171
185
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
172
186
  or png; without it the output's extension decides, and mp4 is the default.
173
187
  --quality is studio, social, or web. --scale multiplies the output size,
174
- 0.25 to 2. A retry keeps the settings its job was created with.`,
188
+ 0.25 to 2. --no-audio writes the picture with no sound. A retry keeps the
189
+ settings its job was created with.`,
175
190
  jobs: `odori jobs
176
191
  List export jobs and their status.`,
177
192
  };
@@ -190,14 +205,14 @@ Usage
190
205
  odori update [components] Apply upstream component changes
191
206
  odori list Print discovered video ids and formats
192
207
  odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
193
- odori still <id> --frame 120 Render one deterministic frame
208
+ odori frame <id> --at 4s Render one deterministic frame to a PNG
194
209
  odori test [id] [--json] Validate contracts and representative frames
195
210
  odori export <id> [--output f] Render and encode a distributable file
196
211
  odori jobs List export jobs and their status
197
212
 
198
213
  Options
199
214
  --input '{"headline":"..."}' Serializable input for the video schema
200
- --output <path> Output path for still and export
215
+ --output <path> Output path for frame and export
201
216
  --force Replace locally modified component source
202
217
  --concurrency <n> Parallel render workers for export
203
218
  --preset <name> x264 preset for export, default medium
@@ -280,9 +295,11 @@ export const run = async (argv: string[]): Promise<number> => {
280
295
  case "inspect":
281
296
  await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
282
297
  return 0;
283
- case "still":
284
- await stillCommand(positionals[0] ?? "", {
285
- frame: numberFlag(flags, "frame") ?? 0,
298
+ case "frame":
299
+ await frameCommand(positionals[0] ?? "", {
300
+ // A duration, so "4s" and "120f" both work; a bare number is
301
+ // seconds, the way every other time value in Odori reads.
302
+ at: typeof flags.at === "string" ? flags.at : (numberFlag(flags, "at") ?? 0),
286
303
  output: typeof flags.output === "string" ? flags.output : undefined,
287
304
  input: parseInput(flags),
288
305
  });
@@ -299,6 +316,7 @@ export const run = async (argv: string[]): Promise<number> => {
299
316
  quality: typeof flags.quality === "string" ? flags.quality : undefined,
300
317
  scale: numberFlag(flags, "scale"),
301
318
  format: typeof flags.format === "string" ? flags.format : undefined,
319
+ audio: flags["no-audio"] === true ? false : undefined,
302
320
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
303
321
  retry: typeof flags.retry === "string" ? flags.retry : undefined,
304
322
  });
@@ -312,6 +330,13 @@ export const run = async (argv: string[]): Promise<number> => {
312
330
  log.info(HELP);
313
331
  return 0;
314
332
  default: {
333
+ // A rename is not a typo, and edit distance will never connect the old
334
+ // name to the new one. Say what happened instead of listing everything.
335
+ const renamed = RENAMED[command];
336
+ if (renamed) {
337
+ log.error(`"odori ${command}" is now "odori ${renamed}".`);
338
+ return 1;
339
+ }
315
340
  const commands = Object.keys(COMMAND_FLAGS).filter((name) => name !== "help");
316
341
  const suggestion = nearest(command, commands);
317
342
  log.error(`Unknown command "${command}".${suggestion ? ` Did you mean "odori ${suggestion}"?` : ""}`);
@@ -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
  /**
@@ -29,6 +30,10 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
29
30
 
30
31
  const queue = [...names.map(normalizeComponentName)];
31
32
  const installed: string[] = [];
33
+ /* Kept so the summary can repeat itself at the end. Installing a library is
34
+ hundreds of lines of output, and a warning at line 40 is a warning nobody
35
+ read. */
36
+ const kept: string[] = [];
32
37
 
33
38
  while (queue.length > 0) {
34
39
  const name = queue.shift()!;
@@ -46,6 +51,30 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
46
51
  else log.warn(`${component.namespaced} needs a "${cue}" cue and no registry entry provides one.`);
47
52
  }
48
53
 
54
+ // An asset is bytes, not source: it is fetched, verified, and written into
55
+ // public/, then registered by URL. Nothing about it is a file to edit, so
56
+ // it never goes through the copy-and-record path below.
57
+ if (component.kind === "asset" && component.asset) {
58
+ const written = await installAsset(config, component, {dryRun: options.dryRun, force: options.force});
59
+ if (!options.dryRun && written) {
60
+ installed.push(component.name);
61
+ const registered = await registerCueInBrand(
62
+ config,
63
+ {name: component.asset.cue, url: component.asset.url},
64
+ component.name,
65
+ );
66
+ if (registered?.already) {
67
+ log.detail(` "${component.asset.cue}" is already registered in ${registered.file}`);
68
+ } else if (registered) {
69
+ log.detail(` registered "${component.asset.cue}" in ${registered.file}`);
70
+ } else {
71
+ log.warn(` No brand with an audio.cues block found. Add it yourself:`);
72
+ log.detail(` audio: {cues: {"${component.asset.cue}": "${component.asset.url}"}}`);
73
+ }
74
+ }
75
+ continue;
76
+ }
77
+
49
78
  // The item document carries the file contents; the index does not.
50
79
  const {item, origin} = await resolveItem(config, component.name);
51
80
  // Before anything is written: the bytes have to be the bytes the registry
@@ -78,7 +107,8 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
78
107
  const current = hashString(await readFile(destination, "utf8"));
79
108
  const recorded = provenance[component.name]?.hashes[name];
80
109
  if (current !== recorded) {
81
- log.warn(`${relative(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
110
+ log.warn(`${relative(config.root, destination)} was modified locally. Keeping your version.`);
111
+ kept.push(relative(config.root, destination));
82
112
  continue;
83
113
  }
84
114
  }
@@ -122,6 +152,12 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
122
152
  return;
123
153
  }
124
154
  await writeProvenance(config, provenance);
155
+
156
+ if (kept.length > 0) {
157
+ log.warn(`Kept ${kept.length} locally modified ${kept.length === 1 ? "file" : "files"}:`);
158
+ for (const file of kept) log.detail(` ${file}`);
159
+ log.detail(`Run odori diff to see what upstream changed, or odori add <name> --force to replace them.`);
160
+ }
125
161
  log.detail("Run odori dev to preview the installed component fixtures.");
126
162
  };
127
163
 
@@ -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";
@@ -174,7 +174,10 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
174
174
  exportDestination(config),
175
175
  `${outputName(video.entry.metadata.id)}${format.extension}`,
176
176
  );
177
- const record = await createJob(config, manifest, output, {format: format.name, quality, scale});
177
+ // Frozen with the job: a retry from the CLI has to produce the
178
+ // same file, silence included.
179
+ const audio = body.audio !== false;
180
+ const record = await createJob(config, manifest, output, {format: format.name, quality, scale, audio});
178
181
  json(response, 202, record.job);
179
182
 
180
183
  void runJob(config, origin, record, video).catch((error) => {
@@ -208,6 +211,33 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
208
211
  return;
209
212
  }
210
213
 
214
+ /**
215
+ * The source that produced the frame you are looking at.
216
+ *
217
+ * Studio shows the path already; a path you cannot read is a
218
+ * riddle. Reading is confined to source files inside the project,
219
+ * because this serves whatever a query string asks for and the
220
+ * only safe answer to "../../.ssh/id_rsa" is no.
221
+ */
222
+ if (request.method === "GET" && url.startsWith("/source")) {
223
+ const asked = new URL(url, "http://localhost").searchParams.get("file") ?? "";
224
+ const file = resolve(config.root, asked);
225
+ const inside = relative(config.root, file);
226
+ const readable = /\.(tsx?|jsx?|css|json|md)$/.test(file);
227
+ if (!inside || inside.startsWith("..") || !readable) {
228
+ json(response, 400, {error: `Refusing to read ${asked}.`});
229
+ return;
230
+ }
231
+ try {
232
+ response.statusCode = 200;
233
+ response.setHeader("content-type", "text/plain; charset=utf-8");
234
+ response.end(await readFile(file, "utf8"));
235
+ } catch {
236
+ json(response, 404, {error: `${asked} is not there.`});
237
+ }
238
+ return;
239
+ }
240
+
211
241
  if (request.method === "GET" && url.startsWith("/jobs")) {
212
242
  json(response, 200, await listJobs(config));
213
243
  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
 
@@ -36,6 +36,7 @@ export const runJob = async (
36
36
  quality?: Quality;
37
37
  scale?: number;
38
38
  format?: VideoFormat;
39
+ audio?: boolean;
39
40
  skipUnchangedFrames?: boolean;
40
41
  signal?: AbortSignal;
41
42
  onProgress?: (job: ExportJob) => void;
@@ -94,6 +95,7 @@ export const runJob = async (
94
95
  quality: options.quality ?? (record.render?.quality as Quality | undefined),
95
96
  scale: options.scale ?? record.render?.scale,
96
97
  format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : undefined),
98
+ audio: options.audio ?? record.render?.audio,
97
99
  skipUnchangedFrames: options.skipUnchangedFrames,
98
100
  signal: controller.signal,
99
101
  onTimings: (timings) => {
@@ -151,6 +153,8 @@ export const exportCommand = async (
151
153
  quality?: string;
152
154
  scale?: number;
153
155
  format?: string;
156
+ /** False writes the picture with no audio track. */
157
+ audio?: boolean;
154
158
  skipUnchangedFrames?: boolean;
155
159
  retry?: string;
156
160
  } = {},
@@ -183,6 +187,7 @@ export const exportCommand = async (
183
187
  format: format.name,
184
188
  quality,
185
189
  scale,
190
+ audio: options.audio !== false,
186
191
  ...(options.preset ? {preset: options.preset} : {}),
187
192
  });
188
193
  })();
@@ -201,6 +206,7 @@ export const exportCommand = async (
201
206
  quality: options.retry && options.quality === undefined ? undefined : quality,
202
207
  scale: options.retry && options.scale === undefined ? undefined : scale,
203
208
  format: options.retry ? undefined : format,
209
+ audio: options.audio,
204
210
  skipUnchangedFrames: options.skipUnchangedFrames,
205
211
  onProgress: (next) => {
206
212
  if (next.status === "rendering" || next.status === "encoding") {
@@ -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
  };