@hyperframes/studio 0.7.55 → 0.7.57

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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -1,3 +1,5 @@
1
+ // @vitest-environment happy-dom
2
+
1
3
  import { describe, expect, it, beforeEach } from "vitest";
2
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
5
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
@@ -5,6 +7,7 @@ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
5
7
  import {
6
8
  commitStaticGsapPosition,
7
9
  commitStaticGsapRotation,
10
+ commitStaticGsapSize,
8
11
  findExistingPositionWrite,
9
12
  parkPlayheadOnKeyframe,
10
13
  type GsapDragCommitCallbacks,
@@ -280,7 +283,7 @@ const existingRotationSet = (): GsapAnimation =>
280
283
  describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
281
284
  beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
282
285
 
283
- it("attaches an instantPatch to BOTH coalesced commits, each derived from its own mutation", async () => {
286
+ it("updates an existing set atomically and derives its instantPatch from that mutation", async () => {
284
287
  const { commits, callbacks } = optionRecordingCallbacks();
285
288
 
286
289
  await commitStaticGsapPosition(
@@ -292,69 +295,122 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
292
295
  callbacks,
293
296
  );
294
297
 
295
- expect(commits).toHaveLength(2);
296
- // First (x) commit is the intermediate skipReload one — it now carries an
297
- // instantPatch for just {x}, so if the SECOND POST fails the preview still
298
- // reflects the x that DID persist (no reload, instant feedback).
299
- expect(commits[0].options.skipReload).toBe(true);
300
- expect(commits[0].options.instantPatch).toEqual({
301
- selector: "#puck-a",
302
- change: { kind: "set", props: { x: -50 } },
298
+ expect(commits).toHaveLength(1);
299
+ expect(commits[0].mutation).toEqual({
300
+ type: "update-properties",
301
+ animationId: "#puck-a-set",
302
+ properties: { x: -50, y: 30 },
303
303
  });
304
- // Final (y) commit triggers the reload and carries the full {x,y} patch.
305
- expect(commits[1].options.softReload).toBe(true);
306
- expect(commits[1].options.instantPatch).toEqual({
304
+ expect(commits[0].options.softReload).toBe(true);
305
+ expect(commits[0].options.instantPatch).toEqual({
307
306
  selector: "#puck-a",
308
307
  change: { kind: "set", props: { x: -50, y: 30 } },
309
308
  });
309
+ const mutation = commits[0].mutation as { properties: Record<string, number> };
310
+ const patch = commits[0].options.instantPatch as {
311
+ change: { props: Record<string, number> };
312
+ };
313
+ expect(patch.change.props).toEqual(mutation.properties);
310
314
  });
311
315
 
312
- it("derives each instantPatch's props from the value in the SAME mutation that's POSTed", async () => {
316
+ it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
313
317
  const { commits, callbacks } = optionRecordingCallbacks();
314
318
 
315
319
  await commitStaticGsapPosition(
316
320
  selection(),
317
321
  { x: -50, y: 30 },
322
+ // fallow-ignore-next-line code-duplication
318
323
  { x: 0, y: 0 },
319
324
  "#puck-a",
320
- existingPositionSet(),
325
+ null, // no existing set → `add` a new base gsap.set
321
326
  callbacks,
322
327
  );
323
328
 
324
- // The patch values must equal the mutation values — they're read out of the
325
- // same object, so a clean mutation can't ship alongside a stale patch.
326
- const xMutation = commits[0].mutation as { property: string; value: number };
327
- const yMutation = commits[1].mutation as { property: string; value: number };
328
- const xPatch = commits[0].options.instantPatch as {
329
- change: { props: Record<string, number> };
330
- };
331
- const yPatch = commits[1].options.instantPatch as {
332
- change: { props: Record<string, number> };
333
- };
334
- expect(xPatch.change.props[xMutation.property]).toBe(xMutation.value);
335
- expect(yPatch.change.props[yMutation.property]).toBe(yMutation.value);
336
- // The y commit's combined patch also carries the x mutation's value.
337
- expect(yPatch.change.props[xMutation.property]).toBe(xMutation.value);
329
+ expect(commits).toHaveLength(1);
330
+ expect(commits[0].mutation.type).toBe("add");
331
+ expect((commits[0].mutation as { global?: boolean }).global).toBe(true);
332
+ const patch = commits[0].options.instantPatch as { change: { kind: string } } | undefined;
333
+ expect(patch?.change.kind).toBe("global-set");
338
334
  });
339
335
 
340
- it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
336
+ it("creates one undo entry when updating an existing static position set", async () => {
341
337
  const { commits, callbacks } = optionRecordingCallbacks();
342
338
 
343
339
  await commitStaticGsapPosition(
344
340
  selection(),
345
341
  { x: -50, y: 30 },
346
- // fallow-ignore-next-line code-duplication
347
342
  { x: 0, y: 0 },
348
343
  "#puck-a",
349
- null, // no existing set → `add` a new base gsap.set
344
+ existingPositionSet(),
345
+ callbacks,
346
+ );
347
+
348
+ expect(commits).toHaveLength(1);
349
+ });
350
+ });
351
+
352
+ const existingSizeSet = (): GsapAnimation =>
353
+ ({
354
+ id: "#puck-a-size-set",
355
+ targetSelector: "#puck-a",
356
+ method: "set",
357
+ properties: { width: 100, height: 80 },
358
+ }) as unknown as GsapAnimation;
359
+
360
+ describe("commitStaticGsapSize", () => {
361
+ it("updates an existing set with one update-properties mutation", async () => {
362
+ const { commits, callbacks } = optionRecordingCallbacks();
363
+
364
+ await commitStaticGsapSize(
365
+ selection(),
366
+ { width: 300.4, height: 199.6 },
367
+ "#puck-a",
368
+ existingSizeSet(),
369
+ callbacks,
370
+ );
371
+
372
+ expect(commits).toHaveLength(1);
373
+ expect(commits[0].mutation).toEqual({
374
+ type: "update-properties",
375
+ animationId: "#puck-a-size-set",
376
+ properties: { width: 300, height: 200 },
377
+ });
378
+ expect(commits.map((commit) => commit.mutation.type)).not.toContain("delete");
379
+ expect(commits.map((commit) => commit.mutation.type)).not.toContain("add");
380
+ });
381
+
382
+ it("adds exactly one set when no existing size set exists", async () => {
383
+ const { commits, callbacks } = optionRecordingCallbacks();
384
+
385
+ await commitStaticGsapSize(
386
+ selection(),
387
+ { width: 300, height: 200 },
388
+ "#puck-a",
389
+ null,
390
+ callbacks,
391
+ );
392
+
393
+ expect(commits).toHaveLength(1);
394
+ expect(commits[0].mutation).toMatchObject({
395
+ type: "add",
396
+ targetSelector: "#puck-a",
397
+ method: "set",
398
+ properties: { width: 300, height: 200 },
399
+ });
400
+ });
401
+
402
+ it("creates one undo entry for an existing static size set", async () => {
403
+ const { commits, callbacks } = optionRecordingCallbacks();
404
+
405
+ await commitStaticGsapSize(
406
+ selection(),
407
+ { width: 300, height: 200 },
408
+ "#puck-a",
409
+ existingSizeSet(),
350
410
  callbacks,
351
411
  );
352
412
 
353
413
  expect(commits).toHaveLength(1);
354
- expect(commits[0].mutation.type).toBe("add");
355
- expect((commits[0].mutation as { global?: boolean }).global).toBe(true);
356
- const patch = commits[0].options.instantPatch as { change: { kind: string } } | undefined;
357
- expect(patch?.change.kind).toBe("global-set");
358
414
  });
359
415
  });
360
416
 
@@ -386,7 +442,7 @@ describe("static position hold recognition + heal (frozen duration-0 keyframed t
386
442
  expect(found?.id).toBe("#puck-a-frozen");
387
443
  });
388
444
 
389
- it("commitStaticGsapPosition heals a keyframed hold by delete + clean add-set (never update-property)", async () => {
445
+ it("heals a keyframed hold with one transaction ordered add before delete", async () => {
390
446
  const { commits, callbacks } = optionRecordingCallbacks();
391
447
 
392
448
  await commitStaticGsapPosition(
@@ -399,11 +455,55 @@ describe("static position hold recognition + heal (frozen duration-0 keyframed t
399
455
  );
400
456
 
401
457
  const types = commits.map((c) => c.mutation.type);
402
- // Can't update-property into keyframes — must delete the frozen tween and
403
- // write a clean static set, so the element becomes freely movable.
404
- expect(types).toEqual(["delete", "add"]);
405
- expect(types).not.toContain("update-property");
406
- expect((commits[1].mutation as { method?: string }).method).toBe("set");
458
+ expect(types).toEqual(["add", "delete"]);
459
+ expect(types).not.toContain("update-properties");
460
+ expect((commits[0].mutation as { method?: string }).method).toBe("set");
461
+ expect(commits[0].options.coalesceKey).toBe(commits[1].options.coalesceKey);
462
+ expect(commits[0].options.coalesceKey).toMatch(/^tx:Move layer:\d+$/);
463
+ });
464
+
465
+ it("keeps the original hold when the replacement add fails", async () => {
466
+ const mutationTypes: string[] = [];
467
+ const callbacks: GsapDragCommitCallbacks = {
468
+ commitMutation: async (_selection, mutation) => {
469
+ mutationTypes.push(mutation.type as string);
470
+ throw new Error("add failed");
471
+ },
472
+ };
473
+
474
+ await expect(
475
+ commitStaticGsapPosition(
476
+ selection(),
477
+ { x: -50, y: 30 },
478
+ { x: 0, y: 0 },
479
+ "#puck-a",
480
+ keyframedZeroDurationHold(),
481
+ callbacks,
482
+ ),
483
+ ).rejects.toThrow("add failed");
484
+ expect(mutationTypes).toEqual(["add"]);
485
+ });
486
+
487
+ it("leaves a recoverable duplicate when delete fails after the replacement add", async () => {
488
+ let holdCount = 1;
489
+ const callbacks: GsapDragCommitCallbacks = {
490
+ commitMutation: async (_selection, mutation) => {
491
+ if (mutation.type === "add") holdCount += 1;
492
+ if (mutation.type === "delete") throw new Error("delete failed");
493
+ },
494
+ };
495
+
496
+ await expect(
497
+ commitStaticGsapPosition(
498
+ selection(),
499
+ { x: -50, y: 30 },
500
+ { x: 0, y: 0 },
501
+ "#puck-a",
502
+ keyframedZeroDurationHold(),
503
+ callbacks,
504
+ ),
505
+ ).rejects.toThrow("delete failed");
506
+ expect(holdCount).toBe(2);
407
507
  });
408
508
  });
409
509
 
@@ -15,10 +15,8 @@ import { roundTo3 } from "../utils/rounding";
15
15
  import { computeElementPercentage } from "./gsapShared";
16
16
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
17
17
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
18
- import {
19
- setPatchFromUpdateProperties,
20
- setPatchFromUpdateProperty,
21
- } from "./gsapDragStaticSetHelpers";
18
+ import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
19
+ import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
22
20
  export {
23
21
  findExistingPositionWrite,
24
22
  findRotationSetAnimation,
@@ -65,6 +63,46 @@ export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
65
63
  usePlayerStore.getState().requestSeek(roundTo3(ts + (pct / 100) * td));
66
64
  }
67
65
 
66
+ async function replaceKeyframedPositionHold(
67
+ selection: DomEditSelection,
68
+ selector: string,
69
+ existingSet: GsapAnimation,
70
+ properties: { x: number; y: number },
71
+ commitMutation: GsapDragCommitCallbacks["commitMutation"],
72
+ ): Promise<void> {
73
+ const persist = async (commit: GsapDragCommitCallbacks["commitMutation"]) => {
74
+ await commit(
75
+ selection,
76
+ {
77
+ type: "add",
78
+ targetSelector: selector,
79
+ method: "set",
80
+ position: 0,
81
+ properties,
82
+ global: true,
83
+ },
84
+ { label: "Move layer", skipReload: true },
85
+ );
86
+ await commit(
87
+ selection,
88
+ { type: "delete", animationId: existingSet.id },
89
+ { label: "Move layer", softReload: true },
90
+ );
91
+ };
92
+
93
+ if (isGestureTransactionCommit(commitMutation)) {
94
+ await persist(commitMutation);
95
+ return;
96
+ }
97
+ await runGestureTransaction({
98
+ element: selection.element,
99
+ label: "Move layer",
100
+ settle: () => undefined,
101
+ persist: async (commit) => persist(commit(commitMutation)),
102
+ restore: () => undefined,
103
+ });
104
+ }
105
+
68
106
  // ── Dynamic keyframe materialization ──────────────────────────────────────
69
107
 
70
108
  export async function materializeIfDynamic(
@@ -116,7 +154,7 @@ export async function materializeIfDynamic(
116
154
  * Commit a STATIC element drag as a `tl.set("#el",{x,y})` — the single-source
117
155
  * position channel for elements with no position animation. Idempotent: a
118
156
  * re-nudge of an element that already has a `set` UPDATES that set's x/y
119
- * (two `update-property` mutations) rather than stacking a second set or
157
+ * in one `update-properties` mutation rather than stacking a second set or
120
158
  * converting it to keyframes (plan R2 / KTD3). New elements get one `add`
121
159
  * mutation with `method:"set"` at position 0.
122
160
  */
@@ -132,70 +170,30 @@ export async function commitStaticGsapPosition(
132
170
  if (existingSet) {
133
171
  if (existingSet.keyframes) {
134
172
  // Keyframed zero-duration hold (drag-path corruption): can't update-property
135
- // into keyframes delete it and write a clean static set instead.
136
- const coalesceKey = `gsap:heal-static:${existingSet.id}`;
137
- await callbacks.commitMutation(
173
+ // into keyframes. Add the replacement first so either failure leaves at
174
+ // least one hold on disk, then delete the corrupt tween in one transaction.
175
+ await replaceKeyframedPositionHold(
138
176
  selection,
139
- { type: "delete", animationId: existingSet.id },
140
- { label: "Move layer", skipReload: true, coalesceKey },
141
- );
142
- await callbacks.commitMutation(
143
- selection,
144
- {
145
- type: "add",
146
- targetSelector: selector,
147
- method: "set",
148
- position: 0,
149
- properties: { x: newX, y: newY },
150
- global: true,
151
- },
152
- {
153
- label: "Move layer",
154
- softReload: true,
155
- coalesceKey,
156
- instantPatch: { selector, change: { kind: "global-set", props: { x: newX, y: newY } } },
157
- },
177
+ selector,
178
+ existingSet,
179
+ { x: newX, y: newY },
180
+ callbacks.commitMutation,
158
181
  );
159
182
  return;
160
183
  }
161
- // Update in place — two single-property mutations (the API updates one prop
162
- // per call). Coalesce them and reload only after the second lands.
163
- const coalesceKey = `gsap:set-nudge:${existingSet.id}`;
164
- // Build each mutation FIRST, then derive its instantPatch from the SAME
165
- // object that's POSTed — so a future caller can't ship a clean mutation with
166
- // a stale/malformed patch (the validated `value` flows straight into the
167
- // patch). `findUnsafeMutationValues` validates the mutation upstream.
168
- const xMutation = {
169
- type: "update-property",
184
+ const mutation = {
185
+ type: "update-properties",
170
186
  animationId: existingSet.id,
171
- property: "x",
172
- value: newX,
173
- } as const;
174
- const yMutation = {
175
- type: "update-property",
176
- animationId: existingSet.id,
177
- property: "y",
178
- value: newY,
187
+ properties: { x: newX, y: newY },
179
188
  } as const;
180
- // Patch BOTH coalesced commits. If the SECOND POST fails server-side, the
181
- // first (x) already persisted — patching its commit too means the live
182
- // preview still reflects what DID persist. The x commit carries skipReload
183
- // (no reload), so its instantPatch gives instant feedback without a reload;
184
- // the y commit triggers the soft reload (skipped when the patch applies).
185
189
  const global = !!existingSet.global;
186
- await callbacks.commitMutation(selection, xMutation, {
187
- label: "Move layer",
188
- skipReload: true,
189
- coalesceKey,
190
- instantPatch: setPatchFromUpdateProperty(selector, xMutation, global),
191
- });
192
- await callbacks.commitMutation(selection, yMutation, {
190
+ await callbacks.commitMutation(selection, mutation, {
193
191
  label: "Move layer",
194
192
  softReload: true,
195
- coalesceKey,
196
- // Final commit of the coalesced x/y pair: carry both channels so the
197
- // runtime set lands the complete {x,y} pose in place.
198
- instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation], global),
193
+ instantPatch: {
194
+ selector,
195
+ change: { kind: global ? "global-set" : "set", props: mutation.properties },
196
+ },
199
197
  });
200
198
  return;
201
199
  }
@@ -279,7 +277,7 @@ export async function commitStaticGsapRotation(
279
277
  * tween: one keyframe at the playhead % renders NaN/0 at every other frame, so
280
278
  * the element collapses/disappears (worst when resized off the 0% mark). A `set`
281
279
  * holds the size at all times. Re-resizing an element that already has a size
282
- * `set` UPDATES it in place (two `update-property`, like x/y); a new element
280
+ * `set` UPDATES it in place with one `update-properties`; a new element
283
281
  * gets one `add` with `method:"set"`.
284
282
  */
285
283
  export async function commitStaticGsapSize(
@@ -292,18 +290,11 @@ export async function commitStaticGsapSize(
292
290
  const width = Math.round(size.width);
293
291
  const height = Math.round(size.height);
294
292
  if (existingSet) {
295
- await callbacks.commitMutation(
296
- selection,
297
- { type: "delete", animationId: existingSet.id },
298
- { label: "Resize layer", skipReload: true },
299
- );
300
293
  await callbacks.commitMutation(
301
294
  selection,
302
295
  {
303
- type: "add",
304
- targetSelector: selector,
305
- method: "set",
306
- position: 0,
296
+ type: "update-properties",
297
+ animationId: existingSet.id,
307
298
  properties: { width, height },
308
299
  },
309
300
  { label: "Resize layer", softReload: true },
@@ -394,10 +385,9 @@ export async function commitKeyframedSizeFromResize(
394
385
  properties: Math.abs(p - pct) < 0.05 ? { width: newW, height: newH } : { ...prior },
395
386
  }));
396
387
 
397
- // Add the size keyframe tween FIRST, then delete the old global hold. The two
398
- // commits aren't transactional, so ordering matters: if the delete fails the
399
- // size is preserved (animated, recoverable) rather than lost. Only the last
400
- // commit triggers the reload.
388
+ // Add the size keyframe tween FIRST, then delete the old global hold. The gesture
389
+ // transport applies both in one ordered batch; a plain commit fallback keeps the
390
+ // same recoverable ordering. Only the transaction's result triggers the reload.
401
391
  const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
402
392
  await callbacks.commitMutation(
403
393
  selection,
@@ -1,6 +1,6 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
- import { resolveTweenDuration } from "../utils/globalTimeCompiler";
3
2
  import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
3
+ import { isInstantHold } from "./gsapShared";
4
4
 
5
5
  /** The shape of an `update-property` mutation a static-set nudge POSTs. */
6
6
  interface UpdatePropertyMutation {
@@ -16,27 +16,17 @@ interface UpdatePropertyMutation {
16
16
  * value the source write didn't (one source of truth). Each mutation contributes
17
17
  * its `{property: value}` channel to the patch's props.
18
18
  */
19
- export function setPatchFromUpdateProperties(
19
+ export function setPatchFromUpdateProperty(
20
20
  selector: string,
21
- mutations: UpdatePropertyMutation[],
21
+ mutation: UpdatePropertyMutation,
22
22
  global = false,
23
23
  ): { selector: string; change: RuntimeTweenChange } {
24
- const props: SetPatchProps = {};
25
- for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
24
+ const props: SetPatchProps = { [mutation.property as keyof SetPatchProps]: mutation.value };
26
25
  // An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
27
26
  // element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
28
27
  return { selector, change: { kind: global ? "global-set" : "set", props } };
29
28
  }
30
29
 
31
- /** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
32
- export function setPatchFromUpdateProperty(
33
- selector: string,
34
- mutation: UpdatePropertyMutation,
35
- global = false,
36
- ): { selector: string; change: RuntimeTweenChange } {
37
- return setPatchFromUpdateProperties(selector, [mutation], global);
38
- }
39
-
40
30
  /**
41
31
  * Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
42
32
  * with no duration. This is what a static-element nudge writes/updates.
@@ -61,7 +51,8 @@ function findPositionSetAnimation(
61
51
  * remove-all-keyframes leaves behind) is a held position too, and the next drag
62
52
  * must UPDATE it in place rather than append a second `gsap.set` that fights it
63
53
  * (the duplicate-position-write bug). Only zero-duration holds qualify — a
64
- * live-duration `to`/`from` is NOT a static hold (and in the static path it's a
54
+ * live-duration tween and a duration-zero `from` are NOT static holds (and in
55
+ * the static path they're a
65
56
  * stale/phantom parse: re-committing it would resurrect a just-deleted tween).
66
57
  * A keyframed zero-duration `to` is ALSO a static hold (a drag-path corruption
67
58
  * artifact) and must be recognized so the static commit normalizes it.
@@ -75,10 +66,7 @@ export function findExistingPositionWrite(
75
66
  if (set) return set;
76
67
  return (
77
68
  animations.find(
78
- (a) =>
79
- a.targetSelector === selector &&
80
- a.propertyGroup === "position" &&
81
- resolveTweenDuration(a) === 0,
69
+ (a) => a.targetSelector === selector && a.propertyGroup === "position" && isInstantHold(a),
82
70
  ) ?? null
83
71
  );
84
72
  }
@@ -89,7 +77,7 @@ export function findRotationSetAnimation(
89
77
  ): GsapAnimation | null {
90
78
  return (
91
79
  animations.find(
92
- (a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties,
80
+ (a) => isInstantHold(a) && a.targetSelector === selector && "rotation" in a.properties,
93
81
  ) ?? null
94
82
  );
95
83
  }
@@ -101,7 +89,7 @@ export function findSizeSetAnimation(
101
89
  return (
102
90
  animations.find(
103
91
  (a) =>
104
- a.method === "set" &&
92
+ isInstantHold(a) &&
105
93
  a.targetSelector === selector &&
106
94
  ("width" in a.properties || "height" in a.properties),
107
95
  ) ?? null
@@ -3,6 +3,7 @@ import { afterEach, expect, it, vi } from "vitest";
3
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
5
  import { usePlayerStore } from "../player/store/playerStore";
6
+ import { computeCurrentPercentage } from "./gsapDragCommit";
6
7
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
7
8
 
8
9
  afterEach(() => {
@@ -69,6 +70,74 @@ function keyframedScaleFixture(): GsapAnimation {
69
70
  } as unknown as GsapAnimation;
70
71
  }
71
72
 
73
+ it("updates a duration-zero size hold in place instead of converting it to keyframes", async () => {
74
+ const el = document.createElement("div");
75
+ el.id = "box";
76
+ document.body.append(el);
77
+ const selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
78
+ const instantSizeHold = {
79
+ id: "#box-to-0-size",
80
+ targetSelector: "#box",
81
+ propertyGroup: "size",
82
+ method: "to",
83
+ properties: { width: 150, height: 150 },
84
+ position: 0,
85
+ resolvedStart: 0,
86
+ duration: 0,
87
+ extras: { immediateRender: "__raw:true" },
88
+ } as unknown as GsapAnimation;
89
+ const commitMutation = vi.fn();
90
+
91
+ const handled = await tryGsapResizeIntercept(
92
+ selection,
93
+ { width: 344, height: 344 },
94
+ [instantSizeHold],
95
+ null,
96
+ commitMutation,
97
+ );
98
+
99
+ expect(handled).toBe(true);
100
+ expect(commitMutation).toHaveBeenCalledTimes(1);
101
+ expect(commitMutation.mock.calls[0]![1]).toEqual({
102
+ type: "update-properties",
103
+ animationId: "#box-to-0-size",
104
+ properties: { width: 344, height: 344 },
105
+ });
106
+ expect(commitMutation).not.toHaveBeenCalledWith(
107
+ expect.anything(),
108
+ expect.objectContaining({ type: "convert-to-keyframes" }),
109
+ expect.anything(),
110
+ );
111
+ expect(commitMutation).not.toHaveBeenCalledWith(
112
+ expect.anything(),
113
+ expect.objectContaining({ type: "add-keyframe" }),
114
+ expect.anything(),
115
+ );
116
+ });
117
+
118
+ it("computes a finite zero percentage for a zero-duration tween", () => {
119
+ const animation = {
120
+ id: "#box-to-0-size",
121
+ targetSelector: "#box",
122
+ propertyGroup: "size",
123
+ method: "to",
124
+ properties: { width: 150 },
125
+ resolvedStart: 1,
126
+ duration: 0,
127
+ } as unknown as GsapAnimation;
128
+ const selection = {
129
+ id: "box",
130
+ selector: "#box",
131
+ element: document.createElement("div"),
132
+ } as DomEditSelection;
133
+ usePlayerStore.setState({ currentTime: 5 });
134
+
135
+ const percentage = computeCurrentPercentage(selection, animation);
136
+
137
+ expect(Number.isFinite(percentage)).toBe(true);
138
+ expect(percentage).toBe(0);
139
+ });
140
+
72
141
  /** Drive one resize through the intercept, returning every committed mutation. */
73
142
  async function runResize(
74
143
  el: HTMLElement,