@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.
- package/LICENSE +22 -0
- package/bin/odori.mjs +39 -0
- package/dist/chunk-7XJL2BYO.js +3552 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +10 -0
- package/dist/index.d.ts +622 -0
- package/dist/index.js +156 -0
- package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
- package/package.json +50 -0
- package/src/audio-mix.ts +133 -0
- package/src/binaries.ts +241 -0
- package/src/brand-file.ts +94 -0
- package/src/chunk-cache.ts +85 -0
- package/src/chunks.ts +78 -0
- package/src/cli.ts +319 -0
- package/src/commands/add.ts +151 -0
- package/src/commands/dev.ts +160 -0
- package/src/commands/doctor.ts +162 -0
- package/src/commands/exportVideo.ts +198 -0
- package/src/commands/init.ts +56 -0
- package/src/commands/inspect.ts +72 -0
- package/src/commands/list.ts +22 -0
- package/src/commands/new.ts +126 -0
- package/src/commands/shared.ts +96 -0
- package/src/commands/still.ts +40 -0
- package/src/commands/test.ts +265 -0
- package/src/commands/update.ts +183 -0
- package/src/config.ts +84 -0
- package/src/contracts.ts +159 -0
- package/src/cues.ts +141 -0
- package/src/determinism.ts +82 -0
- package/src/diff.ts +71 -0
- package/src/discovery.ts +216 -0
- package/src/formats.ts +119 -0
- package/src/index.ts +58 -0
- package/src/integrity.ts +101 -0
- package/src/jobs.ts +151 -0
- package/src/log.ts +17 -0
- package/src/open.ts +32 -0
- package/src/paths.ts +12 -0
- package/src/prepare-cache.ts +58 -0
- package/src/project.ts +196 -0
- package/src/registry-snapshot.json +3431 -0
- package/src/registry-source.ts +269 -0
- package/src/render.ts +627 -0
- package/src/server.ts +307 -0
- package/studio/index.html +41 -0
- package/studio/src/Studio.tsx +192 -0
- package/studio/src/components/AudioClip.tsx +64 -0
- package/studio/src/components/CanvasStage.tsx +79 -0
- package/studio/src/components/CommandPalette.tsx +129 -0
- package/studio/src/components/Diagnostics.tsx +93 -0
- package/studio/src/components/ExportPanel.tsx +234 -0
- package/studio/src/components/InputControls.tsx +110 -0
- package/studio/src/components/Thumbnail.tsx +71 -0
- package/studio/src/components/Transport.tsx +237 -0
- package/studio/src/components/Waveform.tsx +114 -0
- package/studio/src/components/Wordmark.tsx +449 -0
- package/studio/src/components/ui.tsx +138 -0
- package/studio/src/lib/mix-loudness.ts +52 -0
- package/studio/src/main.tsx +34 -0
- package/studio/src/shortcuts.ts +27 -0
- package/studio/src/studio.css +1232 -0
- package/studio/src/theme.ts +61 -0
- package/studio/src/views/AssetsView.tsx +111 -0
- package/studio/src/views/BrandsView.tsx +139 -0
- package/studio/src/views/ComponentsView.tsx +285 -0
- package/studio/src/views/HomeView.tsx +122 -0
- package/studio/src/views/VideosView.tsx +343 -0
- 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++++3YofBgAsej5tjuo3J577qnKmKCHCi7MeKDiZjtYbbXVrFglC2lBaQjgeseksxXQcTHd2kMOOSRzMnjTTTdZMePmSmVcf9JcURKJ34GOxcYbb2zF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" />
|
|
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
|
+
};
|