@hyperframes/studio 0.8.59 → 0.8.61
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-BBub3ZdI.js → hyperframes-player-nmVkPQza.js} +1 -1
- package/dist/assets/{index-CI7WON-E.js → index-2-HsQB2C.js} +1 -1
- package/dist/assets/{index-D1KNPijK.js → index-BK36b0XY.js} +162 -162
- package/dist/assets/{index-BxZ5Cw5w.js → index-COG0K-CQ.js} +1 -1
- package/dist/assets/{index-DZBHG9TW.css → index-DS0kLbuM.css} +1 -1
- package/dist/index.d.ts +5 -6
- package/dist/index.html +2 -2
- package/dist/index.js +4977 -4273
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -0
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +5 -10
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- 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 +278 -7
- package/src/components/nle/AudioMeterStrip.tsx +264 -47
- 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/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
- package/src/hooks/useRenderClipContent.test.ts +33 -4
- package/src/hooks/useRenderClipContent.ts +7 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
- package/src/hooks/useSdkSession.ts +84 -14
- package/src/hooks/useStudioSdkSessions.ts +8 -1
- package/src/player/components/AudioWaveform.test.tsx +53 -1
- package/src/player/components/AudioWaveform.tsx +124 -65
- 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/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +18 -15
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +11 -4
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineClipFades.tsx +64 -31
- 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/TimelineLanes.tsx +15 -8
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineRazorInteraction.tsx +1 -1
- package/src/player/components/TimelineRuler.tsx +3 -2
- 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/TimelineTransitionBadge.tsx +59 -0
- package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
- package/src/player/components/VideoThumbnail.test.tsx +23 -12
- package/src/player/components/VideoThumbnail.tsx +86 -32
- package/src/player/components/audioClipLink.test.ts +34 -0
- package/src/player/components/audioClipLink.ts +33 -0
- package/src/player/components/audioWaveformPeaks.test.ts +32 -0
- package/src/player/components/audioWaveformPeaks.ts +46 -0
- package/src/player/components/thumbnailUtils.test.ts +17 -0
- package/src/player/components/thumbnailUtils.ts +7 -0
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +18 -11
- package/src/player/components/timelineTransitionSeams.test.ts +48 -0
- package/src/player/components/timelineTransitionSeams.ts +36 -0
- package/src/player/components/timelineZones.test.ts +18 -0
- package/src/player/components/timelineZones.ts +4 -1
- package/src/player/lib/playbackTypes.ts +2 -0
- package/src/player/lib/timelineDOM.test.ts +28 -0
- package/src/player/lib/timelineDOM.ts +24 -2
- package/src/player/lib/timelineIframeHelpers.ts +7 -0
- package/src/player/lib/timelineTransitionMetadata.ts +52 -0
- package/src/player/lib/timelineViewportBudgets.ts +2 -1
- package/src/player/store/timelineElement.ts +1 -0
- package/src/styles/hex-baseline.json +3 -22
- package/src/styles/studio.css +126 -46
- package/src/styles/theme.css +113 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterVisibility.ts +2 -2
- package/src/utils/sdkCutover.test.ts +102 -1
- package/src/utils/sdkCutover.ts +16 -21
- package/src/utils/sdkEditTransaction.ts +50 -1
- package/src/utils/studioUiPreferences.ts +1 -1
|
@@ -1,7 +1,27 @@
|
|
|
1
|
-
import {
|
|
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";
|
|
2
20
|
import { usePlayerStore } from "../../player";
|
|
21
|
+
import { clampNumber } from "../../utils/studioHelpers";
|
|
3
22
|
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
4
23
|
import { useStudioShellContext } from "../../contexts/StudioContext";
|
|
24
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
5
25
|
import {
|
|
6
26
|
METER_DB_MARKS,
|
|
7
27
|
useProjectHasAudio,
|
|
@@ -11,8 +31,8 @@ import {
|
|
|
11
31
|
} from "../../utils/audioMeterMath";
|
|
12
32
|
|
|
13
33
|
interface Levels {
|
|
14
|
-
master:
|
|
15
|
-
groups: Record<string,
|
|
34
|
+
master: StereoLevel;
|
|
35
|
+
groups: Record<string, StereoLevel>;
|
|
16
36
|
}
|
|
17
37
|
interface AudioMeterHook {
|
|
18
38
|
start(): void;
|
|
@@ -22,23 +42,67 @@ interface AudioMeterHook {
|
|
|
22
42
|
interface Strip {
|
|
23
43
|
id: string | null;
|
|
24
44
|
label: string;
|
|
45
|
+
volume: number;
|
|
25
46
|
}
|
|
26
|
-
type Bars = {
|
|
47
|
+
type Bars = { mask: HTMLElement | null; peak: HTMLElement | null };
|
|
27
48
|
type StripBars = [Bars, Bars];
|
|
28
49
|
|
|
29
|
-
const
|
|
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);
|
|
30
55
|
|
|
31
56
|
function useStrips(): Strip[] {
|
|
32
57
|
const elements = usePlayerStore((s) => s.elements);
|
|
58
|
+
const masterVolume = usePlayerStore((s) => s.audioVolume);
|
|
33
59
|
return useMemo(() => {
|
|
34
60
|
const labels = new Map<string, string>();
|
|
61
|
+
const volumes = new Map<string, number>();
|
|
35
62
|
for (const el of elements) {
|
|
36
63
|
if (el.audioGroup && !labels.has(el.audioGroup)) {
|
|
37
64
|
labels.set(el.audioGroup, el.audioGroupLabel ?? el.audioGroup);
|
|
65
|
+
volumes.set(el.audioGroup, el.audioGroupVolume ?? 1);
|
|
38
66
|
}
|
|
39
67
|
}
|
|
40
|
-
return [
|
|
41
|
-
|
|
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 };
|
|
42
106
|
}
|
|
43
107
|
|
|
44
108
|
type PreviewWindow = (Window & { __hf?: { audioMeter?: AudioMeterHook } }) | null | undefined;
|
|
@@ -59,13 +123,61 @@ function paintPeak(el: HTMLElement | null, peak: number): void {
|
|
|
59
123
|
el.style.setProperty("transform", peak >= 1 ? "translateY(1px)" : "none");
|
|
60
124
|
}
|
|
61
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. */
|
|
62
129
|
function paint(bars: StripBars | undefined, channels: Pair): void {
|
|
63
130
|
channels.forEach((ch, i) => {
|
|
64
|
-
bars?.[i]?.
|
|
131
|
+
bars?.[i]?.mask?.style.setProperty("height", `${(1 - ch.level) * 100}%`);
|
|
65
132
|
paintPeak(bars?.[i]?.peak ?? null, ch.peak);
|
|
66
133
|
});
|
|
67
134
|
}
|
|
68
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
|
+
|
|
69
181
|
/** One rAF loop re-reads the hook off the live preview window, so a reloaded iframe is followed. */
|
|
70
182
|
function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripBars>>) {
|
|
71
183
|
const { previewIframeRef } = useStudioShellContext();
|
|
@@ -78,34 +190,12 @@ function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripB
|
|
|
78
190
|
const state = new Map<string | null, Pair>();
|
|
79
191
|
const tick = (now: number) => {
|
|
80
192
|
raf = requestAnimationFrame(tick);
|
|
81
|
-
|
|
82
|
-
if (live !== active) {
|
|
83
|
-
try {
|
|
84
|
-
active?.stop();
|
|
85
|
-
} catch {
|
|
86
|
-
// Preview iframe was torn down; the old hook is uncallable.
|
|
87
|
-
}
|
|
88
|
-
try {
|
|
89
|
-
live?.start();
|
|
90
|
-
} catch {
|
|
91
|
-
// New preview is not ready to attach yet.
|
|
92
|
-
}
|
|
93
|
-
active = live;
|
|
94
|
-
}
|
|
193
|
+
active = followMeterHook(active, readHook(previewIframeRef.current));
|
|
95
194
|
const levels = active?.read();
|
|
96
195
|
const dt = now - last;
|
|
97
196
|
last = now;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
state.get(id),
|
|
101
|
-
id === null ? levels?.master : levels?.groups[id],
|
|
102
|
-
now,
|
|
103
|
-
dt,
|
|
104
|
-
);
|
|
105
|
-
if (next === state.get(id)) continue;
|
|
106
|
-
state.set(id, next);
|
|
107
|
-
paint(bars.current.get(id), next);
|
|
108
|
-
}
|
|
197
|
+
evictGoneMeterState(state, new Set(stripsRef.current.map((s) => s.id)));
|
|
198
|
+
stepAndPaintStrips(stripsRef.current, state, bars.current, levels, now, dt);
|
|
109
199
|
};
|
|
110
200
|
raf = requestAnimationFrame(tick);
|
|
111
201
|
return () => {
|
|
@@ -119,18 +209,124 @@ function useMeterLoop(strips: Strip[], bars: RefObject<Map<string | null, StripB
|
|
|
119
209
|
}, [previewIframeRef, bars]);
|
|
120
210
|
}
|
|
121
211
|
|
|
122
|
-
function Bar({
|
|
212
|
+
function Bar({ maskRef, peakRef }: { maskRef: Ref<HTMLDivElement>; peakRef: Ref<HTMLDivElement> }) {
|
|
123
213
|
return (
|
|
124
|
-
<div className="relative h-full w-
|
|
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
|
+
/>
|
|
125
227
|
<div
|
|
126
|
-
ref={
|
|
127
|
-
|
|
128
|
-
|
|
228
|
+
ref={maskRef}
|
|
229
|
+
data-testid="meter-mask"
|
|
230
|
+
className="absolute inset-x-0 top-0 bg-neutral-900"
|
|
231
|
+
style={{ height: "100%" }}
|
|
129
232
|
/>
|
|
130
233
|
<div
|
|
131
234
|
ref={peakRef}
|
|
132
235
|
data-testid="meter-peak"
|
|
133
|
-
className="absolute inset-x-0 bottom-0 h-px bg-
|
|
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}%` }}
|
|
134
330
|
/>
|
|
135
331
|
</div>
|
|
136
332
|
);
|
|
@@ -139,9 +335,13 @@ function Bar({ fillRef, peakRef }: { fillRef: Ref<HTMLDivElement>; peakRef: Ref<
|
|
|
139
335
|
function MeterStrip({
|
|
140
336
|
strip,
|
|
141
337
|
register,
|
|
338
|
+
onLive,
|
|
339
|
+
onCommit,
|
|
142
340
|
}: {
|
|
143
341
|
strip: Strip;
|
|
144
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;
|
|
145
345
|
}) {
|
|
146
346
|
const refs = [
|
|
147
347
|
useRef<HTMLDivElement>(null),
|
|
@@ -151,22 +351,28 @@ function MeterStrip({
|
|
|
151
351
|
] as const;
|
|
152
352
|
useEffect(() => {
|
|
153
353
|
register(strip.id, [
|
|
154
|
-
{
|
|
155
|
-
{
|
|
354
|
+
{ mask: refs[0].current, peak: refs[1].current },
|
|
355
|
+
{ mask: refs[2].current, peak: refs[3].current },
|
|
156
356
|
]);
|
|
157
357
|
return () => register(strip.id, null);
|
|
158
358
|
// refs are stable objects
|
|
159
359
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
160
360
|
}, [strip.id, register]);
|
|
161
361
|
return (
|
|
162
|
-
<div className="flex w-
|
|
362
|
+
<div className="flex w-[104px] shrink-0 flex-col items-center gap-1 px-1.5 pt-2 pb-1">
|
|
163
363
|
<div
|
|
164
|
-
className="flex min-h-0 flex-1 items-stretch gap-
|
|
364
|
+
className="flex min-h-0 flex-1 items-stretch gap-1.5"
|
|
165
365
|
aria-label={`${strip.label} level`}
|
|
166
366
|
>
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
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">
|
|
170
376
|
{METER_DB_MARKS.map((db) => (
|
|
171
377
|
<span
|
|
172
378
|
key={db}
|
|
@@ -177,6 +383,10 @@ function MeterStrip({
|
|
|
177
383
|
</span>
|
|
178
384
|
))}
|
|
179
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>
|
|
180
390
|
</div>
|
|
181
391
|
<span className="max-w-full truncate text-[10px] text-neutral-400" title={strip.label}>
|
|
182
392
|
{strip.label}
|
|
@@ -199,6 +409,7 @@ function MeterStripBody() {
|
|
|
199
409
|
if (b) bars.current.set(id, b);
|
|
200
410
|
else bars.current.delete(id);
|
|
201
411
|
}).current;
|
|
412
|
+
const { onLive, onCommit } = useVolumeHandlers();
|
|
202
413
|
useMeterLoop(strips, bars);
|
|
203
414
|
return (
|
|
204
415
|
<div
|
|
@@ -206,7 +417,13 @@ function MeterStripBody() {
|
|
|
206
417
|
className="flex shrink-0 overflow-x-auto border-l border-neutral-800/50 bg-neutral-950"
|
|
207
418
|
>
|
|
208
419
|
{strips.map((strip) => (
|
|
209
|
-
<MeterStrip
|
|
420
|
+
<MeterStrip
|
|
421
|
+
key={strip.id ?? "master"}
|
|
422
|
+
strip={strip}
|
|
423
|
+
register={register}
|
|
424
|
+
onLive={onLive}
|
|
425
|
+
onCommit={onCommit}
|
|
426
|
+
/>
|
|
210
427
|
))}
|
|
211
428
|
</div>
|
|
212
429
|
);
|
|
@@ -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>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
1
2
|
// @vitest-environment happy-dom
|
|
2
3
|
import { act } from "react";
|
|
3
4
|
import { createRoot } from "react-dom/client";
|
|
@@ -376,4 +377,59 @@ describe("external file change coordinator", () => {
|
|
|
376
377
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
377
378
|
});
|
|
378
379
|
});
|
|
380
|
+
|
|
381
|
+
// `/api/events` is one connection per SERVER (CLI host), not per project — a
|
|
382
|
+
// tab left open from a `preview` run whose port was later reused by a
|
|
383
|
+
// different project's `preview` shares this stream with it. Both projects
|
|
384
|
+
// commonly use the same default composition path, so without the filter a
|
|
385
|
+
// stale tab reloads its preview and re-reads its own composition on every
|
|
386
|
+
// save the OTHER project makes.
|
|
387
|
+
describe("cross-project deliveries on a shared connection", () => {
|
|
388
|
+
it("ignores a delivery whose projectId does not match this tab's", async () => {
|
|
389
|
+
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
|
|
390
|
+
const reloadPreview = vi.fn();
|
|
391
|
+
const reloadSdkSession = vi.fn();
|
|
392
|
+
await mountCoordinator({ drainPendingChanges, reloadPreview, reloadSdkSession });
|
|
393
|
+
|
|
394
|
+
await act(async () =>
|
|
395
|
+
handler?.({
|
|
396
|
+
path: "index.html",
|
|
397
|
+
content: "other project",
|
|
398
|
+
version: "v9",
|
|
399
|
+
projectId: "project-b",
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
expect(drainPendingChanges).not.toHaveBeenCalled();
|
|
404
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
405
|
+
expect(reloadSdkSession).not.toHaveBeenCalled();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("still reloads for a delivery whose projectId matches this tab's", async () => {
|
|
409
|
+
const reloadPreview = vi.fn();
|
|
410
|
+
await mountCoordinator({ reloadPreview });
|
|
411
|
+
|
|
412
|
+
await act(async () =>
|
|
413
|
+
handler?.({
|
|
414
|
+
path: "index.html",
|
|
415
|
+
content: "same project",
|
|
416
|
+
version: "v9",
|
|
417
|
+
projectId: "project-a",
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
expect(reloadPreview).toHaveBeenCalledOnce();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("still reloads when projectId is absent (older server, one release of skew)", async () => {
|
|
425
|
+
const reloadPreview = vi.fn();
|
|
426
|
+
await mountCoordinator({ reloadPreview });
|
|
427
|
+
|
|
428
|
+
await act(async () =>
|
|
429
|
+
handler?.({ path: "index.html", content: "no project id", version: "v9" }),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(reloadPreview).toHaveBeenCalledOnce();
|
|
433
|
+
});
|
|
434
|
+
});
|
|
379
435
|
});
|
|
@@ -349,6 +349,19 @@ export function useExternalFileChangeCoordinator({
|
|
|
349
349
|
logReload("file-change", { path: null, why: path ? "no project" : "no path in payload" });
|
|
350
350
|
return;
|
|
351
351
|
}
|
|
352
|
+
// `/api/events` is one connection per SERVER, not per project: under the
|
|
353
|
+
// CLI host, a tab left open from a `preview` run that has since exited
|
|
354
|
+
// shares this stream with whatever project now runs on that port. Both
|
|
355
|
+
// commonly use the same default composition path, so without this check
|
|
356
|
+
// a stale tab reloads its preview (which then 404s — the CLI host is
|
|
357
|
+
// single-project, so it can't resolve the OTHER project it's serving)
|
|
358
|
+
// and re-reads its own composition on every save the CURRENT project
|
|
359
|
+
// makes. Absent field (older server, one release of skew) still passes.
|
|
360
|
+
const deliveredProjectId = readFileChangeField(payload, "projectId");
|
|
361
|
+
if (deliveredProjectId && deliveredProjectId !== projectId) {
|
|
362
|
+
logReload("suppressed", { path, why: "other project" });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
352
365
|
pendingTimelineEditPathRef.current.delete(path);
|
|
353
366
|
|
|
354
367
|
const content = readFileChangeContent(payload);
|
|
@@ -13,7 +13,7 @@ import { useRenderClipContent } from "./useRenderClipContent";
|
|
|
13
13
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
14
|
|
|
15
15
|
afterEach(() => {
|
|
16
|
-
usePlayerStore.setState({ thumbnailMode: "hidden" });
|
|
16
|
+
usePlayerStore.setState({ thumbnailMode: "hidden", elements: [] });
|
|
17
17
|
document.body.innerHTML = "";
|
|
18
18
|
});
|
|
19
19
|
|
|
@@ -144,6 +144,36 @@ describe("useRenderClipContent", () => {
|
|
|
144
144
|
}
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
+
it("marks audio linked when a video clip uses the same file, and muted when hidden", () => {
|
|
148
|
+
usePlayerStore.setState({
|
|
149
|
+
thumbnailMode: "hidden",
|
|
150
|
+
elements: [
|
|
151
|
+
{
|
|
152
|
+
id: "picture",
|
|
153
|
+
tag: "video",
|
|
154
|
+
start: 0,
|
|
155
|
+
duration: 4,
|
|
156
|
+
track: 0,
|
|
157
|
+
src: "assets/clip.mp4",
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
const linked = renderClipContent({
|
|
162
|
+
id: "bed",
|
|
163
|
+
tag: "audio",
|
|
164
|
+
start: 0,
|
|
165
|
+
duration: 4,
|
|
166
|
+
track: 1,
|
|
167
|
+
src: "assets/clip.mp4",
|
|
168
|
+
hidden: true,
|
|
169
|
+
});
|
|
170
|
+
expect(isValidElement<{ linked: boolean; muted: boolean }>(linked)).toBe(true);
|
|
171
|
+
if (isValidElement<{ linked: boolean; muted: boolean }>(linked)) {
|
|
172
|
+
expect(linked.props.linked).toBe(true);
|
|
173
|
+
expect(linked.props.muted).toBe(true);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
147
177
|
it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
|
|
148
178
|
usePlayerStore.setState({ thumbnailMode: "adaptive" });
|
|
149
179
|
|
|
@@ -208,7 +238,7 @@ describe("useRenderClipContent", () => {
|
|
|
208
238
|
}
|
|
209
239
|
});
|
|
210
240
|
|
|
211
|
-
it("forwards the viewport priority
|
|
241
|
+
it("forwards the viewport priority to video media work", () => {
|
|
212
242
|
usePlayerStore.setState({ thumbnailMode: "adaptive", timelineSessionEpoch: 7 });
|
|
213
243
|
|
|
214
244
|
const content = renderClipContent(
|
|
@@ -229,7 +259,6 @@ describe("useRenderClipContent", () => {
|
|
|
229
259
|
projectId: string;
|
|
230
260
|
sessionEpoch: number;
|
|
231
261
|
priority: string;
|
|
232
|
-
rich: boolean;
|
|
233
262
|
}>(content),
|
|
234
263
|
).toBe(true);
|
|
235
264
|
if (isValidElement(content)) {
|
|
@@ -237,8 +266,8 @@ describe("useRenderClipContent", () => {
|
|
|
237
266
|
projectId: "my-project",
|
|
238
267
|
sessionEpoch: 7,
|
|
239
268
|
priority: "interaction",
|
|
240
|
-
rich: true,
|
|
241
269
|
});
|
|
270
|
+
expect(content.props).not.toHaveProperty("rich");
|
|
242
271
|
}
|
|
243
272
|
});
|
|
244
273
|
|