@hyperframes/studio 0.8.58 → 0.8.60
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/dist/assets/{hyperframes-player-zGyOcwwh.js → hyperframes-player-Di7GyCOP.js} +3 -3
- package/dist/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
- package/dist/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
- package/dist/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
- package/dist/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
- package/dist/index.d.ts +549 -13
- package/dist/index.html +2 -2
- package/dist/index.js +23133 -21941
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +22 -8
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- package/src/components/icons/AudioMetersIcon.tsx +16 -0
- package/src/components/icons/InspectorIcon.tsx +18 -0
- package/src/components/icons/ReverseGradientIcon.tsx +23 -0
- package/src/components/icons/RippleEditIcon.tsx +19 -0
- package/src/components/nle/AudioMeterStrip.test.tsx +395 -0
- package/src/components/nle/AudioMeterStrip.tsx +430 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/components/ui/Menu.test.tsx +3 -4
- package/src/components/ui/Menu.tsx +18 -5
- package/src/components/ui/Select.tsx +7 -1
- package/src/components/ui/Tooltip.tsx +6 -1
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +16 -0
- package/src/player/components/AudioWaveform.tsx +16 -7
- package/src/player/components/ImageThumbnail.tsx +3 -5
- package/src/player/components/LayerDisclosureRow.tsx +9 -4
- package/src/player/components/PlayheadIndicator.tsx +3 -3
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +28 -71
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +25 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +10 -8
- package/src/player/components/TimelineGroupHeader.tsx +18 -9
- package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +14 -0
- package/src/player/components/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/TimelineShortcutHint.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
- package/src/player/components/VideoThumbnail.tsx +3 -5
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +12 -11
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/hex-baseline.json +4 -23
- package/src/styles/studio.css +43 -47
- package/src/styles/theme.css +109 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- package/src/utils/timelineAssetDrop.ts +6 -1
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import {
|
|
2
|
+
memo,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
type PointerEvent as ReactPointerEvent,
|
|
8
|
+
type Ref,
|
|
9
|
+
type RefObject,
|
|
10
|
+
} from "react";
|
|
11
|
+
import {
|
|
12
|
+
AUDIO_GAIN_FADER_MIN,
|
|
13
|
+
AUDIO_GAIN_FADER_MAX,
|
|
14
|
+
audioGainToFaderPosition,
|
|
15
|
+
audioFaderPositionToGain,
|
|
16
|
+
audioGainToText,
|
|
17
|
+
formatAudioGain,
|
|
18
|
+
} from "@hyperframes/core/audio-gain";
|
|
19
|
+
import type { StereoLevel } from "@hyperframes/core/runtime/levelTap";
|
|
20
|
+
import { usePlayerStore } from "../../player";
|
|
21
|
+
import { clampNumber } from "../../utils/studioHelpers";
|
|
22
|
+
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
23
|
+
import { useStudioShellContext } from "../../contexts/StudioContext";
|
|
24
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
25
|
+
import {
|
|
26
|
+
METER_DB_MARKS,
|
|
27
|
+
useProjectHasAudio,
|
|
28
|
+
markFraction,
|
|
29
|
+
stepPair,
|
|
30
|
+
type MeterPair as Pair,
|
|
31
|
+
} from "../../utils/audioMeterMath";
|
|
32
|
+
|
|
33
|
+
interface Levels {
|
|
34
|
+
master: StereoLevel;
|
|
35
|
+
groups: Record<string, StereoLevel>;
|
|
36
|
+
}
|
|
37
|
+
interface AudioMeterHook {
|
|
38
|
+
start(): void;
|
|
39
|
+
stop(): void;
|
|
40
|
+
read(): Levels;
|
|
41
|
+
}
|
|
42
|
+
interface Strip {
|
|
43
|
+
id: string | null;
|
|
44
|
+
label: string;
|
|
45
|
+
volume: number;
|
|
46
|
+
}
|
|
47
|
+
type Bars = { mask: HTMLElement | null; peak: HTMLElement | null };
|
|
48
|
+
type StripBars = [Bars, Bars];
|
|
49
|
+
|
|
50
|
+
const MONITOR_LABEL = "Monitor";
|
|
51
|
+
|
|
52
|
+
/** Where the fill turns amber, then red, on the same piecewise dB scale the marks use. */
|
|
53
|
+
const AMBER_AT = markFraction(-6);
|
|
54
|
+
const RED_AT = markFraction(-3);
|
|
55
|
+
|
|
56
|
+
function useStrips(): Strip[] {
|
|
57
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
58
|
+
const masterVolume = usePlayerStore((s) => s.audioVolume);
|
|
59
|
+
return useMemo(() => {
|
|
60
|
+
const labels = new Map<string, string>();
|
|
61
|
+
const volumes = new Map<string, number>();
|
|
62
|
+
for (const el of elements) {
|
|
63
|
+
if (el.audioGroup && !labels.has(el.audioGroup)) {
|
|
64
|
+
labels.set(el.audioGroup, el.audioGroupLabel ?? el.audioGroup);
|
|
65
|
+
volumes.set(el.audioGroup, el.audioGroupVolume ?? 1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [
|
|
69
|
+
...[...labels].map(([id, label]) => ({ id, label, volume: volumes.get(id) ?? 1 })),
|
|
70
|
+
{ id: null, label: MONITOR_LABEL, volume: masterVolume },
|
|
71
|
+
];
|
|
72
|
+
}, [elements, masterVolume]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Group volume through the existing `data-volume` write path (live while dragging, one
|
|
76
|
+
* undo entry on release); monitor volume through the player store's own volume action —
|
|
77
|
+
* the same one `VolumeControl` in `PlayerControls` already drives. */
|
|
78
|
+
function useVolumeHandlers(): {
|
|
79
|
+
onLive: (id: string | null, volume: number) => void;
|
|
80
|
+
onCommit: (id: string | null, volume: number) => void;
|
|
81
|
+
} {
|
|
82
|
+
const { onSetAudioGroupAttributeLive, onSetAudioGroupAttributeQuiet } =
|
|
83
|
+
useTimelineEditContextOptional();
|
|
84
|
+
const setAudioVolume = usePlayerStore((s) => s.setAudioVolume);
|
|
85
|
+
const onLive = useCallback(
|
|
86
|
+
(id: string | null, volume: number) => {
|
|
87
|
+
if (id === null) setAudioVolume(volume);
|
|
88
|
+
else onSetAudioGroupAttributeLive?.(id, "data-volume", formatAudioGain(volume));
|
|
89
|
+
},
|
|
90
|
+
[onSetAudioGroupAttributeLive, setAudioVolume],
|
|
91
|
+
);
|
|
92
|
+
const onCommit = useCallback(
|
|
93
|
+
(id: string | null, volume: number) => {
|
|
94
|
+
if (id === null) setAudioVolume(volume);
|
|
95
|
+
else
|
|
96
|
+
void onSetAudioGroupAttributeQuiet?.(
|
|
97
|
+
id,
|
|
98
|
+
"data-volume",
|
|
99
|
+
formatAudioGain(volume),
|
|
100
|
+
"Set volume",
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
[onSetAudioGroupAttributeQuiet, setAudioVolume],
|
|
104
|
+
);
|
|
105
|
+
return { onLive, onCommit };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined;
|
|
109
|
+
|
|
110
|
+
function readHook(iframe: HTMLIFrameElement | null): AudioMeterHook | null {
|
|
111
|
+
try {
|
|
112
|
+
return (iframe?.contentWindow as PreviewWindow)?.__hf?.audioMeter ?? null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 0 dB is bottom 100% plus 1px down, so the tick stays inside overflow-hidden.
|
|
119
|
+
* A calc() bottom does not stick as an inline style on Windows happy-dom. */
|
|
120
|
+
function paintPeak(el: HTMLElement | null, peak: number): void {
|
|
121
|
+
if (!el) return;
|
|
122
|
+
el.style.setProperty("bottom", `${peak * 100}%`);
|
|
123
|
+
el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The fill is a fixed green/amber/red backdrop; painting only moves the dark
|
|
127
|
+
* mask that covers the unlit top portion, so a loud peak lights the real red
|
|
128
|
+
* band instead of tinting a flat colour brighter. */
|
|
129
|
+
function paint(bars: StripBars | undefined, channels: Pair): void {
|
|
130
|
+
channels.forEach((ch, i) => {
|
|
131
|
+
bars?.[i]?.mask?.style.setProperty("height", `${(1 - ch.level) * 100}%`);
|
|
132
|
+
paintPeak(bars?.[i]?.peak ?? null, ch.peak);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Attach to the live preview hook. Always returns `live`, even if start/stop throw. */
|
|
137
|
+
export function followMeterHook(
|
|
138
|
+
active: AudioMeterHook | null,
|
|
139
|
+
live: AudioMeterHook | null,
|
|
140
|
+
): AudioMeterHook | null {
|
|
141
|
+
if (live === active) return active;
|
|
142
|
+
try {
|
|
143
|
+
active?.stop();
|
|
144
|
+
} catch {
|
|
145
|
+
// Preview iframe was torn down; the old hook is uncallable.
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
live?.start();
|
|
149
|
+
} catch {
|
|
150
|
+
// New preview is not ready to attach yet.
|
|
151
|
+
}
|
|
152
|
+
return live;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function evictGoneMeterState(
|
|
156
|
+
state: Map<string | null, Pair>,
|
|
157
|
+
liveIds: ReadonlySet<string | null>,
|
|
158
|
+
): void {
|
|
159
|
+
for (const id of [...state.keys()]) {
|
|
160
|
+
if (!liveIds.has(id)) state.delete(id);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function stepAndPaintStrips(
|
|
165
|
+
strips: readonly { id: string | null }[],
|
|
166
|
+
state: Map<string | null, Pair>,
|
|
167
|
+
bars: Map<string | null, StripBars>,
|
|
168
|
+
levels: Levels | undefined,
|
|
169
|
+
now: number,
|
|
170
|
+
dt: number,
|
|
171
|
+
): void {
|
|
172
|
+
for (const { id } of strips) {
|
|
173
|
+
const prev = state.get(id);
|
|
174
|
+
const next = stepPair(prev, id === null ? levels?.master : levels?.groups[id], now, dt);
|
|
175
|
+
if (next === prev) continue;
|
|
176
|
+
state.set(id, next);
|
|
177
|
+
paint(bars.get(id), next);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
|
|
182
|
+
function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripBars>>) {
|
|
183
|
+
const { previewIframeRef } = useStudioShellContext();
|
|
184
|
+
const stripsRef = useRef(strips);
|
|
185
|
+
stripsRef.current = strips;
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
let raf = 0;
|
|
188
|
+
let active: AudioMeterHook | null = null;
|
|
189
|
+
let last = performance.now();
|
|
190
|
+
const state = new Map<string | null, Pair>();
|
|
191
|
+
const tick = (now: number) => {
|
|
192
|
+
raf = requestAnimationFrame(tick);
|
|
193
|
+
active = followMeterHook(active, readHook(previewIframeRef.current));
|
|
194
|
+
const levels = active?.read();
|
|
195
|
+
const dt = now - last;
|
|
196
|
+
last = now;
|
|
197
|
+
evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id)));
|
|
198
|
+
stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt);
|
|
199
|
+
};
|
|
200
|
+
raf = requestAnimationFrame(tick);
|
|
201
|
+
return () => {
|
|
202
|
+
cancelAnimationFrame(raf);
|
|
203
|
+
try {
|
|
204
|
+
active?.stop();
|
|
205
|
+
} catch {
|
|
206
|
+
// Preview iframe already gone.
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}, [previewIframeRef, bars]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function Bar({ maskRef, peakRef }: { maskRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
|
|
213
|
+
return (
|
|
214
|
+
<div className="relative h-full w-[18px] overflow-hidden rounded-[2px] bg-neutral-900">
|
|
215
|
+
<div
|
|
216
|
+
className="absolute inset-x-0 bottom-0 bg-green-500"
|
|
217
|
+
style={{ height: `${AMBER_AT * 100}%` }}
|
|
218
|
+
/>
|
|
219
|
+
<div
|
|
220
|
+
className="absolute inset-x-0 bg-amber-500"
|
|
221
|
+
style={{ bottom: `${AMBER_AT * 100}%`, height: `${(RED_AT - AMBER_AT) * 100}%` }}
|
|
222
|
+
/>
|
|
223
|
+
<div
|
|
224
|
+
className="absolute inset-x-0 top-0 bg-red-500"
|
|
225
|
+
style={{ height: `${(1 - RED_AT) * 100}%` }}
|
|
226
|
+
/>
|
|
227
|
+
<div
|
|
228
|
+
ref={maskRef}
|
|
229
|
+
data-testid="meter-mask"
|
|
230
|
+
className="absolute inset-x-0 top-0 bg-neutral-900"
|
|
231
|
+
style={{ height: "100%" }}
|
|
232
|
+
/>
|
|
233
|
+
<div
|
|
234
|
+
ref={peakRef}
|
|
235
|
+
data-testid="meter-peak"
|
|
236
|
+
className="absolute inset-x-0 bottom-0 h-px bg-white"
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Uses the authored clip-gain scale; meter readings have a separate scale. */
|
|
243
|
+
function Fader({
|
|
244
|
+
label,
|
|
245
|
+
title,
|
|
246
|
+
volume,
|
|
247
|
+
maxPosition,
|
|
248
|
+
onLive,
|
|
249
|
+
onCommit,
|
|
250
|
+
}: {
|
|
251
|
+
label: string;
|
|
252
|
+
title: string;
|
|
253
|
+
volume: number;
|
|
254
|
+
maxPosition: number;
|
|
255
|
+
onLive: (v: number) => void;
|
|
256
|
+
onCommit: (v: number) => void;
|
|
257
|
+
}) {
|
|
258
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
259
|
+
const draggingRef = useRef<number | null>(null);
|
|
260
|
+
const position = audioGainToFaderPosition(volume);
|
|
261
|
+
const span = maxPosition - AUDIO_GAIN_FADER_MIN;
|
|
262
|
+
const fraction = (position - AUDIO_GAIN_FADER_MIN) / span;
|
|
263
|
+
const readout = audioGainToText(volume);
|
|
264
|
+
|
|
265
|
+
const positionAt = useCallback(
|
|
266
|
+
(clientY: number): number => {
|
|
267
|
+
const rect = trackRef.current?.getBoundingClientRect();
|
|
268
|
+
if (!rect || rect.height === 0) return position;
|
|
269
|
+
return (
|
|
270
|
+
AUDIO_GAIN_FADER_MIN + clampNumber(1 - (clientY - rect.top) / rect.height, 0, 1) * span
|
|
271
|
+
);
|
|
272
|
+
},
|
|
273
|
+
[position, span],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const moveTo = (clientY: number) => {
|
|
277
|
+
const gain = audioFaderPositionToGain(positionAt(clientY));
|
|
278
|
+
draggingRef.current = gain;
|
|
279
|
+
onLive(gain);
|
|
280
|
+
};
|
|
281
|
+
const finishDrag = () => {
|
|
282
|
+
if (draggingRef.current === null) return;
|
|
283
|
+
const gain = draggingRef.current;
|
|
284
|
+
draggingRef.current = null;
|
|
285
|
+
onCommit(gain);
|
|
286
|
+
};
|
|
287
|
+
const keyPositions = new Map([
|
|
288
|
+
["ArrowUp", position + span * 0.02],
|
|
289
|
+
["ArrowDown", position - span * 0.02],
|
|
290
|
+
["PageUp", position + span * 0.1],
|
|
291
|
+
["PageDown", position - span * 0.1],
|
|
292
|
+
["Home", AUDIO_GAIN_FADER_MIN],
|
|
293
|
+
["End", maxPosition],
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div
|
|
298
|
+
ref={trackRef}
|
|
299
|
+
role="slider"
|
|
300
|
+
tabIndex={0}
|
|
301
|
+
aria-label={label}
|
|
302
|
+
title={`${title}: ${readout}`}
|
|
303
|
+
aria-orientation="vertical"
|
|
304
|
+
aria-valuemin={AUDIO_GAIN_FADER_MIN}
|
|
305
|
+
aria-valuemax={maxPosition}
|
|
306
|
+
aria-valuenow={Math.round(position)}
|
|
307
|
+
aria-valuetext={readout}
|
|
308
|
+
onPointerDown={(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
309
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
310
|
+
moveTo(e.clientY);
|
|
311
|
+
}}
|
|
312
|
+
onPointerMove={(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
313
|
+
if (draggingRef.current === null) return;
|
|
314
|
+
moveTo(e.clientY);
|
|
315
|
+
}}
|
|
316
|
+
onPointerUp={finishDrag}
|
|
317
|
+
onPointerCancel={finishDrag}
|
|
318
|
+
onLostPointerCapture={finishDrag}
|
|
319
|
+
onKeyDown={(e) => {
|
|
320
|
+
const next = keyPositions.get(e.key);
|
|
321
|
+
if (next === undefined) return;
|
|
322
|
+
onCommit(audioFaderPositionToGain(clampNumber(next, AUDIO_GAIN_FADER_MIN, maxPosition)));
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
}}
|
|
325
|
+
className="relative h-full w-2 shrink-0 cursor-ns-resize touch-none rounded-full bg-neutral-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
|
|
326
|
+
>
|
|
327
|
+
<div
|
|
328
|
+
className="absolute inset-x-[-3px] h-1.5 -translate-y-1/2 rounded-full bg-neutral-200"
|
|
329
|
+
style={{ bottom: `${fraction * 100}%` }}
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function MeterStrip({
|
|
336
|
+
strip,
|
|
337
|
+
register,
|
|
338
|
+
onLive,
|
|
339
|
+
onCommit,
|
|
340
|
+
}: {
|
|
341
|
+
strip: Strip;
|
|
342
|
+
register: (id: string | null, bars: StripBars | null) => void;
|
|
343
|
+
onLive: (id: string | null, volume: number) => void;
|
|
344
|
+
onCommit: (id: string | null, volume: number) => void;
|
|
345
|
+
}) {
|
|
346
|
+
const refs = [
|
|
347
|
+
useRef<HTMLDivElement>(null),
|
|
348
|
+
useRef<HTMLDivElement>(null),
|
|
349
|
+
useRef<HTMLDivElement>(null),
|
|
350
|
+
useRef<HTMLDivElement>(null),
|
|
351
|
+
] as const;
|
|
352
|
+
useEffect(() => {
|
|
353
|
+
register(strip.id, [
|
|
354
|
+
{ mask: refs[0].current, peak: refs[1].current },
|
|
355
|
+
{ mask: refs[2].current, peak: refs[3].current },
|
|
356
|
+
]);
|
|
357
|
+
return () => register(strip.id, null);
|
|
358
|
+
// refs are stable objects
|
|
359
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
360
|
+
}, [strip.id, register]);
|
|
361
|
+
return (
|
|
362
|
+
<div className="flex w-[104px] shrink-0 flex-col items-center gap-1 px-1.5 pt-2 pb-1">
|
|
363
|
+
<div
|
|
364
|
+
className="flex min-h-0 flex-1 items-stretch gap-1.5"
|
|
365
|
+
aria-label={`${strip.label} level`}
|
|
366
|
+
>
|
|
367
|
+
<Fader
|
|
368
|
+
label={`${strip.label} volume`}
|
|
369
|
+
title={strip.id === null ? "Preview monitor volume" : `${strip.label} volume`}
|
|
370
|
+
volume={strip.volume}
|
|
371
|
+
maxPosition={strip.id === null ? audioGainToFaderPosition(1) : AUDIO_GAIN_FADER_MAX}
|
|
372
|
+
onLive={(v) => onLive(strip.id, v)}
|
|
373
|
+
onCommit={(v) => onCommit(strip.id, v)}
|
|
374
|
+
/>
|
|
375
|
+
<div className="relative w-5 font-mono text-[9px] leading-none text-neutral-500">
|
|
376
|
+
{METER_DB_MARKS.map((db) => (
|
|
377
|
+
<span
|
|
378
|
+
key={db}
|
|
379
|
+
className="absolute right-0 translate-y-1/2"
|
|
380
|
+
style={{ bottom: `${markFraction(db) * 100}%` }}
|
|
381
|
+
>
|
|
382
|
+
{db}
|
|
383
|
+
</span>
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
<div className="flex items-stretch gap-0.5">
|
|
387
|
+
<Bar maskRef={refs[0]} peakRef={refs[1]} />
|
|
388
|
+
<Bar maskRef={refs[2]} peakRef={refs[3]} />
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
<span className="max-w-full truncate text-[10px] text-neutral-400" title={strip.label}>
|
|
392
|
+
{strip.label}
|
|
393
|
+
</span>
|
|
394
|
+
</div>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export const AudioMeterStrip = memo(function AudioMeterStrip() {
|
|
399
|
+
const visible = useAudioMetersVisible((s) => s.visible);
|
|
400
|
+
const projectHasAudio = useProjectHasAudio();
|
|
401
|
+
if (!visible || !projectHasAudio) return null;
|
|
402
|
+
return <MeterStripBody />;
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
function MeterStripBody() {
|
|
406
|
+
const strips = useStrips();
|
|
407
|
+
const bars = useRef(new Map<string | null, StripBars>());
|
|
408
|
+
const register = useRef((id: string | null, b: StripBars | null) => {
|
|
409
|
+
if (b) bars.current.set(id, b);
|
|
410
|
+
else bars.current.delete(id);
|
|
411
|
+
}).current;
|
|
412
|
+
const { onLive, onCommit } = useVolumeHandlers();
|
|
413
|
+
useMeterLoop(strips, bars);
|
|
414
|
+
return (
|
|
415
|
+
<div
|
|
416
|
+
data-testid="audio-meter-strip"
|
|
417
|
+
className="flex shrink-0 overflow-x-auto border-l border-neutral-800/50 bg-neutral-950"
|
|
418
|
+
>
|
|
419
|
+
{strips.map((strip) => (
|
|
420
|
+
<MeterStrip
|
|
421
|
+
key={strip.id ?? "master"}
|
|
422
|
+
strip={strip}
|
|
423
|
+
register={register}
|
|
424
|
+
onLive={onLive}
|
|
425
|
+
onCommit={onCommit}
|
|
426
|
+
/>
|
|
427
|
+
))}
|
|
428
|
+
</div>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useCallback, type ReactNode } from "react";
|
|
|
2
2
|
import { Timeline } from "../../player";
|
|
3
3
|
import type { TimelineElement, TimelineTimeRange } from "../../player";
|
|
4
4
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
5
|
+
import { AudioMeterStrip } from "./AudioMeterStrip";
|
|
5
6
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
6
7
|
import { trackStudioExpandedClipEdit } from "../../telemetry/events";
|
|
7
8
|
import { useNLEContext } from "./NLEContext";
|
|
@@ -271,29 +272,34 @@ export function TimelinePane({
|
|
|
271
272
|
}}
|
|
272
273
|
>
|
|
273
274
|
<div className="shrink-0">{timelineToolbar}</div>
|
|
274
|
-
<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
275
|
+
<div className="flex min-h-0 flex-1">
|
|
276
|
+
<div className="min-w-0 flex-1">
|
|
277
|
+
<Timeline
|
|
278
|
+
sessionEpoch={timelineSessionEpoch}
|
|
279
|
+
onSeek={seek}
|
|
280
|
+
onDrillDown={handleDrillDown}
|
|
281
|
+
renderClipContent={renderClipContent}
|
|
282
|
+
onFileDrop={onFileDrop}
|
|
283
|
+
onDeleteElement={handleDeleteElement}
|
|
284
|
+
onAssetDrop={onAssetDrop}
|
|
285
|
+
onBlockDrop={onBlockDrop}
|
|
286
|
+
onCompositionDrop={onCompositionDrop}
|
|
287
|
+
onMoveElement={handleMoveElement}
|
|
288
|
+
onMoveElements={handleMoveElements}
|
|
289
|
+
onResizeElement={handleResizeElement}
|
|
290
|
+
onResizeElements={handleResizeElements}
|
|
291
|
+
onBlockedEditAttempt={onBlockedEditAttempt}
|
|
292
|
+
onSplitElement={handleSplitElement}
|
|
293
|
+
onSelectElement={onSelectTimelineElement}
|
|
294
|
+
onRangeSelect={onRangeSelect}
|
|
295
|
+
onCopyClip={onCopyClip}
|
|
296
|
+
onPasteClip={onPasteClip}
|
|
297
|
+
onDuplicateClip={onDuplicateClip}
|
|
298
|
+
canPasteClip={canPasteClip}
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
<AudioMeterStrip />
|
|
302
|
+
</div>
|
|
297
303
|
</div>
|
|
298
304
|
{timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
|
|
299
305
|
{timelineOverlay && (
|
|
@@ -360,10 +360,9 @@ describe("open motion", () => {
|
|
|
360
360
|
|
|
361
361
|
// AE4.
|
|
362
362
|
it("names a motion token that zeroes itself under reduced motion", async () => {
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// is to name the token; the stylesheet's job is the media query.
|
|
363
|
+
// Reduced motion lives in the `duration-open` utility itself (theme.css),
|
|
364
|
+
// not in a `motion-reduce:` class beside it, so a caller cannot use the
|
|
365
|
+
// token and forget that half. The menu's job is to name the token.
|
|
367
366
|
await openActionMenu();
|
|
368
367
|
|
|
369
368
|
expect([...popup()!.classList]).toContain("duration-open");
|
|
@@ -27,15 +27,28 @@ const SIDE_OFFSET = 6;
|
|
|
27
27
|
const VIEWPORT_MARGIN = 8;
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
30
|
+
* Enter and exit shared by menus, popovers, dropdowns, and tooltips.
|
|
31
|
+
* The open duration class differs; the shape does not.
|
|
31
32
|
* `data-starting-style`/`data-ending-style` are Base UI's transition attributes.
|
|
32
33
|
*/
|
|
34
|
+
export function floatingMotion(openDuration: "duration-open" | "duration-tooltip") {
|
|
35
|
+
return cn(
|
|
36
|
+
"origin-[var(--transform-origin)] outline-hidden",
|
|
37
|
+
"transition-[opacity,transform] ease-out-quint",
|
|
38
|
+
openDuration,
|
|
39
|
+
"data-[ending-style]:duration-close data-[ending-style]:ease-in",
|
|
40
|
+
"data-[starting-style]:[opacity:var(--popup-enter-opacity)]",
|
|
41
|
+
"data-[starting-style]:[scale:var(--popup-enter-scale)]",
|
|
42
|
+
"data-[ending-style]:opacity-0",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Chrome shared by every floating panel (`Popover` too); callers add their own shadow token.
|
|
48
|
+
*/
|
|
33
49
|
export const popupSurface = cn(
|
|
34
50
|
"rounded-lg border border-border-input bg-surface",
|
|
35
|
-
"
|
|
36
|
-
"transition-[opacity,transform] ease-out-quint duration-open",
|
|
37
|
-
"data-[starting-style]:opacity-0 data-[starting-style]:scale-95",
|
|
38
|
-
"data-[ending-style]:opacity-0 data-[ending-style]:scale-95",
|
|
51
|
+
floatingMotion("duration-open"),
|
|
39
52
|
"data-[preview-state=open]:opacity-100 data-[preview-state=open]:scale-100",
|
|
40
53
|
);
|
|
41
54
|
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { Select as BaseSelect } from "@base-ui/react/select";
|
|
7
7
|
import { cn } from "./cn";
|
|
8
8
|
import { fieldBase } from "./Input";
|
|
9
|
+
import { floatingMotion } from "./Menu";
|
|
9
10
|
import type { PreviewState } from "./Button";
|
|
10
11
|
|
|
11
12
|
export interface SelectOption {
|
|
@@ -77,7 +78,12 @@ export function Select({
|
|
|
77
78
|
|
|
78
79
|
<BaseSelect.Portal>
|
|
79
80
|
<BaseSelect.Positioner sideOffset={4} alignItemWithTrigger={false}>
|
|
80
|
-
<BaseSelect.Popup
|
|
81
|
+
<BaseSelect.Popup
|
|
82
|
+
className={cn(
|
|
83
|
+
"min-w-[var(--anchor-width)] rounded-md border border-border bg-surface py-1 shadow-menu",
|
|
84
|
+
floatingMotion("duration-open"),
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
81
87
|
<BaseSelect.List>
|
|
82
88
|
{options.map((option) => (
|
|
83
89
|
<BaseSelect.Item
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
|
|
7
7
|
import { cloneElement, useId, useRef, useState, type ReactElement } from "react";
|
|
8
|
+
import { cn } from "./cn";
|
|
9
|
+
import { floatingMotion } from "./Menu";
|
|
8
10
|
|
|
9
11
|
interface TooltipProps {
|
|
10
12
|
label: string;
|
|
@@ -42,7 +44,10 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|
|
42
44
|
<BaseTooltip.Popup
|
|
43
45
|
id={tooltipId}
|
|
44
46
|
role="tooltip"
|
|
45
|
-
className=
|
|
47
|
+
className={cn(
|
|
48
|
+
"pointer-events-none rounded-md border border-border-input bg-surface px-2 py-1 text-step-10 font-medium text-text-1 whitespace-nowrap shadow-menu",
|
|
49
|
+
floatingMotion("duration-tooltip"),
|
|
50
|
+
)}
|
|
46
51
|
>
|
|
47
52
|
{label}
|
|
48
53
|
</BaseTooltip.Popup>
|
|
@@ -21,6 +21,10 @@ export function useTimelineEditContextOptional(): TimelineEditCallbacks {
|
|
|
21
21
|
return useContext(TimelineEditContext) ?? {};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export function useTimelineEditContextValue(): TimelineEditCallbacks | null {
|
|
25
|
+
return useContext(TimelineEditContext);
|
|
26
|
+
}
|
|
27
|
+
|
|
24
28
|
export function TimelineEditProvider({
|
|
25
29
|
value,
|
|
26
30
|
children,
|
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
} from "../utils/timelineAssetDrop";
|
|
19
19
|
import { generateId } from "../utils/generateId";
|
|
20
20
|
import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
collectHtmlIds,
|
|
23
|
+
resolveDroppedAssetDuration,
|
|
24
|
+
resolveDroppedAssetHasAudio,
|
|
25
|
+
} from "../utils/studioHelpers";
|
|
22
26
|
import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
|
23
27
|
import { readFileContent } from "./timelineTimingSync";
|
|
24
28
|
import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
|
|
@@ -113,6 +117,9 @@ export function useTimelineAssetDropOps({
|
|
|
113
117
|
? durationOverride
|
|
114
118
|
: await resolveDroppedAssetDuration(pid, assetPath, kind);
|
|
115
119
|
const normalizedDuration = Number(formatTimelineAttributeNumber(duration));
|
|
120
|
+
// A video with an audio stream lands audible; the mixer only hears a
|
|
121
|
+
// <video> marked data-has-audio, and a muted drop was losing the sound.
|
|
122
|
+
const hasAudio = await resolveDroppedAssetHasAudio(pid, assetPath, kind);
|
|
116
123
|
const newId = buildTimelineAssetId(assetPath, collectHtmlIds(originalContent));
|
|
117
124
|
const resolvedAssetSrc = resolveTimelineAssetSrc(targetPath, assetPath);
|
|
118
125
|
|
|
@@ -146,6 +153,7 @@ export function useTimelineAssetDropOps({
|
|
|
146
153
|
duration: normalizedDuration,
|
|
147
154
|
track,
|
|
148
155
|
zIndex: newElementZIndex,
|
|
156
|
+
hasAudio,
|
|
149
157
|
geometry: fitTimelineAssetGeometry(
|
|
150
158
|
null,
|
|
151
159
|
resolveTimelineAssetCompositionSize(originalContent),
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,22 @@ export type {
|
|
|
60
60
|
TimelineElement,
|
|
61
61
|
TimelineTimeRange,
|
|
62
62
|
} from "./player";
|
|
63
|
+
export {
|
|
64
|
+
TimelineFrame,
|
|
65
|
+
TimelineLanes,
|
|
66
|
+
TimelineOverlays,
|
|
67
|
+
TimelinePlayhead,
|
|
68
|
+
TimelineRazorGuide,
|
|
69
|
+
TimelineRuler,
|
|
70
|
+
TimelineEmptyStatePart,
|
|
71
|
+
TimelineEditPopover,
|
|
72
|
+
TimelineClipMenu,
|
|
73
|
+
TimelineGapMenu,
|
|
74
|
+
TimelineKeyframeMenu,
|
|
75
|
+
TimelineShortcutHint,
|
|
76
|
+
} from "./player/components/TimelineParts";
|
|
77
|
+
export { TimelineProvider, useTimelineContext } from "./player/components/TimelineProvider";
|
|
78
|
+
export type { TimelineTheme } from "./player/components/timelineTheme";
|
|
63
79
|
|
|
64
80
|
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
|
65
81
|
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|