@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
@@ -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
 
@@ -46,14 +46,28 @@ export type ComponentContract = {
46
46
  loops?: boolean;
47
47
  };
48
48
 
49
- export type RegistryKind = "component" | "cue";
49
+ export type RegistryKind = "component" | "cue" | "asset";
50
+
51
+ /** A produced file the registry publishes: sound synthesis cannot reach. */
52
+ export type RegistryAsset = {
53
+ cue: string;
54
+ url: string;
55
+ target: string;
56
+ bytes: number;
57
+ integrity: string;
58
+ };
50
59
 
51
60
  export type RegistryComponent = {
52
61
  name: string;
53
- /** Components render pixels. Cues render samples. Both install as source. */
62
+ /**
63
+ * Components render pixels and cues render samples; both install as source.
64
+ * An asset installs as bytes into `public/` and is registered by URL.
65
+ */
54
66
  kind?: RegistryKind;
55
67
  /** Present on cues: the brand name it registers, and the factory to call. */
56
68
  cue?: {name: string; export: string};
69
+ /** Present on assets: the file to fetch and the cue name it answers to. */
70
+ asset?: RegistryAsset;
57
71
  namespaced: string;
58
72
  family: string;
59
73
  description: string;
@@ -65,6 +79,42 @@ export type RegistryComponent = {
65
79
  /** `@odori/title-reveal` and `title-reveal` name the same component. */
66
80
  export const normalizeComponentName = (name: string): string => name.replace(/^@odori\//, "");
67
81
 
82
+ /**
83
+ * A registry name becomes a URL path segment, a cache filename, and a directory
84
+ * on disk. The index that supplies it is fetched, so it is not ours to trust:
85
+ * a plain slug is the only thing any real entry ever is, and anything else is a
86
+ * malformed or hostile registry trying to reach out of its lane.
87
+ */
88
+ export const assertSafeName = (name: string): string => {
89
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
90
+ throw new Error(
91
+ `Refusing component name ${JSON.stringify(name)}: a name must be a lowercase slug ` +
92
+ "(letters, digits, and single dashes). This registry is malformed or tampered with.",
93
+ );
94
+ }
95
+ return name;
96
+ };
97
+
98
+ /**
99
+ * Confine a fetched write to the project.
100
+ *
101
+ * A registry item dictates its own `target`, and integrity proves the bytes,
102
+ * not the intent — a compromised or malicious origin can publish a matching
103
+ * hash for a document whose target is `../../etc/...` or an absolute path. So
104
+ * every destination is resolved and checked against the root before a single
105
+ * byte is written, and a target that climbs out is refused rather than trusted.
106
+ */
107
+ export const resolveWithinRoot = (root: string, target: string): string => {
108
+ const destination = resolve(root, target);
109
+ const rel = relative(root, destination);
110
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
111
+ throw new Error(
112
+ `Refusing to write ${JSON.stringify(target)}: it resolves outside the project. Nothing was written.`,
113
+ );
114
+ }
115
+ return destination;
116
+ };
117
+
68
118
  export type RegistryOrigin = "network" | "cache" | "bundled";
69
119
 
70
120
  export type RegistrySource = {
@@ -87,6 +137,7 @@ type RegistryItemDocument = {
87
137
  namespaced?: string;
88
138
  contract?: RegistryComponent["contract"];
89
139
  cue?: {name: string; export: string};
140
+ asset?: RegistryAsset;
90
141
  integrity?: string;
91
142
  };
92
143
  };
@@ -96,6 +147,25 @@ const DEFAULT_URL = "https://odori.dev/r/v1";
96
147
  export const registryUrl = (config: ResolvedConfig): string =>
97
148
  (config.registryUrl ?? process.env.ODORI_REGISTRY ?? DEFAULT_URL).replace(/\/$/, "");
98
149
 
150
+ /**
151
+ * The site the registry is served from, without its document path.
152
+ *
153
+ * Documents live under `/r/v1`; published media lives at the site root, the
154
+ * same absolute path a project will reference it by once it is installed. So
155
+ * an asset URL resolves against the origin rather than against the registry
156
+ * directory, and a self-hosted registry still finds its own files.
157
+ */
158
+ export const registryOrigin = (config: ResolvedConfig): string => {
159
+ const url = registryUrl(config);
160
+ try {
161
+ return new URL(url).origin;
162
+ } catch {
163
+ // A relative or malformed registryUrl: keep everything before /r, which is
164
+ // where a mirror laid out like ours would put its media.
165
+ return url.replace(/\/r(\/v\d+)?$/, "");
166
+ }
167
+ };
168
+
99
169
  /** One directory per origin URL, so two registries never share a cache. */
100
170
  const cacheDir = (url: string): string =>
101
171
  resolve(cacheRoot(), "registry", createHash("sha256").update(url).digest("hex").slice(0, 16));
@@ -113,6 +183,7 @@ const toComponent = (item: RegistryItemDocument): RegistryComponent => ({
113
183
  namespaced: item.meta?.namespaced ?? `@odori/${item.name}`,
114
184
  kind: (item.meta?.kind as RegistryComponent["kind"]) ?? "component",
115
185
  ...(item.meta?.cue ? {cue: item.meta.cue} : {}),
186
+ ...(item.meta?.asset ? {asset: item.meta.asset} : {}),
116
187
  family: item.meta?.family ?? "Uncategorized",
117
188
  description: item.description ?? "",
118
189
  files: item.files.map((file) => file.path.split("/").pop() ?? file.path),
@@ -208,6 +279,7 @@ export const resolveItem = async (
208
279
  name: string,
209
280
  options: {allowNetwork?: boolean} = {},
210
281
  ): Promise<{item: RegistryItemDocument; origin: RegistryOrigin}> => {
282
+ assertSafeName(name);
211
283
  const url = registryUrl(config);
212
284
  const cache = resolve(cacheDir(url), `${name}.json`);
213
285
 
@@ -245,9 +317,19 @@ export const resolveItem = async (
245
317
  * catches truncation, a stale proxy, and a corrupted cache, which are the
246
318
  * failures that actually happen.
247
319
  */
248
- export const verifyIntegrity = (item: RegistryItemDocument): void => {
320
+ export const verifyIntegrity = (item: RegistryItemDocument, origin: RegistryOrigin = "network"): void => {
249
321
  const expected = item.meta?.integrity;
250
- if (!expected) return;
322
+ if (!expected) {
323
+ // The snapshot ships inside the npm tarball and is covered by its checksum,
324
+ // so it carries no hash of its own. Anything fetched must: a document that
325
+ // arrives over the wire with no integrity is one where the field was
326
+ // dropped, and trusting it would defeat the check entirely.
327
+ if (origin === "bundled") return;
328
+ throw new Error(
329
+ `The registry document for "${item.name}" carries no integrity hash. ` +
330
+ "A fetched item must be verifiable; refusing to write it. Nothing was written.",
331
+ );
332
+ }
251
333
 
252
334
  const hash = createHash("sha256");
253
335
  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
@@ -93,6 +93,8 @@ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph
93
93
  `export const project = ${JSON.stringify({
94
94
  root: config.root,
95
95
  videosDir: config.videosDir,
96
+ componentsDir: config.componentsDir,
97
+ categories: graph.categories,
96
98
  exportDir: config.exportDir,
97
99
  audioDir: config.audioDir,
98
100
  docsUrl: config.docsUrl,
@@ -101,7 +103,11 @@ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph
101
103
  assets: config.assets ?? [],
102
104
  files: {
103
105
  videos: graph.videos.map((video) => ({id: video.slug, file: video.relativeFile})),
104
- previews: graph.previews.map((preview) => ({id: preview.name, file: preview.relativeFile})),
106
+ previews: graph.previews.map((preview) => ({
107
+ id: preview.name,
108
+ file: preview.relativeFile,
109
+ usedBy: preview.usedBy ?? [],
110
+ })),
105
111
  brands: graph.brands.map((brand) => ({id: brand.name, file: brand.relativeFile})),
106
112
  },
107
113
  })};`,
@@ -124,11 +130,6 @@ export const startStudioServer = async (
124
130
  const odoriSrc = runtimeSource(config.root);
125
131
  let graph = await discoverProject(config);
126
132
  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
133
 
133
134
  const vite = await createServer({
134
135
  root: studioRoot,
@@ -211,6 +212,42 @@ export const startStudioServer = async (
211
212
  optimizeDeps: {include: ["react", "react-dom", "react/jsx-dev-runtime"]},
212
213
  });
213
214
 
215
+ /**
216
+ * Source modules load through Vite, not Node. Node caches a module for the
217
+ * life of the process and busting the entry does not reach its imports, so
218
+ * a brand whose score lives one file away kept serving the old sound. Vite
219
+ * owns a module graph that invalidates the whole chain on every change —
220
+ * the same reason the browser preview is always current.
221
+ */
222
+ const loadModule = (file: string) => vite.ssrLoadModule(`/@fs${file}`) as Promise<Record<string, unknown>>;
223
+
224
+ // The cue middleware answers out of the registry filled here, so it has to
225
+ // be full before the first preview plays and refilled whenever a source
226
+ // change can redefine a sound.
227
+ await registerProjectCues(graph, loadModule);
228
+
229
+ /**
230
+ * Cue refresh is debounced and serialized. `odori add` writes several files
231
+ * in one gesture and an editor save can fire twice; each burst settles into
232
+ * one refresh, and refreshes never interleave. Old registrations stay: the
233
+ * URLs are content addressed, so an entry can never mean a different sound.
234
+ */
235
+ let refreshTimer: ReturnType<typeof setTimeout> | undefined;
236
+ let refreshChain: Promise<unknown> = Promise.resolve();
237
+ const enqueueRefresh = <Value,>(task: () => Promise<Value>): Promise<Value> => {
238
+ const next = refreshChain.then(task, task);
239
+ refreshChain = next.catch(() => undefined);
240
+ return next;
241
+ };
242
+ const scheduleCueRefresh = () => {
243
+ clearTimeout(refreshTimer);
244
+ refreshTimer = setTimeout(() => {
245
+ void enqueueRefresh(() => registerProjectCues(graph, loadModule)).catch((error) => {
246
+ vite.config.logger.warn(`[odori] cue refresh skipped: ${error instanceof Error ? error.message : String(error)}`);
247
+ });
248
+ }, 150);
249
+ };
250
+
214
251
  // Rediscover when video or preview entries appear or disappear.
215
252
  const rediscover = async (file: string) => {
216
253
  if (!file.startsWith(resolve(config.root, config.videosDir))) return;
@@ -219,7 +256,7 @@ export const startStudioServer = async (
219
256
  try {
220
257
  graph = await discoverProject(config);
221
258
  await writeGenerated(config, graph);
222
- await registerProjectCues(graph);
259
+ await enqueueRefresh(() => registerProjectCues(graph, loadModule));
223
260
  } catch (error) {
224
261
  // A watcher event can arrive after the source root is gone, for example
225
262
  // during a branch switch. Keep the last good graph and stay alive.
@@ -243,7 +280,7 @@ export const startStudioServer = async (
243
280
  config = await loadConfig(config.root);
244
281
  graph = await discoverProject(config);
245
282
  await writeGenerated(config, graph);
246
- await registerProjectCues(graph);
283
+ await enqueueRefresh(() => registerProjectCues(graph, loadModule));
247
284
  } catch (error) {
248
285
  vite.config.logger.warn(`[odori] config reload skipped: ${error instanceof Error ? error.message : String(error)}`);
249
286
  return;
@@ -254,12 +291,17 @@ export const startStudioServer = async (
254
291
  };
255
292
  // Rediscovery is driven by files appearing and disappearing, but a cue is
256
293
  // 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);
294
+ // the preview then asks for a URL the registry has never heard of. The
295
+ // guard is deliberately broad the scaffold's brand lives in
296
+ // videos/layout.tsx and a score can live in a file the brand imports, so
297
+ // any source change under the videos root schedules a refresh and the
298
+ // debounce keeps the cost of that breadth to one pass per burst.
299
+ const refreshCues = (file: string) => {
300
+ if (!file.startsWith(resolve(config.root, config.videosDir) + sep)) return;
301
+ if (!/\.tsx?$/.test(file)) return;
302
+ scheduleCueRefresh();
261
303
  };
262
- vite.watcher.on("change", (file) => void refreshCues(file));
304
+ vite.watcher.on("change", (file) => refreshCues(file));
263
305
 
264
306
  vite.watcher.on("change", (file) => void reloadConfig(file));
265
307
  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>