@hyperframes/studio 0.8.109 → 0.8.111
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-CJYtikAy.js → hyperframes-player-DvJeHBHi.js} +1 -1
- package/dist/assets/{index-CMSgrT2F.js → index--G6c4Q_x.js} +1 -1
- package/dist/assets/{index-HTFVHUJ4.js → index-Bt4vDkfw.js} +1 -1
- package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
- package/dist/assets/{index-CYP6dMd7.js → index-SFktf7O1.js} +205 -204
- package/dist/index.d.ts +22 -6
- package/dist/index.html +2 -2
- package/dist/index.js +5352 -4755
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
- package/src/components/editor/DomEditCropHandles.tsx +2 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
- package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
- package/src/components/editor/domEditOverlayGestures.ts +10 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
- package/src/components/editor/marqueeCommit.test.tsx +31 -6
- package/src/components/editor/marqueeCommit.ts +16 -8
- package/src/components/editor/mediaLinkEdits.test.ts +19 -6
- package/src/components/editor/mediaLinkEdits.ts +34 -13
- package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
- package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/editorGesturesExports.test.tsx +1 -0
- package/src/hooks/appHotkeysDispatch.ts +9 -0
- package/src/hooks/timelineElementsAttribute.test.ts +95 -0
- package/src/hooks/timelineElementsAttribute.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
- package/src/hooks/timelineLinkEditPlan.ts +6 -1
- package/src/hooks/useAppHotkeys.test.ts +34 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
- package/src/hooks/useRenderClipContent.ts +3 -5
- package/src/hooks/useTimelineEditing.ts +13 -0
- package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
- package/src/hooks/useTimelineLinkEditing.ts +5 -0
- package/src/index.ts +5 -0
- package/src/player/components/AudioGainDialog.test.tsx +133 -0
- package/src/player/components/AudioGainDialog.tsx +226 -0
- package/src/player/components/ClipBadges.test.tsx +32 -8
- package/src/player/components/ClipBadges.tsx +37 -31
- package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
- package/src/player/components/ClipContextMenu.tsx +1 -1
- package/src/player/components/ClipPeakMarks.tsx +2 -14
- package/src/player/components/Timeline.test.ts +15 -0
- package/src/player/components/TimelineClip.test.tsx +41 -0
- package/src/player/components/TimelineClip.tsx +3 -2
- package/src/player/components/TimelineLanes.test.tsx +45 -2
- package/src/player/components/TimelineLanes.tsx +0 -2
- package/src/player/components/TimelineOverlays.tsx +2 -0
- package/src/player/components/audioClipLink.test.ts +60 -0
- package/src/player/components/audioClipLink.ts +77 -0
- package/src/player/components/audioGainApply.test.ts +87 -0
- package/src/player/components/audioGainApply.ts +57 -0
- package/src/player/components/audioGainDialogStore.ts +36 -0
- package/src/player/components/audioGainPlan.test.ts +82 -0
- package/src/player/components/audioGainPlan.ts +53 -0
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
- package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
- package/src/player/components/clipMenuAudioItems.tsx +16 -37
- package/src/player/components/clipMenuLinkItems.test.ts +84 -0
- package/src/player/components/clipMenuLinkItems.tsx +54 -9
- package/src/player/components/clipMenuToolItems.test.tsx +130 -4
- package/src/player/components/clipMenuToolItems.tsx +79 -6
- package/src/player/components/clipPeakMap.ts +46 -0
- package/src/player/components/clipPeakRuns.test.ts +14 -1
- package/src/player/components/clipPeakRuns.ts +11 -0
- package/src/player/components/clipToolAttrs.test.ts +57 -41
- package/src/player/components/clipToolAttrs.ts +48 -45
- package/src/player/components/studioShortcuts.ts +2 -0
- package/src/player/components/timelineCallbacks.ts +6 -0
- package/src/player/components/timelineClipDragPreview.test.ts +96 -0
- package/src/player/components/timelineClipDragPreview.ts +32 -7
- package/src/player/components/timelineClipGestureHandlers.ts +26 -31
- package/src/player/components/timelineEditing.test.ts +0 -13
- package/src/player/components/timelineEditing.ts +0 -11
- package/src/player/components/timelineLaneProps.ts +0 -5
- package/src/player/components/timelineLinkSelection.ts +20 -2
- package/src/player/components/timelineViewportGeometry.ts +3 -0
- package/src/player/components/useClipToolState.ts +0 -5
- package/src/player/components/useTimelineClipDrag.ts +12 -5
- package/src/player/components/useTimelineOverlaysState.ts +0 -3
- package/src/player/components/useTimelineProviderState.tsx +0 -2
- package/src/player/components/useTimelineRangeSelection.ts +0 -14
- package/src/player/components/useTimelineRowVirtualization.ts +2 -1
- package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
- package/src/utils/studioPendingEdits.test.ts +18 -0
- package/src/utils/typingTarget.test.ts +8 -0
- package/src/utils/typingTarget.ts +1 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { audioGainToDb } from "@hyperframes/core/audio-gain";
|
|
4
|
+
import type { TimelineElement } from "../store/timelineElement";
|
|
5
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
6
|
+
import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
|
|
7
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
8
|
+
import { measureClipSourcePeak } from "./clipPeakMap";
|
|
9
|
+
import { AUDIO_GAIN_LABEL, normalizeClipsLoudness, writeClipGains } from "./audioGainApply";
|
|
10
|
+
import { useAudioGainDialogStore } from "./audioGainDialogStore";
|
|
11
|
+
import {
|
|
12
|
+
peakAmplitudeDb,
|
|
13
|
+
planAudioGain,
|
|
14
|
+
type AudioGainClip,
|
|
15
|
+
type AudioGainMode,
|
|
16
|
+
} from "./audioGainPlan";
|
|
17
|
+
|
|
18
|
+
type DialogMode = AudioGainMode | "loudness";
|
|
19
|
+
|
|
20
|
+
const PEAK_MODES: ReadonlySet<DialogMode> = new Set(["normalize-max", "normalize-all"]);
|
|
21
|
+
|
|
22
|
+
const ROWS: ReadonlyArray<{ mode: AudioGainMode; label: string }> = [
|
|
23
|
+
{ mode: "set", label: "Set Gain to" },
|
|
24
|
+
{ mode: "adjust", label: "Adjust Gain by" },
|
|
25
|
+
{ mode: "normalize-max", label: "Normalize Max Peak to" },
|
|
26
|
+
{ mode: "normalize-all", label: "Normalize All Peaks to" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
30
|
+
const currentGainDb = (el: TimelineElement | undefined) => {
|
|
31
|
+
const gain = el?.volume ?? 1;
|
|
32
|
+
return gain > 0 ? Math.round(audioGainToDb(gain) * 10) / 10 : -60;
|
|
33
|
+
};
|
|
34
|
+
const dbText = (db: number) => `${db < 0 ? "−" : ""}${Math.abs(db).toFixed(1)} dB`;
|
|
35
|
+
|
|
36
|
+
function useSourcePeaks(elements: readonly TimelineElement[], projectId: string | null) {
|
|
37
|
+
const [peaks, setPeaks] = useState<Map<string, number | null> | null>(null);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!projectId) return;
|
|
40
|
+
let live = true;
|
|
41
|
+
void Promise.all(elements.map((el) => measureClipSourcePeak(el, projectId))).then((found) => {
|
|
42
|
+
if (live) setPeaks(new Map(elements.map((el, i) => [keyOf(el), found[i] ?? null])));
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
live = false;
|
|
46
|
+
};
|
|
47
|
+
}, [elements, projectId]);
|
|
48
|
+
return peaks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Row({
|
|
52
|
+
checked,
|
|
53
|
+
disabled,
|
|
54
|
+
label,
|
|
55
|
+
onSelect,
|
|
56
|
+
children,
|
|
57
|
+
}: {
|
|
58
|
+
checked: boolean;
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
label: string;
|
|
61
|
+
onSelect: () => void;
|
|
62
|
+
children?: ReactNode;
|
|
63
|
+
}) {
|
|
64
|
+
return (
|
|
65
|
+
<label
|
|
66
|
+
className={`flex items-center justify-between gap-3 py-1 ${disabled ? "opacity-40" : ""}`}
|
|
67
|
+
>
|
|
68
|
+
<span className="flex items-center gap-2">
|
|
69
|
+
<input
|
|
70
|
+
type="radio"
|
|
71
|
+
name="audio-gain-mode"
|
|
72
|
+
checked={checked}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
onChange={onSelect}
|
|
75
|
+
/>
|
|
76
|
+
{label}
|
|
77
|
+
</span>
|
|
78
|
+
{children}
|
|
79
|
+
</label>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Premiere's Audio Gain dialog for the clips the store names. */
|
|
84
|
+
export function AudioGainDialog({
|
|
85
|
+
elements,
|
|
86
|
+
onClose,
|
|
87
|
+
}: {
|
|
88
|
+
elements: readonly TimelineElement[];
|
|
89
|
+
onClose: () => void;
|
|
90
|
+
}) {
|
|
91
|
+
const shell = useStudioShellContextOptional();
|
|
92
|
+
const edit = useTimelineEditContextOptional();
|
|
93
|
+
const sessionProjectId = usePlayerStore((s) => s.timelineProjectId);
|
|
94
|
+
const projectId = shell?.projectId ?? sessionProjectId;
|
|
95
|
+
const showToast = shell?.showToast ?? edit.onNotice;
|
|
96
|
+
const peaks = useSourcePeaks(elements, projectId);
|
|
97
|
+
const [mode, setMode] = useState<DialogMode>("adjust");
|
|
98
|
+
const [values, setValues] = useState<Record<AudioGainMode, number>>(() => ({
|
|
99
|
+
set: currentGainDb(elements[0]),
|
|
100
|
+
adjust: 0,
|
|
101
|
+
"normalize-max": 0,
|
|
102
|
+
"normalize-all": 0,
|
|
103
|
+
}));
|
|
104
|
+
const clips: AudioGainClip[] = elements.map((el) => ({
|
|
105
|
+
key: keyOf(el),
|
|
106
|
+
gain: el.volume ?? 1,
|
|
107
|
+
sourcePeak: peaks?.get(keyOf(el)) ?? null,
|
|
108
|
+
}));
|
|
109
|
+
const peakDb = peakAmplitudeDb(clips);
|
|
110
|
+
const outOfRange = PEAK_MODES.has(mode) && mode !== "loudness" && values[mode] > 0;
|
|
111
|
+
const canApply = !outOfRange && !(PEAK_MODES.has(mode) && peakDb === null);
|
|
112
|
+
|
|
113
|
+
const apply = async () => {
|
|
114
|
+
if (!canApply) return;
|
|
115
|
+
onClose();
|
|
116
|
+
try {
|
|
117
|
+
if (mode === "loudness") {
|
|
118
|
+
if (!projectId) return;
|
|
119
|
+
showToast?.(await normalizeClipsLoudness(projectId, elements, edit), "info");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const byKey = new Map(elements.map((el) => [keyOf(el), el]));
|
|
123
|
+
const plan = planAudioGain(clips, { mode, db: values[mode] });
|
|
124
|
+
const edits = plan.flatMap(({ key, gain }) => {
|
|
125
|
+
const element = byKey.get(key);
|
|
126
|
+
return element ? [{ element, gain }] : [];
|
|
127
|
+
});
|
|
128
|
+
await writeClipGains(edits, edit, AUDIO_GAIN_LABEL);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
showToast?.(error instanceof Error ? error.message : String(error), "error");
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const field = (rowMode: AudioGainMode) => (
|
|
135
|
+
<span className="flex items-center gap-1">
|
|
136
|
+
<input
|
|
137
|
+
type="number"
|
|
138
|
+
step={0.1}
|
|
139
|
+
max={PEAK_MODES.has(rowMode) ? 0 : undefined}
|
|
140
|
+
aria-label={`${ROWS.find((row) => row.mode === rowMode)?.label ?? ""} dB`}
|
|
141
|
+
value={values[rowMode]}
|
|
142
|
+
disabled={mode !== rowMode}
|
|
143
|
+
className="w-16 rounded border border-neutral-700 bg-neutral-950 px-1 text-right disabled:opacity-50"
|
|
144
|
+
onChange={(event) =>
|
|
145
|
+
setValues((prev) => ({ ...prev, [rowMode]: Number(event.target.value) || 0 }))
|
|
146
|
+
}
|
|
147
|
+
/>
|
|
148
|
+
dB
|
|
149
|
+
</span>
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return createPortal(
|
|
153
|
+
<div
|
|
154
|
+
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50"
|
|
155
|
+
onPointerDown={(event) => event.target === event.currentTarget && onClose()}
|
|
156
|
+
>
|
|
157
|
+
<div
|
|
158
|
+
role="dialog"
|
|
159
|
+
aria-modal="true"
|
|
160
|
+
aria-label={AUDIO_GAIN_LABEL}
|
|
161
|
+
className="w-[340px] rounded-md border border-neutral-700 bg-neutral-900 p-4 text-xs text-neutral-200 shadow-xl"
|
|
162
|
+
onKeyDown={(event) => {
|
|
163
|
+
event.stopPropagation();
|
|
164
|
+
if (event.key === "Escape") onClose();
|
|
165
|
+
if (event.key !== "Enter") return;
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
void apply();
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<div className="mb-3 text-sm font-medium">{AUDIO_GAIN_LABEL}</div>
|
|
171
|
+
{ROWS.map((row) => (
|
|
172
|
+
<Row
|
|
173
|
+
key={row.mode}
|
|
174
|
+
label={row.label}
|
|
175
|
+
checked={mode === row.mode}
|
|
176
|
+
disabled={PEAK_MODES.has(row.mode) && peaks !== null && peakDb === null}
|
|
177
|
+
onSelect={() => setMode(row.mode)}
|
|
178
|
+
>
|
|
179
|
+
{field(row.mode)}
|
|
180
|
+
</Row>
|
|
181
|
+
))}
|
|
182
|
+
<Row
|
|
183
|
+
label="Normalize loudness to −16 LUFS"
|
|
184
|
+
checked={mode === "loudness"}
|
|
185
|
+
onSelect={() => setMode("loudness")}
|
|
186
|
+
/>
|
|
187
|
+
<div className="mt-3 text-neutral-400" data-testid="audio-gain-peak">
|
|
188
|
+
Peak Amplitude: {peaks === null ? "measuring…" : peakDb === null ? "—" : dbText(peakDb)}
|
|
189
|
+
</div>
|
|
190
|
+
{outOfRange && <div className="mt-1 text-red-400">Peak targets must be 0 dB or lower.</div>}
|
|
191
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
className="rounded px-3 py-1 text-neutral-300 hover:bg-neutral-800"
|
|
195
|
+
onClick={onClose}
|
|
196
|
+
>
|
|
197
|
+
Cancel
|
|
198
|
+
</button>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
disabled={!canApply}
|
|
202
|
+
autoFocus
|
|
203
|
+
className="rounded bg-blue-500 px-3 py-1 text-white disabled:opacity-40"
|
|
204
|
+
onClick={() => void apply()}
|
|
205
|
+
>
|
|
206
|
+
OK
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>,
|
|
211
|
+
document.body,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Mounted in the timeline's overlays so Studio and embedders that mount the Timeline both get it. */
|
|
216
|
+
export function TimelineAudioGainOverlay() {
|
|
217
|
+
const targetKeys = useAudioGainDialogStore((s) => s.targetKeys);
|
|
218
|
+
const close = useAudioGainDialogStore((s) => s.close);
|
|
219
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
220
|
+
const targets = useMemo(() => {
|
|
221
|
+
const wanted = new Set(targetKeys ?? []);
|
|
222
|
+
return elements.filter((el) => wanted.has(keyOf(el)));
|
|
223
|
+
}, [elements, targetKeys]);
|
|
224
|
+
if (!targetKeys || targets.length === 0) return null;
|
|
225
|
+
return <AudioGainDialog key={targetKeys.join("\n")} elements={targets} onClose={close} />;
|
|
226
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { TimelineElement } from "../store/playerStore";
|
|
7
7
|
import { usePreviewIframeStore } from "../store/previewIframeStore";
|
|
8
8
|
import { ClipBadges } from "./ClipBadges";
|
|
@@ -54,29 +54,53 @@ function mountPreview(html: string): Document {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
describe("ClipBadges", () => {
|
|
57
|
-
it("
|
|
57
|
+
it("shows a grey fx badge and no volume on an untouched clip", () => {
|
|
58
58
|
render(talk);
|
|
59
|
-
expect(
|
|
59
|
+
expect(labels()).toEqual(["fx:fx"]);
|
|
60
|
+
const fx = document.querySelector("[data-badge='fx']");
|
|
61
|
+
expect(fx?.getAttribute("data-fx-active")).toBe("false");
|
|
62
|
+
expect(fx?.getAttribute("title")).toBe("No effects");
|
|
60
63
|
});
|
|
61
64
|
|
|
62
65
|
it("falls back to the store's volume without a preview", () => {
|
|
63
66
|
render({ ...talk, volume: 1.8 });
|
|
64
|
-
expect(labels()).toEqual(["volume:180%"]);
|
|
67
|
+
expect(labels()).toEqual(["volume:180%", "fx:fx"]);
|
|
65
68
|
});
|
|
66
69
|
|
|
67
|
-
it("
|
|
70
|
+
it("lights the fx badge from the live node, lists the effects, and shows no link badge", async () => {
|
|
68
71
|
const doc = mountPreview(
|
|
69
72
|
`<video id="talk" data-has-audio="true" data-link="talk-pair" style="clip-path: inset(10px)"></video>`,
|
|
70
73
|
);
|
|
71
74
|
render(talk);
|
|
72
|
-
|
|
75
|
+
const fx = () => document.querySelector("[data-badge='fx']");
|
|
76
|
+
expect(labels()).toEqual(["fx:fx"]);
|
|
77
|
+
expect(fx()?.getAttribute("data-fx-active")).toBe("true");
|
|
78
|
+
expect(fx()?.getAttribute("title")).toBe("Crop");
|
|
73
79
|
const node = doc.getElementById("talk");
|
|
74
80
|
await act(async () => {
|
|
75
81
|
node?.setAttribute("data-color-grading", '{"preset":"warm-daylight","intensity":1}');
|
|
76
82
|
node?.setAttribute("data-volume", "0.6");
|
|
77
83
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
78
84
|
});
|
|
79
|
-
expect(labels()).toEqual(["
|
|
80
|
-
expect(
|
|
85
|
+
expect(labels()).toEqual(["volume:60%", "fx:fx"]);
|
|
86
|
+
expect(fx()?.getAttribute("title")).toBe("Look: Warm daylight · Crop");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("opens the clip menu from the fx badge without reaching the clip", () => {
|
|
90
|
+
const host = document.createElement("div");
|
|
91
|
+
document.body.append(host);
|
|
92
|
+
const onOpenMenu = vi.fn();
|
|
93
|
+
const onClipClick = vi.fn();
|
|
94
|
+
root = createRoot(host);
|
|
95
|
+
act(() =>
|
|
96
|
+
root?.render(
|
|
97
|
+
<div onClick={onClipClick}>
|
|
98
|
+
<ClipBadges el={talk} onOpenMenu={onOpenMenu} />
|
|
99
|
+
</div>,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
act(() => document.querySelector<HTMLElement>("[data-badge='fx']")?.click());
|
|
103
|
+
expect(onOpenMenu).toHaveBeenCalledTimes(1);
|
|
104
|
+
expect(onClipClick).not.toHaveBeenCalled();
|
|
81
105
|
});
|
|
82
106
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
1
|
+
import { memo, type MouseEvent } from "react";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
-
import {
|
|
3
|
+
import { clipVolumeBadge, readClipEffects } from "./clipToolAttrs";
|
|
4
4
|
import { useClipToolState } from "./useClipToolState";
|
|
5
5
|
|
|
6
6
|
function SpeakerGlyph({ muted }: { muted: boolean }) {
|
|
@@ -21,47 +21,53 @@ function SpeakerGlyph({ muted }: { muted: boolean }) {
|
|
|
21
21
|
);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function BadgeContent({ badge }: { badge: ClipBadge }) {
|
|
25
|
-
if (badge.kind === "link") return <span aria-hidden="true">🔗</span>;
|
|
26
|
-
if (badge.kind === "volume") {
|
|
27
|
-
const muted = badge.label === "Muted";
|
|
28
|
-
return (
|
|
29
|
-
<>
|
|
30
|
-
<SpeakerGlyph muted={muted} />
|
|
31
|
-
{muted ? null : <span>{badge.label}</span>}
|
|
32
|
-
</>
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
return <span>{badge.label}</span>;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
24
|
const BADGE_CLASS =
|
|
39
|
-
"inline-flex items-center gap-0.5 rounded-[3px] border
|
|
25
|
+
"timeline-clip__badge inline-flex items-center gap-0.5 rounded-[3px] border bg-black/55 px-1 text-[9px] leading-[14px] whitespace-nowrap";
|
|
26
|
+
const FX_ON = "border-white/40 text-white font-semibold";
|
|
27
|
+
const FX_OFF = "border-white/10 text-white/35";
|
|
40
28
|
|
|
41
|
-
/**
|
|
42
|
-
export const ClipBadges = memo(function ClipBadges({
|
|
29
|
+
/** Premiere's fx badge — grey with no effects, white with any; hover lists them, click opens the clip menu. */
|
|
30
|
+
export const ClipBadges = memo(function ClipBadges({
|
|
31
|
+
el,
|
|
32
|
+
onOpenMenu,
|
|
33
|
+
}: {
|
|
34
|
+
el: TimelineElement;
|
|
35
|
+
onOpenMenu?: (event: MouseEvent) => void;
|
|
36
|
+
}) {
|
|
43
37
|
const state = useClipToolState(el);
|
|
44
|
-
const
|
|
45
|
-
|
|
38
|
+
const effects = readClipEffects(state);
|
|
39
|
+
const volume = clipVolumeBadge(state);
|
|
40
|
+
const hasEffects = effects.length > 0;
|
|
41
|
+
const openMenu = (event: MouseEvent) => {
|
|
42
|
+
event.stopPropagation();
|
|
43
|
+
onOpenMenu?.(event);
|
|
44
|
+
};
|
|
46
45
|
return (
|
|
47
46
|
<span
|
|
48
47
|
className="pointer-events-none absolute right-1.5 top-0.5 z-[3] flex gap-1"
|
|
49
48
|
data-testid="clip-badges"
|
|
50
49
|
>
|
|
51
|
-
{
|
|
52
|
-
<span key={badge.kind} className={BADGE_CLASS} title={badge.label} data-badge={badge.kind}>
|
|
53
|
-
<BadgeContent badge={badge} />
|
|
54
|
-
</span>
|
|
55
|
-
))}
|
|
56
|
-
{hidden.length > 0 && (
|
|
50
|
+
{volume && (
|
|
57
51
|
<span
|
|
58
|
-
className={`${BADGE_CLASS}
|
|
59
|
-
title={
|
|
60
|
-
data-badge="
|
|
52
|
+
className={`${BADGE_CLASS} border-white/20 text-white/90`}
|
|
53
|
+
title={volume}
|
|
54
|
+
data-badge="volume"
|
|
61
55
|
>
|
|
62
|
-
|
|
56
|
+
<SpeakerGlyph muted={volume === "Muted"} />
|
|
57
|
+
{volume === "Muted" ? null : <span>{volume}</span>}
|
|
63
58
|
</span>
|
|
64
59
|
)}
|
|
60
|
+
<span
|
|
61
|
+
className={`${BADGE_CLASS} pointer-events-auto cursor-pointer ${hasEffects ? FX_ON : FX_OFF}`}
|
|
62
|
+
title={hasEffects ? effects.join(" · ") : "No effects"}
|
|
63
|
+
data-badge="fx"
|
|
64
|
+
data-fx-active={hasEffects ? "true" : "false"}
|
|
65
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
66
|
+
onClick={openMenu}
|
|
67
|
+
onContextMenu={openMenu}
|
|
68
|
+
>
|
|
69
|
+
fx
|
|
70
|
+
</span>
|
|
65
71
|
</span>
|
|
66
72
|
);
|
|
67
73
|
});
|
|
@@ -175,7 +175,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
|
|
175
175
|
</div>
|
|
176
176
|
|
|
177
177
|
<div role="group" aria-label="Sound" className={GROUP_CLASS}>
|
|
178
|
-
<ClipMenuAudioItems part="
|
|
178
|
+
<ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
|
|
179
179
|
<ClipMenuToolItems
|
|
180
180
|
group="sound"
|
|
181
181
|
element={element}
|
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
-
import { clipPeakRuns,
|
|
3
|
-
|
|
4
|
-
const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
|
|
5
|
-
|
|
6
|
-
function loadPeakMap(url: string): Promise<PeakMap | null> {
|
|
7
|
-
const pending = peakMapRequests.get(url);
|
|
8
|
-
if (pending) return pending;
|
|
9
|
-
const request = fetch(url)
|
|
10
|
-
.then((res) => (res.ok ? res.json() : null))
|
|
11
|
-
.then((body: unknown) => (isPeakMap(body) ? body : null))
|
|
12
|
-
.catch(() => null);
|
|
13
|
-
peakMapRequests.set(url, request);
|
|
14
|
-
return request;
|
|
15
|
-
}
|
|
2
|
+
import { clipPeakRuns, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
|
|
3
|
+
import { loadPeakMap } from "./clipPeakMap";
|
|
16
4
|
|
|
17
5
|
const dbText = (db: number) => `${db < 0 ? "−" : "+"}${Math.abs(db).toFixed(1)} dBFS`;
|
|
18
6
|
|
|
@@ -70,6 +70,21 @@ describe("timeline viewport geometry", () => {
|
|
|
70
70
|
const scrollTop = previous.getRowTop(2) - RULER_H + 6;
|
|
71
71
|
expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop)).toBe(scrollTop + 56);
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it("keeps a row added above the top row in view while the list sits at the top", () => {
|
|
75
|
+
// No top padding (trackPadding { top: 0 }), so the first row sits right under the ruler.
|
|
76
|
+
const previous = createTimelineRowGeometry([1, 2, 3], [48, 48, 48], { top: 0 });
|
|
77
|
+
const next = createTimelineRowGeometry([9, 1, 2, 3], [48, 48, 48, 48], { top: 0 });
|
|
78
|
+
expect(getTimelineScrollTopForGeometryChange(previous, next, 0)).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("leaves scrollTop to edge auto-scroll while a clip drag adds a row above", () => {
|
|
82
|
+
const previous = createTimelineRowGeometry([1, 2, 3], [48, 48, 48]);
|
|
83
|
+
const next = createTimelineRowGeometry([9, 1, 2, 3], [48, 48, 48, 48]);
|
|
84
|
+
const scrollTop = previous.getRowTop(1) - RULER_H + 6;
|
|
85
|
+
expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop, true)).toBe(scrollTop);
|
|
86
|
+
expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop)).toBe(scrollTop + 48);
|
|
87
|
+
});
|
|
73
88
|
});
|
|
74
89
|
|
|
75
90
|
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
|
|
@@ -133,6 +133,47 @@ describe("TimelineClip", () => {
|
|
|
133
133
|
act(() => root.unmount());
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
it("names a speed-changed clip like Premiere: [150%] for a constant rate, [ramp] for a lane", () => {
|
|
137
|
+
const fast = renderClip({
|
|
138
|
+
element: {
|
|
139
|
+
id: "a",
|
|
140
|
+
label: "Hero",
|
|
141
|
+
tag: "video",
|
|
142
|
+
start: 0,
|
|
143
|
+
duration: 2,
|
|
144
|
+
track: 0,
|
|
145
|
+
playbackRate: 1.5,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
expect(fast.host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero [150%]");
|
|
149
|
+
act(() => fast.root.unmount());
|
|
150
|
+
const automation = JSON.stringify({
|
|
151
|
+
version: 1,
|
|
152
|
+
lanes: [
|
|
153
|
+
{
|
|
154
|
+
target: "rate",
|
|
155
|
+
points: [
|
|
156
|
+
{ t: 0, v: 0.5 },
|
|
157
|
+
{ t: 1, v: 1 },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
const ramp = renderClip({
|
|
163
|
+
element: {
|
|
164
|
+
id: "b",
|
|
165
|
+
label: "Hero",
|
|
166
|
+
tag: "video",
|
|
167
|
+
start: 0,
|
|
168
|
+
duration: 2,
|
|
169
|
+
track: 0,
|
|
170
|
+
automation,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
expect(ramp.host.querySelector(".timeline-clip__label")?.textContent).toBe("Hero [ramp]");
|
|
174
|
+
act(() => ramp.root.unmount());
|
|
175
|
+
});
|
|
176
|
+
|
|
136
177
|
it("drops the label chip under 60px even when the clip is selected", () => {
|
|
137
178
|
const { host, root } = renderClip({
|
|
138
179
|
element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 1, track: 0 },
|
|
@@ -14,6 +14,7 @@ import { rendersWaveform } from "./AudioWaveform";
|
|
|
14
14
|
import { ClipBadges } from "./ClipBadges";
|
|
15
15
|
import { linkLabelColor } from "./linkLabelColor";
|
|
16
16
|
import { OutOfSyncBadge } from "./OutOfSyncBadge";
|
|
17
|
+
import { clipSpeedSuffix } from "./clipToolAttrs";
|
|
17
18
|
|
|
18
19
|
interface TimelineClipProps {
|
|
19
20
|
el: TimelineElement;
|
|
@@ -68,7 +69,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
68
69
|
const leftPx = el.start * pps;
|
|
69
70
|
const widthPx = Math.max(el.duration * pps, 4);
|
|
70
71
|
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
|
71
|
-
const displayLabel = el.label || el.id || el.tag
|
|
72
|
+
const displayLabel = `${el.label || el.id || el.tag}${clipSpeedSuffix(el.playbackRate, el.automation)}`;
|
|
72
73
|
const ladder = clipWidthLadder(widthPx);
|
|
73
74
|
const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
|
|
74
75
|
const showLabel = ladder === "labeled";
|
|
@@ -210,7 +211,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
210
211
|
</div>
|
|
211
212
|
)}
|
|
212
213
|
{showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
|
|
213
|
-
{showLabel && !isGestureActor && <ClipBadges el={el} />}
|
|
214
|
+
{showLabel && !isGestureActor && <ClipBadges el={el} onOpenMenu={onContextMenu} />}
|
|
214
215
|
{!isGestureActor && el.syncOrigin && <OutOfSyncBadge el={el} />}
|
|
215
216
|
{showDefaultText && (
|
|
216
217
|
<span className="timeline-clip__timecode">
|
|
@@ -83,8 +83,14 @@ interface RenderLanesOptions {
|
|
|
83
83
|
hoveredClip?: string | null;
|
|
84
84
|
renderClipContent?: React.ComponentProps<typeof TimelineLanes>["renderClipContent"];
|
|
85
85
|
snapGuide?: { time: number; type: "beat" | "clip-edge" | "playhead" } | null;
|
|
86
|
+
withoutSelectHandler?: boolean;
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
const selectHandlerFor = (
|
|
90
|
+
options: RenderLanesOptions,
|
|
91
|
+
handler: (element: TimelineElement | null) => void,
|
|
92
|
+
) => (options.withoutSelectHandler ? undefined : handler);
|
|
93
|
+
|
|
88
94
|
function renderLanes(options: RenderLanesOptions = {}): {
|
|
89
95
|
host: HTMLDivElement;
|
|
90
96
|
root: Root;
|
|
@@ -163,7 +169,6 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
163
169
|
setResizingClip={vi.fn()}
|
|
164
170
|
setDraggedClip={vi.fn()}
|
|
165
171
|
setSelectedElementId={setSelectedElementId}
|
|
166
|
-
shiftClickClipRef={createRef()}
|
|
167
172
|
getPreviewElement={(el) => el}
|
|
168
173
|
getTrackStyle={getTrackStyle}
|
|
169
174
|
gsapAnimations={gsapAnimations}
|
|
@@ -174,7 +179,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
|
|
|
174
179
|
onTogglePropertyGroupKeyframe={vi.fn()}
|
|
175
180
|
onResizeElement={vi.fn()}
|
|
176
181
|
onMoveElement={vi.fn()}
|
|
177
|
-
onSelectElement={onSelectElement}
|
|
182
|
+
onSelectElement={selectHandlerFor(next, onSelectElement)}
|
|
178
183
|
onRazorSplit={vi.fn()}
|
|
179
184
|
onRazorSplitAll={vi.fn()}
|
|
180
185
|
/>,
|
|
@@ -439,6 +444,44 @@ describe("TimelineLanes selection", () => {
|
|
|
439
444
|
expect(view.onSelectElement).toHaveBeenCalledWith(selected);
|
|
440
445
|
act(() => view.root.unmount());
|
|
441
446
|
});
|
|
447
|
+
|
|
448
|
+
it.each([["shiftKey"], ["metaKey"], ["ctrlKey"]])(
|
|
449
|
+
"%s-click adds a clip to the selection instead of replacing it",
|
|
450
|
+
(modifier) => {
|
|
451
|
+
const first = element("clip-a", TRACK_A);
|
|
452
|
+
const second = element("clip-b", TRACK_B);
|
|
453
|
+
usePlayerStore.getState().setElements([first, second]);
|
|
454
|
+
usePlayerStore.getState().setSelection([first.id], first.id);
|
|
455
|
+
const view = renderLanes({ elements: [first, second] });
|
|
456
|
+
const clipB = view.host.querySelector('[data-el-id="clip-b"]');
|
|
457
|
+
const click = (init: MouseEventInit) =>
|
|
458
|
+
act(() => clipB?.dispatchEvent(new MouseEvent("click", { bubbles: true, ...init })));
|
|
459
|
+
|
|
460
|
+
click({ [modifier]: true });
|
|
461
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a", "clip-b"]));
|
|
462
|
+
expect(view.setSelectedElementId).not.toHaveBeenCalled();
|
|
463
|
+
expect(view.onSelectElement).toHaveBeenLastCalledWith(second);
|
|
464
|
+
|
|
465
|
+
click({ [modifier]: true });
|
|
466
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a"]));
|
|
467
|
+
expect(view.onSelectElement).toHaveBeenLastCalledWith(first);
|
|
468
|
+
act(() => view.root.unmount());
|
|
469
|
+
},
|
|
470
|
+
);
|
|
471
|
+
it("Cmd-click adds a clip in a host that passes no onSelectElement", () => {
|
|
472
|
+
const first = element("clip-a", TRACK_A);
|
|
473
|
+
const second = element("clip-b", TRACK_B);
|
|
474
|
+
usePlayerStore.getState().setElements([first, second]);
|
|
475
|
+
usePlayerStore.getState().setSelection([first.id], first.id);
|
|
476
|
+
const view = renderLanes({ elements: [first, second], withoutSelectHandler: true });
|
|
477
|
+
act(() =>
|
|
478
|
+
view.host
|
|
479
|
+
.querySelector('[data-el-id="clip-b"]')
|
|
480
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true, metaKey: true })),
|
|
481
|
+
);
|
|
482
|
+
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["clip-a", "clip-b"]));
|
|
483
|
+
act(() => view.root.unmount());
|
|
484
|
+
});
|
|
442
485
|
});
|
|
443
486
|
|
|
444
487
|
describe("TimelineLanes clip thumbnails", () => {
|
|
@@ -77,7 +77,6 @@ export function TimelineLanes({
|
|
|
77
77
|
setResizingClip,
|
|
78
78
|
setDraggedClip,
|
|
79
79
|
setSelectedElementId,
|
|
80
|
-
shiftClickClipRef,
|
|
81
80
|
getPreviewElement,
|
|
82
81
|
getTrackStyle,
|
|
83
82
|
keyframeCache,
|
|
@@ -404,7 +403,6 @@ export function TimelineLanes({
|
|
|
404
403
|
onRazorSplit,
|
|
405
404
|
onRazorSplitAll,
|
|
406
405
|
blockedClipRef,
|
|
407
|
-
shiftClickClipRef,
|
|
408
406
|
suppressClickRef,
|
|
409
407
|
scrollRef,
|
|
410
408
|
setShowPopover,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TimelineAudioGainOverlay } from "./AudioGainDialog";
|
|
1
2
|
import { useEffect, useMemo } from "react";
|
|
2
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
4
|
import { EditPopover } from "./EditModal";
|
|
@@ -222,6 +223,7 @@ export function TimelineOverlays() {
|
|
|
222
223
|
<TimelineKeyframeMenuOverlay />
|
|
223
224
|
<TimelineClipMenuOverlay />
|
|
224
225
|
<TimelineGapMenuOverlay />
|
|
226
|
+
<TimelineAudioGainOverlay />
|
|
225
227
|
</>
|
|
226
228
|
);
|
|
227
229
|
}
|