@odori/cli 0.0.2 → 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.
Files changed (41) hide show
  1. package/dist/{chunk-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
  2. package/dist/cli.js +1 -1
  3. package/dist/index.d.ts +63 -8
  4. package/dist/index.js +3 -3
  5. package/dist/registry-snapshot-MSH2EA36.js +4867 -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/chunk-cache.ts +6 -0
  10. package/src/cli.ts +22 -12
  11. package/src/commands/add.ts +33 -8
  12. package/src/commands/dev.ts +114 -12
  13. package/src/commands/doctor.ts +47 -2
  14. package/src/commands/exportVideo.ts +39 -5
  15. package/src/commands/{still.ts → frame.ts} +23 -9
  16. package/src/commands/init.ts +1 -1
  17. package/src/commands/new.ts +1 -1
  18. package/src/cues.ts +34 -21
  19. package/src/discovery.ts +85 -5
  20. package/src/formats.ts +67 -8
  21. package/src/index.ts +1 -1
  22. package/src/jobs.ts +6 -2
  23. package/src/registry-snapshot.json +1530 -328
  24. package/src/registry-source.ts +87 -5
  25. package/src/render.ts +48 -13
  26. package/src/server.ts +55 -13
  27. package/studio/src/Studio.tsx +19 -22
  28. package/studio/src/components/ExportPanel.tsx +124 -90
  29. package/studio/src/components/Inspector.tsx +221 -0
  30. package/studio/src/components/Navigator.tsx +145 -0
  31. package/studio/src/components/Thumbnail.tsx +65 -23
  32. package/studio/src/components/ui.tsx +9 -2
  33. package/studio/src/lib/highlight.ts +85 -0
  34. package/studio/src/studio.css +435 -20
  35. package/studio/src/views/AssetsView.tsx +14 -1
  36. package/studio/src/views/BrandsView.tsx +74 -26
  37. package/studio/src/views/ComponentsView.tsx +206 -55
  38. package/studio/src/views/HomeView.tsx +44 -19
  39. package/studio/src/views/VideosView.tsx +53 -48
  40. package/studio/src/virtual.d.ts +4 -1
  41. package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.2",
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.2"
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.2"
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`);
@@ -12,6 +12,10 @@ export type ChunkIdentity = {
12
12
  height: number;
13
13
  fps: number;
14
14
  preset: string;
15
+ /** Compression tier the chunk was encoded at. Changes the bytes. */
16
+ quality?: string;
17
+ /** Scale multiplier the chunk was encoded at. Changes the dimensions. */
18
+ scale?: number;
15
19
  /** The browser build that captured the frames. See `chunkKey`. */
16
20
  renderer?: string;
17
21
  /** The codec the chunk was encoded with, which decides what it can join. */
@@ -49,6 +53,8 @@ export const chunkKey = (identity: ChunkIdentity): string =>
49
53
  height: identity.height,
50
54
  fps: identity.fps,
51
55
  preset: identity.preset,
56
+ quality: identity.quality ?? null,
57
+ scale: identity.scale ?? null,
52
58
  input: identity.input ?? null,
53
59
  });
54
60
 
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,9 +84,9 @@ 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
- export: ["output", "input", "concurrency", "preset", "format", "no-frame-skip", "retry"],
89
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
90
90
  jobs: [],
91
91
  help: [],
92
92
  };
@@ -160,15 +160,19 @@ 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.`,
168
169
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
169
- [--preset <name>] [--format <name>] [--no-frame-skip] [--retry <job>]
170
+ [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
171
+ [--no-frame-skip] [--retry <job>]
170
172
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
171
- or png; without it the output's extension decides, and mp4 is the default.`,
173
+ or png; without it the output's extension decides, and mp4 is the default.
174
+ --quality is studio, social, or web. --scale multiplies the output size,
175
+ 0.25 to 2. A retry keeps the settings its job was created with.`,
172
176
  jobs: `odori jobs
173
177
  List export jobs and their status.`,
174
178
  };
@@ -187,18 +191,20 @@ Usage
187
191
  odori update [components] Apply upstream component changes
188
192
  odori list Print discovered video ids and formats
189
193
  odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
190
- odori still <id> --frame 120 Render one deterministic frame
194
+ odori frame <id> --at 4s Render one deterministic frame to a PNG
191
195
  odori test [id] [--json] Validate contracts and representative frames
192
196
  odori export <id> [--output f] Render and encode a distributable file
193
197
  odori jobs List export jobs and their status
194
198
 
195
199
  Options
196
200
  --input '{"headline":"..."}' Serializable input for the video schema
197
- --output <path> Output path for still and export
201
+ --output <path> Output path for frame and export
198
202
  --force Replace locally modified component source
199
203
  --concurrency <n> Parallel render workers for export
200
204
  --preset <name> x264 preset for export, default medium
201
205
  --format <name> mp4, webm, prores, gif, or png
206
+ --quality <tier> studio, social, or web compression
207
+ --scale <n> Output size multiplier, 0.25 to 2
202
208
  --no-frame-skip Capture every frame, even unchanged ones
203
209
  --retry <job id> Re-run a recorded job from its frozen manifest
204
210
  --no-open Start dev without opening Studio in a browser
@@ -275,9 +281,11 @@ export const run = async (argv: string[]): Promise<number> => {
275
281
  case "inspect":
276
282
  await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
277
283
  return 0;
278
- case "still":
279
- await stillCommand(positionals[0] ?? "", {
280
- 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),
281
289
  output: typeof flags.output === "string" ? flags.output : undefined,
282
290
  input: parseInput(flags),
283
291
  });
@@ -291,6 +299,8 @@ export const run = async (argv: string[]): Promise<number> => {
291
299
  input: parseInput(flags),
292
300
  concurrency: numberFlag(flags, "concurrency"),
293
301
  preset: typeof flags.preset === "string" ? flags.preset : undefined,
302
+ quality: typeof flags.quality === "string" ? flags.quality : undefined,
303
+ scale: numberFlag(flags, "scale"),
294
304
  format: typeof flags.format === "string" ? flags.format : undefined,
295
305
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
296
306
  retry: typeof flags.retry === "string" ? flags.retry : undefined,
@@ -5,7 +5,8 @@ import {hashString} from "odori";
5
5
  import {loadConfig} from "../config";
6
6
  import {log} from "../log";
7
7
  import {registerCueInBrand} from "../brand-file";
8
- import {normalizeComponentName, registryUrl, resolveItem, resolveRegistry, verifyIntegrity} from "../registry-source";
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
  /**
@@ -14,7 +15,7 @@ import {readProvenance, writeProvenance} from "./update";
14
15
  * replaced without an explicit decision.
15
16
  */
16
17
  export const addCommand = async (names: string[], options: {force?: boolean; dryRun?: boolean} = {}) => {
17
- if (names.length === 0) throw new Error("Name at least one component, for example @odori/title-reveal.");
18
+ if (names.length === 0) throw new Error("Name at least one component, for example title-reveal.");
18
19
  const config = await loadConfig(process.cwd());
19
20
  const source = await resolveRegistry(config);
20
21
  const registry = source.items;
@@ -46,19 +47,43 @@ 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
- const {item} = await resolveItem(config, component.name);
75
+ const {item, origin} = await resolveItem(config, component.name);
51
76
  // Before anything is written: the bytes have to be the bytes the registry
52
- // said it was serving.
53
- verifyIntegrity(item);
77
+ // said it was serving, and a fetched document has to say so at all.
78
+ verifyIntegrity(item, origin);
54
79
 
55
- const target = resolve(config.root, config.componentsDir, component.name);
80
+ const target = resolve(config.root, config.componentsDir, assertSafeName(component.name));
56
81
  const hashes: Record<string, string> = {};
57
82
 
58
83
  // What will be written, listed before it is. Installing source into
59
84
  // somebody's repository should never be the first they hear of a path.
60
85
  for (const file of item.files) {
61
- const destination = resolve(config.root, file.target);
86
+ const destination = resolveWithinRoot(config.root, file.target);
62
87
  const exists = existsSync(destination);
63
88
  log.detail(` ${exists ? "replace" : "create "} ${relative(config.root, destination)}`);
64
89
  }
@@ -71,7 +96,7 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
71
96
 
72
97
  for (const file of item.files) {
73
98
  const name = file.path.split("/").pop() ?? file.path;
74
- const destination = resolve(config.root, file.target);
99
+ const destination = resolveWithinRoot(config.root, file.target);
75
100
  hashes[name] = hashString(file.content);
76
101
 
77
102
  if (existsSync(destination) && !options.force) {
@@ -1,4 +1,6 @@
1
- import {resolve} from "node:path";
1
+ import {relative, resolve} from "node:path";
2
+ import {homedir} from "node:os";
3
+ import {existsSync} from "node:fs";
2
4
  import {readFile} from "node:fs/promises";
3
5
  import type {IncomingMessage, ServerResponse} from "node:http";
4
6
  import {loadConfig} from "../config";
@@ -6,10 +8,11 @@ import {log} from "../log";
6
8
  import {createJob, listJobs, readJob} from "../jobs";
7
9
  import {discoverProject} from "../discovery";
8
10
  import {findVideo, freezeManifest, loadVideos, outputName} from "../project";
11
+ import {resolveFormat} from "../formats";
9
12
  import {renderStill} from "../render";
10
13
  import {openInBrowser, shouldOpenBrowser} from "../open";
11
14
  import {startStudioServer} from "../server";
12
- import {cancelJob, runJob} from "./exportVideo";
15
+ import {cancelJob, resolveQuality, resolveScale, runJob} from "./exportVideo";
13
16
  import {compileInBrowser, targetFor} from "./shared";
14
17
 
15
18
  const readBody = async (request: IncomingMessage): Promise<Record<string, unknown>> => {
@@ -25,6 +28,65 @@ const json = (response: ServerResponse, status: number, payload: unknown) => {
25
28
  response.end(JSON.stringify(payload));
26
29
  };
27
30
 
31
+ /**
32
+ * Where a Studio export lands: the Downloads folder, like any app that hands
33
+ * you a file. The CLI keeps writing into the project's export directory - a
34
+ * build artifact belongs to the build - but a file made by clicking a button
35
+ * belongs where files made by clicking buttons go.
36
+ */
37
+ const exportDestination = (config: {root: string; exportDir: string}): string => {
38
+ const downloads = resolve(homedir(), "Downloads");
39
+ return existsSync(downloads) ? downloads : resolve(config.root, config.exportDir);
40
+ };
41
+
42
+ /**
43
+ * These endpoints spawn a browser and an encoder and write files, so they are
44
+ * not the harmless read-only surface a dev server usually exposes. The server
45
+ * binds to 127.0.0.1, which keeps other machines out, but a page open in the
46
+ * developer's own browser can still reach a loopback port — directly, or by
47
+ * rebinding a hostname it controls to 127.0.0.1 and POSTing to it.
48
+ *
49
+ * So a request has to look like it came from Studio itself: its Host must be a
50
+ * loopback name, and a cross-site fetch (which the browser stamps with an
51
+ * Origin) must be same-origin. A same-origin XHR from Studio carries neither a
52
+ * foreign Origin nor a foreign Host and passes; a drive-by page fails both.
53
+ */
54
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
55
+
56
+ const hostOf = (value: string | undefined): string | null => {
57
+ if (!value) return null;
58
+ // Strip a port without tripping over an IPv6 literal's own colons.
59
+ const withoutPort = value.startsWith("[") ? value.slice(0, value.indexOf("]") + 1) : value.split(":")[0];
60
+ return withoutPort || null;
61
+ };
62
+
63
+ export const isLocalRequest = (request: IncomingMessage): boolean => {
64
+ const host = hostOf(request.headers.host);
65
+ if (!host || !LOOPBACK.has(host)) return false;
66
+ const origin = request.headers.origin;
67
+ if (origin) {
68
+ try {
69
+ if (!LOOPBACK.has(hostOf(new URL(origin).host) ?? "")) return false;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+ return true;
75
+ };
76
+
77
+ /**
78
+ * A job id is minted as `job-<hash>-<base36>` and then read straight back as a
79
+ * filename, so it is confined to the characters that mint it. This keeps a
80
+ * crafted id like `../../etc/passwd` from ever reaching the filesystem, even
81
+ * though the local-origin guard already stands in front of it.
82
+ */
83
+ export const safeJobId = (id: string): string => {
84
+ if (!/^[a-zA-Z0-9._-]+$/.test(id) || id.includes("..")) {
85
+ throw new Error(`Invalid job id ${JSON.stringify(id)}.`);
86
+ }
87
+ return id;
88
+ };
89
+
28
90
  /**
29
91
  * Studio previews without encoding. These endpoints exist so an explicit
30
92
  * export action in the browser reaches the same queue and render worker the
@@ -42,6 +104,10 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
42
104
  middleware: (vite) => {
43
105
  vite.middlewares.use("/__odori", (request, response, next) => {
44
106
  const url = request.url ?? "/";
107
+ if (!isLocalRequest(request)) {
108
+ json(response, 403, {error: "This endpoint only answers same-origin requests from Studio on localhost."});
109
+ return;
110
+ }
45
111
  void (async () => {
46
112
  try {
47
113
  if (request.method === "POST" && url.startsWith("/still")) {
@@ -61,8 +127,9 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
61
127
  // A clipboard grab is transient, so it renders into the generated
62
128
  // directory instead of littering the export directory.
63
129
  const inline = body.inline === true;
64
- const directory = inline ? config.outDir : config.exportDir;
65
- const file = resolve(config.root, `${directory}/${outputName(video.entry.metadata.id)}-${frame}.png`);
130
+ const file = inline
131
+ ? resolve(config.root, config.outDir, `${outputName(video.entry.metadata.id)}-${frame}.png`)
132
+ : resolve(exportDestination(config), `${outputName(video.entry.metadata.id)}-${frame}.png`);
66
133
  await renderStill(
67
134
  origin,
68
135
  targetFor(
@@ -87,6 +154,11 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
87
154
 
88
155
  if (request.method === "POST" && url.startsWith("/exports")) {
89
156
  const body = await readBody(request);
157
+ // Validated before compiling: a wrong option should cost a
158
+ // sentence, not a browser launch.
159
+ const format = resolveFormat(typeof body.format === "string" ? body.format : undefined, undefined);
160
+ const quality = resolveQuality(typeof body.quality === "string" ? body.quality : undefined);
161
+ const scale = resolveScale(typeof body.scale === "number" ? body.scale : undefined);
90
162
  const {graph, videos} = await context();
91
163
  const video = findVideo(videos, String(body.videoId));
92
164
  const input = (body.input ?? {}) as Record<string, unknown>;
@@ -98,8 +170,11 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
98
170
  input,
99
171
  {scenes: compiled.scenes, audio: compiled.audio},
100
172
  );
101
- const output = resolve(config.root, `${config.exportDir}/${outputName(video.entry.metadata.id)}.mp4`);
102
- const record = await createJob(config, manifest, output);
173
+ const output = resolve(
174
+ exportDestination(config),
175
+ `${outputName(video.entry.metadata.id)}${format.extension}`,
176
+ );
177
+ const record = await createJob(config, manifest, output, {format: format.name, quality, scale});
103
178
  json(response, 202, record.job);
104
179
 
105
180
  void runJob(config, origin, record, video).catch((error) => {
@@ -109,7 +184,7 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
109
184
  }
110
185
 
111
186
  if (request.method === "POST" && url.startsWith("/retry/")) {
112
- const id = url.replace("/retry/", "").split("?")[0];
187
+ const id = safeJobId(url.replace("/retry/", "").split("?")[0]);
113
188
  const record = await readJob(config, id);
114
189
  const {videos} = await context();
115
190
  const video = findVideo(videos, record.manifest.videoId);
@@ -121,18 +196,45 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
121
196
  }
122
197
 
123
198
  if (request.method === "POST" && url.startsWith("/cancel/")) {
124
- const id = url.replace("/cancel/", "").split("?")[0];
199
+ const id = safeJobId(url.replace("/cancel/", "").split("?")[0]);
125
200
  const cancelled = cancelJob(id);
126
201
  json(response, cancelled ? 202 : 404, {id, cancelled});
127
202
  return;
128
203
  }
129
204
 
130
205
  if (request.method === "GET" && url.startsWith("/jobs/")) {
131
- const id = url.replace("/jobs/", "").split("?")[0];
206
+ const id = safeJobId(url.replace("/jobs/", "").split("?")[0]);
132
207
  json(response, 200, (await readJob(config, id)).job);
133
208
  return;
134
209
  }
135
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
+
136
238
  if (request.method === "GET" && url.startsWith("/jobs")) {
137
239
  json(response, 200, await listJobs(config));
138
240
  return;
@@ -148,9 +250,9 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
148
250
  });
149
251
 
150
252
  const origin = server.url;
151
- // Views are routable, so name the one Studio starts on rather than leaving a
152
- // bare origin that silently resolves to it.
153
- const entry = `${origin}/videos`;
253
+ // Studio opens on the home overview: what the project contains, each card
254
+ // playing, with every deeper view one click in.
255
+ const entry = `${origin}/`;
154
256
  log.title("Odori Studio");
155
257
  log.info(` ${entry}`);
156
258
  log.detail(` ${server.graph.videos.length} videos, ${server.graph.previews.length} component previews`);
@@ -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