@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
@@ -19,6 +19,7 @@ import {
19
19
  } from "./gsapKeyframeCacheHelpers";
20
20
  import type {
21
21
  CommitMutation,
22
+ CommitMutationOptions,
22
23
  SafeGsapCommitMutation,
23
24
  TrackGsapSaveFailure,
24
25
  } from "./gsapScriptCommitTypes";
@@ -54,6 +55,21 @@ interface GsapKeyframeOpsParams extends SdkKeyframeDeps {
54
55
  trackGsapSaveFailure: TrackGsapSaveFailure;
55
56
  }
56
57
 
58
+ /**
59
+ * Translate a gesture's commit overrides into the SDK persist options. The
60
+ * server path's `softReload`/`skipReload` maps to the SDK's `skipRefresh`, and
61
+ * `coalesceKey`/`coalesceMs` must ride along so an SDK-routed edit folds into
62
+ * one undo entry the same way the server path does.
63
+ */
64
+ function toSdkPersistOptions(label: string, overrides?: Partial<CommitMutationOptions>) {
65
+ return {
66
+ label,
67
+ coalesceKey: overrides?.coalesceKey,
68
+ coalesceMs: overrides?.coalesceMs,
69
+ skipRefresh: overrides?.skipReload,
70
+ };
71
+ }
72
+
57
73
  export function useGsapKeyframeOps({
58
74
  activeCompPath,
59
75
  commitMutation,
@@ -140,6 +156,7 @@ export function useGsapKeyframeOps({
140
156
  animationId: string,
141
157
  percentage: number,
142
158
  properties: Record<string, number | string>,
159
+ commitOverrides?: Partial<CommitMutationOptions>,
143
160
  ) => {
144
161
  if (sdkSession && sdkDeps) {
145
162
  const sourceFile = selection.sourceFile || activeCompPath || "index.html";
@@ -150,21 +167,30 @@ export function useGsapKeyframeOps({
150
167
  properties,
151
168
  sdkSession,
152
169
  sdkDeps,
153
- { label: `Add keyframe at ${percentage}%` },
170
+ toSdkPersistOptions(`Add keyframe at ${percentage}%`, commitOverrides),
154
171
  );
155
172
  if (handled) return;
156
173
  }
157
174
  return commitMutation(
158
175
  selection,
159
176
  { type: "add-keyframe", animationId, percentage, properties },
160
- { label: `Add keyframe at ${percentage}%`, softReload: true },
177
+ {
178
+ label: `Add keyframe at ${percentage}%`,
179
+ softReload: true,
180
+ ...commitOverrides,
181
+ },
161
182
  );
162
183
  },
163
184
  [commitMutation, activeCompPath, sdkSession, sdkDeps],
164
185
  );
165
186
 
166
187
  const removeKeyframe = useCallback(
167
- (selection: DomEditSelection, animationId: string, percentage: number) => {
188
+ (
189
+ selection: DomEditSelection,
190
+ animationId: string,
191
+ percentage: number,
192
+ commitOverrides?: Partial<CommitMutationOptions>,
193
+ ) => {
168
194
  const sourceFile = selection.sourceFile || activeCompPath || "index.html";
169
195
  const mutation = { type: "remove-keyframe", animationId, percentage };
170
196
  void executeOptimisticKeyframeCacheUpdate({
@@ -182,6 +208,7 @@ export function useGsapKeyframeOps({
182
208
  ),
183
209
  }),
184
210
  persist: async () => {
211
+ const label = `Remove keyframe at ${percentage}%`;
185
212
  if (sdkSession && sdkDeps) {
186
213
  const handled = await sdkGsapRemoveKeyframePersist(
187
214
  sourceFile,
@@ -189,14 +216,14 @@ export function useGsapKeyframeOps({
189
216
  percentage,
190
217
  sdkSession,
191
218
  sdkDeps,
192
- { label: `Remove keyframe at ${percentage}%` },
219
+ toSdkPersistOptions(label, commitOverrides),
193
220
  );
194
221
  if (handled) return;
195
222
  }
196
- await commitMutation(selection, mutation, {
197
- label: `Remove keyframe at ${percentage}%`,
198
- softReload: true,
199
- });
223
+ const commitOptions = commitOverrides?.skipReload
224
+ ? { label, ...commitOverrides }
225
+ : { label, softReload: true, ...commitOverrides };
226
+ await commitMutation(selection, mutation, commitOptions);
200
227
  },
201
228
  }).catch((error) => {
202
229
  trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
@@ -261,6 +288,7 @@ export function useGsapKeyframeOps({
261
288
  animationId: string,
262
289
  resolvedFromValues?: Record<string, number | string>,
263
290
  duration?: number,
291
+ commitOverrides: Partial<CommitMutationOptions> = { softReload: true },
264
292
  ) => {
265
293
  if (sdkSession && sdkDeps) {
266
294
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
@@ -270,7 +298,7 @@ export function useGsapKeyframeOps({
270
298
  resolvedFromValues,
271
299
  sdkSession,
272
300
  sdkDeps,
273
- { label: "Convert to keyframes" },
301
+ toSdkPersistOptions("Convert to keyframes", commitOverrides),
274
302
  );
275
303
  if (handled) return;
276
304
  }
@@ -279,7 +307,7 @@ export function useGsapKeyframeOps({
279
307
  // `duration` only applies when the target is a static `set` (which has
280
308
  // none) — it spans the converted keyframes across the element's clip.
281
309
  { type: "convert-to-keyframes", animationId, resolvedFromValues, duration },
282
- { label: "Convert to keyframes" },
310
+ { label: "Convert to keyframes", ...commitOverrides },
283
311
  );
284
312
  },
285
313
  [commitMutation, activeCompPath, sdkSession, sdkDeps],
@@ -217,6 +217,7 @@ let cleanup: (() => void) | null = null;
217
217
  function renderCommitHook() {
218
218
  const reloadPreview = vi.fn();
219
219
  const onCacheInvalidate = vi.fn();
220
+ const onFileContentChanged = vi.fn();
220
221
  const forceReloadSdkSession = vi.fn();
221
222
  const recordEdit = vi.fn(async () => {});
222
223
  const showToast = vi.fn();
@@ -231,7 +232,7 @@ function renderCommitHook() {
231
232
  domEditSaveTimestampRef: { current: 0 },
232
233
  reloadPreview,
233
234
  onCacheInvalidate,
234
- onFileContentChanged: undefined,
235
+ onFileContentChanged,
235
236
  showToast,
236
237
  sdkSession: null,
237
238
  writeProjectFile: undefined,
@@ -252,6 +253,7 @@ function renderCommitHook() {
252
253
  api: hookApi,
253
254
  reloadPreview,
254
255
  onCacheInvalidate,
256
+ onFileContentChanged,
255
257
  forceReloadSdkSession,
256
258
  recordEdit,
257
259
  showToast,
@@ -383,4 +385,41 @@ describe("runCommit — instantPatch wiring", () => {
383
385
  expectSoftReloadedWith(deps.reloadPreview, "AFTER");
384
386
  expect(deps.reloadPreview).not.toHaveBeenCalled();
385
387
  });
388
+
389
+ it("batch capability posts ordered mutations and finalizes the result once", async () => {
390
+ applySoftReload.mockReturnValue("applied");
391
+ mockFetchResult();
392
+ const deps = renderCommitHook();
393
+ const firstMutation = { type: "add", value: 1 };
394
+ const lastMutation = { type: "delete", value: 2 };
395
+ const batch = deps.api.commitMutation.batch;
396
+ if (!batch) throw new Error("batch capability missing");
397
+
398
+ await act(async () => {
399
+ await batch(
400
+ [
401
+ { selection, mutation: firstMutation, options: { label: "Resize", skipReload: true } },
402
+ { selection, mutation: lastMutation, options: { label: "Resize", softReload: true } },
403
+ ],
404
+ { label: "Resize", coalesceKey: "tx:resize:1", coalesceMs: Infinity, softReload: true },
405
+ );
406
+ });
407
+
408
+ expect(fetch).toHaveBeenCalledTimes(1);
409
+ expect(fetch).toHaveBeenCalledWith(
410
+ "/api/projects/proj-1/gsap-mutations-batch/index.html",
411
+ expect.objectContaining({
412
+ method: "POST",
413
+ body: JSON.stringify({ mutations: [firstMutation, lastMutation] }),
414
+ }),
415
+ );
416
+ expect(deps.recordEdit).toHaveBeenCalledTimes(1);
417
+ expect(deps.recordEdit).toHaveBeenCalledWith(
418
+ expect.objectContaining({ label: "Resize", coalesceKey: "tx:resize:1" }),
419
+ );
420
+ expect(deps.onFileContentChanged).toHaveBeenCalledTimes(1);
421
+ expect(deps.forceReloadSdkSession).toHaveBeenCalledTimes(1);
422
+ expect(applySoftReload).toHaveBeenCalledTimes(1);
423
+ expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
424
+ });
386
425
  });
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useMemo, useRef } from "react";
2
2
  import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
3
+ import { readProjectFileContent as readSharedProjectFileContent } from "../utils/studioFileHistory";
3
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
5
  import { usePlayerStore } from "../player/store/playerStore";
5
6
  import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
@@ -15,6 +16,8 @@ import {
15
16
  readJsonResponseBody,
16
17
  } from "./gsapScriptCommitHelpers";
17
18
  import type {
19
+ CommitMutation,
20
+ CommitMutationCall,
18
21
  CommitMutationOptions,
19
22
  GsapScriptCommitsParams,
20
23
  MutationResult,
@@ -47,6 +50,76 @@ async function mutateGsapScript(
47
50
  return result;
48
51
  }
49
52
 
53
+ async function mutateGsapScriptBatch(
54
+ projectId: string,
55
+ sourceFile: string,
56
+ mutations: Record<string, unknown>[],
57
+ ): Promise<MutationResult> {
58
+ const res = await fetch(
59
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`,
60
+ {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify({ mutations }),
64
+ },
65
+ );
66
+ if (!res.ok) throw new GsapMutationHttpError(res.status, await readJsonResponseBody(res));
67
+ const result = (await res.json()) as MutationResult;
68
+ if (!result.ok) throw new Error(`Failed to update GSAP in ${sourceFile}`);
69
+ return result;
70
+ }
71
+
72
+ type ShowToast = (message: string, tone?: "error" | "info") => void;
73
+
74
+ async function runMutationRequest(
75
+ mutations: Record<string, unknown>[],
76
+ options: CommitMutationOptions,
77
+ showToast: ShowToast | undefined,
78
+ request: () => Promise<MutationResult>,
79
+ ): Promise<MutationResult | undefined> {
80
+ const unsafeFields = mutations.flatMap((mutation) => findUnsafeMutationValues(mutation));
81
+ if (unsafeFields.length > 0) {
82
+ showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
83
+ if (options.skipReload) return;
84
+ throw new Error(
85
+ `Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`,
86
+ );
87
+ }
88
+ try {
89
+ return await request();
90
+ } catch (error) {
91
+ if (error instanceof GsapMutationHttpError)
92
+ showToast?.(formatGsapMutationRejectionToast(error), "error");
93
+ if (options.skipReload) return;
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ function finishUnchangedMutation(
99
+ iframe: HTMLIFrameElement | null,
100
+ result: MutationResult,
101
+ options: CommitMutationOptions,
102
+ reloadPreview: () => void,
103
+ ): boolean {
104
+ if (result.changed !== false) return false;
105
+ if (!options.skipReload && options.instantPatch) {
106
+ applyPreviewSync(iframe, result, options, reloadPreview);
107
+ }
108
+ return true;
109
+ }
110
+
111
+ function refreshMutationPreview(
112
+ iframe: HTMLIFrameElement | null,
113
+ result: MutationResult,
114
+ options: CommitMutationOptions,
115
+ reloadPreview: () => void,
116
+ onCacheInvalidate: () => void,
117
+ ): void {
118
+ options.beforeReload?.();
119
+ applyPreviewSync(iframe, result, options, reloadPreview);
120
+ onCacheInvalidate();
121
+ }
122
+
50
123
  /**
51
124
  * Apply a soft reload and enforce the U4 invariant via the richer
52
125
  * `SoftReloadResult`, with telemetry on every non-success path so the invariant
@@ -141,63 +214,78 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
141
214
  // the same animationId so their POSTs can't interleave. Held in a ref so the
142
215
  // chain survives re-renders.
143
216
  const serializerRef = useRef(createKeyedSerializer());
144
- // fallow-ignore-next-line complexity
145
- const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
146
- const pid = projectIdRef.current;
147
- if (!pid) return;
148
- const unsafeFields = findUnsafeMutationValues(mutation);
149
- if (unsafeFields.length > 0) {
150
- showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
151
- if (options.skipReload) return;
152
- throw new Error(`Mutation contains unsafe values: ${unsafeFields.map((field) => field.path).join(", ")}`);
153
- }
154
- const targetPath = selection.sourceFile || activeCompPath || "index.html";
155
- let result: MutationResult;
156
- try {
157
- result = await mutateGsapScript(pid, targetPath, mutation);
158
- } catch (error) {
159
- if (error instanceof GsapMutationHttpError) showToast?.(formatGsapMutationRejectionToast(error), "error");
160
- if (options.skipReload) return;
161
- throw error;
162
- }
163
- if (result.changed === false) {
164
- // The FILE already matched, but a deferred instant patch may still be
165
- // owed to the RUNTIME: paired commits (x with skipReload, then y carrying
166
- // the patch for both) rely on the SECOND commit to sync the preview — if
167
- // that half happens to be a no-op (a purely-horizontal drag or resize
168
- // compensation), returning here would leave the runtime showing the old
169
- // value while the file holds the new one. Patching in place is idempotent
170
- // when the values truly match everywhere.
171
- if (!options.skipReload && options.instantPatch) {
172
- applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
173
- }
174
- return;
175
- }
217
+ const recordMutationEdit = useCallback(async (targetPath: string, result: MutationResult, options: CommitMutationOptions) => {
218
+ if (result.before == null || result.after == null) return;
219
+ await editHistory.recordEdit({
220
+ label: options.label,
221
+ kind: "manual",
222
+ coalesceKey: options.coalesceKey,
223
+ coalesceMs: options.coalesceMs,
224
+ files: { [targetPath]: { before: result.before, after: result.after } },
225
+ });
226
+ }, [editHistory]);
227
+
228
+ const finalizeSuccessfulMutation = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, targetPath: string, result: MutationResult, options: CommitMutationOptions) => {
229
+ // A no-op file write may still owe the runtime a deferred instant patch.
230
+ if (finishUnchangedMutation(previewIframeRef.current, result, options, reloadPreview)) return;
176
231
  domEditSaveTimestampRef.current = Date.now();
177
- if (result.before != null && result.after != null) {
178
- await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
179
- }
232
+ await recordMutationEdit(targetPath, result, options);
180
233
  if (result.after != null) onFileContentChanged?.(targetPath, result.after);
181
234
  // Server wrote the file; the in-memory SDK doc is now stale. Resync it so a
182
235
  // later SDK-routed edit doesn't serialize the pre-write doc and revert this.
183
236
  forceReloadSdkSession?.();
184
237
  if (options.skipReload) return;
185
238
  if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
186
- options.beforeReload?.();
187
- applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
188
- onCacheInvalidate();
189
- }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
239
+ refreshMutationPreview(
240
+ previewIframeRef.current,
241
+ result,
242
+ options,
243
+ reloadPreview,
244
+ onCacheInvalidate,
245
+ );
246
+ }, [previewIframeRef, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, forceReloadSdkSession, recordMutationEdit]);
247
+
248
+ const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
249
+ const pid = projectIdRef.current;
250
+ if (!pid) return;
251
+ const targetPath = selection.sourceFile || activeCompPath || "index.html";
252
+ const result = await runMutationRequest([mutation], options, showToast, () =>
253
+ mutateGsapScript(pid, targetPath, mutation),
254
+ );
255
+ if (!result) return;
256
+ await finalizeSuccessfulMutation(selection, mutation, targetPath, result, options);
257
+ }, [projectIdRef, activeCompPath, showToast, finalizeSuccessfulMutation]);
258
+
259
+ const runBatchCommit = useCallback(async (calls: CommitMutationCall[], options: CommitMutationOptions) => {
260
+ const pid = projectIdRef.current;
261
+ const first = calls[0];
262
+ const last = calls.at(-1);
263
+ if (!pid || !first || !last) return;
264
+ const targetPath = first.selection.sourceFile || activeCompPath || "index.html";
265
+ const mutations = calls.map(({ mutation }) => mutation);
266
+ const result = await runMutationRequest(mutations, options, showToast, () =>
267
+ mutateGsapScriptBatch(pid, targetPath, mutations),
268
+ );
269
+ if (!result) return;
270
+ await finalizeSuccessfulMutation(last.selection, last.mutation, targetPath, result, options);
271
+ }, [projectIdRef, activeCompPath, showToast, finalizeSuccessfulMutation]);
272
+
190
273
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
191
274
  // commits to the SAME file (any op type, any animation) interleave server-side,
192
275
  // so serialize per target file by default; an explicit serializeKey overrides.
193
- const commitMutation = useCallback(
194
- (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
276
+ const commitMutation = useMemo<CommitMutation>(() => {
277
+ const commit: CommitMutation = (selection, mutation, options) => {
195
278
  const file = selection.sourceFile || activeCompPath || "index.html";
196
279
  const key = options.serializeKey ?? `gsap-file:${file}`;
197
280
  return serializerRef.current(key, () => runCommit(selection, mutation, options));
198
- },
199
- [runCommit, activeCompPath],
200
- );
281
+ };
282
+ commit.batch = (calls, options) => {
283
+ const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
284
+ const key = options.serializeKey ?? `gsap-file:${file}`;
285
+ return serializerRef.current(key, () => runBatchCommit(calls, options));
286
+ };
287
+ return commit;
288
+ }, [runCommit, runBatchCommit, activeCompPath]);
201
289
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
202
290
  const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
203
291
 
@@ -238,14 +326,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
238
326
  // exact prior content as its undo `before` (matching the style/delete paths),
239
327
  // instead of a normalized full-DOM re-emit that would reformat the whole file.
240
328
  const readProjectFileContent = useCallback(
241
- async (path: string): Promise<string> => {
329
+ (path: string): Promise<string> => {
242
330
  const pid = projectIdRef.current;
243
331
  if (!pid) throw new Error("No active project");
244
- const res = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`);
245
- if (!res.ok) throw new Error(`Failed to read ${path}`);
246
- const data = (await res.json()) as { content?: string };
247
- if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
248
- return data.content;
332
+ return readSharedProjectFileContent(pid, path);
249
333
  },
250
334
  [projectIdRef],
251
335
  );
@@ -5,6 +5,7 @@ import { usePlayerStore } from "../player";
5
5
  import { computeCurrentPercentage } from "./gsapDragCommit";
6
6
  import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
7
7
  import { trackStudioEvent } from "../utils/studioTelemetry";
8
+ import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
8
9
 
9
10
  /**
10
11
  * Thin useCallback wrappers that guard on `domEditSelection` before
@@ -75,8 +76,14 @@ export function useGsapSelectionHandlers({
75
76
  animId: string,
76
77
  percentage: number,
77
78
  properties: Record<string, number | string>,
79
+ commitOverrides?: Partial<CommitMutationOptions>,
78
80
  ) => Promise<void>;
79
- removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
81
+ removeKeyframe: (
82
+ sel: DomEditSelection,
83
+ animId: string,
84
+ percentage: number,
85
+ commitOverrides?: Partial<CommitMutationOptions>,
86
+ ) => void;
80
87
  moveKeyframe: (
81
88
  sel: DomEditSelection,
82
89
  animId: string,
@@ -95,6 +102,7 @@ export function useGsapSelectionHandlers({
95
102
  animId: string,
96
103
  resolvedFromValues?: Record<string, number | string>,
97
104
  duration?: number,
105
+ commitOverrides?: Partial<CommitMutationOptions>,
98
106
  ) => Promise<void>;
99
107
  removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
100
108
 
@@ -232,20 +240,36 @@ export function useGsapSelectionHandlers({
232
240
  );
233
241
 
234
242
  const handleGsapAddKeyframeBatch = useCallback(
235
- (animId: string, percentage: number, properties: Record<string, number | string>) => {
243
+ (
244
+ animId: string,
245
+ percentage: number,
246
+ properties: Record<string, number | string>,
247
+ commitOverrides?: Partial<CommitMutationOptions>,
248
+ ) => {
236
249
  if (!domEditSelection) return Promise.resolve();
237
- return addKeyframeBatch(domEditSelection, animId, percentage, properties).catch((error) => {
250
+ return addKeyframeBatch(
251
+ domEditSelection,
252
+ animId,
253
+ percentage,
254
+ properties,
255
+ commitOverrides,
256
+ ).catch((error) => {
238
257
  trackGsapHandlerFailure(error, domEditSelection, "add-keyframe", "Add keyframe");
239
258
  });
240
259
  },
241
260
  [domEditSelection, addKeyframeBatch, trackGsapHandlerFailure],
242
261
  );
243
262
  const handleGsapRemoveKeyframe = useCallback(
244
- (animId: string, percentage: number, selectionOverride?: DomEditSelection | null) => {
263
+ (
264
+ animId: string,
265
+ percentage: number,
266
+ commitOverrides?: Partial<CommitMutationOptions>,
267
+ selectionOverride?: DomEditSelection | null,
268
+ ) => {
245
269
  const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
246
270
  if (!sel) return;
247
271
  trackStudioEvent("keyframe", { action: "remove" });
248
- removeKeyframe(sel, animId, percentage);
272
+ removeKeyframe(sel, animId, percentage, commitOverrides);
249
273
  },
250
274
  [domEditSelection, removeKeyframe],
251
275
  );
@@ -302,18 +326,27 @@ export function useGsapSelectionHandlers({
302
326
  );
303
327
 
304
328
  const handleGsapConvertToKeyframes = useCallback(
305
- (animId: string, resolvedFromValues?: Record<string, number | string>, duration?: number) => {
329
+ (
330
+ animId: string,
331
+ resolvedFromValues?: Record<string, number | string>,
332
+ duration?: number,
333
+ commitOverrides?: Partial<CommitMutationOptions>,
334
+ ) => {
306
335
  if (!domEditSelection) return Promise.resolve();
307
- return convertToKeyframes(domEditSelection, animId, resolvedFromValues, duration).catch(
308
- (error) => {
309
- trackGsapHandlerFailure(
310
- error,
311
- domEditSelection,
312
- "convert-to-keyframes",
313
- "Convert to keyframes",
314
- );
315
- },
316
- );
336
+ return convertToKeyframes(
337
+ domEditSelection,
338
+ animId,
339
+ resolvedFromValues,
340
+ duration,
341
+ commitOverrides,
342
+ ).catch((error) => {
343
+ trackGsapHandlerFailure(
344
+ error,
345
+ domEditSelection,
346
+ "convert-to-keyframes",
347
+ "Convert to keyframes",
348
+ );
349
+ });
317
350
  },
318
351
  [domEditSelection, convertToKeyframes, trackGsapHandlerFailure],
319
352
  );