@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
@@ -0,0 +1,122 @@
1
+ import {entryDurationInFrames, framesFromDuration, resolveEntryLayout} from "odori";
2
+ import {componentPreviews, project, videos} from "virtual:odori-project";
3
+ import {Thumbnail} from "../components/Thumbnail";
4
+ import {AudioClip} from "../components/AudioClip";
5
+ import {SectionTitle} from "../components/ui";
6
+ import {collectBrands} from "./BrandsView";
7
+ import {entryFor} from "./ComponentsView";
8
+ import type {StudioView} from "../Studio";
9
+
10
+ /**
11
+ * What the project contains, shown rather than listed.
12
+ *
13
+ * Every other view answers a question about one thing. This one answers "what
14
+ * is in here", which is the question you have on arrival, so each card plays a
15
+ * frame of the real composition and opens the view that owns it.
16
+ */
17
+ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: string) => void}) => {
18
+ const brands = collectBrands();
19
+ const brand = brands[0];
20
+ const fonts = [
21
+ ...new Map(videos.flatMap((video) => resolveEntryLayout(video).brand.fonts).map((font) => [font.url, font])).values(),
22
+ ];
23
+
24
+ return (
25
+ <div className="home">
26
+ <section>
27
+ <SectionTitle>Videos</SectionTitle>
28
+ <div className="gallery">
29
+ {videos.map((video) => {
30
+ const layout = resolveEntryLayout(video);
31
+ return (
32
+ <button key={video.metadata.id} type="button" className="card" onClick={() => onOpen("videos", video.metadata.id)}>
33
+ <Thumbnail
34
+ entry={video}
35
+ frame={video.metadata.thumbnailFrame ?? Math.round(entryDurationInFrames(video, layout) * 0.4)}
36
+ durationInFrames={entryDurationInFrames(video, layout)}
37
+ />
38
+ <div className="card-meta">
39
+ <strong>{video.metadata.title}</strong>
40
+ <span>
41
+ {layout.format.width}x{layout.format.height} · {video.metadata.id}
42
+ </span>
43
+ </div>
44
+ </button>
45
+ );
46
+ })}
47
+ </div>
48
+ </section>
49
+
50
+ <section>
51
+ <SectionTitle>Components</SectionTitle>
52
+ <div className="gallery">
53
+ {componentPreviews.map((item) => {
54
+ const defaults = Object.fromEntries(
55
+ Object.entries(item.preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
56
+ );
57
+ const total = framesFromDuration(item.preview.canvas.duration, 30);
58
+ return (
59
+ <button key={item.id} type="button" className="card" onClick={() => onOpen("components", item.id)}>
60
+ <Thumbnail
61
+ entry={entryFor(
62
+ item,
63
+ {...defaults, ...(item.preview.examples[0]?.props ?? {})},
64
+ brand ?? resolveEntryLayout(videos[0]).brand,
65
+ {width: 1920, height: 1080},
66
+ )}
67
+ frame={Math.round(total * 0.6)}
68
+ durationInFrames={total}
69
+ />
70
+ <div className="card-meta">
71
+ <strong>{item.preview.title}</strong>
72
+ <span>{item.preview.category.toLowerCase()}</span>
73
+ </div>
74
+ </button>
75
+ );
76
+ })}
77
+ </div>
78
+ </section>
79
+
80
+ <section>
81
+ <SectionTitle>Brands</SectionTitle>
82
+ <div className="home-swatches">
83
+ {brands.map((item) => (
84
+ <button key={item.name} type="button" className="brand-card" onClick={() => onOpen("brands", item.name)}>
85
+ <div className="swatch-colors">
86
+ {[item.colors.background, item.colors.surface, item.colors.foreground, item.colors.accent, item.colors.border].map(
87
+ (color) => (
88
+ <span key={color} style={{background: color}} />
89
+ ),
90
+ )}
91
+ </div>
92
+ <div className="card-meta">
93
+ <strong>{item.name}</strong>
94
+ <span>
95
+ {item.typography.sans.split(",")[0].replace(/"/g, "")} · {item.audio.targetLufs} LUFS
96
+ </span>
97
+ </div>
98
+ </button>
99
+ ))}
100
+ </div>
101
+ </section>
102
+
103
+ <section>
104
+ <SectionTitle>Assets</SectionTitle>
105
+ <ul className="asset-list">
106
+ {project.audio.slice(0, 4).map((entry) => (
107
+ <li key={entry.url}>
108
+ <AudioClip url={entry.url} label={entry.name} shape />
109
+ <div style={{display: "flex", flexDirection: "column", gap: 2, minWidth: 0}}>
110
+ <strong>{entry.name}</strong>
111
+ <code>{entry.url}</code>
112
+ </div>
113
+ </li>
114
+ ))}
115
+ </ul>
116
+ <button type="button" className="home-more" onClick={() => onOpen("assets")}>
117
+ {project.assets.length} declared assets · {project.audio.length} sounds · {fonts.length} fonts
118
+ </button>
119
+ </section>
120
+ </div>
121
+ );
122
+ };
@@ -0,0 +1,343 @@
1
+ import {useCallback, useEffect, useMemo, useRef, useState} from "react";
2
+ import {
3
+ DUCK_GAIN,
4
+ OdoriRuntime,
5
+ entryDurationInFrames,
6
+ isOdoriSchema,
7
+ resolveEntryLayout,
8
+ usePlayback,
9
+ useAudioPlayback,
10
+ type AudioTrack,
11
+ type CompiledTimeline,
12
+ type FieldDescriptor,
13
+ } from "odori";
14
+ import {PreviewBoundary} from "odori/preview";
15
+ import {project, videos} from "virtual:odori-project";
16
+ import {CanvasStage} from "../components/CanvasStage";
17
+ import {RATES, Transport} from "../components/Transport";
18
+ import {Thumbnail} from "../components/Thumbnail";
19
+ import {InputControls} from "../components/InputControls";
20
+ import {ExportPanel} from "../components/ExportPanel";
21
+ import {Diagnostics} from "../components/Diagnostics";
22
+ import {Badge, Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
23
+ import {measureTrack} from "../lib/mix-loudness";
24
+ import {useShortcuts} from "../shortcuts";
25
+
26
+ export const VideosView = ({
27
+ selection,
28
+ onSelect,
29
+ mode,
30
+ }: {
31
+ selection: string | null;
32
+ onSelect: (id: string) => void;
33
+ mode: "player" | "gallery";
34
+ }) => {
35
+ // Finding one by name is ⌘K; this list is for browsing them all.
36
+ const filtered = videos;
37
+ const selected = filtered.find((video) => video.metadata.id === selection) ?? filtered[0] ?? videos[0];
38
+ const [input, setInput] = useState<Record<string, unknown>>({});
39
+ const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
40
+ const [track, setTrack] = useState<AudioTrack | null>(null);
41
+ const [loop, setLoop] = useState(true);
42
+ const [muted, setMuted] = useState(false);
43
+ const [soloCue, setSoloCue] = useState<string | null>(null);
44
+ const [scrubbing, setScrubbing] = useState(false);
45
+ const [rate, setRate] = useState(1);
46
+ const [loudness, setLoudness] = useState<number | null>(null);
47
+ const [audioBlocked, setAudioBlocked] = useState(false);
48
+ const [audioFailed, setAudioFailed] = useState<string[]>([]);
49
+ const [showSafeArea, setShowSafeArea] = useState(false);
50
+
51
+ const layout = selected ? resolveEntryLayout(selected) : null;
52
+ const declared = selected && layout ? entryDurationInFrames(selected, layout) : 0;
53
+ const durationInFrames = Math.max(1, declared || timeline?.durationInFrames || 30);
54
+ const playback = usePlayback({
55
+ fps: layout?.format.fps ?? 30,
56
+ durationInFrames,
57
+ rate,
58
+ autoPlay: true,
59
+ loop,
60
+ });
61
+
62
+ // Store compiled output only when it changes, so a runtime report can never
63
+ // feed a render loop.
64
+ const handleTimeline = useCallback(
65
+ (next: CompiledTimeline) =>
66
+ setTimeline((current) => (JSON.stringify(current) === JSON.stringify(next) ? current : next)),
67
+ [],
68
+ );
69
+ // Measuring decodes and renders the whole track, so it runs when the track
70
+ // changes rather than on every frame.
71
+ useEffect(() => {
72
+ const fps = layout?.format.fps;
73
+ if (!track || track.cues.length === 0 || !fps) {
74
+ setLoudness(null);
75
+ return undefined;
76
+ }
77
+ let live = true;
78
+ setLoudness(null);
79
+ void measureTrack(track, fps, durationInFrames).then((value) => {
80
+ if (live) setLoudness(value);
81
+ });
82
+ return () => {
83
+ live = false;
84
+ };
85
+ }, [durationInFrames, layout?.format.fps, track]);
86
+
87
+ const handleAudio = useCallback(
88
+ (next: AudioTrack) => setTrack((current) => (JSON.stringify(current) === JSON.stringify(next) ? current : next)),
89
+ [],
90
+ );
91
+
92
+ useAudioPlayback({
93
+ track,
94
+ frame: playback.frame,
95
+ fps: layout?.format.fps ?? 30,
96
+ playing: playback.playing,
97
+ muted,
98
+ soloCue,
99
+ scrubbing,
100
+ rate,
101
+ onBlocked: setAudioBlocked,
102
+ onFailed: setAudioFailed,
103
+ });
104
+
105
+ // The runtime publishes the timeline and the audio track from its own
106
+ // effects, which commit before this component's. Clearing them in an effect
107
+ // therefore discarded what the newly selected video had just published, and
108
+ // the track never came back. Resetting during render instead puts the clear
109
+ // ahead of the publish, which is the order the data actually flows in.
110
+ const [renderedId, setRenderedId] = useState(selected?.metadata.id);
111
+ if (selected?.metadata.id !== renderedId) {
112
+ setRenderedId(selected?.metadata.id);
113
+ setInput({});
114
+ setTimeline(null);
115
+ setTrack(null);
116
+ }
117
+
118
+ const previousId = useRef(selected?.metadata.id);
119
+ useEffect(() => {
120
+ const id = selected?.metadata.id;
121
+ if (previousId.current === id) return;
122
+ previousId.current = id;
123
+ playback.seek(0);
124
+ playback.play();
125
+ // eslint-disable-next-line react-hooks/exhaustive-deps
126
+ }, [selected?.metadata.id]);
127
+
128
+ const scenes = timeline?.scenes ?? [];
129
+ const sceneJump = (direction: 1 | -1) => {
130
+ const boundaries = [0, ...scenes.map((scene) => scene.start), durationInFrames - 1];
131
+ const next =
132
+ direction === 1
133
+ ? boundaries.find((boundary) => boundary > playback.frame)
134
+ : [...boundaries].reverse().find((boundary) => boundary < playback.frame);
135
+ playback.seek(next ?? playback.frame);
136
+ };
137
+
138
+ useShortcuts({
139
+ " ": () => playback.toggle(),
140
+ ArrowRight: (event) => playback.step(event.shiftKey ? (layout?.format.fps ?? 30) : 1),
141
+ ArrowLeft: (event) => playback.step(event.shiftKey ? -(layout?.format.fps ?? 30) : -1),
142
+ "-": () => setRate((value) => RATES[Math.max(0, RATES.indexOf(value) - 1)] ?? value),
143
+ "=": () => setRate((value) => RATES[Math.min(RATES.length - 1, RATES.indexOf(value) + 1)] ?? value),
144
+ Home: () => playback.seek(0),
145
+ End: () => playback.seek(durationInFrames - 1),
146
+ "]": () => sceneJump(1),
147
+ "[": () => sceneJump(-1),
148
+ l: () => setLoop((value) => !value),
149
+ m: () => setMuted((value) => !value),
150
+ s: () => setShowSafeArea((value) => !value),
151
+ });
152
+
153
+ if (!selected || !layout) {
154
+ return (
155
+ <Empty title="No videos yet">
156
+ Create <code>{project.videosDir}/launch/video.tsx</code> or run <code>odori new launch</code>. A video exists
157
+ because its file exists.
158
+ </Empty>
159
+ );
160
+ }
161
+
162
+ if (mode === "gallery") {
163
+ return (
164
+ <div className="gallery">
165
+ {filtered.map((video) => {
166
+ const videoLayout = resolveEntryLayout(video);
167
+ return (
168
+ <button
169
+ key={video.metadata.id}
170
+ type="button"
171
+ className="card"
172
+ data-active={video.metadata.id === selected.metadata.id ? "true" : undefined}
173
+ onClick={() => onSelect(video.metadata.id)}
174
+ >
175
+ <Thumbnail
176
+ entry={video}
177
+ frame={video.metadata.thumbnailFrame ?? Math.round(entryDurationInFrames(video, videoLayout) * 0.4)}
178
+ durationInFrames={entryDurationInFrames(video, videoLayout)}
179
+ />
180
+ <div className="card-meta">
181
+ <strong>{video.metadata.title}</strong>
182
+ <span>
183
+ {videoLayout.format.width}x{videoLayout.format.height} · {String(video.metadata.duration ?? "auto")}
184
+ </span>
185
+ </div>
186
+ </button>
187
+ );
188
+ })}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ const schema = selected.metadata.schema;
194
+ const fields: Record<string, FieldDescriptor> = isOdoriSchema(schema) ? schema.describe() : {};
195
+ const file = project.files.videos.find((entry) => entry.id === selected.metadata.id)?.file;
196
+
197
+ return (
198
+ <>
199
+ <aside className="sidebar">
200
+ <div className="sidebar-scroll">
201
+ <ul className="list">
202
+ {filtered.map((video) => (
203
+ <li key={video.metadata.id}>
204
+ <button
205
+ type="button"
206
+ className="list-item"
207
+ data-active={video.metadata.id === selected.metadata.id ? "true" : undefined}
208
+ onClick={() => onSelect(video.metadata.id)}
209
+ >
210
+ {/* Format and duration are facts about the selection, and the
211
+ inspector states both. The list is for choosing. */}
212
+ <strong>{video.metadata.title}</strong>
213
+ </button>
214
+ </li>
215
+ ))}
216
+ </ul>
217
+
218
+ </div>
219
+ </aside>
220
+
221
+ <section className="stage">
222
+ <CanvasStage
223
+ width={layout.format.width}
224
+ height={layout.format.height}
225
+ safeArea={layout.safeArea}
226
+ showSafeArea={showSafeArea}
227
+ >
228
+ {/* A composition that throws costs the stage, not Studio: the
229
+ transport, the inspector, and the file list stay usable while
230
+ the error is read and fixed. */}
231
+ <PreviewBoundary resetKey={selected.metadata.id}>
232
+ <OdoriRuntime
233
+ entry={selected}
234
+ frame={playback.frame}
235
+ input={input}
236
+ assets={project.assets}
237
+ onTimeline={handleTimeline}
238
+ onAudio={handleAudio}
239
+ />
240
+ </PreviewBoundary>
241
+ </CanvasStage>
242
+ <Transport
243
+ playback={playback}
244
+ timeline={timeline}
245
+ track={track}
246
+ durationInFrames={durationInFrames}
247
+ fps={layout.format.fps}
248
+ loop={loop}
249
+ muted={muted}
250
+ soloCue={soloCue}
251
+ audioBlocked={audioBlocked}
252
+ onEnableAudio={() => setMuted(false)}
253
+ onSoloCue={setSoloCue}
254
+ onScrubbing={setScrubbing}
255
+ rate={rate}
256
+ onRate={setRate}
257
+ onToggleLoop={() => setLoop((value) => !value)}
258
+ onToggleMuted={() => setMuted((value) => !value)}
259
+ />
260
+ </section>
261
+
262
+ <aside className="inspector">
263
+ <SectionTitle>Video</SectionTitle>
264
+ <dl className="facts">
265
+ <Fact label="id">{selected.metadata.id}</Fact>
266
+ <Fact label="format">
267
+ {layout.format.width}x{layout.format.height} at {layout.format.fps}fps
268
+ </Fact>
269
+ <Fact label="duration">
270
+ {durationInFrames}f · {(durationInFrames / layout.format.fps).toFixed(2)}s
271
+ </Fact>
272
+ <Fact label="brand">{layout.brand.name}</Fact>
273
+ {file ? <Fact label="source">{file}</Fact> : null}
274
+ {(selected.metadata.tags ?? []).length > 0 ? (
275
+ <Fact label="tags">
276
+ <span style={{display: "flex", flexWrap: "wrap", gap: 6}}>
277
+ {(selected.metadata.tags ?? []).map((tag) => (
278
+ <Badge key={tag}>{tag}</Badge>
279
+ ))}
280
+ </span>
281
+ </Fact>
282
+ ) : null}
283
+ </dl>
284
+ {selected.metadata.description ? <p className="hint">{selected.metadata.description}</p> : null}
285
+ <div style={{marginTop: 8}}>
286
+ <Button variant="outline" active={showSafeArea} onClick={() => setShowSafeArea((value) => !value)}>
287
+ Safe area
288
+ </Button>
289
+ </div>
290
+
291
+ {Object.keys(fields).length > 0 ? (
292
+ <>
293
+ <Separator />
294
+ <SectionTitle>Inputs</SectionTitle>
295
+ <InputControls fields={fields} value={input} onChange={setInput} />
296
+ </>
297
+ ) : null}
298
+
299
+ {/* A silent cut is audible as silence, and the cue lane is empty, so
300
+ the section appears once there is a track to describe. */}
301
+ {track && track.cues.length > 0 ? (
302
+ <>
303
+ <Separator />
304
+ <SectionTitle>Audio</SectionTitle>
305
+ <dl className="facts">
306
+ {track.cues.map((cue) => (
307
+ <Fact key={cue.id} label={cue.src.split("/").pop() ?? cue.src}>
308
+ {cue.fromFrame}-{cue.fromFrame + cue.durationInFrames - 1} · gain {cue.gain}
309
+ {cue.duckUnder ? ` · ducked to ${DUCK_GAIN}` : ""}
310
+ {soloCue === cue.id ? " · solo" : ""}
311
+ {/* A cue whose file will not load is silence with no other
312
+ symptom, so it says so where its gain is read. */}
313
+ {audioFailed.includes(cue.src) ? (
314
+ <span style={{color: "var(--warning, #e5a663)"}}> · file will not load</span>
315
+ ) : null}
316
+ </Fact>
317
+ ))}
318
+ {/* Declared policy, so the number the encoder normalizes to is
319
+ visible while mixing rather than only after an export. */}
320
+ <Fact label="loudness">
321
+ {loudness === null ? "measuring" : `${loudness.toFixed(1)} LUFS`}
322
+ {" · "}
323
+ <span style={{color: "var(--subtle)"}}>
324
+ target {layout.brand.audio.targetLufs}, normalized on export
325
+ </span>
326
+ </Fact>
327
+ </dl>
328
+ </>
329
+ ) : null}
330
+
331
+ <Diagnostics entry={selected} timeline={timeline} track={track} />
332
+
333
+ <Separator />
334
+ <ExportPanel
335
+ videoId={selected.metadata.id}
336
+ input={input}
337
+ frame={playback.frame}
338
+ exportDir={project.exportDir}
339
+ />
340
+ </aside>
341
+ </>
342
+ );
343
+ };
@@ -0,0 +1,25 @@
1
+ declare module "virtual:odori-project" {
2
+ import type {VideoEntry} from "odori";
3
+ import type {ComponentPreview} from "odori/preview";
4
+
5
+ import type {Brand} from "odori";
6
+
7
+ export const videos: VideoEntry[];
8
+ export const brands: Brand[];
9
+ export const componentPreviews: Array<{id: string; preview: ComponentPreview<any>}>;
10
+ export const project: {
11
+ root: string;
12
+ videosDir: string;
13
+ docsUrl: string;
14
+ exportDir: string;
15
+ audioDir: string;
16
+ audio: Array<{name: string; url: string; relativeFile: string; bytes: number}>;
17
+ sourceHash: string;
18
+ assets: Array<{reference: string; url: string}>;
19
+ files: {
20
+ videos: Array<{id: string; file: string}>;
21
+ previews: Array<{id: string; file: string}>;
22
+ brands: Array<{id: string; file: string}>;
23
+ };
24
+ };
25
+ }