@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 { memo } from "react";
1
+ import { memo, useLayoutEffect, useState, type RefObject } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
4
4
  import { canSplitElement } from "../../utils/timelineElementSplit";
@@ -7,6 +7,25 @@ import { useMenuKeyboardNav } from "./menuKeyboardNav";
7
7
  import type { TimelineClipMenuItem } from "./TimelineTypes";
8
8
  import { ClipMenuToolItems } from "./clipMenuToolItems";
9
9
  import { ClipMenuAudioItems } from "./clipMenuAudioItems";
10
+ import { ClipMenuLinkItems } from "./clipMenuLinkItems";
11
+
12
+ const MENU_MARGIN = 8;
13
+ // Empty groups collapse; every non-empty group before the always-present Delete group ends in a divider.
14
+ const GROUP_CLASS = "empty:hidden mb-1 pb-1 border-b border-neutral-700/60";
15
+
16
+ function useMeasuredHeight(ref: RefObject<HTMLDivElement | null>, anchorKey: string): number {
17
+ const [height, setHeight] = useState(0);
18
+ useLayoutEffect(() => {
19
+ const node = ref.current;
20
+ if (!node) return;
21
+ setHeight(node.offsetHeight);
22
+ if (typeof ResizeObserver === "undefined") return;
23
+ const observer = new ResizeObserver(() => setHeight(node.offsetHeight));
24
+ observer.observe(node);
25
+ return () => observer.disconnect();
26
+ }, [ref, anchorKey]);
27
+ return height;
28
+ }
10
29
 
11
30
  interface ClipContextMenuProps {
12
31
  x: number;
@@ -62,7 +81,6 @@ function HostItems({
62
81
  )}
63
82
  </button>
64
83
  ))}
65
- <div className="my-1 border-t border-neutral-700/60" />
66
84
  </>
67
85
  );
68
86
  }
@@ -105,16 +123,11 @@ export const ClipContextMenu = memo(function ClipContextMenu({
105
123
  ? `Split at ${currentTime.toFixed(2)}s`
106
124
  : "Split (move playhead inside clip)";
107
125
 
108
- const clipboardItemCount = [onCopy, onPaste, onDuplicate].filter(Boolean).length;
109
- const hostRows = hostItems.length;
110
- const rowCount = hostRows + (splitLabel ? 1 : 0) + clipboardItemCount + 1; // + Delete, always present
111
- const dividerCount =
112
- (hostRows > 0 ? 1 : 0) + (splitLabel ? 1 : 0) + (clipboardItemCount > 0 ? 1 : 0);
113
126
  const menuWidth = 200;
114
- const menuHeight = rowCount * 30 + dividerCount * 9 + 8;
115
- const overflowY = y + menuHeight - window.innerHeight;
116
127
  const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
117
- const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
128
+ const menuHeight = useMeasuredHeight(menuRef, `${x},${y},${element.key ?? element.id}`);
129
+ const overflowY = y + menuHeight - window.innerHeight;
130
+ const adjustedY = overflowY > 0 ? Math.max(MENU_MARGIN, y - overflowY - MENU_MARGIN) : y;
118
131
 
119
132
  return createPortal(
120
133
  <div
@@ -124,9 +137,13 @@ export const ClipContextMenu = memo(function ClipContextMenu({
124
137
  className="fixed z-200 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
125
138
  style={{ left: adjustedX, top: adjustedY }}
126
139
  >
127
- {hostRows > 0 && <HostItems items={hostItems} onClose={onClose} />}
128
- {splitLabel && (
129
- <>
140
+ {hostItems.length > 0 && (
141
+ <div role="group" aria-label="Host" className={GROUP_CLASS}>
142
+ <HostItems items={hostItems} onClose={onClose} />
143
+ </div>
144
+ )}
145
+ <div role="group" aria-label="Time" className={GROUP_CLASS}>
146
+ {splitLabel && (
130
147
  <button
131
148
  type="button"
132
149
  role="menuitem"
@@ -146,92 +163,100 @@ export const ClipContextMenu = memo(function ClipContextMenu({
146
163
  <span>{splitLabel}</span>
147
164
  <span className="text-neutral-500 text-[10px] ml-3">S</span>
148
165
  </button>
166
+ )}
167
+ {splitLabel && (
149
168
  <ClipMenuToolItems
150
169
  group="time"
151
170
  element={element}
152
171
  currentTime={currentTime}
153
172
  onClose={onClose}
154
173
  />
155
- <div className="my-1 border-t border-neutral-700/60" />
156
- </>
157
- )}
174
+ )}
175
+ </div>
158
176
 
159
- <ClipMenuAudioItems element={element} onClose={onClose} />
177
+ <div role="group" aria-label="Sound" className={GROUP_CLASS}>
178
+ <ClipMenuAudioItems part="gain" element={element} onClose={onClose} />
179
+ <ClipMenuToolItems
180
+ group="sound"
181
+ element={element}
182
+ currentTime={currentTime}
183
+ onClose={onClose}
184
+ />
185
+ <ClipMenuLinkItems part="link" element={element} onClose={onClose} />
186
+ <ClipMenuAudioItems part="duck" element={element} onClose={onClose} />
187
+ </div>
160
188
 
161
- {(onCopy || onPaste || onDuplicate) && (
162
- <>
163
- {onCopy && (
164
- <button
165
- type="button"
166
- role="menuitem"
167
- className={itemClass(true)}
168
- onClick={() => {
169
- onCopy();
170
- onClose();
171
- }}
172
- >
173
- <span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
174
- <span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
175
- </button>
176
- )}
177
- {onPaste && (
178
- <button
179
- type="button"
180
- role="menuitem"
181
- className={itemClass(!!canPaste)}
182
- disabled={!canPaste}
183
- onClick={() => {
184
- if (!canPaste) return;
185
- void onPaste();
186
- onClose();
187
- }}
188
- >
189
- <span>Paste</span>
190
- <span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
191
- </button>
192
- )}
193
- {onDuplicate && (
194
- <button
195
- type="button"
196
- role="menuitem"
197
- className={itemClass(true)}
198
- onClick={() => {
199
- void onDuplicate();
200
- onClose();
201
- }}
202
- >
203
- <span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
204
- <span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
205
- </button>
206
- )}
207
- <div className="my-1 border-t border-neutral-700/60" />
208
- </>
209
- )}
189
+ <div role="group" aria-label="Picture" className={GROUP_CLASS}>
190
+ <ClipMenuToolItems
191
+ group="picture"
192
+ element={element}
193
+ currentTime={currentTime}
194
+ onClose={onClose}
195
+ />
196
+ </div>
197
+
198
+ <div role="group" aria-label="Clipboard" className={GROUP_CLASS}>
199
+ {onCopy && (
200
+ <button
201
+ type="button"
202
+ role="menuitem"
203
+ className={itemClass(true)}
204
+ onClick={() => {
205
+ onCopy();
206
+ onClose();
207
+ }}
208
+ >
209
+ <span>{selectionSize > 1 ? `Copy ${selectionSize} clips` : "Copy"}</span>
210
+ <span className="text-neutral-500 text-[10px] ml-3">⌘C</span>
211
+ </button>
212
+ )}
213
+ {onPaste && (
214
+ <button
215
+ type="button"
216
+ role="menuitem"
217
+ className={itemClass(!!canPaste)}
218
+ disabled={!canPaste}
219
+ onClick={() => {
220
+ if (!canPaste) return;
221
+ void onPaste();
222
+ onClose();
223
+ }}
224
+ >
225
+ <span>Paste</span>
226
+ <span className="text-neutral-500 text-[10px] ml-3">⌘V</span>
227
+ </button>
228
+ )}
229
+ {onDuplicate && (
230
+ <button
231
+ type="button"
232
+ role="menuitem"
233
+ className={itemClass(true)}
234
+ onClick={() => {
235
+ void onDuplicate();
236
+ onClose();
237
+ }}
238
+ >
239
+ <span>{selectionSize > 1 ? `Duplicate ${selectionSize} clips` : "Duplicate"}</span>
240
+ <span className="text-neutral-500 text-[10px] ml-3">⌘D</span>
241
+ </button>
242
+ )}
243
+ </div>
210
244
 
211
- <ClipMenuToolItems
212
- group="sound"
213
- element={element}
214
- currentTime={currentTime}
215
- onClose={onClose}
216
- />
217
- <ClipMenuToolItems
218
- group="picture"
219
- element={element}
220
- currentTime={currentTime}
221
- onClose={onClose}
222
- />
223
- <button
224
- type="button"
225
- role="menuitem"
226
- className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
227
- onClick={() => {
228
- onDelete(element);
229
- onClose();
230
- }}
231
- >
232
- <span>Delete</span>
233
- <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
234
- </button>
245
+ <div role="group" aria-label="Delete">
246
+ <button
247
+ type="button"
248
+ role="menuitem"
249
+ className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-danger-ink hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden cursor-pointer text-left"
250
+ onClick={() => {
251
+ onDelete(element);
252
+ onClose();
253
+ }}
254
+ >
255
+ <span>Delete</span>
256
+ <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
257
+ </button>
258
+ <ClipMenuLinkItems part="delete" element={element} onClose={onClose} />
259
+ </div>
235
260
  </div>,
236
261
  document.body,
237
262
  );
@@ -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
 
@@ -0,0 +1,91 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
5
+ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
+ import { OutOfSyncBadge } from "./OutOfSyncBadge";
8
+ import { createHappyDomRootHarness } from "./testRootHarness";
9
+
10
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ const harness = createHappyDomRootHarness();
13
+ const clip = (id: string, tag: string, start: number): TimelineElement => ({
14
+ id,
15
+ domId: id,
16
+ tag,
17
+ start,
18
+ duration: 4,
19
+ track: 0,
20
+ playbackStart: 0,
21
+ syncOrigin: "lk-1",
22
+ });
23
+ const video = clip("talk", "video", 1);
24
+ const lateAudio = clip("talk-audio", "audio", 1 + 51 / 30);
25
+
26
+ afterEach(() =>
27
+ useLinkedClipPreferences.setState({ syncIndicatorsVisible: true, compositionFps: 30 }),
28
+ );
29
+
30
+ function render(el: TimelineElement, onLinkEdit = vi.fn()) {
31
+ usePlayerStore.getState().setElements([video, lateAudio]);
32
+ const host = document.body.appendChild(document.createElement("div"));
33
+ act(() =>
34
+ harness.mount(host).render(
35
+ <TimelineEditProvider value={{ onLinkEdit }}>
36
+ <OutOfSyncBadge el={el} />
37
+ </TimelineEditProvider>,
38
+ ),
39
+ );
40
+ const badge = () => document.querySelector('[data-testid="out-of-sync-badge"]');
41
+ return { badge, onLinkEdit };
42
+ }
43
+
44
+ describe("OutOfSyncBadge", () => {
45
+ it("shows the signed offset on both halves", () => {
46
+ useLinkedClipPreferences.setState({ compositionFps: 30 });
47
+ expect(render(lateAudio).badge()?.textContent).toBe("+1:21");
48
+ document.body.innerHTML = "";
49
+ expect(render(video).badge()?.textContent).toBe("-1:21");
50
+ });
51
+
52
+ it("hides when the preference is off", () => {
53
+ useLinkedClipPreferences.setState({ syncIndicatorsVisible: false });
54
+ expect(render(lateAudio).badge()).toBeNull();
55
+ });
56
+
57
+ it("opens Move / Slip into Sync from a click anywhere on the badge", () => {
58
+ const { badge, onLinkEdit } = render(lateAudio);
59
+ act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
60
+ const items = Array.from(document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'));
61
+ expect(items.map((item) => item.textContent)).toEqual(["Move into Sync", "Slip into Sync"]);
62
+ act(() => items[0]?.click());
63
+ expect(onLinkEdit).toHaveBeenCalledWith(
64
+ expect.objectContaining({ kind: "move-into-sync", element: lateAudio, start: 1 }),
65
+ );
66
+ });
67
+
68
+ it("slips from a right-click, and the click never reaches the clip", () => {
69
+ const clipClick = vi.fn();
70
+ document.body.addEventListener("contextmenu", clipClick);
71
+ const { badge, onLinkEdit } = render(lateAudio);
72
+ act(() => badge()?.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true })));
73
+ expect(clipClick).not.toHaveBeenCalled();
74
+ const slip = document.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')[1];
75
+ act(() => slip?.click());
76
+ const edit = onLinkEdit.mock.calls[0]?.[0];
77
+ expect(edit.kind).toBe("slip-into-sync");
78
+ expect(edit.mediaStart).toBeCloseTo(51 / 30);
79
+ document.body.removeEventListener("contextmenu", clipClick);
80
+ });
81
+
82
+ it("flips the menu above the pointer near the bottom edge", () => {
83
+ const { badge } = render(lateAudio);
84
+ const y = window.innerHeight - 10;
85
+ act(() => badge()?.dispatchEvent(new MouseEvent("click", { bubbles: true, clientY: y })));
86
+ const menu = document.querySelector<HTMLElement>('[role="menu"]');
87
+ const top = Number.parseFloat(menu?.style.top ?? "");
88
+ expect(top).toBeLessThan(y);
89
+ expect(top).toBeGreaterThanOrEqual(0);
90
+ });
91
+ });
@@ -0,0 +1,119 @@
1
+ import { memo, useCallback, useState, type SyntheticEvent } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { formatSyncOffset } from "@hyperframes/core/media-link";
4
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
5
+ import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext";
6
+ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
7
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
8
+ import { clipSyncState, type ClipSyncState } from "./clipSync";
9
+ import { useMenuKeyboardNav } from "./menuKeyboardNav";
10
+
11
+ const stop = (e: SyntheticEvent) => e.stopPropagation();
12
+
13
+ const MENU_ROW =
14
+ "block w-full px-3 py-1.5 text-left text-xs text-neutral-300 outline-hidden hover:bg-neutral-800 focus-visible:bg-neutral-800 disabled:opacity-40";
15
+
16
+ function SyncMenu({
17
+ x,
18
+ y,
19
+ el,
20
+ sync,
21
+ onClose,
22
+ }: {
23
+ x: number;
24
+ y: number;
25
+ el: TimelineElement;
26
+ sync: ClipSyncState<TimelineElement>;
27
+ onClose: () => void;
28
+ }) {
29
+ const menuRef = useContextMenuDismiss(onClose);
30
+ useMenuKeyboardNav(menuRef);
31
+ const { onLinkEdit } = useTimelineEditContextOptional();
32
+ const { moveStart, slipMediaStart } = sync;
33
+ const menuWidth = 170;
34
+ const menuHeight = 66;
35
+ const left = x + menuWidth > window.innerWidth ? x - menuWidth : x;
36
+ const top = y + menuHeight > window.innerHeight ? Math.max(0, y - menuHeight) : y;
37
+ return createPortal(
38
+ <div
39
+ ref={menuRef}
40
+ role="menu"
41
+ aria-label="Out of sync"
42
+ className="fixed z-200 min-w-[170px] rounded-sm border border-neutral-700 bg-neutral-900 py-1 shadow-lg"
43
+ style={{ left, top }}
44
+ >
45
+ <button
46
+ type="button"
47
+ role="menuitem"
48
+ className={MENU_ROW}
49
+ disabled={!onLinkEdit || moveStart === null}
50
+ title={moveStart === null ? "Would move the clip before 0:00" : undefined}
51
+ onClick={() => {
52
+ if (moveStart !== null)
53
+ void onLinkEdit?.({ kind: "move-into-sync", element: el, start: moveStart });
54
+ onClose();
55
+ }}
56
+ >
57
+ Move into Sync
58
+ </button>
59
+ <button
60
+ type="button"
61
+ role="menuitem"
62
+ className={MENU_ROW}
63
+ disabled={!onLinkEdit || slipMediaStart === null}
64
+ title={slipMediaStart === null ? "Would slip before the start of the file" : undefined}
65
+ onClick={() => {
66
+ if (slipMediaStart !== null) {
67
+ void onLinkEdit?.({ kind: "slip-into-sync", element: el, mediaStart: slipMediaStart });
68
+ }
69
+ onClose();
70
+ }}
71
+ >
72
+ Slip into Sync
73
+ </button>
74
+ </div>,
75
+ document.body,
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Premiere's red out-of-sync number, at the start of each half of a source pair
81
+ * that drifted. The whole badge opens Move / Slip into Sync (Premiere's own hit
82
+ * target is a sliver at the badge edge, a known complaint).
83
+ */
84
+ export const OutOfSyncBadge = memo(function OutOfSyncBadge({ el }: { el: TimelineElement }) {
85
+ const visible = useLinkedClipPreferences((s) => s.syncIndicatorsVisible);
86
+ const fps = useLinkedClipPreferences((s) => s.compositionFps);
87
+ const elements = usePlayerStore((s) => s.elements);
88
+ const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
89
+ const closeMenu = useCallback(() => setMenuAt(null), []);
90
+ const sync = visible ? clipSyncState(el, elements, fps) : null;
91
+ if (!sync) return null;
92
+ const label = formatSyncOffset(sync.frames, fps);
93
+ const partnerName = sync.partner.label || sync.partner.id;
94
+ const open = (e: React.MouseEvent) => {
95
+ e.preventDefault();
96
+ e.stopPropagation();
97
+ setMenuAt({ x: e.clientX, y: e.clientY });
98
+ };
99
+ return (
100
+ <>
101
+ <span
102
+ role="button"
103
+ tabIndex={-1}
104
+ aria-haspopup="menu"
105
+ aria-label={`Out of sync with ${partnerName} by ${label} frames`}
106
+ title={`Out of sync with ${partnerName} by ${label} — click for Move or Slip into Sync`}
107
+ data-testid="out-of-sync-badge"
108
+ className="absolute bottom-0.5 left-1 z-[7] cursor-pointer rounded-[3px] bg-red-600 px-1 font-mono text-[9px] leading-[14px] text-white tabular-nums"
109
+ onPointerDown={stop}
110
+ onDoubleClick={stop}
111
+ onClick={open}
112
+ onContextMenu={open}
113
+ >
114
+ {label}
115
+ </span>
116
+ {menuAt && <SyncMenu x={menuAt.x} y={menuAt.y} el={el} sync={sync} onClose={closeMenu} />}
117
+ </>
118
+ );
119
+ });
@@ -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) {
@@ -105,6 +105,23 @@ describe("TimelineClip", () => {
105
105
  expect(seen).toEqual([{ fadeIn: 1, fadeOut: 0.5, duration: 4 }, null]);
106
106
  });
107
107
 
108
+ it("gives both halves of a link group the same label colour, and unlinked clips none", () => {
109
+ const colorOf = (element: TimelineElement) => {
110
+ const { host, root } = renderClip({ element });
111
+ const clip = host.querySelector<HTMLElement>(".timeline-clip");
112
+ const color = clip?.getAttribute("data-link-color") ?? null;
113
+ expect(clip?.style.getPropertyValue("--clip-link-color") || null).toBe(color);
114
+ act(() => root.unmount());
115
+ return color;
116
+ };
117
+ const base = { start: 0, duration: 2, track: 0 };
118
+ const video = colorOf({ id: "v", tag: "video", link: "lk-3", ...base });
119
+ expect(video).not.toBeNull();
120
+ expect(colorOf({ id: "a", tag: "audio", link: "lk-3", ...base })).toBe(video);
121
+ expect(colorOf({ id: "b", tag: "audio", link: "lk-4", ...base })).not.toBe(video);
122
+ expect(colorOf({ id: "c", tag: "audio", ...base })).toBeNull();
123
+ });
124
+
108
125
  it("renders the clip label above custom content without showing default timecode", () => {
109
126
  const { host, root } = renderClip({
110
127
  element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 1, track: 0 },
@@ -116,6 +133,47 @@ describe("TimelineClip", () => {
116
133
  act(() => root.unmount());
117
134
  });
118
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
+
119
177
  it("drops the label chip under 60px even when the clip is selected", () => {
120
178
  const { host, root } = renderClip({
121
179
  element: { id: "fx", label: "FX", tag: "div", start: 0, duration: 1, track: 0 },
@@ -12,6 +12,9 @@ import { timelineClipFocusId } from "./timelineNavigationIdentity";
12
12
  import { ClipFadesContext, TimelineClipFades, useClipFadeDraft } from "./TimelineClipFades";
13
13
  import { rendersWaveform } from "./AudioWaveform";
14
14
  import { ClipBadges } from "./ClipBadges";
15
+ import { linkLabelColor } from "./linkLabelColor";
16
+ import { OutOfSyncBadge } from "./OutOfSyncBadge";
17
+ import { clipSpeedSuffix } from "./clipToolAttrs";
15
18
 
16
19
  interface TimelineClipProps {
17
20
  el: TimelineElement;
@@ -66,7 +69,7 @@ export const TimelineClip = memo(function TimelineClip({
66
69
  const leftPx = el.start * pps;
67
70
  const widthPx = Math.max(el.duration * pps, 4);
68
71
  const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
69
- const displayLabel = el.label || el.id || el.tag;
72
+ const displayLabel = `${el.label || el.id || el.tag}${clipSpeedSuffix(el.playbackRate, el.automation)}`;
70
73
  const ladder = clipWidthLadder(widthPx);
71
74
  const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
72
75
  const showLabel = ladder === "labeled";
@@ -83,6 +86,8 @@ export const TimelineClip = memo(function TimelineClip({
83
86
  "--clip-border-active": theme.clipBorderActive,
84
87
  "--clip-handle": theme.handleColor,
85
88
  } as CSSProperties;
89
+ const linkColor = linkLabelColor(el.link);
90
+ if (linkColor) Object.assign(themeVariables, { "--clip-link-color": linkColor });
86
91
  const isAudioClip = isAudioTimelineElement(el);
87
92
  const hasFades = (isAudioClip || Boolean(el.hasAudio)) && !isGestureActor;
88
93
  const fade = useClipFadeDraft(el);
@@ -124,6 +129,7 @@ export const TimelineClip = memo(function TimelineClip({
124
129
  data-clip-start={el.start}
125
130
  data-clip-end={el.start + el.duration}
126
131
  data-clip-hidden={el.hidden ? "true" : undefined}
132
+ data-link-color={linkColor ?? undefined}
127
133
  data-ladder={ladder}
128
134
  data-active={isActive ? "" : undefined}
129
135
  aria-hidden={isGestureActor ? "true" : undefined}
@@ -205,7 +211,8 @@ export const TimelineClip = memo(function TimelineClip({
205
211
  </div>
206
212
  )}
207
213
  {showLabel && <span className="timeline-clip__label">{displayLabel}</span>}
208
- {showLabel && !isGestureActor && <ClipBadges el={el} />}
214
+ {showLabel && !isGestureActor && <ClipBadges el={el} onOpenMenu={onContextMenu} />}
215
+ {!isGestureActor && el.syncOrigin && <OutOfSyncBadge el={el} />}
209
216
  {showDefaultText && (
210
217
  <span className="timeline-clip__timecode">
211
218
  {startLabel}-{endLabel}s