@hyperframes/studio 0.6.86 → 0.6.88

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 (87) hide show
  1. package/dist/assets/index-B9_ctmee.js +143 -0
  2. package/dist/assets/index-CGlIm_-E.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +159 -6
  6. package/src/components/StudioHeader.tsx +20 -7
  7. package/src/components/StudioPreviewArea.tsx +6 -1
  8. package/src/components/StudioRightPanel.tsx +13 -0
  9. package/src/components/StudioToast.tsx +47 -7
  10. package/src/components/TimelineToolbar.tsx +12 -122
  11. package/src/components/editor/AnimationCard.tsx +64 -10
  12. package/src/components/editor/ArcPathControls.tsx +131 -0
  13. package/src/components/editor/BorderRadiusEditor.tsx +209 -0
  14. package/src/components/editor/DomEditOverlay.tsx +70 -11
  15. package/src/components/editor/DopesheetStrip.tsx +141 -0
  16. package/src/components/editor/EaseCurveSection.tsx +82 -7
  17. package/src/components/editor/GestureTrailOverlay.tsx +132 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +14 -1
  19. package/src/components/editor/KeyframeDiamond.tsx +27 -12
  20. package/src/components/editor/LayersPanel.tsx +14 -12
  21. package/src/components/editor/MotionPathOverlay.tsx +146 -0
  22. package/src/components/editor/PropertyPanel.tsx +196 -66
  23. package/src/components/editor/SourceEditor.tsx +0 -1
  24. package/src/components/editor/StaggerControls.tsx +61 -0
  25. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +2 -1
  27. package/src/components/editor/domEditing.test.ts +43 -0
  28. package/src/components/editor/domEditing.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +25 -2
  30. package/src/components/editor/domEditingLayers.test.ts +78 -0
  31. package/src/components/editor/domEditingLayers.ts +33 -13
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/manualEditingAvailability.ts +1 -1
  34. package/src/components/editor/manualEdits.ts +3 -0
  35. package/src/components/editor/manualEditsDom.ts +23 -5
  36. package/src/components/editor/manualOffsetDrag.ts +59 -0
  37. package/src/components/editor/panelTokens.ts +10 -0
  38. package/src/components/editor/propertyPanelColor.tsx +2 -2
  39. package/src/components/editor/propertyPanelFill.tsx +1 -1
  40. package/src/components/editor/propertyPanelHelpers.ts +18 -2
  41. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  42. package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
  43. package/src/components/editor/propertyPanelSections.tsx +4 -6
  44. package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
  45. package/src/components/editor/useDomEditOverlayRects.ts +46 -2
  46. package/src/components/renders/RenderQueue.tsx +121 -100
  47. package/src/components/renders/RenderQueueItem.tsx +13 -13
  48. package/src/contexts/DomEditContext.tsx +12 -0
  49. package/src/contexts/FileManagerContext.tsx +3 -0
  50. package/src/contexts/StudioContext.tsx +0 -4
  51. package/src/hooks/gsapKeyframeCommit.ts +92 -0
  52. package/src/hooks/gsapRuntimeBridge.ts +147 -85
  53. package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
  54. package/src/hooks/gsapRuntimePreview.ts +19 -0
  55. package/src/hooks/useAppHotkeys.ts +18 -0
  56. package/src/hooks/useAskAgentModal.ts +2 -4
  57. package/src/hooks/useDomEditCommits.ts +11 -17
  58. package/src/hooks/useDomEditSession.ts +47 -4
  59. package/src/hooks/useEnableKeyframes.ts +171 -0
  60. package/src/hooks/useFileManager.ts +7 -0
  61. package/src/hooks/useGestureRecording.ts +340 -0
  62. package/src/hooks/useGsapScriptCommits.ts +171 -35
  63. package/src/hooks/useGsapSelectionHandlers.ts +27 -8
  64. package/src/hooks/useGsapTweenCache.ts +169 -11
  65. package/src/hooks/useKeyframeKeyboard.ts +103 -0
  66. package/src/hooks/useStudioContextValue.ts +5 -4
  67. package/src/hooks/useStudioUrlState.ts +1 -2
  68. package/src/hooks/useTimelineEditing.ts +50 -3
  69. package/src/hooks/useToast.ts +6 -1
  70. package/src/player/components/ShortcutsPanel.tsx +40 -0
  71. package/src/player/components/TimelineClipDiamonds.tsx +3 -3
  72. package/src/player/components/TimelinePropertyRows.tsx +120 -0
  73. package/src/player/lib/timelineDOM.test.ts +55 -0
  74. package/src/player/lib/timelineDOM.ts +13 -0
  75. package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
  76. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  77. package/src/player/store/playerStore.ts +43 -0
  78. package/src/utils/audioBeatDetection.ts +58 -0
  79. package/src/utils/globalTimeCompiler.test.ts +169 -0
  80. package/src/utils/globalTimeCompiler.ts +77 -0
  81. package/src/utils/gsapSoftReload.ts +30 -10
  82. package/src/utils/keyframeSnapping.test.ts +74 -0
  83. package/src/utils/keyframeSnapping.ts +63 -0
  84. package/src/utils/rdpSimplify.ts +183 -0
  85. package/src/utils/sourcePatcher.ts +2 -0
  86. package/dist/assets/index-BT9VHgSy.js +0 -140
  87. package/dist/assets/index-DHcptK1_.css +0 -1
@@ -57,15 +57,15 @@ export const RenderQueueItem = memo(function RenderQueueItem({
57
57
  onPointerLeave={() => setHovered(false)}
58
58
  onClick={isComplete ? handleOpen : undefined}
59
59
  className={[
60
- "px-3 py-2.5 border-b border-neutral-800/30 last:border-0 transition-colors duration-150",
61
- isComplete ? "cursor-pointer hover:bg-neutral-800/30" : "",
60
+ "px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150",
61
+ isComplete ? "cursor-pointer hover:bg-panel-hover/30" : "",
62
62
  ]
63
63
  .filter(Boolean)
64
64
  .join(" ")}
65
65
  >
66
66
  <div className="flex items-center gap-2.5">
67
67
  {/* Thumbnail — static frame; swaps to live video on hover */}
68
- <div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
68
+ <div className="w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative">
69
69
  {isComplete && (
70
70
  <>
71
71
  {/* Live video — visible on hover */}
@@ -90,7 +90,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
90
90
  )}
91
91
  {job.status === "rendering" && (
92
92
  <div className="w-full h-full flex items-center justify-center">
93
- <div className="w-2 h-2 rounded-full bg-studio-accent animate-pulse" />
93
+ <div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse" />
94
94
  </div>
95
95
  )}
96
96
  {job.status === "failed" && (
@@ -108,11 +108,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
108
108
  {/* Info */}
109
109
  <div className="flex-1 min-w-0">
110
110
  <div className="flex items-center gap-1.5">
111
- <span className="text-[11px] font-medium text-neutral-300 truncate">
111
+ <span className="text-[11px] font-medium text-panel-text-2 truncate">
112
112
  {job.filename}
113
113
  </span>
114
114
  {job.durationMs && (
115
- <span className="text-[9px] text-neutral-600 flex-shrink-0">
115
+ <span className="text-[9px] text-panel-text-5 flex-shrink-0">
116
116
  {formatDuration(job.durationMs)}
117
117
  </span>
118
118
  )}
@@ -121,12 +121,12 @@ export const RenderQueueItem = memo(function RenderQueueItem({
121
121
  {job.status === "rendering" && (
122
122
  <div className="mt-1">
123
123
  <div className="flex items-center justify-between mb-0.5">
124
- <span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
125
- <span className="text-[9px] font-mono text-studio-accent">{job.progress}%</span>
124
+ <span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
125
+ <span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
126
126
  </div>
127
- <div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
127
+ <div className="w-full h-1 bg-panel-border rounded-full overflow-hidden">
128
128
  <div
129
- className="h-full bg-studio-accent rounded-full transition-all duration-300"
129
+ className="h-full bg-panel-accent rounded-full transition-all duration-300"
130
130
  style={{ width: `${job.progress}%` }}
131
131
  />
132
132
  </div>
@@ -138,7 +138,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
138
138
  )}
139
139
 
140
140
  {job.status !== "rendering" && (
141
- <span className="text-[9px] text-neutral-600">{formatTimeAgo(job.createdAt)}</span>
141
+ <span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
142
142
  )}
143
143
  </div>
144
144
 
@@ -148,7 +148,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
148
148
  {isComplete && (
149
149
  <button
150
150
  onClick={handleDownload}
151
- className="p-1 rounded text-neutral-500 hover:text-green-400 transition-colors"
151
+ className="p-1 rounded text-panel-text-4 hover:text-panel-accent transition-colors"
152
152
  title="Download"
153
153
  >
154
154
  <svg
@@ -172,7 +172,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
172
172
  e.stopPropagation();
173
173
  onDelete();
174
174
  }}
175
- className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
175
+ className="p-1 rounded text-panel-text-4 hover:text-red-400 transition-colors"
176
176
  title="Remove"
177
177
  >
178
178
  <svg
@@ -67,13 +67,17 @@ export function DomEditProvider({
67
67
  handleGsapAddFromProperty,
68
68
  handleGsapRemoveFromProperty,
69
69
  handleGsapAddKeyframe,
70
+ handleGsapAddKeyframeBatch,
70
71
  handleGsapRemoveKeyframe,
71
72
  handleGsapConvertToKeyframes,
72
73
  handleGsapRemoveAllKeyframes,
73
74
  handleResetSelectedElementKeyframes,
74
75
  commitAnimatedProperty,
76
+ handleSetArcPath,
77
+ handleUpdateArcSegment,
75
78
  invalidateGsapCache,
76
79
  previewIframeRef,
80
+ commitMutation,
77
81
  },
78
82
  children,
79
83
  }: {
@@ -136,13 +140,17 @@ export function DomEditProvider({
136
140
  handleGsapAddFromProperty,
137
141
  handleGsapRemoveFromProperty,
138
142
  handleGsapAddKeyframe,
143
+ handleGsapAddKeyframeBatch,
139
144
  handleGsapRemoveKeyframe,
140
145
  handleGsapConvertToKeyframes,
141
146
  handleGsapRemoveAllKeyframes,
142
147
  handleResetSelectedElementKeyframes,
143
148
  commitAnimatedProperty,
149
+ handleSetArcPath,
150
+ handleUpdateArcSegment,
144
151
  invalidateGsapCache,
145
152
  previewIframeRef,
153
+ commitMutation,
146
154
  }),
147
155
  [
148
156
  domEditSelection,
@@ -199,13 +207,17 @@ export function DomEditProvider({
199
207
  handleGsapAddFromProperty,
200
208
  handleGsapRemoveFromProperty,
201
209
  handleGsapAddKeyframe,
210
+ handleGsapAddKeyframeBatch,
202
211
  handleGsapRemoveKeyframe,
203
212
  handleGsapConvertToKeyframes,
204
213
  handleGsapRemoveAllKeyframes,
205
214
  handleResetSelectedElementKeyframes,
206
215
  commitAnimatedProperty,
216
+ handleSetArcPath,
217
+ handleUpdateArcSegment,
207
218
  invalidateGsapCache,
208
219
  previewIframeRef,
220
+ commitMutation,
209
221
  ],
210
222
  );
211
223
  return <DomEditContext value={stable}>{children}</DomEditContext>;
@@ -26,6 +26,7 @@ export function FileManagerProvider({
26
26
  readProjectFile,
27
27
  writeProjectFile,
28
28
  readOptionalProjectFile,
29
+ updateEditingFileContent,
29
30
  revealSourceOffset,
30
31
  openSourceForSelection,
31
32
  handleFileSelect,
@@ -64,6 +65,7 @@ export function FileManagerProvider({
64
65
  readProjectFile,
65
66
  writeProjectFile,
66
67
  readOptionalProjectFile,
68
+ updateEditingFileContent,
67
69
  revealSourceOffset,
68
70
  openSourceForSelection,
69
71
  handleFileSelect,
@@ -96,6 +98,7 @@ export function FileManagerProvider({
96
98
  readProjectFile,
97
99
  writeProjectFile,
98
100
  readOptionalProjectFile,
101
+ updateEditingFileContent,
99
102
  revealSourceOffset,
100
103
  openSourceForSelection,
101
104
  handleFileSelect,
@@ -12,7 +12,6 @@ export interface StudioContextValue {
12
12
  compositionLoading: boolean;
13
13
  refreshKey: number;
14
14
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
15
- currentTime: number;
16
15
  timelineElements: TimelineElement[];
17
16
  isPlaying: boolean;
18
17
  editHistory: {
@@ -63,7 +62,6 @@ export function StudioProvider({
63
62
  compositionLoading,
64
63
  refreshKey,
65
64
  setRefreshKey,
66
- currentTime,
67
65
  timelineElements,
68
66
  isPlaying,
69
67
  editHistory,
@@ -89,7 +87,6 @@ export function StudioProvider({
89
87
  compositionLoading,
90
88
  refreshKey,
91
89
  setRefreshKey,
92
- currentTime,
93
90
  timelineElements,
94
91
  isPlaying,
95
92
  editHistory,
@@ -112,7 +109,6 @@ export function StudioProvider({
112
109
  captionEditMode,
113
110
  compositionLoading,
114
111
  refreshKey,
115
- currentTime,
116
112
  isPlaying,
117
113
  compositionDimensions,
118
114
  timelineVisible,
@@ -0,0 +1,92 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
+ import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/globalTimeCompiler";
4
+
5
+ const PROPERTY_DEFAULTS: Record<string, number> = {
6
+ opacity: 1,
7
+ x: 0,
8
+ y: 0,
9
+ scale: 1,
10
+ scaleX: 1,
11
+ scaleY: 1,
12
+ rotation: 0,
13
+ width: 100,
14
+ height: 100,
15
+ };
16
+
17
+ type CommitFn = (
18
+ selection: DomEditSelection,
19
+ mutation: Record<string, unknown>,
20
+ options: {
21
+ label: string;
22
+ coalesceKey?: string;
23
+ softReload?: boolean;
24
+ skipReload?: boolean;
25
+ },
26
+ ) => Promise<void>;
27
+
28
+ export async function commitKeyframeAtTimeImpl(
29
+ selection: DomEditSelection,
30
+ absoluteTime: number,
31
+ animations: GsapAnimation[],
32
+ properties: Record<string, number | string>,
33
+ commitMutation: CommitFn,
34
+ ): Promise<void> {
35
+ const selector = selection.id ? `#${selection.id}` : selection.selector;
36
+ if (!selector) return;
37
+
38
+ const tween = findTweenAtTime(absoluteTime, animations, selector);
39
+ if (tween) {
40
+ const pct = absoluteToPercentageForAnimation(absoluteTime, tween);
41
+ if (pct === null) return;
42
+
43
+ const hasExplicitKeyframes = !!tween.keyframes && tween.keyframes.keyframes.length > 0;
44
+ if (!hasExplicitKeyframes) {
45
+ await commitMutation(
46
+ selection,
47
+ { type: "convert-to-keyframes", animationId: tween.id },
48
+ { label: "Convert to keyframes", skipReload: true },
49
+ );
50
+ }
51
+
52
+ const backfillDefaults: Record<string, number | string> = {};
53
+ for (const key of Object.keys(properties)) {
54
+ backfillDefaults[key] = PROPERTY_DEFAULTS[key] ?? 0;
55
+ }
56
+
57
+ await commitMutation(
58
+ selection,
59
+ {
60
+ type: "add-keyframe",
61
+ animationId: tween.id,
62
+ percentage: pct,
63
+ properties,
64
+ backfillDefaults,
65
+ },
66
+ {
67
+ label: `Add keyframe at ${Math.round(absoluteTime * 100) / 100}s`,
68
+ coalesceKey: `keyframe:${tween.id}:${pct}`,
69
+ softReload: true,
70
+ },
71
+ );
72
+ } else {
73
+ const defaultDuration = 0.5;
74
+ await commitMutation(
75
+ selection,
76
+ {
77
+ type: "add-with-keyframes" as const,
78
+ targetSelector: selector,
79
+ position: absoluteTime,
80
+ duration: defaultDuration,
81
+ keyframes: [
82
+ { percentage: 0, properties },
83
+ { percentage: 100, properties },
84
+ ],
85
+ },
86
+ {
87
+ label: `New animation at ${Math.round(absoluteTime * 100) / 100}s`,
88
+ softReload: true,
89
+ },
90
+ );
91
+ }
92
+ }
@@ -10,9 +10,14 @@
10
10
  */
11
11
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
- import { clearStudioPathOffset } from "../components/editor/manualEdits";
13
+
14
14
  import { usePlayerStore } from "../player/store/playerStore";
15
15
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
16
+ import {
17
+ absoluteToPercentage,
18
+ resolveTweenStart,
19
+ resolveTweenDuration,
20
+ } from "../utils/globalTimeCompiler";
16
21
 
17
22
  // ── Runtime reads ──────────────────────────────────────────────────────────
18
23
 
@@ -91,10 +96,17 @@ function selectorForSelection(selection: DomEditSelection): string | null {
91
96
 
92
97
  // ── Percentage computation ─────────────────────────────────────────────────
93
98
 
94
- function computeCurrentPercentage(selection: DomEditSelection): number {
99
+ function computeCurrentPercentage(selection: DomEditSelection, animation?: GsapAnimation): number {
100
+ const currentTime = usePlayerStore.getState().currentTime;
101
+ if (animation) {
102
+ const start = resolveTweenStart(animation);
103
+ const duration = resolveTweenDuration(animation);
104
+ if (start !== null) {
105
+ return absoluteToPercentage(currentTime, start, duration);
106
+ }
107
+ }
95
108
  const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
96
109
  const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
97
- const currentTime = usePlayerStore.getState().currentTime;
98
110
  return elDuration > 0
99
111
  ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
100
112
  : 0;
@@ -190,6 +202,10 @@ export async function tryGsapDragIntercept(
190
202
  const selector = selectorForSelection(selection);
191
203
  if (!selector) return false;
192
204
 
205
+ // Keyframe writes at 0%/100% when outside the tween range. Acceptable
206
+ // trade-off — CSS path must NEVER touch GSAP-targeted elements because
207
+ // changing the CSS offset corrupts all existing keyframes (baked mismatch).
208
+
193
209
  const gsapPos = readGsapPositionFromIframe(iframe, selector);
194
210
  if (!gsapPos) return false;
195
211
 
@@ -232,50 +248,155 @@ async function commitGsapPositionFromDrag(
232
248
  const rad = (-rotDeg * Math.PI) / 180;
233
249
  const cos = Math.cos(rad);
234
250
  const sin = Math.sin(rad);
235
- const adjX = studioOffset.x * cos - studioOffset.y * sin;
236
- const adjY = studioOffset.x * sin + studioOffset.y * cos;
237
- const newX = Math.round(gsapPos.x + adjX);
238
- const newY = Math.round(gsapPos.y + adjY);
239
- const clearOffset = () => clearStudioPathOffset(selection.element);
251
+ const el = selection.element;
252
+ const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
253
+ const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
254
+ const deltaX = studioOffset.x - origX;
255
+ const deltaY = studioOffset.y - origY;
256
+ const adjX = deltaX * cos - deltaY * sin;
257
+ const adjY = deltaX * sin + deltaY * cos;
258
+ // Use the GSAP base captured at drag start — the live gsapPos is corrupted
259
+ // by the draft's gsap.set() calls during drag.
260
+ const baseGsapX =
261
+ Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x;
262
+ const baseGsapY =
263
+ Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y;
264
+ const newX = Math.round(baseGsapX + adjX);
265
+ const newY = Math.round(baseGsapY + adjY);
266
+ // Restore the CSS offset to pre-drag value so the baked translate stays
267
+ // consistent with existing keyframes. The drag is captured in the new keyframe.
268
+ const restoreOffset = () => {
269
+ el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
270
+ el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
271
+ el.removeAttribute("data-hf-drag-initial-offset-x");
272
+ el.removeAttribute("data-hf-drag-initial-offset-y");
273
+ };
240
274
 
241
275
  if (anim.keyframes) {
242
276
  const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
243
277
  const effectiveAnim = newId ? { ...anim, id: newId } : anim;
244
278
  const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
245
- await commitKeyframedPosition(
279
+
280
+ // Check if current time is outside the tween's range — extend the tween
281
+ // to cover the playhead, remap existing keyframes, then add the new one.
282
+ const ct = usePlayerStore.getState().currentTime;
283
+ const ts = resolveTweenStart(effectiveAnim);
284
+ const td = resolveTweenDuration(effectiveAnim);
285
+ if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) {
286
+ await extendTweenAndAddKeyframe(
287
+ selection,
288
+ effectiveAnim,
289
+ { ...runtimeProps, x: newX, y: newY },
290
+ ct,
291
+ ts,
292
+ td,
293
+ callbacks,
294
+ restoreOffset,
295
+ );
296
+ } else {
297
+ await commitKeyframedPosition(
298
+ selection,
299
+ effectiveAnim,
300
+ { ...runtimeProps, x: newX, y: newY },
301
+ callbacks,
302
+ restoreOffset,
303
+ );
304
+ }
305
+ } else if (anim.method === "from" || anim.method === "fromTo") {
306
+ // from()/fromTo() — convert to keyframes in a single mutation, placing
307
+ // the dragged position at the 100% (rest) keyframe. A single mutation
308
+ // avoids the stable-id flip (from→to) that breaks chained mutations.
309
+ await callbacks.commitMutation(
246
310
  selection,
247
- effectiveAnim,
248
- { ...runtimeProps, x: newX, y: newY },
249
- callbacks,
250
- clearOffset,
311
+ {
312
+ type: "convert-to-keyframes",
313
+ animationId: anim.id,
314
+ resolvedFromValues: { x: newX, y: newY },
315
+ },
316
+ { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
251
317
  );
252
- } else if (anim.method === "from") {
253
- await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset);
254
- } else if (anim.method === "fromTo") {
255
- await commitFromToPosition(selection, anim, studioOffset, callbacks, clearOffset);
256
318
  } else {
257
- // Flat to()/set() — convert to keyframes first so the drag position
258
- // is captured at the current seek time, not just the tween endpoint.
319
+ // Flat to()/set() — convert to keyframes then add at current percentage.
259
320
  const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
260
321
  await commitFlatViaKeyframes(
261
322
  selection,
262
323
  anim,
263
324
  { ...runtimeProps, x: newX, y: newY },
264
325
  callbacks,
265
- clearOffset,
326
+ restoreOffset,
266
327
  );
267
328
  }
268
329
  }
269
330
 
331
+ /**
332
+ * Extend a tween's time range to cover `targetTime`, remap all existing
333
+ * keyframe percentages to preserve their absolute positions, then add
334
+ * a new keyframe at the target time.
335
+ */
336
+ async function extendTweenAndAddKeyframe(
337
+ selection: DomEditSelection,
338
+ anim: GsapAnimation,
339
+ properties: Record<string, number>,
340
+ targetTime: number,
341
+ tweenStart: number,
342
+ tweenDuration: number,
343
+ callbacks: GsapDragCommitCallbacks,
344
+ beforeReload?: () => void,
345
+ ): Promise<void> {
346
+ const tweenEnd = tweenStart + tweenDuration;
347
+ const newStart = Math.min(targetTime, tweenStart);
348
+ const newEnd = Math.max(targetTime, tweenEnd);
349
+ const newDuration = Math.max(0.01, newEnd - newStart);
350
+
351
+ // Step 1: Remap all existing keyframes to preserve their absolute times
352
+ // in the new range, then add the new keyframe.
353
+ const existingKfs = anim.keyframes?.keyframes ?? [];
354
+ const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
355
+ [];
356
+ for (const kf of existingKfs) {
357
+ const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
358
+ const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
359
+ remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } });
360
+ }
361
+
362
+ // Add the new keyframe at the target time
363
+ const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
364
+ remappedKfs.push({ percentage: targetPct, properties });
365
+
366
+ // Sort and dedupe
367
+ remappedKfs.sort((a, b) => a.percentage - b.percentage);
368
+
369
+ // Step 2: Delete the old tween and create a new one with the extended range
370
+ // and all remapped keyframes. Using delete + add-with-keyframes as an atomic pair.
371
+ await callbacks.commitMutation(
372
+ selection,
373
+ { type: "delete", animationId: anim.id },
374
+ { label: "Extend tween range", skipReload: true },
375
+ );
376
+
377
+ const selector = anim.targetSelector;
378
+ await callbacks.commitMutation(
379
+ selection,
380
+ {
381
+ type: "add-with-keyframes",
382
+ targetSelector: selector,
383
+ position: Math.round(newStart * 1000) / 1000,
384
+ duration: Math.round(newDuration * 1000) / 1000,
385
+ keyframes: remappedKfs,
386
+ },
387
+ { label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
388
+ );
389
+ }
390
+
270
391
  // fallow-ignore-next-line complexity
271
392
  async function commitKeyframedPosition(
272
393
  selection: DomEditSelection,
273
394
  anim: GsapAnimation,
274
395
  properties: Record<string, number>,
275
396
  callbacks: GsapDragCommitCallbacks,
276
- beforeReload: () => void,
397
+ beforeReload?: () => void,
277
398
  ): Promise<void> {
278
- const pct = computeCurrentPercentage(selection);
399
+ const pct = computeCurrentPercentage(selection, anim);
279
400
 
280
401
  await callbacks.commitMutation(
281
402
  selection,
@@ -300,7 +421,7 @@ async function commitFlatViaKeyframes(
300
421
  anim: GsapAnimation,
301
422
  properties: Record<string, number>,
302
423
  callbacks: GsapDragCommitCallbacks,
303
- beforeReload: () => void,
424
+ beforeReload?: () => void,
304
425
  ): Promise<void> {
305
426
  await callbacks.commitMutation(
306
427
  selection,
@@ -308,7 +429,7 @@ async function commitFlatViaKeyframes(
308
429
  { label: "Convert to keyframes for drag", skipReload: true },
309
430
  );
310
431
 
311
- const pct = computeCurrentPercentage(selection);
432
+ const pct = computeCurrentPercentage(selection, anim);
312
433
 
313
434
  await callbacks.commitMutation(
314
435
  selection,
@@ -322,65 +443,6 @@ async function commitFlatViaKeyframes(
322
443
  );
323
444
  }
324
445
 
325
- async function commitFromPosition(
326
- selection: DomEditSelection,
327
- anim: GsapAnimation,
328
- delta: { x: number; y: number },
329
- callbacks: GsapDragCommitCallbacks,
330
- beforeReload: () => void,
331
- ): Promise<void> {
332
- const fromX = Math.round(Number(anim.properties.x ?? 0) + delta.x);
333
- const fromY = Math.round(Number(anim.properties.y ?? 0) + delta.y);
334
-
335
- await callbacks.commitMutation(
336
- selection,
337
- { type: "update-property", animationId: anim.id, property: "x", value: fromX },
338
- { label: "Move layer (GSAP from x)", skipReload: true },
339
- );
340
- await callbacks.commitMutation(
341
- selection,
342
- { type: "update-property", animationId: anim.id, property: "y", value: fromY },
343
- { label: "Move layer (GSAP from y)", softReload: true, beforeReload },
344
- );
345
- }
346
-
347
- // fallow-ignore-next-line complexity
348
- async function commitFromToPosition(
349
- selection: DomEditSelection,
350
- anim: GsapAnimation,
351
- delta: { x: number; y: number },
352
- callbacks: GsapDragCommitCallbacks,
353
- beforeReload: () => void,
354
- ): Promise<void> {
355
- if (anim.fromProperties) {
356
- const fromX = Math.round(Number(anim.fromProperties.x ?? 0) + delta.x);
357
- const fromY = Math.round(Number(anim.fromProperties.y ?? 0) + delta.y);
358
- await callbacks.commitMutation(
359
- selection,
360
- { type: "update-from-property", animationId: anim.id, property: "x", value: fromX },
361
- { label: "Move (GSAP from x)", skipReload: true },
362
- );
363
- await callbacks.commitMutation(
364
- selection,
365
- { type: "update-from-property", animationId: anim.id, property: "y", value: fromY },
366
- { label: "Move (GSAP from y)", skipReload: true },
367
- );
368
- }
369
-
370
- const toX = Math.round(Number(anim.properties.x ?? 0) + delta.x);
371
- const toY = Math.round(Number(anim.properties.y ?? 0) + delta.y);
372
- await callbacks.commitMutation(
373
- selection,
374
- { type: "update-property", animationId: anim.id, property: "x", value: toX },
375
- { label: "Move (GSAP to x)", skipReload: true },
376
- );
377
- await callbacks.commitMutation(
378
- selection,
379
- { type: "update-property", animationId: anim.id, property: "y", value: toY },
380
- { label: "Move (GSAP to y)", softReload: true, beforeReload },
381
- );
382
- }
383
-
384
446
  // ── Runtime property reader ───────────────────────────────────────────────
385
447
 
386
448
  export function readGsapProperty(
@@ -461,7 +523,7 @@ export async function tryGsapResizeIntercept(
461
523
  }
462
524
  if (!anim) return false;
463
525
 
464
- const pct = computeCurrentPercentage(selection);
526
+ const pct = computeCurrentPercentage(selection, anim);
465
527
 
466
528
  if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
467
529
  const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
@@ -545,7 +607,7 @@ export async function tryGsapRotationIntercept(
545
607
  }
546
608
  }
547
609
 
548
- const pct = computeCurrentPercentage(selection);
610
+ const pct = computeCurrentPercentage(selection, anim);
549
611
  const newRotation = Math.round(gsapRotation + angle);
550
612
 
551
613
  if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {