@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/src/discovery.ts CHANGED
@@ -108,11 +108,13 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
108
108
  const previews: DiscoveredPreview[] = [];
109
109
  const brands: DiscoveredBrandModule[] = [];
110
110
  const hashParts: string[] = [];
111
+ const componentsRoot = resolve(config.root, config.componentsDir);
111
112
 
112
113
  for (const file of files) {
113
114
  const relativeFile = relative(config.root, file);
115
+ let contents = "";
114
116
  if (/\.(tsx|ts|css|json)$/.test(file)) {
115
- const contents = await readFile(file, "utf8");
117
+ contents = await readFile(file, "utf8");
116
118
  hashParts.push(`${relativeFile}:${hashString(contents)}`);
117
119
  }
118
120
 
@@ -127,13 +129,30 @@ export const discoverProject = async (config: ResolvedConfig): Promise<ProjectGr
127
129
  importPath: file,
128
130
  identifier: toIdentifier(slug, "video"),
129
131
  });
130
- } else if (file.split(sep).includes("brands") && /\.tsx?$/.test(base) && !base.endsWith(".preview.tsx")) {
132
+ } else if (
133
+ // A brand module is recognized by what it does, not where it sits. The
134
+ // scaffold defines its brand in videos/layout.tsx, so a directory-name
135
+ // rule alone left the default project's brand invisible to everything
136
+ // that reads this list — most visibly the dev server's cue registry,
137
+ // which then answered new cue URLs with 404s until a restart. Installed
138
+ // component source is excluded the way brand-file.ts excludes it: a
139
+ // component may mention defineBrand without being where a brand lives.
140
+ /\.tsx?$/.test(base) &&
141
+ !base.endsWith(".preview.tsx") &&
142
+ !file.startsWith(componentsRoot + sep) &&
143
+ (file.split(sep).includes("brands") || contents.includes("defineBrand("))
144
+ ) {
131
145
  const name = base.replace(/\.tsx?$/, "");
132
146
  brands.push({
133
147
  name,
134
148
  file,
135
149
  relativeFile,
136
- identifier: toIdentifier(`${name}-module`, "brands"),
150
+ // From the whole relative path, like previews: basenames repeat
151
+ // (`layout.tsx` beside `brands/layout.ts`), identifiers cannot.
152
+ identifier: toIdentifier(
153
+ `${relative(videosRoot, file).replace(/\.tsx?$/, "").split(sep).join("-")}-module`,
154
+ "brands",
155
+ ),
137
156
  });
138
157
  } else if (base.endsWith(".preview.tsx")) {
139
158
  const name = base.replace(/\.preview\.tsx$/, "");
package/src/formats.ts CHANGED
@@ -11,6 +11,29 @@
11
11
  * concatenated without re-encoding. `chunked: false` says a format has to be
12
12
  * encoded in one pass, which is slower and correct.
13
13
  */
14
+ /** Compression tiers, named for where the file is going. */
15
+ export type Quality = "studio" | "social" | "web";
16
+
17
+ export const QUALITIES: Quality[] = ["studio", "social", "web"];
18
+
19
+ export type EncodeOptions = {
20
+ /** x264-style speed preset. Orthogonal to quality: it trades time, not pixels. */
21
+ preset: string;
22
+ /** Compression tier. Codecs that are already lossless or mandated ignore it. */
23
+ quality: Quality;
24
+ /** Output scale multiplier. 1 is the composition's own size. */
25
+ scale: number;
26
+ };
27
+
28
+ /**
29
+ * A scale filter that lands on even dimensions, which yuv420p requires.
30
+ * Lanczos, because a product video is text and lines more than it is grain.
31
+ */
32
+ const scaleFilter = (scale: number): string =>
33
+ `scale=trunc(iw*${scale}/2)*2:trunc(ih*${scale}/2)*2:flags=lanczos`;
34
+
35
+ const scaleArgs = (scale: number): string[] => (scale === 1 ? [] : ["-vf", scaleFilter(scale)]);
36
+
14
37
  export type VideoFormat = {
15
38
  name: string;
16
39
  extension: string;
@@ -20,8 +43,8 @@ export type VideoFormat = {
20
43
  chunked: boolean;
21
44
  /** Whether the container carries an audio track at all. */
22
45
  audio: boolean;
23
- /** FFmpeg arguments for the video stream, given the requested preset. */
24
- args: (preset: string) => string[];
46
+ /** FFmpeg arguments for the video stream. */
47
+ args: (options: EncodeOptions) => string[];
25
48
  description: string;
26
49
  };
27
50
 
@@ -33,7 +56,17 @@ export const FORMATS: Record<string, VideoFormat> = {
33
56
  chunked: true,
34
57
  audio: true,
35
58
  description: "H.264 in MP4. Plays everywhere; the default.",
36
- args: (preset) => ["-c:v", "libx264", "-crf", "17", "-preset", preset, "-pix_fmt", "yuv420p"],
59
+ args: ({preset, quality, scale}) => [
60
+ ...scaleArgs(scale),
61
+ "-c:v",
62
+ "libx264",
63
+ "-crf",
64
+ {studio: "17", social: "21", web: "27"}[quality],
65
+ "-preset",
66
+ preset,
67
+ "-pix_fmt",
68
+ "yuv420p",
69
+ ],
37
70
  },
38
71
  webm: {
39
72
  name: "webm",
@@ -43,7 +76,19 @@ export const FORMATS: Record<string, VideoFormat> = {
43
76
  chunked: true,
44
77
  audio: true,
45
78
  description: "VP9 in WebM, with alpha. For the web, and for overlays.",
46
- args: () => ["-c:v", "libvpx-vp9", "-crf", "24", "-b:v", "0", "-pix_fmt", "yuva420p", "-row-mt", "1"],
79
+ args: ({quality, scale}) => [
80
+ ...scaleArgs(scale),
81
+ "-c:v",
82
+ "libvpx-vp9",
83
+ "-crf",
84
+ {studio: "24", social: "31", web: "38"}[quality],
85
+ "-b:v",
86
+ "0",
87
+ "-pix_fmt",
88
+ "yuva420p",
89
+ "-row-mt",
90
+ "1",
91
+ ],
47
92
  },
48
93
  prores: {
49
94
  name: "prores",
@@ -52,7 +97,19 @@ export const FORMATS: Record<string, VideoFormat> = {
52
97
  chunked: true,
53
98
  audio: true,
54
99
  description: "ProRes 4444 in MOV, with alpha. For handing to an editor.",
55
- args: () => ["-c:v", "prores_ks", "-profile:v", "4444", "-pix_fmt", "yuva444p10le", "-alpha_bits", "8"],
100
+ // Quality is the profile here, and the profile is the point: an editor
101
+ // format that quietly compressed would defeat its own reason to exist.
102
+ args: ({scale}) => [
103
+ ...scaleArgs(scale),
104
+ "-c:v",
105
+ "prores_ks",
106
+ "-profile:v",
107
+ "4444",
108
+ "-pix_fmt",
109
+ "yuva444p10le",
110
+ "-alpha_bits",
111
+ "8",
112
+ ],
56
113
  },
57
114
  gif: {
58
115
  name: "gif",
@@ -63,9 +120,11 @@ export const FORMATS: Record<string, VideoFormat> = {
63
120
  chunked: false,
64
121
  audio: false,
65
122
  description: "An animated GIF, palette optimised. Silent, by the format.",
66
- args: () => [
123
+ // The palette pass is a filter graph already, so scaling joins it rather
124
+ // than adding a second -vf that ffmpeg would silently drop.
125
+ args: ({scale}) => [
67
126
  "-vf",
68
- "split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3",
127
+ `${scale === 1 ? "" : `${scaleFilter(scale)},`}split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3`,
69
128
  "-loop",
70
129
  "0",
71
130
  ],
@@ -77,7 +136,7 @@ export const FORMATS: Record<string, VideoFormat> = {
77
136
  chunked: false,
78
137
  audio: false,
79
138
  description: "A numbered PNG sequence, with alpha. For a compositor.",
80
- args: () => ["-c:v", "png", "-pix_fmt", "rgba"],
139
+ args: ({scale}) => [...scaleArgs(scale), "-c:v", "png", "-pix_fmt", "rgba"],
81
140
  },
82
141
  };
83
142
 
package/src/jobs.ts CHANGED
@@ -4,7 +4,10 @@ import {join, resolve} from "node:path";
4
4
  import type {ExportJob, RenderManifest} from "odori";
5
5
  import type {ResolvedConfig} from "./config";
6
6
 
7
- export type JobRecord = {job: ExportJob; manifest: RenderManifest; output: string};
7
+ /** How a job should be encoded, frozen with it so a retry cannot drift. */
8
+ export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string};
9
+
10
+ export type JobRecord = {job: ExportJob; manifest: RenderManifest; output: string; render?: JobRender};
8
11
 
9
12
  const buildsDir = (config: ResolvedConfig) => resolve(config.root, config.outDir, "builds");
10
13
  const jobFile = (config: ResolvedConfig, id: string) => join(buildsDir(config), `${id}.json`);
@@ -17,6 +20,7 @@ export const createJob = async (
17
20
  config: ResolvedConfig,
18
21
  manifest: RenderManifest,
19
22
  output: string,
23
+ render?: JobRender,
20
24
  ): Promise<JobRecord> => {
21
25
  await mkdir(buildsDir(config), {recursive: true});
22
26
  const now = new Date().toISOString();
@@ -31,7 +35,7 @@ export const createJob = async (
31
35
  createdAt: now,
32
36
  updatedAt: now,
33
37
  };
34
- const record: JobRecord = {job, manifest, output};
38
+ const record: JobRecord = {job, manifest, output, ...(render ? {render} : {})};
35
39
  await writeFile(jobFile(config, job.id), `${JSON.stringify(record, null, 2)}\n`, "utf8");
36
40
  return record;
37
41
  };
@@ -400,7 +400,7 @@
400
400
  },
401
401
  {
402
402
  "name": "brand-provider",
403
- "description": "Typed color, type, spacing, radius, and motion tokens.",
403
+ "description": "The resolved brand rendered as a sheet: color, type, and motion.",
404
404
  "registryDependencies": [],
405
405
  "files": [
406
406
  {
@@ -410,7 +410,7 @@
410
410
  },
411
411
  {
412
412
  "path": "components/brand-provider/brand-provider.preview.tsx",
413
- "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",
413
+ "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",
414
414
  "target": "videos/components/brand-provider/brand-provider.preview.tsx"
415
415
  }
416
416
  ],
@@ -1,7 +1,7 @@
1
1
  import {createHash} from "node:crypto";
2
2
  import {existsSync} from "node:fs";
3
3
  import {mkdir, readFile, writeFile} from "node:fs/promises";
4
- import {dirname, resolve} from "node:path";
4
+ import {dirname, isAbsolute, relative, resolve, sep} from "node:path";
5
5
  import {cacheRoot} from "./binaries";
6
6
  import type {ResolvedConfig} from "./config";
7
7
 
@@ -65,6 +65,42 @@ export type RegistryComponent = {
65
65
  /** `@odori/title-reveal` and `title-reveal` name the same component. */
66
66
  export const normalizeComponentName = (name: string): string => name.replace(/^@odori\//, "");
67
67
 
68
+ /**
69
+ * A registry name becomes a URL path segment, a cache filename, and a directory
70
+ * on disk. The index that supplies it is fetched, so it is not ours to trust:
71
+ * a plain slug is the only thing any real entry ever is, and anything else is a
72
+ * malformed or hostile registry trying to reach out of its lane.
73
+ */
74
+ export const assertSafeName = (name: string): string => {
75
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
76
+ throw new Error(
77
+ `Refusing component name ${JSON.stringify(name)}: a name must be a lowercase slug ` +
78
+ "(letters, digits, and single dashes). This registry is malformed or tampered with.",
79
+ );
80
+ }
81
+ return name;
82
+ };
83
+
84
+ /**
85
+ * Confine a fetched write to the project.
86
+ *
87
+ * A registry item dictates its own `target`, and integrity proves the bytes,
88
+ * not the intent — a compromised or malicious origin can publish a matching
89
+ * hash for a document whose target is `../../etc/...` or an absolute path. So
90
+ * every destination is resolved and checked against the root before a single
91
+ * byte is written, and a target that climbs out is refused rather than trusted.
92
+ */
93
+ export const resolveWithinRoot = (root: string, target: string): string => {
94
+ const destination = resolve(root, target);
95
+ const rel = relative(root, destination);
96
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
97
+ throw new Error(
98
+ `Refusing to write ${JSON.stringify(target)}: it resolves outside the project. Nothing was written.`,
99
+ );
100
+ }
101
+ return destination;
102
+ };
103
+
68
104
  export type RegistryOrigin = "network" | "cache" | "bundled";
69
105
 
70
106
  export type RegistrySource = {
@@ -208,6 +244,7 @@ export const resolveItem = async (
208
244
  name: string,
209
245
  options: {allowNetwork?: boolean} = {},
210
246
  ): Promise<{item: RegistryItemDocument; origin: RegistryOrigin}> => {
247
+ assertSafeName(name);
211
248
  const url = registryUrl(config);
212
249
  const cache = resolve(cacheDir(url), `${name}.json`);
213
250
 
@@ -245,9 +282,19 @@ export const resolveItem = async (
245
282
  * catches truncation, a stale proxy, and a corrupted cache, which are the
246
283
  * failures that actually happen.
247
284
  */
248
- export const verifyIntegrity = (item: RegistryItemDocument): void => {
285
+ export const verifyIntegrity = (item: RegistryItemDocument, origin: RegistryOrigin = "network"): void => {
249
286
  const expected = item.meta?.integrity;
250
- if (!expected) return;
287
+ if (!expected) {
288
+ // The snapshot ships inside the npm tarball and is covered by its checksum,
289
+ // so it carries no hash of its own. Anything fetched must: a document that
290
+ // arrives over the wire with no integrity is one where the field was
291
+ // dropped, and trusting it would defeat the check entirely.
292
+ if (origin === "bundled") return;
293
+ throw new Error(
294
+ `The registry document for "${item.name}" carries no integrity hash. ` +
295
+ "A fetched item must be verifiable; refusing to write it. Nothing was written.",
296
+ );
297
+ }
251
298
 
252
299
  const hash = createHash("sha256");
253
300
  for (const file of [...item.files].sort((left, right) => left.path.localeCompare(right.path))) {
package/src/render.ts CHANGED
@@ -10,7 +10,7 @@ import {chunkFrames, planChunks, type FrameChunk} from "./chunks";
10
10
  import {chunkKey, readChunkRecord, signaturesMatch, useChunkRecord, writeChunkRecord} from "./chunk-cache";
11
11
  import {type ResolvedConfig} from "./config";
12
12
  import {installBrowser, installFfmpeg, resolveBrowser, resolveFfmpeg} from "./binaries";
13
- import {FORMATS, type VideoFormat} from "./formats";
13
+ import {FORMATS, type EncodeOptions, type Quality, type VideoFormat} from "./formats";
14
14
  import {log} from "./log";
15
15
 
16
16
  export type RenderTarget = {
@@ -270,17 +270,34 @@ const sequencePattern = (output: string): string => {
270
270
  return join(stem, `%05d${extension}`);
271
271
  };
272
272
 
273
+ /**
274
+ * The intermediate for formats that must see every frame at once. FFV1 is
275
+ * genuinely lossless, so the palette or the sequence is computed from the
276
+ * pixels the browser drew — not from an H.264 generation of them, which is
277
+ * what the requested format's own args would have produced here. Scale and
278
+ * quality deliberately do not apply: they belong to the final pass, once.
279
+ */
280
+ const LOSSLESS: VideoFormat = {
281
+ name: "lossless",
282
+ extension: ".mkv",
283
+ alpha: true,
284
+ chunked: true,
285
+ audio: false,
286
+ description: "FFV1 in MKV, the lossless intermediate for one-pass formats.",
287
+ args: () => ["-c:v", "ffv1", "-level", "3"],
288
+ };
289
+
273
290
  const openChunkEncoder = (
274
291
  ffmpeg: string,
275
292
  file: string,
276
293
  fps: number,
277
- preset: string,
294
+ encode: EncodeOptions,
278
295
  format: VideoFormat,
279
296
  signal?: AbortSignal,
280
297
  ) => {
281
298
  const child = spawn(
282
299
  ffmpeg,
283
- ["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(preset), file],
300
+ ["-y", "-f", "image2pipe", "-framerate", String(fps), "-i", "pipe:0", ...format.args(encode), file],
284
301
  {stdio: ["pipe", "ignore", "pipe"]},
285
302
  );
286
303
 
@@ -307,7 +324,7 @@ const openChunkEncoder = (
307
324
  type LaneOptions = {
308
325
  skipUnchanged: boolean;
309
326
  signal?: AbortSignal;
310
- preset: string;
327
+ encode: EncodeOptions;
311
328
  format: VideoFormat;
312
329
  /** The resolved encoder, so a lane never guesses at what is on PATH. */
313
330
  ffmpeg: string;
@@ -368,7 +385,7 @@ const captureLane = async (
368
385
  }
369
386
 
370
387
  const file = options.chunkFile(chunk);
371
- const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.preset, options.format, options.signal);
388
+ const encoder = openChunkEncoder(options.ffmpeg, file, target.fps, options.encode, options.format, options.signal);
372
389
  const signatures: string[] = [];
373
390
  let previousSignature: string | null = null;
374
391
  let previousFrame: Buffer | null = null;
@@ -445,6 +462,10 @@ export type RenderTimings = {
445
462
  export type RenderOptions = {
446
463
  concurrency?: number;
447
464
  preset?: string;
465
+ /** Compression tier. Defaults to studio, which is what the pipeline always was. */
466
+ quality?: Quality;
467
+ /** Output scale multiplier. Defaults to 1, the composition's own size. */
468
+ scale?: number;
448
469
  /** Container and codec. Defaults to H.264 in MP4. */
449
470
  format?: VideoFormat;
450
471
  skipUnchangedFrames?: boolean;
@@ -474,12 +495,19 @@ export const renderMovie = async (
474
495
  const renderer = (await resolveBrowser(config))?.version ?? browserPath;
475
496
 
476
497
  const requested = Math.max(1, Math.min(options.concurrency ?? config.concurrency ?? defaultConcurrency(), 16));
477
- const preset = options.preset ?? config.preset ?? "medium";
498
+ const encode: EncodeOptions = {
499
+ preset: options.preset ?? config.preset ?? "medium",
500
+ quality: options.quality ?? "studio",
501
+ scale: options.scale ?? 1,
502
+ };
478
503
  const format = options.format ?? FORMATS.mp4;
479
504
  // A palette or a still sequence is computed across the whole animation, so
480
505
  // it cannot be assembled from independently encoded chunks. One lane, one
481
506
  // pass: slower, and the only way the output is correct.
482
507
  const chunkable = format.chunked;
508
+ // What a lane encodes: the requested codec when chunks can be joined by
509
+ // stream copy, the lossless intermediate when the format needs one pass.
510
+ const chunkFormat = chunkable ? format : LOSSLESS;
483
511
  const skipUnchanged = options.skipUnchangedFrames ?? config.skipUnchangedFrames ?? true;
484
512
  const cache = options.cache ?? config.cacheChunks ?? true;
485
513
 
@@ -511,8 +539,8 @@ export const renderMovie = async (
511
539
  captureLane(origin, target, config, lane, stats, {
512
540
  skipUnchanged,
513
541
  signal: options.signal,
514
- preset,
515
- format,
542
+ encode,
543
+ format: chunkFormat,
516
544
  ffmpeg,
517
545
  workDir: work,
518
546
  chunkFile,
@@ -521,11 +549,17 @@ export const renderMovie = async (
521
549
  videoId: target.videoId,
522
550
  chunk,
523
551
  renderer,
524
- format: format.name,
552
+ format: chunkFormat.name,
525
553
  width: target.width,
526
554
  height: target.height,
527
555
  fps: target.fps,
528
- preset,
556
+ preset: encode.preset,
557
+ // Both change the encoded bytes, so both are part of what a
558
+ // chunk is: a half-size chunk must never answer for a full
559
+ // one. A lossless intermediate is the exception by design —
560
+ // the final pass applies them, so one capture serves all.
561
+ quality: chunkable ? encode.quality : undefined,
562
+ scale: chunkable ? encode.scale : undefined,
529
563
  input: target.input,
530
564
  })
531
565
  : undefined,
@@ -557,15 +591,16 @@ export const renderMovie = async (
557
591
  await run(ffmpeg, ["-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", silent], options.signal);
558
592
  }
559
593
 
560
- // A format that cannot be chunked was captured losslessly and is encoded
561
- // here in one pass, where a palette or a sequence can see every frame.
594
+ // A format that cannot be chunked was captured to the lossless
595
+ // intermediate and is encoded here in one pass, where a palette or a
596
+ // sequence can see every frame of the real pixels.
562
597
  if (!chunkable) {
563
598
  // A sequence is many files. Given one path it writes beside it, using
564
599
  // the name as the directory, rather than overwriting a single frame 270
565
600
  // times and reporting success.
566
601
  const destination = format.name === "png" && !output.includes("%") ? sequencePattern(output) : output;
567
602
  if (destination !== output) await mkdir(dirname(destination), {recursive: true});
568
- await run(ffmpeg, ["-y", "-i", silent, ...format.args(preset), destination], options.signal);
603
+ await run(ffmpeg, ["-y", "-i", silent, ...format.args(encode), destination], options.signal);
569
604
  if (mixInputs.length > 0) {
570
605
  log.detail(`${format.name} carries no audio track; ${mixInputs.length} cue(s) were not mixed in.`);
571
606
  }
package/src/server.ts CHANGED
@@ -124,11 +124,6 @@ export const startStudioServer = async (
124
124
  const odoriSrc = runtimeSource(config.root);
125
125
  let graph = await discoverProject(config);
126
126
  await writeGenerated(config, graph);
127
- // The cue middleware below answers out of this registry, so it has to be
128
- // filled before the first preview plays and refilled whenever discovery
129
- // runs again: a brand added or edited while the server is up declares
130
- // sounds the server has never seen.
131
- await registerProjectCues(graph);
132
127
 
133
128
  const vite = await createServer({
134
129
  root: studioRoot,
@@ -211,6 +206,42 @@ export const startStudioServer = async (
211
206
  optimizeDeps: {include: ["react", "react-dom", "react/jsx-dev-runtime"]},
212
207
  });
213
208
 
209
+ /**
210
+ * Source modules load through Vite, not Node. Node caches a module for the
211
+ * life of the process and busting the entry does not reach its imports, so
212
+ * a brand whose score lives one file away kept serving the old sound. Vite
213
+ * owns a module graph that invalidates the whole chain on every change —
214
+ * the same reason the browser preview is always current.
215
+ */
216
+ const loadModule = (file: string) => vite.ssrLoadModule(`/@fs${file}`) as Promise<Record<string, unknown>>;
217
+
218
+ // The cue middleware answers out of the registry filled here, so it has to
219
+ // be full before the first preview plays and refilled whenever a source
220
+ // change can redefine a sound.
221
+ await registerProjectCues(graph, loadModule);
222
+
223
+ /**
224
+ * Cue refresh is debounced and serialized. `odori add` writes several files
225
+ * in one gesture and an editor save can fire twice; each burst settles into
226
+ * one refresh, and refreshes never interleave. Old registrations stay: the
227
+ * URLs are content addressed, so an entry can never mean a different sound.
228
+ */
229
+ let refreshTimer: ReturnType<typeof setTimeout> | undefined;
230
+ let refreshChain: Promise<unknown> = Promise.resolve();
231
+ const enqueueRefresh = <Value,>(task: () => Promise<Value>): Promise<Value> => {
232
+ const next = refreshChain.then(task, task);
233
+ refreshChain = next.catch(() => undefined);
234
+ return next;
235
+ };
236
+ const scheduleCueRefresh = () => {
237
+ clearTimeout(refreshTimer);
238
+ refreshTimer = setTimeout(() => {
239
+ void enqueueRefresh(() => registerProjectCues(graph, loadModule)).catch((error) => {
240
+ vite.config.logger.warn(`[odori] cue refresh skipped: ${error instanceof Error ? error.message : String(error)}`);
241
+ });
242
+ }, 150);
243
+ };
244
+
214
245
  // Rediscover when video or preview entries appear or disappear.
215
246
  const rediscover = async (file: string) => {
216
247
  if (!file.startsWith(resolve(config.root, config.videosDir))) return;
@@ -219,7 +250,7 @@ export const startStudioServer = async (
219
250
  try {
220
251
  graph = await discoverProject(config);
221
252
  await writeGenerated(config, graph);
222
- await registerProjectCues(graph);
253
+ await enqueueRefresh(() => registerProjectCues(graph, loadModule));
223
254
  } catch (error) {
224
255
  // A watcher event can arrive after the source root is gone, for example
225
256
  // during a branch switch. Keep the last good graph and stay alive.
@@ -243,7 +274,7 @@ export const startStudioServer = async (
243
274
  config = await loadConfig(config.root);
244
275
  graph = await discoverProject(config);
245
276
  await writeGenerated(config, graph);
246
- await registerProjectCues(graph);
277
+ await enqueueRefresh(() => registerProjectCues(graph, loadModule));
247
278
  } catch (error) {
248
279
  vite.config.logger.warn(`[odori] config reload skipped: ${error instanceof Error ? error.message : String(error)}`);
249
280
  return;
@@ -254,12 +285,17 @@ export const startStudioServer = async (
254
285
  };
255
286
  // Rediscovery is driven by files appearing and disappearing, but a cue is
256
287
  // edited in place: the score changes, its content hash changes with it, and
257
- // the preview then asks for a URL the registry has never heard of.
258
- const refreshCues = async (file: string) => {
259
- if (!file.startsWith(resolve(config.root, config.videosDir)) || !file.split(sep).includes("brands")) return;
260
- await registerProjectCues(graph);
288
+ // the preview then asks for a URL the registry has never heard of. The
289
+ // guard is deliberately broad the scaffold's brand lives in
290
+ // videos/layout.tsx and a score can live in a file the brand imports, so
291
+ // any source change under the videos root schedules a refresh and the
292
+ // debounce keeps the cost of that breadth to one pass per burst.
293
+ const refreshCues = (file: string) => {
294
+ if (!file.startsWith(resolve(config.root, config.videosDir) + sep)) return;
295
+ if (!/\.tsx?$/.test(file)) return;
296
+ scheduleCueRefresh();
261
297
  };
262
- vite.watcher.on("change", (file) => void refreshCues(file));
298
+ vite.watcher.on("change", (file) => refreshCues(file));
263
299
 
264
300
  vite.watcher.on("change", (file) => void reloadConfig(file));
265
301
  for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
@@ -43,7 +43,10 @@ const pathFor = (view: StudioView, selection?: string | null): string =>
43
43
  export const Studio = () => {
44
44
  const [route, setRoute] = useState<Route>(readRoute);
45
45
  const [paletteOpen, setPaletteOpen] = useState(false);
46
- const [mode, setMode] = useState<"player" | "gallery">("player");
46
+ // The keydown listener registers once; the ref keeps it reading the live
47
+ // route instead of the one from its first render.
48
+ const routeRef = useRef(route);
49
+ routeRef.current = route;
47
50
  const search = useRef<HTMLInputElement>(null);
48
51
  const {theme, setTheme} = useTheme();
49
52
 
@@ -80,15 +83,20 @@ export const Studio = () => {
80
83
  search.current?.focus();
81
84
  }
82
85
  if (event.key.toLowerCase() === "g") {
83
- setMode((value) => (value === "player" ? "gallery" : "player"));
86
+ // From a detail back to its gallery; already-gallery routes stay put.
87
+ const current = routeRef.current;
88
+ if (current.selection && current.view !== "home") navigate(current.view);
84
89
  }
85
90
  };
86
91
  window.addEventListener("keydown", onKeyDown);
87
92
  return () => window.removeEventListener("keydown", onKeyDown);
88
93
  }, []);
89
94
 
90
- const galleryCapable = route.view === "videos" || route.view === "components";
91
- const singleColumn = route.view === "assets" || route.view === "home" || (galleryCapable && mode === "gallery");
95
+ // The list pages are galleries; a selection is that item's detail, which
96
+ // shows the stage and the inspector with no list beside them. What used to
97
+ // be a modal "gallery mode" is now just the route with no selection.
98
+ const detailCapable = route.view === "videos" || route.view === "components" || route.view === "brands";
99
+ const singleColumn = !detailCapable || route.selection === null;
92
100
 
93
101
  return (
94
102
  <div className="studio">
@@ -144,17 +152,14 @@ export const Studio = () => {
144
152
 
145
153
  <main className="main" data-single={singleColumn ? "true" : undefined}>
146
154
  {route.view === "videos" ? (
147
- <VideosView selection={route.selection} onSelect={(id) => navigate("videos", id)} mode={mode} />
155
+ <VideosView selection={route.selection} onSelect={(id) => navigate("videos", id)} />
148
156
  ) : null}
149
157
  {route.view === "components" ? (
150
- <ComponentsView
151
-
152
- selection={route.selection}
153
- onSelect={(id) => navigate("components", id)}
154
- mode={mode}
155
- />
158
+ <ComponentsView selection={route.selection} onSelect={(id) => navigate("components", id)} />
159
+ ) : null}
160
+ {route.view === "brands" ? (
161
+ <BrandsView selection={route.selection} onSelect={(name) => navigate("brands", name)} />
156
162
  ) : null}
157
- {route.view === "brands" ? <BrandsView /> : null}
158
163
  {route.view === "assets" ? <AssetsView /> : null}
159
164
  {route.view === "home" ? <HomeView onOpen={(view, selection) => navigate(view, selection)} /> : null}
160
165
  </main>
@@ -170,21 +175,13 @@ export const Studio = () => {
170
175
  <span>arrows step</span>
171
176
  <span>- = speed</span>
172
177
  <span>[ ] scene</span>
173
- <span>s safe area</span>
174
- <span>g gallery</span>
175
178
  </footer>
176
179
 
177
180
  <CommandPalette
178
181
  open={paletteOpen}
179
182
  onClose={() => setPaletteOpen(false)}
180
- onOpenVideo={(id) => {
181
- setMode("player");
182
- navigate("videos", id);
183
- }}
184
- onOpenComponent={(id) => {
185
- setMode("player");
186
- navigate("components", id);
187
- }}
183
+ onOpenVideo={(id) => navigate("videos", id)}
184
+ onOpenComponent={(id) => navigate("components", id)}
188
185
  onView={(view) => navigate(view as StudioView)}
189
186
  />
190
187
  </div>