@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
@@ -24,10 +24,11 @@ import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
24
24
  import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
25
25
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
26
26
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
27
- import { selectorFromSelection } from "./gsapShared";
27
+ import { isInstantHold, selectorFromSelection } from "./gsapShared";
28
28
  import { roundTo3 } from "../utils/rounding";
29
29
  import { resolveGroupTween, POSITION_CHANNELS } from "./gsapRuntimeBridge";
30
30
  import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
31
+ import { logResize } from "../utils/resizeDebug";
31
32
 
32
33
  const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
33
34
 
@@ -68,19 +69,27 @@ export async function tryGsapResizeIntercept(
68
69
  );
69
70
 
70
71
  let anim = resolved?.anim ?? null;
71
- if (!anim || anim.method === "set") {
72
+ logResize("intercept-enter", {
73
+ hasScaleGroup,
74
+ resizeGroup,
75
+ animMethod: anim?.method ?? null,
76
+ animId: anim?.id ?? null,
77
+ size,
78
+ });
79
+ if (!anim || isInstantHold(anim)) {
72
80
  const sel = selectorFromSelection(selection);
73
81
  if (!sel) return false;
74
- const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
82
+ const sizeSet = anim ?? findSizeSetAnimation(animations, sel);
75
83
 
76
84
  // If the element is animated (has a real tween, not just a static size
77
85
  // hold), keyframe the size at the playhead so other keyframes keep theirs —
78
86
  // instead of a global set that resizes every frame.
79
87
  if (resizeGroup === "size") {
80
88
  const animatedTween = pickClosestToPlayhead(
81
- animations.filter((a) => a.method !== "set" && resolveTweenDuration(a) > 0),
89
+ animations.filter((a) => !isInstantHold(a) && resolveTweenDuration(a) > 0),
82
90
  );
83
91
  if (animatedTween) {
92
+ logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
84
93
  const handled = await commitKeyframedSizeFromResize(
85
94
  selection,
86
95
  size,
@@ -93,6 +102,7 @@ export async function tryGsapResizeIntercept(
93
102
  }
94
103
  }
95
104
 
105
+ logResize("intercept-route", { route: "static-size-set", hadSizeSet: !!sizeSet, resizeGroup });
96
106
  await commitStaticGsapSize(selection, size, sel, sizeSet, {
97
107
  commitMutation,
98
108
  fetchAnimations: fetchFallbackAnimations,
@@ -100,11 +110,12 @@ export async function tryGsapResizeIntercept(
100
110
  return true;
101
111
  }
102
112
 
113
+ const tweenDuration = resolveTweenDuration(anim);
114
+ if (tweenDuration <= 0) return false;
115
+
103
116
  const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
104
117
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
105
118
  if (activeKeyframePct != null) setActiveKeyframePct(null);
106
- const coalesceKey = `gsap:resize:${anim.id}`;
107
-
108
119
  const selector = selectorFromSelection(selection);
109
120
  // Scope every capture to the resize group — same contract as the rotation
110
121
  // intercept. Unfiltered, an opacity-touching intro tween on the element
@@ -144,6 +155,16 @@ export async function tryGsapResizeIntercept(
144
155
  // shorthand when the two agree (aspect-true drags, shift-drags).
145
156
  nonUniformScale = Math.abs(newScaleX - newScaleY) > 0.01;
146
157
  resizeProps = nonUniformScale ? { scaleX: newScaleX, scaleY: newScaleY } : { scale: newScaleX };
158
+ logResize("intercept-route", {
159
+ route: "scale-tween",
160
+ cssW,
161
+ cssH,
162
+ liveScaleX,
163
+ liveScaleY,
164
+ newScaleX,
165
+ newScaleY,
166
+ nonUniformScale,
167
+ });
147
168
  scaleDraftEl = el;
148
169
  // Where the user DROPPED the box: the draft (anchor-pinned to the
149
170
  // gesture-start top-left) is still applied here, so this rect is exactly
@@ -184,6 +205,7 @@ export async function tryGsapResizeIntercept(
184
205
  POSITION_CHANNELS,
185
206
  );
186
207
  if (hasLivePositionTween) {
208
+ logResize("scale-finalize", { skipped: "live-position-tween" });
187
209
  return;
188
210
  }
189
211
  // The scale commit has rendered (instant patch or soft-reload seek) and the
@@ -200,6 +222,13 @@ export async function tryGsapResizeIntercept(
200
222
  x: Math.round(gsapPos.x + residual.x),
201
223
  y: Math.round(gsapPos.y + residual.y),
202
224
  };
225
+ logResize("scale-finalize", {
226
+ dropPoint: scaleDraftDropPoint,
227
+ post: { x: post.x, y: post.y },
228
+ residual,
229
+ gsapPos,
230
+ corrected,
231
+ });
203
232
  // Correct the LIVE runtime NOW, synchronously: the soft reload above just
204
233
  // rendered the committed scale around the element center — NOT at the drop
205
234
  // point — and everything up to here runs in the same microtask chain as
@@ -249,7 +278,7 @@ export async function tryGsapResizeIntercept(
249
278
 
250
279
  const ct = usePlayerStore.getState().currentTime;
251
280
  const ts = resolveTweenStart(anim);
252
- const td = resolveTweenDuration(anim);
281
+ const td = tweenDuration;
253
282
  const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
254
283
  // Outside-range uses the extend path which handles everything atomically.
255
284
  if (!outsideRange) {
@@ -264,7 +293,7 @@ export async function tryGsapResizeIntercept(
264
293
  await commitMutation(
265
294
  selection,
266
295
  { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
267
- { label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
296
+ { label: "Convert to keyframes for resize" },
268
297
  );
269
298
  if (fetchFallbackAnimations) {
270
299
  const fresh = await fetchFallbackAnimations();
@@ -353,7 +382,6 @@ export async function tryGsapResizeIntercept(
353
382
  ? `Resize (extended to ${ct.toFixed(2)}s)`
354
383
  : `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
355
384
  softReload: true,
356
- coalesceKey,
357
385
  },
358
386
  );
359
387
  await finalizeScaleResizeCommit();
@@ -376,7 +404,7 @@ export async function tryGsapResizeIntercept(
376
404
  properties: resizeProps,
377
405
  backfillDefaults,
378
406
  },
379
- { label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey },
407
+ { label: `Resize (keyframe ${pct}%)`, softReload: true },
380
408
  );
381
409
  await finalizeScaleResizeCommit();
382
410
  return true;
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
- import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
4
+ import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
5
5
  import { usePlayerStore } from "../player/store/playerStore";
6
6
 
7
7
  /**
@@ -90,7 +90,7 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
90
90
  expect(mutation.type).not.toBe("add-keyframe");
91
91
  });
92
92
 
93
- it("forwards instantPatch on BOTH coalesced commits when updating an existing static set", async () => {
93
+ it("forwards one complete instantPatch when atomically updating an existing static set", async () => {
94
94
  const commitMutation = vi.fn();
95
95
  const iframe = fakeIframe("puck-b", []); // runtime empty → STATIC path
96
96
  // An existing position-hold `set` for the selector → update-in-place (not add).
@@ -113,16 +113,14 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
113
113
  );
114
114
 
115
115
  expect(handled).toBe(true);
116
- // The coalesced update-property pair both carry an instantPatch so a partial
117
- // (second-POST) failure still leaves the preview patched for what persisted:
118
- // the x commit patches {x}, the final y commit patches the full {x,y}.
119
- const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-property");
120
- expect(updates).toHaveLength(2);
121
- expect(updates[0][2].instantPatch).toEqual({
122
- selector: "#puck-b",
123
- change: { kind: "set", props: { x: -50 } },
116
+ const updates = commitMutation.mock.calls.filter(([, m]) => m.type === "update-properties");
117
+ expect(updates).toHaveLength(1);
118
+ expect(updates[0][1]).toEqual({
119
+ type: "update-properties",
120
+ animationId: "#puck-b-set",
121
+ properties: { x: -50, y: 30 },
124
122
  });
125
- expect(updates[1][2].instantPatch).toEqual({
123
+ expect(updates[0][2].instantPatch).toEqual({
126
124
  selector: "#puck-b",
127
125
  change: { kind: "set", props: { x: -50, y: 30 } },
128
126
  });
@@ -154,9 +152,9 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
154
152
  );
155
153
 
156
154
  expect(handled).toBe(true);
157
- // In-place update (2 coalesced update-property), NOT an `add`/`add-keyframe`.
155
+ // One atomic in-place update, NOT an `add`/`add-keyframe`.
158
156
  const types = commitMutation.mock.calls.map(([, m]) => m.type);
159
- expect(types.every((t: string) => t === "update-property")).toBe(true);
157
+ expect(types).toEqual(["update-properties"]);
160
158
  expect(types).not.toContain("add");
161
159
  expect(types).not.toContain("add-keyframe");
162
160
  });
@@ -180,6 +178,44 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
180
178
  });
181
179
  });
182
180
 
181
+ describe("tryGsapRotationIntercept — instant holds", () => {
182
+ it("updates a duration-zero fromTo hold instead of converting it to keyframes", async () => {
183
+ const rotationHold = {
184
+ id: "#puck-b-fromTo-0-rotation",
185
+ targetSelector: "#puck-b",
186
+ propertyGroup: "rotation",
187
+ method: "fromTo",
188
+ fromProperties: { rotation: 0 },
189
+ properties: { rotation: 30 },
190
+ position: 0,
191
+ resolvedStart: 0,
192
+ duration: 0,
193
+ } as unknown as GsapAnimation;
194
+ const commitMutation = vi.fn();
195
+
196
+ const handled = await tryGsapRotationIntercept(
197
+ selection,
198
+ 75,
199
+ [rotationHold],
200
+ null,
201
+ commitMutation,
202
+ );
203
+
204
+ expect(handled).toBe(true);
205
+ expect(commitMutation).toHaveBeenCalledTimes(1);
206
+ expect(commitMutation.mock.calls[0]![1]).toEqual({
207
+ type: "update-property",
208
+ animationId: rotationHold.id,
209
+ property: "rotation",
210
+ value: 75,
211
+ });
212
+ const types = commitMutation.mock.calls.map(([, mutation]) => mutation.type);
213
+ expect(types).not.toContain("convert-to-keyframes");
214
+ expect(types).not.toContain("add-keyframe");
215
+ expect(types).not.toContain("add");
216
+ });
217
+ });
218
+
183
219
  // Regression (#1808): with the global auto-keyframe toggle off, dragging an
184
220
  // element that already has a keyframed position tween must shift the whole
185
221
  // tween (a "replace-with-keyframes" carrying every original percentage) —
@@ -26,7 +26,7 @@ import {
26
26
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
27
27
  import { resolveTweenDuration } from "../utils/globalTimeCompiler";
28
28
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
29
- import { selectorFromSelection } from "./gsapShared";
29
+ import { isInstantHold, selectorFromSelection } from "./gsapShared";
30
30
  import {
31
31
  findGsapPositionAnimation,
32
32
  pickClosestToPlayhead,
@@ -211,7 +211,7 @@ export async function tryGsapDragIntercept(
211
211
  const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
212
212
  if (!hasNonHold && !hasKeyframedPosTween) {
213
213
  const existingSet =
214
- posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
214
+ posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
215
215
  ? posAnim
216
216
  : findExistingPositionWrite(resolvedAnimations, selector);
217
217
  await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
@@ -300,8 +300,8 @@ export async function tryGsapRotationIntercept(
300
300
  // mirroring the static position set. Idempotent: re-rotate updates an existing
301
301
  // rotation set in place, else add a new one. This replaces the old
302
302
  // `--hf-studio-rotation` CSS-var fallback (the same dual-channel bug class).
303
- if (!anim) {
304
- const existingSet = findRotationSetAnimation(resolvedAnimations, selector);
303
+ if (!anim || isInstantHold(anim)) {
304
+ const existingSet = anim ?? findRotationSetAnimation(resolvedAnimations, selector);
305
305
  await commitStaticGsapRotation(selection, newRotation, selector, existingSet, {
306
306
  commitMutation,
307
307
  fetchAnimations: fetchFallbackAnimations,
@@ -16,6 +16,7 @@ export interface MutationResult {
16
16
  export interface CommitMutationOptions {
17
17
  label: string;
18
18
  coalesceKey?: string;
19
+ coalesceMs?: number;
19
20
  softReload?: boolean;
20
21
  skipReload?: boolean;
21
22
  beforeReload?: () => void;
@@ -38,11 +39,20 @@ export interface CommitMutationOptions {
38
39
  instantPatch?: { selector: string; change: RuntimeTweenChange };
39
40
  }
40
41
 
41
- export type CommitMutation = (
42
- selection: DomEditSelection,
43
- mutation: Record<string, unknown>,
44
- options: CommitMutationOptions,
45
- ) => Promise<void>;
42
+ export interface CommitMutationCall {
43
+ selection: DomEditSelection;
44
+ mutation: Record<string, unknown>;
45
+ options: CommitMutationOptions;
46
+ }
47
+
48
+ export interface CommitMutation {
49
+ (
50
+ selection: DomEditSelection,
51
+ mutation: Record<string, unknown>,
52
+ options: CommitMutationOptions,
53
+ ): Promise<void>;
54
+ batch?: (calls: CommitMutationCall[], options: CommitMutationOptions) => Promise<void>;
55
+ }
46
56
 
47
57
  export type SafeGsapCommitMutation = (
48
58
  selection: DomEditSelection,
@@ -66,6 +76,7 @@ export interface GsapScriptCommitsParams {
66
76
  label: string;
67
77
  kind: EditHistoryKind;
68
78
  coalesceKey?: string;
79
+ coalesceMs?: number;
69
80
  files: Record<string, { before: string; after: string }>;
70
81
  }) => Promise<void>;
71
82
  };
@@ -1,5 +1,23 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { parsePercentageKeyframes } from "./gsapShared";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { isInstantHold, parsePercentageKeyframes } from "./gsapShared";
4
+
5
+ describe("isInstantHold", () => {
6
+ const animation = (method: GsapAnimation["method"], duration?: number) =>
7
+ ({ method, duration }) as unknown as GsapAnimation;
8
+
9
+ it("classifies set and duration-zero to/fromTo writes as instant holds", () => {
10
+ expect(isInstantHold(animation("set"))).toBe(true);
11
+ expect(isInstantHold(animation("to", 0))).toBe(true);
12
+ expect(isInstantHold(animation("fromTo", 0))).toBe(true);
13
+ });
14
+
15
+ it("does not classify live tweens or duration-zero from writes as instant holds", () => {
16
+ expect(isInstantHold(animation("to", 1))).toBe(false);
17
+ expect(isInstantHold(animation("fromTo"))).toBe(false);
18
+ expect(isInstantHold(animation("from", 0))).toBe(false);
19
+ });
20
+ });
3
21
 
4
22
  describe("parsePercentageKeyframes", () => {
5
23
  it("parses the object/percentage form", () => {
@@ -33,6 +33,19 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
33
33
  height: 100,
34
34
  };
35
35
 
36
+ /**
37
+ * A timeline write that applies an instantaneous value and then holds it.
38
+ * `set()` is always a hold; authored `to()` / `fromTo()` tweens are holds only
39
+ * when their resolved duration is exactly zero.
40
+ */
41
+ export function isInstantHold(animation: GsapAnimation): boolean {
42
+ return (
43
+ animation.method === "set" ||
44
+ ((animation.method === "to" || animation.method === "fromTo") &&
45
+ resolveTweenDuration(animation) === 0)
46
+ );
47
+ }
48
+
36
49
  // ── Selector resolution ───────────────────────────────────────────────────────
37
50
 
38
51
  /**
@@ -61,6 +74,7 @@ export function computeElementPercentage(
61
74
  if (animation) {
62
75
  const start = resolveTweenStart(animation);
63
76
  const duration = resolveTweenDuration(animation);
77
+ if (duration <= 0) return 0;
64
78
  if (start !== null) {
65
79
  return absoluteToPercentage(currentTime, start, duration);
66
80
  }
@@ -0,0 +1,71 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import { patchDocumentRootDuration } from "./timelineEditingHelpers";
4
+
5
+ // The regression these guard against: timing edits now soft-reload instead of
6
+ // full-reloading the iframe, so the runtime recomputes the composition length
7
+ // from the ROOT's `data-duration` and posts it back — overwriting the studio's
8
+ // optimistic duration readout. Patching the live root's `data-duration` in the
9
+ // same tick keeps the runtime's post-soft-reload report in agreement, so the
10
+ // readout stays live on grow AND shrink instead of snapping back.
11
+ function parse(html: string): Document {
12
+ return new DOMParser().parseFromString(html, "text/html");
13
+ }
14
+
15
+ describe("patchDocumentRootDuration", () => {
16
+ it("writes the content end onto the TOP-LEVEL composition root (grow)", () => {
17
+ const doc = parse(`
18
+ <div data-composition-id="root" data-duration="15.18">
19
+ <div data-composition-id="scene" data-start="0" data-duration="8"></div>
20
+ <div data-start="12" data-duration="8"></div>
21
+ </div>
22
+ `);
23
+ expect(patchDocumentRootDuration(doc, 20)).toBe(true);
24
+ const root = doc.querySelector('[data-composition-id="root"]');
25
+ expect(root?.getAttribute("data-duration")).toBe("20");
26
+ // Nested sub-composition is untouched — only the outermost root is the length.
27
+ expect(doc.querySelector('[data-composition-id="scene"]')?.getAttribute("data-duration")).toBe(
28
+ "8",
29
+ );
30
+ });
31
+
32
+ it("shrinks the root duration too (not grow-only)", () => {
33
+ const doc = parse(`<div data-composition-id="root" data-duration="15.18"></div>`);
34
+ expect(patchDocumentRootDuration(doc, 6.5)).toBe(true);
35
+ expect(doc.querySelector("[data-composition-id]")?.getAttribute("data-duration")).toBe("6.5");
36
+ });
37
+
38
+ it("picks the composition with no ancestor composition as the root", () => {
39
+ // Root appears AFTER a nested one in document order — selection must be by
40
+ // ancestry, not first-match, matching the runtime's own root resolver.
41
+ const doc = parse(`
42
+ <section data-composition-id="root" data-duration="10">
43
+ <div data-composition-id="inner" data-duration="4"></div>
44
+ </section>
45
+ `);
46
+ patchDocumentRootDuration(doc, 12);
47
+ expect(doc.querySelector('[data-composition-id="root"]')?.getAttribute("data-duration")).toBe(
48
+ "12",
49
+ );
50
+ expect(doc.querySelector('[data-composition-id="inner"]')?.getAttribute("data-duration")).toBe(
51
+ "4",
52
+ );
53
+ });
54
+
55
+ it("no-ops on a non-positive or non-finite content end (never collapses to 0)", () => {
56
+ const doc = parse(`<div data-composition-id="root" data-duration="15.18"></div>`);
57
+ expect(patchDocumentRootDuration(doc, 0)).toBe(false);
58
+ expect(patchDocumentRootDuration(doc, -3)).toBe(false);
59
+ expect(patchDocumentRootDuration(doc, Number.NaN)).toBe(false);
60
+ expect(doc.querySelector("[data-composition-id]")?.getAttribute("data-duration")).toBe("15.18");
61
+ });
62
+
63
+ it("no-ops when there is no composition root and reports false", () => {
64
+ const doc = parse(`<div data-start="0" data-duration="5"></div>`);
65
+ expect(patchDocumentRootDuration(doc, 10)).toBe(false);
66
+ });
67
+
68
+ it("returns false for a null document", () => {
69
+ expect(patchDocumentRootDuration(null, 10)).toBe(false);
70
+ });
71
+ });
@@ -0,0 +1,14 @@
1
+ import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
2
+
3
+ export function patchDocumentRootDuration(
4
+ doc: Document | null | undefined,
5
+ contentEnd: number,
6
+ ): boolean {
7
+ if (!doc || !Number.isFinite(contentEnd) || contentEnd <= 0) return false;
8
+ const nodes = Array.from(doc.querySelectorAll("[data-composition-id]"));
9
+ const root =
10
+ nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ?? nodes[0] ?? null;
11
+ if (!root) return false;
12
+ root.setAttribute("data-duration", formatTimelineAttributeNumber(contentEnd));
13
+ return true;
14
+ }
@@ -1,8 +1,16 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { applyTimelineStackingReorder, extendRootDurationIfNeeded } from "./timelineEditingHelpers";
3
+ import {
4
+ applyTimelineStackingReorder,
5
+ buildTimelineMoveTimingPatch,
6
+ deleteSelectedKeyframes,
7
+ extendRootDurationIfNeeded,
8
+ persistTimelineBatchEdit,
9
+ type PersistTimelineBatchChange,
10
+ } from "./timelineEditingHelpers";
4
11
  import type { TimelineElement } from "../player/store/playerStore";
5
12
  import { usePlayerStore } from "../player/store/playerStore";
13
+ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
6
14
 
7
15
  afterEach(() => {
8
16
  usePlayerStore.getState().reset();
@@ -102,3 +110,119 @@ describe("extendRootDurationIfNeeded", () => {
102
110
  expect(usePlayerStore.getState().duration).toBe(5);
103
111
  });
104
112
  });
113
+
114
+ describe("persistTimelineBatchEdit", () => {
115
+ const SOURCE = `<div id="root"><video id="a" class="clip" data-start="1" data-track-index="0"></video><video id="b" class="clip" data-start="2" data-track-index="1"></video></div>`;
116
+
117
+ function batchInput(changes: PersistTimelineBatchChange[], writes: Array<[string, string]>) {
118
+ return {
119
+ projectId: "p1",
120
+ activeCompPath: "index.html",
121
+ label: "Move timeline clips",
122
+ changes,
123
+ writeProjectFile: async (path: string, content: string) => {
124
+ writes.push([path, content]);
125
+ },
126
+ recordEdit: async () => {},
127
+ domEditSaveTimestampRef: { current: 0 },
128
+ pendingTimelineEditPathRef: { current: new Set<string>() },
129
+ };
130
+ }
131
+
132
+ function stubReadFileContent(content: string) {
133
+ vi.stubGlobal(
134
+ "fetch",
135
+ vi.fn(async () => ({
136
+ ok: true,
137
+ json: async () => ({ content }),
138
+ })),
139
+ );
140
+ }
141
+
142
+ function moveMember(
143
+ id: string,
144
+ start: number,
145
+ fromTrack: number,
146
+ toTrack: number,
147
+ ): PersistTimelineBatchChange {
148
+ return {
149
+ element: el({ id, tag: "video", domId: id, start, track: fromTrack }),
150
+ buildPatches: (original, target) =>
151
+ buildTimelineMoveTimingPatch(original, target, start, 5, toTrack),
152
+ };
153
+ }
154
+
155
+ async function runBatch(changes: PersistTimelineBatchChange[]) {
156
+ stubReadFileContent(SOURCE);
157
+ const writes: Array<[string, string]> = [];
158
+ await persistTimelineBatchEdit(batchInput(changes, writes));
159
+ return writes;
160
+ }
161
+
162
+ afterEach(() => {
163
+ vi.unstubAllGlobals();
164
+ });
165
+
166
+ it("skips no-op members instead of aborting the batch (track-insert renumber)", async () => {
167
+ // A track-insert renumber can include a member whose attributes already
168
+ // hold the target values — its patch is string-identical. The batch must
169
+ // skip it and still persist the members that DID change.
170
+ const writes = await runBatch([
171
+ // no-op: data-start already "1", track already 0
172
+ moveMember("a", 1, 0, 0),
173
+ // real change: track 1 -> 2
174
+ moveMember("b", 2, 1, 2),
175
+ ]);
176
+
177
+ expect(writes).toHaveLength(1);
178
+ expect(writes[0]![0]).toBe("index.html");
179
+ expect(writes[0]![1]).toContain('id="b" class="clip" data-start="2" data-track-index="2"');
180
+ });
181
+
182
+ it("saves nothing when every member is a no-op", async () => {
183
+ const writes = await runBatch([moveMember("a", 1, 0, 0)]);
184
+
185
+ expect(writes).toHaveLength(0);
186
+ });
187
+
188
+ it("throws on a mistargeted member instead of silently dropping it", async () => {
189
+ // A member whose target does not resolve in the source (stale id) patches
190
+ // to the identical string too — but that is a targeting FAILURE, not an
191
+ // already-at-target no-op, and must abort the batch like the single path.
192
+ stubReadFileContent(SOURCE);
193
+ const writes: Array<[string, string]> = [];
194
+
195
+ await expect(
196
+ persistTimelineBatchEdit(batchInput([moveMember("ghost", 3, 0, 2)], writes)),
197
+ ).rejects.toThrow("Unable to patch timeline element ghost in index.html");
198
+ expect(writes).toHaveLength(0);
199
+ });
200
+ });
201
+
202
+ describe("deleteSelectedKeyframes", () => {
203
+ it("coalesces all removals and reloads only after the last one", () => {
204
+ usePlayerStore.setState({
205
+ selectedElementId: "card",
206
+ selectedKeyframes: new Set(["card:10", "card:50", "card:90"]),
207
+ });
208
+ const handleGsapRemoveKeyframe =
209
+ vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
210
+
211
+ deleteSelectedKeyframes({
212
+ selectedGsapAnimations: [{ id: "card-position", keyframes: {} }],
213
+ handleGsapRemoveKeyframe,
214
+ });
215
+
216
+ expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(3);
217
+ const options = handleGsapRemoveKeyframe.mock.calls.map((call) => call[2]);
218
+ expect(new Set(options.map((entry) => entry?.coalesceKey)).size).toBe(1);
219
+ expect(options).toEqual([
220
+ expect.objectContaining({ coalesceMs: Infinity, skipReload: true }),
221
+ expect.objectContaining({ coalesceMs: Infinity, skipReload: true }),
222
+ expect.objectContaining({ coalesceMs: Infinity, softReload: true }),
223
+ ]);
224
+ expect(options[0]).not.toHaveProperty("softReload");
225
+ expect(options[1]).not.toHaveProperty("softReload");
226
+ expect(options[2]).not.toHaveProperty("skipReload");
227
+ });
228
+ });