@hyperframes/studio 0.8.114 → 0.8.116

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 (266) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
  4. package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  6. package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
  7. package/dist/chunk-3F47EFCT.js.map +1 -0
  8. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  9. package/dist/chunk-5EKWJZLM.js.map +1 -0
  10. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
  12. package/dist/index.d.ts +146 -40
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +4021 -3591
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +9 -8
  19. package/src/App.tsx +2 -2
  20. package/src/captions/hooks/useCaptionSync.ts +10 -6
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  23. package/src/components/dock/Dock.tsx +5 -7
  24. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  25. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  26. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  27. package/src/components/editor/DomEditOverlay.tsx +5 -1
  28. package/src/components/editor/FileTreeNodes.tsx +13 -10
  29. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  30. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  31. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  32. package/src/components/editor/SnapToolbar.tsx +43 -5
  33. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  34. package/src/components/editor/audioFxTelemetry.ts +2 -3
  35. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  36. package/src/components/editor/cropResize.ts +4 -3
  37. package/src/components/editor/domEditingDom.ts +4 -0
  38. package/src/components/editor/domEditingLayers.ts +1 -5
  39. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  40. package/src/components/editor/domGroupMoveBy.ts +95 -0
  41. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  42. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  43. package/src/components/editor/manualEditingAvailability.ts +8 -0
  44. package/src/components/editor/manualEdits.ts +1 -0
  45. package/src/components/editor/manualEditsParsing.ts +7 -0
  46. package/src/components/editor/manualOffsetDrag.ts +5 -6
  47. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  48. package/src/components/editor/marqueeCommit.ts +24 -8
  49. package/src/components/editor/motionPathCommit.test.ts +29 -1
  50. package/src/components/editor/motionPathCommit.ts +21 -9
  51. package/src/components/editor/motionPathSelection.test.ts +6 -31
  52. package/src/components/editor/probeSourceElement.test.ts +45 -0
  53. package/src/components/editor/probeSourceElement.ts +41 -16
  54. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  55. package/src/components/editor/propertyPanelFont.tsx +3 -2
  56. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  57. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  58. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  59. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  60. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  61. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  62. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  63. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  64. package/src/components/editor/propertyPanelTypes.ts +6 -3
  65. package/src/components/editor/useColorGradingController.ts +2 -1
  66. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  67. package/src/components/editor/useDomEditNudge.ts +2 -0
  68. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  69. package/src/components/editor/useDomEditZOrder.ts +12 -2
  70. package/src/components/editor/useFxCarveNodes.ts +2 -1
  71. package/src/components/editor/useFxLevelling.ts +2 -1
  72. package/src/components/feedback/projectProvenance.ts +2 -1
  73. package/src/components/nle/NLEContext.test.ts +1 -0
  74. package/src/components/nle/NLEContext.tsx +2 -1
  75. package/src/components/nle/NLEPreview.tsx +12 -10
  76. package/src/components/nle/PreviewPoster.tsx +2 -1
  77. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  78. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  79. package/src/components/renders/useFfmpegStatus.ts +2 -1
  80. package/src/components/renders/useRenderQueue.ts +5 -4
  81. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  82. package/src/components/sidebar/AssetsTab.tsx +2 -1
  83. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  84. package/src/components/ui/menuStyle.test.ts +61 -0
  85. package/src/components/ui/menuStyle.ts +10 -0
  86. package/src/contexts/DomEditContext.tsx +5 -1
  87. package/src/contexts/TimelineEditContext.tsx +1 -0
  88. package/src/domEditExports.test.tsx +9 -1
  89. package/src/hooks/domEditCommitRunner.ts +1 -1
  90. package/src/hooks/domSelectionTestHarness.ts +23 -0
  91. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  92. package/src/hooks/domStyleCommit.ts +8 -1
  93. package/src/hooks/draggedGsapPosition.ts +10 -0
  94. package/src/hooks/elementOffsetStager.ts +5 -4
  95. package/src/hooks/gsapDragCommit.test.ts +75 -164
  96. package/src/hooks/gsapDragCommit.ts +4 -125
  97. package/src/hooks/gsapDragPositionCommit.ts +36 -384
  98. package/src/hooks/gsapEditOutcome.ts +22 -2
  99. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  100. package/src/hooks/gsapGestureOutcome.ts +64 -0
  101. package/src/hooks/gsapGroupPreflight.ts +34 -0
  102. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  103. package/src/hooks/gsapMutationClient.ts +4 -3
  104. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  105. package/src/hooks/gsapParsedTween.test.ts +62 -0
  106. package/src/hooks/gsapParsedTween.ts +243 -0
  107. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  108. package/src/hooks/gsapPositionDetection.ts +52 -33
  109. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  110. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  111. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  112. package/src/hooks/gsapResizeIntercept.test.ts +96 -11
  113. package/src/hooks/gsapResizeIntercept.ts +79 -168
  114. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  115. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  116. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  117. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  118. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  119. package/src/hooks/gsapScriptCommitTypes.ts +13 -0
  120. package/src/hooks/gsapShared.ts +7 -0
  121. package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
  122. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  123. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  124. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  125. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  126. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  127. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  128. package/src/hooks/keyframeCacheAstLoad.ts +8 -6
  129. package/src/hooks/newTweenTarget.test.ts +41 -76
  130. package/src/hooks/plainRotation.ts +3 -2
  131. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  132. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  133. package/src/hooks/refreshTimelineRowText.ts +29 -0
  134. package/src/hooks/timelineTimingSync.test.ts +1 -0
  135. package/src/hooks/timelineTimingSync.ts +2 -1
  136. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  137. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  138. package/src/hooks/useAskAgentModal.ts +2 -1
  139. package/src/hooks/useBlockCatalog.ts +2 -1
  140. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  141. package/src/hooks/useCaptionDetection.ts +2 -1
  142. package/src/hooks/useCompositionContentLoader.ts +2 -1
  143. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  144. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  145. package/src/hooks/useDomEditSession.test.tsx +30 -1
  146. package/src/hooks/useDomEditSession.ts +10 -0
  147. package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
  148. package/src/hooks/useDomEditTextCommits.ts +13 -3
  149. package/src/hooks/useDomEditWiring.ts +4 -2
  150. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  151. package/src/hooks/useDomGeometryCommit.ts +8 -4
  152. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  153. package/src/hooks/useDomGeometryCommits.ts +3 -2
  154. package/src/hooks/useDomSelection.test.ts +26 -0
  155. package/src/hooks/useDomSelection.ts +10 -3
  156. package/src/hooks/useDomSelectionTypes.ts +6 -1
  157. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  158. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  159. package/src/hooks/useEditHistoryActions.ts +16 -5
  160. package/src/hooks/useElementLifecycleOps.ts +2 -1
  161. package/src/hooks/useEnableKeyframes.test.ts +34 -3
  162. package/src/hooks/useEnableKeyframes.ts +7 -4
  163. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
  164. package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
  165. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  166. package/src/hooks/useFileManager.ts +14 -10
  167. package/src/hooks/useFileTree.ts +3 -2
  168. package/src/hooks/useFrameCapture.ts +5 -1
  169. package/src/hooks/useFreezeFrame.ts +3 -2
  170. package/src/hooks/useGestureCommit.test.tsx +225 -55
  171. package/src/hooks/useGestureCommit.ts +86 -55
  172. package/src/hooks/useGroupCommits.ts +2 -1
  173. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  174. package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
  175. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  176. package/src/hooks/useGsapAwareEditing.ts +101 -195
  177. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  178. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  179. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  180. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  181. package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
  182. package/src/hooks/useGsapScriptCommits.ts +15 -10
  183. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  184. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  185. package/src/hooks/useGsapTweenCache.ts +18 -12
  186. package/src/hooks/useLintModal.ts +2 -1
  187. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  188. package/src/hooks/usePersistentEditHistory.ts +47 -8
  189. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  190. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  191. package/src/hooks/usePreviewInteraction.ts +2 -0
  192. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  193. package/src/hooks/usePreviewPersistence.ts +13 -11
  194. package/src/hooks/useProjectFileWriter.ts +20 -19
  195. package/src/hooks/useRemoveBackground.ts +2 -1
  196. package/src/hooks/useRenderClipContent.test.ts +18 -0
  197. package/src/hooks/useRenderClipContent.ts +4 -5
  198. package/src/hooks/useSdkSession.ts +2 -1
  199. package/src/hooks/useServerConnection.ts +4 -3
  200. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  201. package/src/hooks/useThumbnailStill.test.ts +100 -0
  202. package/src/hooks/useThumbnailStill.ts +37 -0
  203. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  204. package/src/hooks/useTimelineDeleteOps.ts +22 -27
  205. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  206. package/src/index.ts +4 -0
  207. package/src/player/components/AudioWaveform.tsx +3 -2
  208. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  209. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  210. package/src/player/components/ClipContextMenu.tsx +33 -31
  211. package/src/player/components/CompositionThumbnail.tsx +2 -1
  212. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  213. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  214. package/src/player/components/TextClipContent.test.ts +19 -0
  215. package/src/player/components/TextClipContent.tsx +27 -0
  216. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  217. package/src/player/components/clipMenuNormalize.ts +2 -1
  218. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  219. package/src/player/components/clipMenuToolItems.tsx +13 -9
  220. package/src/player/components/clipPeakMap.ts +2 -1
  221. package/src/player/components/timelineCallbacks.ts +1 -0
  222. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  223. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  224. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  225. package/src/player/components/timelineEditing.ts +3 -1
  226. package/src/player/components/timelineLinkSelection.ts +35 -1
  227. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  228. package/src/player/components/useTimelineClipDrag.ts +6 -7
  229. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  230. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  231. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  232. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  233. package/src/player/lib/thumbnailScheduler.ts +10 -2
  234. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  235. package/src/player/lib/timelineElementHelpers.ts +6 -15
  236. package/src/player/lib/timelineText.ts +40 -0
  237. package/src/player/sceneSwap.ts +2 -1
  238. package/src/player/store/timelineElement.ts +12 -0
  239. package/src/styles/theme.css +3 -0
  240. package/src/telemetry/client.ts +2 -1
  241. package/src/utils/blockInstaller.ts +2 -1
  242. package/src/utils/elementGsap.test.ts +9 -0
  243. package/src/utils/elementGsap.ts +7 -0
  244. package/src/utils/gsapSoftReload.ts +2 -3
  245. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  246. package/src/utils/gsapUndoRestore.ts +3 -6
  247. package/src/utils/keyframeUsage.ts +79 -0
  248. package/src/utils/previewFeatureUsage.test.ts +32 -0
  249. package/src/utils/previewFeatureUsage.ts +46 -0
  250. package/src/utils/previewPrefetch.ts +2 -1
  251. package/src/utils/razorSplitTransaction.ts +12 -8
  252. package/src/utils/softReloadTargets.ts +1 -3
  253. package/src/utils/studioApiFetch.test.ts +75 -0
  254. package/src/utils/studioApiFetch.ts +13 -0
  255. package/src/utils/studioFileHistory.ts +4 -1
  256. package/src/utils/studioHelpers.ts +7 -1
  257. package/src/utils/studioPendingEdits.test.ts +23 -6
  258. package/src/utils/studioPendingEdits.ts +32 -8
  259. package/src/utils/studioTelemetry.test.ts +107 -0
  260. package/src/utils/studioTelemetry.ts +51 -5
  261. package/src/utils/timelineCompositionInsert.ts +3 -2
  262. package/src/webmcp/StudioAgentTools.tsx +4 -3
  263. package/dist/chunk-BIUXP3PG.js.map +0 -1
  264. package/dist/chunk-K47E2ZJA.js.map +0 -1
  265. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  266. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -1,12 +1,18 @@
1
1
  // @vitest-environment jsdom
2
- import { act } from "react";
2
+ import { act, useLayoutEffect } from "react";
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import { trackStudioEvent } from "../utils/studioTelemetry";
6
+ import { trackKeyframeCommit } from "../utils/keyframeUsage";
4
7
  import type { DomEditSelection } from "../components/editor/domEditing";
5
8
  import { usePlayerStore } from "../player";
6
9
  import { mountReactHarness } from "./domSelectionTestHarness";
7
10
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
11
+ import { useGsapAwareEditing } from "./useGsapAwareEditing";
8
12
  import { useGestureCommit } from "./useGestureCommit";
9
13
 
14
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
15
+
10
16
  const gestureRecording = vi.hoisted(() => ({
11
17
  startRecording: vi.fn(),
12
18
  stopRecording: vi.fn(() => [
@@ -84,44 +90,88 @@ function makeSelection(element: HTMLElement): DomEditSelection {
84
90
  };
85
91
  }
86
92
 
93
+ function mountRecording(
94
+ writer: (
95
+ mutation: Record<string, unknown>,
96
+ options: CommitMutationOptions,
97
+ selection?: DomEditSelection,
98
+ ) => Promise<void>,
99
+ animations: GsapAnimation[] = [],
100
+ readOnlyPreview = false,
101
+ ) {
102
+ const iframe = document.createElement("iframe");
103
+ document.body.append(iframe);
104
+ const element = document.createElement("div");
105
+ element.id = "card";
106
+ const session = {
107
+ current: {
108
+ domEditSelection: makeSelection(element),
109
+ selectedGsapAnimations: animations,
110
+ commitMutation: writer,
111
+ },
112
+ };
113
+ let hook: ReturnType<typeof useGestureCommit> | null = null;
114
+ function Probe() {
115
+ const editing = useGsapAwareEditing({
116
+ domEditSelection: session.current.domEditSelection,
117
+ selectedGsapAnimations: session.current.selectedGsapAnimations,
118
+ gsapCommitMutation: (selection, mutation, options) => writer(mutation, options, selection),
119
+ previewIframeRef: { current: iframe },
120
+ showToast: vi.fn(),
121
+ bumpGsapCache: vi.fn(),
122
+ makeFetchFallback: () => vi.fn(),
123
+ trackGsapInteractionFailure: vi.fn(),
124
+ stageElementPositionOffset: vi.fn(),
125
+ handleDomBoxSizeCommit: vi.fn(),
126
+ handleDomRotationCommit: vi.fn(),
127
+ commitPositionPatchToHtml: vi.fn(),
128
+ addGsapAnimation: vi.fn(),
129
+ convertToKeyframes: vi.fn(),
130
+ setArcPath: vi.fn(),
131
+ updateArcSegment: vi.fn(),
132
+ });
133
+ const currentHook = useGestureCommit({
134
+ domEditSessionRef: session,
135
+ previewIframeRef: { current: iframe },
136
+ showToast: vi.fn(),
137
+ isGestureRecordingRef: { current: false },
138
+ readOnlyPreview,
139
+ });
140
+ useLayoutEffect(() => {
141
+ session.current.commitMutation = editing.commitMutation;
142
+ hook = currentHook;
143
+ });
144
+ return null;
145
+ }
146
+ const root = mountReactHarness(<Probe />);
147
+ cleanup = () => act(() => root.unmount());
148
+ return Object.assign(
149
+ () => {
150
+ if (!hook) throw new Error("hook did not initialize");
151
+ return hook;
152
+ },
153
+ {
154
+ select: (selection: DomEditSelection, nextAnimations: GsapAnimation[]) => {
155
+ session.current.domEditSelection = selection;
156
+ session.current.selectedGsapAnimations = nextAnimations;
157
+ act(() => root.render(<Probe />));
158
+ },
159
+ },
160
+ );
161
+ }
162
+
87
163
  describe("useGestureCommit", () => {
88
164
  it("coalesces property-group commits and reloads only the terminal group", async () => {
89
- const iframe = document.createElement("iframe");
90
- document.body.append(iframe);
91
- const element = document.createElement("div");
92
- element.id = "card";
93
- const commitMutation = vi.fn<
165
+ const writer = vi.fn<
94
166
  (mutation: Record<string, unknown>, options: CommitMutationOptions) => Promise<void>
95
167
  >(async () => {});
96
- const sessionRef = {
97
- current: {
98
- domEditSelection: makeSelection(element),
99
- selectedGsapAnimations: [],
100
- commitMutation,
101
- },
102
- };
103
- const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
104
- function Probe() {
105
- captured.hook = useGestureCommit({
106
- domEditSessionRef: sessionRef,
107
- previewIframeRef: { current: iframe },
108
- showToast: vi.fn(),
109
- isGestureRecordingRef: { current: false },
110
- readOnlyPreview: false,
111
- });
112
- return null;
113
- }
114
- const root = mountReactHarness(<Probe />);
115
- cleanup = () => act(() => root.unmount());
116
- if (!captured.hook) throw new Error("hook did not initialize");
117
-
118
- act(() => captured.hook?.handleToggleRecording());
119
- act(() => captured.hook?.handleToggleRecording());
168
+ const hook = mountRecording(writer);
169
+ act(() => hook().handleToggleRecording());
170
+ act(() => hook().handleToggleRecording());
120
171
  await act(async () => {
121
- await vi.waitFor(() => expect(commitMutation).toHaveBeenCalledTimes(2));
172
+ await vi.waitFor(() => expect(writer).toHaveBeenCalledTimes(2));
122
173
  });
123
-
124
- const options = commitMutation.mock.calls.map((call) => call[1]);
174
+ const options = writer.mock.calls.map((call) => call[1]);
125
175
  expect(new Set(options.map((entry) => entry.coalesceKey)).size).toBe(1);
126
176
  expect(options[0]).toEqual(expect.objectContaining({ coalesceMs: Infinity, skipReload: true }));
127
177
  expect(options[0]).not.toHaveProperty("softReload");
@@ -130,30 +180,150 @@ describe("useGestureCommit", () => {
130
180
  });
131
181
 
132
182
  it("does not start a recording while the preview is read-only", () => {
133
- const element = document.createElement("div");
134
- const commitMutation = vi.fn(async () => {});
135
- const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
136
- function Probe() {
137
- captured.hook = useGestureCommit({
138
- domEditSessionRef: {
139
- current: {
140
- domEditSelection: makeSelection(element),
141
- selectedGsapAnimations: [],
142
- commitMutation,
143
- },
144
- },
145
- previewIframeRef: { current: document.createElement("iframe") },
146
- showToast: vi.fn(),
147
- isGestureRecordingRef: { current: false },
148
- readOnlyPreview: true,
183
+ const writer = vi.fn(async () => {});
184
+ const hook = mountRecording(writer, [], true);
185
+ act(() => hook().handleToggleRecording());
186
+ expect(gestureRecording.startRecording).not.toHaveBeenCalled();
187
+ expect(writer).not.toHaveBeenCalled();
188
+ });
189
+ });
190
+
191
+ it.each([
192
+ ["new mixed-property recording", undefined, true, true, 1, "button"],
193
+ ["keyboard recording", undefined, true, true, 1, "keyboard"],
194
+ ["static hold replacement", "set", true, true, 1, "button"],
195
+ ["overlapping tween replacement", "to", true, true, 1, "button"],
196
+ ["unchanged recording", undefined, true, false, 0, "button"],
197
+ ["refused recording", undefined, false, true, 0, "button"],
198
+ ] as const)(
199
+ "counts writer receipts once for %s",
200
+ async (_name, method, ok, changed, count, inputMethod) => {
201
+ const animations = method
202
+ ? [
203
+ {
204
+ id: "card-position",
205
+ targetSelector: "#card",
206
+ propertyGroup: "position",
207
+ method,
208
+ properties: { x: 0, y: 0 },
209
+ resolvedStart: 0,
210
+ position: 0,
211
+ duration: method === "set" ? 0 : 2,
212
+ keyframes: {
213
+ keyframes: [
214
+ { percentage: 0, properties: { x: 0 } },
215
+ { percentage: 100, properties: { x: 100 } },
216
+ ],
217
+ },
218
+ } as unknown as GsapAnimation,
219
+ ]
220
+ : [];
221
+ const writer = vi.fn(
222
+ async (mutation: Record<string, unknown>, options: CommitMutationOptions) => {
223
+ const result = { ok, changed };
224
+ trackKeyframeCommit([mutation], result, options);
225
+ options.onResult?.(result);
226
+ },
227
+ );
228
+ const hook = mountRecording(writer, animations);
229
+ act(() => hook().handleToggleRecording(inputMethod));
230
+ await act(async () => {
231
+ hook().handleToggleRecording();
232
+ await vi.waitFor(() => expect(writer).toHaveBeenCalled());
233
+ });
234
+ expect(trackStudioEvent).toHaveBeenCalledTimes(count * 2);
235
+ if (count) {
236
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
237
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
238
+ feature: "gesture_recording",
239
+ surface: "preview",
240
+ method: inputMethod,
149
241
  });
150
- return null;
151
242
  }
152
- const root = mountReactHarness(<Probe />);
153
- cleanup = () => act(() => root.unmount());
243
+ },
244
+ );
154
245
 
155
- act(() => captured.hook?.handleToggleRecording());
156
- expect(gestureRecording.startRecording).not.toHaveBeenCalled();
157
- expect(commitMutation).not.toHaveBeenCalled();
246
+ it("keeps a pending recording isolated until its writer settles", async () => {
247
+ let release!: () => void;
248
+ const pending = new Promise<void>((resolve) => {
249
+ release = resolve;
250
+ });
251
+ const writer = vi.fn(
252
+ async (_mutation: Record<string, unknown>, options: CommitMutationOptions) => {
253
+ await pending;
254
+ options.onResult?.({ ok: true, changed: true });
255
+ },
256
+ );
257
+ const hook = mountRecording(writer);
258
+ act(() => hook().handleToggleRecording("keyboard"));
259
+ act(() => hook().handleToggleRecording());
260
+ await vi.waitFor(() => expect(writer).toHaveBeenCalledTimes(1));
261
+ act(() => hook().handleToggleRecording("button"));
262
+ expect(gestureRecording.startRecording).toHaveBeenCalledTimes(1);
263
+ await act(async () => {
264
+ release();
265
+ await vi.waitFor(() =>
266
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
267
+ feature: "gesture_recording",
268
+ surface: "preview",
269
+ method: "keyboard",
270
+ }),
271
+ );
272
+ });
273
+ expect(gestureRecording.clearSamples).toHaveBeenCalledTimes(1);
274
+ act(() => hook().handleToggleRecording("button"));
275
+ expect(gestureRecording.startRecording).toHaveBeenCalledTimes(2);
276
+ });
277
+
278
+ it("writes one recording to its original selection after selecting another source", async () => {
279
+ const original = {
280
+ id: "original-position",
281
+ targetSelector: "#card",
282
+ propertyGroup: "position",
283
+ method: "set",
284
+ properties: { x: 0, y: 0 },
285
+ resolvedStart: 0,
286
+ position: 0,
287
+ duration: 0,
288
+ } as GsapAnimation;
289
+ const writer = vi.fn(
290
+ async (
291
+ _mutation: Record<string, unknown>,
292
+ options: CommitMutationOptions,
293
+ _selection?: DomEditSelection,
294
+ ) => {
295
+ options.onResult?.({ ok: true, changed: true });
296
+ },
297
+ );
298
+ const hook = mountRecording(writer, [original]);
299
+ act(() => hook().handleToggleRecording("keyboard"));
300
+ const other = document.createElement("div");
301
+ other.id = "other";
302
+ hook.select(
303
+ { ...makeSelection(other), sourceFile: "other.html", compositionPath: "other.html" },
304
+ [],
305
+ );
306
+ await act(async () => {
307
+ hook().handleToggleRecording();
308
+ await vi.waitFor(() => expect(trackStudioEvent).toHaveBeenCalledTimes(2));
309
+ });
310
+ expect(writer).toHaveBeenCalledExactlyOnceWith(
311
+ expect.objectContaining({
312
+ type: "replace-with-keyframes",
313
+ animationId: "original-position",
314
+ targetSelector: "#card",
315
+ }),
316
+ expect.objectContaining({ keyframeAction: "add" }),
317
+ expect.objectContaining({
318
+ id: "card",
319
+ sourceFile: "index.html",
320
+ compositionPath: "index.html",
321
+ }),
322
+ );
323
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
324
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
325
+ feature: "gesture_recording",
326
+ surface: "preview",
327
+ method: "keyboard",
158
328
  });
159
329
  });
@@ -11,6 +11,8 @@ import { usePlayerStore } from "../player";
11
11
  import type { DomEditSelection } from "../components/editor/domEditing";
12
12
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
13
13
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
14
+ import { trackPreviewEditResult } from "../utils/previewFeatureUsage";
15
+ import { observeGsapGesture } from "./gsapGestureOutcome";
14
16
  import { roundTo3 } from "../utils/rounding";
15
17
  import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser";
16
18
  import { isInstantHold, idSelector, writeTargetSelector, tweenTargetsElement } from "./gsapShared";
@@ -72,6 +74,7 @@ interface GestureSessionRef {
72
74
  commitMutation?: (
73
75
  mutation: Record<string, unknown>,
74
76
  options: CommitMutationOptions,
77
+ selection?: DomEditSelection,
75
78
  ) => Promise<void>;
76
79
  }
77
80
 
@@ -81,6 +84,12 @@ function reloadOnlyLast(index: number, count: number): Partial<CommitMutationOpt
81
84
  return index === count - 1 ? { softReload: true } : { skipReload: true };
82
85
  }
83
86
 
87
+ function recordingEnd(selection: DomEditSelection): number | undefined {
88
+ const start = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
89
+ const duration = Number.parseFloat(selection.dataAttributes?.duration ?? "0") || 0;
90
+ return duration > 0 ? start + duration : undefined;
91
+ }
92
+
84
93
  let gestureRecordingCommitCounter = 0;
85
94
 
86
95
  interface UseGestureCommitParams {
@@ -95,7 +104,7 @@ interface UseGestureCommitParams {
95
104
  export interface UseGestureCommitResult {
96
105
  gestureState: "idle" | "recording";
97
106
  gestureRecording: ReturnType<typeof useGestureRecording>;
98
- handleToggleRecording: () => void;
107
+ handleToggleRecording: (method?: "button" | "keyboard") => void;
99
108
  }
100
109
 
101
110
  // fallow-ignore-next-line complexity
@@ -111,11 +120,13 @@ export function useGestureCommit({
111
120
  const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
112
121
  const gestureStateRef = useRef<"idle" | "recording">("idle");
113
122
  const recordingAutoStopRef = useRef<ReturnType<typeof setInterval>>(undefined);
123
+ const recordingMethodRef = useRef<"button" | "keyboard">("button");
114
124
  const recordingStartTimeRef = useRef(0);
115
125
  const commitInFlightRef = useRef(false);
116
126
  // Capture selection at recording start so commit always targets the recorded element,
117
127
  // even if the user's selection changes mid-recording.
118
128
  const capturedSelectionRef = useRef<DomEditSelection | null>(null);
129
+ const capturedAnimationsRef = useRef<GsapAnimation[]>([]);
119
130
 
120
131
  // Unmount: clear auto-stop interval
121
132
  useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
@@ -206,6 +217,12 @@ export function useGestureCommit({
206
217
  return;
207
218
  }
208
219
  if (liveSession.commitMutation) {
220
+ const writer = liveSession.commitMutation;
221
+ const outcome = observeGsapGesture((_selection, mutation, options) =>
222
+ writer(mutation, options, sel),
223
+ );
224
+ const commit = (mutation: Record<string, unknown>, options: CommitMutationOptions) =>
225
+ outcome.commit!(sel, mutation, options);
209
226
  const recStart = recordingStartTimeRef.current;
210
227
  const rawKeyframes = sortedPcts.map((pct) => ({
211
228
  percentage: pct,
@@ -216,7 +233,7 @@ export function useGestureCommit({
216
233
  const hasPositionProps = keyframes.some((kf) =>
217
234
  Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"),
218
235
  );
219
- const allAnims = liveSession.selectedGsapAnimations ?? [];
236
+ const allAnims = capturedAnimationsRef.current;
220
237
  const existingPositionTween = hasPositionProps
221
238
  ? allAnims.find(
222
239
  (a) =>
@@ -228,7 +245,7 @@ export function useGestureCommit({
228
245
  if (isInstantHold(existingPositionTween)) {
229
246
  // An instant hold is not a tween to merge into — replace it with the
230
247
  // recorded motion (which already starts from the held position).
231
- await liveSession.commitMutation(
248
+ await commit(
232
249
  {
233
250
  type: "replace-with-keyframes",
234
251
  animationId: existingPositionTween.id,
@@ -237,7 +254,7 @@ export function useGestureCommit({
237
254
  duration: roundTo3(duration),
238
255
  keyframes,
239
256
  },
240
- { label: "Gesture recording (replace set)", softReload: true },
257
+ { label: "Gesture recording (replace set)", softReload: true, keyframeAction: "add" },
241
258
  );
242
259
  } else {
243
260
  const tweenStart = existingPositionTween.resolvedStart ?? 0;
@@ -274,7 +291,7 @@ export function useGestureCommit({
274
291
 
275
292
  const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage);
276
293
 
277
- await liveSession.commitMutation(
294
+ await commit(
278
295
  {
279
296
  type: "replace-with-keyframes",
280
297
  animationId: existingPositionTween.id,
@@ -286,7 +303,7 @@ export function useGestureCommit({
286
303
  duration: tweenDur,
287
304
  keyframes: merged,
288
305
  },
289
- { label: "Gesture recording (merge)", softReload: true },
306
+ { label: "Gesture recording (merge)", softReload: true, keyframeAction: "add" },
290
307
  );
291
308
  } else {
292
309
  // Emit one tween per property group so a mixed-prop gesture (e.g.
@@ -294,7 +311,7 @@ export function useGestureCommit({
294
311
  // tween that the position-only drag intercept can't edit.
295
312
  const keyframeGroups = partitionKeyframesByGroup(keyframes);
296
313
  for (const [index, groupKfs] of keyframeGroups.entries()) {
297
- await liveSession.commitMutation(
314
+ await commit(
298
315
  {
299
316
  type: "add-with-keyframes",
300
317
  targetSelector: writeSelector,
@@ -320,7 +337,7 @@ export function useGestureCommit({
320
337
  // No existing tween — same per-group split as the new-range branch above.
321
338
  const keyframeGroups = partitionKeyframesByGroup(keyframes);
322
339
  for (const [index, groupKfs] of keyframeGroups.entries()) {
323
- await liveSession.commitMutation(
340
+ await commit(
324
341
  {
325
342
  type: "add-with-keyframes",
326
343
  targetSelector: writeSelector,
@@ -338,6 +355,7 @@ export function useGestureCommit({
338
355
  );
339
356
  }
340
357
  }
358
+ trackPreviewEditResult("gesture_recording", recordingMethodRef.current, outcome.finish());
341
359
  }
342
360
  showToast(`Recorded ${sortedPcts.length} keyframes`, "info");
343
361
  } catch (err) {
@@ -351,58 +369,71 @@ export function useGestureCommit({
351
369
  }
352
370
  }, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]);
353
371
 
354
- // fallow-ignore-next-line complexity
355
- const handleToggleRecording = useCallback(() => {
356
- if (gestureStateRef.current === "recording") {
357
- if (readOnlyPreview) {
358
- cancelRecording();
372
+ const armAutoStop = useCallback(
373
+ (elementEnd: number | undefined) => {
374
+ clearInterval(recordingAutoStopRef.current);
375
+ const autoStopAt = elementEnd ?? Infinity;
376
+ recordingAutoStopRef.current = setInterval(() => {
377
+ const { currentTime: t, duration: d } = usePlayerStore.getState();
378
+ const limit = Math.min(autoStopAt, d);
379
+ if (limit > 0 && t >= limit - 0.05) {
380
+ void stopAndCommitRecording();
381
+ }
382
+ }, 100);
383
+ },
384
+ [stopAndCommitRecording],
385
+ );
386
+
387
+ const startRecording = useCallback(
388
+ (method: "button" | "keyboard") => {
389
+ if (readOnlyPreview) return;
390
+ const sel = domEditSessionRef.current.domEditSelection;
391
+ if (!sel) {
392
+ showToast("Select an element first", "error");
393
+ return;
394
+ }
395
+ const iframe = previewIframeRef.current;
396
+ if (!iframe) {
397
+ showToast("Preview not ready — try again", "error");
359
398
  return;
360
399
  }
361
- void stopAndCommitRecording();
362
- return;
363
- }
364
- if (readOnlyPreview) return;
365
- const sel = domEditSessionRef.current.domEditSelection;
366
- if (!sel) {
367
- showToast("Select an element first", "error");
368
- return;
369
- }
370
- const iframe = previewIframeRef.current;
371
- if (!iframe) {
372
- showToast("Preview not ready — try again", "error");
373
- return;
374
- }
375
400
 
376
- const store = usePlayerStore.getState();
377
- recordingStartTimeRef.current = store.currentTime;
378
- const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
379
- const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0;
380
- const elementEnd = elDur > 0 ? elStart + elDur : undefined;
381
- capturedSelectionRef.current = sel;
382
- gestureRecording.startRecording(sel.element, iframe, elementEnd);
383
- gestureStateRef.current = "recording";
384
- isGestureRecordingRef.current = true;
385
- setGestureState("recording");
401
+ const store = usePlayerStore.getState();
402
+ recordingMethodRef.current = method;
403
+ recordingStartTimeRef.current = store.currentTime;
404
+ const elementEnd = recordingEnd(sel);
405
+ capturedSelectionRef.current = sel;
406
+ capturedAnimationsRef.current = domEditSessionRef.current.selectedGsapAnimations ?? [];
407
+ gestureRecording.startRecording(sel.element, iframe, elementEnd);
408
+ gestureStateRef.current = "recording";
409
+ isGestureRecordingRef.current = true;
410
+ setGestureState("recording");
386
411
 
387
- clearInterval(recordingAutoStopRef.current);
388
- const autoStopAt = elementEnd ?? Infinity;
389
- recordingAutoStopRef.current = setInterval(() => {
390
- const { currentTime: t, duration: d } = usePlayerStore.getState();
391
- const limit = Math.min(autoStopAt, d);
392
- if (limit > 0 && t >= limit - 0.05) {
393
- void stopAndCommitRecording();
412
+ armAutoStop(elementEnd);
413
+ },
414
+ [
415
+ gestureRecording,
416
+ showToast,
417
+ previewIframeRef,
418
+ domEditSessionRef,
419
+ isGestureRecordingRef,
420
+ readOnlyPreview,
421
+ armAutoStop,
422
+ ],
423
+ );
424
+
425
+ const handleToggleRecording = useCallback(
426
+ (method: "button" | "keyboard" = "button") => {
427
+ if (commitInFlightRef.current) return;
428
+ if (gestureStateRef.current === "recording") {
429
+ if (readOnlyPreview) cancelRecording();
430
+ else void stopAndCommitRecording();
431
+ return;
394
432
  }
395
- }, 100);
396
- }, [
397
- gestureRecording,
398
- showToast,
399
- stopAndCommitRecording,
400
- cancelRecording,
401
- previewIframeRef,
402
- domEditSessionRef,
403
- isGestureRecordingRef,
404
- readOnlyPreview,
405
- ]);
433
+ startRecording(method);
434
+ },
435
+ [cancelRecording, readOnlyPreview, startRecording, stopAndCommitRecording],
436
+ );
406
437
 
407
438
  return useStableHandlers({ gestureState, gestureRecording, handleToggleRecording }, projectId);
408
439
  }
@@ -16,6 +16,7 @@ import {
16
16
  type ElementMatchSelection,
17
17
  } from "../utils/studioHelpers";
18
18
  import type { TimelineElement } from "../player";
19
+ import { studioApiFetch } from "../utils/studioApiFetch";
19
20
 
20
21
  interface UseGroupCommitsParams extends DomEditCommitBaseParams {
21
22
  /** Resync the SDK session after a server-side write (the wrapper/unwrap changes
@@ -132,7 +133,7 @@ async function commitStructuralMutation(
132
133
  writeFile: deps.writeProjectFile,
133
134
  recordEdit: deps.editHistory.recordEdit,
134
135
  rewrite: async (originalContent) => {
135
- const mutateResponse = await fetch(
136
+ const mutateResponse = await studioApiFetch(
136
137
  buildProjectApiPath(pid, `/file-mutations/${route}/${encodeURIComponent(targetPath)}`),
137
138
  {
138
139
  method: "POST",