@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
@@ -9,12 +9,13 @@
9
9
  * easing, or seek position.
10
10
  */
11
11
  import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
12
- import { isXYPositionWrite } from "@hyperframes/parsers/gsap-constants";
12
+ import { isXYPositionWrite, PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
13
13
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
14
14
  import { usePlayerStore } from "../player/store/playerStore";
15
15
 
16
16
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
17
- import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
17
+ import { commitGsapPositionFromDrag, gsapPositionFromDragOutcome } from "./gsapDragPositionCommit";
18
+ import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
18
19
  import {
19
20
  commitStaticGsapPosition,
20
21
  commitStaticGsapRotation,
@@ -22,7 +23,6 @@ import {
22
23
  computeCurrentPercentage,
23
24
  findExistingPositionWrite,
24
25
  findRotationSetAnimation,
25
- materializeIfDynamic,
26
26
  } from "./gsapDragCommit";
27
27
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
28
28
  import { isGestureTransactionCommit } from "./gestureTransaction";
@@ -54,64 +54,29 @@ const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
54
54
  // ── Property-group tween resolution ───────────────────────────────────────
55
55
 
56
56
  /**
57
- * Find the tween for a given property group, splitting a legacy mixed tween
58
- * if necessary. Returns the resolved animation or null if none exists.
59
- *
60
- * Resolution order:
61
- * 1. Tween already tagged with `propertyGroup === group`
62
- * 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation,
63
- * re-fetch, then return the group tween
64
- * 3. null — caller must handle the missing-tween case
57
+ * The tween to edit for a property group: a tween tagged with it, else a legacy tween that mixes
58
+ * it with other groups. A mixed tween is edited in place, never split first: inside a gesture the
59
+ * split is only buffered, so ids read after it are stale and the edit lands beside the old tween.
65
60
  */
66
61
  export async function resolveGroupTween(
67
62
  group: PropertyGroupName,
68
63
  animations: GsapAnimation[],
69
- selection: DomEditSelection,
70
- commitMutation: GsapDragCommitCallbacks["commitMutation"],
64
+ _selection: DomEditSelection,
65
+ _commitMutation: GsapDragCommitCallbacks["commitMutation"],
71
66
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
72
67
  ): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
73
- // 1. Already-split group tween — pick the one closest to the current
74
- // playhead so a drag at t=6s edits the tween at 4s, not the one at 1.5s.
75
- const groupAnims = animations.filter((a) => a.propertyGroup === group);
76
- const groupAnim = pickClosestToPlayhead(groupAnims);
77
- if (groupAnim) return { anim: groupAnim, animations };
78
-
79
- // 2. Legacy mixed tween — split it, then re-fetch
80
- const legacyMixed = animations.find((a) => !a.propertyGroup);
81
- if (legacyMixed) {
82
- await commitMutation(
83
- selection,
84
- { type: "split-into-property-groups", animationId: legacyMixed.id },
85
- { label: "Split mixed tween into property groups", skipReload: true },
86
- );
87
- if (fetchFallbackAnimations) {
88
- const fresh = await fetchFallbackAnimations();
89
- const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
90
- if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
91
- }
92
- }
93
-
94
- // 3. Try fallback fetch (no split needed, just wasn't in the initial list)
95
- if (!legacyMixed && fetchFallbackAnimations) {
96
- const fresh = await fetchFallbackAnimations();
97
- const freshGroupAnim = fresh.find((a) => a.propertyGroup === group);
98
- if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh };
99
-
100
- // Fallback: legacy mixed in the fresh list
101
- const freshLegacy = fresh.find((a) => !a.propertyGroup);
102
- if (freshLegacy) {
103
- await commitMutation(
104
- selection,
105
- { type: "split-into-property-groups", animationId: freshLegacy.id },
106
- { label: "Split mixed tween into property groups", skipReload: true },
107
- );
108
- const reFetched = await fetchFallbackAnimations();
109
- const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group);
110
- if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched };
111
- }
112
- }
113
-
114
- return null;
68
+ const inGroup = (list: GsapAnimation[]) => {
69
+ const tagged = list.filter((a) => a.propertyGroup === group);
70
+ const props = new Set(PROPERTY_GROUPS[group]);
71
+ const mixed = list.filter((a) => !a.propertyGroup && animationWritesAnyProperty(a, props));
72
+ return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed);
73
+ };
74
+ const anim = inGroup(animations);
75
+ if (anim) return { anim, animations };
76
+ if (!fetchFallbackAnimations) return null;
77
+ const fresh = await fetchFallbackAnimations();
78
+ const freshAnim = inGroup(fresh);
79
+ return freshAnim ? { anim: freshAnim, animations: fresh } : null;
115
80
  }
116
81
 
117
82
  // ── High-level intercept ───────────────────────────────────────────────────
@@ -205,6 +170,59 @@ function oneUndoStep(
205
170
  commit(selection, mutation, { ...options, coalesceKey, coalesceMs: Number.POSITIVE_INFINITY });
206
171
  }
207
172
 
173
+ const isPositionWriteOf = (selector: string) => (a: GsapAnimation) =>
174
+ a.targetSelector === selector && isXYPositionWrite(a);
175
+
176
+ /** The one position write a self-heal keeps when holds fight over `selector`, else null. */
177
+ function positionWriteKeeper(animations: GsapAnimation[], selector: string): GsapAnimation | null {
178
+ const dupes = animations.filter(isPositionWriteOf(selector));
179
+ // Real tweens one after another are a motion, not a conflict: only holds can fight.
180
+ if (dupes.length < 2 || dupes.filter((a) => !isInstantHold(a)).length > 1) return null;
181
+ return dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
182
+ }
183
+
184
+ /** Where a drag writes. With no live motion and no keyframed tween (a hold, or a
185
+ * zero-length keyframed tween) the position belongs in a `tl.set`, never keyframes. */
186
+ function dragRoute(
187
+ posAnim: GsapAnimation | null,
188
+ iframe: HTMLIFrameElement | null,
189
+ selector: string,
190
+ altKey?: boolean,
191
+ ): "static" | "whole-path" | "at-playhead" {
192
+ const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
193
+ const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
194
+ if (!hasNonHold && !hasKeyframedPosTween) return "static";
195
+ // Alt-drag shifts the whole path; with auto-keyframe off (#1808) that is the default.
196
+ return altKey || !usePlayerStore.getState().autoKeyframeEnabled ? "whole-path" : "at-playhead";
197
+ }
198
+
199
+ /** The commit's route and keyframe plan, writing nothing, so a group refuses whole. */
200
+ async function planDrag(
201
+ selection: DomEditSelection,
202
+ offset: { x: number; y: number },
203
+ allAnimations: GsapAnimation[],
204
+ iframe: HTMLIFrameElement | null,
205
+ options: { altKey?: boolean },
206
+ ): Promise<GsapEditOutcome> {
207
+ const selector = selectorFromSelection(selection);
208
+ if (!selector) return { status: "blocked", reason: "no-selector" };
209
+ const own = tweensForThisElement(selection, allAnimations);
210
+ const keeper = positionWriteKeeper(own, selector);
211
+ const animations = keeper
212
+ ? own.filter((a) => a === keeper || !isPositionWriteOf(selector)(a))
213
+ : own;
214
+ const resolved = await resolveGroupTween("position", animations, selection, async () => {});
215
+ const posAnim = resolved?.anim ?? findGsapPositionAnimation(animations, selector);
216
+ const route = dragRoute(posAnim, iframe, selector, options.altKey);
217
+ if (route === "static") return { status: "persisted" };
218
+ if (!posAnim) {
219
+ return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
220
+ }
221
+ if (route === "whole-path") return { status: "persisted" };
222
+ const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
223
+ return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
224
+ }
225
+
208
226
  /** Commits a drag through the GSAP script. Callers reject `blocked` (the gesture layer
209
227
  * restores its drafts) and save `element-offset` on the element itself. */
210
228
  export async function tryGsapDragIntercept(
@@ -229,7 +247,12 @@ export async function tryGsapDragIntercept(
229
247
  fetchAllAnimations,
230
248
  options?.group,
231
249
  );
232
- if (preflight.status !== "persisted" || options?.preflightOnly) return preflight;
250
+ if (preflight.status !== "persisted") return preflight;
251
+ if (options?.preflightOnly) {
252
+ return options.group
253
+ ? planDrag(selection, offset, allAnimations, iframe, options)
254
+ : preflight;
255
+ }
233
256
  }
234
257
  const animations = tweensForThisElement(selection, allAnimations);
235
258
  const fetchFallbackAnimations =
@@ -246,13 +269,10 @@ export async function tryGsapDragIntercept(
246
269
  // the live keyframed/real tween if present (else any), strip the rest, so the
247
270
  // commit below updates ONE write instead of fighting duplicates.
248
271
  let workingAnimations = animations;
249
- const isPosWrite = (a: GsapAnimation) => a.targetSelector === selector && isXYPositionWrite(a);
250
- if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) {
272
+ if (animations.filter(isPositionWriteOf(selector)).length > 1 && fetchFallbackAnimations) {
251
273
  const fresh = await fetchFallbackAnimations();
252
- const dupes = fresh.filter(isPosWrite);
253
- if (dupes.length > 1) {
254
- const keeper =
255
- dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!;
274
+ const keeper = positionWriteKeeper(fresh, selector);
275
+ if (keeper) {
256
276
  await commitMutation(
257
277
  selection,
258
278
  {
@@ -288,26 +308,8 @@ export async function tryGsapDragIntercept(
288
308
  }
289
309
 
290
310
  const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
291
-
292
- // STATIC case (single source of truth = GSAP timeline): the element has no LIVE
293
- // keyframed/tweened position motion. Use the strict non-hold check — a leftover
294
- // position-hold `set` (after a delete-all, or a stale parse that lags it) must
295
- // NOT count as live motion. Either way the position belongs in a
296
- // `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
297
- // place (idempotent), else add a new one. This also covers the stale-cache
298
- // phantom — committing a set is correct because the element genuinely has no live motion.
299
- const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
300
- // A KEYFRAMED position tween — even one that's currently a flat constant ("hold",
301
- // e.g. 0% and 100% identical) — is still an animation the user is building, so a
302
- // drag must add/update a keyframe, NOT fall back to a static `set`. Without this,
303
- // dragging an element whose position tween is constant writes a `gsap.set` that
304
- // fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The
305
- // static path is only for elements with NO keyframed position tween (truly static,
306
- // or just a leftover position-hold `set`).
307
- // A zero-duration keyframed tween is a static HOLD, not a live animation —
308
- // treat it as static so the drag heals it instead of feeding it more keyframes.
309
- const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
310
- if (!hasNonHold && !hasKeyframedPosTween) {
311
+ const route = dragRoute(posAnim, iframe, selector, options?.altKey);
312
+ if (route === "static") {
311
313
  const existingSet =
312
314
  posAnim && isInstantHold(posAnim) && posAnim.targetSelector === selector
313
315
  ? posAnim
@@ -329,27 +331,26 @@ export async function tryGsapDragIntercept(
329
331
  // current ID and avoid a stale-ID remove that creates duplicate tweens.
330
332
  if (fetchFallbackAnimations) {
331
333
  const fresh = await fetchFallbackAnimations();
332
- const freshMatch = fresh.find(
333
- (a) =>
334
- a.targetSelector === posAnim!.targetSelector &&
335
- a.propertyGroup === posAnim!.propertyGroup &&
336
- isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
337
- );
334
+ const freshMatch =
335
+ fresh.find((a) => a.id === posAnim!.id) ??
336
+ pickClosestToPlayhead(
337
+ fresh.filter(
338
+ (a) =>
339
+ a.targetSelector === posAnim!.targetSelector &&
340
+ a.propertyGroup === posAnim!.propertyGroup &&
341
+ isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
342
+ ),
343
+ );
338
344
  if (freshMatch && freshMatch.id !== posAnim.id) {
339
345
  posAnim = freshMatch;
340
346
  }
341
347
  }
342
348
 
343
349
  const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
344
- // Alt-drag already means "shift the whole path" — the global auto-keyframe
345
- // toggle (#1808) just makes that the default while it's off, so a manual
346
- // edit on an already-animated element nudges the animation instead of
347
- // inserting/updating a keyframe at the playhead.
348
- const autoKeyframeEnabled = usePlayerStore.getState().autoKeyframeEnabled;
349
- if (options?.altKey || !autoKeyframeEnabled) {
350
+ if (route === "whole-path") {
350
351
  await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
351
352
  } else {
352
- await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
353
+ return commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, cbs);
353
354
  }
354
355
  return { status: "persisted" };
355
356
  }
@@ -428,8 +429,9 @@ export async function tryGsapRotationIntercept(
428
429
  ? resolved.anim
429
430
  : null;
430
431
  if (!anim) {
431
- anim =
432
- workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null;
432
+ anim = pickClosestToPlayhead(
433
+ workingAnimations.filter((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)),
434
+ );
433
435
  }
434
436
 
435
437
  // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
@@ -469,42 +471,14 @@ export async function tryGsapRotationIntercept(
469
471
  return { status: "persisted" };
470
472
  }
471
473
 
472
- // fallow-ignore-next-line code-duplication
473
- if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
474
- const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
475
- if (newId) anim = { ...anim, id: newId };
476
- } else if (!anim.keyframes) {
477
- const resolvedFromValues = selector
478
- ? readAllAnimatedProperties(iframe, selector, anim, "rotation")
479
- : undefined;
480
- await commitMutation(
481
- selection,
482
- { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
483
- { label: "Convert to keyframes for rotation", skipReload: true },
484
- );
485
- }
486
-
487
- const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation");
488
-
489
- const backfillDefaults: Record<string, number> = { ...runtimeProps };
490
- if (!("rotation" in runtimeProps)) {
491
- backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0;
492
- }
493
-
494
- const properties = { ...runtimeProps, rotation: newRotation };
495
-
496
- await commitMutation(
474
+ return commitValueAtPlayhead(
497
475
  selection,
498
- {
499
- type: "add-keyframe",
500
- animationId: anim.id,
501
- percentage: pct,
502
- properties,
503
- backfillDefaults,
504
- },
505
- { label: `Rotate (keyframe ${pct}%)`, softReload: true },
476
+ anim,
477
+ { rotation: newRotation },
478
+ iframe,
479
+ { commitMutation, fetchAnimations: fetchFallbackAnimations },
480
+ { label: "Rotate", backfill: { rotation: newRotation }, holdFromStart: true },
506
481
  );
507
- return { status: "persisted" };
508
482
  }
509
483
 
510
484
  export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
@@ -449,12 +449,12 @@ export const ROTATION_CHANNELS: string[] = [
449
449
 
450
450
  /** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
451
451
  * transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
452
- export function gsapWritesPosition(el: Element): boolean {
452
+ function gsapWritesPosition(el: Element): boolean {
453
453
  return gsapRendersTransform(el) || gsapWritesChannels(el, MOVE_CHANNELS);
454
454
  }
455
455
 
456
456
  /** `gsapWritesPosition` for a rotate: everything else turns by its own CSS `rotate`. */
457
- export function gsapWritesRotation(el: Element): boolean {
457
+ function gsapWritesRotation(el: Element): boolean {
458
458
  return gsapRendersTransform(el) || gsapWritesChannels(el, ROTATION_CHANNELS);
459
459
  }
460
460
 
@@ -474,10 +474,23 @@ const BOX_CHANNELS = [
474
474
  ];
475
475
 
476
476
  /** GSAP owns this element's box: its position, or any property the CSS box writer sets. Else a resize writes CSS. */
477
- export function gsapWritesBox(el: Element): boolean {
477
+ function gsapWritesBox(el: Element): boolean {
478
478
  return gsapWritesPosition(el) || gsapWritesChannels(el, BOX_CHANNELS);
479
479
  }
480
480
 
481
+ export type EditGesture = "move" | "resize" | "rotate";
482
+ const GSAP_OWNS: Record<EditGesture, (el: Element) => boolean> = {
483
+ move: gsapWritesPosition,
484
+ resize: gsapWritesBox,
485
+ rotate: gsapWritesRotation,
486
+ };
487
+
488
+ /** The one GSAP-or-CSS route decision. Make it when an edit starts and carry it: mid-gesture
489
+ * writes make GSAP render the element, so asking again at commit can flip the route. */
490
+ export function editsPlainCss(el: Element, gesture: EditGesture): boolean {
491
+ return !GSAP_OWNS[gesture](el);
492
+ }
493
+
481
494
  /** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
482
495
  export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
483
496
  const win = el.ownerDocument.defaultView as {
@@ -4,6 +4,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  export { PROPERTY_DEFAULTS } from "./gsapShared";
5
5
  import { idSelector, matchesExactlyOne } from "./gsapShared";
6
6
  import { studioWriteHeaders } from "../utils/studioFileVersion";
7
+ import { studioApiFetch } from "../utils/studioApiFetch";
7
8
 
8
9
  /**
9
10
  * The selector to author a NEW tween against, minting an id on the element when
@@ -116,7 +117,7 @@ export async function assignGsapTargetAutoIdIfNeeded({
116
117
  showToast?.("Couldn't assign element id because the patch contains invalid values", "error");
117
118
  return false;
118
119
  }
119
- const res = await fetch(
120
+ const res = await studioApiFetch(
120
121
  `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
121
122
  {
122
123
  method: "POST",
@@ -5,6 +5,14 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
5
5
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
6
6
  import type { KeyframeUsageAction } from "../utils/keyframeUsage";
7
7
 
8
+ /** The file a selection's GSAP write goes to. */
9
+ export function gsapWriteFile(
10
+ selection: Pick<DomEditSelection, "sourceFile">,
11
+ activeCompPath: string | null | undefined,
12
+ ): string {
13
+ return selection.sourceFile || activeCompPath || "index.html";
14
+ }
15
+
8
16
  export interface MutationResult {
9
17
  ok: boolean;
10
18
  changed?: boolean;
@@ -36,7 +44,7 @@ export interface CommitMutationOptions {
36
44
  * error handling — a failed write still throws.
37
45
  */
38
46
  deferPreviewSync?: boolean;
39
- /** Shares an in-place patch miss with the final render of one multi-write action. */
47
+ /** Carries a deferred write not yet on screen (no patch, or a missed one) to the final render. */
40
48
  previewFallbackLatch?: { pending: boolean };
41
49
  beforeReload?: () => void;
42
50
  /**
@@ -34,6 +34,13 @@ export const PROPERTY_DEFAULTS: Record<string, number> = {
34
34
  height: 100,
35
35
  };
36
36
 
37
+ /** A tween's eases for re-writing it as keyframes: a flat tween's ease must become easeEach,
38
+ * since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */
39
+ export function keyframeEases(anim: GsapAnimation): { ease?: string; easeEach?: string } {
40
+ if (!anim.keyframes) return { easeEach: anim.ease };
41
+ return { ease: anim.ease, easeEach: anim.keyframes.easeEach };
42
+ }
43
+
37
44
  /**
38
45
  * A timeline write that applies an instantaneous value and then holds it.
39
46
  * `set()` is always a hold; authored `to()` / `fromTo()` tweens are holds only
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import { parseGsapScript } from "@hyperframes/core/gsap-parser";
4
4
  import { addAnimationWithKeyframesToScript } from "@hyperframes/parsers/gsap-writer-acorn";
5
5
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
- import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
6
+ import { mountGroupSiblings, stableSelectionFor } from "./domSelectionTestHarness";
7
7
  import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
8
8
  import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
9
9
  import { trackStudioEvent } from "../utils/studioTelemetry";
@@ -16,38 +16,6 @@ afterEach(() => {
16
16
  document.body.innerHTML = "";
17
17
  });
18
18
 
19
- /**
20
- * A selection built the way production builds it (getDomLayerPatchTarget), so
21
- * the class-only case under test is the real one: buildStableSelector hands back
22
- * a BARE class for an element with no id / hf-id / composition id.
23
- */
24
- function selectionFor(el: HTMLElement): DomEditSelection {
25
- const selector = buildStableSelector(el);
26
- return {
27
- element: el,
28
- id: el.id || undefined,
29
- hfId: el.getAttribute("data-hf-id") || undefined,
30
- selector,
31
- selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
32
- sourceFile: "index.html",
33
- dataAttributes: { start: "0", duration: "2" },
34
- } as unknown as DomEditSelection;
35
- }
36
-
37
- /** Five class-only siblings — the shape that made one add collapse the timeline. */
38
- function mountGroupSiblings(): HTMLElement[] {
39
- document.body.innerHTML = `
40
- <div id="scene" class="clip" data-start="0" data-duration="2">
41
- <div class="group"></div>
42
- <div class="group"></div>
43
- <div class="group"></div>
44
- <div class="group"></div>
45
- <div class="group"></div>
46
- </div>
47
- `;
48
- return Array.from(document.querySelectorAll<HTMLElement>(".group"));
49
- }
50
-
51
19
  const BASE_SCRIPT = `
52
20
  const tl = gsap.timeline({ paused: true });
53
21
  tl.to("#scene", { opacity: 1, duration: 0.5 }, 0);
@@ -68,8 +36,8 @@ function roundTripTargets(selector: string): { script: string; targets: string[]
68
36
 
69
37
  describe("writeTargetSelector", () => {
70
38
  it("addresses exactly one element when the only identity is a shared class", () => {
71
- const groups = mountGroupSiblings();
72
- const selection = selectionFor(groups[2]);
39
+ const groups = mountGroupSiblings(5);
40
+ const selection = stableSelectionFor(groups[2]);
73
41
 
74
42
  // Precondition: this is the defect's input — a bare class hitting all five.
75
43
  expect(selection.selector).toBe(".group");
@@ -86,7 +54,7 @@ describe("writeTargetSelector", () => {
86
54
  document.body.innerHTML = `<div id="box" class="card"></div>`;
87
55
  const el = document.querySelector<HTMLElement>("#box")!;
88
56
 
89
- expect(writeTargetSelector(selectionFor(el))).toBe("#box");
57
+ expect(writeTargetSelector(stableSelectionFor(el))).toBe("#box");
90
58
  });
91
59
 
92
60
  it("prefers data-hf-id over a generated structural selector", () => {
@@ -95,7 +63,7 @@ describe("writeTargetSelector", () => {
95
63
  `;
96
64
  const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
97
65
 
98
- const written = writeTargetSelector(selectionFor(el));
66
+ const written = writeTargetSelector(stableSelectionFor(el));
99
67
 
100
68
  expect(written).toBe('[data-hf-id="hf-42"]');
101
69
  expect(document.querySelector(written!)).toBe(el);
@@ -105,7 +73,7 @@ describe("writeTargetSelector", () => {
105
73
  document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
106
74
  const el = document.querySelector<HTMLElement>(".header")!;
107
75
 
108
- expect(writeTargetSelector(selectionFor(el))).toBe(".header");
76
+ expect(writeTargetSelector(stableSelectionFor(el))).toBe(".header");
109
77
  });
110
78
 
111
79
  it("hands identity back to a data-hf-id ancestor part way up a deep chain", () => {
@@ -125,7 +93,7 @@ describe("writeTargetSelector", () => {
125
93
  `;
126
94
  const el = document.querySelectorAll<HTMLElement>(".cell")[2]!;
127
95
 
128
- const written = writeTargetSelector(selectionFor(el));
96
+ const written = writeTargetSelector(stableSelectionFor(el));
129
97
 
130
98
  expect(written).toBe('[data-hf-id="mid"] > div:nth-child(3)');
131
99
  expect(document.querySelectorAll(written!)).toHaveLength(1);
@@ -137,7 +105,7 @@ describe("writeTargetSelector", () => {
137
105
  const el = document.querySelectorAll<HTMLElement>("#scene > div")[1]!;
138
106
  // querySelectorAll throws on this, so the "does it match exactly one?" rung
139
107
  // must read as a miss rather than crashing the add.
140
- const selection = { ...selectionFor(el), selector: "div[unclosed" } as DomEditSelection;
108
+ const selection = { ...stableSelectionFor(el), selector: "div[unclosed" } as DomEditSelection;
141
109
 
142
110
  expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
143
111
  });
@@ -145,7 +113,7 @@ describe("writeTargetSelector", () => {
145
113
  it("returns null when a live DOM is present and no rung addresses one element", () => {
146
114
  document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
147
115
  const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
148
- const selection = selectionFor(el);
116
+ const selection = stableSelectionFor(el);
149
117
  expect(selection.selector).toBe(".group");
150
118
  // Detached between selecting and committing: ownerDocument still resolves,
151
119
  // but there is no parent to count a :nth-child position in. Returning
@@ -167,11 +135,11 @@ describe("writeTargetSelector", () => {
167
135
 
168
136
  describe("writeTargetSelector — write/read round trip", () => {
169
137
  it("re-parses and attributes the new tween to the one element it targeted", () => {
170
- const groups = mountGroupSiblings();
138
+ const groups = mountGroupSiblings(5);
171
139
  // Ids let resolveSelectorElementIds name the attributed elements; the
172
140
  // SELECTION still has none, so the write path still faces the bare class.
173
141
  groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
174
- const selection = { ...selectionFor(groups[2]), id: undefined } as DomEditSelection;
142
+ const selection = { ...stableSelectionFor(groups[2]), id: undefined } as DomEditSelection;
175
143
 
176
144
  const written = writeTargetSelector(selection);
177
145
  const { targets } = roundTripTargets(written!);
@@ -180,9 +148,9 @@ describe("writeTargetSelector — write/read round trip", () => {
180
148
  });
181
149
 
182
150
  it("does not widen the add to the element's four class siblings", () => {
183
- const groups = mountGroupSiblings();
151
+ const groups = mountGroupSiblings(5);
184
152
  groups.forEach((el, i) => el.setAttribute("id", `group-${i}`));
185
- const selection = { ...selectionFor(groups[0]), id: undefined } as DomEditSelection;
153
+ const selection = { ...stableSelectionFor(groups[0]), id: undefined } as DomEditSelection;
186
154
 
187
155
  // The old bare-class write attributed the one add to every sibling — the
188
156
  // attribution blow-up behind "one add collapsed the timeline to a single row".
@@ -194,8 +162,8 @@ describe("writeTargetSelector — write/read round trip", () => {
194
162
 
195
163
  describe("existingTweenTargetSelector", () => {
196
164
  it("keeps the narrowed target when a replace re-authors the tween", async () => {
197
- const groups = mountGroupSiblings();
198
- const selection = selectionFor(groups[2]);
165
+ const groups = mountGroupSiblings(5);
166
+ const selection = stableSelectionFor(groups[2]);
199
167
  const commitMutation = vi.fn(async () => undefined);
200
168
  // A tween a previous add already narrowed to one sibling. Re-deriving the
201
169
  // target from the selection would widen it back to all five.
@@ -215,8 +183,8 @@ describe("existingTweenTargetSelector", () => {
215
183
  });
216
184
 
217
185
  it("falls back to the selection when the tween's own target did not resolve", async () => {
218
- const groups = mountGroupSiblings();
219
- const selection = selectionFor(groups[2]);
186
+ const groups = mountGroupSiblings(5);
187
+ const selection = stableSelectionFor(groups[2]);
220
188
  const commitMutation = vi.fn(async () => undefined);
221
189
  const setAnim = {
222
190
  id: "a1",
@@ -236,8 +204,8 @@ describe("existingTweenTargetSelector", () => {
236
204
 
237
205
  describe("commitKeyframeAtTimeImpl — new-tween target", () => {
238
206
  it("authors a one-element selector when no tween exists at the playhead", async () => {
239
- const groups = mountGroupSiblings();
240
- const selection = selectionFor(groups[3]);
207
+ const groups = mountGroupSiblings(5);
208
+ const selection = stableSelectionFor(groups[3]);
241
209
  const commitMutation = vi.fn(async () => undefined);
242
210
 
243
211
  await commitKeyframeAtTimeImpl(selection, 1, [], { x: 12 }, commitMutation);
@@ -251,7 +219,7 @@ describe("commitKeyframeAtTimeImpl — new-tween target", () => {
251
219
 
252
220
  describe("tweenTargetsElement", () => {
253
221
  it("matches a tween narrowed to this element by a selector the selection does not use", () => {
254
- const groups = mountGroupSiblings();
222
+ const groups = mountGroupSiblings(5);
255
223
  groups[2]!.id = "narrowed";
256
224
 
257
225
  // The write half authored "#narrowed"; the read half still has ".group".
@@ -259,7 +227,7 @@ describe("tweenTargetsElement", () => {
259
227
  });
260
228
 
261
229
  it("does not hand an individual element the group tween it merely inherits", () => {
262
- const groups = mountGroupSiblings();
230
+ const groups = mountGroupSiblings(5);
263
231
 
264
232
  // Selecting one sibling by its own address must not let an edit mutate the
265
233
  // ".group" tween: that write moves all five, not the one being nudged.
@@ -267,13 +235,13 @@ describe("tweenTargetsElement", () => {
267
235
  });
268
236
 
269
237
  it("still edits a group tween when the group itself is the selection", () => {
270
- const groups = mountGroupSiblings();
238
+ const groups = mountGroupSiblings(5);
271
239
 
272
240
  expect(tweenTargetsElement(".group", ".group", groups[0])).toBe(true);
273
241
  });
274
242
 
275
243
  it("does not match a target that is not a selector matches() understands", () => {
276
- const groups = mountGroupSiblings();
244
+ const groups = mountGroupSiblings(5);
277
245
 
278
246
  expect(tweenTargetsElement("div[unclosed", ".group", groups[0])).toBe(false);
279
247
  });
@@ -281,8 +249,8 @@ describe("tweenTargetsElement", () => {
281
249
 
282
250
  describe("commitKeyframeAtTimeImpl — no one-element target", () => {
283
251
  it("drops the keyframe rather than authoring the bare class", async () => {
284
- const groups = mountGroupSiblings();
285
- const selection = selectionFor(groups[3]!);
252
+ const groups = mountGroupSiblings(5);
253
+ const selection = stableSelectionFor(groups[3]!);
286
254
  groups[3]!.remove();
287
255
  const commitMutation = vi.fn(async () => undefined);
288
256
 
@@ -297,7 +265,7 @@ it("counts a keyframe-at-time conversion and insertion as one add gesture", asyn
297
265
  const el = document.createElement("div");
298
266
  el.id = "target";
299
267
  document.body.append(el);
300
- const selection = selectionFor(el);
268
+ const selection = stableSelectionFor(el);
301
269
  const animations = parseGsapScript(
302
270
  'const tl = gsap.timeline(); tl.to("#target", {x:100,duration:4},0);',
303
271
  ).animations;