@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
@@ -1,4 +1,4 @@
1
- import { useRef, useState, type KeyboardEvent } from "react";
1
+ import { useEffect, useRef, useState, type KeyboardEvent } from "react";
2
2
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
3
3
  import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
4
4
  import type { TimelineElement } from "../store/playerStore";
@@ -30,6 +30,47 @@ const ROW_CLASS =
30
30
  const DISABLED_ROW_CLASS =
31
31
  "w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none text-neutral-600 cursor-not-allowed";
32
32
  const SUBMENU_WIDTH = 170;
33
+ const HOVER_PREVIEW_DELAY_MS = 80;
34
+
35
+ interface HoverPreview {
36
+ apply: (id: string | null) => void;
37
+ clear: () => void;
38
+ }
39
+
40
+ function useHoverPreview(preview: HoverPreview | undefined) {
41
+ const previewRef = useRef(preview);
42
+ previewRef.current = preview;
43
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
44
+ const activeRef = useRef(false);
45
+
46
+ const cancelTimer = () => {
47
+ if (timerRef.current === null) return;
48
+ clearTimeout(timerRef.current);
49
+ timerRef.current = null;
50
+ };
51
+ const end = () => {
52
+ cancelTimer();
53
+ if (!activeRef.current) return;
54
+ activeRef.current = false;
55
+ previewRef.current?.clear();
56
+ };
57
+ const settle = () => {
58
+ cancelTimer();
59
+ activeRef.current = false;
60
+ };
61
+ const start = (id: string | null) => {
62
+ if (!previewRef.current) return;
63
+ cancelTimer();
64
+ timerRef.current = setTimeout(() => {
65
+ timerRef.current = null;
66
+ activeRef.current = true;
67
+ previewRef.current?.apply(id);
68
+ }, HOVER_PREVIEW_DELAY_MS);
69
+ };
70
+
71
+ useEffect(() => end, []);
72
+ return { start, end, settle };
73
+ }
33
74
 
34
75
  interface ChoiceSection {
35
76
  heading?: string;
@@ -48,22 +89,36 @@ function ChoiceSubmenu({
48
89
  sections,
49
90
  activeId,
50
91
  onPick,
92
+ preview,
51
93
  }: {
52
94
  label: string;
53
95
  sections: readonly ChoiceSection[];
54
96
  activeId: string | null;
55
97
  onPick: (id: string | null) => void;
98
+ preview?: HoverPreview;
56
99
  }) {
57
100
  const [open, setOpen] = useState(false);
58
101
  const [flipLeft, setFlipLeft] = useState(false);
59
102
  const rowRef = useRef<HTMLButtonElement | null>(null);
60
103
  const submenuRef = useRef<HTMLDivElement | null>(null);
104
+ const hover = useHoverPreview(preview);
105
+ const openingFocusRef = useRef(false);
106
+
107
+ const hideSubmenu = () => {
108
+ hover.end();
109
+ setOpen(false);
110
+ };
61
111
 
62
112
  const show = (focusFirst: boolean) => {
63
113
  const rect = rowRef.current?.getBoundingClientRect();
64
114
  setFlipLeft(rect ? rect.right + SUBMENU_WIDTH > window.innerWidth : false);
65
115
  setOpen(true);
66
- if (focusFirst) requestAnimationFrame(() => focusSibling(submenuRef.current, 1));
116
+ if (!focusFirst) return;
117
+ openingFocusRef.current = true;
118
+ requestAnimationFrame(() => {
119
+ focusSibling(submenuRef.current, 1);
120
+ openingFocusRef.current = false;
121
+ });
67
122
  };
68
123
 
69
124
  const onSubmenuKeyDown = (event: KeyboardEvent) => {
@@ -74,7 +129,7 @@ function ChoiceSubmenu({
74
129
  } else if (event.key === "ArrowLeft") {
75
130
  event.preventDefault();
76
131
  event.stopPropagation();
77
- setOpen(false);
132
+ hideSubmenu();
78
133
  rowRef.current?.focus();
79
134
  }
80
135
  };
@@ -86,7 +141,15 @@ function ChoiceSubmenu({
86
141
  role="menuitemradio"
87
142
  aria-checked={activeId === id}
88
143
  className={ROW_CLASS}
89
- onClick={() => onPick(id)}
144
+ onClick={() => {
145
+ hover.settle();
146
+ onPick(id);
147
+ }}
148
+ onMouseEnter={() => hover.start(id)}
149
+ onFocus={() => {
150
+ if (openingFocusRef.current) openingFocusRef.current = false;
151
+ else hover.start(id);
152
+ }}
90
153
  >
91
154
  <span className="flex items-center gap-1.5">
92
155
  <span aria-hidden="true" className="w-3 text-center">
@@ -98,7 +161,7 @@ function ChoiceSubmenu({
98
161
  );
99
162
 
100
163
  return (
101
- <div className="relative" onMouseEnter={() => show(false)} onMouseLeave={() => setOpen(false)}>
164
+ <div className="relative" onMouseEnter={() => show(false)} onMouseLeave={hideSubmenu}>
102
165
  <button
103
166
  ref={rowRef}
104
167
  type="button"
@@ -175,14 +238,15 @@ function FreezeFrameItem({ element, currentTime, onClose }: Omit<ClipMenuToolIte
175
238
  );
176
239
  }
177
240
 
178
- /** Freeze (time), Voice (sound), Look and Crop (picture); sound and picture end in their own divider. */
241
+ /** Freeze (time), Voice (sound), Look and Crop (picture). */
179
242
  export function ClipMenuToolItems(props: ClipMenuToolItemsProps) {
180
243
  if (props.group === "time") return <FreezeFrameItem {...props} />;
181
244
  return <ClipMenuAttributeItems {...props} />;
182
245
  }
183
246
 
184
247
  function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsProps) {
185
- const { onSetElementAttributeQuiet } = useTimelineEditContextOptional();
248
+ const { onSetElementAttributeQuiet, onSetElementAttributeLive, onRevertElementAttributeLive } =
249
+ useTimelineEditContextOptional();
186
250
  const state = useClipToolState(element);
187
251
  const openCropBar = useCropPresetBarStore((s) => s.open);
188
252
  if (!onSetElementAttributeQuiet) return null;
@@ -204,12 +268,19 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
204
268
  write(HF_AUDIO_FX_ATTR, chainWithVoicePreset(state.fxChain, id), "Voice preset")
205
269
  }
206
270
  />
207
- <div className="my-1 border-t border-neutral-700/60" />
208
271
  </>
209
272
  );
210
273
  }
211
274
 
212
275
  if (!isPictureClip(state.tag)) return null;
276
+ const lookPreview =
277
+ onSetElementAttributeLive && onRevertElementAttributeLive
278
+ ? {
279
+ apply: (id: string | null) =>
280
+ onSetElementAttributeLive(element, HF_COLOR_GRADING_ATTR, lookAttrValue(id)),
281
+ clear: () => onRevertElementAttributeLive(element, HF_COLOR_GRADING_ATTR),
282
+ }
283
+ : undefined;
213
284
  return (
214
285
  <>
215
286
  <ChoiceSubmenu
@@ -217,6 +288,7 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
217
288
  sections={LOOK_SECTIONS}
218
289
  activeId={activeLook(state.colorGrading)}
219
290
  onPick={(id) => write(HF_COLOR_GRADING_ATTR, lookAttrValue(id), "Look")}
291
+ preview={lookPreview}
220
292
  />
221
293
  <button
222
294
  type="button"
@@ -229,7 +301,6 @@ function ClipMenuAttributeItems({ group, element, onClose }: ClipMenuToolItemsPr
229
301
  >
230
302
  <span>Crop</span>
231
303
  </button>
232
- <div className="my-1 border-t border-neutral-700/60" />
233
304
  </>
234
305
  );
235
306
  }
@@ -0,0 +1,46 @@
1
+ import { buildProjectApiPath } from "../../utils/projectRouting";
2
+ import { resolvePreviewRelative } from "../../utils/previewRelativePath";
3
+ import type { TimelineElement } from "../store/timelineElement";
4
+ import { clipSourcePeak, isPeakMap, type ClipSourceWindow, type PeakMap } from "./clipPeakRuns";
5
+ import { encodePreviewPath, resolveMediaPreviewUrl } from "./thumbnailUtils";
6
+
7
+ const peakMapRequests = new Map<string, Promise<PeakMap | null>>();
8
+
9
+ /** One fetch per peak-map URL per session; a failed or malformed answer reads as null. */
10
+ export function loadPeakMap(url: string): Promise<PeakMap | null> {
11
+ const pending = peakMapRequests.get(url);
12
+ if (pending) return pending;
13
+ const request = fetch(url)
14
+ .then((res) => (res.ok ? res.json() : null))
15
+ .then((body: unknown) => (isPeakMap(body) ? body : null))
16
+ .catch(() => null);
17
+ peakMapRequests.set(url, request);
18
+ return request;
19
+ }
20
+
21
+ /** The studio-server `/peaks/*` URL for a clip's project media, or undefined for outside media. */
22
+ export function clipPeaksUrl(src: string | undefined, projectId: string): string | undefined {
23
+ const origin = window.location.origin;
24
+ const relative = resolvePreviewRelative(
25
+ resolveMediaPreviewUrl(src ?? "", projectId, origin),
26
+ projectId,
27
+ origin,
28
+ );
29
+ return relative
30
+ ? buildProjectApiPath(projectId, `/peaks/${encodePreviewPath(relative)}`)
31
+ : undefined;
32
+ }
33
+
34
+ export function clipSourceWindow(el: TimelineElement): ClipSourceWindow {
35
+ return { mediaStart: el.playbackStart ?? 0, sourceSpan: el.duration * (el.playbackRate ?? 1) };
36
+ }
37
+
38
+ /** The clip's source peak, linear, over the part it plays; null when it cannot be measured. */
39
+ export async function measureClipSourcePeak(
40
+ el: TimelineElement,
41
+ projectId: string,
42
+ ): Promise<number | null> {
43
+ const url = clipPeaksUrl(el.src, projectId);
44
+ const map = url ? await loadPeakMap(url) : null;
45
+ return map ? clipSourcePeak(map, clipSourceWindow(el)) : null;
46
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { clipPeakRuns } from "./clipPeakRuns";
2
+ import { clipPeakRuns, clipSourcePeak } from "./clipPeakRuns";
3
3
 
4
4
  const map = (bins: number[]) => ({ binSeconds: 1, bins });
5
5
 
@@ -36,3 +36,16 @@ describe("clipPeakRuns", () => {
36
36
  expect(clipPeakRuns(map([1]), { mediaStart: 0, sourceSpan: 0 }, 1).runs).toEqual([]);
37
37
  });
38
38
  });
39
+
40
+ describe("clipSourcePeak", () => {
41
+ it("is the loudest bin inside the played source window only", () => {
42
+ const bins = map([0.9, 0.2, 0.4, 0.3, 1]);
43
+ expect(clipSourcePeak(bins, { mediaStart: 1, sourceSpan: 3 })).toBe(0.4);
44
+ expect(clipSourcePeak(bins, { mediaStart: 0, sourceSpan: 5 })).toBe(1);
45
+ });
46
+
47
+ it("is null for an empty window or one past the file's end", () => {
48
+ expect(clipSourcePeak(map([0.5]), { mediaStart: 0, sourceSpan: 0 })).toBeNull();
49
+ expect(clipSourcePeak(map([0.5]), { mediaStart: 4, sourceSpan: 2 })).toBeNull();
50
+ });
51
+ });
@@ -48,6 +48,17 @@ export function clipPeakRuns(
48
48
  return { runs, peakDbfs: runs.length > 0 ? 20 * Math.log10(loudest) : null };
49
49
  }
50
50
 
51
+ export function clipSourcePeak(map: PeakMap, window: ClipSourceWindow): number | null {
52
+ if (!(window.sourceSpan > 0) || !(map.binSeconds > 0)) return null;
53
+ const first = Math.max(0, Math.floor(window.mediaStart / map.binSeconds));
54
+ const last = Math.min(
55
+ map.bins.length,
56
+ Math.ceil((window.mediaStart + window.sourceSpan) / map.binSeconds),
57
+ );
58
+ if (last <= first) return null;
59
+ return map.bins.slice(first, last).reduce((loudest, bin) => Math.max(loudest, bin), 0);
60
+ }
61
+
51
62
  export function isPeakMap(value: unknown): value is PeakMap {
52
63
  if (typeof value !== "object" || value === null) return false;
53
64
  const bins: unknown = Reflect.get(value, "bins");
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { clipSyncState, syncPartnerOf } from "./clipSync";
3
+
4
+ const clip = (id: string, tag: string, start: number, extra: Record<string, unknown> = {}) => ({
5
+ id,
6
+ tag,
7
+ start,
8
+ duration: 4,
9
+ track: 0,
10
+ syncOrigin: "lk-1",
11
+ ...extra,
12
+ });
13
+
14
+ describe("syncPartnerOf", () => {
15
+ it("pairs a video with the audio sharing its sync origin, not another video", () => {
16
+ const v = clip("v", "video", 0);
17
+ const a = clip("a", "audio", 0);
18
+ const other = clip("v2", "video", 0);
19
+ expect(syncPartnerOf(a, [v, a, other])?.id).toBe("v");
20
+ expect(syncPartnerOf(v, [v, a, other])?.id).toBe("a");
21
+ });
22
+
23
+ it("ignores clips with no or another origin", () => {
24
+ const v = clip("v", "video", 0);
25
+ expect(syncPartnerOf(v, [v, clip("a", "audio", 0, { syncOrigin: "lk-2" })])).toBeNull();
26
+ expect(syncPartnerOf({ ...v, syncOrigin: undefined }, [v])).toBeNull();
27
+ });
28
+
29
+ it.each([{ sourceFile: "child.html" }, { compositionScope: "child" }])(
30
+ "never pairs across compositions reusing an origin (%o)",
31
+ (scope) => {
32
+ const v = clip("v", "video", 0);
33
+ expect(syncPartnerOf(v, [v, clip("a", "audio", 0, scope)])).toBeNull();
34
+ },
35
+ );
36
+
37
+ it("after a split, picks the partner sharing the most timeline", () => {
38
+ const a = clip("a", "audio", 4.2);
39
+ const left = clip("v", "video", 0);
40
+ const right = clip("v-split", "video", 4);
41
+ expect(syncPartnerOf(a, [left, right, a])?.id).toBe("v-split");
42
+ });
43
+ });
44
+
45
+ describe("clipSyncState", () => {
46
+ it("is null in sync and signed per side when drifted", () => {
47
+ const v = clip("v", "video", 1);
48
+ expect(clipSyncState(v, [v, clip("a", "audio", 1)], 30)).toBeNull();
49
+ const late = clip("a", "audio", 1 + 10 / 30);
50
+ expect(clipSyncState(late, [v, late], 30)?.frames).toBe(10);
51
+ expect(clipSyncState(v, [v, late], 30)?.frames).toBe(-10);
52
+ });
53
+
54
+ it("offers move and slip targets that each restore sync", () => {
55
+ const v = clip("v", "video", 1, { playbackStart: 0 });
56
+ const a = clip("a", "audio", 1.5, { playbackStart: 0 });
57
+ const state = clipSyncState(a, [v, a], 30);
58
+ expect(state?.moveStart).toBeCloseTo(1);
59
+ expect(state?.slipMediaStart).toBeCloseTo(0.5);
60
+ });
61
+
62
+ it("has no badge when the halves play at different rates", () => {
63
+ const v = clip("v", "video", 1, { playbackRate: 2 });
64
+ const a = clip("a", "audio", 2);
65
+ expect(clipSyncState(a, [v, a], 30)).toBeNull();
66
+ });
67
+ });
@@ -0,0 +1,80 @@
1
+ import {
2
+ moveIntoSyncStart,
3
+ slipIntoSyncMediaStart,
4
+ syncOffsetFrames,
5
+ type SyncTiming,
6
+ } from "@hyperframes/core/media-link";
7
+ import { sameCompositionScope, type TimelineElement } from "../store/timelineElement";
8
+
9
+ type SyncElement = Pick<
10
+ TimelineElement,
11
+ "id" | "key" | "tag" | "start" | "duration" | "playbackStart" | "playbackRate" | "syncOrigin"
12
+ > &
13
+ Pick<TimelineElement, "sourceFile" | "compositionScope">;
14
+
15
+ const keyOf = (el: Pick<TimelineElement, "id" | "key">) => el.key ?? el.id;
16
+ const kindOf = (el: Pick<TimelineElement, "tag">) => el.tag.trim().toLowerCase();
17
+
18
+ function syncTimingOf(el: SyncElement): SyncTiming {
19
+ return { start: el.start, mediaStart: el.playbackStart ?? 0, playbackRate: el.playbackRate ?? 1 };
20
+ }
21
+
22
+ function overlap(a: SyncElement, b: SyncElement): number {
23
+ return Math.min(a.start + a.duration, b.start + b.duration) - Math.max(a.start, b.start);
24
+ }
25
+
26
+ /**
27
+ * The other half of `el`'s source pair: a video for an audio and the reverse,
28
+ * sharing its sync origin. After splits several may qualify; the one sharing the
29
+ * most timeline wins, then the nearest start.
30
+ */
31
+ export function syncPartnerOf<T extends SyncElement>(el: T, elements: readonly T[]): T | null {
32
+ const kind = kindOf(el);
33
+ if (!el.syncOrigin || (kind !== "video" && kind !== "audio")) return null;
34
+ const partnerKind = kind === "video" ? "audio" : "video";
35
+ const candidates = elements.filter(
36
+ (other) =>
37
+ keyOf(other) !== keyOf(el) &&
38
+ other.syncOrigin === el.syncOrigin &&
39
+ sameCompositionScope(other, el) &&
40
+ kindOf(other) === partnerKind,
41
+ );
42
+ const rank = (other: T) => [overlap(el, other), -Math.abs(other.start - el.start)] as const;
43
+ let best: T | null = null;
44
+ for (const candidate of candidates) {
45
+ if (!best) best = candidate;
46
+ else {
47
+ const [o1, d1] = rank(candidate);
48
+ const [o2, d2] = rank(best);
49
+ if (o1 > o2 || (o1 === o2 && d1 > d2)) best = candidate;
50
+ }
51
+ }
52
+ return best;
53
+ }
54
+
55
+ export interface ClipSyncState<T> {
56
+ partner: T;
57
+ frames: number;
58
+ moveStart: number | null;
59
+ slipMediaStart: number | null;
60
+ }
61
+
62
+ /** `el`'s drift from its source partner, or null when in sync, unpaired or at a different rate. */
63
+ export function clipSyncState<T extends SyncElement>(
64
+ el: T,
65
+ elements: readonly T[],
66
+ fps: number,
67
+ ): ClipSyncState<T> | null {
68
+ const partner = syncPartnerOf(el, elements);
69
+ if (!partner) return null;
70
+ const self = syncTimingOf(el);
71
+ const other = syncTimingOf(partner);
72
+ const frames = syncOffsetFrames(self, other, fps);
73
+ if (frames === null || frames === 0) return null;
74
+ return {
75
+ partner,
76
+ frames,
77
+ moveStart: moveIntoSyncStart(self, other),
78
+ slipMediaStart: slipIntoSyncMediaStart(self, other),
79
+ };
80
+ }
@@ -5,11 +5,12 @@ import {
5
5
  activeLook,
6
6
  activeVoicePreset,
7
7
  chainWithVoicePreset,
8
+ clipSpeedSuffix,
8
9
  hasCrop,
9
10
  isDucked,
11
+ clipVolumeBadge,
10
12
  lookAttrValue,
11
- readClipBadges,
12
- splitVisibleBadges,
13
+ readClipEffects,
13
14
  type ClipToolState,
14
15
  } from "./clipToolAttrs";
15
16
 
@@ -98,7 +99,6 @@ const baseState: ClipToolState = {
98
99
  colorGrading: null,
99
100
  clipPath: null,
100
101
  carve: null,
101
- link: null,
102
102
  };
103
103
 
104
104
  const rampAutomation = JSON.stringify({
@@ -114,48 +114,64 @@ const rampAutomation = JSON.stringify({
114
114
  ],
115
115
  });
116
116
 
117
- describe("readClipBadges", () => {
118
- it("shows nothing for a plain clip at 100%", () => {
119
- expect(readClipBadges(baseState)).toEqual([]);
120
- });
121
-
122
- it("lists every applied tool in the wireframe's order", () => {
123
- const labels = readClipBadges({
124
- ...baseState,
125
- volume: 1.8,
126
- fxChain: chainWithVoicePreset(null, "voice-clean"),
127
- automation: rampAutomation,
128
- colorGrading: lookAttrValue("warm-daylight"),
129
- clipPath: "inset(0px 20px)",
130
- carve: "{}",
131
- link: "talk",
132
- }).map((b) => b.label);
133
- expect(labels).toEqual([
134
- "Linked",
135
- "Warm daylight",
136
- "Voice: Clean",
137
- "Ramp",
138
- "Crop",
139
- "Ducked",
140
- "180%",
141
- ]);
117
+ describe("readClipEffects", () => {
118
+ it("lists nothing for a plain clip, so the fx badge stays grey", () => {
119
+ expect(readClipEffects(baseState)).toEqual([]);
120
+ expect(readClipEffects({ ...baseState, automation: rampAutomation, volume: 1.8 })).toEqual([]);
142
121
  });
143
122
 
144
- it("badges a muted audio clip but not muted b-roll", () => {
145
- expect(readClipBadges({ ...baseState, tag: "audio", muted: true })[0]?.label).toBe("Muted");
146
- expect(readClipBadges({ ...baseState, hasSound: false, muted: true })).toEqual([]);
123
+ it("lists every applied effect in the tooltip's order", () => {
124
+ expect(
125
+ readClipEffects({
126
+ ...baseState,
127
+ fxChain: chainWithVoicePreset(null, "voice-clean"),
128
+ colorGrading: lookAttrValue("warm-daylight"),
129
+ clipPath: "inset(0px 20px)",
130
+ carve: "{}",
131
+ }),
132
+ ).toEqual(["Look: Warm daylight", "Voice: Clean", "Crop", "Ducked"]);
147
133
  });
148
134
 
149
- it("caps at two visible badges plus the link badge", () => {
150
- const badges = readClipBadges({
151
- ...baseState,
152
- link: "talk",
153
- colorGrading: lookAttrValue("mono-clean"),
154
- clipPath: "inset(5px)",
155
- volume: 0.6,
135
+ it("names a hand-added effect and skips disabled and carve-generated nodes", () => {
136
+ const chain = serializeAudioFxChain({
137
+ version: 1,
138
+ nodes: [
139
+ { id: "a", type: "highpass", enabled: true, params: { frequency: 80 } },
140
+ { id: "b", type: "gain", enabled: false, params: {} },
141
+ { id: "c", type: "gain", enabled: true, fromCarve: true, params: {} },
142
+ ],
156
143
  });
157
- const { visible, hidden } = splitVisibleBadges(badges);
158
- expect(visible.map((b) => b.label)).toEqual(["Linked", "Mono", "Crop"]);
159
- expect(hidden.map((b) => b.label)).toEqual(["60%"]);
144
+ const effects = readClipEffects({ ...baseState, fxChain: chain });
145
+ expect(effects).toHaveLength(1);
146
+ expect(effects[0]).not.toBe("highpass");
147
+ });
148
+
149
+ it("reads a hand-made grading without a menu preset as a Look", () => {
150
+ expect(readClipEffects({ ...baseState, colorGrading: '{"adjust":{"contrast":0.2}}' })).toEqual([
151
+ "Look",
152
+ ]);
153
+ });
154
+ });
155
+
156
+ describe("clipVolumeBadge", () => {
157
+ it("shows volume off 100% and Muted only on a clip with sound", () => {
158
+ expect(clipVolumeBadge({ ...baseState, volume: 1.8 })).toBe("180%");
159
+ expect(clipVolumeBadge(baseState)).toBeNull();
160
+ expect(clipVolumeBadge({ ...baseState, tag: "audio", muted: true })).toBe("Muted");
161
+ expect(clipVolumeBadge({ ...baseState, hasSound: false, muted: true })).toBeNull();
162
+ });
163
+ });
164
+
165
+ describe("clipSpeedSuffix", () => {
166
+ it("shows a constant speed as a percentage and hides 100%", () => {
167
+ expect(clipSpeedSuffix(1.5, null)).toBe(" [150%]");
168
+ expect(clipSpeedSuffix(0.35, null)).toBe(" [35%]");
169
+ expect(clipSpeedSuffix(1, null)).toBe("");
170
+ expect(clipSpeedSuffix(undefined, null)).toBe("");
171
+ });
172
+
173
+ it("shows a rate lane as a ramp, whatever the base rate", () => {
174
+ expect(clipSpeedSuffix(2, rampAutomation)).toBe(" [ramp]");
175
+ expect(clipSpeedSuffix(1, rampAutomation)).toBe(" [ramp]");
160
176
  });
161
177
  });
@@ -4,11 +4,13 @@
4
4
  */
5
5
 
6
6
  import {
7
+ getAudioFxDef,
7
8
  parseAudioFxChain,
8
9
  serializeAudioFxChain,
9
10
  type HfAudioFxChain,
11
+ type HfAudioFxNode,
10
12
  } from "@hyperframes/core/audio-fx";
11
- import { activeAudioFxPresetIds } from "@hyperframes/core/audio-fx-presets";
13
+ import { activeAudioFxPresetIds, getAudioFxPreset } from "@hyperframes/core/audio-fx-presets";
12
14
  import { normalizeHfColorGrading } from "@hyperframes/core/color-grading";
13
15
  import { parseRateLane } from "@hyperframes/core/speed-ramp";
14
16
  import { applyPresetToChain } from "../../components/editor/useApplyAudioFxPreset";
@@ -117,6 +119,17 @@ function hasRateRamp(rawAutomation: string | null | undefined): boolean {
117
119
  return parseRateLane(rawAutomation) !== null;
118
120
  }
119
121
 
122
+ /** Premiere's speed cue on the clip name: ` [ramp]` under a rate lane, else ` [150%]` off 100%. */
123
+ export function clipSpeedSuffix(
124
+ playbackRate: number | null | undefined,
125
+ rawAutomation: string | null | undefined,
126
+ ): string {
127
+ if (hasRateRamp(rawAutomation)) return " [ramp]";
128
+ const rate = playbackRate ?? 1;
129
+ if (!Number.isFinite(rate) || Math.abs(rate - 1) < 0.005) return "";
130
+ return ` [${Math.round(rate * 100)}%]`;
131
+ }
132
+
120
133
  const ZERO_INSET = /^inset\(\s*0(px|%)?\s*\)$/i;
121
134
 
122
135
  export function hasCrop(clipPath: string | null | undefined): boolean {
@@ -124,13 +137,6 @@ export function hasCrop(clipPath: string | null | undefined): boolean {
124
137
  return value !== "" && value !== "none" && !ZERO_INSET.test(value);
125
138
  }
126
139
 
127
- export type ClipBadgeKind = "link" | "look" | "voice" | "ramp" | "crop" | "ducked" | "volume";
128
-
129
- export interface ClipBadge {
130
- kind: ClipBadgeKind;
131
- label: string;
132
- }
133
-
134
140
  /** What a clip's attributes say is applied; the element's own attributes, as read off its node. */
135
141
  export interface ClipToolState {
136
142
  tag: string;
@@ -142,46 +148,43 @@ export interface ClipToolState {
142
148
  colorGrading: string | null;
143
149
  clipPath: string | null;
144
150
  carve: string | null;
145
- link: string | null;
146
151
  }
147
152
 
148
- function volumeBadge(state: ClipToolState): ClipBadge | null {
149
- const isAudio = state.tag === "audio";
150
- if (!isAudio && !state.hasSound) return null;
151
- if (state.muted) return { kind: "volume", label: "Muted" };
153
+ export function clipVolumeBadge(state: ClipToolState): string | null {
154
+ if (state.tag !== "audio" && !state.hasSound) return null;
155
+ if (state.muted) return "Muted";
152
156
  const volume = state.volume ?? 1;
153
157
  if (Math.abs(volume - 1) < 0.005) return null;
154
- return { kind: "volume", label: `${Math.round(volume * 100)}%` };
155
- }
156
-
157
- /** Badges in the wireframe's order: look, voice, ramp, crop, ducked, volume. The link badge leads. */
158
- export function readClipBadges(state: ClipToolState): ClipBadge[] {
159
- const look = lookLabel(activeLook(state.colorGrading));
160
- const voice = voicePresetLabel(activeVoicePreset(state.fxChain));
161
- const volume = volumeBadge(state);
162
- const badges: Array<ClipBadge | null> = [
163
- state.link ? { kind: "link", label: "Linked" } : null,
164
- look ? { kind: "look", label: look } : null,
165
- voice ? { kind: "voice", label: `Voice: ${voice}` } : null,
166
- hasRateRamp(state.automation) ? { kind: "ramp", label: "Ramp" } : null,
167
- hasCrop(state.clipPath) ? { kind: "crop", label: "Crop" } : null,
168
- isDucked(state.carve) ? { kind: "ducked", label: "Ducked" } : null,
169
- volume,
158
+ return `${Math.round(volume * 100)}%`;
159
+ }
160
+
161
+ function lookEffect(rawGrading: string | null): string | null {
162
+ const grading = normalizeHfColorGrading(parseJson(rawGrading));
163
+ if (!grading) return null;
164
+ const label = lookLabel(grading.preset ?? null);
165
+ return label ? `Look: ${label}` : "Look";
166
+ }
167
+
168
+ function presetEffect(presetId: string): string {
169
+ const voice = voicePresetLabel(presetId);
170
+ return voice ? `Voice: ${voice}` : (getAudioFxPreset(presetId)?.label ?? presetId);
171
+ }
172
+
173
+ function chainNodeEffect(node: HfAudioFxNode): string | null {
174
+ if (node.enabled === false || node.fromCarve) return null;
175
+ if (node.fromPreset) return presetEffect(node.fromPreset);
176
+ if (node.fromEq) return "EQ";
177
+ if (node.fromLeveller) return "Leveller";
178
+ return node.label ?? getAudioFxDef(node.type)?.label ?? node.type;
179
+ }
180
+
181
+ /** Every effect on the clip, in the fx badge tooltip's order; empty means a grey badge. */
182
+ export function readClipEffects(state: ClipToolState): string[] {
183
+ const effects: Array<string | null> = [
184
+ lookEffect(state.colorGrading),
185
+ ...parseChainOrEmpty(state.fxChain).nodes.map(chainNodeEffect),
186
+ hasCrop(state.clipPath) ? "Crop" : null,
187
+ isDucked(state.carve) ? "Ducked" : null,
170
188
  ];
171
- return badges.filter((badge): badge is ClipBadge => badge !== null);
172
- }
173
-
174
- const MAX_VISIBLE_BADGES = 2;
175
-
176
- /** The link badge is always shown; the rest cap at two, then `+N`. */
177
- export function splitVisibleBadges(badges: readonly ClipBadge[]): {
178
- visible: ClipBadge[];
179
- hidden: ClipBadge[];
180
- } {
181
- const link = badges.filter((badge) => badge.kind === "link");
182
- const rest = badges.filter((badge) => badge.kind !== "link");
183
- return {
184
- visible: [...link, ...rest.slice(0, MAX_VISIBLE_BADGES)],
185
- hidden: rest.slice(MAX_VISIBLE_BADGES),
186
- };
189
+ return [...new Set(effects.filter((effect): effect is string => effect !== null))];
187
190
  }