@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
@@ -27,6 +27,8 @@ import type {
27
27
  BlockedClipState,
28
28
  } from "./timelineClipDragTypes";
29
29
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
30
+ import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink";
31
+ import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
30
32
  import type { TimelineRowGeometry } from "./timelineLayout";
31
33
  import {
32
34
  mountTimelineClipDragGestureLifecycle,
@@ -65,6 +67,7 @@ interface UseTimelineClipDragInput {
65
67
  ) => Promise<void> | void;
66
68
  onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
67
69
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
70
+ onLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
68
71
  /** Seeks the preview; a trim shows the frame at its dragged edge. */
69
72
  onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void;
70
73
  setShowPopover: (show: boolean) => void;
@@ -95,6 +98,7 @@ export function useTimelineClipDrag({
95
98
  onResizeElement,
96
99
  onResizeElements,
97
100
  onBlockedEditAttempt,
101
+ onLinkEdit,
98
102
  onSeek,
99
103
  setShowPopover,
100
104
  setRangeSelectionRef,
@@ -220,6 +224,13 @@ export function useTimelineClipDrag({
220
224
  return;
221
225
  }
222
226
  beginGesture("drag", next.pointerId);
227
+ gestureSelectedKeysRef.current = linkedGestureKeys(
228
+ gestureSelectedKeysRef.current,
229
+ next.element,
230
+ elementsRef.current,
231
+ next.altKey === true,
232
+ isLinkedSelectionOn(),
233
+ );
223
234
  publishDraggedClip(next);
224
235
  },
225
236
  [beginGesture, publishDraggedClip],
@@ -231,6 +242,18 @@ export function useTimelineClipDrag({
231
242
  return;
232
243
  }
233
244
  beginGesture("resize", next.pointerId);
245
+ gestureSelectedKeysRef.current = dropMisalignedTrimPartners(
246
+ linkedGestureKeys(
247
+ gestureSelectedKeysRef.current,
248
+ next.element,
249
+ elementsRef.current,
250
+ next.altKey === true,
251
+ isLinkedSelectionOn(),
252
+ ),
253
+ next.element,
254
+ elementsRef.current,
255
+ next.edge,
256
+ );
234
257
  publishResizingClip(next);
235
258
  },
236
259
  [beginGesture, publishResizingClip],
@@ -249,6 +272,8 @@ export function useTimelineClipDrag({
249
272
  onMoveElementsRef.current = onMoveElements;
250
273
  const onBlockedEditAttemptRef = useRef(onBlockedEditAttempt);
251
274
  onBlockedEditAttemptRef.current = onBlockedEditAttempt;
275
+ const onLinkEditRef = useRef(onLinkEdit);
276
+ onLinkEditRef.current = onLinkEdit;
252
277
  const onResizeElementRef = useRef(onResizeElement);
253
278
  onResizeElementRef.current = onResizeElement;
254
279
  const onResizeElementsRef = useRef(onResizeElements);
@@ -304,6 +329,8 @@ export function useTimelineClipDrag({
304
329
  scroll: scrollRef.current,
305
330
  pps: ppsRef.current,
306
331
  buildSnapTargets,
332
+ elements: elementsRef.current,
333
+ gestureKeys: gestureSelectedKeysRef.current,
307
334
  });
308
335
  trimSeekOriginRef.current ??= usePlayerStore.getState().currentTime;
309
336
  const setResizeState = (v: ResizePreviewResult) => {
@@ -422,6 +449,7 @@ export function useTimelineClipDrag({
422
449
  refreshAfterLaneMoveRef,
423
450
  readZIndexRef,
424
451
  onBlockedEditAttemptRef,
452
+ onLinkEditRef,
425
453
  onResizeElementsRef,
426
454
  onResizeElementRef,
427
455
  onMoveElementsRef,
@@ -32,7 +32,6 @@ type OverlayStateInputs = Omit<
32
32
 
33
33
  export type TimelineOverlaysStateResult = {
34
34
  overlays: TimelineOverlaysState;
35
- shiftClickClipRef: ReturnType<typeof useTimelineRangeSelection>["shiftClickClipRef"];
36
35
  marqueeRect: ReturnType<typeof useTimelineRangeSelection>["marqueeRect"];
37
36
  isScrubbing: ReturnType<typeof useTimelineRangeSelection>["isScrubbing"];
38
37
  handlePointerDown: ReturnType<typeof useTimelineRangeSelection>["handlePointerDown"];
@@ -55,7 +54,6 @@ export function useTimelineOverlaysState({
55
54
  const {
56
55
  rangeSelection,
57
56
  setRangeSelection,
58
- shiftClickClipRef,
59
57
  marqueeRect,
60
58
  isScrubbing,
61
59
  handlePointerDown,
@@ -86,7 +84,6 @@ export function useTimelineOverlaysState({
86
84
  onCloseAllTrackGaps: gap.closeAllTrackGaps,
87
85
  onHoverGapAction: gap.setHoveredGapAction,
88
86
  },
89
- shiftClickClipRef,
90
87
  marqueeRect,
91
88
  isScrubbing,
92
89
  handlePointerDown,
@@ -86,6 +86,7 @@ export function useTimelineProviderState({
86
86
  onResizeElement,
87
87
  onResizeElements,
88
88
  onBlockedEditAttempt,
89
+ onLinkEdit,
89
90
  onSplitElement,
90
91
  onRazorSplitAll,
91
92
  onDeleteKeyframe,
@@ -105,6 +106,7 @@ export function useTimelineProviderState({
105
106
  const readOnlyPress = useTimelineReadOnlyPress();
106
107
  const oneShotEdits = refuseWhenReadOnly(readOnlyPress, {
107
108
  onBlockedEditAttempt,
109
+ onLinkEdit,
108
110
  onRazorSplitAll,
109
111
  onToggleTrackHidden: editContext.onToggleTrackHidden,
110
112
  onTogglePropertyGroupKeyframe: editContext.onTogglePropertyGroupKeyframe,
@@ -217,6 +219,7 @@ export function useTimelineProviderState({
217
219
  onResizeElement: pinnedOnResizeElement,
218
220
  onResizeElements: pinnedOnResizeElements,
219
221
  onBlockedEditAttempt: oneShotEdits.onBlockedEditAttempt,
222
+ onLinkEdit: oneShotEdits.onLinkEdit,
220
223
  onSeek,
221
224
  setShowPopover,
222
225
  setRangeSelectionRef,
@@ -417,7 +420,6 @@ export function useTimelineProviderState({
417
420
  gapHighlight,
418
421
  openGapMenu,
419
422
  onContextMenuClip,
420
- shiftClickClipRef,
421
423
  marqueeRect,
422
424
  isScrubbing,
423
425
  handlePointerDown,
@@ -500,7 +502,6 @@ export function useTimelineProviderState({
500
502
  setResizingClip,
501
503
  setDraggedClip,
502
504
  setSelectedElementId,
503
- shiftClickClipRef,
504
505
  getPreviewElement,
505
506
  getTrackStyle,
506
507
  keyframeCache,
@@ -1,6 +1,5 @@
1
1
  import { useRef, useState, useCallback, useEffect } from "react";
2
2
  import {
3
- buildClipRangeSelection,
4
3
  applyTimelineAutoScrollStep,
5
4
  resolveTimelineAutoScrollLoopAction,
6
5
  type TimelineRangeSelection,
@@ -149,11 +148,6 @@ export function useTimelineRangeSelection({
149
148
  // rendering). Drives the playhead head's filled-vs-hollow state.
150
149
  const [isScrubbing, setIsScrubbing] = useState(false);
151
150
  const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | null>(null);
152
- const shiftClickClipRef = useRef<{
153
- element: TimelineElement;
154
- anchorX: number;
155
- anchorY: number;
156
- } | null>(null);
157
151
 
158
152
  const seekRafRef = useRef(0);
159
153
  const pendingClientXRef = useRef(0);
@@ -322,7 +316,6 @@ export function useTimelineRangeSelection({
322
316
  beginRangeSelection(e);
323
317
  return;
324
318
  }
325
- shiftClickClipRef.current = null;
326
319
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
327
320
  (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
328
321
  activePointerIdRef.current = e.pointerId;
@@ -427,13 +420,7 @@ export function useTimelineRangeSelection({
427
420
  // clip range), otherwise clear it.
428
421
  const finishRangeSelection = useCallback(() => {
429
422
  isRangeSelecting.current = false;
430
- const pendingShiftClick = shiftClickClipRef.current;
431
- shiftClickClipRef.current = null;
432
423
  setRangeSelection((prev) => {
433
- if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
434
- setShowPopover(true);
435
- return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
436
- }
437
424
  if (prev && Math.abs(prev.end - prev.start) > 0.2) {
438
425
  setShowPopover(true);
439
426
  return prev;
@@ -589,7 +576,6 @@ export function useTimelineRangeSelection({
589
576
  return {
590
577
  rangeSelection,
591
578
  setRangeSelection,
592
- shiftClickClipRef,
593
579
  marqueeRect,
594
580
  isScrubbing,
595
581
  handlePointerDown,
@@ -120,6 +120,7 @@ export function useTimelineRowVirtualization({
120
120
  previousGeometry,
121
121
  rowGeometry,
122
122
  scroll.scrollTop,
123
+ draggedRowKey !== undefined,
123
124
  );
124
125
  if (nextScrollTop !== scroll.scrollTop) {
125
126
  scroll.scrollTop = nextScrollTop;
@@ -127,7 +128,7 @@ export function useTimelineRowVirtualization({
127
128
  }
128
129
  }
129
130
  previousLayoutRef.current = rowGeometry;
130
- }, [lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
131
+ }, [draggedRowKey, lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
131
132
 
132
133
  return {
133
134
  enabled,
@@ -13,9 +13,15 @@ describe("timelineElementsChanged", () => {
13
13
  ["audio group hidden", { audioGroupHidden: true }],
14
14
  ["fade in", { fadeIn: 1 }],
15
15
  ["fade out", { fadeOut: 1 }],
16
+ ["link", { link: "lk-1" }],
16
17
  ["source (re-pointed at its preview copy)", { src: "clip1.mp4?hf-proxy=h264" }],
17
18
  ])("sees a clip whose %s changed with no timing change", (_name, change) => {
18
19
  expect(timelineElementsChanged([clip], [{ ...clip, ...change }])).toBe(true);
19
20
  expect(timelineElementsChanged([{ ...clip, ...change }], [{ ...clip, ...change }])).toBe(false);
20
21
  });
22
+
23
+ it("sees an unlink: the same clips re-derived without their link", () => {
24
+ const linked = { ...clip, link: "lk-1" };
25
+ expect(timelineElementsChanged([linked], [clip])).toBe(true);
26
+ });
21
27
  });
@@ -16,6 +16,7 @@ const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
16
16
  "fadeIn",
17
17
  "fadeOut",
18
18
  "src",
19
+ "link",
19
20
  ];
20
21
 
21
22
  /** Whether a derived timeline changes any field that affects rendering. */
@@ -15,6 +15,7 @@ import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
15
15
  import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
16
16
  import { buildMissingCompositionElements } from "../lib/timelineIframeHelpers";
17
17
  import { acceptedRuntimeMessageFps } from "../lib/runtimeProtocol";
18
+ import { useLinkedClipPreferences } from "../../utils/linkedClipPreferences";
18
19
  import {
19
20
  buildTimelineElementsFromClips,
20
21
  syncManifestTimeline,
@@ -196,8 +197,10 @@ export function useTimelineSyncCallbacks({
196
197
  // at the authored root `data-duration` so a runtime that measures only the
197
198
  // furthest clip end (shorter than the authored window) can't leave a stale,
198
199
  // too-short total in the transport (the "0:44/0:40" bug).
200
+ const fps = acceptedRuntimeMessageFps(data);
201
+ useLinkedClipPreferences.getState().setCompositionFps(fps);
199
202
  const newDuration = resolveTimelineTotalDuration({
200
- manifestDurationSeconds: data.durationInFrames / acceptedRuntimeMessageFps(data),
203
+ manifestDurationSeconds: data.durationInFrames / fps,
201
204
  authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
202
205
  });
203
206
  syncManifestTimeline(
@@ -281,6 +281,37 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
281
281
 
282
282
  expect(element.hidden).toBe(true);
283
283
  });
284
+
285
+ it("reads data-link and data-sync-origin on both the DOM and manifest paths", () => {
286
+ const doc = makeDoc(`
287
+ <div data-composition-id="root">
288
+ <video id="talk" class="clip" src="t.mp4" muted data-link="lk-1" data-sync-origin="lk-1" data-start="0" data-duration="5"></video>
289
+ </div>
290
+ `);
291
+ const fromDom = parseTimelineFromDOM(doc, 10).find((el) => el.domId === "talk");
292
+ expect(fromDom?.link).toBe("lk-1");
293
+ expect(fromDom?.syncOrigin).toBe("lk-1");
294
+ const element = createTimelineElementFromManifestClip({
295
+ clip: {
296
+ id: "talk",
297
+ label: "Talk",
298
+ kind: "element",
299
+ tagName: "video",
300
+ start: 0,
301
+ duration: 5,
302
+ track: 0,
303
+ compositionId: null,
304
+ parentCompositionId: null,
305
+ compositionSrc: null,
306
+ assetUrl: null,
307
+ },
308
+ fallbackIndex: 0,
309
+ doc,
310
+ hostEl: doc.getElementById("talk"),
311
+ });
312
+ expect(element.link).toBe("lk-1");
313
+ expect(element.syncOrigin).toBe("lk-1");
314
+ });
284
315
  });
285
316
 
286
317
  describe("group info cache", () => {
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
12
12
  import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
13
13
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
14
14
  import { readClipTiming } from "@hyperframes/core/composition-contract";
15
+ import { linkScopeOf } from "@hyperframes/core/media-link";
15
16
  import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
16
17
  import { groupInfoFor } from "./timelineGroupInfo";
17
18
  import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
@@ -150,6 +151,12 @@ export function createTimelineElementFromManifestClip(params: {
150
151
  if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
151
152
  const timelineRole = hostEl.getAttribute("data-timeline-role");
152
153
  if (timelineRole) entry.timelineRole = timelineRole;
154
+ const link = hostEl.getAttribute("data-link");
155
+ if (link) entry.link = link;
156
+ const compositionScope = linkScopeOf(hostEl)?.getAttribute("data-composition-id");
157
+ if (compositionScope) entry.compositionScope = compositionScope;
158
+ const syncOrigin = hostEl.getAttribute("data-sync-origin");
159
+ if (syncOrigin) entry.syncOrigin = syncOrigin;
153
160
  const audioGroup = hostEl.getAttribute("data-audio-group");
154
161
  if (audioGroup) {
155
162
  entry.audioGroup = audioGroup;
@@ -337,6 +344,12 @@ export function parseTimelineFromDOM(
337
344
 
338
345
  const timelineRole = el.getAttribute("data-timeline-role");
339
346
  if (timelineRole) entry.timelineRole = timelineRole;
347
+ const domLink = el.getAttribute("data-link");
348
+ if (domLink) entry.link = domLink;
349
+ const domCompositionScope = linkScopeOf(el)?.getAttribute("data-composition-id");
350
+ if (domCompositionScope) entry.compositionScope = domCompositionScope;
351
+ const domSyncOrigin = el.getAttribute("data-sync-origin");
352
+ if (domSyncOrigin) entry.syncOrigin = domSyncOrigin;
340
353
 
341
354
  const domAudioGroup = el.getAttribute("data-audio-group");
342
355
  if (domAudioGroup) {
@@ -83,6 +83,9 @@ export interface TimelineElement {
83
83
  /** The owning group's serialized `data-fx-chain`, when set — resolved once per parse. */
84
84
  audioGroupFxChain?: string;
85
85
  audioGroupAutomation?: string;
86
+ link?: string;
87
+ compositionScope?: string;
88
+ syncOrigin?: string;
86
89
  /**
87
90
  * Master start of the composition this row runs in, which its tweens and its
88
91
  * `data-start` are local to; 0 at the root. Writes go through toAuthoredStart.
@@ -116,6 +119,15 @@ export function toCompositionTime(element: RowClock, masterTime: number): number
116
119
  return masterTime - (element.parentCompositionStart ?? 0);
117
120
  }
118
121
 
122
+ type CompositionScoped = Pick<TimelineElement, "sourceFile" | "compositionScope">;
123
+
124
+ export function sameCompositionScope(a: CompositionScoped, b: CompositionScoped): boolean {
125
+ return (
126
+ (a.sourceFile ?? "") === (b.sourceFile ?? "") &&
127
+ (a.compositionScope ?? "") === (b.compositionScope ?? "")
128
+ );
129
+ }
130
+
119
131
  /**
120
132
  * The fields `updateElement` may write.
121
133
  *
@@ -296,17 +296,6 @@
296
296
  transition: none;
297
297
  }
298
298
 
299
- .timeline-audio-link {
300
- position: absolute;
301
- z-index: 4;
302
- top: -9px;
303
- left: 50%;
304
- width: 1px;
305
- height: 10px;
306
- background: var(--timeline-audio-link);
307
- pointer-events: none;
308
- }
309
-
310
299
  .timeline-clip.is-hovered {
311
300
  background-color: var(--clip-bg-hover);
312
301
  }
@@ -362,6 +351,16 @@
362
351
  white-space: nowrap;
363
352
  }
364
353
 
354
+ /* Link groups share a label colour on the border and the name chip. */
355
+ .timeline-clip[data-link-color] {
356
+ border-color: var(--clip-link-color);
357
+ }
358
+
359
+ .timeline-clip[data-link-color] .timeline-clip__label {
360
+ box-shadow: inset 3px 0 0 var(--clip-link-color);
361
+ padding-left: 8px;
362
+ }
363
+
365
364
  .timeline-clip.is-audio .timeline-clip__label {
366
365
  top: 11px;
367
366
  left: 9px;
@@ -517,6 +516,23 @@
517
516
  transition: transform 160ms ease;
518
517
  }
519
518
 
519
+ /* The caret of a text edited in place (InlineTextCaret): lit half the time, like the system caret */
520
+ .hf-inline-text-caret {
521
+ animation: hf-inline-text-caret-blink 1.06s step-end infinite;
522
+ }
523
+
524
+ @keyframes hf-inline-text-caret-blink {
525
+ 50% {
526
+ opacity: 0;
527
+ }
528
+ }
529
+
530
+ @media (prefers-reduced-motion: reduce) {
531
+ .hf-inline-text-caret {
532
+ animation: none;
533
+ }
534
+ }
535
+
520
536
  /* Toast enter/exit (StudioToast) */
521
537
  @keyframes hf-toast-in {
522
538
  from {
@@ -117,7 +117,6 @@
117
117
  --timeline-clip-audio-bg-hover: oklch(0.49 0.12 300);
118
118
  --timeline-clip-audio-bg-dragging: oklch(0.42 0.12 300 / 0.96);
119
119
  --timeline-clip-audio-muted-bg: rgb(72, 74, 78);
120
- --timeline-audio-link: light-dark(#626d66, #a0aaa4);
121
120
  --timeline-clip-selection: light-dark(#0f766e, rgba(255, 255, 255, 0.85));
122
121
  --timeline-clip-selection-highlight: rgb(255 255 255 / 0.55);
123
122
  --timeline-clip-radius: 8px;