@hyperframes/studio 0.8.109 → 0.8.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/assets/{hyperframes-player-CJYtikAy.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-HTFVHUJ4.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CMSgrT2F.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-BQZBDqMs.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-CYP6dMd7.js → index-Co6W8IVk.js} +205 -204
  6. package/dist/index.d.ts +21 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5351 -4755
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +8 -8
  12. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  13. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  14. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  15. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  16. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  17. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  18. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  19. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  20. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  21. package/src/components/editor/marqueeCommit.ts +16 -8
  22. package/src/components/editor/mediaLinkEdits.test.ts +19 -6
  23. package/src/components/editor/mediaLinkEdits.ts +34 -13
  24. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  25. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  26. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  27. package/src/components/nle/useTimelineEditCallbacks.ts +2 -0
  28. package/src/contexts/TimelineEditContext.tsx +1 -0
  29. package/src/editorGesturesExports.test.tsx +1 -0
  30. package/src/hooks/appHotkeysDispatch.ts +9 -0
  31. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  32. package/src/hooks/timelineElementsAttribute.ts +113 -0
  33. package/src/hooks/timelineLinkEditPlan.test.ts +28 -0
  34. package/src/hooks/timelineLinkEditPlan.ts +6 -1
  35. package/src/hooks/useAppHotkeys.test.ts +34 -0
  36. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  37. package/src/hooks/useRenderClipContent.ts +3 -5
  38. package/src/hooks/useTimelineEditing.ts +13 -0
  39. package/src/hooks/useTimelineLinkEditing.test.tsx +12 -0
  40. package/src/hooks/useTimelineLinkEditing.ts +5 -0
  41. package/src/index.ts +4 -0
  42. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  43. package/src/player/components/AudioGainDialog.tsx +226 -0
  44. package/src/player/components/ClipBadges.test.tsx +32 -8
  45. package/src/player/components/ClipBadges.tsx +37 -31
  46. package/src/player/components/ClipContextMenu.groups.test.tsx +1 -1
  47. package/src/player/components/ClipContextMenu.tsx +1 -1
  48. package/src/player/components/ClipPeakMarks.tsx +2 -14
  49. package/src/player/components/Timeline.test.ts +15 -0
  50. package/src/player/components/TimelineClip.test.tsx +41 -0
  51. package/src/player/components/TimelineClip.tsx +3 -2
  52. package/src/player/components/TimelineLanes.test.tsx +45 -2
  53. package/src/player/components/TimelineLanes.tsx +0 -2
  54. package/src/player/components/TimelineOverlays.tsx +2 -0
  55. package/src/player/components/audioClipLink.test.ts +60 -0
  56. package/src/player/components/audioClipLink.ts +77 -0
  57. package/src/player/components/audioGainApply.test.ts +87 -0
  58. package/src/player/components/audioGainApply.ts +57 -0
  59. package/src/player/components/audioGainDialogStore.ts +36 -0
  60. package/src/player/components/audioGainPlan.test.ts +82 -0
  61. package/src/player/components/audioGainPlan.ts +53 -0
  62. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +16 -8
  63. package/src/player/components/clipMenuAudioItems.test.tsx +8 -42
  64. package/src/player/components/clipMenuAudioItems.tsx +16 -37
  65. package/src/player/components/clipMenuLinkItems.test.ts +84 -0
  66. package/src/player/components/clipMenuLinkItems.tsx +54 -9
  67. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  68. package/src/player/components/clipMenuToolItems.tsx +79 -6
  69. package/src/player/components/clipPeakMap.ts +46 -0
  70. package/src/player/components/clipPeakRuns.test.ts +14 -1
  71. package/src/player/components/clipPeakRuns.ts +11 -0
  72. package/src/player/components/clipToolAttrs.test.ts +57 -41
  73. package/src/player/components/clipToolAttrs.ts +48 -45
  74. package/src/player/components/studioShortcuts.ts +2 -0
  75. package/src/player/components/timelineCallbacks.ts +6 -0
  76. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  77. package/src/player/components/timelineClipDragPreview.ts +32 -7
  78. package/src/player/components/timelineClipGestureHandlers.ts +26 -31
  79. package/src/player/components/timelineEditing.test.ts +0 -13
  80. package/src/player/components/timelineEditing.ts +0 -11
  81. package/src/player/components/timelineLaneProps.ts +0 -5
  82. package/src/player/components/timelineLinkSelection.ts +20 -2
  83. package/src/player/components/timelineViewportGeometry.ts +3 -0
  84. package/src/player/components/useClipToolState.ts +0 -5
  85. package/src/player/components/useTimelineClipDrag.ts +12 -5
  86. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  87. package/src/player/components/useTimelineProviderState.tsx +0 -2
  88. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  89. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  90. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  91. package/src/utils/typingTarget.test.ts +8 -0
  92. 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("renders nothing for an untouched clip", () => {
57
+ it("shows a grey fx badge and no volume on an untouched clip", () => {
58
58
  render(talk);
59
- expect(document.querySelector("[data-testid='clip-badges']")).toBeNull();
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("reads the live node and follows an agent's attribute edit", async () => {
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
- expect(labels()).toEqual(["link:🔗", "crop:Crop"]);
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(["link:🔗", "look:Warm daylight", "crop:Crop", "more:+1"]);
80
- expect(document.querySelector("[data-badge='more']")?.getAttribute("title")).toBe("60%");
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 { readClipBadges, splitVisibleBadges, type ClipBadge } from "./clipToolAttrs";
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 border-white/20 bg-black/55 px-1 text-[9px] leading-[14px] text-white/90 whitespace-nowrap";
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
- /** What is applied to a clip, read from its attributes: a link badge, then at most two more and `+N`. */
42
- export const ClipBadges = memo(function ClipBadges({ el }: { el: TimelineElement }) {
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 { visible, hidden } = splitVisibleBadges(readClipBadges(state));
45
- if (visible.length === 0) return null;
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
- {visible.map((badge) => (
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} pointer-events-auto`}
59
- title={hidden.map((badge) => badge.label).join(", ")}
60
- data-badge="more"
52
+ className={`${BADGE_CLASS} border-white/20 text-white/90`}
53
+ title={volume}
54
+ data-badge="volume"
61
55
  >
62
- +{hidden.length}
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
  });
@@ -84,7 +84,7 @@ describe("ClipContextMenu order", () => {
84
84
  const expected = [
85
85
  "Split at 2.00s",
86
86
  "Freeze frame",
87
- "Normalize loudness",
87
+ "Audio Gain…",
88
88
  "Voice",
89
89
  "Detach audio",
90
90
  "Unlink from audio",
@@ -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="normalize" element={element} onClose={onClose} />
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, isPeakMap, type ClipSourceWindow, type PeakMap } from "./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
  }