@hyperframes/studio 0.8.108 → 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 (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -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
+ }
@@ -96,7 +96,7 @@ describe("AudioWaveform", () => {
96
96
  act(() => root.unmount());
97
97
  });
98
98
 
99
- it("greys the clip in place when muted and draws the parent tick when linked", () => {
99
+ it("greys the clip in place when muted", () => {
100
100
  const host = document.createElement("div");
101
101
  host.className = "timeline-clip is-audio";
102
102
  document.body.append(host);
@@ -112,15 +112,42 @@ describe("AudioWaveform", () => {
112
112
  sessionEpoch={1}
113
113
  priority="visible"
114
114
  muted
115
- linked
116
115
  />,
117
116
  );
118
117
  });
119
118
 
120
119
  expect(host.getAttribute("data-audio-muted")).toBe("true");
121
- expect(host.querySelector(".timeline-audio-link")).not.toBeNull();
122
120
 
123
121
  act(() => root.unmount());
124
122
  expect(host.hasAttribute("data-audio-muted")).toBe(false);
125
123
  });
124
+
125
+ it("fills a short sound strip when the label band is dropped", () => {
126
+ const heights = [16, 0].map((labelInset) => {
127
+ const host = document.createElement("div");
128
+ document.body.append(host);
129
+ const root = createRoot(host);
130
+ act(() => {
131
+ root.render(
132
+ <AudioWaveform
133
+ audioUrl="/media/talk.mp4"
134
+ label=""
135
+ labelColor="#fff"
136
+ projectId="project-a"
137
+ sessionEpoch={1}
138
+ priority="visible"
139
+ {...(labelInset === 16 ? {} : { labelInset })}
140
+ />,
141
+ );
142
+ });
143
+ const canvas = host.querySelector("canvas");
144
+ const box = { top: canvas?.style.top, height: canvas?.style.height };
145
+ act(() => root.unmount());
146
+ return box;
147
+ });
148
+ expect(heights).toEqual([
149
+ { top: "16px", height: "calc(100% - 16px)" },
150
+ { top: "0px", height: "calc(100% - 0px)" },
151
+ ]);
152
+ });
126
153
  });
@@ -18,8 +18,7 @@ export interface AudioWaveformProps {
18
18
  priority: ThumbnailPriority;
19
19
  /** `data-hidden` or a muted audio group. Greys the pill; the clip stays. */
20
20
  muted?: boolean;
21
- /** Same media file as a video clip. Draws the 1px parent tick. */
22
- linked?: boolean;
21
+ labelInset?: number;
23
22
  }
24
23
 
25
24
  const BAR_STEP = 3;
@@ -167,7 +166,7 @@ export const AudioWaveform = memo(function AudioWaveform({
167
166
  sessionEpoch,
168
167
  priority,
169
168
  muted = false,
170
- linked = false,
169
+ labelInset = 16,
171
170
  }: AudioWaveformProps) {
172
171
  const rootRef = useRef<HTMLDivElement | null>(null);
173
172
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -236,17 +235,17 @@ export const AudioWaveform = memo(function AudioWaveform({
236
235
 
237
236
  return (
238
237
  <div ref={rootRef} className="absolute inset-0">
239
- {linked ? <span className="timeline-audio-link" aria-hidden="true" /> : null}
240
238
  <div className="absolute inset-0 overflow-hidden" style={{ zIndex: 10 }}>
241
239
  <canvas
242
240
  ref={setCanvasRef}
243
241
  className="absolute inset-x-0 bottom-0 w-full"
244
- style={{ top: 16, height: "calc(100% - 16px)" }}
242
+ style={{ top: labelInset, height: `calc(100% - ${labelInset}px)` }}
245
243
  />
246
244
  {snapshot.status === "loading" && (
247
245
  <div
248
- className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
246
+ className="absolute inset-x-0 bottom-0 animate-pulse"
249
247
  style={{
248
+ top: labelInset,
250
249
  background: "var(--timeline-thumbnail-shimmer)",
251
250
  }}
252
251
  />
@@ -256,7 +255,7 @@ export const AudioWaveform = memo(function AudioWaveform({
256
255
  {snapshot.status === "error" && (
257
256
  <div
258
257
  className="absolute inset-x-0 flex items-center justify-center gap-1.5"
259
- style={{ top: 16, bottom: 0 }}
258
+ style={{ top: labelInset, bottom: 0 }}
260
259
  >
261
260
  <div
262
261
  className="absolute inset-x-0"
@@ -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
  });
@@ -0,0 +1,102 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { createHappyDomRootHarness } from "./testRootHarness";
5
+ import { ClipContextMenu } from "./ClipContextMenu";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
+ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
8
+
9
+ const iframe = document.createElement("iframe");
10
+ vi.mock("../../contexts/StudioContext", () => ({
11
+ useStudioShellContextOptional: () => ({
12
+ projectId: "p1",
13
+ showToast: vi.fn(),
14
+ previewIframeRef: { current: iframe },
15
+ }),
16
+ }));
17
+
18
+ const harness = createHappyDomRootHarness();
19
+
20
+ const talk: TimelineElement = {
21
+ id: "talk",
22
+ domId: "talk",
23
+ tag: "video",
24
+ src: "talk.mp4",
25
+ start: 0,
26
+ duration: 6,
27
+ track: 0,
28
+ hasAudio: true,
29
+ link: "lk-1",
30
+ };
31
+ const talkAudio: TimelineElement = {
32
+ ...talk,
33
+ id: "talk-a",
34
+ domId: "talk-a",
35
+ tag: "audio",
36
+ track: 1,
37
+ };
38
+
39
+ function renderMenu(element: TimelineElement) {
40
+ usePlayerStore.getState().setElements([talk, talkAudio]);
41
+ document.body.appendChild(iframe);
42
+ const node = iframe.contentDocument?.createElement("video");
43
+ if (node) {
44
+ node.id = "talk";
45
+ node.setAttribute("data-has-audio", "true");
46
+ iframe.contentDocument?.body.appendChild(node);
47
+ }
48
+ const host = document.createElement("div");
49
+ document.body.appendChild(host);
50
+ const noop = vi.fn();
51
+ act(() =>
52
+ harness.mount(host).render(
53
+ <TimelineEditProvider
54
+ value={{
55
+ onSetElementAttributeQuiet: vi.fn(async () => undefined),
56
+ onFreezeFrame: noop,
57
+ onLinkEdit: noop,
58
+ onDeleteElementOnly: noop,
59
+ }}
60
+ >
61
+ <ClipContextMenu
62
+ x={10}
63
+ y={10}
64
+ element={element}
65
+ currentTime={2}
66
+ onClose={noop}
67
+ onSplit={noop}
68
+ onDelete={noop}
69
+ />
70
+ </TimelineEditProvider>,
71
+ ),
72
+ );
73
+ }
74
+
75
+ function menuLabels(): string[] {
76
+ return Array.from(document.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]')).map(
77
+ (item) => item.querySelector("span")?.textContent ?? "",
78
+ );
79
+ }
80
+
81
+ describe("ClipContextMenu order", () => {
82
+ it("groups time, sound, picture, then delete for a linked video with sound", () => {
83
+ renderMenu(talk);
84
+ const expected = [
85
+ "Split at 2.00s",
86
+ "Freeze frame",
87
+ "Audio Gain…",
88
+ "Voice",
89
+ "Detach audio",
90
+ "Unlink from audio",
91
+ "Look",
92
+ "Crop",
93
+ "Delete",
94
+ "Delete this clip only",
95
+ ];
96
+ expect(menuLabels().filter((label) => expected.includes(label))).toEqual(expected);
97
+ const groups = Array.from(document.querySelectorAll('[role="group"]')).map((group) =>
98
+ group.getAttribute("aria-label"),
99
+ );
100
+ expect(groups).toEqual(["Time", "Sound", "Picture", "Clipboard", "Delete"]);
101
+ });
102
+ });
@@ -60,9 +60,14 @@ describe("ClipContextMenu host items", () => {
60
60
  unmount();
61
61
  });
62
62
 
63
- it("counts host rows and their divider when keeping the menu inside the window", () => {
63
+ it("keeps the menu, host rows included, inside the window by its measured height", () => {
64
64
  const innerHeight = Object.getOwnPropertyDescriptor(window, "innerHeight");
65
65
  Object.defineProperty(window, "innerHeight", { configurable: true, value: 600 });
66
+ const offsetHeight = vi
67
+ .spyOn(HTMLElement.prototype, "offsetHeight", "get")
68
+ .mockImplementation(function (this: HTMLElement) {
69
+ return this.getAttribute("role") === "menu" ? 150 : 0;
70
+ });
66
71
  try {
67
72
  const host = document.createElement("div");
68
73
  document.body.appendChild(host);
@@ -86,10 +91,11 @@ describe("ClipContextMenu host items", () => {
86
91
  />,
87
92
  ),
88
93
  );
89
- // Four 30 px rows, two 9 px dividers and 8 px of padding end 96 px below the window.
90
- expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("446px");
94
+ // 150 px measured from y 550 ends 100 px below the window, kept 8 px off its edge.
95
+ expect(document.body.querySelector<HTMLElement>("[role=menu]")!.style.top).toBe("442px");
91
96
  act(() => root.unmount());
92
97
  } finally {
98
+ offsetHeight.mockRestore();
93
99
  if (innerHeight) Object.defineProperty(window, "innerHeight", innerHeight);
94
100
  }
95
101
  });
@@ -106,7 +112,7 @@ describe("ClipContextMenu host items", () => {
106
112
  it("shows only Studio's items when the host adds none", () => {
107
113
  const { items, unmount } = renderMenu([]);
108
114
  expect(items().map((item) => item.textContent)).toEqual(["Copy⌘C", "Delete⌫"]);
109
- expect(document.body.querySelectorAll(".border-t")).toHaveLength(1);
115
+ expect(document.body.querySelector("[role=group][aria-label=Host]")).toBeNull();
110
116
  unmount();
111
117
  });
112
118
  });