@hyperframes/studio 0.8.115 → 0.8.117

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 (163) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
  3. package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +132 -41
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1184 -1084
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/DomEditOverlay.tsx +1 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  17. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  18. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  19. package/src/components/editor/domGroupMoveBy.ts +95 -0
  20. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  21. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  22. package/src/components/editor/manualEditingAvailability.ts +8 -0
  23. package/src/components/editor/manualOffsetDrag.ts +8 -9
  24. package/src/components/editor/motionPathSelection.test.ts +6 -31
  25. package/src/components/editor/plainTranslate.ts +5 -3
  26. package/src/components/editor/probeSourceElement.ts +2 -1
  27. package/src/components/editor/propertyPanelFont.tsx +3 -2
  28. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  29. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  30. package/src/components/editor/rotationDraft.ts +2 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -1
  32. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  33. package/src/components/editor/useFxCarveNodes.ts +2 -1
  34. package/src/components/editor/useFxLevelling.ts +2 -1
  35. package/src/components/feedback/projectProvenance.ts +2 -1
  36. package/src/components/nle/NLEContext.test.ts +1 -0
  37. package/src/components/nle/NLEContext.tsx +2 -1
  38. package/src/components/nle/NLEPreview.tsx +12 -10
  39. package/src/components/nle/PreviewPoster.tsx +2 -1
  40. package/src/components/renders/useFfmpegStatus.ts +2 -1
  41. package/src/components/renders/useRenderQueue.ts +5 -4
  42. package/src/components/sidebar/AssetsTab.tsx +2 -1
  43. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  44. package/src/contexts/DomEditContext.tsx +4 -0
  45. package/src/hooks/domSelectionTestHarness.ts +23 -0
  46. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  47. package/src/hooks/domStyleCommit.ts +8 -1
  48. package/src/hooks/draggedGsapPosition.ts +10 -0
  49. package/src/hooks/gsapDragCommit.test.ts +75 -164
  50. package/src/hooks/gsapDragCommit.ts +2 -105
  51. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  52. package/src/hooks/gsapEditOutcome.ts +22 -2
  53. package/src/hooks/gsapGroupPreflight.ts +34 -0
  54. package/src/hooks/gsapMutationClient.ts +4 -3
  55. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  56. package/src/hooks/gsapParsedTween.test.ts +62 -0
  57. package/src/hooks/gsapParsedTween.ts +243 -0
  58. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  59. package/src/hooks/gsapPositionDetection.ts +52 -33
  60. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  61. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  62. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  63. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  64. package/src/hooks/gsapResizeIntercept.ts +79 -169
  65. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  66. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  67. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  68. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  69. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  70. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  71. package/src/hooks/gsapScriptCommitTypes.ts +9 -1
  72. package/src/hooks/gsapShared.ts +7 -0
  73. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  74. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  75. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  76. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  77. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  78. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  79. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  80. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  81. package/src/hooks/newTweenTarget.test.ts +41 -76
  82. package/src/hooks/plainRotation.ts +5 -1
  83. package/src/hooks/timelineTimingSync.test.ts +1 -0
  84. package/src/hooks/timelineTimingSync.ts +2 -1
  85. package/src/hooks/useAskAgentModal.ts +2 -1
  86. package/src/hooks/useBlockCatalog.ts +2 -1
  87. package/src/hooks/useCaptionDetection.ts +2 -1
  88. package/src/hooks/useCompositionContentLoader.ts +2 -1
  89. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  90. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  91. package/src/hooks/useDomEditSession.test.tsx +30 -1
  92. package/src/hooks/useDomEditSession.ts +10 -0
  93. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  94. package/src/hooks/useDomEditTextCommits.ts +8 -2
  95. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  96. package/src/hooks/useDomGeometryCommit.ts +7 -9
  97. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  98. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  99. package/src/hooks/useEditHistoryActions.ts +16 -5
  100. package/src/hooks/useElementLifecycleOps.ts +2 -1
  101. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  102. package/src/hooks/useEnableKeyframes.ts +2 -1
  103. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  104. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  105. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  106. package/src/hooks/useFileManager.ts +14 -10
  107. package/src/hooks/useFileTree.ts +3 -2
  108. package/src/hooks/useFrameCapture.ts +5 -1
  109. package/src/hooks/useFreezeFrame.test.ts +39 -3
  110. package/src/hooks/useFreezeFrame.ts +7 -3
  111. package/src/hooks/useGroupCommits.ts +2 -1
  112. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  113. package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
  114. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  115. package/src/hooks/useGsapAwareEditing.ts +17 -8
  116. package/src/hooks/useGsapAwareGroupMove.ts +18 -18
  117. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  118. package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
  119. package/src/hooks/useGsapScriptCommits.ts +21 -17
  120. package/src/hooks/useLintModal.ts +2 -1
  121. package/src/hooks/usePersistentEditHistory.test.ts +302 -3
  122. package/src/hooks/usePersistentEditHistory.ts +50 -17
  123. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  124. package/src/hooks/usePreviewPersistence.ts +13 -11
  125. package/src/hooks/useProjectFileWriter.ts +20 -19
  126. package/src/hooks/useRemoveBackground.ts +2 -1
  127. package/src/hooks/useSdkSession.ts +2 -1
  128. package/src/hooks/useServerConnection.ts +4 -3
  129. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  130. package/src/hooks/useThumbnailStill.test.ts +100 -0
  131. package/src/hooks/useThumbnailStill.ts +37 -0
  132. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  133. package/src/index.ts +4 -0
  134. package/src/player/components/AudioWaveform.tsx +3 -2
  135. package/src/player/components/CompositionThumbnail.tsx +2 -1
  136. package/src/player/components/clipMenuNormalize.ts +2 -1
  137. package/src/player/components/clipPeakMap.ts +2 -1
  138. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  139. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  140. package/src/player/lib/thumbnailScheduler.ts +10 -2
  141. package/src/player/sceneSwap.ts +2 -1
  142. package/src/telemetry/client.ts +2 -1
  143. package/src/utils/blockInstaller.ts +2 -1
  144. package/src/utils/elementGsap.test.ts +9 -0
  145. package/src/utils/elementGsap.ts +7 -0
  146. package/src/utils/gsapSoftReload.test.ts +39 -0
  147. package/src/utils/gsapSoftReload.ts +9 -14
  148. package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
  149. package/src/utils/gsapUndoRestore.ts +3 -6
  150. package/src/utils/previewPrefetch.ts +2 -1
  151. package/src/utils/razorSplitTransaction.ts +12 -8
  152. package/src/utils/softReloadTargets.ts +5 -6
  153. package/src/utils/studioApiFetch.test.ts +75 -0
  154. package/src/utils/studioApiFetch.ts +13 -0
  155. package/src/utils/studioFileHistory.ts +5 -1
  156. package/src/utils/studioHelpers.ts +7 -1
  157. package/src/utils/studioPendingEdits.test.ts +23 -6
  158. package/src/utils/studioPendingEdits.ts +32 -8
  159. package/src/utils/studioTelemetry.ts +2 -1
  160. package/src/utils/timelineCompositionInsert.ts +3 -2
  161. package/src/webmcp/StudioAgentTools.tsx +2 -1
  162. package/dist/chunk-TXZMMOMA.js.map +0 -1
  163. /package/dist/{domEditingLayers-D7VYFJJD.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
+ });