@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
|
@@ -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
|
+
};
|