@hyperframes/studio 0.7.54 → 0.7.56

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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,126 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import {
4
+ buildTimelineGroupResizeMembers,
5
+ resolveTimelineGroupResize,
6
+ resolveTimelineGroupResizeChanges,
7
+ } from "./timelineGroupEditing";
8
+
9
+ function el(id: string, over: Partial<TimelineElement> = {}): TimelineElement {
10
+ // domId gives a patchable target so getTimelineEditCapabilities().canTrim* is true.
11
+ return { id, key: id, tag: "video", start: 0, duration: 2, track: 0, domId: id, ...over };
12
+ }
13
+
14
+ function keys(...ids: string[]): ReadonlySet<string> {
15
+ return new Set(ids);
16
+ }
17
+
18
+ describe("buildTimelineGroupResizeMembers (legacy 36413da7f semantics)", () => {
19
+ it("returns null for a single-clip selection (no group forms)", () => {
20
+ const a = el("a");
21
+ expect(buildTimelineGroupResizeMembers([a], keys("a"), "a", "end")).toBeNull();
22
+ expect(buildTimelineGroupResizeMembers([a], new Set(), "a", "end")).toBeNull();
23
+ });
24
+
25
+ it("returns null when the grabbed clip is not part of the selection", () => {
26
+ const a = el("a");
27
+ const b = el("b");
28
+ expect(buildTimelineGroupResizeMembers([a, b], keys("a", "b"), "c", "end")).toBeNull();
29
+ });
30
+
31
+ it("degrades to single-clip (null) when ANY member is locked — locked clip never patched", () => {
32
+ const a = el("a");
33
+ const locked = el("b", { timelineLocked: true });
34
+ expect(buildTimelineGroupResizeMembers([a, locked], keys("a", "b"), "a", "end")).toBeNull();
35
+ });
36
+
37
+ it("degrades when a member is implicitly timed or has no patch target", () => {
38
+ const a = el("a");
39
+ const implicit = el("b", { timingSource: "implicit" });
40
+ const noTarget = el("c", { domId: undefined, selector: undefined });
41
+ expect(buildTimelineGroupResizeMembers([a, implicit], keys("a", "b"), "a", "end")).toBeNull();
42
+ expect(buildTimelineGroupResizeMembers([a, noTarget], keys("a", "c"), "a", "end")).toBeNull();
43
+ });
44
+
45
+ it("snapshots members and seeds start-edge media playbackStart to 0", () => {
46
+ const grabbed = el("a", { start: 2, duration: 2 });
47
+ const audio = el("b", { tag: "audio", start: 5, duration: 3 }); // playbackStart undefined
48
+ const members = buildTimelineGroupResizeMembers([grabbed, audio], keys("a", "b"), "a", "start");
49
+ expect(members).not.toBeNull();
50
+ expect(members!.map((m) => [m.key, m.start, m.duration, m.playbackStart])).toEqual([
51
+ ["a", 2, 2, 0], // start-edge media seeds playbackStart to 0
52
+ ["b", 5, 3, 0],
53
+ ]);
54
+ });
55
+
56
+ it("does not seed playbackStart on the END edge", () => {
57
+ const grabbed = el("a", { tag: "audio", start: 0, duration: 2 });
58
+ const b = el("b", { tag: "audio", start: 3, duration: 2 });
59
+ const members = buildTimelineGroupResizeMembers([grabbed, b], keys("a", "b"), "a", "end");
60
+ expect(members!.every((m) => m.playbackStart === undefined)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("resolveTimelineGroupResizeChanges (rigid group patch set)", () => {
65
+ it("END edge: extends every member's duration by the shared delta", () => {
66
+ const members = buildTimelineGroupResizeMembers(
67
+ [el("a", { duration: 2 }), el("b", { duration: 3 })],
68
+ keys("a", "b"),
69
+ "a",
70
+ "end",
71
+ )!;
72
+ const changes = resolveTimelineGroupResizeChanges(members, "end", 0.5);
73
+ expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
74
+ ["a", 0, 2.5],
75
+ ["b", 0, 3.5],
76
+ ]);
77
+ });
78
+
79
+ it("END edge: rigid — the most-constrained member clamps the whole group", () => {
80
+ // b (0.2s) can only shrink 0.1 before hitting min duration, so the group as a
81
+ // whole shrinks 0.1 even though the grabbed clip asked for -0.5.
82
+ const members = buildTimelineGroupResizeMembers(
83
+ [el("a", { duration: 2 }), el("b", { duration: 0.2 })],
84
+ keys("a", "b"),
85
+ "a",
86
+ "end",
87
+ )!;
88
+ const changes = resolveTimelineGroupResizeChanges(members, "end", -0.5);
89
+ expect(changes.map((c) => c.duration)).toEqual([1.9, 0.1]);
90
+ });
91
+
92
+ it("START edge: shifts start + duration together across the group", () => {
93
+ const members = buildTimelineGroupResizeMembers(
94
+ [
95
+ el("a", { tag: "text", start: 2, duration: 2 }),
96
+ el("b", { tag: "text", start: 5, duration: 3 }),
97
+ ],
98
+ keys("a", "b"),
99
+ "a",
100
+ "start",
101
+ )!;
102
+ const changes = resolveTimelineGroupResizeChanges(members, "start", -0.5);
103
+ expect(changes.map((c) => [c.key, c.start, c.duration])).toEqual([
104
+ ["a", 1.5, 2.5],
105
+ ["b", 4.5, 3.5],
106
+ ]);
107
+ });
108
+
109
+ it("produces exactly the resolveTimelineGroupResize output, keyed per member", () => {
110
+ const members = buildTimelineGroupResizeMembers(
111
+ [el("a", { duration: 2 }), el("b", { duration: 4 })],
112
+ keys("a", "b"),
113
+ "a",
114
+ "end",
115
+ )!;
116
+ const raw = resolveTimelineGroupResize(members, "end", 0.75);
117
+ const changes = resolveTimelineGroupResizeChanges(members, "end", 0.75);
118
+ expect(
119
+ changes.map((c) => ({
120
+ start: c.start,
121
+ duration: c.duration,
122
+ playbackStart: c.playbackStart,
123
+ })),
124
+ ).toEqual(raw.members);
125
+ });
126
+ });
@@ -1,4 +1,6 @@
1
1
  import { roundToCenti } from "../../utils/rounding";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
2
4
 
3
5
  const DEFAULT_TIMELINE_MIN_DURATION = 0.1;
4
6
  const ABSOLUTE_TIMELINE_MIN_DURATION = 0.05;
@@ -157,3 +159,134 @@ export function resolveTimelineGroupResize(
157
159
  }),
158
160
  };
159
161
  }
162
+
163
+ /* ── Multi-select group resize wiring (restored from main 36413da7f) ───────── */
164
+
165
+ /** A selected clip snapshot captured at group-resize gesture start. */
166
+ export interface TimelineGroupResizeMember extends TimelineGroupTimingMember {
167
+ element: TimelineElement;
168
+ key: string;
169
+ }
170
+
171
+ /** Per-member timing patch produced by a group resize (grabbed clip included). */
172
+ export interface TimelineGroupResizeChange {
173
+ element: TimelineElement;
174
+ key: string;
175
+ start: number;
176
+ duration: number;
177
+ playbackStart?: number;
178
+ }
179
+
180
+ function elementKey(element: TimelineElement): string {
181
+ return element.key ?? element.id;
182
+ }
183
+
184
+ function isMediaTimelineElement(element: TimelineElement): boolean {
185
+ const tag = element.tag.toLowerCase();
186
+ return tag === "audio" || tag === "video";
187
+ }
188
+
189
+ function canTrimEdge(element: TimelineElement, edge: TimelineGroupResizeEdge): boolean {
190
+ const caps = getTimelineEditCapabilities({
191
+ tag: element.tag,
192
+ duration: element.duration,
193
+ domId: element.domId,
194
+ selector: element.selector,
195
+ compositionSrc: element.compositionSrc,
196
+ playbackStart: element.playbackStart,
197
+ playbackStartAttr: element.playbackStartAttr,
198
+ sourceDuration: element.sourceDuration,
199
+ timingSource: element.timingSource,
200
+ timelineLocked: element.timelineLocked,
201
+ });
202
+ return edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
203
+ }
204
+
205
+ /**
206
+ * Snapshot the members for a group resize, or `null` when the gesture must stay a
207
+ * single-clip resize. Legacy semantics (main 36413da7f): a group forms only when
208
+ * the grabbed clip is part of a multi-selection (> 1) AND EVERY selected member
209
+ * can take THIS edge's trim. It is all-or-nothing — if any member is locked or
210
+ * implicitly-timed the group does not form, so a locked member is never patched
211
+ * (the grabbed clip then resizes on its own). Start-edge media members seed
212
+ * `playbackStart` to 0 so the in-point trim math has a base, mirroring the legacy
213
+ * `resizeMember`.
214
+ */
215
+ export function buildTimelineGroupResizeMembers(
216
+ elements: readonly TimelineElement[],
217
+ selectedKeys: ReadonlySet<string>,
218
+ grabbedKey: string,
219
+ edge: TimelineGroupResizeEdge,
220
+ ): TimelineGroupResizeMember[] | null {
221
+ if (selectedKeys.size <= 1 || !selectedKeys.has(grabbedKey)) return null;
222
+ const selected = elements.filter((element) => selectedKeys.has(elementKey(element)));
223
+ if (selected.length <= 1) return null;
224
+ if (!selected.every((element) => canTrimEdge(element, edge))) return null;
225
+ return selected.map((element) => ({
226
+ element,
227
+ key: elementKey(element),
228
+ start: element.start,
229
+ duration: element.duration,
230
+ playbackStart:
231
+ edge === "start" && isMediaTimelineElement(element)
232
+ ? (element.playbackStart ?? 0)
233
+ : element.playbackStart,
234
+ playbackRate: element.playbackRate,
235
+ }));
236
+ }
237
+
238
+ /**
239
+ * Resolve the per-member timing patches for a group resize from the grabbed
240
+ * clip's raw edge delta. Rigid group: one shared delta, clamped by the most
241
+ * constrained member (see resolveTimelineGroupResize), applied to every member.
242
+ */
243
+ export function resolveTimelineGroupResizeChanges(
244
+ members: readonly TimelineGroupResizeMember[],
245
+ edge: TimelineGroupResizeEdge,
246
+ rawDelta: number,
247
+ minDuration = resolveTimelineMinDuration(),
248
+ ): TimelineGroupResizeChange[] {
249
+ const result = resolveTimelineGroupResize(members, edge, rawDelta, minDuration);
250
+ return result.members.map((member, index) => ({
251
+ element: members[index]!.element,
252
+ key: members[index]!.key,
253
+ start: member.start,
254
+ duration: member.duration,
255
+ playbackStart: member.playbackStart,
256
+ }));
257
+ }
258
+
259
+ /** In-flight multi-select group-resize gesture (owned by the drag hook). */
260
+ export interface TimelineGroupResizeSession {
261
+ grabbedKey: string;
262
+ edge: TimelineGroupResizeEdge;
263
+ members: TimelineGroupResizeMember[];
264
+ changes: TimelineGroupResizeChange[];
265
+ hasChanged: boolean;
266
+ }
267
+
268
+ /**
269
+ * Fold the grabbed clip's single-clip preview into the group: derive the raw
270
+ * edge delta from it, resolve the rigid per-member changes onto the session
271
+ * (updating `changes` + `hasChanged`), and return the grabbed clip's own change
272
+ * so the caller can render it from the resize state.
273
+ */
274
+ export function applyTimelineGroupResizePreview(
275
+ session: TimelineGroupResizeSession,
276
+ grabbedPreview: { previewStart: number; previewDuration: number },
277
+ ): TimelineGroupResizeChange | undefined {
278
+ const grabbed = session.members.find((m) => m.key === session.grabbedKey);
279
+ const rawDelta =
280
+ session.edge === "start"
281
+ ? grabbedPreview.previewStart - (grabbed?.start ?? grabbedPreview.previewStart)
282
+ : grabbedPreview.previewDuration - (grabbed?.duration ?? grabbedPreview.previewDuration);
283
+ const changes = resolveTimelineGroupResizeChanges(session.members, session.edge, rawDelta);
284
+ session.changes = changes;
285
+ session.hasChanged = changes.some(
286
+ (c, i) =>
287
+ c.start !== session.members[i]!.start ||
288
+ c.duration !== session.members[i]!.duration ||
289
+ c.playbackStart !== session.members[i]!.playbackStart,
290
+ );
291
+ return changes.find((c) => c.key === session.grabbedKey);
292
+ }
@@ -0,0 +1,54 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
3
+ import type { TimelineGroupResizeSession } from "./timelineGroupEditing";
4
+ import {
5
+ beginTimelineOptimisticGesture,
6
+ rollbackLatestTimelineOptimisticGesture,
7
+ } from "./timelineOptimisticRevision";
8
+
9
+ export function commitTimelineGroupResize(
10
+ session: TimelineGroupResizeSession,
11
+ updateElement: (key: string, updates: Partial<TimelineElement>) => void,
12
+ persist: TimelineEditCallbacks["onResizeElements"],
13
+ ): void {
14
+ if (!session.hasChanged) return;
15
+ const changes = session.changes;
16
+ const revision = beginTimelineOptimisticGesture(
17
+ updateElement,
18
+ changes.map((change) => change.key),
19
+ );
20
+ for (const change of changes) {
21
+ updateElement(change.key, {
22
+ start: change.start,
23
+ duration: change.duration,
24
+ playbackStart: change.playbackStart,
25
+ });
26
+ }
27
+ if (!persist) {
28
+ rollbackLatestTimelineOptimisticGesture(
29
+ updateElement,
30
+ revision,
31
+ session.members.map((member) => ({ key: member.key, updates: member })),
32
+ );
33
+ return;
34
+ }
35
+ const coalesceKey = `clip-group-resize:${changes.map((change) => change.key).join(":")}`;
36
+ Promise.resolve(
37
+ persist(
38
+ changes.map((change) => ({
39
+ element: change.element,
40
+ start: change.start,
41
+ duration: change.duration,
42
+ playbackStart: change.playbackStart,
43
+ })),
44
+ { coalesceKey },
45
+ ),
46
+ ).catch((error) => {
47
+ rollbackLatestTimelineOptimisticGesture(
48
+ updateElement,
49
+ revision,
50
+ session.members.map((member) => ({ key: member.key, updates: member })),
51
+ );
52
+ console.error("[Timeline] Failed to persist group clip resize", error);
53
+ });
54
+ }
@@ -1,34 +1,106 @@
1
- import { describe, expect, it } from "vitest";
2
- import { computeTimelineBasisDuration, computeTimelineEffectiveDuration } from "./timelineLayout";
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ RULER_H,
4
+ TRACK_H,
5
+ TRACKS_TOP_PAD,
6
+ TRACKS_BOTTOM_PAD,
7
+ GUTTER,
8
+ getTimelineRowTop,
9
+ getTimelineRowFromY,
10
+ getTimelineCanvasHeight,
11
+ resolveTimelineAssetDrop,
12
+ } from "./timelineLayout";
3
13
 
4
- describe("computeTimelineBasisDuration", () => {
5
- it("uses the root duration when it exceeds every clip end", () => {
6
- expect(computeTimelineBasisDuration(12, [4, 6, 9])).toBe(12);
7
- });
14
+ describe("track-area breathing pad y-math", () => {
15
+ describe("getTimelineRowTop", () => {
16
+ it("offsets the first lane below the ruler by the top pad", () => {
17
+ expect(getTimelineRowTop(0)).toBe(RULER_H + TRACKS_TOP_PAD);
18
+ });
8
19
 
9
- it("grows to the furthest committed clip end past the root duration", () => {
10
- expect(computeTimelineBasisDuration(12, [4, 18, 9])).toBe(18);
11
- });
20
+ it("advances by one track height per row, keeping the pad", () => {
21
+ expect(getTimelineRowTop(1)).toBe(RULER_H + TRACKS_TOP_PAD + TRACK_H);
22
+ expect(getTimelineRowTop(3)).toBe(RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H);
23
+ });
12
24
 
13
- it("falls back to the root duration with no clips / non-finite ends", () => {
14
- expect(computeTimelineBasisDuration(10, [])).toBe(10);
15
- expect(computeTimelineBasisDuration(Number.NaN, [])).toBe(0);
25
+ it("is a strict positive shift from the pre-pad formula (pad is non-zero)", () => {
26
+ expect(TRACKS_TOP_PAD).toBeGreaterThan(0);
27
+ expect(getTimelineRowTop(2)).toBe(RULER_H + 2 * TRACK_H + TRACKS_TOP_PAD);
28
+ });
16
29
  });
17
- });
18
30
 
19
- describe("computeTimelineEffectiveDuration", () => {
20
- it("returns the basis when there is no active preview", () => {
21
- expect(computeTimelineEffectiveDuration(12, [null, null])).toBe(12);
31
+ describe("getTimelineRowFromY", () => {
32
+ it("is the exact inverse of getTimelineRowTop at lane boundaries", () => {
33
+ for (const row of [0, 1, 2, 7]) {
34
+ expect(getTimelineRowFromY(getTimelineRowTop(row))).toBeCloseTo(row, 10);
35
+ }
36
+ });
37
+
38
+ it("floors a y inside the top pad (above lane 0) to a negative fraction", () => {
39
+ // A drop in the pad between the ruler and lane 0 sits at row < 0, so a
40
+ // floor lands it on row -1 → getDefaultDroppedTrack floors to the top lane.
41
+ const yInPad = RULER_H + TRACKS_TOP_PAD / 2;
42
+ expect(getTimelineRowFromY(yInPad)).toBeLessThan(0);
43
+ });
44
+
45
+ it("maps a y in the middle of lane 1 into [1,2)", () => {
46
+ const yMidLane1 = getTimelineRowTop(1) + TRACK_H / 2;
47
+ const row = getTimelineRowFromY(yMidLane1);
48
+ expect(row).toBeGreaterThanOrEqual(1);
49
+ expect(row).toBeLessThan(2);
50
+ });
22
51
  });
23
52
 
24
- it("extends to a drag/resize preview end beyond the basis", () => {
25
- expect(computeTimelineEffectiveDuration(12, [20, null])).toBe(20);
26
- expect(computeTimelineEffectiveDuration(12, [null, 16])).toBe(16);
53
+ describe("getTimelineCanvasHeight", () => {
54
+ it("reserves ruler + top pad + lanes + bottom pad", () => {
55
+ expect(getTimelineCanvasHeight(0)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
56
+ expect(getTimelineCanvasHeight(3)).toBe(
57
+ RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + TRACKS_BOTTOM_PAD,
58
+ );
59
+ });
60
+
61
+ it("clamps a negative track count to zero lanes", () => {
62
+ expect(getTimelineCanvasHeight(-4)).toBe(RULER_H + TRACKS_TOP_PAD + TRACKS_BOTTOM_PAD);
63
+ });
64
+
65
+ it("leaves room below the last lane for a drag-into-void new track", () => {
66
+ // The gap below the final lane must be at least a full track height so a
67
+ // clip can be dropped there to create a new bottom track.
68
+ const oneLane = getTimelineCanvasHeight(1);
69
+ const lastLaneBottom = getTimelineRowTop(0) + TRACK_H;
70
+ expect(oneLane - lastLaneBottom).toBeGreaterThanOrEqual(TRACK_H);
71
+ });
27
72
  });
28
73
 
29
- it("never shrinks below the basis for a preview inside the current length", () => {
30
- // The invariant behind the jump fix: the basis (which drives zoom) is
31
- // independent of the preview, and a smaller preview end can't reduce it.
32
- expect(computeTimelineEffectiveDuration(12, [8])).toBe(12);
74
+ describe("resolveTimelineAssetDrop honours the top pad", () => {
75
+ const base = {
76
+ rectLeft: 0,
77
+ rectTop: 0,
78
+ scrollLeft: 0,
79
+ scrollTop: 0,
80
+ pixelsPerSecond: 100,
81
+ duration: 60,
82
+ trackHeight: TRACK_H,
83
+ trackOrder: [0, 1, 2],
84
+ };
85
+
86
+ it("drops onto lane 0 when the pointer is in the middle of the first lane", () => {
87
+ const clientY = getTimelineRowTop(0) + TRACK_H / 2;
88
+ const clientX = GUTTER + 100; // t = 1s
89
+ const { start, track } = resolveTimelineAssetDrop(base, clientX, clientY);
90
+ expect(track).toBe(0);
91
+ expect(start).toBe(1);
92
+ });
93
+
94
+ it("drops into the top pad → floors to the first lane (row < 0)", () => {
95
+ const clientY = RULER_H + TRACKS_TOP_PAD / 2; // inside the pad, above lane 0
96
+ const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
97
+ expect(track).toBe(0);
98
+ });
99
+
100
+ it("drops below the last lane → appends a new track", () => {
101
+ const clientY = getTimelineRowTop(2) + TRACK_H + 4; // in the bottom pad
102
+ const { track } = resolveTimelineAssetDrop(base, GUTTER, clientY);
103
+ expect(track).toBe(3); // max(trackOrder)+1
104
+ });
33
105
  });
34
106
  });