@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,71 @@
1
+ import {useState} from "react";
2
+ import {OdoriRuntime, resolveEntryLayout, usePlayback, type VideoEntry} from "odori";
3
+ import {PreviewBoundary} from "odori/preview";
4
+ import {project} from "virtual:odori-project";
5
+ import {useFitScale} from "./CanvasStage";
6
+
7
+ /**
8
+ * A gallery thumbnail is the real composition at a chosen frame, letterboxed
9
+ * into a uniform card. Hovering plays it through the same runtime, so the
10
+ * catalog can never drift from the render.
11
+ */
12
+ export const Thumbnail = ({
13
+ entry,
14
+ frame = 0,
15
+ durationInFrames,
16
+ playOnHover = true,
17
+ }: {
18
+ entry: VideoEntry;
19
+ frame?: number;
20
+ durationInFrames?: number;
21
+ playOnHover?: boolean;
22
+ }) => {
23
+ const layout = resolveEntryLayout(entry);
24
+ const {width, height, fps} = layout.format;
25
+ const [container, scale] = useFitScale(width, height);
26
+ const [hovering, setHovering] = useState(false);
27
+ const playback = usePlayback({
28
+ fps,
29
+ durationInFrames: Math.max(1, durationInFrames ?? fps * 8),
30
+ initialFrame: frame,
31
+ autoPlay: false,
32
+ loop: true,
33
+ });
34
+
35
+ const hover = (next: boolean) => {
36
+ if (!playOnHover) return;
37
+ setHovering(next);
38
+ if (next) playback.play();
39
+ else {
40
+ playback.pause();
41
+ playback.seek(frame);
42
+ }
43
+ };
44
+
45
+ return (
46
+ <div
47
+ className="card-canvas"
48
+ ref={container}
49
+ onPointerEnter={() => hover(true)}
50
+ onPointerLeave={() => hover(false)}
51
+ >
52
+ {/* Absolutely positioned so the composition never sizes the card. */}
53
+ <div
54
+ style={{
55
+ height,
56
+ left: "50%",
57
+ position: "absolute",
58
+ top: "50%",
59
+ transform: `translate(-50%, -50%) scale(${scale})`,
60
+ width,
61
+ }}
62
+ >
63
+ {/* A card in a grid of forty: one broken component must not blank
64
+ the library it is listed in. */}
65
+ <PreviewBoundary resetKey={entry.metadata.id} label="Threw">
66
+ <OdoriRuntime entry={entry} frame={hovering ? playback.frame : frame} assets={project.assets} />
67
+ </PreviewBoundary>
68
+ </div>
69
+ </div>
70
+ );
71
+ };
@@ -0,0 +1,237 @@
1
+ import {useEffect, useRef} from "react";
2
+ import {formatTimecode, type AudioTrack, type CompiledTimeline, type Playback} from "odori";
3
+ import {Button, Icon} from "./ui";
4
+
5
+ /**
6
+ * Transport plus a scene-aware timeline. The track is the scene structure the
7
+ * runtime compiled, not a decorative bar.
8
+ */
9
+ export const RATES = [0.25, 0.5, 1, 1.5, 2, 4];
10
+
11
+ export const Transport = ({
12
+ playback,
13
+ timeline,
14
+ track,
15
+ durationInFrames,
16
+ fps,
17
+ loop,
18
+ muted,
19
+ soloCue,
20
+ audioBlocked,
21
+ onEnableAudio,
22
+ onToggleLoop,
23
+ onToggleMuted,
24
+ onSoloCue,
25
+ onScrubbing,
26
+ rate = 1,
27
+ onRate,
28
+ }: {
29
+ playback: Playback;
30
+ timeline: CompiledTimeline | null;
31
+ track?: AudioTrack | null;
32
+ durationInFrames: number;
33
+ fps: number;
34
+ loop: boolean;
35
+ muted?: boolean;
36
+ /** Cue id auditioned alone, or null for the whole mix. */
37
+ soloCue?: string | null;
38
+ /** The browser is refusing to start cues until the page is interacted with. */
39
+ audioBlocked?: boolean;
40
+ onEnableAudio?: () => void;
41
+ onToggleLoop: () => void;
42
+ onToggleMuted?: () => void;
43
+ onSoloCue?: (id: string | null) => void;
44
+ onScrubbing?: (scrubbing: boolean) => void;
45
+ rate?: number;
46
+ onRate?: (rate: number) => void;
47
+ }) => {
48
+ const trackRef = useRef<HTMLDivElement>(null);
49
+ const scrubbing = useRef(false);
50
+ // Scrubbing pauses the clock so the drag owns the playhead, then hands
51
+ // playback back in the state it was in. Picking a frame is not a request to
52
+ // stop.
53
+ const resumeAfterScrub = useRef(false);
54
+ // A click picks a frame; only a drag auditions. Audio waits for movement so
55
+ // choosing a frame while paused stays silent.
56
+ const dragOrigin = useRef(0);
57
+ const auditioning = useRef(false);
58
+ const {frame} = playback;
59
+
60
+ const scrubTo = (clientX: number) => {
61
+ const element = trackRef.current;
62
+ if (!element) return;
63
+ const bounds = element.getBoundingClientRect();
64
+ const ratio = Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width));
65
+ playback.seek(Math.round(ratio * (durationInFrames - 1)));
66
+ };
67
+
68
+ useEffect(() => {
69
+ const move = (event: PointerEvent) => {
70
+ if (!scrubbing.current) return;
71
+ if (!auditioning.current && Math.abs(event.clientX - dragOrigin.current) > 3) {
72
+ auditioning.current = true;
73
+ onScrubbing?.(true);
74
+ }
75
+ scrubTo(event.clientX);
76
+ };
77
+ const up = () => {
78
+ if (!scrubbing.current) return;
79
+ scrubbing.current = false;
80
+ if (auditioning.current) {
81
+ auditioning.current = false;
82
+ onScrubbing?.(false);
83
+ }
84
+ if (resumeAfterScrub.current) {
85
+ resumeAfterScrub.current = false;
86
+ playback.play();
87
+ }
88
+ };
89
+ window.addEventListener("pointermove", move);
90
+ window.addEventListener("pointerup", up);
91
+ return () => {
92
+ window.removeEventListener("pointermove", move);
93
+ window.removeEventListener("pointerup", up);
94
+ };
95
+ });
96
+
97
+ const scenes = timeline?.scenes ?? [];
98
+ const active = scenes.find((scene) => frame >= scene.start && frame < scene.start + scene.durationInFrames);
99
+ const seconds = Math.max(1, Math.round(durationInFrames / fps));
100
+ const ticks = Array.from({length: Math.min(seconds, 12) + 1}, (_, index) =>
101
+ Math.round((index * durationInFrames) / Math.min(seconds, 12)),
102
+ );
103
+
104
+ return (
105
+ <div className="transport">
106
+ <div className="timeline">
107
+ <div className="ruler">
108
+ {ticks.map((tick, index) => (
109
+ <span key={tick} style={{opacity: index === 0 || index === ticks.length - 1 ? 1 : 0.6}}>
110
+ {(tick / fps).toFixed(1)}s
111
+ </span>
112
+ ))}
113
+ </div>
114
+ <div
115
+ className="track"
116
+ ref={trackRef}
117
+ onPointerDown={(event) => {
118
+ scrubbing.current = true;
119
+ dragOrigin.current = event.clientX;
120
+ resumeAfterScrub.current = playback.playing;
121
+ playback.pause();
122
+ scrubTo(event.clientX);
123
+ }}
124
+ >
125
+ {scenes.length > 0 ? (
126
+ scenes.map((scene) => (
127
+ <div
128
+ key={scene.id}
129
+ className="segment"
130
+ data-active={active?.id === scene.id ? "true" : undefined}
131
+ style={{flexGrow: scene.durationInFrames, flexBasis: 0}}
132
+ title={`${scene.id}: frames ${scene.start} to ${scene.start + scene.durationInFrames - 1}`}
133
+ >
134
+ {scene.name ?? scene.id}
135
+ </div>
136
+ ))
137
+ ) : (
138
+ <div className="segment" style={{flexGrow: 1}}>
139
+ continuous timeline
140
+ </div>
141
+ )}
142
+ <div className="playhead" style={{left: `${(frame / Math.max(1, durationInFrames - 1)) * 100}%`}} />
143
+ </div>
144
+
145
+ {track && track.cues.length > 0 ? (
146
+ <div className="audio-track">
147
+ {track.cues.map((cue) => {
148
+ const share = cue.durationInFrames / durationInFrames;
149
+ // A short cue is narrower than its own name, so the label moves
150
+ // outside the bar rather than being cut mid-word.
151
+ const placement = share > 0.18 ? undefined : cue.fromFrame / durationInFrames > 0.6 ? "before" : "after";
152
+ return (
153
+ <div
154
+ key={cue.id}
155
+ className="cue"
156
+ data-label={placement}
157
+ data-active={
158
+ frame >= cue.fromFrame && frame < cue.fromFrame + cue.durationInFrames ? "true" : undefined
159
+ }
160
+ style={{
161
+ left: `${(cue.fromFrame / durationInFrames) * 100}%`,
162
+ width: `${(cue.durationInFrames / durationInFrames) * 100}%`,
163
+ }}
164
+ data-solo={soloCue && soloCue !== cue.id ? "off" : undefined}
165
+ title={`${cue.src} · gain ${cue.gain}${cue.duckUnder ? " · ducked" : ""}`}
166
+ onPointerDown={(event) => event.stopPropagation()}
167
+ onClick={() => onSoloCue?.(soloCue === cue.id ? null : cue.id)}
168
+ >
169
+ <span>{cue.src.split("/").pop()}</span>
170
+ </div>
171
+ );
172
+ })}
173
+ </div>
174
+ ) : null}
175
+ </div>
176
+
177
+ <div className="transport-row">
178
+ <Button icon aria-label="Go to start" onClick={() => playback.seek(0)}>
179
+ <Icon name="start" />
180
+ </Button>
181
+ <Button icon aria-label="Previous frame" onClick={() => playback.step(-1)}>
182
+ <Icon name="prev" />
183
+ </Button>
184
+ <Button variant="outline" icon aria-label={playback.playing ? "Pause" : "Play"} onClick={playback.toggle}>
185
+ <Icon name={playback.playing ? "pause" : "play"} />
186
+ </Button>
187
+ <Button icon aria-label="Next frame" onClick={() => playback.step(1)}>
188
+ <Icon name="next" />
189
+ </Button>
190
+ <Button icon aria-label="Go to end" onClick={() => playback.seek(durationInFrames - 1)}>
191
+ <Icon name="end" />
192
+ </Button>
193
+ <Button icon aria-label="Toggle loop" active={loop} onClick={onToggleLoop}>
194
+ <Icon name="loop" />
195
+ </Button>
196
+ {onRate ? (
197
+ // Speed is a preview convenience, never a property of the video: the
198
+ // frame clock and the export are untouched.
199
+ <select
200
+ className="rate"
201
+ aria-label="Playback speed"
202
+ value={rate}
203
+ onChange={(event) => onRate(Number(event.currentTarget.value))}
204
+ >
205
+ {RATES.map((option) => (
206
+ <option key={option} value={option}>
207
+ {option}x
208
+ </option>
209
+ ))}
210
+ </select>
211
+ ) : null}
212
+ {onToggleMuted ? (
213
+ // Sound is on unless it is turned off, so the one state worth
214
+ // showing here is that the browser is holding it until the page has
215
+ // been interacted with. Clicking is that interaction.
216
+ <Button
217
+ icon
218
+ aria-label={audioBlocked ? "Enable sound" : muted ? "Unmute" : "Mute"}
219
+ title={audioBlocked ? "The browser blocks audio until you interact with the page" : undefined}
220
+ active={!muted && !audioBlocked}
221
+ onClick={() => (audioBlocked ? onEnableAudio?.() : onToggleMuted())}
222
+ >
223
+ <Icon name={muted || audioBlocked ? "muted" : "sound"} />
224
+ </Button>
225
+ ) : null}
226
+
227
+ <span className="timecode" style={{marginLeft: 8}}>
228
+ <b>{formatTimecode(frame, fps)}</b> / {formatTimecode(durationInFrames - 1, fps)}
229
+ </span>
230
+ <span className="timecode">
231
+ frame <b>{frame}</b> of {durationInFrames - 1}
232
+ </span>
233
+ {active ? <span className="timecode">scene <b>{active.name ?? active.id}</b></span> : null}
234
+ </div>
235
+ </div>
236
+ );
237
+ };
@@ -0,0 +1,114 @@
1
+ import {useEffect, useState} from "react";
2
+
3
+ const PEAKS = 240;
4
+ /** Half-height of the drawn wave, out of a 100 unit viewBox. Deliberately short
5
+ * of the lane so the cue's label stays the loudest thing in the row. */
6
+ const AMPLITUDE = 30;
7
+ type Decoded = {peaks: number[]; duration: number};
8
+
9
+ const cache = new Map<string, Promise<Decoded>>();
10
+
11
+ /**
12
+ * Peaks for one source, decoded once per Studio session.
13
+ *
14
+ * A flat bar tells you a cue exists; peaks tell you where its hits are, which
15
+ * is what aligning a cut to a sound actually needs.
16
+ */
17
+ const loadPeaks = (url: string): Promise<Decoded> => {
18
+ const hit = cache.get(url);
19
+ if (hit) return hit;
20
+
21
+ const pending = (async () => {
22
+ const response = await fetch(url);
23
+ if (!response.ok) throw new Error(`${response.status}`);
24
+ const context = new AudioContext();
25
+ try {
26
+ const buffer = await context.decodeAudioData(await response.arrayBuffer());
27
+ const samples = buffer.getChannelData(0);
28
+ const window = Math.max(1, Math.floor(samples.length / PEAKS));
29
+ const peaks: number[] = [];
30
+ // RMS rather than peak: a mastered music bed is peak-limited, so peaks
31
+ // draw one solid block where RMS still shows the shape of the track.
32
+ for (let index = 0; index < PEAKS; index += 1) {
33
+ const start = index * window;
34
+ let sum = 0;
35
+ let counted = 0;
36
+ for (let offset = 0; offset < window && start + offset < samples.length; offset += 1) {
37
+ const sample = samples[start + offset];
38
+ sum += sample * sample;
39
+ counted += 1;
40
+ }
41
+ peaks.push(counted === 0 ? 0 : Math.sqrt(sum / counted));
42
+ }
43
+ return {peaks, duration: buffer.duration};
44
+ } finally {
45
+ void context.close();
46
+ }
47
+ })();
48
+
49
+ cache.set(url, pending);
50
+ // A source that cannot be decoded falls back to a flat lane rather than
51
+ // taking the transport down with it.
52
+ return pending.catch(() => {
53
+ cache.delete(url);
54
+ return {peaks: [], duration: 0};
55
+ });
56
+ };
57
+
58
+ /** Decoded peaks and duration for a source, shared with the transport lanes. */
59
+ export const useAudioPeaks = (url: string): Decoded => {
60
+ const [decoded, setDecoded] = useState<Decoded>({peaks: [], duration: 0});
61
+ useEffect(() => {
62
+ let live = true;
63
+ void loadPeaks(url).then((value) => {
64
+ if (live) setDecoded(value);
65
+ });
66
+ return () => {
67
+ live = false;
68
+ };
69
+ }, [url]);
70
+ return decoded;
71
+ };
72
+
73
+ export const Waveform = ({
74
+ url,
75
+ trimStartSeconds = 0,
76
+ windowSeconds,
77
+ loop = false,
78
+ }: {
79
+ url: string;
80
+ trimStartSeconds?: number;
81
+ /** Seconds of the source the cue actually uses. */
82
+ windowSeconds?: number;
83
+ loop?: boolean;
84
+ }) => {
85
+ const {peaks, duration} = useAudioPeaks(url);
86
+ if (peaks.length === 0 || duration === 0) return null;
87
+
88
+ // The lane shows the slice the cue plays, not the whole file.
89
+ const perSecond = peaks.length / duration;
90
+ const start = Math.min(peaks.length - 1, Math.max(0, Math.round(trimStartSeconds * perSecond)));
91
+ const count = windowSeconds ? Math.max(1, Math.round(windowSeconds * perSecond)) : peaks.length - start;
92
+ const window = loop
93
+ ? Array.from({length: count}, (_, index) => peaks[(start + index) % peaks.length])
94
+ : peaks.slice(start, start + count);
95
+
96
+ // Normalized to the loudest moment of the slice this cue plays, so a lane
97
+ // 20 pixels tall shows shape. Level lives in the Audio panel as numbers.
98
+ const ceiling = Math.max(...window);
99
+ const visible = ceiling > 0.001 ? window.map((peak) => peak / ceiling) : window;
100
+ const step = 100 / Math.max(1, visible.length - 1);
101
+ const top = visible.map((peak, index) => `${index * step},${50 - peak * AMPLITUDE}`).join(" ");
102
+ const bottom = visible
103
+ .map((_, index) => {
104
+ const mirrored = visible.length - 1 - index;
105
+ return `${mirrored * step},${50 + visible[mirrored] * AMPLITUDE}`;
106
+ })
107
+ .join(" ");
108
+
109
+ return (
110
+ <svg className="waveform" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
111
+ <polygon points={`${top} ${bottom}`} fill="currentColor" />
112
+ </svg>
113
+ );
114
+ };