@odori/cli 0.0.2 → 0.0.3

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/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  checkFlags,
3
3
  parseArgs,
4
4
  run
5
- } from "./chunk-7XJL2BYO.js";
5
+ } from "./chunk-RXLB2CXH.js";
6
6
  export {
7
7
  checkFlags,
8
8
  parseArgs,
package/dist/index.d.ts CHANGED
@@ -177,6 +177,16 @@ declare const freezeManifest: (video: LoadedVideo, graph: ProjectGraph, config:
177
177
  * concatenated without re-encoding. `chunked: false` says a format has to be
178
178
  * encoded in one pass, which is slower and correct.
179
179
  */
180
+ /** Compression tiers, named for where the file is going. */
181
+ type Quality = "studio" | "social" | "web";
182
+ type EncodeOptions = {
183
+ /** x264-style speed preset. Orthogonal to quality: it trades time, not pixels. */
184
+ preset: string;
185
+ /** Compression tier. Codecs that are already lossless or mandated ignore it. */
186
+ quality: Quality;
187
+ /** Output scale multiplier. 1 is the composition's own size. */
188
+ scale: number;
189
+ };
180
190
  type VideoFormat = {
181
191
  name: string;
182
192
  extension: string;
@@ -186,8 +196,8 @@ type VideoFormat = {
186
196
  chunked: boolean;
187
197
  /** Whether the container carries an audio track at all. */
188
198
  audio: boolean;
189
- /** FFmpeg arguments for the video stream, given the requested preset. */
190
- args: (preset: string) => string[];
199
+ /** FFmpeg arguments for the video stream. */
200
+ args: (options: EncodeOptions) => string[];
191
201
  description: string;
192
202
  };
193
203
  declare const FORMATS: Record<string, VideoFormat>;
@@ -254,6 +264,10 @@ type RenderTimings = {
254
264
  type RenderOptions = {
255
265
  concurrency?: number;
256
266
  preset?: string;
267
+ /** Compression tier. Defaults to studio, which is what the pipeline always was. */
268
+ quality?: Quality;
269
+ /** Output scale multiplier. Defaults to 1, the composition's own size. */
270
+ scale?: number;
257
271
  /** Container and codec. Defaults to H.264 in MP4. */
258
272
  format?: VideoFormat;
259
273
  skipUnchangedFrames?: boolean;
@@ -298,16 +312,24 @@ type CompileResult = {
298
312
  declare const compileInBrowser: (origin: string, target: RenderTarget, config: ResolvedConfig) => Promise<CompileResult>;
299
313
  declare const withServer: <Value>(config: ResolvedConfig, handler: (server: StudioServer) => Promise<Value>) => Promise<Value>;
300
314
 
315
+ /** How a job should be encoded, frozen with it so a retry cannot drift. */
316
+ type JobRender = {
317
+ format?: string;
318
+ quality?: string;
319
+ scale?: number;
320
+ preset?: string;
321
+ };
301
322
  type JobRecord = {
302
323
  job: ExportJob;
303
324
  manifest: RenderManifest;
304
325
  output: string;
326
+ render?: JobRender;
305
327
  };
306
328
  /**
307
329
  * An export job records the frozen manifest, progress, attempts, and result,
308
330
  * so a retry reuses the approved inputs instead of resolving them again.
309
331
  */
310
- declare const createJob: (config: ResolvedConfig, manifest: RenderManifest, output: string) => Promise<JobRecord>;
332
+ declare const createJob: (config: ResolvedConfig, manifest: RenderManifest, output: string, render?: JobRender) => Promise<JobRecord>;
311
333
  declare const readJob: (config: ResolvedConfig, id: string) => Promise<JobRecord>;
312
334
  declare const updateJob: (config: ResolvedConfig, job: ExportJob) => Promise<ExportJob>;
313
335
  declare const appendJobLog: (config: ResolvedConfig, id: string, message: string) => Promise<ExportJob>;
@@ -494,6 +516,8 @@ declare const cancelJob: (id: string) => boolean;
494
516
  declare const runJob: (config: ResolvedConfig, origin: string, record: JobRecord, video: LoadedVideo, options?: {
495
517
  concurrency?: number;
496
518
  preset?: string;
519
+ quality?: Quality;
520
+ scale?: number;
497
521
  format?: VideoFormat;
498
522
  skipUnchangedFrames?: boolean;
499
523
  signal?: AbortSignal;
@@ -504,6 +528,8 @@ declare const exportCommand: (id: string, options?: {
504
528
  input?: Record<string, unknown>;
505
529
  concurrency?: number;
506
530
  preset?: string;
531
+ quality?: string;
532
+ scale?: number;
507
533
  format?: string;
508
534
  skipUnchangedFrames?: boolean;
509
535
  retry?: string;
package/dist/index.js CHANGED
@@ -75,7 +75,7 @@ import {
75
75
  withServer,
76
76
  writeGenerated,
77
77
  writePrepareCache
78
- } from "./chunk-7XJL2BYO.js";
78
+ } from "./chunk-RXLB2CXH.js";
79
79
  export {
80
80
  CHROME_BUILD,
81
81
  FORMATS,
@@ -401,7 +401,7 @@ var registry_snapshot_default = {
401
401
  },
402
402
  {
403
403
  name: "brand-provider",
404
- description: "Typed color, type, spacing, radius, and motion tokens.",
404
+ description: "The resolved brand rendered as a sheet: color, type, and motion.",
405
405
  registryDependencies: [],
406
406
  files: [
407
407
  {
@@ -411,7 +411,7 @@ var registry_snapshot_default = {
411
411
  },
412
412
  {
413
413
  path: "components/brand-provider/brand-provider.preview.tsx",
414
- content: 'import {defineComponentPreview} from "odori/preview";\nimport {BrandProvider} from "./brand-provider";\n\nexport default defineComponentPreview({\n title: "Brand provider",\n category: "Foundation",\n description: "Typed color, type, spacing, radius, and motion tokens.",\n component: BrandProvider,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Brand tokens"},\n },\n examples: [\n {name: "Everything", props: {}},\n {name: "Colors only", props: {show: ["colors"], title: "Palette"}},\n ],\n});\n',
414
+ content: 'import {defineComponentPreview} from "odori/preview";\nimport {BrandProvider} from "./brand-provider";\n\nexport default defineComponentPreview({\n title: "Brand provider",\n category: "Foundation",\n description: "The resolved brand rendered as a sheet: color, type, and motion.",\n component: BrandProvider,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Brand tokens"},\n },\n examples: [\n {name: "Everything", props: {}},\n {name: "Colors only", props: {show: ["colors"], title: "Palette"}},\n ],\n});\n',
415
415
  target: "videos/components/brand-provider/brand-provider.preview.tsx"
416
416
  }
417
417
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
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.3"
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.3"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"
@@ -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
@@ -86,7 +86,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
86
86
  inspect: ["json", "input"],
87
87
  still: ["frame", "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
  };
@@ -166,9 +166,12 @@ const USAGE: Record<string, string> = {
166
166
  Validate contracts and representative frames. --json emits one object per
167
167
  check, for CI.`,
168
168
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
169
- [--preset <name>] [--format <name>] [--no-frame-skip] [--retry <job>]
169
+ [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
170
+ [--no-frame-skip] [--retry <job>]
170
171
  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.`,
172
+ or png; without it the output's extension decides, and mp4 is the default.
173
+ --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.`,
172
175
  jobs: `odori jobs
173
176
  List export jobs and their status.`,
174
177
  };
@@ -199,6 +202,8 @@ Options
199
202
  --concurrency <n> Parallel render workers for export
200
203
  --preset <name> x264 preset for export, default medium
201
204
  --format <name> mp4, webm, prores, gif, or png
205
+ --quality <tier> studio, social, or web compression
206
+ --scale <n> Output size multiplier, 0.25 to 2
202
207
  --no-frame-skip Capture every frame, even unchanged ones
203
208
  --retry <job id> Re-run a recorded job from its frozen manifest
204
209
  --no-open Start dev without opening Studio in a browser
@@ -291,6 +296,8 @@ export const run = async (argv: string[]): Promise<number> => {
291
296
  input: parseInput(flags),
292
297
  concurrency: numberFlag(flags, "concurrency"),
293
298
  preset: typeof flags.preset === "string" ? flags.preset : undefined,
299
+ quality: typeof flags.quality === "string" ? flags.quality : undefined,
300
+ scale: numberFlag(flags, "scale"),
294
301
  format: typeof flags.format === "string" ? flags.format : undefined,
295
302
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
296
303
  retry: typeof flags.retry === "string" ? flags.retry : undefined,
@@ -5,7 +5,7 @@ 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
9
  import {readProvenance, writeProvenance} from "./update";
10
10
 
11
11
  /**
@@ -14,7 +14,7 @@ import {readProvenance, writeProvenance} from "./update";
14
14
  * replaced without an explicit decision.
15
15
  */
16
16
  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.");
17
+ if (names.length === 0) throw new Error("Name at least one component, for example title-reveal.");
18
18
  const config = await loadConfig(process.cwd());
19
19
  const source = await resolveRegistry(config);
20
20
  const registry = source.items;
@@ -47,18 +47,18 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
47
47
  }
48
48
 
49
49
  // The item document carries the file contents; the index does not.
50
- const {item} = await resolveItem(config, component.name);
50
+ const {item, origin} = await resolveItem(config, component.name);
51
51
  // Before anything is written: the bytes have to be the bytes the registry
52
- // said it was serving.
53
- verifyIntegrity(item);
52
+ // said it was serving, and a fetched document has to say so at all.
53
+ verifyIntegrity(item, origin);
54
54
 
55
- const target = resolve(config.root, config.componentsDir, component.name);
55
+ const target = resolve(config.root, config.componentsDir, assertSafeName(component.name));
56
56
  const hashes: Record<string, string> = {};
57
57
 
58
58
  // What will be written, listed before it is. Installing source into
59
59
  // somebody's repository should never be the first they hear of a path.
60
60
  for (const file of item.files) {
61
- const destination = resolve(config.root, file.target);
61
+ const destination = resolveWithinRoot(config.root, file.target);
62
62
  const exists = existsSync(destination);
63
63
  log.detail(` ${exists ? "replace" : "create "} ${relative(config.root, destination)}`);
64
64
  }
@@ -71,7 +71,7 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
71
71
 
72
72
  for (const file of item.files) {
73
73
  const name = file.path.split("/").pop() ?? file.path;
74
- const destination = resolve(config.root, file.target);
74
+ const destination = resolveWithinRoot(config.root, file.target);
75
75
  hashes[name] = hashString(file.content);
76
76
 
77
77
  if (existsSync(destination) && !options.force) {
@@ -1,4 +1,6 @@
1
1
  import {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,14 +196,14 @@ 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
  }
@@ -148,9 +223,9 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
148
223
  });
149
224
 
150
225
  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`;
226
+ // Studio opens on the home overview: what the project contains, each card
227
+ // playing, with every deeper view one click in.
228
+ const entry = `${origin}/`;
154
229
  log.title("Odori Studio");
155
230
  log.info(` ${entry}`);
156
231
  log.detail(` ${server.graph.videos.length} videos, ${server.graph.previews.length} component previews`);
@@ -4,7 +4,7 @@ import type {ResolvedConfig} from "../config";
4
4
  import {log} from "../log";
5
5
  import {JobQueue, appendJobLog, createJob, listJobs, readJob, updateJob, type JobRecord} from "../jobs";
6
6
  import {findVideo, freezeManifest, outputName, type LoadedVideo} from "../project";
7
- import {resolveFormat, type VideoFormat} from "../formats";
7
+ import {resolveFormat, QUALITIES, type Quality, type VideoFormat} from "../formats";
8
8
  import {materializeCues} from "../cues";
9
9
  import {renderMovie} from "../render";
10
10
  import {compileInBrowser, createContext, targetFor, withServer} from "./shared";
@@ -33,6 +33,8 @@ export const runJob = async (
33
33
  options: {
34
34
  concurrency?: number;
35
35
  preset?: string;
36
+ quality?: Quality;
37
+ scale?: number;
36
38
  format?: VideoFormat;
37
39
  skipUnchangedFrames?: boolean;
38
40
  signal?: AbortSignal;
@@ -86,8 +88,12 @@ export const runJob = async (
86
88
  },
87
89
  {
88
90
  concurrency: options.concurrency,
89
- preset: options.preset,
90
- format: options.format,
91
+ // The record freezes how it should be encoded alongside what, so a
92
+ // retry from any surface produces the same file the first run would.
93
+ preset: options.preset ?? record.render?.preset,
94
+ quality: options.quality ?? (record.render?.quality as Quality | undefined),
95
+ scale: options.scale ?? record.render?.scale,
96
+ format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : undefined),
91
97
  skipUnchangedFrames: options.skipUnchangedFrames,
92
98
  signal: controller.signal,
93
99
  onTimings: (timings) => {
@@ -119,6 +125,22 @@ export const runJob = async (
119
125
  }
120
126
  });
121
127
 
128
+ /** A named tier, or a sentence naming the tiers. */
129
+ export const resolveQuality = (requested: string | undefined): Quality => {
130
+ if (!requested) return "studio";
131
+ if ((QUALITIES as string[]).includes(requested)) return requested as Quality;
132
+ throw new Error(`Unknown quality "${requested}". Available: ${QUALITIES.join(", ")}`);
133
+ };
134
+
135
+ /** A sane multiplier: enough for a half-size preview and a 2x retina cut. */
136
+ export const resolveScale = (requested: number | undefined): number => {
137
+ if (requested === undefined) return 1;
138
+ if (!Number.isFinite(requested) || requested < 0.25 || requested > 2) {
139
+ throw new Error(`Scale ${requested} is out of range. Use a value between 0.25 and 2.`);
140
+ }
141
+ return requested;
142
+ };
143
+
122
144
  export const exportCommand = async (
123
145
  id: string,
124
146
  options: {
@@ -126,6 +148,8 @@ export const exportCommand = async (
126
148
  input?: Record<string, unknown>;
127
149
  concurrency?: number;
128
150
  preset?: string;
151
+ quality?: string;
152
+ scale?: number;
129
153
  format?: string;
130
154
  skipUnchangedFrames?: boolean;
131
155
  retry?: string;
@@ -135,6 +159,8 @@ export const exportCommand = async (
135
159
  // Resolved before anything renders: an unknown format should cost a sentence,
136
160
  // not twenty minutes of capture.
137
161
  const format = resolveFormat(options.format ?? config.format, options.output);
162
+ const quality = resolveQuality(options.quality);
163
+ const scale = resolveScale(options.scale);
138
164
 
139
165
  return withServer(config, async (server) => {
140
166
  const record = options.retry
@@ -153,7 +179,12 @@ export const exportCommand = async (
153
179
  config.root,
154
180
  options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`,
155
181
  );
156
- return createJob(config, manifest, output);
182
+ return createJob(config, manifest, output, {
183
+ format: format.name,
184
+ quality,
185
+ scale,
186
+ ...(options.preset ? {preset: options.preset} : {}),
187
+ });
157
188
  })();
158
189
 
159
190
  const video = findVideo(videos, record.manifest.videoId);
@@ -166,7 +197,10 @@ export const exportCommand = async (
166
197
  const job = await runJob(config, server.url, record, video, {
167
198
  concurrency: options.concurrency,
168
199
  preset: options.preset,
169
- format,
200
+ // A retry keeps what its record froze; an explicit flag still wins.
201
+ quality: options.retry && options.quality === undefined ? undefined : quality,
202
+ scale: options.retry && options.scale === undefined ? undefined : scale,
203
+ format: options.retry ? undefined : format,
170
204
  skipUnchangedFrames: options.skipUnchangedFrames,
171
205
  onProgress: (next) => {
172
206
  if (next.status === "rendering" || next.status === "encoding") {
@@ -52,5 +52,5 @@ export const initCommand = async (root = process.cwd()) => {
52
52
  log.success(`Created ${relative(root, file)}`);
53
53
  }
54
54
 
55
- log.detail("Next: odori doctor, then odori add @odori/title-reveal @odori/end-card, then odori dev.");
55
+ log.detail("Next: odori doctor, then odori add title-reveal end-card, then odori dev.");
56
56
  };
@@ -120,7 +120,7 @@ export const newCommand = async (name: string, options: {blank?: boolean} = {})
120
120
  else if (options.blank !== true) {
121
121
  // Nothing to compose with is worth saying once, with the command that
122
122
  // changes it, rather than leaving the plain entry to imply this is the way.
123
- log.detail("No registry components installed yet: odori add @odori/title-reveal @odori/end-card");
123
+ log.detail("No registry components installed yet: odori add title-reveal end-card");
124
124
  }
125
125
  log.detail("Run odori dev to preview it.");
126
126
  };
package/src/cues.ts CHANGED
@@ -12,11 +12,12 @@ import {
12
12
  resolveEntryLayout,
13
13
  type Brand,
14
14
  type CueDefinition,
15
+ type VideoEntry,
16
+ type VideoLayout,
15
17
  } from "odori";
16
18
  import type {ResolvedConfig} from "./config";
17
19
  import type {ProjectGraph} from "./discovery";
18
20
  import {log} from "./log";
19
- import {loadVideos} from "./project";
20
21
 
21
22
  /** Where a rendered cue lands. Content addressed, so it is written once. */
22
23
  export const cueCacheDir = (config: ResolvedConfig) => resolve(config.root, config.outDir, "cues");
@@ -93,15 +94,22 @@ export const renderedCue = (url: string): Uint8Array | null => {
93
94
  const isBrand = (value: unknown): value is Brand =>
94
95
  typeof value === "object" && value !== null && (value as Brand).kind === "odori-brand";
95
96
 
97
+ const isLayout = (value: unknown): value is VideoLayout =>
98
+ typeof value === "object" && value !== null && (value as VideoLayout).kind === "odori-layout";
99
+
100
+ /** How registration reads a source module. The dev server supplies Vite. */
101
+ export type ModuleLoader = (file: string) => Promise<Record<string, unknown>>;
102
+
96
103
  /**
97
- * Import a source file fresh.
104
+ * Import a source file fresh, for callers with no dev server.
98
105
  *
99
- * Node caches modules by URL for the life of the process, so a dev server that
100
- * re-reads a brand after an edit would otherwise keep the scores it saw at
101
- * boot. The file's mtime in the specifier makes an edited file a new module
102
- * and an untouched one a cache hit.
106
+ * Node caches modules by URL for the life of the process; the file's mtime in
107
+ * the specifier makes an edited file a new module and an untouched one a cache
108
+ * hit. What this cannot do is see through an import: a brand whose score lives
109
+ * in another file keeps that child cached, which is why the dev server loads
110
+ * through Vite's module graph instead — Vite invalidates the whole chain.
103
111
  */
104
- const importFresh = async (file: string): Promise<Record<string, unknown>> =>
112
+ export const importFresh: ModuleLoader = async (file) =>
105
113
  (await import(`${pathToFileURL(file).href}?odori=${statSync(file).mtimeMs}`)) as Record<string, unknown>;
106
114
 
107
115
  /**
@@ -109,30 +117,35 @@ const importFresh = async (file: string): Promise<Record<string, unknown>> =>
109
117
  * one by content hash the moment Studio loads.
110
118
  *
111
119
  * Discovery is the source of truth on both halves. The brand modules are what
112
- * make a cue exist at all, including a brand no video has adopted yet, and
113
- * they are registered first at the default frame rate. The video entries are
114
- * then loaded for their layouts, because the frame rate decides how many
115
- * samples a cue occupies and only an entry knows its own. A project that
116
- * cannot be loaded a half-written entry mid-edit leaves the brand pass in
117
- * place rather than taking the server down.
120
+ * make a cue exist at all, including a brand no video has adopted yet. They
121
+ * register first at the default frame rate; any layout exported beside a brand
122
+ * then re-registers it at the rate that layout pins, because the frame rate
123
+ * decides how many samples a cue occupies. The video entries come last for the
124
+ * same reason: only an entry knows the layout it actually plays under. A file
125
+ * that cannot be loaded — half-written mid-edit — leaves the passes that
126
+ * succeeded in place rather than taking the server down.
118
127
  */
119
- export const registerProjectCues = async (graph: ProjectGraph): Promise<number> => {
128
+ export const registerProjectCues = async (graph: ProjectGraph, load: ModuleLoader = importFresh): Promise<number> => {
120
129
  for (const discovered of graph.brands) {
121
130
  try {
122
- const module = await importFresh(discovered.file);
123
- registerCues(Object.values(module).filter(isBrand), defaultLayout.format.fps);
131
+ const values = Object.values(await load(discovered.file));
132
+ registerCues(values.filter(isBrand), defaultLayout.format.fps);
133
+ for (const layout of values.filter(isLayout)) registerCues([layout.brand], layout.format.fps);
124
134
  } catch (error) {
125
135
  log.warn(`[odori] could not read cues from ${discovered.relativeFile}: ${message(error)}`);
126
136
  }
127
137
  }
128
138
 
129
- try {
130
- for (const video of await loadVideos(graph)) {
131
- const layout = resolveEntryLayout(video.entry);
139
+ for (const video of graph.videos) {
140
+ try {
141
+ const module = await load(video.file);
142
+ if (!module.default || !module.metadata) continue;
143
+ const entry = {component: module.default, metadata: module.metadata} as VideoEntry;
144
+ const layout = resolveEntryLayout(entry);
132
145
  registerCues([layout.brand], layout.format.fps);
146
+ } catch (error) {
147
+ log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
133
148
  }
134
- } catch (error) {
135
- log.warn(`[odori] generated cues may use the default frame rate: ${message(error)}`);
136
149
  }
137
150
 
138
151
  return known.size;