@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,8 +1,7 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
- import { PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
3
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
3
  import { usePlayerStore } from "../player/store/playerStore";
5
- import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
4
+ import { resolveTweenStart } from "../utils/globalTimeCompiler";
6
5
  import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
7
6
  import { roundTo3 } from "../utils/rounding";
8
7
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
@@ -10,8 +9,9 @@ import {
10
9
  type GsapDragCommitCallbacks,
11
10
  computeCurrentPercentage,
12
11
  parkPlayheadOnKeyframe,
13
- materializeIfDynamic,
14
12
  } from "./gsapDragCommit";
13
+ import type { GsapEditOutcome } from "./gsapEditOutcome";
14
+ import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
15
15
 
16
16
  /**
17
17
  * The tween's keyframes with one inserted at `percentage`. Any existing keyframe
@@ -38,232 +38,26 @@ export function buildTemporalArcKeyframes(
38
38
  ].sort((a, b) => a.percentage - b.percentage);
39
39
  }
40
40
 
41
- async function extendTweenAndAddKeyframe(
41
+ /** commitGsapPositionFromDrag's refusal, decided without writing. */
42
+ export function gsapPositionFromDragOutcome(
42
43
  selection: DomEditSelection,
43
44
  anim: GsapAnimation,
44
- properties: Record<string, number>,
45
- targetTime: number,
46
- tweenStart: number,
47
- tweenDuration: number,
48
- callbacks: GsapDragCommitCallbacks,
49
- beforeReload?: () => void,
50
- backfillDefaults?: Record<string, number>,
51
- ): Promise<void> {
52
- const tweenEnd = tweenStart + tweenDuration;
53
- const newStart = Math.min(targetTime, tweenStart);
54
- const newEnd = Math.max(targetTime, tweenEnd);
55
- const newDuration = Math.max(0.01, newEnd - newStart);
56
- const existingKfs = anim.keyframes?.keyframes ?? [];
57
- const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
58
- [];
59
- for (const kf of existingKfs) {
60
- const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
61
- const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
62
- const props: Record<string, number | string> = { ...kf.properties };
63
- for (const k of Object.keys(properties)) {
64
- if (!(k in props) && backfillDefaults?.[k] != null) props[k] = backfillDefaults[k];
65
- }
66
- remappedKfs.push({ percentage: newPct, properties: props });
67
- }
68
-
69
- const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
70
- remappedKfs.push({ percentage: targetPct, properties });
71
-
72
- remappedKfs.sort((a, b) => a.percentage - b.percentage);
73
-
74
- await callbacks.commitMutation(
75
- selection,
76
- {
77
- type: "replace-with-keyframes",
78
- animationId: anim.id,
79
- targetSelector: anim.targetSelector,
80
- position: roundTo3(newStart),
81
- duration: roundTo3(newDuration),
82
- keyframes: remappedKfs,
83
- },
84
- { label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
85
- );
86
- }
87
-
88
- // fallow-ignore-next-line complexity
89
- async function commitKeyframedPosition(
90
- selection: DomEditSelection,
91
- anim: GsapAnimation,
92
- properties: Record<string, number>,
93
- callbacks: GsapDragCommitCallbacks,
94
- beforeReload?: () => void,
95
- backfillDefaults?: Record<string, number>,
96
- ): Promise<void> {
97
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
98
- const computedPct = computeCurrentPercentage(selection, anim);
99
- const pct = activeKeyframePct ?? computedPct;
100
- await callbacks.commitMutation(
101
- selection,
102
- {
103
- type: "add-keyframe",
104
- animationId: anim.id,
105
- percentage: pct,
106
- properties,
107
- ...(backfillDefaults ? { backfillDefaults } : {}),
108
- },
109
- { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
110
- );
111
- if (activeKeyframePct != null) {
112
- setActiveKeyframePct(null);
113
- parkPlayheadOnKeyframe(anim, pct);
114
- }
115
- }
116
-
117
- interface DragRuntimeGsap {
118
- getProperty: (target: Element, key: string) => unknown;
119
- set: (target: Element, vars: Record<string, unknown>) => void;
120
- }
121
- interface DragRuntimeTimeline {
122
- seek: (time: number) => void;
123
- }
124
- interface DragRuntime {
125
- gsapLib: DragRuntimeGsap;
126
- el: Element;
127
- mainTl: DragRuntimeTimeline;
128
- }
129
-
130
- function resolveDragRuntime(
131
- iframe: HTMLIFrameElement | null | undefined,
132
- selector: string | undefined,
133
- ): DragRuntime | null {
134
- if (!iframe || !selector) return null;
135
- const win = iframe.contentWindow as
136
- | (Window & {
137
- gsap?: Partial<DragRuntimeGsap>;
138
- __timelines?: Record<string, Partial<DragRuntimeTimeline>>;
139
- })
140
- | null;
141
- const gsap = win?.gsap;
142
- if (typeof gsap?.getProperty !== "function" || typeof gsap.set !== "function") return null;
143
- let el: Element | null = null;
144
- try {
145
- el = iframe.contentDocument?.querySelector(selector) ?? null;
146
- } catch {
147
- return null;
148
- }
149
- if (!el) return null;
150
- const timelines = win?.__timelines;
151
- const mainTl = timelines ? Object.values(timelines)[0] : undefined;
152
- if (typeof mainTl?.seek !== "function") return null;
153
- return {
154
- gsapLib: gsap as DragRuntimeGsap,
155
- el,
156
- mainTl: mainTl as DragRuntimeTimeline,
157
- };
158
- }
159
-
160
- // fallow-ignore-next-line complexity
161
- async function commitFlatViaKeyframes(
162
- selection: DomEditSelection,
163
- anim: GsapAnimation,
164
- properties: Record<string, number>,
165
- callbacks: GsapDragCommitCallbacks,
166
- beforeReload?: () => void,
167
- iframe?: HTMLIFrameElement | null,
168
- selector?: string,
169
- backfillDefaults?: Record<string, number>,
170
- ): Promise<void> {
171
- const ct = usePlayerStore.getState().currentTime;
172
- const ts = resolveTweenStart(anim);
173
- const td = resolveEditableTweenDuration(anim, selection);
174
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
175
- const outsideRange =
176
- activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
177
-
178
- const resolvedFromValues: Record<string, number | string> = {};
179
- const runtime = resolveDragRuntime(iframe, selector);
180
- if (runtime && ts !== null) {
181
- const { gsapLib, el, mainTl } = runtime;
182
- const draggedValues: Record<string, number> = {};
183
- const channelsTheClearWipes = new Set([
184
- ...Object.keys(properties),
185
- ...PROPERTY_GROUPS.position,
186
- ]);
187
- for (const key of channelsTheClearWipes) {
188
- const v = Number(gsapLib.getProperty(el, key));
189
- if (Number.isFinite(v)) draggedValues[key] = v;
190
- }
191
- try {
192
- gsapLib.set(el, { clearProps: Object.keys(properties).join(",") });
193
- mainTl.seek(ts);
194
- for (const key of Object.keys(properties)) {
195
- const v = Number(gsapLib.getProperty(el, key));
196
- if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
197
- }
198
- mainTl.seek(ct);
199
- } catch {
200
- for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key];
201
- } finally {
202
- if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues);
203
- }
204
- }
205
-
206
- if (outsideRange && ts !== null) {
207
- const coalesceKey = `gsap:convert-drag:${anim.id}`;
208
- await callbacks.commitMutation(
209
- selection,
210
- {
211
- type: "convert-to-keyframes",
212
- animationId: anim.id,
213
- ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
214
- },
215
- { label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
216
- );
217
- const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
218
- // By id first: a target with several tweens (two `to`s on the same selector)
219
- // matches the selector lookup on whichever one happens to be first, and the
220
- // extend-and-add below would then rewrite a tween the drag never touched.
221
- const converted =
222
- fresh.find((a) => a.id === anim.id && a.keyframes) ??
223
- fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ??
224
- anim;
225
- const convertedStart = resolveTweenStart(converted) ?? ts;
226
- const convertedDur = resolveTweenDuration(converted) || td;
227
- await extendTweenAndAddKeyframe(
228
- selection,
229
- converted,
230
- properties,
231
- ct,
232
- convertedStart,
233
- convertedDur,
234
- callbacks,
235
- beforeReload,
236
- );
237
- return;
238
- }
239
-
240
- const coalesceKey = `gsap:convert-drag:${anim.id}`;
241
- // fallow-ignore-next-line code-duplication
242
- await callbacks.commitMutation(
243
- selection,
244
- {
245
- type: "convert-to-keyframes",
246
- animationId: anim.id,
247
- ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
248
- },
249
- { label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
250
- );
251
- const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
252
- const editedSelected = activeKeyframePct != null;
253
- if (editedSelected) setActiveKeyframePct(null);
254
-
255
- await callbacks.commitMutation(
256
- selection,
257
- {
258
- type: "add-keyframe",
259
- animationId: anim.id,
260
- percentage: pct,
261
- properties,
262
- ...(backfillDefaults ? { backfillDefaults } : {}),
263
- },
264
- { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
45
+ studioOffset: { x: number; y: number },
46
+ gsapPos: { x: number; y: number },
47
+ iframe: HTMLIFrameElement | null,
48
+ ): GsapEditOutcome {
49
+ if (anim.arcPath?.enabled) return { status: "persisted" };
50
+ const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
51
+ selection.element,
52
+ studioOffset,
53
+ gsapPos,
265
54
  );
266
- if (editedSelected) parkPlayheadOnKeyframe(anim, pct);
55
+ const plan = planValueEdit(selection, anim, { x: newX, y: newY }, iframe, {
56
+ backfill: { x: baseGsapX, y: baseGsapY },
57
+ });
58
+ return plan.ok
59
+ ? { status: "persisted" }
60
+ : { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
267
61
  }
268
62
 
269
63
  // fallow-ignore-next-line code-duplication
@@ -274,9 +68,8 @@ export async function commitGsapPositionFromDrag(
274
68
  studioOffset: { x: number; y: number },
275
69
  gsapPos: { x: number; y: number },
276
70
  iframe: HTMLIFrameElement | null,
277
- selector: string,
278
71
  callbacks: GsapDragCommitCallbacks,
279
- ): Promise<void> {
72
+ ): Promise<GsapEditOutcome> {
280
73
  const el = selection.element;
281
74
  // fallow-ignore-next-line code-duplication
282
75
  const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
@@ -293,8 +86,6 @@ export async function commitGsapPositionFromDrag(
293
86
  el.removeAttribute("data-hf-drag-initial-offset-y");
294
87
  };
295
88
 
296
- const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
297
- const ct = usePlayerStore.getState().currentTime;
298
89
  if (anim.arcPath?.enabled) {
299
90
  const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
300
91
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
@@ -319,14 +110,15 @@ export async function commitGsapPositionFromDrag(
319
110
  );
320
111
  setActiveKeyframePct(null);
321
112
  parkPlayheadOnKeyframe(anim, pct);
322
- return;
113
+ return { status: "persisted" };
323
114
  }
324
115
 
325
116
  const tweenStart = resolveTweenStart(anim);
326
117
  // Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
327
118
  // default here would collapse a duration-less arc's window on drag.
328
119
  const tweenDuration = resolveEditableTweenDuration(anim, selection);
329
- if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
120
+ if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2)
121
+ return { status: "persisted" };
330
122
  const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
331
123
  await callbacks.commitMutation(
332
124
  selection,
@@ -339,158 +131,18 @@ export async function commitGsapPositionFromDrag(
339
131
  keyframes: temporalKeyframes,
340
132
  ease: "none",
341
133
  },
342
- { label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset },
343
- );
344
- return;
345
- }
346
- if (anim.keyframes) {
347
- const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
348
- const effectiveAnim = newId ? { ...anim, id: newId } : anim;
349
- const dragProps: Record<string, number> = { x: newX, y: newY };
350
-
351
- const ts = resolveTweenStart(effectiveAnim);
352
- const td = resolveEditableTweenDuration(effectiveAnim, selection);
353
- const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
354
- const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
355
- if (outsideRange && !hasSelectedKeyframe) {
356
- await extendTweenAndAddKeyframe(
357
- selection,
358
- effectiveAnim,
359
- dragProps,
360
- ct,
361
- ts,
362
- td,
363
- callbacks,
364
- restoreOffset,
365
- backfillDefaults,
366
- );
367
- } else {
368
- await commitKeyframedPosition(
369
- selection,
370
- effectiveAnim,
371
- dragProps,
372
- callbacks,
373
- restoreOffset,
374
- backfillDefaults,
375
- );
376
- }
377
- } else if (anim.method === "from" || anim.method === "fromTo") {
378
- const ct = usePlayerStore.getState().currentTime;
379
- const ts = resolveTweenStart(anim);
380
- const td = resolveEditableTweenDuration(anim, selection);
381
- const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
382
- const outsideRange =
383
- !hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
384
- const dragProps: Record<string, number> = { x: newX, y: newY };
385
-
386
- if (outsideRange && ts !== null) {
387
- await callbacks.commitMutation(
388
- selection,
389
- { type: "split-into-property-groups", animationId: anim.id },
390
- { label: "Split from() for drag", skipReload: true },
391
- );
392
-
393
- const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
394
- const existingPosAnim = allAnims.find(
395
- (a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
396
- );
397
-
398
- if (existingPosAnim?.keyframes) {
399
- const posTs = resolveTweenStart(existingPosAnim);
400
- const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
401
- if (posTs !== null) {
402
- await extendTweenAndAddKeyframe(
403
- selection,
404
- existingPosAnim,
405
- { x: newX, y: newY },
406
- ct,
407
- posTs,
408
- posTd,
409
- callbacks,
410
- restoreOffset,
411
- backfillDefaults,
412
- );
413
- return;
414
- }
415
- }
416
-
417
- const newStart = Math.min(ct, ts);
418
- const newEnd = Math.max(ct, ts + td);
419
- const newDuration = Math.max(0.01, newEnd - newStart);
420
- const dragBefore = ct < ts;
421
- const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
422
- const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
423
-
424
- const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
425
- [];
426
- if (dragBefore) {
427
- keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
428
- if (origStartPct > 0.5 && origStartPct < 99.5) {
429
- keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
430
- }
431
- keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
432
- } else {
433
- keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
434
- if (origEndPct > 0.5 && origEndPct < 99.5) {
435
- keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
436
- }
437
- keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
438
- }
439
- keyframes.sort((a, b) => a.percentage - b.percentage);
440
-
441
- const baseKf = {
442
- targetSelector: anim.targetSelector,
443
- position: roundTo3(newStart),
444
- duration: roundTo3(newDuration),
445
- keyframes,
446
- };
447
- await callbacks.commitMutation(
448
- selection,
449
- existingPosAnim
450
- ? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf }
451
- : { type: "add-with-keyframes", ...baseKf },
452
- { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
453
- );
454
- } else {
455
- const coalesceKey = `gsap:convert-drag:${anim.id}`;
456
- await callbacks.commitMutation(
457
- selection,
458
- {
459
- type: "convert-to-keyframes",
460
- animationId: anim.id,
461
- },
462
- { label: "Convert from() for drag", skipReload: true, coalesceKey },
463
- );
464
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
465
- const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
466
- if (activeKeyframePct != null) setActiveKeyframePct(null);
467
- await callbacks.commitMutation(
468
- selection,
469
- {
470
- type: "add-keyframe",
471
- animationId: anim.id,
472
- percentage: pct,
473
- properties: dragProps,
474
- ...(backfillDefaults ? { backfillDefaults } : {}),
475
- },
476
- {
477
- label: `Move layer (keyframe ${pct}%)`,
478
- softReload: true,
479
- beforeReload: restoreOffset,
480
- coalesceKey,
481
- },
482
- );
483
- }
484
- } else {
485
- await commitFlatViaKeyframes(
486
- selection,
487
- anim,
488
- { x: newX, y: newY },
489
- callbacks,
490
- restoreOffset,
491
- iframe,
492
- selector,
493
- backfillDefaults,
134
+ {
135
+ label: "Move layer (new keyframe)",
136
+ keyframeAction: "add",
137
+ softReload: true,
138
+ beforeReload: restoreOffset,
139
+ },
494
140
  );
141
+ return { status: "persisted" };
495
142
  }
143
+ return commitValueAtPlayhead(selection, anim, { x: newX, y: newY }, iframe, callbacks, {
144
+ label: "Move layer",
145
+ backfill: { x: baseGsapX, y: baseGsapY },
146
+ beforeReload: restoreOffset,
147
+ });
496
148
  }
@@ -1,6 +1,11 @@
1
1
  import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
 
3
- export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
3
+ export type GsapEditBlockReason =
4
+ | "no-selector"
5
+ | "unroll-required"
6
+ | "source-uneditable"
7
+ | "keyframes-uneditable"
8
+ | "mixed-files";
4
9
 
5
10
  /**
6
11
  * Which of the nine situations produced a block. The user-facing `reason` stays
@@ -21,7 +26,18 @@ export type GsapEditBlockDetail =
21
26
  | "no-position-tween"
22
27
  | "live-rotation-no-source-tween"
23
28
  | "live-resize-no-source-tween"
24
- | "zero-duration-tween";
29
+ | "zero-duration-tween"
30
+ | PlayheadEditRefusal;
31
+
32
+ /** Why an edit at the playhead could not be written as keyframes of the file's tween. */
33
+ export type PlayheadEditRefusal =
34
+ | "eased-keyframes"
35
+ | "simple-array-keyframes"
36
+ | "unknown-ease"
37
+ | "implicit-end-unknown"
38
+ | "not-a-tween"
39
+ | "no-timing"
40
+ | "shared-tween";
25
41
 
26
42
  export type GsapEditOutcome =
27
43
  | {
@@ -52,6 +68,10 @@ export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
52
68
  "unroll-required":
53
69
  "This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
54
70
  "source-uneditable": "This animation is computed at runtime. Edit the animation in the Code tab.",
71
+ "keyframes-uneditable":
72
+ "Studio can't add this edit to the animation's keyframes. Edit this animation in the Code tab.",
73
+ "mixed-files":
74
+ "These layers are animated in different files. Move each file's layers separately.",
55
75
  };
56
76
 
57
77
  export class GsapEditBlockedError extends Error {
@@ -0,0 +1,161 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { trackKeyframeCommit } from "../utils/keyframeUsage";
3
+ import { observeGsapGesture } from "./gsapGestureOutcome";
4
+ import type { CommitMutation, MutationResult } from "./gsapScriptCommitTypes";
5
+ import { trackStudioEvent } from "../utils/studioTelemetry";
6
+
7
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
8
+ beforeEach(() => vi.clearAllMocks());
9
+ const selection = {} as Parameters<CommitMutation>[0];
10
+ const mutation = {
11
+ type: "add-keyframe",
12
+ animationId: "private-target",
13
+ properties: { text: "private-content" },
14
+ };
15
+ function writer(result?: MutationResult): CommitMutation {
16
+ const commit: CommitMutation = async (_selection, _mutation, options) => {
17
+ if (result) options.onResult?.(result);
18
+ };
19
+ commit.batch = async (_calls, options) => {
20
+ if (result) options.onResult?.(result);
21
+ };
22
+ return commit;
23
+ }
24
+
25
+ describe("GSAP gesture usage", () => {
26
+ it("counts a multi-write add gesture once from successful writer results", async () => {
27
+ const observed = observeGsapGesture(
28
+ writer({ ok: true, changed: true, mutationChanges: [true] }),
29
+ );
30
+ const onResult = vi.fn();
31
+ await observed.commit!(
32
+ selection,
33
+ { type: "convert-to-keyframes" },
34
+ { label: "Convert", onResult },
35
+ );
36
+ await observed.commit!(selection, mutation, { label: "Add" });
37
+ expect(trackStudioEvent).not.toHaveBeenCalled();
38
+ expect(observed.finish()).toEqual({ ok: true, changed: true });
39
+ expect(onResult).toHaveBeenCalledWith({ ok: true, changed: true, mutationChanges: [true] });
40
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
41
+ });
42
+
43
+ it("counts a batched insertion once", async () => {
44
+ const observed = observeGsapGesture(
45
+ writer({ ok: true, changed: true, mutationChanges: [true, true] }),
46
+ );
47
+ await observed.commit!.batch!(
48
+ [
49
+ { selection, mutation, options: { label: "Add" } },
50
+ { selection, mutation, options: { label: "Add" } },
51
+ ],
52
+ { label: "Add together" },
53
+ );
54
+ expect(observed.finish().changed).toBe(true);
55
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
56
+ });
57
+
58
+ it("retains insertion metadata from an earlier member of a mixed batch", async () => {
59
+ const observed = observeGsapGesture(
60
+ writer({ ok: true, changed: true, mutationChanges: [true, true] }),
61
+ );
62
+ await observed.commit!.batch!(
63
+ [
64
+ {
65
+ selection,
66
+ mutation: { type: "replace-with-keyframes" },
67
+ options: { label: "Extend", keyframeAction: "add" },
68
+ },
69
+ { selection, mutation: { type: "update-keyframe" }, options: { label: "Update" } },
70
+ ],
71
+ { label: "Update" },
72
+ );
73
+ observed.finish();
74
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
75
+ });
76
+
77
+ it("uses semantic insertion metadata for a replacement", async () => {
78
+ const observed = observeGsapGesture(
79
+ writer({ ok: true, changed: true, mutationChanges: [true] }),
80
+ );
81
+ await observed.commit!(
82
+ selection,
83
+ { type: "replace-with-keyframes" },
84
+ { label: "Replace", keyframeAction: "add" },
85
+ );
86
+ observed.finish();
87
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
88
+ });
89
+
90
+ it.each([undefined, { ok: true }, { ok: true, changed: false }, { ok: false, changed: true }])(
91
+ "does not count resolution without a changed successful write (%j)",
92
+ async (result) => {
93
+ const observed = observeGsapGesture(writer(result));
94
+ await observed.commit!(selection, mutation, { label: "Add" });
95
+ expect(observed.finish()).toEqual({ ok: true, changed: false });
96
+ expect(trackStudioEvent).not.toHaveBeenCalled();
97
+ },
98
+ );
99
+
100
+ it("does not count missing writers or an unfinished group", async () => {
101
+ expect(observeGsapGesture(null).finish()).toEqual({ ok: true, changed: false });
102
+ const observed = observeGsapGesture(writer());
103
+ await observed.commit!(selection, mutation, { label: "Add" });
104
+ expect(observed.finish(true).changed).toBe(false);
105
+ expect(trackStudioEvent).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it("reports a successful plain DOM gesture without inventing keyframes", () => {
109
+ expect(observeGsapGesture(null).finish(true).changed).toBe(true);
110
+ expect(trackStudioEvent).not.toHaveBeenCalled();
111
+ });
112
+ });
113
+
114
+ it.each([
115
+ [[false, true], 0],
116
+ [[true, false], 1],
117
+ [undefined, 0],
118
+ ] as const)(
119
+ "attributes mixed batches only to changed members (%j)",
120
+ async (mutationChanges, count) => {
121
+ const result = {
122
+ ok: true,
123
+ changed: true,
124
+ mutationChanges: mutationChanges && [...mutationChanges],
125
+ };
126
+ const observed = observeGsapGesture(writer(result));
127
+ const mutations = [mutation, { type: "update-property" }];
128
+ await observed.commit!.batch!(
129
+ mutations.map((mutation) => ({ selection, mutation, options: { label: "Edit" } })),
130
+ { label: "Edit" },
131
+ );
132
+ expect(observed.finish()).toEqual({ ok: true, changed: true });
133
+ expect(trackStudioEvent).toHaveBeenCalledTimes(count);
134
+ if (count) expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
135
+ vi.mocked(trackStudioEvent).mockClear();
136
+ trackKeyframeCommit(mutations, result, { label: "Edit" });
137
+ expect(trackStudioEvent).toHaveBeenCalledTimes(count);
138
+ },
139
+ );
140
+
141
+ it("excludes a suppressed preparation from mixed-batch attribution", async () => {
142
+ const result = { ok: true, changed: true, mutationChanges: [true, false] };
143
+ const calls = [
144
+ {
145
+ selection,
146
+ mutation: { type: "convert-to-keyframes" },
147
+ options: { label: "Prepare", keyframeTelemetry: false },
148
+ },
149
+ { selection, mutation, options: { label: "Add" } },
150
+ ];
151
+ const observed = observeGsapGesture(writer(result));
152
+ await observed.commit!.batch!(calls, { label: "Add" });
153
+ expect(observed.finish()).toEqual({ ok: true, changed: true });
154
+ trackKeyframeCommit(
155
+ calls.map((call) => call.mutation),
156
+ result,
157
+ { label: "Add" },
158
+ calls.map((call) => call.options),
159
+ );
160
+ expect(trackStudioEvent).not.toHaveBeenCalled();
161
+ });