@hyperframes/studio 0.7.25 → 0.7.26

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 (34) hide show
  1. package/dist/assets/{index-CF35g_E4.js → index-BPya4QRs.js} +1 -1
  2. package/dist/assets/{index-HBY-OdAh.js → index-BZqvc9Ns.js} +183 -183
  3. package/dist/assets/{index-Bmd8RnCq.js → index-D3eh3pRK.js} +1 -1
  4. package/dist/index.d.ts +6 -0
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +377 -210
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/components/TimelineToolbar.test.tsx +50 -0
  10. package/src/components/TimelineToolbar.tsx +70 -35
  11. package/src/components/editor/MotionPathOverlay.tsx +27 -4
  12. package/src/components/editor/SnapToolbar.test.tsx +124 -0
  13. package/src/components/editor/SnapToolbar.tsx +1 -0
  14. package/src/hooks/gsapRuntimeBridge.test.ts +66 -0
  15. package/src/hooks/gsapRuntimeBridge.ts +41 -1
  16. package/src/hooks/gsapTweenSynth.test.ts +55 -0
  17. package/src/hooks/gsapTweenSynth.ts +12 -6
  18. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +162 -0
  19. package/src/hooks/gsapWholePropertyOffsetCommit.ts +85 -0
  20. package/src/hooks/useAnimatedPropertyCommit.test.tsx +105 -0
  21. package/src/hooks/useAnimatedPropertyCommit.ts +22 -0
  22. package/src/hooks/useAppHotkeys.ts +3 -2
  23. package/src/hooks/useGsapScriptCommits.test.tsx +8 -8
  24. package/src/hooks/useGsapScriptCommits.ts +6 -1
  25. package/src/hooks/useRenderClipContent.test.ts +54 -1
  26. package/src/hooks/useRenderClipContent.ts +7 -5
  27. package/src/player/components/Timeline.test.ts +51 -1
  28. package/src/player/components/Timeline.tsx +0 -2
  29. package/src/player/components/TimelineCanvas.tsx +1 -0
  30. package/src/player/components/TimelineClipDiamonds.test.tsx +215 -0
  31. package/src/player/components/TimelineClipDiamonds.tsx +46 -2
  32. package/src/player/store/playerStore.ts +8 -0
  33. package/src/utils/gsapSoftReload.test.ts +12 -0
  34. package/src/utils/gsapSoftReload.ts +15 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.25",
3
+ "version": "0.7.26",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.25",
50
- "@hyperframes/parsers": "0.7.25",
51
- "@hyperframes/player": "0.7.25",
52
- "@hyperframes/sdk": "0.7.25",
53
- "@hyperframes/studio-server": "0.7.25"
49
+ "@hyperframes/core": "0.7.26",
50
+ "@hyperframes/parsers": "0.7.26",
51
+ "@hyperframes/sdk": "0.7.26",
52
+ "@hyperframes/player": "0.7.26",
53
+ "@hyperframes/studio-server": "0.7.26"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.25"
68
+ "@hyperframes/producer": "0.7.26"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -0,0 +1,50 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { usePlayerStore } from "../player/store/playerStore";
7
+ import { TimelineToolbar } from "./TimelineToolbar";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ usePlayerStore.setState({ autoKeyframeEnabled: true });
14
+ });
15
+
16
+ function renderToolbar() {
17
+ const host = document.createElement("div");
18
+ document.body.append(host);
19
+ const root = createRoot(host);
20
+ act(() => {
21
+ root.render(<TimelineToolbar toggleTimelineVisibility={vi.fn()} />);
22
+ });
23
+ return { host, root };
24
+ }
25
+
26
+ // Regression (#1808): the auto-keyframe toggle is a GLOBAL setting (unlike the
27
+ // diamond "Add keyframe" button, which needs a selection to mean anything), so
28
+ // it must stay visible and usable with nothing selected — it must not be
29
+ // gated behind `domEditSession`/`onToggleKeyframe`.
30
+ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
31
+ it("renders enabled (pressed) by default with no selection", () => {
32
+ const { host, root } = renderToolbar();
33
+ const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
34
+ expect(btn).not.toBeNull();
35
+ act(() => root.unmount());
36
+ });
37
+
38
+ it("flips autoKeyframeEnabled in the store when clicked", () => {
39
+ const { host, root } = renderToolbar();
40
+ const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')!;
41
+
42
+ act(() => {
43
+ btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
44
+ });
45
+
46
+ expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false);
47
+ expect(host.querySelector('button[aria-pressed="false"]')).not.toBeNull();
48
+ act(() => root.unmount());
49
+ });
50
+ });
@@ -79,6 +79,8 @@ export function TimelineToolbar({
79
79
  }: TimelineToolbarProps) {
80
80
  const activeTool = usePlayerStore((s) => s.activeTool);
81
81
  const setActiveTool = usePlayerStore((s) => s.setActiveTool);
82
+ const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
83
+ const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
82
84
  // Subscribe so the add-beat button reacts to playhead movement and analysis load.
83
85
  const currentTime = usePlayerStore((s) => s.currentTime);
84
86
  const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
@@ -137,44 +139,77 @@ export function TimelineToolbar({
137
139
  </div>
138
140
  )}
139
141
  {STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
140
- <>
141
- <Tooltip
142
- label={
142
+ <Tooltip
143
+ label={
144
+ keyframeState === "active"
145
+ ? "Remove keyframe at playhead (K)"
146
+ : keyframeState === "inactive"
147
+ ? keyframeWillExtend
148
+ ? "Add keyframe at playhead, extends animation (K)"
149
+ : "Add keyframe at playhead (K)"
150
+ : "Add keyframe (K)"
151
+ }
152
+ >
153
+ <button
154
+ type="button"
155
+ onClick={onToggleKeyframe}
156
+ className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
143
157
  keyframeState === "active"
144
- ? "Remove keyframe at playhead (K)"
158
+ ? "text-studio-accent"
145
159
  : keyframeState === "inactive"
146
- ? keyframeWillExtend
147
- ? "Add keyframe at playhead — extends animation (K)"
148
- : "Add keyframe at playhead (K)"
149
- : "Add keyframe (K)"
150
- }
160
+ ? "text-neutral-400 hover:text-studio-accent"
161
+ : "text-neutral-600 hover:text-neutral-400"
162
+ }`}
151
163
  >
152
- <button
153
- type="button"
154
- onClick={onToggleKeyframe}
155
- className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
156
- keyframeState === "active"
157
- ? "text-studio-accent"
158
- : keyframeState === "inactive"
159
- ? "text-neutral-400 hover:text-studio-accent"
160
- : "text-neutral-600 hover:text-neutral-400"
161
- }`}
162
- >
163
- <svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
164
- {keyframeState === "active" ? (
165
- <path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
166
- ) : (
167
- <path
168
- d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
169
- fill="none"
170
- stroke="currentColor"
171
- strokeWidth="1.2"
172
- />
173
- )}
174
- </svg>
175
- </button>
176
- </Tooltip>
177
- </>
164
+ <svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
165
+ {keyframeState === "active" ? (
166
+ <path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
167
+ ) : (
168
+ <path
169
+ d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
170
+ fill="none"
171
+ stroke="currentColor"
172
+ strokeWidth="1.2"
173
+ />
174
+ )}
175
+ </svg>
176
+ </button>
177
+ </Tooltip>
178
+ )}
179
+ {STUDIO_KEYFRAMES_ENABLED && (
180
+ <Tooltip
181
+ label={
182
+ autoKeyframeEnabled
183
+ ? "Auto-record manual edits as keyframes (click to turn off)"
184
+ : "Manual edits will not be recorded as keyframes (click to turn on)"
185
+ }
186
+ >
187
+ <button
188
+ type="button"
189
+ onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
190
+ aria-pressed={autoKeyframeEnabled}
191
+ className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
192
+ autoKeyframeEnabled
193
+ ? "text-red-400 hover:text-red-300"
194
+ : "text-neutral-600 hover:text-neutral-400"
195
+ }`}
196
+ >
197
+ <svg width="18" height="18" viewBox="0 0 10 10" fill="none">
198
+ {/* Same diamond outline as the Add-keyframe icon, with a
199
+ record-style dot inside: filled = auto-recording,
200
+ hollow = manual edits won't be keyframed. */}
201
+ <path d="M5 0.7L9.3 5L5 9.3L0.7 5Z" stroke="currentColor" strokeWidth="1" />
202
+ <circle
203
+ cx="5"
204
+ cy="5"
205
+ r="1.8"
206
+ fill={autoKeyframeEnabled ? "currentColor" : "none"}
207
+ stroke="currentColor"
208
+ strokeWidth="1"
209
+ />
210
+ </svg>
211
+ </button>
212
+ </Tooltip>
178
213
  )}
179
214
  {onSplitElement &&
180
215
  (() => {
@@ -3,6 +3,7 @@ import type { DomEditSelection } from "./domEditing";
3
3
  import { useDomEditContext } from "../../contexts/DomEditContext";
4
4
  import { usePlayerStore } from "../../player/store/playerStore";
5
5
  import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
6
+ import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
6
7
  import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
7
8
  import { editableAnimationId, selectorFor } from "./motionPathSelection";
8
9
  import { ACCENT, MotionPathNode } from "./MotionPathNode";
@@ -334,13 +335,35 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
334
335
  // high zoom) would commit an identical value — a no-op undo entry. Skip the
335
336
  // commit, but don't treat it as a click either (the user did drag).
336
337
  if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
337
- void commitNode(d.ref, x, y, animId, commitMutation);
338
+ // With auto-keyframe off (#1808), dragging a keyframe's node on the motion
339
+ // path (the common way to nudge a KEYFRAMED element's position on canvas,
340
+ // since the element renders exactly at its current keyframe) shifts the
341
+ // whole path instead of moving just that one keyframe.
342
+ const anim =
343
+ d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
344
+ if (
345
+ d.ref.type === "keyframe" &&
346
+ anim &&
347
+ selection &&
348
+ !usePlayerStore.getState().autoKeyframeEnabled
349
+ ) {
350
+ void commitWholePropertyOffset(
351
+ selection,
352
+ anim,
353
+ { x, y },
354
+ d.ref.pct,
355
+ iframeRef.current,
356
+ { commitMutation: (_sel, mutation, options) => commitMutation(mutation, options) },
357
+ "Move animation path",
358
+ );
359
+ } else {
360
+ void commitNode(d.ref, x, y, animId, commitMutation);
361
+ }
338
362
  // Park the playhead on the edited keyframe's time so the element previews AT
339
363
  // that keyframe. Without it, a playhead sitting before the tween renders the
340
364
  // element's base pose — the edit (correct on the path) looks like it vanished.
341
- if (d.ref.type === "keyframe") {
342
- const anim = selectedGsapAnimations?.find((a) => a.id === animId);
343
- if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct);
365
+ if (d.ref.type === "keyframe" && anim) {
366
+ parkPlayheadOnKeyframe(anim, d.ref.pct);
344
367
  }
345
368
  };
346
369
 
@@ -0,0 +1,124 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useRef } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { useAppHotkeys } from "../../hooks/useAppHotkeys";
7
+ import { usePlayerStore } from "../../player/store/playerStore";
8
+ import type { LeftSidebarHandle } from "../sidebar/LeftSidebar";
9
+ import type { DomEditSelection } from "./domEditing";
10
+ import { SnapToolbar } from "./SnapToolbar";
11
+
12
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ afterEach(() => {
15
+ document.body.innerHTML = "";
16
+ window.localStorage.clear();
17
+ usePlayerStore.getState().reset();
18
+ });
19
+
20
+ function renderToolbar(onSnapChange = vi.fn()) {
21
+ const host = document.createElement("div");
22
+ document.body.append(host);
23
+ const root = createRoot(host);
24
+ act(() => {
25
+ root.render(<SnapToolbar onSnapChange={onSnapChange} />);
26
+ });
27
+ return { root, onSnapChange };
28
+ }
29
+
30
+ function AppHotkeyHarness() {
31
+ const domEditSelectionRef = useRef<DomEditSelection | null>(null);
32
+ const clearDomSelectionRef = useRef<() => void>(() => undefined);
33
+ const domEditSaveTimestampRef = useRef(0);
34
+ const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
35
+
36
+ useAppHotkeys({
37
+ toggleTimelineVisibility: vi.fn(),
38
+ handleTimelineElementDelete: vi.fn(),
39
+ handleTimelineElementSplit: vi.fn(),
40
+ handleDomEditElementDelete: vi.fn(),
41
+ domEditSelectionRef,
42
+ clearDomSelectionRef,
43
+ editHistory: {
44
+ undo: vi.fn(async () => ({ ok: false })),
45
+ redo: vi.fn(async () => ({ ok: false })),
46
+ state: { undo: [], redo: [] },
47
+ },
48
+ readOptionalProjectFile: vi.fn(async () => ""),
49
+ readProjectFile: vi.fn(async () => ""),
50
+ writeProjectFile: vi.fn(async () => undefined),
51
+ domEditSaveTimestampRef,
52
+ showToast: vi.fn(),
53
+ syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
54
+ waitForPendingDomEditSaves: vi.fn(async () => undefined),
55
+ leftSidebarRef,
56
+ handleCopy: vi.fn(() => false),
57
+ handlePaste: vi.fn(async () => undefined),
58
+ handleCut: vi.fn(async () => false),
59
+ onResetKeyframes: vi.fn(() => false),
60
+ onDeleteSelectedKeyframes: vi.fn(),
61
+ });
62
+
63
+ return null;
64
+ }
65
+
66
+ function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) {
67
+ const host = document.createElement("div");
68
+ document.body.append(host);
69
+ const root = createRoot(host);
70
+ act(() => {
71
+ root.render(
72
+ <>
73
+ <AppHotkeyHarness />
74
+ <SnapToolbar onSnapChange={onSnapChange} />
75
+ </>,
76
+ );
77
+ });
78
+ return { root, onSnapChange };
79
+ }
80
+
81
+ describe("SnapToolbar keyboard shortcuts", () => {
82
+ it("toggles snap on an unclaimed S keypress", () => {
83
+ const { root, onSnapChange } = renderToolbar();
84
+
85
+ act(() => {
86
+ document.dispatchEvent(
87
+ new KeyboardEvent("keydown", { key: "s", bubbles: true, cancelable: true }),
88
+ );
89
+ });
90
+
91
+ expect(onSnapChange).toHaveBeenCalledWith(expect.objectContaining({ snapEnabled: false }));
92
+ act(() => root.unmount());
93
+ });
94
+
95
+ it("does not toggle snap when another handler already prevented S", () => {
96
+ const { root, onSnapChange } = renderToolbar();
97
+ const event = new KeyboardEvent("keydown", {
98
+ key: "s",
99
+ bubbles: true,
100
+ cancelable: true,
101
+ });
102
+ event.preventDefault();
103
+
104
+ act(() => {
105
+ document.dispatchEvent(event);
106
+ });
107
+
108
+ expect(onSnapChange).not.toHaveBeenCalled();
109
+ act(() => root.unmount());
110
+ });
111
+
112
+ it("does not toggle snap when the app split shortcut claims S without a selected clip", () => {
113
+ const { root, onSnapChange } = renderToolbarWithAppHotkeys();
114
+
115
+ act(() => {
116
+ document.dispatchEvent(
117
+ new KeyboardEvent("keydown", { key: "s", bubbles: true, cancelable: true }),
118
+ );
119
+ });
120
+
121
+ expect(onSnapChange).not.toHaveBeenCalled();
122
+ act(() => root.unmount());
123
+ });
124
+ });
@@ -65,6 +65,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
65
65
  useEffect(() => {
66
66
  // fallow-ignore-next-line complexity
67
67
  const handleKeyDown = (e: KeyboardEvent) => {
68
+ if (e.defaultPrevented) return;
68
69
  const t = e.target;
69
70
  if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
70
71
  if (t instanceof HTMLElement && t.isContentEditable) return;
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
5
+ import { usePlayerStore } from "../player/store/playerStore";
5
6
 
6
7
  /**
7
8
  * Regression: `selectedGsapAnimations` (and the fetch fallback) is an async
@@ -178,3 +179,68 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
178
179
  expect(staleLogged).toBe(false);
179
180
  });
180
181
  });
182
+
183
+ // Regression (#1808): with the global auto-keyframe toggle off, dragging an
184
+ // element that already has a keyframed position tween must shift the whole
185
+ // tween (a "replace-with-keyframes" carrying every original percentage) —
186
+ // the same path Alt-drag already takes — instead of inserting a keyframe at
187
+ // the playhead.
188
+ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
189
+ afterEach(() => {
190
+ usePlayerStore.setState({ autoKeyframeEnabled: true });
191
+ });
192
+
193
+ const keyframedPositionAnim = {
194
+ id: "#puck-b-to-position",
195
+ targetSelector: "#puck-b",
196
+ propertyGroup: "position",
197
+ method: "to",
198
+ properties: {},
199
+ position: 0,
200
+ resolvedStart: 0,
201
+ duration: 2,
202
+ keyframes: {
203
+ keyframes: [
204
+ { percentage: 0, properties: { x: 0, y: 0 } },
205
+ { percentage: 100, properties: { x: 100, y: 0 } },
206
+ ],
207
+ },
208
+ } as unknown as GsapAnimation;
209
+
210
+ it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
211
+ usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 2 }); // playhead at 100%
212
+ const commitMutation = vi.fn();
213
+ const iframe = fakeIframe("puck-b", []);
214
+
215
+ const handled = await tryGsapDragIntercept(
216
+ selection,
217
+ { x: -50, y: 0 },
218
+ [keyframedPositionAnim],
219
+ iframe,
220
+ commitMutation,
221
+ );
222
+
223
+ expect(handled).toBe(true);
224
+ const types = commitMutation.mock.calls.map(([, m]) => m.type);
225
+ expect(types).toContain("replace-with-keyframes");
226
+ expect(types).not.toContain("add-keyframe");
227
+ });
228
+
229
+ it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
230
+ usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 2 });
231
+ const commitMutation = vi.fn();
232
+ const iframe = fakeIframe("puck-b", []);
233
+
234
+ const handled = await tryGsapDragIntercept(
235
+ selection,
236
+ { x: -50, y: 0 },
237
+ [keyframedPositionAnim],
238
+ iframe,
239
+ commitMutation,
240
+ );
241
+
242
+ expect(handled).toBe(true);
243
+ const types = commitMutation.mock.calls.map(([, m]) => m.type);
244
+ expect(types).not.toContain("replace-with-keyframes");
245
+ });
246
+ });
@@ -26,6 +26,7 @@ import {
26
26
  findSizeSetAnimation,
27
27
  materializeIfDynamic,
28
28
  } from "./gsapDragCommit";
29
+ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
29
30
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
30
31
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
31
32
  import { selectorFromSelection } from "./gsapShared";
@@ -225,7 +226,12 @@ export async function tryGsapDragIntercept(
225
226
  }
226
227
 
227
228
  const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
228
- if (options?.altKey) {
229
+ // Alt-drag already means "shift the whole path" — the global auto-keyframe
230
+ // toggle (#1808) just makes that the default while it's off, so a manual
231
+ // edit on an already-animated element nudges the animation instead of
232
+ // inserting/updating a keyframe at the playhead.
233
+ const autoKeyframeEnabled = usePlayerStore.getState().autoKeyframeEnabled;
234
+ if (options?.altKey || !autoKeyframeEnabled) {
229
235
  await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
230
236
  } else {
231
237
  await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
@@ -332,6 +338,24 @@ export async function tryGsapResizeIntercept(
332
338
  height: Math.round(size.height),
333
339
  };
334
340
  }
341
+
342
+ // With auto-keyframe off (#1808), `anim` is already a real (non-"set")
343
+ // tween for this resize group, so nudge it as a whole rather than adding a
344
+ // keyframe at the playhead.
345
+ if (!usePlayerStore.getState().autoKeyframeEnabled) {
346
+ if (activeKeyframePct != null) setActiveKeyframePct(null);
347
+ await commitWholePropertyOffset(
348
+ selection,
349
+ anim,
350
+ resizeProps,
351
+ pct,
352
+ iframe,
353
+ { commitMutation, fetchAnimations: fetchFallbackAnimations },
354
+ "Resize animation",
355
+ );
356
+ return true;
357
+ }
358
+
335
359
  const ct = usePlayerStore.getState().currentTime;
336
360
  const ts = resolveTweenStart(anim);
337
361
  const td = resolveTweenDuration(anim);
@@ -490,6 +514,22 @@ export async function tryGsapRotationIntercept(
490
514
 
491
515
  const pct = computeCurrentPercentage(selection, anim);
492
516
 
517
+ // With auto-keyframe off (#1808), a rotation tween already exists for this
518
+ // element (checked above) so nudge it as a whole rather than adding a
519
+ // keyframe at the playhead.
520
+ if (!usePlayerStore.getState().autoKeyframeEnabled) {
521
+ await commitWholePropertyOffset(
522
+ selection,
523
+ anim,
524
+ { rotation: newRotation },
525
+ pct,
526
+ iframe,
527
+ { commitMutation, fetchAnimations: fetchFallbackAnimations },
528
+ "Rotate animation",
529
+ );
530
+ return true;
531
+ }
532
+
493
533
  if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
494
534
  const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
495
535
  if (newId) anim = { ...anim, id: newId };
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
4
+
5
+ function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
6
+ return {
7
+ id: "a1",
8
+ targetSelector: "#title",
9
+ method: "to",
10
+ position: 0,
11
+ properties: {},
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ describe("synthesizeFlatTweenKeyframes", () => {
17
+ it("returns null for a set() static hold", () => {
18
+ expect(synthesizeFlatTweenKeyframes(anim({ method: "set", properties: { x: 5 } }))).toBeNull();
19
+ });
20
+
21
+ // Regression: removeAllKeyframesFromScript collapses a keyframed tween to
22
+ // `tl.to(..., { duration: 0, immediateRender: true })` — a static hold with
23
+ // the same "not an animation" semantics as set(), but a different method
24
+ // string. Before this fix, only position-only (x/y) collapses were treated
25
+ // as holds elsewhere; a collapse to scale/opacity (or anything else) still
26
+ // synthesized a phantom keyframe diamond after "Delete All Keyframes".
27
+ it("returns null for a to() collapsed to a zero-duration immediateRender hold", () => {
28
+ const collapsed = anim({
29
+ method: "to",
30
+ duration: 0,
31
+ // Both parsers encode a literal `immediateRender: true` as this raw
32
+ // source string, not a boolean — see gsapParser.ts/gsapParserAcorn.ts.
33
+ extras: { immediateRender: "__raw:true" },
34
+ properties: { scale: 1, opacity: 1 },
35
+ });
36
+ expect(synthesizeFlatTweenKeyframes(collapsed)).toBeNull();
37
+ });
38
+
39
+ it("still synthesizes keyframes for a genuine animated to() tween", () => {
40
+ const out = synthesizeFlatTweenKeyframes(
41
+ anim({ method: "to", duration: 1, properties: { opacity: 1 } }),
42
+ );
43
+ expect(out).not.toBeNull();
44
+ expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 100]);
45
+ });
46
+
47
+ it("still synthesizes keyframes for a duration:0 tween that isn't an immediateRender hold", () => {
48
+ // duration:0 alone isn't enough — only paired with immediateRender does it
49
+ // mean "this is a static hold, not an animation".
50
+ const out = synthesizeFlatTweenKeyframes(
51
+ anim({ method: "to", duration: 0, properties: { opacity: 1 } }),
52
+ );
53
+ expect(out).not.toBeNull();
54
+ });
55
+ });
@@ -23,12 +23,18 @@ export function deduplicateKeyframes(
23
23
 
24
24
  // fallow-ignore-next-line complexity
25
25
  export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
26
- if (anim.method === "set") {
27
- // A `set` is a STATIC HOLD — a value applied at one point, not an animated
28
- // keyframe. It must NOT synthesize a keyframe, or the timeline + panel show a
29
- // phantom diamond for a value that doesn't animate. This holds for a base
30
- // `gsap.set` (off-timeline) AND an on-timeline `tl.set`, and aligns the AST
31
- // path with the runtime scan, which already skips every zero-duration set.
26
+ // Both parsers store extras as raw source text (`__raw:${code}`) so
27
+ // non-editable config like `stagger: {...}` survives verbatim — a literal
28
+ // `immediateRender: true` prints as exactly this string, not a boolean.
29
+ const hasImmediateRenderHold = anim.extras?.immediateRender === "__raw:true";
30
+ if (anim.method === "set" || (anim.duration === 0 && hasImmediateRenderHold)) {
31
+ // A `set` or a `to()`/`from()` collapsed to a zero-duration
32
+ // immediateRender hold (what removeAllKeyframesFromScript collapses a
33
+ // keyframed tween to) — is a STATIC HOLD: a value applied at one point,
34
+ // not an animated keyframe. It must NOT synthesize a keyframe, or the
35
+ // timeline + panel show a phantom diamond for a value that doesn't
36
+ // animate. This aligns the AST path with the runtime scan, which already
37
+ // skips every zero-duration set.
32
38
  return null;
33
39
  }
34
40
  const toProps = anim.properties;