@hyperframes/studio 0.8.108 → 0.8.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/dist/assets/{hyperframes-player-BakGew45.js → hyperframes-player-c6WR3RsF.js} +1 -1
  2. package/dist/assets/{index-C5dyUHwk.js → index-BJpQzV6h.js} +1 -1
  3. package/dist/assets/{index-CPe7d_wO.js → index-C0KfbPAo.js} +1 -1
  4. package/dist/assets/{index-ncVJ5IVu.css → index-C7apTvpr.css} +1 -1
  5. package/dist/assets/{index-C842tcVa.js → index-Co6W8IVk.js} +211 -208
  6. package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
  7. package/dist/chunk-5VSXRGIX.js.map +1 -0
  8. package/dist/{chunk-AOWVEVEU.js → chunk-QUXZVHOJ.js} +8 -2
  9. package/dist/chunk-QUXZVHOJ.js.map +1 -0
  10. package/dist/index.d.ts +64 -9
  11. package/dist/index.html +2 -2
  12. package/dist/index.js +8216 -6268
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/dist/styles.css +1 -1
  16. package/dist/ui.js +1 -1
  17. package/package.json +8 -8
  18. package/src/App.tsx +7 -11
  19. package/src/components/EditorShell.tsx +4 -0
  20. package/src/components/LinkedSelectionToggle.tsx +73 -0
  21. package/src/components/TimelineToolbar.test.tsx +31 -0
  22. package/src/components/TimelineToolbar.tsx +2 -0
  23. package/src/components/editor/DomEditCropHandles.test.tsx +51 -22
  24. package/src/components/editor/DomEditCropHandles.tsx +2 -1
  25. package/src/components/editor/InlineTextCaret.test.tsx +150 -0
  26. package/src/components/editor/InlineTextCaret.tsx +211 -0
  27. package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
  28. package/src/components/editor/InlineTextToolbar.tsx +7 -5
  29. package/src/components/editor/PropertyPanelFlat.tsx +4 -1
  30. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +1 -0
  31. package/src/components/editor/commitPreflightHandles.test.tsx +4 -1
  32. package/src/components/editor/domEditInlineText.ts +6 -4
  33. package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
  34. package/src/components/editor/domEditOverlayGestures.ts +10 -0
  35. package/src/components/editor/domEditOverlayStartGesture.test.ts +1 -0
  36. package/src/components/editor/domEditOverlayStartGesture.ts +2 -0
  37. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +39 -2
  38. package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
  39. package/src/components/editor/linkedSpeedEdits.ts +94 -0
  40. package/src/components/editor/marqueeCommit.test.tsx +31 -6
  41. package/src/components/editor/marqueeCommit.ts +16 -8
  42. package/src/components/editor/mediaAudioEdits.test.ts +20 -4
  43. package/src/components/editor/mediaAudioEdits.ts +27 -14
  44. package/src/components/editor/mediaLinkEdits.test.ts +334 -0
  45. package/src/components/editor/mediaLinkEdits.ts +303 -0
  46. package/src/components/editor/previewReadOnly.overlay.test.tsx +8 -1
  47. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +4 -3
  48. package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
  49. package/src/components/editor/shiftDragAxisLock.test.tsx +17 -3
  50. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  51. package/src/components/editor/useInlineTextEditing.tsx +11 -4
  52. package/src/components/editor/useVolumeAutomation.ts +29 -7
  53. package/src/components/nle/useTimelineEditCallbacks.ts +10 -0
  54. package/src/components/ui/Menu.test.tsx +3 -22
  55. package/src/components/ui/Menu.tsx +2 -2
  56. package/src/components/ui/Popover.tsx +2 -2
  57. package/src/components/ui/Select.tsx +2 -2
  58. package/src/components/ui/Tooltip.tsx +2 -2
  59. package/src/components/ui/mountHost.testHelpers.ts +8 -0
  60. package/src/components/ui/valueControls.test.tsx +2 -21
  61. package/src/contexts/TimelineEditContext.tsx +4 -0
  62. package/src/editorGesturesExports.test.tsx +1 -0
  63. package/src/hooks/appHotkeysDispatch.ts +11 -1
  64. package/src/hooks/linkShortcuts.test.ts +70 -0
  65. package/src/hooks/linkShortcuts.ts +41 -0
  66. package/src/hooks/timelineElementsAttribute.test.ts +95 -0
  67. package/src/hooks/timelineElementsAttribute.ts +113 -0
  68. package/src/hooks/timelineLinkEditPlan.test.ts +141 -0
  69. package/src/hooks/timelineLinkEditPlan.ts +128 -0
  70. package/src/hooks/timelineMoveAdapter.ts +13 -0
  71. package/src/hooks/timelineTimingSync.test.ts +59 -0
  72. package/src/hooks/timelineTimingSync.ts +22 -3
  73. package/src/hooks/useAppHotkeys.test.ts +34 -0
  74. package/src/hooks/useAppHotkeys.ts +9 -0
  75. package/src/hooks/useDomGeometryCommits.test.tsx +8 -1
  76. package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
  77. package/src/hooks/useGsapScriptCommits.ts +23 -3
  78. package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
  79. package/src/hooks/usePreviewPersistence.ts +15 -0
  80. package/src/hooks/useRazorSplit.test.tsx +48 -0
  81. package/src/hooks/useRazorSplit.ts +12 -1
  82. package/src/hooks/useRenderClipContent.test.ts +6 -4
  83. package/src/hooks/useRenderClipContent.ts +9 -8
  84. package/src/hooks/useTimelineDeleteOps.ts +10 -4
  85. package/src/hooks/useTimelineEditGuard.ts +54 -0
  86. package/src/hooks/useTimelineEditing.ts +49 -55
  87. package/src/hooks/useTimelineLinkEditing.test.tsx +124 -0
  88. package/src/hooks/useTimelineLinkEditing.ts +158 -0
  89. package/src/index.ts +5 -0
  90. package/src/player/components/AudioGainDialog.test.tsx +133 -0
  91. package/src/player/components/AudioGainDialog.tsx +226 -0
  92. package/src/player/components/AudioWaveform.test.tsx +30 -3
  93. package/src/player/components/AudioWaveform.tsx +6 -7
  94. package/src/player/components/ClipBadges.test.tsx +32 -8
  95. package/src/player/components/ClipBadges.tsx +37 -31
  96. package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
  97. package/src/player/components/ClipContextMenu.test.tsx +10 -4
  98. package/src/player/components/ClipContextMenu.tsx +115 -90
  99. package/src/player/components/ClipPeakMarks.tsx +2 -14
  100. package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
  101. package/src/player/components/OutOfSyncBadge.tsx +119 -0
  102. package/src/player/components/Timeline.test.ts +15 -0
  103. package/src/player/components/TimelineClip.test.tsx +58 -0
  104. package/src/player/components/TimelineClip.tsx +9 -2
  105. package/src/player/components/TimelineLanes.test.tsx +45 -2
  106. package/src/player/components/TimelineLanes.tsx +0 -2
  107. package/src/player/components/TimelineOverlays.tsx +2 -0
  108. package/src/player/components/audioClipLink.test.ts +288 -31
  109. package/src/player/components/audioClipLink.ts +151 -27
  110. package/src/player/components/audioGainApply.test.ts +87 -0
  111. package/src/player/components/audioGainApply.ts +57 -0
  112. package/src/player/components/audioGainDialogStore.ts +36 -0
  113. package/src/player/components/audioGainPlan.test.ts +82 -0
  114. package/src/player/components/audioGainPlan.ts +53 -0
  115. package/src/player/components/clipMenuAudioItems.noShell.test.tsx +86 -0
  116. package/src/player/components/clipMenuAudioItems.test.tsx +59 -50
  117. package/src/player/components/clipMenuAudioItems.tsx +44 -57
  118. package/src/player/components/clipMenuDuck.ts +5 -0
  119. package/src/player/components/clipMenuLinkItems.test.ts +174 -0
  120. package/src/player/components/clipMenuLinkItems.tsx +166 -0
  121. package/src/player/components/clipMenuToolItems.test.tsx +130 -4
  122. package/src/player/components/clipMenuToolItems.tsx +80 -9
  123. package/src/player/components/clipPeakMap.ts +46 -0
  124. package/src/player/components/clipPeakRuns.test.ts +14 -1
  125. package/src/player/components/clipPeakRuns.ts +11 -0
  126. package/src/player/components/clipSync.test.ts +67 -0
  127. package/src/player/components/clipSync.ts +80 -0
  128. package/src/player/components/clipToolAttrs.test.ts +57 -41
  129. package/src/player/components/clipToolAttrs.ts +48 -45
  130. package/src/player/components/linkLabelColor.test.ts +20 -0
  131. package/src/player/components/linkLabelColor.ts +24 -0
  132. package/src/player/components/studioShortcuts.ts +2 -0
  133. package/src/player/components/timelineCallbacks.ts +17 -0
  134. package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
  135. package/src/player/components/timelineClipDragPreview.test.ts +96 -0
  136. package/src/player/components/timelineClipDragPreview.ts +32 -7
  137. package/src/player/components/timelineClipDragTypes.ts +2 -0
  138. package/src/player/components/timelineClipGestureHandlers.ts +29 -31
  139. package/src/player/components/timelineEditing.test.ts +0 -13
  140. package/src/player/components/timelineEditing.ts +0 -11
  141. package/src/player/components/timelineLaneProps.ts +0 -5
  142. package/src/player/components/timelineLinkSelection.ts +41 -0
  143. package/src/player/components/timelineViewportGeometry.ts +3 -0
  144. package/src/player/components/useClipToolState.ts +0 -5
  145. package/src/player/components/useTimelineClipContextMenu.ts +2 -1
  146. package/src/player/components/useTimelineClipDrag.ts +28 -0
  147. package/src/player/components/useTimelineOverlaysState.ts +0 -3
  148. package/src/player/components/useTimelineProviderState.tsx +3 -2
  149. package/src/player/components/useTimelineRangeSelection.ts +0 -14
  150. package/src/player/components/useTimelineRowVirtualization.ts +2 -1
  151. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  152. package/src/player/hooks/timelinePlayerSync.ts +1 -0
  153. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
  154. package/src/player/lib/timelineDOM.test.ts +31 -0
  155. package/src/player/lib/timelineDOM.ts +13 -0
  156. package/src/player/store/timelineElement.ts +12 -0
  157. package/src/styles/components.css +27 -11
  158. package/src/styles/theme.css +0 -1
  159. package/src/utils/gsapSoftReload.test.ts +238 -2
  160. package/src/utils/gsapSoftReload.ts +89 -74
  161. package/src/utils/gsapUndoRestore.test.ts +4 -3
  162. package/src/utils/gsapUndoRestore.ts +15 -0
  163. package/src/utils/linkedClipPreferences.ts +34 -0
  164. package/src/utils/sdkCutover.linkedTiming.test.ts +52 -0
  165. package/src/utils/softReloadTargets.ts +128 -0
  166. package/src/utils/studioUiPreferences.ts +8 -0
  167. package/src/utils/typingTarget.test.ts +8 -0
  168. package/src/utils/typingTarget.ts +1 -0
  169. package/dist/chunk-AOWVEVEU.js.map +0 -1
  170. package/dist/chunk-NCUBAVGK.js.map +0 -1
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { LINK_LABEL_COLORS, linkLabelColor } from "./linkLabelColor";
3
+
4
+ describe("linkLabelColor", () => {
5
+ it("has no colour for an unlinked clip", () => {
6
+ expect(linkLabelColor(undefined)).toBeNull();
7
+ expect(linkLabelColor("")).toBeNull();
8
+ });
9
+
10
+ it("walks the palette in lk-N order, wrapping", () => {
11
+ expect(linkLabelColor("lk-1")).toBe(LINK_LABEL_COLORS[0]);
12
+ expect(linkLabelColor("lk-2")).toBe(LINK_LABEL_COLORS[1]);
13
+ expect(linkLabelColor(`lk-${LINK_LABEL_COLORS.length + 1}`)).toBe(LINK_LABEL_COLORS[0]);
14
+ });
15
+
16
+ it("is deterministic for hand-written ids", () => {
17
+ expect(linkLabelColor("talk-pair")).toBe(linkLabelColor("talk-pair"));
18
+ expect(LINK_LABEL_COLORS).toContain(linkLabelColor("talk-pair"));
19
+ });
20
+ });
@@ -0,0 +1,24 @@
1
+ /** Link-group label colours; teal, violet, white and red belong to clips, selection and the sync badge. */
2
+ export const LINK_LABEL_COLORS = [
3
+ "var(--color-amber-500)",
4
+ "var(--color-sky-400)",
5
+ "var(--color-green-400)",
6
+ "var(--color-pink-400)",
7
+ "var(--color-orange-400)",
8
+ "var(--color-amber-300)",
9
+ ] as const;
10
+
11
+ function stableIndex(link: string): number {
12
+ const numbered = /^lk-(\d+)$/.exec(link);
13
+ if (numbered) return Number(numbered[1]) - 1;
14
+ let hash = 0;
15
+ for (const char of link) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
16
+ return hash;
17
+ }
18
+
19
+ /** The same colour for every member of a link group, from its id alone. */
20
+ export function linkLabelColor(link: string | undefined): string | null {
21
+ if (!link) return null;
22
+ const count = LINK_LABEL_COLORS.length;
23
+ return LINK_LABEL_COLORS[((stableIndex(link) % count) + count) % count] ?? null;
24
+ }
@@ -4,6 +4,7 @@ export const STUDIO_PLAIN_KEYS = {
4
4
  split: "s",
5
5
  record: "r",
6
6
  addKeyframe: "k",
7
+ audioGain: "g",
7
8
  } as const;
8
9
 
9
10
  export interface ShortcutHint {
@@ -51,6 +52,7 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
51
52
  { key: "⌘V", label: "Paste element" },
52
53
  { key: "⌘X", label: "Cut element" },
53
54
  { key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
55
+ { key: hintKey(STUDIO_PLAIN_KEYS.audioGain), label: "Audio Gain for clips with sound" },
54
56
  { key: "⇧Click", label: "Razor tool: split all tracks" },
55
57
  { key: "[", label: "Select clips starting before the playhead" },
56
58
  { key: "]", label: "Select clips running at or after the playhead" },
@@ -39,6 +39,14 @@ export interface TimelineDropCallbacks {
39
39
  ) => Promise<void> | void;
40
40
  }
41
41
 
42
+ export type TimelineLinkEdit =
43
+ | { kind: "unlink"; elements: readonly TimelineElement[] }
44
+ | { kind: "link"; elements: readonly TimelineElement[] }
45
+ | { kind: "detach"; element: TimelineElement }
46
+ | { kind: "merge"; video: TimelineElement; audio: TimelineElement }
47
+ | { kind: "move-into-sync"; element: TimelineElement; start: number }
48
+ | { kind: "slip-into-sync"; element: TimelineElement; mediaStart: number };
49
+
42
50
  export interface TimelineEditCallbacks {
43
51
  onMoveElement?: (
44
52
  element: TimelineElement,
@@ -114,11 +122,20 @@ export interface TimelineEditCallbacks {
114
122
  value: string | null,
115
123
  label: string,
116
124
  ) => Promise<TimelineEditOutcome | void>;
125
+ /** One attribute on several clips, saved as one undo step. */
126
+ onSetElementsAttributeQuiet?: (
127
+ edits: ReadonlyArray<{ element: TimelineElement; value: string | null }>,
128
+ attr: string,
129
+ label: string,
130
+ ) => Promise<TimelineEditOutcome | void>;
117
131
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
132
+ onLinkEdit?: (edit: TimelineLinkEdit) => Promise<void> | void;
133
+ onDeleteElementOnly?: (element: TimelineElement) => Promise<void> | void;
118
134
  onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
119
135
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
120
136
  onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
121
137
  onFreezeFrame?: (element: TimelineElement, time: number) => Promise<void> | void;
138
+ onNotice?: (message: string, tone?: "error" | "info") => void;
122
139
  onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
123
140
  onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
124
141
  onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
@@ -70,6 +70,7 @@ interface TimelineClipDragGestureLifecycleInput {
70
70
  onBlockedEditAttemptRef: RefObject<
71
71
  ((element: TimelineElement, intent: BlockedClipState["intent"]) => void) | undefined
72
72
  >;
73
+ onLinkEditRef: RefObject<TimelineEditCallbacks["onLinkEdit"]>;
73
74
  readZIndexRef: RefObject<((element: TimelineElement) => number) | undefined>;
74
75
  onStackingPatchesRef: RefObject<
75
76
  ((patches: StackingPatch[]) => Promise<unknown> | void) | undefined
@@ -107,6 +108,7 @@ export function mountTimelineClipDragGestureLifecycle({
107
108
  onResizeElementRef,
108
109
  onResizeElementsRef,
109
110
  onBlockedEditAttemptRef,
111
+ onLinkEditRef,
110
112
  readZIndexRef,
111
113
  onStackingPatchesRef,
112
114
  refreshAfterLaneMoveRef,
@@ -228,6 +230,11 @@ export function mountTimelineClipDragGestureLifecycle({
228
230
  if (drag && pointerMatchesGesture(event)) handleDragPointerMove(event, drag);
229
231
  };
230
232
 
233
+ const unlinkAltEdited = (gesture: { element: TimelineElement; altKey?: boolean }) => {
234
+ if (!gesture.altKey || !gesture.element.link) return;
235
+ void onLinkEditRef.current?.({ kind: "unlink", elements: [gesture.element] });
236
+ };
237
+
231
238
  const commitResizePointerUp = (
232
239
  resize: ResizingClipState,
233
240
  groupSession: TimelineGroupResizeSession | null,
@@ -271,6 +278,7 @@ export function mountTimelineClipDragGestureLifecycle({
271
278
  ]);
272
279
  console.error("[Timeline] Failed to persist clip resize", error);
273
280
  });
281
+ unlinkAltEdited(resize);
274
282
  };
275
283
 
276
284
  const finishBlockedPointerUp = (blocked: BlockedClipState) => {
@@ -293,6 +301,7 @@ export function mountTimelineClipDragGestureLifecycle({
293
301
  onStackingPatches: onStackingPatchesRef.current,
294
302
  refreshAfterLaneMove: refreshAfterLaneMoveRef.current,
295
303
  });
304
+ unlinkAltEdited(drag);
296
305
  };
297
306
 
298
307
  /** Group gestures commit atomically: one missing member cancels the whole resize. */
@@ -726,3 +726,99 @@ describe("a nested clip's drop stops at its host's start in the preview", () =>
726
726
  expect(await saved(early, 1)).toBe(1);
727
727
  });
728
728
  });
729
+
730
+ describe("an audio clip stays inside its partner video", () => {
731
+ const video = (extra: Partial<TimelineElement> = {}) => ({
732
+ ...clip("v", 0, 10, 20, 1),
733
+ syncOrigin: "lk-1",
734
+ ...extra,
735
+ });
736
+ const audio = (extra: Partial<TimelineElement> = {}) => ({
737
+ ...clip("a", 1, 12, 8, 0, "audio"),
738
+ syncOrigin: "lk-1",
739
+ ...extra,
740
+ });
741
+ const audioCtx = (elements: TimelineElement[], selected: string[] = []) => ({
742
+ ...ctx(undefined, elements),
743
+ trackOrder: [0, 1],
744
+ audioTracks: new Set([1]),
745
+ selectedKeys: new Set(selected),
746
+ });
747
+
748
+ it("a drag past the video's end stops with the audio's end on it", () => {
749
+ const a = audio();
750
+ const { drag, clientX, clientY } = horizontalDrag(a, 1.5, 30);
751
+ const next = computeDragPreview(drag, clientX, clientY, audioCtx([video(), a]));
752
+ expect(next.previewStart).toBe(22);
753
+ expect(next.pointerClientX).toBe(drag.originClientX + 10 * PPS);
754
+ });
755
+
756
+ it("a drag before the video's start stops at it, linked or same-source", () => {
757
+ const linked = audio({ syncOrigin: undefined, link: "lk-9" });
758
+ const { drag, clientX, clientY } = horizontalDrag(linked, 1.5, -10);
759
+ const elements = [video({ syncOrigin: undefined, link: "lk-9" }), linked];
760
+ expect(computeDragPreview(drag, clientX, clientY, audioCtx(elements)).previewStart).toBe(10);
761
+ });
762
+
763
+ it("moves freely with no partner video, or when the video moves with it", () => {
764
+ const loose = audio({ syncOrigin: undefined });
765
+ const free = horizontalDrag(loose, 1.5, 30);
766
+ expect(
767
+ computeDragPreview(free.drag, free.clientX, free.clientY, audioCtx([video(), loose]))
768
+ .previewStart,
769
+ ).toBe(42);
770
+ const a = audio();
771
+ const both = horizontalDrag(a, 1.5, 30);
772
+ expect(
773
+ computeDragPreview(both.drag, both.clientX, both.clientY, audioCtx([video(), a], ["v", "a"]))
774
+ .previewStart,
775
+ ).toBe(42);
776
+ });
777
+
778
+ it("rejects a drop that collision placement would push outside the video", () => {
779
+ const a = audio();
780
+ const obstacle = clip("o", 1, 20, 10, 0, "audio");
781
+ const { drag, clientX, clientY } = horizontalDrag(a, 1.5, 10);
782
+ const next = computeDragPreview(drag, clientX, clientY, audioCtx([video(), a, obstacle]));
783
+ expect(next.previewStart).toBeGreaterThanOrEqual(10);
784
+ expect(next.previewStart + a.duration).toBeLessThanOrEqual(30);
785
+ });
786
+
787
+ it("keeps an audio carried in a multi-selection inside its video", () => {
788
+ const a = audio();
789
+ const title = clip("t", 0, 40, 4, 0, "text");
790
+ const { drag, clientX, clientY } = horizontalDrag(title, 0.5, 30);
791
+ const elements = [video(), a, title];
792
+ const next = computeDragPreview(drag, clientX, clientY, audioCtx(elements, ["t", "a"]));
793
+ expect(next.previewStart).toBe(50);
794
+ });
795
+
796
+ const trim = (edge: "start" | "end", deltaSeconds: number, gestureKeys: string[] = []) =>
797
+ computeResizePreview(
798
+ {
799
+ element: audio({ sourceDuration: 100, playbackStart: 20 }),
800
+ edge,
801
+ originClientX: 0,
802
+ previewStart: 12,
803
+ previewDuration: 8,
804
+ started: true,
805
+ },
806
+ deltaSeconds * 100,
807
+ {
808
+ scroll: fakeScroll(),
809
+ pps: 100,
810
+ buildSnapTargets: () => [],
811
+ elements: [video(), audio()],
812
+ gestureKeys: new Set(["a", ...gestureKeys]),
813
+ },
814
+ );
815
+
816
+ it("a trim cannot extend past the video's start or end", () => {
817
+ expect(trim("end", 50)).toMatchObject({ previewStart: 12, previewDuration: 18 });
818
+ expect(trim("start", -10)).toMatchObject({ previewStart: 10, previewDuration: 10 });
819
+ });
820
+
821
+ it("a trim carried by the video too is not held to the video's old span", () => {
822
+ expect(trim("end", 50, ["v"]).previewDuration).toBe(58);
823
+ });
824
+ });
@@ -23,6 +23,7 @@ import {
23
23
  import { groupMoveFloor, resolveGroupMovers } from "./timelineMultiDragPreview";
24
24
  import type { DraggedClipState, ResizingClipState } from "./timelineClipDragTypes";
25
25
  import { STUDIO_PREVIEW_FPS } from "../lib/time";
26
+ import { heldAudioShiftRange, heldPartnerVideoBounds } from "./audioClipLink";
26
27
 
27
28
  /** Snap-target builder closure supplied by the hook (closes over refs + store). */
28
29
  type BuildSnapTargets = (
@@ -111,6 +112,7 @@ interface GroupDrag {
111
112
  obstacles: TimelineElement[];
112
113
  /** Lowest start the grabbed clip may take (see groupMoveFloor). */
113
114
  floor: number;
115
+ moving: ReadonlySet<string>;
114
116
  }
115
117
 
116
118
  function resolveGroupDrag(drag: DraggedClipState, ctx: DragPreviewContext): GroupDrag {
@@ -119,14 +121,20 @@ function resolveGroupDrag(drag: DraggedClipState, ctx: DragPreviewContext): Grou
119
121
  ctx.selectedKeys,
120
122
  drag.element.key ?? drag.element.id,
121
123
  );
122
- if (!movers) return { obstacles: ctx.elements, floor: groupMoveFloor(drag.element, []) };
124
+ if (!movers) {
125
+ const moving = new Set([drag.element.key ?? drag.element.id]);
126
+ return { obstacles: ctx.elements, floor: groupMoveFloor(drag.element, []), moving };
127
+ }
123
128
  const moving = new Set(movers.map((e) => e.key ?? e.id));
124
129
  return {
125
130
  obstacles: ctx.elements.filter((e) => !moving.has(e.key ?? e.id)),
126
131
  floor: groupMoveFloor(drag.element, movers),
132
+ moving,
127
133
  };
128
134
  }
129
135
 
136
+ const SHIFT_EPSILON_S = 1e-6;
137
+
130
138
  /** Recompute the dragged-clip preview (move + snap + group clamp + drop placement). */
131
139
  export function computeDragPreview(
132
140
  drag: DraggedClipState,
@@ -179,13 +187,25 @@ export function computeDragPreview(
179
187
  );
180
188
  // A group moves rigidly: the grabbed clip stops where any mover would cross its host's start.
181
189
  const group = resolveGroupDrag(drag, ctx);
182
- const previewStart = Math.max(snap.start, group.floor);
190
+ const dragKey = drag.element.key ?? drag.element.id;
191
+ const passengers = ctx.elements.filter(
192
+ (el) => (el.key ?? el.id) !== dragKey && group.moving.has(el.key ?? el.id),
193
+ );
194
+ const movers = [drag.element, ...passengers];
195
+ const shift = heldAudioShiftRange(movers, ctx.elements, group.moving);
196
+ const origin = drag.element.start;
197
+ const floored = Math.max(snap.start, group.floor);
198
+ const previewStart = origin + Math.max(shift.min, Math.min(floored - origin, shift.max));
183
199
  const placement = resolveDropPlacement(drag, clientY, previewStart, nextMove.track, ctx, group);
200
+ const placedShift = placement.start - origin;
201
+ if (placedShift < shift.min - SHIFT_EPSILON_S || placedShift > shift.max + SHIFT_EPSILON_S) {
202
+ return { ...drag, started: true };
203
+ }
184
204
  const { track: previewTrack, insertRow } = placement;
185
205
  return {
186
206
  ...drag,
187
207
  started: true,
188
- pointerClientX: clientX,
208
+ pointerClientX: clientX - (floored - previewStart) * pps,
189
209
  pointerClientY: clientY,
190
210
  previewStart: placement.start,
191
211
  previewTrack,
@@ -210,6 +230,8 @@ export interface ResizePreviewContext {
210
230
  scroll: HTMLDivElement | null;
211
231
  pps: number;
212
232
  buildSnapTargets: BuildSnapTargets;
233
+ elements?: readonly TimelineElement[];
234
+ gestureKeys?: ReadonlySet<string>;
213
235
  }
214
236
 
215
237
  export interface ResizePreviewResult {
@@ -253,14 +275,18 @@ export function computeResizePreview(
253
275
  // rightward and, after a far move, collapsed a clip to the sliver between its
254
276
  // start and the comp end (the 8s→0.95s audio incident). Images/text/shapes
255
277
  // have no source, so they extend freely.
256
- const maxEnd = resize.element.start + sourceRemaining;
278
+ const video = ctx.elements
279
+ ? heldPartnerVideoBounds(resize.element, ctx.elements, ctx.gestureKeys ?? new Set())
280
+ : null;
281
+ const minStart = Math.max(clampToHostStart(resize.element, 0), video?.start ?? 0);
282
+ const maxEnd = Math.min(resize.element.start + sourceRemaining, video?.end ?? Infinity);
257
283
  let nextResize = resolveTimelineResize(
258
284
  {
259
285
  start: resize.element.start,
260
286
  duration: resize.element.duration,
261
287
  originClientX: resize.originClientX,
262
288
  pixelsPerSecond: pps,
263
- minStart: clampToHostStart(resize.element, 0),
289
+ minStart,
264
290
  maxEnd,
265
291
  playbackStart:
266
292
  resize.edge === "start" && canSeedPlaybackStart
@@ -303,8 +329,7 @@ export function computeResizePreview(
303
329
  const { time: snapped, target } = snapTimelineTime(nextResize.start, trimTargets, snapSecs);
304
330
  const clip = { ...nextResize, playbackRate: resize.element.playbackRate };
305
331
  const delta = snapped - nextResize.start;
306
- const floor = clampToHostStart(resize.element, 0);
307
- const bounds = clipStartTrimDeltaBounds(clip, floor, resolveTimelineMinDuration());
332
+ const bounds = clipStartTrimDeltaBounds(clip, minStart, resolveTimelineMinDuration());
308
333
  if (target && delta >= bounds.minDelta - 1e-6 && delta <= bounds.maxDelta + 1e-6) {
309
334
  if (snapped !== nextResize.start) nextResize = applyClipStartTrimDelta(clip, delta);
310
335
  snap = target;
@@ -33,6 +33,7 @@ export interface DraggedClipState {
33
33
  snapTime: number | null;
34
34
  snapType: TimelineSnapType | null;
35
35
  started: boolean;
36
+ altKey?: boolean;
36
37
  }
37
38
 
38
39
  export interface ResizingClipState {
@@ -63,6 +64,7 @@ export interface ResizingClipState {
63
64
  playbackStart?: number;
64
65
  }[];
65
66
  started: boolean;
67
+ altKey?: boolean;
66
68
  }
67
69
 
68
70
  export interface BlockedClipState {
@@ -8,6 +8,7 @@ import {
8
8
  import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
9
9
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
10
10
  import { CLIP_HANDLE_W } from "./timelineLayout";
11
+ import { selectClipWithLinks, toggleClipWithLinks } from "./timelineLinkSelection";
11
12
  import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
12
13
 
13
14
  export interface ClipGestureDeps {
@@ -17,11 +18,6 @@ export interface ClipGestureDeps {
17
18
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
18
19
  onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
19
20
  blockedClipRef: React.RefObject<BlockedClipState | null>;
20
- shiftClickClipRef: React.RefObject<{
21
- element: TimelineElement;
22
- anchorX: number;
23
- anchorY: number;
24
- } | null>;
25
21
  suppressClickRef: React.RefObject<boolean>;
26
22
  scrollRef: React.RefObject<HTMLDivElement | null>;
27
23
  setShowPopover(v: boolean): void;
@@ -44,9 +40,11 @@ function canStartResize(
44
40
  return capabilities.canTrimEnd;
45
41
  }
46
42
 
43
+ const isAdditiveClick = (e: { shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }) =>
44
+ e.shiftKey || e.metaKey || e.ctrlKey;
45
+
47
46
  type PointerDownAction =
48
47
  | { kind: "ignore" }
49
- | { kind: "arm-shift-click" }
50
48
  | { kind: "block"; intent: BlockedTimelineEditIntent; rect: DOMRect }
51
49
  | { kind: "move"; rect: DOMRect };
52
50
 
@@ -81,7 +79,7 @@ function resolvePointerDownAction(
81
79
  ): PointerDownAction {
82
80
  if (e.button !== 0) return { kind: "ignore" };
83
81
  if (usePlayerStore.getState().activeTool === "razor") return { kind: "ignore" };
84
- if (e.shiftKey) return { kind: "arm-shift-click" };
82
+ if (isAdditiveClick(e)) return { kind: "ignore" };
85
83
 
86
84
  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
87
85
  const intent = resolveBlockedTimelineEditIntent({
@@ -118,7 +116,6 @@ export function createClipGestureHandlers(
118
116
  onRazorSplit,
119
117
  onRazorSplitAll,
120
118
  blockedClipRef,
121
- shiftClickClipRef,
122
119
  suppressClickRef,
123
120
  scrollRef,
124
121
  setShowPopover,
@@ -145,6 +142,7 @@ export function createClipGestureHandlers(
145
142
  previewDuration: el.duration,
146
143
  previewPlaybackStart: el.playbackStart,
147
144
  started: false,
145
+ altKey: e.altKey,
148
146
  });
149
147
  };
150
148
 
@@ -152,11 +150,6 @@ export function createClipGestureHandlers(
152
150
  const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
153
151
  if (action.kind === "ignore") return;
154
152
 
155
- if (action.kind === "arm-shift-click") {
156
- shiftClickClipRef.current = { element: el, anchorX: e.clientX, anchorY: e.clientY };
157
- return;
158
- }
159
-
160
153
  if (action.kind === "block") {
161
154
  blockedClipRef.current = {
162
155
  pointerId: e.pointerId,
@@ -191,33 +184,38 @@ export function createClipGestureHandlers(
191
184
  snapTime: null,
192
185
  snapType: null,
193
186
  started: false,
187
+ altKey: e.altKey,
194
188
  });
195
189
  };
196
190
 
191
+ const razorSplit = (e: ReactMouseEvent, split: NonNullable<typeof onRazorSplit>): void => {
192
+ const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
193
+ const splitTime = previewElement.start + (e.clientX - clipRect.left) / pps;
194
+ const clampedTime = Math.max(
195
+ previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
196
+ Math.min(
197
+ previewElement.start + previewElement.duration - SPLIT_BOUNDARY_EPSILON_S,
198
+ splitTime,
199
+ ),
200
+ );
201
+ if (e.shiftKey && onRazorSplitAll) onRazorSplitAll(clampedTime);
202
+ else split(el, clampedTime);
203
+ };
204
+
197
205
  const onClick = (e: ReactMouseEvent): void => {
198
206
  e.stopPropagation();
199
207
  if (suppressClickRef.current) return;
200
- const { activeTool } = usePlayerStore.getState();
201
- if (activeTool === "razor" && onRazorSplit) {
202
- const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
203
- const clickOffsetX = e.clientX - clipRect.left;
204
- const splitTime = previewElement.start + clickOffsetX / pps;
205
- const clampedTime = Math.max(
206
- previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
207
- Math.min(
208
- previewElement.start + previewElement.duration - SPLIT_BOUNDARY_EPSILON_S,
209
- splitTime,
210
- ),
211
- );
212
- if (e.shiftKey && onRazorSplitAll) {
213
- onRazorSplitAll(clampedTime);
214
- } else {
215
- onRazorSplit(el, clampedTime);
216
- }
208
+ if (usePlayerStore.getState().activeTool === "razor" && onRazorSplit) {
209
+ razorSplit(e, onRazorSplit);
210
+ return;
211
+ }
212
+ if (isAdditiveClick(e)) {
213
+ const primary = toggleClipWithLinks(elementKey, e.altKey);
214
+ onSelectElement?.(primary);
217
215
  return;
218
216
  }
219
217
  // Clip selection is idempotent; empty timeline space owns deselection.
220
- setSelectedElementId(elementKey);
218
+ selectClipWithLinks(elementKey, e.altKey, setSelectedElementId);
221
219
  onSelectElement?.(el);
222
220
  };
223
221
 
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
- buildClipRangeSelection,
4
3
  buildPromptCopyText,
5
4
  buildTimelineElementAgentPrompt,
6
5
  buildTimelineAgentPrompt,
@@ -607,18 +606,6 @@ describe("resolveBlockedTimelineEditIntent", () => {
607
606
  });
608
607
  });
609
608
 
610
- describe("buildClipRangeSelection", () => {
611
- it("anchors the full clip range at the click position", () => {
612
- expect(
613
- buildClipRangeSelection({ start: 1.25, duration: 3.5 }, { anchorX: 320, anchorY: 180 }),
614
- ).toEqual({
615
- start: 1.25,
616
- end: 4.75,
617
- anchorX: 320,
618
- anchorY: 180,
619
- });
620
- });
621
- });
622
609
  describe("resolveTimelineAutoScroll", () => {
623
610
  it("does not scroll when the pointer stays away from the edges", () => {
624
611
  expect(
@@ -382,17 +382,6 @@ export function resolveBlockedTimelineEditIntent(input: {
382
382
  return "move";
383
383
  }
384
384
 
385
- export function buildClipRangeSelection(
386
- clip: { start: number; duration: number },
387
- anchor: { anchorX: number; anchorY: number },
388
- ): TimelineRangeSelection {
389
- return {
390
- start: clip.start,
391
- end: clip.start + clip.duration,
392
- anchorX: anchor.anchorX,
393
- anchorY: anchor.anchorY,
394
- };
395
- }
396
385
  export function buildTimelineAgentPrompt({
397
386
  rangeStart,
398
387
  rangeEnd,
@@ -66,11 +66,6 @@ export interface TimelineLaneBaseProps {
66
66
  setResizingClip: (v: ResizingClipState | null) => void;
67
67
  setDraggedClip: (v: DraggedClipState | null) => void;
68
68
  setSelectedElementId: (id: string | null) => void;
69
- shiftClickClipRef: React.RefObject<{
70
- element: TimelineElement;
71
- anchorX: number;
72
- anchorY: number;
73
- } | null>;
74
69
  getPreviewElement: (element: TimelineElement) => TimelineElement;
75
70
  getTrackStyle: (tag: string) => TrackVisualStyle;
76
71
  keyframeCache?: Map<string, KeyframeCacheEntry>;
@@ -0,0 +1,41 @@
1
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
2
+ import { expandToLinkedMembers, linkedMembersOf } from "./audioClipLink";
3
+ import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
4
+
5
+ /**
6
+ * Select `key` the way a clip click does, then widen to link partners; Alt
7
+ * (or Linked Selection off) selects `key` alone, even inside a larger selection.
8
+ */
9
+ export function selectClipWithLinks(
10
+ key: string,
11
+ altKey: boolean,
12
+ setSelectedElementId: (id: string) => void,
13
+ ): void {
14
+ const state = usePlayerStore.getState();
15
+ if (altKey || !isLinkedSelectionOn()) {
16
+ state.setSelection([key], key);
17
+ return;
18
+ }
19
+ setSelectedElementId(key);
20
+ const { selectedElementIds, elements } = usePlayerStore.getState();
21
+ const expanded = expandToLinkedMembers(selectedElementIds, elements);
22
+ if (expanded.size > selectedElementIds.size) state.setSelection(expanded, key);
23
+ }
24
+
25
+ export function toggleClipWithLinks(key: string, altKey: boolean): TimelineElement | null {
26
+ const state = usePlayerStore.getState();
27
+ const element = state.elements.find((el) => (el.key ?? el.id) === key);
28
+ const members = element
29
+ ? linkedMembersOf(element, state.elements, !altKey && isLinkedSelectionOn())
30
+ : [];
31
+ const memberKeys = members.length > 0 ? members.map((el) => el.key ?? el.id) : [key];
32
+ const next = new Set(state.selectedElementIds);
33
+ const removing = next.has(key);
34
+ for (const memberKey of memberKeys) {
35
+ if (removing) next.delete(memberKey);
36
+ else next.add(memberKey);
37
+ }
38
+ state.setSelection(next, removing ? state.selectedElementId : key);
39
+ const primary = usePlayerStore.getState().selectedElementId;
40
+ return state.elements.find((el) => (el.key ?? el.id) === primary) ?? null;
41
+ }
@@ -40,7 +40,10 @@ export function getTimelineScrollTopForGeometryChange(
40
40
  previous: TimelineRowGeometry,
41
41
  next: TimelineRowGeometry,
42
42
  scrollTop: number,
43
+ dragAutoScrollOwnsScrollTop = false,
43
44
  ): number {
45
+ const atTopSoRowsAddedAboveStayShown = scrollTop <= 0;
46
+ if (dragAutoScrollOwnsScrollTop || atTopSoRowsAddedAboveStayShown) return scrollTop;
44
47
  const anchor = previous.getRowPositionFromY(scrollTop + RULER_H);
45
48
  if (anchor.row < 0 || anchor.row >= previous.rowKeys.length) return scrollTop;
46
49
  const anchorKey = previous.rowKeys[anchor.row];
@@ -11,14 +11,11 @@ import { committedClipPath } from "../../components/editor/cropPresetStore";
11
11
  import { isAudibleVideoNode } from "../lib/timelineElementHelpers";
12
12
  import type { ClipToolState } from "./clipToolAttrs";
13
13
 
14
- const LINK_ATTR = "data-link";
15
-
16
14
  const WATCHED_ATTRS = [
17
15
  HF_AUDIO_FX_ATTR,
18
16
  HF_AUDIO_AUTOMATION_ATTR,
19
17
  HF_AUDIO_CARVE_ATTR,
20
18
  HF_COLOR_GRADING_ATTR,
21
- LINK_ATTR,
22
19
  "data-volume",
23
20
  "data-has-audio",
24
21
  "muted",
@@ -42,7 +39,6 @@ function toolStateFromNode(node: Element): ClipToolState {
42
39
  colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR),
43
40
  clipPath: committedClipPath(node),
44
41
  carve: node.getAttribute(HF_AUDIO_CARVE_ATTR),
45
- link: node.getAttribute(LINK_ATTR),
46
42
  };
47
43
  }
48
44
 
@@ -58,7 +54,6 @@ function toolStateFromElement(el: TimelineElement): ClipToolState {
58
54
  colorGrading: null,
59
55
  clipPath: null,
60
56
  carve: null,
61
- link: null,
62
57
  };
63
58
  }
64
59
 
@@ -1,6 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
3
  import type { ClipContextMenuState } from "./TimelineProvider";
4
+ import { selectClipWithLinks } from "./timelineLinkSelection";
4
5
 
5
6
  /** A clicked member of an active multi-selection keeps the whole group
6
7
  * selected, so the context menu it opens acts on all of it. */
@@ -16,7 +17,7 @@ export function useClipContextMenu(
16
17
  e.preventDefault();
17
18
  const id = el.key ?? el.id;
18
19
  if (!(selectedElementIds.size > 1 && selectedElementIds.has(id))) {
19
- setSelectedElementId(id);
20
+ selectClipWithLinks(id, e.altKey, setSelectedElementId);
20
21
  onSelectElement?.(el);
21
22
  }
22
23
  dismissGapMenu();