@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,552 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
+ import { usePlayerStore } from "../player/store/playerStore";
6
+ import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
7
+ import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
8
+ import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
9
+ import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
10
+ import { planValueAtPlayhead, planValueEdit, type PlayheadEdit } from "./gsapValueAtPlayhead";
11
+
12
+ let el: HTMLElement;
13
+ let selection: DomEditSelection;
14
+ beforeEach(() => {
15
+ el = document.createElement("div");
16
+ el.id = "box";
17
+ document.body.append(el);
18
+ selection = { id: "box", selector: "#box", element: el } as DomEditSelection;
19
+ });
20
+ afterEach(() => {
21
+ el.remove();
22
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
23
+ });
24
+
25
+ /** Drags `#box` by `dx` from a pre-gesture GSAP position of `base`, at `time`. */
26
+ async function drag(
27
+ anims: GsapAnimation[],
28
+ live: unknown[],
29
+ {
30
+ time,
31
+ base,
32
+ dx,
33
+ values = {},
34
+ }: { time: number; base: [number, number]; dx: number; values?: Record<string, number> },
35
+ ) {
36
+ usePlayerStore.setState({ currentTime: time });
37
+ el.setAttribute("data-hf-drag-gsap-base-x", String(base[0]));
38
+ el.setAttribute("data-hf-drag-gsap-base-y", String(base[1]));
39
+ const commitMutation = vi.fn();
40
+ const outcome = await tryGsapDragIntercept(
41
+ selection,
42
+ { x: dx, y: 0 },
43
+ anims,
44
+ previewWith(el, live, values),
45
+ commitMutation,
46
+ async () => anims,
47
+ );
48
+ return { outcome, mutations: commitMutation.mock.calls.map((call) => call[1]) };
49
+ }
50
+
51
+ const fromX = tween({
52
+ id: "#box-from-0-position",
53
+ method: "from",
54
+ properties: { x: -60 },
55
+ resolvedStart: 0,
56
+ duration: 2,
57
+ ease: "none",
58
+ });
59
+ const fromXLive = (end: number) =>
60
+ liveTween(
61
+ el,
62
+ { start: 0, duration: 2, vars: { x: -60, ease: "none" }, ends: { x: [-60, end] } },
63
+ { from: true },
64
+ );
65
+
66
+ /** Two linear `to` tweens of `group` on `#box`, 0-2s then 2-3s, their live copies and a commit recorder. */
67
+ function backToBack(
68
+ group: "size" | "scale" | "rotation",
69
+ firstEnd: Record<string, number>,
70
+ secondEnd: Record<string, number>,
71
+ ) {
72
+ const flat = (start: number, duration: number, properties: Record<string, number>) =>
73
+ tween({
74
+ id: `#box-to-${start * 1000}-${group}`,
75
+ propertyGroup: group,
76
+ method: "to",
77
+ properties,
78
+ resolvedStart: start,
79
+ duration,
80
+ ease: "none",
81
+ });
82
+ const tweens = [flat(0, 2, firstEnd), flat(2, 1, secondEnd)];
83
+ const live = tweens.map((a) =>
84
+ liveTween(el, { start: a.resolvedStart!, duration: a.duration!, vars: a.properties }),
85
+ );
86
+ const commitMutation = vi.fn();
87
+ const written = () => commitMutation.mock.calls.map((call) => call[1]);
88
+ return { tweens, live, commitMutation, written };
89
+ }
90
+
91
+ describe("a move on a GSAP-animated layer, at the playhead", () => {
92
+ it("adds a keyframe inside a from() tween and keeps its end where GSAP parsed it", async () => {
93
+ const { outcome, mutations } = await drag([fromX], [fromXLive(40)], {
94
+ time: 1,
95
+ base: [-10, 0],
96
+ dx: 30,
97
+ });
98
+ expect(outcome).toEqual({ status: "persisted" });
99
+ expect(mutations).toEqual([
100
+ expect.objectContaining({
101
+ type: "replace-with-keyframes",
102
+ animationId: "#box-from-0-position",
103
+ position: 0,
104
+ duration: 2,
105
+ easeEach: "none",
106
+ keyframes: [
107
+ { percentage: 0, properties: { x: -60, y: 0 } },
108
+ { percentage: 50, properties: { x: 20, y: 0 } },
109
+ { percentage: 100, properties: { x: 40, y: 0 } },
110
+ ],
111
+ }),
112
+ ]);
113
+ });
114
+
115
+ it("writes the end GSAP parsed from the file, not the dragged element's live value", async () => {
116
+ const { mutations } = await drag([fromX], [fromXLive(40)], {
117
+ time: 1,
118
+ base: [-10, 0],
119
+ dx: 30,
120
+ values: { x: 999 },
121
+ });
122
+ expect(mutations[0].keyframes.at(-1)).toEqual({ percentage: 100, properties: { x: 40, y: 0 } });
123
+ });
124
+
125
+ it("extends a to() tween back to the playhead, holding the start GSAP folded from CSS translate", async () => {
126
+ const toX = tween({
127
+ id: "#box-to-2000-position",
128
+ method: "to",
129
+ properties: { x: 60 },
130
+ resolvedStart: 2,
131
+ duration: 1,
132
+ ease: "none",
133
+ });
134
+ // `translate: 40px 30px` on the element: GSAP parses x from 40, not 0.
135
+ const live = liveTween(el, { start: 2, duration: 1, vars: { x: 60 }, ends: { x: [40, 60] } });
136
+ const { mutations } = await drag([toX], [live], { time: 1, base: [40, 0], dx: -20 });
137
+ expect(mutations).toEqual([
138
+ expect.objectContaining({
139
+ animationId: "#box-to-2000-position",
140
+ position: 1,
141
+ duration: 2,
142
+ easeEach: "none",
143
+ keyframes: [
144
+ { percentage: 0, properties: { x: 20, y: 0 } },
145
+ { percentage: 50, properties: { x: 40, y: 0 } },
146
+ { percentage: 100, properties: { x: 60, y: 0 } },
147
+ ],
148
+ }),
149
+ ]);
150
+ });
151
+
152
+ it("refuses, with the Code tab message, when GSAP has not parsed the tween's end yet", async () => {
153
+ const unparsed = liveTween(el, { start: 0, duration: 2, vars: { x: -60 } }, { from: true });
154
+ const { outcome, mutations } = await drag([fromX], [unparsed], {
155
+ time: 1,
156
+ base: [-10, 0],
157
+ dx: 30,
158
+ });
159
+ expect(outcome).toEqual({
160
+ status: "blocked",
161
+ reason: "keyframes-uneditable",
162
+ detail: "implicit-end-unknown",
163
+ });
164
+ expect(mutations).toEqual([]);
165
+ expect(GSAP_EDIT_BLOCK_COPY["keyframes-uneditable"]).toContain("Code tab");
166
+ });
167
+
168
+ it("adds a keyframe between two steps of a keyframes array", async () => {
169
+ const keys = tween({
170
+ id: "#box-to-0-position",
171
+ method: "to",
172
+ properties: {},
173
+ resolvedStart: 0,
174
+ duration: 3,
175
+ keyframes: {
176
+ format: "object-array",
177
+ keyframes: [
178
+ // The parse rounds the step to 66.7%; GSAP times it at 2 s of 3.
179
+ { percentage: 66.7, properties: { x: 60 } },
180
+ { percentage: 100, properties: { x: 120 } },
181
+ ],
182
+ },
183
+ });
184
+ const steps = [
185
+ { startTime: () => 0, duration: () => 2 },
186
+ { startTime: () => 2, duration: () => 1 },
187
+ ];
188
+ const live = liveTween(
189
+ el,
190
+ { start: 0, duration: 3, vars: { keyframes: [] } },
191
+ { parts: steps },
192
+ );
193
+ const { mutations } = await drag([keys], [live], { time: 1, base: [30, 0], dx: 10 });
194
+ expect(mutations.map((m) => m.type)).toEqual(["replace-with-keyframes"]);
195
+ // An array step eases linearly unless it says otherwise; percentage keyframes would not.
196
+ expect(mutations[0].keyframes).toEqual([
197
+ { percentage: 33.333, properties: { x: 40, y: 0 }, ease: "none" },
198
+ { percentage: 66.667, properties: { x: 60, y: 0 }, ease: "none" },
199
+ { percentage: 100, properties: { x: 120, y: 0 }, ease: "none" },
200
+ ]);
201
+ });
202
+
203
+ it("never writes x and y into a tween that only animates size", async () => {
204
+ const size = tween({
205
+ id: "#box-to-0-size",
206
+ propertyGroup: "size",
207
+ method: "to",
208
+ properties: {},
209
+ resolvedStart: 0,
210
+ duration: 2,
211
+ keyframes: {
212
+ format: "percentage",
213
+ keyframes: [{ percentage: 100, properties: { width: 300 } }],
214
+ },
215
+ });
216
+ const live = liveTween(el, {
217
+ start: 0,
218
+ duration: 2,
219
+ vars: { keyframes: { "100%": { width: 300 } } },
220
+ });
221
+ const { mutations } = await drag([size], [live], { time: 1, base: [0, 0], dx: 10 });
222
+ expect(mutations.some((m) => m.animationId === "#box-to-0-size")).toBe(false);
223
+ });
224
+ });
225
+
226
+ describe("where two tweens meet at the playhead", () => {
227
+ it("edits the one whose keyframe the file states there, not the one listed first", async () => {
228
+ const fromTo = tween({
229
+ id: "#box-fromTo-2000-position",
230
+ method: "fromTo",
231
+ fromProperties: { x: 0 },
232
+ properties: { x: 60 },
233
+ resolvedStart: 2,
234
+ duration: 1,
235
+ ease: "none",
236
+ });
237
+ const live = liveTween(el, { start: 2, duration: 1, vars: { x: 60 }, ends: { x: [0, 60] } });
238
+ const { mutations } = await drag([fromX, fromTo], [fromXLive(40), live], {
239
+ time: 2,
240
+ base: [0, 0],
241
+ dx: 25,
242
+ });
243
+ expect(mutations).toEqual([
244
+ expect.objectContaining({
245
+ animationId: "#box-fromTo-2000-position",
246
+ easeEach: "none",
247
+ keyframes: [
248
+ { percentage: 0, properties: { x: 25, y: 0 } },
249
+ { percentage: 100, properties: { x: 60, y: 0 } },
250
+ ],
251
+ }),
252
+ ]);
253
+ });
254
+ });
255
+
256
+ describe("a resize on a layer whose size GSAP animates, at the playhead", () => {
257
+ it("adds a size keyframe inside the tween at the playhead", async () => {
258
+ const { tweens, live, commitMutation, written } = backToBack(
259
+ "size",
260
+ { width: 300, height: 200 },
261
+ { width: 360, height: 240 },
262
+ );
263
+ usePlayerStore.setState({ currentTime: 1 });
264
+ await tryGsapResizeIntercept(
265
+ selection,
266
+ { width: 330, height: 210 },
267
+ tweens,
268
+ previewWith(el, live),
269
+ commitMutation,
270
+ );
271
+ expect(written()).toEqual([
272
+ expect.objectContaining({
273
+ type: "replace-with-keyframes",
274
+ animationId: "#box-to-0-size",
275
+ easeEach: "none",
276
+ keyframes: [
277
+ { percentage: 50, properties: { width: 330, height: 210 } },
278
+ { percentage: 100, properties: { width: 300, height: 200 } },
279
+ ],
280
+ }),
281
+ ]);
282
+ });
283
+ });
284
+
285
+ describe("a resize on a layer whose scale GSAP animates, at the playhead", () => {
286
+ it("adds a scale keyframe inside the tween at the playhead and leaves the next tween alone", async () => {
287
+ const { tweens, live, commitMutation, written } = backToBack(
288
+ "scale",
289
+ { scale: 1.25 },
290
+ { scale: 1.5 },
291
+ );
292
+ el.setAttribute("data-hf-studio-original-box-width", "240");
293
+ el.setAttribute("data-hf-studio-original-box-height", "160");
294
+ usePlayerStore.setState({ currentTime: 1 });
295
+ // Dropped at 1.5x the box with a live scale of 1.125, the playhead's value of the first tween.
296
+ await tryGsapResizeIntercept(
297
+ selection,
298
+ { width: 320, height: 213.333 },
299
+ tweens,
300
+ previewWith(el, live, { scaleX: 1.125, scaleY: 1.125 }),
301
+ commitMutation,
302
+ );
303
+ const writes = written();
304
+ expect(writes[0]).toMatchObject({
305
+ type: "replace-with-keyframes",
306
+ animationId: "#box-to-0-scale",
307
+ easeEach: "none",
308
+ keyframes: [
309
+ { percentage: 50, properties: { scale: 1.5 } },
310
+ { percentage: 100, properties: { scale: 1.25 } },
311
+ ],
312
+ });
313
+ expect(writes.some((m) => m.animationId === "#box-to-2000-scale")).toBe(false);
314
+ });
315
+ });
316
+
317
+ describe("a resize inside one gesture, on a tween that mixes size and position", () => {
318
+ it("writes the size and the anchor move once, into the tween the file has", async () => {
319
+ const keys = tween({
320
+ id: "#box-to-0",
321
+ propertyGroup: undefined,
322
+ method: "to",
323
+ properties: {},
324
+ resolvedStart: 0,
325
+ duration: 3,
326
+ keyframes: {
327
+ format: "object-array",
328
+ keyframes: [
329
+ { percentage: 66.667, properties: { x: 60, width: 280 }, ease: "none" },
330
+ { percentage: 100, properties: { x: 120, width: 320 }, ease: "none" },
331
+ ],
332
+ },
333
+ });
334
+ el.setAttribute("data-hf-studio-original-box-width", "260");
335
+ el.setAttribute("data-hf-studio-original-box-height", "160");
336
+ el.setAttribute("data-hf-drag-gsap-base-x", "50");
337
+ el.setAttribute("data-hf-drag-gsap-base-y", "30");
338
+ const live = liveTween(el, { start: 0, duration: 3, vars: { keyframes: [] } });
339
+ usePlayerStore.setState({ currentTime: 1 });
340
+ // A gesture buffers its writes: the file the intercept reads back stays as it was.
341
+ const commitMutation = vi.fn();
342
+ const outcome = await tryGsapResizeIntercept(
343
+ selection,
344
+ { width: 300, height: 200 },
345
+ [keys],
346
+ previewWith(el, [live]),
347
+ commitMutation,
348
+ async () => [keys],
349
+ { x: -40, y: -40 },
350
+ );
351
+ expect(outcome).toMatchObject({ status: "persisted", ownsDragOffset: true });
352
+ expect(commitMutation.mock.calls.map((call) => call[1])).toEqual([
353
+ expect.objectContaining({
354
+ type: "replace-with-keyframes",
355
+ animationId: "#box-to-0",
356
+ keyframes: [
357
+ {
358
+ percentage: 33.333,
359
+ properties: { width: 300, height: 200, x: 10, y: -10 },
360
+ ease: "none",
361
+ },
362
+ {
363
+ percentage: 66.667,
364
+ properties: { x: 60, width: 280, height: 160, y: 30 },
365
+ ease: "none",
366
+ },
367
+ { percentage: 100, properties: { x: 120, width: 320, height: 160, y: 30 }, ease: "none" },
368
+ ],
369
+ }),
370
+ ]);
371
+ });
372
+ });
373
+
374
+ describe("a rotate on a GSAP-animated layer, at the playhead", () => {
375
+ it("changes the keyframe two flat tweens meet at, on the tween that states it", async () => {
376
+ const { tweens, live, commitMutation, written } = backToBack(
377
+ "rotation",
378
+ { rotation: 20 },
379
+ { rotation: 40 },
380
+ );
381
+ usePlayerStore.setState({ currentTime: 2 });
382
+ await tryGsapRotationIntercept(selection, 25, tweens, previewWith(el, live), commitMutation);
383
+ expect(written()).toEqual([
384
+ expect.objectContaining({
385
+ type: "replace-with-keyframes",
386
+ animationId: "#box-to-0-rotation",
387
+ position: 0,
388
+ duration: 2,
389
+ easeEach: "none",
390
+ keyframes: [{ percentage: 100, properties: { rotation: 25 } }],
391
+ }),
392
+ ]);
393
+ });
394
+ });
395
+
396
+ describe("a rotate on a layer whose tween turns it on another axis", () => {
397
+ it("holds the new rotation across the whole tween, so the layer does not turn before the playhead", async () => {
398
+ const flip = tween({
399
+ id: "#box-to-0-rotation",
400
+ propertyGroup: "rotation",
401
+ method: "to",
402
+ properties: { rotationY: 180 },
403
+ resolvedStart: 0,
404
+ duration: 2,
405
+ ease: "none",
406
+ });
407
+ usePlayerStore.setState({ currentTime: 1 });
408
+ const commitMutation = vi.fn();
409
+ const live = [liveTween(el, { start: 0, duration: 2, vars: flip.properties })];
410
+ await tryGsapRotationIntercept(selection, 45, [flip], previewWith(el, live), commitMutation);
411
+ expect(commitMutation.mock.calls.map((call) => call[1].keyframes)).toEqual([
412
+ [
413
+ { percentage: 0, properties: { rotation: 45 } },
414
+ { percentage: 50, properties: { rotation: 45 } },
415
+ { percentage: 100, properties: { rotationY: 180, rotation: 45 } },
416
+ ],
417
+ ]);
418
+ });
419
+ });
420
+
421
+ describe("planValueAtPlayhead", () => {
422
+ const plan = (edit: Partial<PlayheadEdit>) =>
423
+ planValueAtPlayhead({
424
+ anim: tween({ method: "to", properties: {}, resolvedStart: 0, duration: 4 }),
425
+ at: { time: 2 },
426
+ values: {},
427
+ implicitEndValue: () => null,
428
+ ...edit,
429
+ });
430
+ const kf = (
431
+ keyframes: Array<{ percentage: number; properties: Record<string, number> }>,
432
+ easeEach?: string,
433
+ ) =>
434
+ tween({
435
+ method: "to",
436
+ properties: {},
437
+ resolvedStart: 0,
438
+ duration: 4,
439
+ keyframes: { format: "percentage", keyframes, ...(easeEach && { easeEach }) },
440
+ });
441
+
442
+ it("changes the keyframe under the playhead and no other", () => {
443
+ const result = plan({
444
+ anim: kf([
445
+ { percentage: 0, properties: { x: 0 } },
446
+ { percentage: 50, properties: { x: 10 } },
447
+ { percentage: 100, properties: { x: 20 } },
448
+ ]),
449
+ values: { x: 15 },
450
+ });
451
+ expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([0, 15, 20]);
452
+ });
453
+
454
+ it("reports an add only when it writes a keyframe the tween did not have", () => {
455
+ const changed = plan({
456
+ anim: kf([
457
+ { percentage: 0, properties: { x: 0 } },
458
+ { percentage: 100, properties: { x: 20 } },
459
+ ]),
460
+ at: { time: 4 },
461
+ values: { x: 15 },
462
+ });
463
+ const held = plan({
464
+ anim: kf([{ percentage: 100, properties: { x: 20 } }]),
465
+ at: { time: 4 },
466
+ values: { y: 5 },
467
+ backfill: { y: 0 },
468
+ holdFromStart: true,
469
+ });
470
+ expect(changed.ok && changed.added).toBe(false);
471
+ expect(held.ok && held.mutation.keyframes.length).toBe(2);
472
+ expect(held.ok && held.added).toBe(true);
473
+ });
474
+
475
+ it("eases the new segment like the one it splits", () => {
476
+ const result = plan({
477
+ anim: kf([{ percentage: 100, properties: { x: 20 } }], "power2.in"),
478
+ at: { time: 1 },
479
+ values: { x: 5 },
480
+ });
481
+ expect(result.ok && result.mutation).toMatchObject({
482
+ easeEach: "power2.in",
483
+ keyframes: [
484
+ { percentage: 25, properties: { x: 5 } },
485
+ { percentage: 100, properties: { x: 20 } },
486
+ ],
487
+ });
488
+ });
489
+
490
+ it("extends past the end and keeps the authored end where it was", () => {
491
+ const result = plan({
492
+ anim: tween({
493
+ method: "to",
494
+ properties: { width: 300 },
495
+ resolvedStart: 0,
496
+ duration: 2,
497
+ ease: "none",
498
+ }),
499
+ at: { time: 3 },
500
+ values: { width: 400 },
501
+ });
502
+ expect(result.ok && result.mutation).toMatchObject({
503
+ position: 0,
504
+ duration: 3,
505
+ easeEach: "none",
506
+ keyframes: [
507
+ { percentage: 66.667, properties: { width: 300 } },
508
+ { percentage: 100, properties: { width: 400 } },
509
+ ],
510
+ });
511
+ });
512
+
513
+ it("splits a uniform scale into longhands for a per-axis edit", () => {
514
+ const result = plan({
515
+ anim: tween({
516
+ method: "to",
517
+ properties: { scale: 1.25 },
518
+ resolvedStart: 0,
519
+ duration: 4,
520
+ ease: "none",
521
+ }),
522
+ values: { scaleX: 1.5, scaleY: 1.1 },
523
+ });
524
+ expect(result.ok && result.mutation.keyframes).toEqual([
525
+ { percentage: 50, properties: { scaleX: 1.5, scaleY: 1.1 } },
526
+ { percentage: 100, properties: { scaleX: 1.25, scaleY: 1.25 } },
527
+ ]);
528
+ });
529
+
530
+ it("refuses keyframes eased as a whole, whose segment curves it cannot keep", () => {
531
+ const anim = { ...kf([{ percentage: 100, properties: { x: 20 } }]), ease: "power2.out" };
532
+ expect(plan({ anim, values: { x: 5 } })).toEqual({ ok: false, reason: "eased-keyframes" });
533
+ });
534
+ });
535
+
536
+ it("refuses a tween whose selector also animates a sibling, before it plans anything", () => {
537
+ el.className = "card";
538
+ const sibling = document.body.appendChild(document.createElement("div"));
539
+ sibling.className = "card";
540
+ const shared = tween({
541
+ targetSelector: ".card",
542
+ properties: { x: 100 },
543
+ resolvedStart: 0,
544
+ duration: 1,
545
+ });
546
+
547
+ expect(planValueEdit(selection, shared, { x: 40 }, null)).toEqual({
548
+ ok: false,
549
+ reason: "shared-tween",
550
+ });
551
+ sibling.remove();
552
+ });