@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.
- package/dist/{chunk-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +63 -8
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-MSH2EA36.js +4867 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +22 -12
- package/src/commands/add.ts +33 -8
- package/src/commands/dev.ts +114 -12
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +85 -5
- package/src/formats.ts +67 -8
- package/src/index.ts +1 -1
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +1530 -328
- package/src/registry-source.ts +87 -5
- package/src/render.ts +48 -13
- package/src/server.ts +55 -13
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +221 -0
- package/studio/src/components/Navigator.tsx +145 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/studio.css +435 -20
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +74 -26
- package/studio/src/views/ComponentsView.tsx +206 -55
- package/studio/src/views/HomeView.tsx +44 -19
- package/studio/src/views/VideosView.tsx +53 -48
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
package/src/registry-source.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
561
|
-
// here in one pass, where a palette or a
|
|
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(
|
|
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) => ({
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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) =>
|
|
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"]) {
|
package/studio/src/Studio.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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)}
|
|
155
|
+
<VideosView selection={route.selection} onSelect={(id) => navigate("videos", id)} />
|
|
148
156
|
) : null}
|
|
149
157
|
{route.view === "components" ? (
|
|
150
|
-
<ComponentsView
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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>
|