@odori/cli 0.0.2

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 (70) hide show
  1. package/LICENSE +22 -0
  2. package/bin/odori.mjs +39 -0
  3. package/dist/chunk-7XJL2BYO.js +3552 -0
  4. package/dist/cli.d.ts +10 -0
  5. package/dist/cli.js +10 -0
  6. package/dist/index.d.ts +622 -0
  7. package/dist/index.js +156 -0
  8. package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
  9. package/package.json +50 -0
  10. package/src/audio-mix.ts +133 -0
  11. package/src/binaries.ts +241 -0
  12. package/src/brand-file.ts +94 -0
  13. package/src/chunk-cache.ts +85 -0
  14. package/src/chunks.ts +78 -0
  15. package/src/cli.ts +319 -0
  16. package/src/commands/add.ts +151 -0
  17. package/src/commands/dev.ts +160 -0
  18. package/src/commands/doctor.ts +162 -0
  19. package/src/commands/exportVideo.ts +198 -0
  20. package/src/commands/init.ts +56 -0
  21. package/src/commands/inspect.ts +72 -0
  22. package/src/commands/list.ts +22 -0
  23. package/src/commands/new.ts +126 -0
  24. package/src/commands/shared.ts +96 -0
  25. package/src/commands/still.ts +40 -0
  26. package/src/commands/test.ts +265 -0
  27. package/src/commands/update.ts +183 -0
  28. package/src/config.ts +84 -0
  29. package/src/contracts.ts +159 -0
  30. package/src/cues.ts +141 -0
  31. package/src/determinism.ts +82 -0
  32. package/src/diff.ts +71 -0
  33. package/src/discovery.ts +216 -0
  34. package/src/formats.ts +119 -0
  35. package/src/index.ts +58 -0
  36. package/src/integrity.ts +101 -0
  37. package/src/jobs.ts +151 -0
  38. package/src/log.ts +17 -0
  39. package/src/open.ts +32 -0
  40. package/src/paths.ts +12 -0
  41. package/src/prepare-cache.ts +58 -0
  42. package/src/project.ts +196 -0
  43. package/src/registry-snapshot.json +3431 -0
  44. package/src/registry-source.ts +269 -0
  45. package/src/render.ts +627 -0
  46. package/src/server.ts +307 -0
  47. package/studio/index.html +41 -0
  48. package/studio/src/Studio.tsx +192 -0
  49. package/studio/src/components/AudioClip.tsx +64 -0
  50. package/studio/src/components/CanvasStage.tsx +79 -0
  51. package/studio/src/components/CommandPalette.tsx +129 -0
  52. package/studio/src/components/Diagnostics.tsx +93 -0
  53. package/studio/src/components/ExportPanel.tsx +234 -0
  54. package/studio/src/components/InputControls.tsx +110 -0
  55. package/studio/src/components/Thumbnail.tsx +71 -0
  56. package/studio/src/components/Transport.tsx +237 -0
  57. package/studio/src/components/Waveform.tsx +114 -0
  58. package/studio/src/components/Wordmark.tsx +449 -0
  59. package/studio/src/components/ui.tsx +138 -0
  60. package/studio/src/lib/mix-loudness.ts +52 -0
  61. package/studio/src/main.tsx +34 -0
  62. package/studio/src/shortcuts.ts +27 -0
  63. package/studio/src/studio.css +1232 -0
  64. package/studio/src/theme.ts +61 -0
  65. package/studio/src/views/AssetsView.tsx +111 -0
  66. package/studio/src/views/BrandsView.tsx +139 -0
  67. package/studio/src/views/ComponentsView.tsx +285 -0
  68. package/studio/src/views/HomeView.tsx +122 -0
  69. package/studio/src/views/VideosView.tsx +343 -0
  70. package/studio/src/virtual.d.ts +25 -0
package/src/server.ts ADDED
@@ -0,0 +1,307 @@
1
+ import {existsSync} from "node:fs";
2
+ import {createRequire} from "node:module";
3
+ import {fileURLToPath} from "node:url";
4
+ import {createServer, type ViteDevServer} from "vite";
5
+ import {registerProjectCues, renderedCue} from "./cues";
6
+ import react from "@vitejs/plugin-react";
7
+ import {readFile} from "node:fs/promises";
8
+ import {dirname, resolve, sep} from "node:path";
9
+ import {discoverProject, writeGenerated, type ProjectGraph} from "./discovery";
10
+ import {loadConfig, type ResolvedConfig} from "./config";
11
+
12
+ /**
13
+ * Studio is a directory of this package, not a package of its own.
14
+ *
15
+ * Both `src/` (in this repository) and `dist/` (once built) sit one level under
16
+ * the package root, so the same relative walk finds `studio/` from either.
17
+ */
18
+ const cliRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
19
+ const studioRoot = resolve(cliRoot, "studio");
20
+ const studioEntry = resolve(studioRoot, "index.html");
21
+
22
+ /**
23
+ * The tree this package is installed into: `node_modules` in a consumer, the
24
+ * repository root here. Vite is allowed to serve out of it because the runtime
25
+ * and the registry live beside this package rather than under it.
26
+ */
27
+ const installRoot = resolve(cliRoot, "..", "..");
28
+
29
+ const VIRTUAL_ID = "virtual:odori-project";
30
+ const RESOLVED_ID = `\0${VIRTUAL_ID}`;
31
+
32
+ /**
33
+ * Where the runtime's source lives, when it is available as source.
34
+ *
35
+ * This used to be `studioRoot/../odori/src`, which is true in this repository
36
+ * and nowhere else: in a consumer, Studio sits at `node_modules/@odori/studio`
37
+ * and that path resolves to `node_modules/@odori/odori/src`, a directory that
38
+ * has never existed. Every install outside the monorepo failed on it.
39
+ *
40
+ * Resolving the package properly gives the right answer in both places, and
41
+ * the published package has no `src/index.tsx`, so a consumer gets `null` and
42
+ * the aliases below are simply not installed — Vite then resolves `odori`
43
+ * through its own exports map, which is what should happen.
44
+ */
45
+ const runtimeSource = (root: string): string | null => {
46
+ for (const from of [resolve(root, "package.json"), import.meta.url]) {
47
+ try {
48
+ const manifest = createRequire(from).resolve("odori/package.json");
49
+ const src = resolve(manifest, "..", "src");
50
+ if (existsSync(resolve(src, "index.tsx"))) return src;
51
+ } catch {
52
+ // Not resolvable from here; try the next origin.
53
+ }
54
+ }
55
+ return null;
56
+ };
57
+
58
+ /**
59
+ * The project is injected as a virtual module of static imports. Studio itself
60
+ * stays a plain application: everything it knows comes from discovery.
61
+ */
62
+ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph) => ({
63
+ name: "odori:project",
64
+ resolveId(id: string) {
65
+ return id === VIRTUAL_ID ? RESOLVED_ID : null;
66
+ },
67
+ load(id: string) {
68
+ if (id !== RESOLVED_ID) return null;
69
+ const graph = getGraph();
70
+ const fsPath = (file: string) => JSON.stringify(`/@fs${file}`);
71
+ return [
72
+ ...graph.videos.map(
73
+ (video) => `import ${video.identifier}, {metadata as ${video.identifier}Metadata} from ${fsPath(video.file)};`,
74
+ ),
75
+ ...graph.previews.map((preview) => `import ${preview.identifier} from ${fsPath(preview.file)};`),
76
+ ...graph.brands.map((brand) => `import * as ${brand.identifier} from ${fsPath(brand.file)};`),
77
+ "export const videos = [",
78
+ ...graph.videos.map(
79
+ (video) =>
80
+ ` {component: ${video.identifier}, metadata: {...${video.identifier}Metadata, id: ${video.identifier}Metadata.id || ${JSON.stringify(video.slug)}}},`,
81
+ ),
82
+ "];",
83
+ "export const componentPreviews = [",
84
+ ...graph.previews.map(
85
+ (preview) => ` {id: ${JSON.stringify(preview.name)}, preview: ${preview.identifier}.default ?? ${preview.identifier}},`,
86
+ ),
87
+ "];",
88
+ "export const brands = [",
89
+ ...graph.brands.map(
90
+ (brand) => ` ...Object.values(${brand.identifier}).filter((value) => value?.kind === "odori-brand"),`,
91
+ ),
92
+ "];",
93
+ `export const project = ${JSON.stringify({
94
+ root: config.root,
95
+ videosDir: config.videosDir,
96
+ exportDir: config.exportDir,
97
+ audioDir: config.audioDir,
98
+ docsUrl: config.docsUrl,
99
+ audio: graph.audio,
100
+ sourceHash: graph.sourceHash,
101
+ assets: config.assets ?? [],
102
+ files: {
103
+ videos: graph.videos.map((video) => ({id: video.slug, file: video.relativeFile})),
104
+ previews: graph.previews.map((preview) => ({id: preview.name, file: preview.relativeFile})),
105
+ brands: graph.brands.map((brand) => ({id: brand.name, file: brand.relativeFile})),
106
+ },
107
+ })};`,
108
+ ].join("\n");
109
+ },
110
+ });
111
+
112
+ export type StudioServer = {
113
+ vite: ViteDevServer;
114
+ url: string;
115
+ graph: ProjectGraph;
116
+ close(): Promise<void>;
117
+ };
118
+
119
+ export const startStudioServer = async (
120
+ initialConfig: ResolvedConfig,
121
+ options: {port?: number; strictPort?: boolean; middleware?: (server: ViteDevServer) => void} = {},
122
+ ): Promise<StudioServer> => {
123
+ let config = initialConfig;
124
+ const odoriSrc = runtimeSource(config.root);
125
+ let graph = await discoverProject(config);
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
+
133
+ const vite = await createServer({
134
+ root: studioRoot,
135
+ configFile: false,
136
+ // Studio owns the fallback so an unknown asset can 404 instead of being
137
+ // answered with the app's HTML, which would only fail later at decode.
138
+ appType: "custom",
139
+ logLevel: "warn",
140
+ // The API middleware is installed through a plugin so it runs before
141
+ // Vite's own history fallback, which would otherwise answer with HTML.
142
+ plugins: [
143
+ react(),
144
+ odoriProjectPlugin(config, () => graph),
145
+ {name: "odori:api", configureServer: (server: ViteDevServer) => options.middleware?.(server)},
146
+ {
147
+ // Generated cues are rendered on demand and served from memory, so a
148
+ // preview hears the same bytes the encoder will mix. The URL carries a
149
+ // hash of the score, which makes it safe to cache forever.
150
+ name: "odori:generated-cues",
151
+ configureServer: (server: ViteDevServer) => () => {
152
+ server.middlewares.use((request, response, next) => {
153
+ const path = (request.url ?? "").split("?")[0];
154
+ if (!path.startsWith("/__odori/cue/")) return next();
155
+ const wav = renderedCue(path);
156
+ if (!wav) {
157
+ response.statusCode = 404;
158
+ response.setHeader("content-type", "text/plain");
159
+ response.end(`odori: no generated cue for ${path}`);
160
+ return;
161
+ }
162
+ response.statusCode = 200;
163
+ response.setHeader("content-type", "audio/wav");
164
+ response.setHeader("content-length", String(wav.byteLength));
165
+ response.setHeader("cache-control", "public, max-age=31536000, immutable");
166
+ response.end(Buffer.from(wav));
167
+ });
168
+ },
169
+ },
170
+ {
171
+ // A cue pointing at a file that is not there must fail as a missing
172
+ // file. Vite's history fallback would answer with the Studio's HTML,
173
+ // which an <audio> element accepts and then silently fails to decode,
174
+ // turning a typo in a brand's audio map into a preview with no sound
175
+ // and no error.
176
+ name: "odori:asset-404",
177
+ configureServer: (server: ViteDevServer) => () => {
178
+ server.middlewares.use((request, response, next) => {
179
+ const path = (request.url ?? "").split("?")[0];
180
+ const isAsset = /\.(?:mp3|m4a|wav|ogg|aac|flac|mp4|webm|mov|png|jpe?g|gif|webp|avif|svg|woff2?|ttf|otf)$/i.test(path);
181
+ if (!isAsset) return next();
182
+ response.statusCode = 404;
183
+ response.setHeader("content-type", "text/plain");
184
+ response.end(`odori: ${path} is not in public/`);
185
+ });
186
+ },
187
+ },
188
+ ],
189
+ // The project's public/ directory is served at the root, so brand fonts,
190
+ // logos, and footage resolve identically in preview and render.
191
+ publicDir: existsSync(resolve(config.root, "public")) ? resolve(config.root, "public") : false,
192
+ resolve: {
193
+ dedupe: ["react", "react-dom", "odori"],
194
+ // Only when the runtime is present as source. A consumer resolves the
195
+ // published package through its exports map instead.
196
+ alias: odoriSrc
197
+ ? [
198
+ {find: /^odori\/preview$/, replacement: resolve(odoriSrc, "preview.ts")},
199
+ {find: /^odori\/manifest$/, replacement: resolve(odoriSrc, "manifest.ts")},
200
+ {find: /^odori$/, replacement: resolve(odoriSrc, "index.tsx")},
201
+ ]
202
+ : [],
203
+ },
204
+ server: {
205
+ host: "127.0.0.1",
206
+ port: options.port ?? config.port,
207
+ strictPort: options.strictPort ?? false,
208
+ fs: {allow: [studioRoot, config.root, installRoot]},
209
+ watch: {ignored: [`${config.root}/${config.outDir}/**`]},
210
+ },
211
+ optimizeDeps: {include: ["react", "react-dom", "react/jsx-dev-runtime"]},
212
+ });
213
+
214
+ // Rediscover when video or preview entries appear or disappear.
215
+ const rediscover = async (file: string) => {
216
+ if (!file.startsWith(resolve(config.root, config.videosDir))) return;
217
+ const isEntry = file.endsWith("video.tsx") || file.endsWith(".preview.tsx") || file.includes(`${sep}brands${sep}`);
218
+ if (!isEntry) return;
219
+ try {
220
+ graph = await discoverProject(config);
221
+ await writeGenerated(config, graph);
222
+ await registerProjectCues(graph);
223
+ } catch (error) {
224
+ // A watcher event can arrive after the source root is gone, for example
225
+ // during a branch switch. Keep the last good graph and stay alive.
226
+ vite.config.logger.warn(`[odori] rediscovery skipped: ${error instanceof Error ? error.message : String(error)}`);
227
+ return;
228
+ }
229
+ const module = vite.moduleGraph.getModuleById(RESOLVED_ID);
230
+ if (module) vite.moduleGraph.invalidateModule(module);
231
+ vite.ws.send({type: "full-reload"});
232
+ };
233
+ vite.watcher.on("add", (file) => void rediscover(file));
234
+ vite.watcher.on("unlink", (file) => void rediscover(file));
235
+ vite.watcher.add(resolve(config.root, config.videosDir));
236
+
237
+ // The config carries the asset map, so editing it changes which file a
238
+ // symbolic cue resolves to. Without this, a renamed asset keeps resolving to
239
+ // the URL the server read at startup, and the only symptom is silence.
240
+ const reloadConfig = async (file: string) => {
241
+ if (!/odori\.config\.(?:ts|mjs|js)$/.test(file)) return;
242
+ try {
243
+ config = await loadConfig(config.root);
244
+ graph = await discoverProject(config);
245
+ await writeGenerated(config, graph);
246
+ await registerProjectCues(graph);
247
+ } catch (error) {
248
+ vite.config.logger.warn(`[odori] config reload skipped: ${error instanceof Error ? error.message : String(error)}`);
249
+ return;
250
+ }
251
+ const module = vite.moduleGraph.getModuleById(RESOLVED_ID);
252
+ if (module) vite.moduleGraph.invalidateModule(module);
253
+ vite.ws.send({type: "full-reload"});
254
+ };
255
+ // Rediscovery is driven by files appearing and disappearing, but a cue is
256
+ // 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);
261
+ };
262
+ vite.watcher.on("change", (file) => void refreshCues(file));
263
+
264
+ vite.watcher.on("change", (file) => void reloadConfig(file));
265
+ for (const name of ["odori.config.ts", "odori.config.mjs", "odori.config.js"]) {
266
+ vite.watcher.add(resolve(config.root, name));
267
+ }
268
+
269
+ // Routes are real paths, so anything that is not a file and not an internal
270
+ // Vite request renders the app.
271
+ vite.middlewares.use(async (request, response, next) => {
272
+ const url = (request.url ?? "/").split("?")[0];
273
+ // A path with a file extension is an asset request. If nothing served it by
274
+ // now the file is missing, and a 404 is more useful than the app's HTML.
275
+ const routable =
276
+ request.method === "GET" &&
277
+ !url.startsWith("/@") &&
278
+ !url.startsWith("/__odori") &&
279
+ !/\.[a-zA-Z0-9]+$/.test(url);
280
+
281
+ if (!routable) {
282
+ next();
283
+ return;
284
+ }
285
+
286
+ try {
287
+ const html = await readFile(studioEntry, "utf8");
288
+ response.statusCode = 200;
289
+ response.setHeader("content-type", "text/html");
290
+ response.end(await vite.transformIndexHtml(url, html));
291
+ } catch (error) {
292
+ next(error);
293
+ }
294
+ });
295
+
296
+ await vite.listen();
297
+
298
+ const address = vite.resolvedUrls?.local[0] ?? `http://127.0.0.1:${options.port ?? config.port}`;
299
+ return {
300
+ vite,
301
+ url: address.replace(/\/$/, ""),
302
+ graph,
303
+ close: async () => {
304
+ await vite.close();
305
+ },
306
+ };
307
+ };
@@ -0,0 +1,41 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Odori Studio</title>
7
+ <!--
8
+ Inlined, not a file: the dev server serves the *project*'s public/ at the
9
+ root (see odori-cli/src/server.ts), so a /icon.svg here would 404. A 128px
10
+ PNG rather than the mark's SVG, which is a hand-drawn trace and an order of
11
+ magnitude larger inline.
12
+ -->
13
+ <link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAARGVYSWZNTQAqAAAACAABh2kABAAAAAEAAAAaAAAAAAADoAEAAwAAAAEAAQAAoAIABAAAAAEAAACAoAMABAAAAAEAAACAAAAAAEiOBHcAAAGdaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA2LjAuMCI+CiAgIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOmV4aWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vZXhpZi8xLjAvIj4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjUxMjwvZXhpZjpQaXhlbFhEaW1lbnNpb24+CiAgICAgICAgIDxleGlmOlBpeGVsWURpbWVuc2lvbj41MTI8L2V4aWY6UGl4ZWxZRGltZW5zaW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KuC9IVwAAGapJREFUeAHtnQWwHEUTgCcEd3cJbsEJHtxdgwaCS3B3dy9ck8Ld3QMUEDzBLbi7O8zfX9c/j3uX3buZ9Xvvuupq7/ZmZ0d6etqnhxUwbei2IzBKt+15u+M6Am0E6OaI0EaANgJ08xHo5t1vU4A2AnTzEejm3W9TgDYCdPMR6Obdb1OAbo4Ao3b1/v/zzz/ml19+Md988435+uuvzccff2y++uor88cffxj+69GjhxljjDHMuOOOa8Ybbzwz0UQTmfHHH18/7nvPnj277DB1KQRgkt966y3z4osvmldeecW8//775rPPPjOff/65+f77783vv//uPZEgxQQTTGCmm24606dPHzP//PObJZdc0swxxxxm1FG7zrD1aGVbABP65JNPmscee8w89dRTZvjw4eaLL77oNMlM1iSTTGImnXRSvU422WRmwgkn1BU/5phj6mRiDoEiQClAlC+//FI/IA5UwwEUYZpppjFzzz23IsPSSy9t5p13XqUirkyrXVsOAX788Ufz8MMPm/vvv9888sgj5o033ugY86mnntrMMssspnfv3vqZffbZzfTTT28mnnhiJe+jjTZaR9lmX/7++29Fhg8++ECpCpTlhRdeME8//XQnJFtqqaXM2WefrZQBhGo1aBkEeOedd8w111xjLr/8csN3YK655tLVuPLKK5tFF11UJxuynSd8++23iggPPfSQIuCrr75q+vXrZ9Zee22lMOOMM45ZYIEF8mxCtnWzBVQZhMTb/v37W2HSMFvbUUYZxcpebC+55BL722+/ldr0f//91w4bNswOGDDAbrTRRla2Gm3jCiusYG+++WYrW1Sp7fN5ufEpVEaZZ5991m644YY6oEy8kHG7yy67WCHDloGvGvz000+KlLL6O9oszKMdOnRo1ZraqT2VQ4ARI0bYnXfe2QoXrgMpXLg94YQT7Icfftip4VX9wapn9cuWYGeYYQY7xRRT2OWWW84Kz1LJJlcGAf766y97xhlnWOHWdeJFFrfHHHOMFTGukgPn0yiRIOyll15qV1ppJbvFFltYkVLsp59+6vNoYWUqwQTK6ja77babuf3225XB2Wyzzcxhhx2mnHW2HE85tb322mvm5ZdfNu+++64qpFZffXWz/PLLl9OY+rcWhmoxLxoyZIideeaZddXPM8889u67744p2fq3L7zwQqVqoouwAwcOtKKzKL1TpW4BgwYNsmOPPbZO/nbbbWdFk1f6gOTdANFbWFEgaZ9Fq2ife+65vF/ZsP5SEAAuHsZOqJEye+eff37DRna1P5EYdtxxR+0/vA58QllQCgJcddVV2nnR3Nl77rmnrL6X/l6kBcRbFsKBBx5oRftYeJtKQYBNNtlEO73sssvajz76qPBOV+mFolq2M800k47H1ltvXbhyqxQEeP75561TmLACjjjiCAtZzAtOPPFEe9111+VVfep6RTqwYlNQJFh33XXtDz/8kLpO3wpKQQAaJzZ5u++++3YofBgAsej5tjuo3J577qnKmKCHCi7MeKDiZjtYbbXVrFglC2lBaQjgeseksxXQcTHd2kMOOSRzMnjTTTdZMePmSmVcf9JcURKJ34GOxcYbb2zFYSVNdV7Plo4AtBLm54orrrDTTjutdh6EEIcOrw74FEL0wpiUl8glfgP2559/9mlK0zJi6bRi0tZxgHLljQSVQAA3KmJ7t5tvvrl2Xpwv7ODBg91fqa7sqfAaZ555Zqp6oh7GIrnwwgvba6+9NurvRPewME411VQ6Dscee2yiOnwfqhQCuEbfdtttFhGRbWH77bdPvR/++eefdrbZZrNrrLGGe0Vm1xtvvFHbmbXV79FHH7Xi22DFiSVXUbmSCMDswBljUQMJWGGiS081aeuvv75uMb/++muqemofRqGFpW+++eazGLOyBvQl4rSq/Mvbb7+ddfVaX2URgNbBG5x66qm6CrAS3nHHHYkH4aSTTtLBFNeuxHXUP/jSSy9pnYiZeYHTGIrXUy5IVmkEcIMq7ldWfPvUG+i8885zt4Oud911l1KTNEhU/8J99tnHisu4ff311+v/yuw3/Mtiiy2mbRcLaWb1uopaAgFoLNwxHjZsCUcddZRrv/dVfPf02eOPP977mUYFxTnVTjnllLZv3765eyghvcAP4CSD5jBLaBkEoNMoS8SWrhOJH17Ifi7OnDqIm266aSbjhx4fZLz44oszqa9ZJUgDvA9LoriwNyvu/X/pCICcixwNk8MqZY/GCwjOPQqQt50tYZtttvE2oMBPiLu4KlrSytaO+UO0lBiCqGZmfo+tQDyfFQkuuOCCzOovBQEYNLRzO+20k11ooYXs5JNPbsWnXpk9yBwMnwRc2K222krl6++++65Th/G723LLLXUwdthhB29KsNZaa1kJCrFQgzSAaxfeyTBoRYIEwCjTiZ8k1DALKBQBJGRLHT6djA9J8/ngMQTW15pLIYOoS3me7cAHYNooLy5aPsVjy+y1116KAHgoFwn0HzsBfchKQVQIAkDSxefPjjXWWF4THoUUEnChuoHaAceC6AbER8uH4wl1S2RRbTVB33Hjwv9/iSWWyJ35i2oYDOHoo4+umsIstp/cEQB3aOfzFzWxvvcQt/bee++RBh3+AV9CFCbNGLIHHnhAEeCyyy6LGluve4ihtPnKK6/0Kp9HITyMacNpp52WuvpcEeDcc8+1uDz5TrJPOYkHHKnTMI7wEahN2Sfj4M0331REOfLII+OKNLyPtm+RRRZRjWJR5tqoBsGDwCvNOuusqY1QuSAAXDa2fp8JDSmDA2mcHHzLLbeoUmbBBReMdahAdofB9OUZ6gffURAoUdng1OSoi9NA5ggAh84Ah0ysT1kMOcQJNoLjjjtO34uHcRwsvvjidplllon7u+F9LJVsRazArCE0zvHWW2/VvsKL1DLHoe3KHAGOPvroTCcfkefqq6/26hcD4RRFEkUc+QziI/b2OD1D5ENyE99FfAqIV8wyNhF7ApFDREWFAAgjeQp026OOpJApAsAY4dXjs6J9yqD1QgUcAiiUUNGipGHPrwf8DyUVTHAMAgYf2vz444/XV5noN/zEKaecopIR0cRJQuAInaNNXJNCZgjA3szA+kysTxlWclJGy7mdr7feeiONC5ICEkOIVRDtI567KK2yMPsSALvKKqvoWO23336Jw8ifeeYZpQCYpJOGomeCAJJapcNi5TO5zcrgIFqv/RtpJhvcgAll8nnPnXfe2akk1kDuh6xktiCewW0tLeA5hMSCLO+7tcW9k20AfkbiDBOPV2oEYD8Ei5tNqu//OG+yQtICJlooEvt9LTKxamjL9ddf7/0KVhi8SBq/P5RWu+66q75bUtekUkbVNhyKBi/AdpIEUiMAezTcsYvn953oqHKQ5pCJadbhk08+WQecqwP2WrSKvkwX6mv0/oceeqirIviKUyrcOn3GvpGVHp+GEGOB/yTibxIjVyoEQB+PSOTcuqMmNeTeBhtsEDy4jR5g1aGFxMGSWH0ASYEViMetD6B1g5IkjWDC6IXuAaY0jQYyrq0YtthSUE+7PsaVjbqfCgFQrDiyFjLRUWURsbJ0BXedZd/mfbUkEg+bddZZxxWJvbIVsVfvsccesWXi/oCBxf7Bu+FpoiSSuGdD76+55pr6nigtabO6UiGAZO1KbOCBrLrETwwSpuE8gBXPhBNz4KQKGEQUQs1g//33VwQIkRiok/xGLvSNvEZ5hr3xvsMPP1wRIEmUcWIEYDBhjtCMRa3ouHvsv05XABJQDkTI06+OgeE9F110EeNld999d2UOG4l08Aq4YaE4CgGMRfg2kAQiy1iBRm1gIdK/JJQqMQKgacP4EjfRtfcho84UDKPH79r/s3LTihskxCUshnDLUASsaLSnUYYOp9H0tfmz/8Lg0S8UO6FUI67tPvdhBBlXNIqhkAgBGFB08yGcPw1kcFj1rBCHANzHyJI3OF5A8hBZDEe0I87XnkwlMFZ4EPnAE088YSVppfaJOP+kShmfd0WVQapAEsA6GPruRAiAUwJ7nCPhbjKTXGecccbc90gGDV0AKdtYJUTdgHhxqxsRkb74OI5ImlilhKSEI6KpDGDS55xzTjW9h6qUEyEAnjWoRkP3fwaVgXfUgN++qyyLgT3ggAOU/4AaQIWiJhjRFjERB0y2izhA++mCNnBNKzv9mzOChUZQBR8YIYyTpjsTrDOCADKHYcAzZPB2IAyg+5r7VRRWRjSXRrYcI74FnTKBu5eTA1hENiM2/9j+kY5+xRVXNJKu1gi1MMLsGdE1uCpKubr3k8s4BP6bCc+nRNtkyNgNAohJ1fOp/4rJqvrvh3wj2TL3apGiU4EMf5DmXTx6zH333aeHRci20Kl2kEOsfoYs4yR/joIbbrjBiL+BHiwBIokkFFWs8HukxAfq+9SsIcEUgJTrwgQa0Ys3q7vp/6Id05UoImXTslkUEJ7FSFJnTfcOEnM+QC2Qgp6zB8Tr18gWUfuXEb98IylsNTO4aD6NMH6VmXwaKtpKba/oHDq1u9mPYARgIESM02NWmlXe7H8OYGDVQQWKAvEi1smVvV0R2b2X32Iz0AMhJPDE3dYrmT7J7Cmu6UZCy4xEBWm5ToVK/sGWBki0VFBLghGAVSscrwnFtKhWkSKW0zk49aMokDwBRsK59XVsYw5EItD8/6La1qNi3H0JKjWseE4Puffee81BBx0Uyxu4Z7K60j4WHMjZDFiUADxaCATxAOz/NIpjV0QKMO+9955X4+IaRGM51wcmSpxIDdtL3gATKrpzPfnDrRZ4GdEO6oEPEm6mTWCLk2SWuuJXXXVVIxo+IyJrZs1jHNn6OOKGRcBi4MOhVmJ4UgaVbQqQ0HYjhrKG73Y8FBQ1BIIRwDGBnIzhg5k+jeGAJ/Ze0aD5FE9dRlKxGXENM45jZmVDhcTka0QBZCD5AwYMMKLTN2I11K0hFDlhbFm9TC7Ug6NnSBbNouHDRDP5tVSIVQx15WAqUbTpeURizdRjcJp1WkRrLQKfEwJBCEBjwUoGjuzXWQFYizjFPus6klXdUfUwwKI10xPFmChR+xrxIzQSOqZH0iACwugi5m277bZRVXTcg1KIJk4nWNS/ijycYwRl++STTzoxy6xSMQ2ryCiqaSOyu76X9vTq1cuIwUqpayiy0Ri38t1W0NHAJl+CEIDz9ERRYkT3rNw7g5dEFIxqE6twyJAhhXDWTISkndGj5RAJ6Y+Yi5XUIgZyEhhnEzlegfaC9Ew0qxamVTyLdLIh22IHUFGWcug1kMnZImE42TaYXA60AsnciWWUzRIcJXHMoG/dQQgAKRMXZJWhGUS2g6yAuiTg0XAUWxIFU2g7kPXFedRIelYVocQ+oNuA+NipGAiZ5nQyUa3qSmbC4RlYnfSdiRUnUd2bxV1MpQK2D/gjRLLQlRja/vry4pmlt0CwIJB93BuwnuGDLgc6dBhz5GWZfk/rKOnbGczP5N3BW6hWNV1v4KIMySBIWkUwJipg1MVVA6cKxhchBIIoAHI7pAayD7PBfp0lFQBzxQnDiAeNntgZhMmBhdl3If9w4jB9ghDKpKHmZQ9n/6afrHxIP/wJ1AlNIqS8SiATrlsTyisxeIU1LQRbcNnCCxXPXYwpfORtmX/IACJMTUjTMi2LBw99xaEDty4sba6fOK8Ql0cQDBShCiDShuZVxECHqT4EgqyB2L2Jw3c+97IXdgyMG6CsrphZqwJCCSyevYMlcymOq3gK0U8cMTnhLJTsZt0vnE+Eb9ItLbTuIASgcuHW1fkAXwCidbOacFePMFlaL4McatoM7XzS8tjcoQDsu45/EG2hIgiuckWDS4FHBpRQCEIASCOuT3SWiXKTluUVTHZ1kzmbBBBVBrYKBh63b8YBN3QcSoo8/4j8gbw7SdKKIAQQmVcTMOAIUTtRWSJAfV0iliXydy8aaXAI4Rwkl/FcZH9LgoyQVHZJ24ykAkVOEiUchAA0EP8zOsr+7xw96yct69+iiw9mbpIOZtrnoJA4nTpEIGInz6PwEM3xB4QBREQNhSAEEJFP5WHOtoECZD3RjeoT3Xwhqyl0AOPKIyEQio7UQL9wLxf9f1zxxPfd/p80ZV0QAiCa4UeH+3NRq78WKQipZhtqJSAiyCV1It8xZxdlKeK66KOkMQhBCMDAIw4RxQNZrp2cor7j2x+aNKIKCEPwBl7JjFP//v0z4WvgL2A6kcbE+JSom8EIAFeO+zPHoDq1KQxIkVsC+x0yeVVBtKWRTQNxXWII4ghwT08DZFEHodIchBGMAHSOGHtSuRLmRQPE8NGBDEVRAt6DxrAq2jgmkv1YvIWtqJnVXhIlwmJH4GAs2o8mNWn6e95H3CH1pLGfBCMA+xf7MNG1ZfAB9QhG3r4HH3yQ8SgN4PzFVWwkKkhQahQS0FDyIKBJpD/oERrFKUZ1DIYS7h+1fG0CjKiyje4FI4CrDD4ApqZ+Qsr4zRYEQhIjVySIP4QydZxHFNdvsorFAcknoKQ8S3wkijZfOP300/U5gl3SQGIEIGMGETRF7v1xg+zusyWRPTwumWSagap9FnEYck90tHt31JWtkRhD8hbGGWmgEC7fMfwB1KQZgCiMPcxf2rwDiREALRdGIUfGogagrHswp4RrwayKC1uz8fT+H6ULzCeq8JC+oQs466yzYt+DAgfdCnWSTKJZmBkWWcoijqeFxAjAHsRq4zQuSHDIgBRZFvKMrMwp5aEMIwwbYi86dgab/TZp25nYRlw/2wlpa6ifU9LiUtKALEgQ2Euy2PJ6gEHy0mCQhhihAvrBL64VAEcOHEFwBxP+RZ0n8OETiqHNx9kFp1c8dnHwxPcPT17nPp62jxJwauSo3IaOrzjE4J8oKmQjFEwdRWvfK+K3Ef5LI5yEkaz9K9n3NCQEixex9jhMYL6tEj8go5F4tebxLPwA22VtxrK4sScZNW1AwqmVIqC6UCHqQhTPAhJvAbwcRwmYFvT0MGBV3grymNSQOp3xjIROzUzFMJlIWdQPv4HHD+Ducc0KUiEAjWAfQpQpc/JxyoAjdoaXkIkpoixjg9IHCklSTR+rHfwH/BXtw5ZAFhWYW47bgRJkBakRAPGGJEwS0aIHMmV9QITPBIEAOGQ47xyfZ4osg8KMXIFQSWz3vtm8JBah46Qwp3RLez5APeKkRgAqxFfQaQZZiVWdiCInPe5dpJwl1794HdfPReRv3NExgFEfB2mzPWQJmSAA6mH00WApZLjNDI7MgMIoQ75R4EDaySDqCyALvph5eBcFxQUIFkaCrHiDiCNcropQkYW6+U1iDIR719BzMQdrjKCsZq8oKMLK+OQCvljoUw7S1l79I69+tkRUwowNPgFoJ2HksnQM8ZmfqDI9jxTICrNQshBLB0UQ+ZXtJauqW7Ye0dhpLKFsj5pXiKhkqAFXKIDwTKX2LbEmMK7Vom41JFQg1ArNWncHUdyo9pGxIKCULGRoIoVaGqKtWSxlQuYIQGeIp5OEjJpGBszPSpVa5kAlfTeTzSqHGpLBSzKJGtHlaxQx6XaCY/mSNiTmubB0EjGV1N8mgPKcc87RUGqXvaq+THf5zQQTXMrWyHfRnJo+ffooUgSHcucwaLkgAO2E0yXvDwkVuhNgWHLGJfZ/SaurSSHIDNK3b1/d+zE4Mfn8XzbksgW4TpFBhAQMYk51t7rFlYkllB6L3tChQzUzqUgAmlYHUZC8P/zH/i9q4lLHJNe3k0lDcvQb8XQptZNFvpxJhdljoklJh36EFHRyqqnqAOAJRPwz4sZd+uQzLrkiAC+ACRQvGk2IxO+uDvRXdP462ez9rHb6L84gmv6N/EFIBpSrAuS6BdR2kKxbEr5kJFN37e0u+Z39Hc0dk0y/SSzdu3dv1QfwH+RfjGeV6HvuFMD1ksEYNGiQJmBy97rqFW8pGEE8i0j9xqonuRbJqstIINVonAujALWNkCwjKg+Lzbv2dst/Z/9n74fEwweQQ1BOJVGGEOqHbgQNIFtEVaAwClDbYbJvknC5l+TP60qAYgdxD5CjaTXRFBrAgQMHag5f9ABVmnzaWQoC8GKyZJJyXVLO8bOlgZUPiKuXZh2DyycTKFsBmcfFX1L1ImwFlYMoC1GR93CHJvGUMEfq9CAD1HJXQrRoN65fOG3KStf+4N5NEK1sBUUOadC7MnEICXpjTOFhw4Zp+jVZTS2HAA5pcYYRZlePmj344IO9fP9ihqOw25VBANdjSd5oRWXaEkiAjx+Omvgj4vVLwAaHRHM0nfPkdf2q6rVyCMBA4REryhNLljC3uqp4rU2Uic8/aWDw3q2Co4cvwlUSAVzjcZ8mft4dvV5FJGDlE9dX1ZyGbizjrpVGANdoGEU50Mn269dPY+KLRASR69Wlm4l273WZQgmOJVAzLvLXtb/K11IUQTKQiYE4RE73QqU8fPhwPZUjcWX/fxCLnEySfmrrQrzDsocLF4CSR5I+qIUT5w4Ok8Crp5Wh5RCgdrA5hgUjC+f3cbzLe5Lj301Wbblm3/HY4TnM1wCTynmA+DIQJIqDi0QHd3jyVFKeb9bJmP9bGgFq+4TRZcSIEUbESXXCQPfOb5QzKGRCAJ09h0Fgwyd1vUTzjBSlG1Jflct2GQSIGmQ8b3BS5bgXSDaHOIEMqGc5sQyvXMg8fgsYq1j5LoQchw7ud3Xo0gjQ1Scvi/6VZgvIovHtOtKPQBsB0o9hS9fQRoCWnr70jW8jQPoxbOka2gjQ0tOXvvFtBEg/hi1dQxsBWnr60je+jQDpx7Cla2gjQEtPX/rG/w/Y/N4jEXTYgAAAAABJRU5ErkJggg==" />
14
+ <script>
15
+ // Resolve the theme before the first paint, so a reload never flashes the
16
+ // opposite background while the bundle loads.
17
+ (function () {
18
+ var stored = null;
19
+ try {
20
+ stored = localStorage.getItem("odori-studio-theme");
21
+ } catch (error) {
22
+ stored = null;
23
+ }
24
+ var theme =
25
+ stored === "light" || stored === "dark"
26
+ ? stored
27
+ : matchMedia("(prefers-color-scheme: dark)").matches
28
+ ? "dark"
29
+ : "light";
30
+ var root = document.documentElement;
31
+ root.dataset.theme = theme;
32
+ root.style.colorScheme = theme;
33
+ root.style.background = theme === "dark" ? "#000000" : "#ffffff";
34
+ })();
35
+ </script>
36
+ </head>
37
+ <body>
38
+ <div id="root"></div>
39
+ <script type="module" src="/src/main.tsx"></script>
40
+ </body>
41
+ </html>
@@ -0,0 +1,192 @@
1
+ import {useEffect, useRef, useState} from "react";
2
+ import {componentPreviews, project, videos} from "virtual:odori-project";
3
+ import {VideosView} from "./views/VideosView";
4
+ import {ComponentsView} from "./views/ComponentsView";
5
+ import {BrandsView} from "./views/BrandsView";
6
+ import {AssetsView} from "./views/AssetsView";
7
+ import {HomeView} from "./views/HomeView";
8
+ import {CommandPalette} from "./components/CommandPalette";
9
+ import {Wordmark} from "./components/Wordmark";
10
+ import {Button, Icon, Kbd} from "./components/ui";
11
+ import {useTheme, type Theme} from "./theme";
12
+
13
+ const VIEWS = ["videos", "components", "brands", "assets"] as const;
14
+
15
+ const THEMES: Array<{id: Theme; label: string; icon: "display" | "sun" | "moon"}> = [
16
+ {id: "system", label: "System", icon: "display"},
17
+ {id: "light", label: "Light", icon: "sun"},
18
+ {id: "dark", label: "Dark", icon: "moon"},
19
+ ];
20
+ export type StudioView = (typeof VIEWS)[number] | "home";
21
+
22
+ const ROUTES: readonly string[] = ["home", ...VIEWS];
23
+
24
+ type Route = {view: StudioView; selection: string | null};
25
+
26
+ /**
27
+ * Routes are real paths. The dev server serves the project's public directory
28
+ * at the root, so a request for an existing file still wins; anything else
29
+ * falls back to this app.
30
+ */
31
+ const readRoute = (): Route => {
32
+ const [view, ...rest] = window.location.pathname.replace(/^\/+/, "").split("/");
33
+ return {
34
+ view: ROUTES.includes(view) ? (view as StudioView) : "home",
35
+ // A video id can contain a slash, so the remainder is the selection.
36
+ selection: rest.length > 0 ? decodeURIComponent(rest.join("/")) : null,
37
+ };
38
+ };
39
+
40
+ const pathFor = (view: StudioView, selection?: string | null): string =>
41
+ view === "home" ? "/" : `/${view}${selection ? `/${selection.split("/").map(encodeURIComponent).join("/")}` : ""}`;
42
+
43
+ export const Studio = () => {
44
+ const [route, setRoute] = useState<Route>(readRoute);
45
+ const [paletteOpen, setPaletteOpen] = useState(false);
46
+ const [mode, setMode] = useState<"player" | "gallery">("player");
47
+ const search = useRef<HTMLInputElement>(null);
48
+ const {theme, setTheme} = useTheme();
49
+
50
+ const navigate = (view: StudioView, selection?: string | null) => {
51
+ const next: Route = {view, selection: selection ?? null};
52
+ const path = pathFor(view, next.selection);
53
+ // Keep the query, so a render-mode or debugging parameter survives a click.
54
+ if (path !== window.location.pathname) window.history.pushState(null, "", `${path}${window.location.search}`);
55
+ setRoute(next);
56
+ };
57
+
58
+ useEffect(() => {
59
+ const onPopState = () => setRoute(readRoute());
60
+ window.addEventListener("popstate", onPopState);
61
+ return () => window.removeEventListener("popstate", onPopState);
62
+ }, []);
63
+
64
+ useEffect(() => {
65
+ const onKeyDown = (event: KeyboardEvent) => {
66
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
67
+ event.preventDefault();
68
+ setPaletteOpen((value) => !value);
69
+ }
70
+ if ((event.metaKey || event.ctrlKey) && ["1", "2", "3", "4"].includes(event.key)) {
71
+ event.preventDefault();
72
+ navigate(VIEWS[Number(event.key) - 1]);
73
+ }
74
+ const target = event.target as HTMLElement | null;
75
+ const typing = Boolean(target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));
76
+ if (typing || event.metaKey || event.ctrlKey) return;
77
+
78
+ if (event.key === "/") {
79
+ event.preventDefault();
80
+ search.current?.focus();
81
+ }
82
+ if (event.key.toLowerCase() === "g") {
83
+ setMode((value) => (value === "player" ? "gallery" : "player"));
84
+ }
85
+ };
86
+ window.addEventListener("keydown", onKeyDown);
87
+ return () => window.removeEventListener("keydown", onKeyDown);
88
+ }, []);
89
+
90
+ const galleryCapable = route.view === "videos" || route.view === "components";
91
+ const singleColumn = route.view === "assets" || route.view === "home" || (galleryCapable && mode === "gallery");
92
+
93
+ return (
94
+ <div className="studio">
95
+ <header className="topbar">
96
+ <button
97
+ type="button"
98
+ className="wordmark"
99
+ aria-label="Project overview"
100
+ data-active={route.view === "home" ? "true" : undefined}
101
+ onClick={() => navigate("home")}
102
+ >
103
+ <Wordmark />
104
+ </button>
105
+ <div className="views">
106
+ {VIEWS.map((view) => (
107
+ <Button key={view} active={view === route.view} onClick={() => navigate(view)}>
108
+ {view}
109
+ </Button>
110
+ ))}
111
+ </div>
112
+ <div className="topbar-right">
113
+ <div className="search">
114
+ <input
115
+ ref={search}
116
+ className="input"
117
+ placeholder="Search"
118
+ value=""
119
+ readOnly
120
+ onFocus={() => setPaletteOpen(true)}
121
+ onKeyDown={(event) => {
122
+ if (event.key === "Escape") event.currentTarget.blur();
123
+ }}
124
+ />
125
+ <Kbd>⌘K</Kbd>
126
+ </div>
127
+ <div className="tabs" role="group" aria-label="Theme">
128
+ {THEMES.map((option) => (
129
+ <Button
130
+ key={option.id}
131
+ icon
132
+ active={theme === option.id}
133
+ aria-pressed={theme === option.id}
134
+ aria-label={option.label}
135
+ title={`${option.label} theme`}
136
+ onClick={() => setTheme(option.id)}
137
+ >
138
+ <Icon name={option.icon} />
139
+ </Button>
140
+ ))}
141
+ </div>
142
+ </div>
143
+ </header>
144
+
145
+ <main className="main" data-single={singleColumn ? "true" : undefined}>
146
+ {route.view === "videos" ? (
147
+ <VideosView selection={route.selection} onSelect={(id) => navigate("videos", id)} mode={mode} />
148
+ ) : null}
149
+ {route.view === "components" ? (
150
+ <ComponentsView
151
+
152
+ selection={route.selection}
153
+ onSelect={(id) => navigate("components", id)}
154
+ mode={mode}
155
+ />
156
+ ) : null}
157
+ {route.view === "brands" ? <BrandsView /> : null}
158
+ {route.view === "assets" ? <AssetsView /> : null}
159
+ {route.view === "home" ? <HomeView onOpen={(view, selection) => navigate(view, selection)} /> : null}
160
+ </main>
161
+
162
+ <footer className="statusbar">
163
+ <span>source {project.sourceHash.slice(0, 10)}</span>
164
+ <a className="statusbar-link" href={project.docsUrl} target="_blank" rel="noreferrer">
165
+ Documentation
166
+ <Icon name="external" />
167
+ </a>
168
+ <span className="spacer" />
169
+ <span>space play</span>
170
+ <span>arrows step</span>
171
+ <span>- = speed</span>
172
+ <span>[ ] scene</span>
173
+ <span>s safe area</span>
174
+ <span>g gallery</span>
175
+ </footer>
176
+
177
+ <CommandPalette
178
+ open={paletteOpen}
179
+ 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
+ }}
188
+ onView={(view) => navigate(view as StudioView)}
189
+ />
190
+ </div>
191
+ );
192
+ };
@@ -0,0 +1,64 @@
1
+ import {useEffect, useRef, useState} from "react";
2
+ import {Waveform, useAudioPeaks} from "./Waveform";
3
+ import {Button, Icon} from "./ui";
4
+
5
+ /** Seconds, at the precision a short cue actually needs. */
6
+ export const clipLength = (seconds: number) => (seconds >= 10 ? `${seconds.toFixed(0)}s` : `${seconds.toFixed(2)}s`);
7
+
8
+ /**
9
+ * Audition one file without leaving Studio.
10
+ *
11
+ * The element is created on demand rather than per row, so a library of
12
+ * hundreds of sounds costs nothing until something is played.
13
+ */
14
+ export const AudioClip = ({url, label, shape}: {url: string; label?: string; shape?: boolean}) => {
15
+ const element = useRef<HTMLAudioElement | null>(null);
16
+ const [playing, setPlaying] = useState(false);
17
+ const {duration} = useAudioPeaks(url);
18
+
19
+ useEffect(
20
+ () => () => {
21
+ element.current?.pause();
22
+ element.current = null;
23
+ },
24
+ [],
25
+ );
26
+
27
+ const toggle = () => {
28
+ if (!element.current) {
29
+ element.current = new window.Audio(url);
30
+ element.current.addEventListener("ended", () => setPlaying(false));
31
+ }
32
+ if (playing) {
33
+ element.current.pause();
34
+ setPlaying(false);
35
+ return;
36
+ }
37
+ void element.current.play().then(
38
+ () => setPlaying(true),
39
+ // Autoplay policy rejects until the page has been interacted with. The
40
+ // click that got here is that interaction, so a failure is a real one.
41
+ () => setPlaying(false),
42
+ );
43
+ };
44
+
45
+ const control = (
46
+ <Button icon aria-label={playing ? `Pause ${label ?? url}` : `Play ${label ?? url}`} onClick={toggle}>
47
+ <Icon name={playing ? "pause" : "play"} />
48
+ </Button>
49
+ );
50
+
51
+ if (!shape) return control;
52
+
53
+ // A third of a second at low level is over before the button finishes
54
+ // changing, so the clip shows its own shape and length.
55
+ return (
56
+ <div className="clip">
57
+ {control}
58
+ <div className="clip-shape">
59
+ <Waveform url={url} />
60
+ </div>
61
+ {duration > 0 ? <span className="clip-length">{clipLength(duration)}</span> : null}
62
+ </div>
63
+ );
64
+ };