@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
@@ -0,0 +1,114 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
6
+ import type { CutoverResult } from "../utils/sdkCutover";
7
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
8
+ import { trackStudioEvent } from "../utils/studioTelemetry";
9
+
10
+ const sdkWrite = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<CutoverResult>>());
11
+ vi.mock("../utils/sdkCutover", async (original) => ({
12
+ ...(await original<typeof import("../utils/sdkCutover")>()),
13
+ sdkGsapKeyframePersist: sdkWrite,
14
+ sdkGsapConvertToKeyframesPersist: sdkWrite,
15
+ sdkGsapRemoveAllKeyframesPersist: sdkWrite,
16
+ }));
17
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
18
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
19
+ type Params = Parameters<typeof useGsapKeyframeOps>[0];
20
+ let root: Root;
21
+ function mount() {
22
+ let api: ReturnType<typeof useGsapKeyframeOps> | undefined;
23
+ const legacy = vi.fn(async () => undefined);
24
+ function Probe() {
25
+ api = useGsapKeyframeOps({
26
+ activeCompPath: "index.html",
27
+ commitMutation: legacy,
28
+ commitMutationSafely: legacy,
29
+ trackGsapSaveFailure: vi.fn(),
30
+ sdkSession: {} as NonNullable<Params["sdkSession"]>,
31
+ sdkDeps: {
32
+ editHistory: { recordEdit: vi.fn(async () => undefined) },
33
+ writeProjectFile: vi.fn(async () => undefined),
34
+ reloadPreview: vi.fn(),
35
+ },
36
+ });
37
+ return null;
38
+ }
39
+ root = createRoot(document.createElement("div"));
40
+ act(() => root.render(<Probe />));
41
+ if (!api) throw new Error("hook did not mount");
42
+ return { api, legacy };
43
+ }
44
+ const selection = { id: "private-target", sourceFile: "private.html" } as DomEditSelection;
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ sdkWrite.mockResolvedValue({
48
+ status: "committed",
49
+ version: "v2",
50
+ before: "before",
51
+ after: "after",
52
+ });
53
+ });
54
+ afterEach(() => act(() => root.unmount()));
55
+ describe("SDK keyframe usage", () => {
56
+ it.each(["add", "convert", "remove_all", "reset"] as const)(
57
+ "counts a changed committed %s operation once",
58
+ async (action) => {
59
+ const { api, legacy } = mount();
60
+ await act(async () => {
61
+ if (action === "add")
62
+ await api.addKeyframeBatch(selection, "private-animation", 50, { x: 12, y: 4 });
63
+ else if (action === "convert") await api.convertToKeyframes(selection, "private-animation");
64
+ else await api.removeAllKeyframes(selection, "private-animation", action);
65
+ });
66
+ expect(legacy).not.toHaveBeenCalled();
67
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action });
68
+ },
69
+ );
70
+ it("preserves property metadata on a single-property SDK add", async () => {
71
+ const { api } = mount();
72
+ await act(async () => api.addKeyframe(selection, "private-animation", 50, "x", 12));
73
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", {
74
+ action: "add",
75
+ property: "x",
76
+ });
77
+ });
78
+ it("does not count unchanged SDK source", async () => {
79
+ sdkWrite.mockResolvedValue({
80
+ status: "committed",
81
+ version: "v2",
82
+ before: "same",
83
+ after: "same",
84
+ });
85
+ const { api } = mount();
86
+ await api.addKeyframeBatch(selection, "private-animation", 50, { x: 12 });
87
+ expect(await api.removeAllKeyframes(selection, "private-animation")).toBe(false);
88
+ expect(trackStudioEvent).not.toHaveBeenCalled();
89
+ });
90
+ it("honors intermediate-write suppression in SDK paths", async () => {
91
+ const { api } = mount();
92
+ await api.convertToKeyframes(selection, "private-animation", undefined, undefined, {
93
+ keyframeTelemetry: false,
94
+ });
95
+ await api.addKeyframeBatch(
96
+ selection,
97
+ "private-animation",
98
+ 50,
99
+ { x: 12 },
100
+ { keyframeTelemetry: false },
101
+ );
102
+ await api.removeAllKeyframes(selection, "private-animation", "remove_all", false);
103
+ expect(trackStudioEvent).not.toHaveBeenCalled();
104
+ });
105
+ it("does not fall back or count after a failed SDK write", async () => {
106
+ sdkWrite.mockResolvedValue({ status: "failed", error: new Error("refused") });
107
+ const { api, legacy } = mount();
108
+ await expect(
109
+ api.addKeyframeBatch(selection, "private-animation", 50, { x: 12 }),
110
+ ).rejects.toThrow("refused");
111
+ expect(legacy).not.toHaveBeenCalled();
112
+ expect(trackStudioEvent).not.toHaveBeenCalled();
113
+ });
114
+ });
@@ -1,3 +1,4 @@
1
+ import { trackKeyframeUsage } from "../utils/keyframeUsage";
1
2
  // fallow-ignore-file code-duplication
2
3
  // Add/remove operation-family transaction shapes stay parallel until SDK graduation.
3
4
  import { useCallback } from "react";
@@ -139,10 +140,14 @@ export function useGsapKeyframeOps({
139
140
  coalesceKey: `gsap:${animationId}:kf:${percentage}`,
140
141
  },
141
142
  );
142
- if (cutoverCommittedOrThrow(handled)) return;
143
+ if (cutoverCommittedOrThrow(handled)) {
144
+ if (handled.before !== handled.after) trackKeyframeUsage("add", property);
145
+ return;
146
+ }
143
147
  }
144
148
  await commitMutation(selection, mutation, {
145
149
  label: `Add keyframe at ${percentage}%`,
150
+ keyframeProperty: property,
146
151
  softReload: true,
147
152
  });
148
153
  },
@@ -172,7 +177,11 @@ export function useGsapKeyframeOps({
172
177
  sdkDeps,
173
178
  toSdkPersistOptions(`Add keyframe at ${percentage}%`, commitOverrides),
174
179
  );
175
- if (cutoverCommittedOrThrow(handled)) return;
180
+ if (cutoverCommittedOrThrow(handled)) {
181
+ if (handled.before !== handled.after && commitOverrides?.keyframeTelemetry !== false)
182
+ trackKeyframeUsage("add");
183
+ return;
184
+ }
176
185
  }
177
186
  return commitMutation(
178
187
  selection,
@@ -319,7 +328,11 @@ export function useGsapKeyframeOps({
319
328
  sdkDeps,
320
329
  toSdkPersistOptions("Convert to keyframes", commitOverrides),
321
330
  );
322
- if (cutoverCommittedOrThrow(handled)) return;
331
+ if (cutoverCommittedOrThrow(handled)) {
332
+ if (handled.before !== handled.after && commitOverrides.keyframeTelemetry !== false)
333
+ trackKeyframeUsage("convert");
334
+ return;
335
+ }
323
336
  }
324
337
  return commitMutation(
325
338
  selection,
@@ -333,11 +346,19 @@ export function useGsapKeyframeOps({
333
346
  );
334
347
 
335
348
  const removeAllKeyframes = useCallback(
336
- async (selection: DomEditSelection, animationId: string) => {
349
+ async (
350
+ selection: DomEditSelection,
351
+ animationId: string,
352
+ action: "remove_all" | "reset" = "remove_all",
353
+ telemetry = true,
354
+ ): Promise<boolean> => {
337
355
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
338
356
  // A class/descendant selector can resolve a live element whose selection
339
357
  // deliberately has no id. The cache is still keyed by that DOM id.
340
- const cacheElementId = selection.id || selection.element?.id;
358
+ const clearCache = () => {
359
+ const cacheElementId = selection.id || selection.element?.id;
360
+ if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
361
+ };
341
362
  if (sdkSession && sdkDeps) {
342
363
  const handled = await sdkGsapRemoveAllKeyframesPersist(
343
364
  targetPath,
@@ -347,26 +368,31 @@ export function useGsapKeyframeOps({
347
368
  { label: "Remove all keyframes" },
348
369
  );
349
370
  if (cutoverCommittedOrThrow(handled)) {
350
- if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
351
- return;
371
+ clearCache();
372
+ const changed = handled.before !== handled.after;
373
+ if (changed && telemetry) trackKeyframeUsage(action);
374
+ return changed;
352
375
  }
353
376
  }
377
+ let changed = false;
354
378
  await commitMutationSafely(
355
379
  selection,
356
380
  { type: "remove-all-keyframes", animationId },
357
381
  {
358
382
  label: "Remove all keyframes",
383
+ keyframeAction: action,
384
+ keyframeTelemetry: telemetry,
359
385
  softReload: true,
360
386
  // The committed result is the single success boundary: clearing
361
387
  // before it makes failed saves lie, while waiting for the reload leaves
362
388
  // stale diamonds visible during the source round-trip.
363
389
  onResult: (result) => {
364
- if (result.changed !== false && cacheElementId) {
365
- clearKeyframeCacheForElement(targetPath, cacheElementId);
366
- }
390
+ changed = result.ok && result.changed === true;
391
+ if (changed) clearCache();
367
392
  },
368
393
  },
369
394
  );
395
+ return changed;
370
396
  },
371
397
  [commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
372
398
  );
@@ -590,6 +590,63 @@ describe("runCommit — instantPatch wiring", () => {
590
590
  vi.unstubAllGlobals();
591
591
  });
592
592
 
593
+ it.each([
594
+ ["add-keyframe", "add"],
595
+ ["add-with-keyframes", "add"],
596
+ ["convert-to-keyframes", "convert"],
597
+ ["remove-all-keyframes", "remove_all"],
598
+ ])("counts %s only after a changed durable write", async (type, action) => {
599
+ mockFetchResult();
600
+ const deps = renderCommitHook();
601
+ await deps.api.commitMutation(
602
+ selection,
603
+ { type, properties: { text: "private-content" } },
604
+ { label: "Edit" },
605
+ );
606
+ expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([
607
+ ["keyframe", { action }],
608
+ ]);
609
+ });
610
+
611
+ it("counts reset with its explicit action and suppresses intermediate writes", async () => {
612
+ mockFetchResult();
613
+ const deps = renderCommitHook();
614
+ await deps.api.commitMutation(
615
+ selection,
616
+ { type: "convert-to-keyframes" },
617
+ { label: "Convert", keyframeTelemetry: false },
618
+ );
619
+ await deps.api.commitMutation(
620
+ selection,
621
+ { type: "remove-all-keyframes" },
622
+ { label: "Reset", keyframeAction: "reset" },
623
+ );
624
+ expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([
625
+ ["keyframe", { action: "reset" }],
626
+ ]);
627
+ });
628
+
629
+ it("does not count a successful unchanged keyframe write", async () => {
630
+ mockFetchResult({ changed: false });
631
+ const deps = renderCommitHook();
632
+ await deps.api.commitMutation(selection, { type: "add-keyframe" }, { label: "Add" });
633
+ expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([]);
634
+ });
635
+
636
+ it("does not count a failed write that skipReload suppresses", async () => {
637
+ vi.stubGlobal(
638
+ "fetch",
639
+ vi.fn(async () => ({ ok: false, status: 500 })),
640
+ );
641
+ const deps = renderCommitHook();
642
+ await deps.api.commitMutation(
643
+ selection,
644
+ { type: "add-keyframe" },
645
+ { label: "Add", skipReload: true },
646
+ );
647
+ expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([]);
648
+ });
649
+
593
650
  const NESTED_SCRIPT = 'window.__timelines["root"] = tl;';
594
651
  const SUB = `<template><div data-composition-id="sub"><div id="nwid" style="left: 40px"></div></div></template>`;
595
652
 
@@ -641,7 +698,9 @@ describe("runCommit — instantPatch wiring", () => {
641
698
  await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
642
699
  });
643
700
 
644
- expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"));
701
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/files/compositions%2Fsub.html"), {
702
+ credentials: "omit",
703
+ });
645
704
  expect(applySoftReload).toHaveBeenCalledWith(
646
705
  expect.anything(),
647
706
  NESTED_SCRIPT,
@@ -11,6 +11,7 @@ import {
11
11
  } from "../utils/gsapSoftReload";
12
12
  import type { SoftReloadResult } from "../utils/gsapSoftReload";
13
13
  import { trackStudioEvent } from "../utils/studioTelemetry";
14
+ import { trackKeyframeCommit } from "../utils/keyframeUsage";
14
15
  import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
15
16
  import { trackedStudioEdit } from "../utils/studioPendingEdits";
16
17
  import {
@@ -27,12 +28,13 @@ import {
27
28
  formatGsapMutationRejectionToast,
28
29
  readJsonResponseBody,
29
30
  } from "./gsapScriptCommitHelpers";
30
- import type {
31
- CommitMutation,
32
- CommitMutationCall,
33
- CommitMutationOptions,
34
- GsapScriptCommitsParams,
35
- MutationResult,
31
+ import {
32
+ gsapWriteFile,
33
+ type CommitMutation,
34
+ type CommitMutationCall,
35
+ type CommitMutationOptions,
36
+ type GsapScriptCommitsParams,
37
+ type MutationResult,
36
38
  } from "./gsapScriptCommitTypes";
37
39
  import { useGsapAnimationOps } from "./useGsapAnimationOps";
38
40
  import { useGsapArcPathOps } from "./useGsapArcPathOps";
@@ -43,13 +45,14 @@ import {
43
45
  useSafeGsapCommitMutation,
44
46
  } from "./useSafeGsapCommitMutation";
45
47
  import { studioWriteHeaders } from "../utils/studioFileVersion";
48
+ import { studioApiFetch } from "../utils/studioApiFetch";
46
49
 
47
50
  async function mutateGsapScript(
48
51
  projectId: string,
49
52
  sourceFile: string,
50
53
  mutation: Record<string, unknown>,
51
54
  ): Promise<MutationResult> {
52
- const res = await fetch(
55
+ const res = await studioApiFetch(
53
56
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
54
57
  {
55
58
  method: "POST",
@@ -68,7 +71,7 @@ async function mutateGsapScriptBatch(
68
71
  sourceFile: string,
69
72
  mutations: Record<string, unknown>[],
70
73
  ): Promise<MutationResult> {
71
- const res = await fetch(
74
+ const res = await studioApiFetch(
72
75
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`,
73
76
  {
74
77
  method: "POST",
@@ -391,6 +394,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
391
394
  mutateGsapScript(pid, targetPath, mutation),
392
395
  );
393
396
  if (!result) return;
397
+ trackKeyframeCommit([mutation], result, options);
394
398
  options.onResult?.(result);
395
399
  await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
396
400
  }, [showToast, finalizeSuccessfulMutation]);
@@ -404,6 +408,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
404
408
  mutateGsapScriptBatch(pid, targetPath, mutations),
405
409
  );
406
410
  if (!result) return;
411
+ trackKeyframeCommit(mutations, result, options, calls.map((call) => call.options));
407
412
  options.onResult?.(result);
408
413
  // Each call brings its own fast-path patch; the batch wrote them all, so the
409
414
  // preview sync applies them all rather than just the last call's.
@@ -439,14 +444,14 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
439
444
  };
440
445
  const commit: CommitMutation = trackedStudioEdit((selection, mutation, options) => {
441
446
  if (!activeProjectId) return Promise.resolve();
442
- const file = selection.sourceFile || activeCompPath || "index.html";
447
+ const file = gsapWriteFile(selection, activeCompPath);
443
448
  return serializeCommit(file, options.serializeKey, () =>
444
449
  runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
445
450
  );
446
451
  }) as CommitMutation;
447
452
  commit.batch = trackedStudioEdit((calls: CommitMutationCall[], options: CommitMutationOptions) => {
448
453
  if (!activeProjectId) return Promise.resolve();
449
- const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
454
+ const file = calls[0] ? gsapWriteFile(calls[0].selection, activeCompPath) : "index.html";
450
455
  return serializeCommit(file, options.serializeKey, () =>
451
456
  runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
452
457
  );
@@ -42,7 +42,7 @@ function makeParams(overrides: Partial<Params> = {}): Params {
42
42
  moveKeyframe: vi.fn().mockResolvedValue(true),
43
43
  resizeKeyframedTween: vi.fn().mockResolvedValue(true),
44
44
  convertToKeyframes: resolved(),
45
- removeAllKeyframes: resolved(),
45
+ removeAllKeyframes: vi.fn().mockResolvedValue(true),
46
46
  handleDomManualEditsReset: vi.fn(),
47
47
  selectedGsapAnimations: [],
48
48
  showToast: vi.fn(),
@@ -109,9 +109,16 @@ export function useGsapSelectionHandlers({
109
109
  duration?: number,
110
110
  commitOverrides?: Partial<CommitMutationOptions>,
111
111
  ) => Promise<void>;
112
- removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
112
+ removeAllKeyframes: (
113
+ sel: DomEditSelection,
114
+ animId: string,
115
+ action?: "remove_all" | "reset",
116
+ telemetry?: boolean,
117
+ ) => Promise<boolean>;
113
118
 
114
- handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
119
+ handleDomManualEditsReset: (
120
+ sel: DomEditSelection,
121
+ ) => Promise<void | import("./domEditCommitTypes").DomEditPersistOutcome>;
115
122
  selectedGsapAnimations: GsapAnimation[];
116
123
  showToast: (message: string, tone?: "error" | "info") => void;
117
124
  }) {
@@ -323,7 +330,6 @@ export function useGsapSelectionHandlers({
323
330
  ) => {
324
331
  const sel = resolveWriteSelection(selectionOverride);
325
332
  if (!sel) return;
326
- trackStudioEvent("keyframe", { action: "add", property });
327
333
  addKeyframe(sel, animId, percentage, property, value);
328
334
  },
329
335
  [resolveWriteSelection, addKeyframe],
@@ -441,31 +447,31 @@ export function useGsapSelectionHandlers({
441
447
  );
442
448
 
443
449
  const handleGsapRemoveAllKeyframes = useCallback(
444
- (animId: string, selectionOverride?: DomEditSelection | null) => {
450
+ (animId: string, selectionOverride?: DomEditSelection | null, telemetry = true) => {
445
451
  const selection = resolveWriteSelection(selectionOverride);
446
452
  if (!selection) return Promise.resolve(false);
447
- return observeGsapMutation(
448
- removeAllKeyframes(selection, animId),
449
- selection,
450
- "remove-all-keyframes",
451
- "Remove all keyframes",
452
- );
453
+ return removeAllKeyframes(selection, animId, "remove_all", telemetry).catch((error) => {
454
+ trackGsapHandlerFailure(error, selection, "remove-all-keyframes", "Remove all keyframes");
455
+ return false;
456
+ });
453
457
  },
454
- [resolveWriteSelection, observeGsapMutation, removeAllKeyframes],
458
+ [resolveWriteSelection, trackGsapHandlerFailure, removeAllKeyframes],
455
459
  );
456
460
 
457
461
  const handleResetSelectedElementKeyframes = useCallback((): boolean => {
458
462
  if (!domEditSelection) return false;
459
463
  const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
460
464
  if (!withKeyframes) return false;
461
- observeGsapMutation(
462
- removeAllKeyframes(domEditSelection, withKeyframes.id),
463
- domEditSelection,
464
- "remove-all-keyframes",
465
- "Remove all keyframes",
466
- );
465
+ void removeAllKeyframes(domEditSelection, withKeyframes.id, "reset").catch((error) => {
466
+ trackGsapHandlerFailure(
467
+ error,
468
+ domEditSelection,
469
+ "remove-all-keyframes",
470
+ "Remove all keyframes",
471
+ );
472
+ });
467
473
  return true;
468
- }, [domEditSelection, observeGsapMutation, removeAllKeyframes, selectedGsapAnimations]);
474
+ }, [domEditSelection, trackGsapHandlerFailure, removeAllKeyframes, selectedGsapAnimations]);
469
475
 
470
476
  return {
471
477
  handleGsapUpdateProperty,
@@ -290,17 +290,15 @@ export function usePopulateKeyframeCacheForFile(
290
290
  const domClipChildrenKey = usePlayerStore((s) =>
291
291
  s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
292
292
  );
293
- const lastFetchKeyRef = useRef("");
293
+ const loadedRef = useRef<{ dataKey: string; files: Set<string> }>({
294
+ dataKey: "",
295
+ files: new Set(),
296
+ });
294
297
 
295
298
  const runtimeScanDoneRef = useRef("");
296
- const astFetchDoneRef = useRef("");
297
299
 
298
300
  useEffect(() => {
299
- const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}:${compositionSrcKey}`;
300
- if (fetchKey === lastFetchKeyRef.current) return;
301
- lastFetchKeyRef.current = fetchKey;
302
- runtimeScanDoneRef.current = "";
303
- astFetchDoneRef.current = "";
301
+ const dataKey = `kf-cache:${projectId}:${version}:${elementCount}:${domClipChildrenKey}:${compositionSrcKey}`;
304
302
  if (!projectId) return;
305
303
 
306
304
  // The active file first: it owns the selection, and each file clears only
@@ -309,13 +307,21 @@ export function usePopulateKeyframeCacheForFile(
309
307
  const files = Array.from(
310
308
  new Set([sourceFile, ...(compositionSrcKey ? compositionSrcKey.split("|") : [])]),
311
309
  );
312
- const doc = iframeRef?.current?.contentDocument;
310
+ const sameData = loadedRef.current.dataKey === dataKey;
311
+ const stale = sameData ? files.filter((sf) => !loadedRef.current.files.has(sf)) : files;
312
+ const covered = sameData ? new Set([...loadedRef.current.files, ...files]) : new Set(files);
313
+ loadedRef.current = { dataKey, files: covered };
313
314
  // Everything the previous scan cached for a file this one no longer covers
314
315
  // (the composition just switched away from) has no owner left to clear it.
315
- pruneKeyframeCacheToFiles(files);
316
- Promise.all(files.map((sf) => populateKeyframeCacheFromAst(projectId, sf, doc))).then(() => {
317
- astFetchDoneRef.current = fetchKey;
318
- });
316
+ if (!sameData) pruneKeyframeCacheToFiles(files);
317
+ if (stale.length === 0) return;
318
+ runtimeScanDoneRef.current = "";
319
+ const doc = iframeRef?.current?.contentDocument;
320
+ for (const sf of stale) {
321
+ void populateKeyframeCacheFromAst(projectId, sf, doc).then((loaded) => {
322
+ if (!loaded && loadedRef.current.dataKey === dataKey) loadedRef.current.files.delete(sf);
323
+ });
324
+ }
319
325
  // elementCount is in the deps because new timeline elements (e.g. after a
320
326
  // sub-composition expand) need their keyframe cache populated immediately;
321
327
  // without it the effect won't re-run when elements appear/disappear.
@@ -3,6 +3,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react";
3
3
  import type { LintFinding } from "../components/LintModal";
4
4
  import { usePlayerStore } from "../player";
5
5
  import { isPreviewBooted, whenPreviewBooted } from "../player/store/playerStore";
6
+ import { studioApiFetch } from "../utils/studioApiFetch";
6
7
 
7
8
  interface RawFinding {
8
9
  severity?: string;
@@ -25,7 +26,7 @@ function parseFinding(f: RawFinding): LintFinding & { elementId?: string; file?:
25
26
  }
26
27
 
27
28
  async function fetchLintFindings(projectId: string) {
28
- const res = await fetch(buildProjectApiPath(projectId, `/lint`));
29
+ const res = await studioApiFetch(buildProjectApiPath(projectId, `/lint`));
29
30
  const data = await res.json();
30
31
  return ((data.findings ?? []) as RawFinding[]).map(parseFinding);
31
32
  }