@hyperframes/studio 0.8.133 → 0.8.134

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.133",
3
+ "version": "0.8.134",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.133",
64
- "@hyperframes/sdk": "0.8.133",
65
- "@hyperframes/parsers": "0.8.133",
66
- "@hyperframes/player": "0.8.133",
67
- "@hyperframes/shader-transitions": "0.8.133",
68
- "@hyperframes/studio-server": "0.8.133"
63
+ "@hyperframes/sdk": "0.8.134",
64
+ "@hyperframes/core": "0.8.134",
65
+ "@hyperframes/studio-server": "0.8.134",
66
+ "@hyperframes/parsers": "0.8.134",
67
+ "@hyperframes/player": "0.8.134",
68
+ "@hyperframes/shader-transitions": "0.8.134"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.133"
84
+ "@hyperframes/producer": "0.8.134"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -91,10 +91,14 @@ export function studioManualEditSavesIn(doc: Document): number {
91
91
  return gestureSaves.get(doc) ?? 0;
92
92
  }
93
93
 
94
+ export function countStudioPreviewChange(doc: Document): void {
95
+ gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
96
+ }
97
+
94
98
  /** Runs a gesture's save, counted as it starts and as it settles: a reload requested before shows the old file. */
95
99
  export function countStudioManualEditSave<R>(element: HTMLElement, save: () => R): R {
96
100
  const doc = element.ownerDocument;
97
- const count = () => void gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
101
+ const count = () => countStudioPreviewChange(doc);
98
102
  count();
99
103
  const result = save();
100
104
  void Promise.resolve(result).then(count, count);
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
- import { describe, expect, it } from "vitest";
3
- import { readTranslatePx } from "./plainTranslate";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { readTranslatePx, readTranslatePxLeavingPercent } from "./plainTranslate";
4
4
 
5
5
  function box(style: string): HTMLElement {
6
6
  const element = document.createElement("div");
@@ -76,3 +76,22 @@ describe("readTranslatePx", () => {
76
76
  },
77
77
  );
78
78
  });
79
+
80
+ describe("readTranslatePxLeavingPercent", () => {
81
+ it("seeds a first tween from the px a plain move left in CSS translate, leaving % to xPercent", () => {
82
+ const el = document.body.appendChild(document.createElement("div"));
83
+ // happy-dom computes no `translate`; Chromium resolves it to these strings.
84
+ const computed = (translate: string) =>
85
+ vi.spyOn(window, "getComputedStyle").mockReturnValue({ translate } as CSSStyleDeclaration);
86
+ computed("299.893px 203.262px");
87
+ expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 299.893, y: 203.262 });
88
+ computed("calc(-50% + 120px) -50%");
89
+ expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 120, y: 0 });
90
+ computed("calc(-50% - 120px) calc(min(10px, 20px) + 5px)");
91
+ expect(readTranslatePxLeavingPercent(el)).toEqual({ x: -120, y: 15 });
92
+ computed("none");
93
+ expect(readTranslatePxLeavingPercent(el)).toEqual({ x: 0, y: 0 });
94
+ vi.restoreAllMocks();
95
+ el.remove();
96
+ });
97
+ });
@@ -100,6 +100,13 @@ export function readTranslatePx(el: HTMLElement): Point {
100
100
  };
101
101
  }
102
102
 
103
+ export function readTranslatePxLeavingPercent(el: HTMLElement): Point {
104
+ const value = el.ownerDocument.defaultView?.getComputedStyle(el).translate || "none";
105
+ const [x = "0px", y = "0px"] = value === "none" ? [] : splitTopLevelWhitespace(value);
106
+ const px = (part: string) => roundTo3(evaluateLength(part, 0)) || 0;
107
+ return { x: px(x), y: px(y) };
108
+ }
109
+
103
110
  export const UNREADABLE_TRANSLATE =
104
111
  "Studio can't read this layer's translate. Move it in the Code tab.";
105
112
 
@@ -0,0 +1,34 @@
1
+ // @vitest-environment happy-dom
2
+ import { expect, it, vi } from "vitest";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import { freezeDragStamp, restoreDragOffset } from "./draggedGsapPosition";
5
+ import { commitStaticGsapPosition } from "./gsapDragCommit";
6
+
7
+ function stamp(element: HTMLElement, base: { x: number; y: number }) {
8
+ element.setAttribute("data-hf-drag-gsap-base-x", String(base.x));
9
+ element.setAttribute("data-hf-drag-gsap-base-y", String(base.y));
10
+ element.setAttribute("data-hf-drag-initial-offset-x", "0");
11
+ element.setAttribute("data-hf-drag-initial-offset-y", "0");
12
+ }
13
+
14
+ // A resize released at x 60 moves its anchor by -50; the next drag presses before that write is planned.
15
+ it("a commit that outlives its gesture uses the gesture's stamp, not the next one's", async () => {
16
+ const element = document.createElement("div");
17
+ stamp(element, { x: 60, y: 37.5 });
18
+ const frozen = freezeDragStamp(element);
19
+ stamp(element, { x: 10, y: 4 });
20
+ const commitMutation = vi.fn();
21
+
22
+ await commitStaticGsapPosition(
23
+ { id: "target", selector: "#target", element } as DomEditSelection,
24
+ { x: -50, y: -33.5 },
25
+ { x: 0, y: 0 },
26
+ "#target",
27
+ null,
28
+ { commitMutation, stamp: frozen },
29
+ );
30
+
31
+ expect(commitMutation.mock.calls[0]![1].properties).toEqual({ x: 10, y: 4 });
32
+ restoreDragOffset(element, frozen);
33
+ expect(element.getAttribute("data-hf-drag-initial-offset-x")).toBe("0");
34
+ });
@@ -36,10 +36,35 @@ export function readCssRotation(element: HTMLElement, withRotate = true): number
36
36
  }
37
37
  }
38
38
 
39
+ export interface DragStamp {
40
+ origX: number;
41
+ origY: number;
42
+ baseX: number;
43
+ baseY: number;
44
+ frozen?: boolean;
45
+ }
46
+
47
+ /** The drag-start attributes as they are now; NaN for an absent base. */
48
+ export function readDragStamp(element: HTMLElement): DragStamp {
49
+ const read = (name: string) => Number.parseFloat(element.getAttribute(name) ?? "");
50
+ return {
51
+ origX: read("data-hf-drag-initial-offset-x") || 0,
52
+ origY: read("data-hf-drag-initial-offset-y") || 0,
53
+ baseX: read("data-hf-drag-gsap-base-x"),
54
+ baseY: read("data-hf-drag-gsap-base-y"),
55
+ };
56
+ }
57
+
58
+ /** A gesture's stamp read at its release, for commits that finish after the next gesture re-stamps the element. */
59
+ export const freezeDragStamp = (element: HTMLElement): DragStamp => ({
60
+ ...readDragStamp(element),
61
+ frozen: true,
62
+ });
63
+
39
64
  /**
40
65
  * Translate a studio drag offset into absolute GSAP x/y, accounting for the
41
66
  * element's rotation and its drag-start base pose. Reads the drag-start
42
- * attributes stamped by `createManualOffsetDragMember`
67
+ * stamp (`readDragStamp` by default) set by `createManualOffsetDragMember`
43
68
  * (`data-hf-drag-initial-offset-*`, `data-hf-drag-gsap-base-*`); `fallbackBase`
44
69
  * is used when the base attributes are absent (e.g. a static element that GSAP
45
70
  * hasn't given an x/y yet).
@@ -52,22 +77,19 @@ export function computeDraggedGsapPosition(
52
77
  element: HTMLElement,
53
78
  studioOffset: { x: number; y: number },
54
79
  fallbackBase: { x: number; y: number },
80
+ stamp: DragStamp = readDragStamp(element),
55
81
  ): { newX: number; newY: number; baseGsapX: number; baseGsapY: number } {
56
82
  const rotStyle = element.style.getPropertyValue("--hf-studio-rotation");
57
83
  const rotDeg = Number.parseFloat(rotStyle) || 0;
58
84
  const rad = (-rotDeg * Math.PI) / 180;
59
85
  const cos = Math.cos(rad);
60
86
  const sin = Math.sin(rad);
61
- const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
62
- const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
63
- const deltaX = studioOffset.x - origX;
64
- const deltaY = studioOffset.y - origY;
87
+ const deltaX = studioOffset.x - stamp.origX;
88
+ const deltaY = studioOffset.y - stamp.origY;
65
89
  const adjX = deltaX * cos - deltaY * sin;
66
90
  const adjY = deltaX * sin + deltaY * cos;
67
- const parsedBaseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
68
- const parsedBaseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
69
- const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : fallbackBase.x;
70
- const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : fallbackBase.y;
91
+ const baseGsapX = Number.isFinite(stamp.baseX) ? stamp.baseX : fallbackBase.x;
92
+ const baseGsapY = Number.isFinite(stamp.baseY) ? stamp.baseY : fallbackBase.y;
71
93
  return {
72
94
  newX: roundTo3(baseGsapX + adjX),
73
95
  newY: roundTo3(baseGsapY + adjY),
@@ -76,12 +98,15 @@ export function computeDraggedGsapPosition(
76
98
  };
77
99
  }
78
100
 
79
- /** Puts the drag's preview offset back once the written position renders instead. */
80
- export function restoreDragOffset(element: HTMLElement): void {
81
- const origin = (axis: "x" | "y") =>
82
- Number.parseFloat(element.getAttribute(`data-hf-drag-initial-offset-${axis}`) ?? "") || 0;
83
- element.style.setProperty("--hf-studio-offset-x", `${origin("x")}px`);
84
- element.style.setProperty("--hf-studio-offset-y", `${origin("y")}px`);
101
+ /** Puts the drag's preview offset back once the written position renders instead. A frozen stamp's
102
+ * gesture is over: the attributes belong to the one after it. */
103
+ export function restoreDragOffset(
104
+ element: HTMLElement,
105
+ stamp: DragStamp = readDragStamp(element),
106
+ ): void {
107
+ if (stamp.frozen) return;
108
+ element.style.setProperty("--hf-studio-offset-x", `${stamp.origX}px`);
109
+ element.style.setProperty("--hf-studio-offset-y", `${stamp.origY}px`);
85
110
  element.removeAttribute("data-hf-drag-initial-offset-x");
86
111
  element.removeAttribute("data-hf-drag-initial-offset-y");
87
112
  }
@@ -12,7 +12,12 @@ import {
12
12
  } from "../utils/globalTimeCompiler";
13
13
  import { roundTo3, roundToLayoutPx } from "../utils/rounding";
14
14
  import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared";
15
- import { computeDraggedGsapPosition } from "./draggedGsapPosition";
15
+ import {
16
+ computeDraggedGsapPosition,
17
+ readDragStamp,
18
+ restoreDragOffset,
19
+ type DragStamp,
20
+ } from "./draggedGsapPosition";
16
21
  import type { CommitMutation } from "./gsapScriptCommitTypes";
17
22
  import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
18
23
  import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
@@ -26,6 +31,8 @@ export {
26
31
  export interface GsapDragCommitCallbacks {
27
32
  commitMutation: CommitMutation;
28
33
  fetchAnimations?: () => Promise<GsapAnimation[]>;
34
+ /** The gesture's drag stamp, when its commit can outlive it; else read from the element. */
35
+ stamp?: DragStamp;
29
36
  }
30
37
 
31
38
  /**
@@ -169,7 +176,12 @@ export async function commitStaticGsapPosition(
169
176
  existingSet: GsapAnimation | null,
170
177
  callbacks: GsapDragCommitCallbacks,
171
178
  ): Promise<void> {
172
- const { newX, newY } = computeDraggedGsapPosition(selection.element, studioOffset, gsapPos);
179
+ const { newX, newY } = computeDraggedGsapPosition(
180
+ selection.element,
181
+ studioOffset,
182
+ gsapPos,
183
+ callbacks.stamp,
184
+ );
173
185
  if (existingSet) {
174
186
  if (existingSet.keyframes) {
175
187
  // Keyframed zero-duration hold (drag-path corruption): can't update-property
@@ -344,22 +356,17 @@ export async function commitWholePathOffset(
344
356
  callbacks: GsapDragCommitCallbacks,
345
357
  ): Promise<void> {
346
358
  const el = selection.element;
359
+ const stamp = callbacks.stamp ?? readDragStamp(el);
347
360
  const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
348
361
  el,
349
362
  studioOffset,
350
363
  gsapPos,
364
+ stamp,
351
365
  );
352
366
  const deltaX = newX - baseGsapX;
353
367
  // fallow-ignore-next-line code-duplication
354
368
  const deltaY = newY - baseGsapY;
355
- const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
356
- const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
357
- const restoreOffset = () => {
358
- el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
359
- el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
360
- el.removeAttribute("data-hf-drag-initial-offset-x");
361
- el.removeAttribute("data-hf-drag-initial-offset-y");
362
- };
369
+ const restoreOffset = () => restoreDragOffset(el, stamp);
363
370
 
364
371
  // fallow-ignore-next-line code-duplication
365
372
  let effectiveAnim = anim;
@@ -9,7 +9,11 @@ import {
9
9
  import { KEYFRAME_PCT_MATCH, playsNear } from "./gsapShared";
10
10
  import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
11
11
  import { roundTo3 } from "../utils/rounding";
12
- import { computeDraggedGsapPosition } from "./draggedGsapPosition";
12
+ import {
13
+ computeDraggedGsapPosition,
14
+ readDragStamp,
15
+ restoreDragOffset,
16
+ } from "./draggedGsapPosition";
13
17
  import {
14
18
  type GsapDragCommitCallbacks,
15
19
  computeCurrentPercentage,
@@ -117,20 +121,15 @@ export async function commitGsapPositionFromDrag(
117
121
  callbacks: GsapDragCommitCallbacks,
118
122
  ): Promise<GsapEditOutcome> {
119
123
  const el = selection.element;
124
+ const stamp = callbacks.stamp ?? readDragStamp(el);
120
125
  // fallow-ignore-next-line code-duplication
121
126
  const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
122
127
  el,
123
128
  studioOffset,
124
129
  gsapPos,
130
+ stamp,
125
131
  );
126
- const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
127
- const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
128
- const restoreOffset = () => {
129
- el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
130
- el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
131
- el.removeAttribute("data-hf-drag-initial-offset-x");
132
- el.removeAttribute("data-hf-drag-initial-offset-y");
133
- };
132
+ const restoreOffset = () => restoreDragOffset(el, stamp);
134
133
 
135
134
  if (anim.arcPath?.enabled) {
136
135
  const { activeKeyframePct, currentTime, setActiveKeyframePct } = usePlayerStore.getState();
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
- import { afterEach, expect, it, vi } from "vitest";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
5
  import { usePlayerStore } from "../player/store/playerStore";
@@ -182,6 +182,7 @@ it("reuses the ownership parse instead of fetching a resolved size group twice",
182
182
  await expect(
183
183
  tryGsapResizeIntercept(
184
184
  selection,
185
+ // fallow-ignore-next-line code-duplication
185
186
  { width: 344, height: 344 },
186
187
  [],
187
188
  null,
@@ -573,6 +574,76 @@ it("hands the size to the element's CSS when its only tween is a fade", async ()
573
574
  expect(commitMutation).not.toHaveBeenCalled();
574
575
  });
575
576
 
577
+ describe("the first resize of a keyframed element under auto-record", () => {
578
+ const positionKeys = {
579
+ id: "#title-to-1-position",
580
+ targetSelector: "#title",
581
+ propertyGroup: "position",
582
+ method: "to",
583
+ properties: {},
584
+ keyframes: { keyframes: [{ percentage: 0, properties: { x: 300, y: 200 } }] },
585
+ position: 1,
586
+ resolvedStart: 1,
587
+ duration: 3,
588
+ } as unknown as GsapAnimation;
589
+
590
+ it("writes a size key at the playhead in one mutation and hands the draft size to GSAP", async () => {
591
+ usePlayerStore.setState({ currentTime: 2 });
592
+ const commitMutation = vi.fn();
593
+ const selection = titleSelection();
594
+ selection.element.setAttribute("data-hf-studio-box-size", "true");
595
+
596
+ // fallow-ignore-next-line code-duplication
597
+ const handled = await tryGsapResizeIntercept(
598
+ selection,
599
+ { width: 424.2, height: 237 },
600
+ [positionKeys],
601
+ null,
602
+ commitMutation,
603
+ );
604
+
605
+ expect(handled).toEqual({ status: "persisted" });
606
+ expect(commitMutation.mock.calls.map((call) => call[1])).toEqual([
607
+ {
608
+ type: "add-with-keyframes",
609
+ targetSelector: "#title",
610
+ position: 2,
611
+ duration: 1,
612
+ keyframes: [{ percentage: 0, properties: { width: 424, height: 237 } }],
613
+ },
614
+ ]);
615
+ expect(selection.element.hasAttribute("data-hf-studio-box-size")).toBe(false);
616
+ });
617
+
618
+ const fadeKeys = {
619
+ ...positionKeys,
620
+ id: "#title-to-1-visual",
621
+ propertyGroup: "visual",
622
+ keyframes: { keyframes: [{ percentage: 0, properties: { opacity: 0 } }] },
623
+ } as unknown as GsapAnimation;
624
+
625
+ it.each([
626
+ ["with auto-record off", positionKeys, false],
627
+ ["on an element whose only keyframes fade it", fadeKeys, true],
628
+ ])("hands the size to CSS %s", async (_, keyed, autoKeyframeEnabled) => {
629
+ usePlayerStore.setState({ autoKeyframeEnabled });
630
+ const commitMutation = vi.fn();
631
+ try {
632
+ const handled = await tryGsapResizeIntercept(
633
+ titleSelection(),
634
+ { width: 424, height: 237 },
635
+ [keyed],
636
+ null,
637
+ commitMutation,
638
+ );
639
+ expect(handled).toEqual({ status: "element-size" });
640
+ expect(commitMutation).not.toHaveBeenCalled();
641
+ } finally {
642
+ usePlayerStore.setState({ autoKeyframeEnabled: true });
643
+ }
644
+ });
645
+ });
646
+
576
647
  it.each([
577
648
  ["stays put", { x: 0, y: 0 }, { width: 300 }, undefined],
578
649
  ["moves", { x: -40, y: 0 }, { width: 300, x: 10, y: 0 }, true],
@@ -5,14 +5,18 @@
5
5
  * frame can't jump. Split from gsapRuntimeBridge, which owns the shared
6
6
  * group-tween resolution used by the drag/resize/rotate intercepts.
7
7
  */
8
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
8
+ import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
9
9
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
10
10
  import { clearStudioBoxSize } from "../components/editor/manualEdits";
11
11
  import {
12
12
  STUDIO_ORIGINAL_BOX_HEIGHT_ATTR,
13
13
  STUDIO_ORIGINAL_BOX_WIDTH_ATTR,
14
14
  } from "../components/editor/manualEditsTypes";
15
- import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
15
+ import {
16
+ setElementGsapPosition,
17
+ setElementGsapScale,
18
+ setElementGsapSize,
19
+ } from "../utils/elementGsap";
16
20
  import { usePlayerStore } from "../player/store/playerStore";
17
21
  import { readGsapProperty } from "./gsapRuntimeReaders";
18
22
  import {
@@ -23,7 +27,11 @@ import {
23
27
  findSizeSetAnimation,
24
28
  } from "./gsapDragCommit";
25
29
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
26
- import { computeDraggedGsapPosition, restoreDragOffset } from "./draggedGsapPosition";
30
+ import {
31
+ computeDraggedGsapPosition,
32
+ restoreDragOffset,
33
+ type DragStamp,
34
+ } from "./draggedGsapPosition";
27
35
  import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
28
36
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
29
37
  import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
@@ -39,9 +47,11 @@ import {
39
47
  } from "./gsapEditOutcome";
40
48
  import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
41
49
  import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
50
+ import { singleKeyTweenMutation } from "./useEnableKeyframes";
42
51
 
43
52
  const SIZE_PROPS = new Set(["width", "height"]);
44
53
  const POSITION_XY = new Set(["x", "y"]);
54
+ const MOVED_OR_SIZED = new Set(["x", "y", "xPercent", "yPercent", "width", "height"]);
45
55
 
46
56
  /**
47
57
  * The element's box before the resize draft ran, in CSS pixels.
@@ -88,6 +98,7 @@ export async function commitSizeAtPlayhead(
88
98
  selection.element,
89
99
  dragOffset,
90
100
  readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 },
101
+ callbacks.stamp,
91
102
  )
92
103
  : null;
93
104
  const written = await commitValueAtPlayhead(
@@ -102,12 +113,43 @@ export async function commitSizeAtPlayhead(
102
113
  ...preGestureBoxSize(selection.element),
103
114
  ...(anchor && { x: anchor.baseGsapX, y: anchor.baseGsapY }),
104
115
  },
105
- ...(anchor && { beforeReload: () => restoreDragOffset(selection.element) }),
116
+ ...(anchor && { beforeReload: () => restoreDragOffset(selection.element, callbacks.stamp) }),
106
117
  },
107
118
  );
108
119
  return written.status === "persisted" && anchor ? { ...written, ownsDragOffset: true } : written;
109
120
  }
110
121
 
122
+ /** A keyed size is GSAP's at every time; the gesture's draft, re-applied after each seek, would pin it there. */
123
+ function handOverDraftSize(
124
+ selection: DomEditSelection,
125
+ written: GsapEditOutcome,
126
+ size: Record<string, number>,
127
+ draw: <T>(run: () => T) => T,
128
+ ): GsapEditOutcome {
129
+ const { width, height } = size;
130
+ if (written.status !== "persisted" || width == null || height == null) return written;
131
+ draw(() => {
132
+ clearStudioBoxSize(selection.element);
133
+ setElementGsapSize(selection.element, width, height);
134
+ });
135
+ return written;
136
+ }
137
+
138
+ /** Under auto-record, a keyframed element's first resize is a size key at the playhead, held at all times. */
139
+ function firstSizeKey(
140
+ selection: DomEditSelection,
141
+ size: { width: number; height: number },
142
+ resizeGroup: PropertyGroupName,
143
+ animations: GsapAnimation[],
144
+ ): Record<string, unknown> | null {
145
+ const { autoKeyframeEnabled, currentTime } = usePlayerStore.getState();
146
+ const keyframed = animations.some(
147
+ (a) => a.keyframes && animationWritesAnyProperty(a, MOVED_OR_SIZED),
148
+ );
149
+ if (resizeGroup !== "size" || !autoKeyframeEnabled || !keyframed) return null;
150
+ return singleKeyTweenMutation(selection, size, currentTime);
151
+ }
152
+
111
153
  /**
112
154
  * Whether this tween already states scale as `scaleX`/`scaleY`.
113
155
  *
@@ -136,6 +178,7 @@ export async function tryGsapResizeIntercept(
136
178
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
137
179
  dragOffset?: { x: number; y: number },
138
180
  draw: <T>(run: () => T) => T = (run) => run(),
181
+ stamp?: DragStamp,
139
182
  ): Promise<GsapEditOutcome> {
140
183
  const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
141
184
  const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
@@ -170,10 +213,16 @@ export async function tryGsapResizeIntercept(
170
213
  size,
171
214
  });
172
215
  if (!anim || isInstantHold(anim)) {
216
+ const sized = { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) };
173
217
  const scriptWritesSize = allKnownAnimations.some((a) =>
174
218
  animationWritesAnyProperty(a, SIZE_PROPS),
175
219
  );
176
- if (!scriptWritesSize) return { status: "element-size" };
220
+ if (!scriptWritesSize) {
221
+ const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations);
222
+ if (!firstKey) return { status: "element-size" };
223
+ await commitMutation(selection, firstKey, { label: "Resize", softReload: true });
224
+ return handOverDraftSize(selection, { status: "persisted" }, sized, draw);
225
+ }
177
226
  const sel = selectorFromSelection(selection) ?? writeTargetSelector(selection);
178
227
  if (!sel) return { status: "blocked", reason: "no-selector" };
179
228
  // A scale hold is not a size hold.
@@ -191,8 +240,7 @@ export async function tryGsapResizeIntercept(
191
240
  ? (anim ?? findSizeSetAnimation(workingAnimations, sel, selection.element))
192
241
  : findSizeSetAnimation(workingAnimations, sel, selection.element);
193
242
 
194
- // Keyframe the size only when a real tween already animates it, as move and
195
- // rotate do; a fade or a slide on the element gets a plain size.
243
+ // Keyframe the size when a real tween already animates it; a static size set stays a set.
196
244
  if (resizeGroup === "size") {
197
245
  const animatedTween = pickClosestToPlayhead(
198
246
  workingAnimations.filter(
@@ -204,11 +252,19 @@ export async function tryGsapResizeIntercept(
204
252
  );
205
253
  if (animatedTween) {
206
254
  logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
207
- const sized = { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) };
208
- return commitSizeAtPlayhead(selection, animatedTween, sized, iframe, dragOffset, {
209
- commitMutation,
210
- fetchAnimations: fetchFallbackAnimations,
211
- });
255
+ const written = await commitSizeAtPlayhead(
256
+ selection,
257
+ animatedTween,
258
+ sized,
259
+ iframe,
260
+ dragOffset,
261
+ {
262
+ commitMutation,
263
+ fetchAnimations: fetchFallbackAnimations,
264
+ stamp,
265
+ },
266
+ );
267
+ return handOverDraftSize(selection, written, sized, draw);
212
268
  }
213
269
  }
214
270
 
@@ -333,8 +389,7 @@ export async function tryGsapResizeIntercept(
333
389
  // it lands back on the drop point. The compensation only applies to a STATIC
334
390
  // position (a `tl.set` hold or none) — a keyframed position path has no
335
391
  // single anchor to preserve, so it keeps the plain center-scale behavior.
336
- // The size route commits the same width/height channels the draft wrote, so
337
- // it needs none of this.
392
+ // The size route hands its draft to GSAP itself (handOverDraftSize).
338
393
  // ponytail: for a 3D-rotated element the rects are AABBs, so the anchor is
339
394
  // approximate rather than corner-exact.
340
395
  // fallow-ignore-next-line complexity
@@ -363,6 +418,7 @@ export async function tryGsapResizeIntercept(
363
418
  selection.element,
364
419
  { x: 0, y: 0 },
365
420
  gsapPos,
421
+ stamp,
366
422
  );
367
423
  const base = { x: baseGsapX, y: baseGsapY };
368
424
  setElementGsapPosition(draftEl, base.x, base.y);
@@ -424,6 +480,7 @@ export async function tryGsapResizeIntercept(
424
480
  await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
425
481
  commitMutation,
426
482
  fetchAnimations: fetchFallbackAnimations,
483
+ stamp,
427
484
  }),
428
485
  );
429
486
  return true;
@@ -432,6 +489,7 @@ export async function tryGsapResizeIntercept(
432
489
  await commitStaticGsapPosition(selection, delta, base, selector, existingSet, {
433
490
  commitMutation,
434
491
  fetchAnimations: fetchFallbackAnimations,
492
+ stamp,
435
493
  });
436
494
  return true;
437
495
  };
@@ -453,9 +511,18 @@ export async function tryGsapResizeIntercept(
453
511
  return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
454
512
  }
455
513
 
456
- const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations };
457
- if (resizeGroup === "size")
458
- return commitSizeAtPlayhead(selection, anim, resizeProps, iframe, dragOffset, callbacks);
514
+ const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp };
515
+ if (resizeGroup === "size") {
516
+ const written = await commitSizeAtPlayhead(
517
+ selection,
518
+ anim,
519
+ resizeProps,
520
+ iframe,
521
+ dragOffset,
522
+ callbacks,
523
+ );
524
+ return handOverDraftSize(selection, written, resizeProps, draw);
525
+ }
459
526
  const written = await commitValueAtPlayhead(selection, anim, resizeProps, iframe, callbacks, {
460
527
  label: "Resize",
461
528
  backfill: resizeBackfill,
@@ -31,6 +31,7 @@ import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
31
31
  import { resolveTweenDuration } from "../utils/globalTimeCompiler";
32
32
  import { roundTo3 } from "../utils/rounding";
33
33
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
34
+ import type { DragStamp } from "./draggedGsapPosition";
34
35
  import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
35
36
  import {
36
37
  findGsapPositionAnimation,
@@ -243,6 +244,7 @@ export async function tryGsapDragIntercept(
243
244
  preflightOnly?: boolean;
244
245
  preflightPassed?: boolean;
245
246
  group?: boolean;
247
+ stamp?: DragStamp;
246
248
  },
247
249
  ): Promise<GsapEditOutcome> {
248
250
  if (!options?.preflightPassed) {
@@ -323,6 +325,7 @@ export async function tryGsapDragIntercept(
323
325
  await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
324
326
  commitMutation,
325
327
  fetchAnimations: fetchFallbackAnimations,
328
+ stamp: options?.stamp,
326
329
  });
327
330
  return { status: "persisted" };
328
331
  }
@@ -352,7 +355,7 @@ export async function tryGsapDragIntercept(
352
355
  }
353
356
  }
354
357
 
355
- const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
358
+ const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp: options?.stamp };
356
359
  if (route === "whole-path") {
357
360
  await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
358
361
  } else {