@hyperframes/studio 0.8.126 → 0.8.127

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 (29) hide show
  1. package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-uqzDaxVL.js} +1 -1
  2. package/dist/assets/{index-nvLF29ZH.js → index-CLBNYlDY.js} +1 -1
  3. package/dist/assets/{index-B8kU2xXl.js → index-COhbL1xq.js} +171 -171
  4. package/dist/assets/{index-Cll7uTNy.js → index-DXSYlqmV.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +1008 -893
  7. package/dist/index.js.map +1 -1
  8. package/package.json +8 -8
  9. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
  10. package/src/components/editor/MotionPathOverlay.tsx +20 -26
  11. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  12. package/src/components/editor/motionPathCommit.ts +58 -0
  13. package/src/components/editor/motionPathGeometry.test.ts +13 -0
  14. package/src/components/editor/motionPathGeometry.ts +4 -2
  15. package/src/hooks/gsapDragCommit.ts +33 -1
  16. package/src/hooks/gsapEditOutcome.ts +27 -2
  17. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  18. package/src/hooks/gsapParsedTween.ts +23 -2
  19. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  20. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  21. package/src/hooks/gsapShared.test.ts +1 -0
  22. package/src/hooks/gsapShared.ts +6 -2
  23. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +107 -5
  24. package/src/hooks/gsapValueAtPlayhead.test.ts +129 -27
  25. package/src/hooks/gsapValueAtPlayhead.ts +46 -6
  26. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  27. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  28. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  29. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.126",
3
+ "version": "0.8.127",
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.126",
64
- "@hyperframes/sdk": "0.8.126",
65
- "@hyperframes/shader-transitions": "0.8.126",
66
- "@hyperframes/player": "0.8.126",
67
- "@hyperframes/parsers": "0.8.126",
68
- "@hyperframes/studio-server": "0.8.126"
63
+ "@hyperframes/parsers": "0.8.127",
64
+ "@hyperframes/sdk": "0.8.127",
65
+ "@hyperframes/player": "0.8.127",
66
+ "@hyperframes/core": "0.8.127",
67
+ "@hyperframes/shader-transitions": "0.8.127",
68
+ "@hyperframes/studio-server": "0.8.127"
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.126"
84
+ "@hyperframes/producer": "0.8.127"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -2,8 +2,10 @@
2
2
  import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { afterEach, expect, it, vi } from "vitest";
5
+ import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
5
6
  import { usePlayerStore } from "../../player/store/playerStore";
6
7
  import { MotionPathOverlay } from "./MotionPathOverlay";
8
+ import { commitNodeDrop } from "./motionPathCommit";
7
9
  import type { DomEditSelection } from "./domEditing";
8
10
 
9
11
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
@@ -14,6 +16,14 @@ const { commitMutation } = vi.hoisted(() => ({
14
16
  vi.mock("../../contexts/DomEditContext", () => ({
15
17
  useDomEditContext: () => ({ selectedGsapAnimations: [], commitMutation }),
16
18
  }));
19
+ const showToast = vi.hoisted(() => vi.fn());
20
+ vi.mock("../../contexts/StudioContext", () => ({
21
+ useStudioShellContextOptional: () => ({ showToast }),
22
+ }));
23
+ vi.mock("./motionPathCommit", async (importOriginal) => ({
24
+ ...(await importOriginal<typeof import("./motionPathCommit")>()),
25
+ commitNodeDrop: vi.fn(),
26
+ }));
17
27
  vi.mock("./motionPathSelection", () => ({
18
28
  selectorFor: () => "#box",
19
29
  editableAnimationId: () => "a1",
@@ -189,3 +199,35 @@ it("draws where GSAP started the tween before the first keyframe, as a mark that
189
199
  const drawn = host.querySelector("polyline:not(.pointer-events-auto)")!;
190
200
  expect(drawn.getAttribute("points")).toBe("50,30 60,30 140,30");
191
201
  });
202
+
203
+ it("a node drop the writer refuses says why", async () => {
204
+ const refusal = new GsapEditBlockedError("keyframes-uneditable");
205
+ vi.mocked(commitNodeDrop).mockRejectedValue(refusal);
206
+ vi.spyOn(Element.prototype, "setPointerCapture").mockImplementation(() => {});
207
+ const host = document.body.appendChild(document.createElement("div"));
208
+ const root = createRoot(host);
209
+ const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
210
+ try {
211
+ act(() =>
212
+ root.render(
213
+ <MotionPathOverlay
214
+ iframeRef={{ current: null }}
215
+ selection={selection}
216
+ compositionSize={{ width: 1920, height: 1080 }}
217
+ isPlaying={false}
218
+ />,
219
+ ),
220
+ );
221
+ const node = host.querySelector('circle.pointer-events-auto[cx="140"]')!;
222
+ document.elementsFromPoint = () => [node];
223
+ const at = (clientX: number) => ({ bubbles: true, button: 0, clientX, clientY: 30 });
224
+ act(() => void node.dispatchEvent(new PointerEvent("pointerdown", at(140))));
225
+ act(() => void node.dispatchEvent(new PointerEvent("pointermove", at(180))));
226
+ await act(async () => void node.dispatchEvent(new PointerEvent("pointerup", at(180))));
227
+ expect(commitNodeDrop).toHaveBeenCalledTimes(1);
228
+ expect(showToast).toHaveBeenCalledWith(refusal.message, "error");
229
+ } finally {
230
+ act(() => root.unmount());
231
+ host.remove();
232
+ }
233
+ });
@@ -1,13 +1,12 @@
1
- import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
2
- import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
3
1
  import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
4
2
  import { memo, useEffect, useRef, useState, type RefObject } from "react";
5
3
  import type { DomEditSelection } from "./domEditing";
6
4
  import { useDomEditContext } from "../../contexts/DomEditContext";
5
+ import { useStudioShellContextOptional } from "../../contexts/StudioContext";
6
+ import { useGsapInteractionFailureTelemetry } from "../../hooks/useGsapInteractionFailureTelemetry";
7
7
  import { usePlayerStore } from "../../player/store/playerStore";
8
8
  import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
9
9
  import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
10
- import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
11
10
  import { nearestPointOnPath, nodeCentre, type MotionNodeRef } from "./motionPathGeometry";
12
11
  import { editableAnimationId, selectorFor } from "./motionPathSelection";
13
12
  import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
@@ -23,8 +22,9 @@ import {
23
22
  commitAddKeyframe,
24
23
  commitAddWaypoint,
25
24
  commitCreatePath,
26
- commitNode,
25
+ commitNodeDrop,
27
26
  commitRemoveWaypoint,
27
+ nodeDropLabel,
28
28
  } from "./motionPathCommit";
29
29
  import { elementHome } from "./motionPathHome";
30
30
  import { hasMotionPathPlugin, transformWDivisor, useMotionPathData } from "./useMotionPathData";
@@ -66,6 +66,7 @@ const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
66
66
  // (select the keyframe); at or above it the gesture commits a move. Screen-space
67
67
  // (not composition px) so it behaves identically at any zoom.
68
68
  const DRAG_THRESHOLD_PX = 3;
69
+ const noToast = () => {};
69
70
 
70
71
  /**
71
72
  * Draws the selected element's GSAP motion path over the canvas — a dashed
@@ -91,6 +92,11 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
91
92
  handleGsapRemoveAllKeyframes,
92
93
  handleGsapMoveKeyframeToPlayhead,
93
94
  } = useDomEditContext();
95
+ const shell = useStudioShellContextOptional();
96
+ const reportFailure = useGsapInteractionFailureTelemetry(
97
+ shell?.activeCompPath ?? null,
98
+ shell?.showToast ?? noToast,
99
+ );
94
100
  const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
95
101
  iframeRef,
96
102
  selectorFor(selection),
@@ -373,30 +379,18 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
373
379
  // high zoom) would commit an identical value — a no-op undo entry. Skip the
374
380
  // commit, but don't treat it as a click either (the user did drag).
375
381
  if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
376
- // Auto-keyframe off (#1808): dragging a keyframe's node shifts the whole path.
377
382
  const anim =
378
383
  d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
379
- if (
380
- d.ref.type === "keyframe" &&
381
- anim &&
382
- selection &&
383
- !usePlayerStore.getState().autoKeyframeEnabled
384
- ) {
385
- const writes = observeGsapGesture((_sel, mutation, options) =>
386
- commitMutation(mutation, options),
387
- );
388
- void commitWholePropertyOffset(
389
- selection,
390
- anim,
391
- { x, y },
392
- d.ref.pct,
393
- iframeRef.current,
394
- { commitMutation: writes.commit! },
395
- "Move animation path",
396
- ).then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
397
- } else {
398
- void commitNode(d.ref, x, y, animId, commitMutation);
399
- }
384
+ const label = nodeDropLabel(d.ref);
385
+ commitNodeDrop({
386
+ ref: d.ref,
387
+ at: { x, y },
388
+ animId,
389
+ anim,
390
+ selection,
391
+ iframe: iframeRef.current,
392
+ commitMutation,
393
+ }).catch((error: unknown) => reportFailure(error, selection, "drag", label));
400
394
  // Park on the edited keyframe, or a playhead before the tween hides the edit.
401
395
  if (d.ref.type === "keyframe" && anim) {
402
396
  parkPlayheadOnKeyframe(anim, d.ref.pct);
@@ -0,0 +1,212 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, expect, it, onTestFinished, vi } from "vitest";
3
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
+ import type { DomEditSelection } from "./domEditing";
5
+ import { boxSteps, previewWith, tween } from "../../hooks/gsapParsedTween.test-helpers";
6
+ import { usePlayerStore } from "../../player/store/playerStore";
7
+ import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
8
+ import { commitNodeDrop, nodeDropLabel, type CommitFn } from "./motionPathCommit";
9
+
10
+ it("names a node drop for its undo entry by what it moved", () => {
11
+ const { autoKeyframeEnabled } = usePlayerStore.getState();
12
+ onTestFinished(() => usePlayerStore.setState({ autoKeyframeEnabled }));
13
+ usePlayerStore.setState({ autoKeyframeEnabled: true });
14
+ expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move keyframe");
15
+ usePlayerStore.setState({ autoKeyframeEnabled: false });
16
+ expect(nodeDropLabel({ type: "keyframe", pct: 50 })).toBe("Move animation path");
17
+ expect(nodeDropLabel({ type: "waypoint", index: 0 })).toBe("Move waypoint");
18
+ });
19
+
20
+ afterEach(() => {
21
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null, requestedSeekTime: null });
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ function box(id = "box") {
26
+ const element = document.body.appendChild(document.createElement("div"));
27
+ element.id = id;
28
+ return { element, selection: { id, selector: `#${id}`, element } as DomEditSelection };
29
+ }
30
+
31
+ type Drop = Parameters<typeof commitNodeDrop>[0];
32
+
33
+ function drop(fields: Omit<Drop, "animId" | "commitMutation"> & { anim: GsapAnimation }) {
34
+ const commitMutation = vi.fn<CommitFn>(async () => {});
35
+ const done = commitNodeDrop({ ...fields, animId: fields.anim.id, commitMutation });
36
+ const written = () =>
37
+ (commitMutation.mock.calls[0]![0] as { keyframes: { properties: object }[] }).keyframes.map(
38
+ (kf) => kf.properties,
39
+ );
40
+ return { done, commitMutation, written };
41
+ }
42
+
43
+ it("a keyframe node's drop changes that keyframe and keeps a newly animated channel on the others", async () => {
44
+ const { element, selection } = box();
45
+ // Over a CSS translate GSAP reads as y 30.
46
+ const { keys, live } = boxSteps([
47
+ [2, { x: 60 }],
48
+ [1, { x: 120 }],
49
+ ]);
50
+ usePlayerStore.setState({ currentTime: 2, autoKeyframeEnabled: true });
51
+
52
+ const { done, commitMutation, written } = drop({
53
+ ref: { type: "keyframe", pct: 100 },
54
+ at: { x: 150, y: 90 },
55
+ anim: keys,
56
+ selection,
57
+ iframe: previewWith(element, [live(element)], { x: 60, y: 30 }),
58
+ });
59
+ await done;
60
+
61
+ expect(commitMutation.mock.calls[0]![0].type).toBe("replace-with-keyframes");
62
+ expect(written()).toEqual([
63
+ { x: 60, y: 30 },
64
+ { x: 150, y: 90 },
65
+ ]);
66
+ });
67
+
68
+ it("a drop on an array step's node edits that step, though the path spaces nodes evenly", async () => {
69
+ const { element, selection } = box();
70
+ // Step ends at 2 s and 3 s; the path draws the first node at 0%, the parser and lane at 66.7%.
71
+ const { keys, live } = boxSteps([
72
+ [2, { x: 60, y: 30 }],
73
+ [1, { x: 120, y: 30 }],
74
+ ]);
75
+ usePlayerStore.setState({ currentTime: 1, autoKeyframeEnabled: true, activeKeyframePct: null });
76
+
77
+ const { done, written } = drop({
78
+ ref: { type: "keyframe", pct: 0, step: 0 },
79
+ at: { x: 40, y: 90 },
80
+ anim: keys,
81
+ selection,
82
+ iframe: previewWith(element, [live(element)], { x: 60, y: 30 }),
83
+ });
84
+ await done;
85
+
86
+ expect(written()).toEqual([
87
+ { x: 40, y: 90 },
88
+ { x: 120, y: 30 },
89
+ ]);
90
+ expect(usePlayerStore.getState().requestedSeekTime).toBe(2);
91
+ });
92
+
93
+ it("a drop the writer refuses rejects with the reason, writes nothing and selects nothing", async () => {
94
+ const [a, b] = [box("a"), box("b")];
95
+ for (const { element } of [a, b]) element.className = "card";
96
+ const shared = tween({
97
+ targetSelector: ".card",
98
+ method: "to",
99
+ properties: {},
100
+ resolvedStart: 0,
101
+ duration: 1,
102
+ keyframes: { format: "percentage", keyframes: [{ percentage: 100, properties: { x: 20 } }] },
103
+ });
104
+ usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
105
+
106
+ const { done, commitMutation } = drop({
107
+ ref: { type: "keyframe", pct: 100 },
108
+ at: { x: 5, y: 0 },
109
+ anim: shared,
110
+ selection: a.selection,
111
+ iframe: previewWith(b.element, [], { x: 0, y: 0 }),
112
+ });
113
+
114
+ await expect(done).rejects.toBeInstanceOf(GsapEditBlockedError);
115
+ expect(commitMutation).not.toHaveBeenCalled();
116
+ expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
117
+ });
118
+
119
+ it.each([true, false])(
120
+ "a drop on a step list with a step delay says the delay blocks it and writes nothing (auto-keyframe %s)",
121
+ async (autoKeyframeEnabled) => {
122
+ const { element, selection } = box();
123
+ const { keys } = boxSteps([
124
+ [1.5, { x: 100, delay: 0.5 }],
125
+ [1.5, { x: 200, delay: 0.5 }],
126
+ ]);
127
+ usePlayerStore.setState({ autoKeyframeEnabled, activeKeyframePct: null });
128
+
129
+ const { done, commitMutation } = drop({
130
+ ref: { type: "keyframe", pct: 100 },
131
+ at: { x: 150, y: 0 },
132
+ anim: keys,
133
+ selection,
134
+ iframe: previewWith(element, [], { x: 0, y: 0 }),
135
+ });
136
+
137
+ await expect(done).rejects.toThrow(/its own delay.*Code tab/);
138
+ expect(commitMutation).not.toHaveBeenCalled();
139
+ },
140
+ );
141
+
142
+ it("with auto-keyframe off, a step's node shifts the path by its own keyframe's move", async () => {
143
+ const { selection } = box();
144
+ // Steps of 4 s, 1 s and 1 s: drawn at 0/50/100%, parsed at their step ends.
145
+ const { keys } = boxSteps([
146
+ [4, { x: 60, y: 0 }],
147
+ [1, { x: 120, y: 0 }],
148
+ [1, { x: 180, y: 0 }],
149
+ ]);
150
+ usePlayerStore.setState({ autoKeyframeEnabled: false, activeKeyframePct: null });
151
+
152
+ const { done, written } = drop({
153
+ ref: { type: "keyframe", pct: 50, step: 1 },
154
+ at: { x: 157, y: 0 },
155
+ anim: keys,
156
+ selection,
157
+ iframe: null,
158
+ });
159
+ await done;
160
+
161
+ expect(written().map((props) => (props as { x: number }).x)).toEqual([97, 157, 217]);
162
+ });
163
+
164
+ it("a drop on an inner keyframe of a tween eased as a whole changes that keyframe", async () => {
165
+ const { element, selection } = box();
166
+ const keys = tween({
167
+ id: "#box-to-0-position",
168
+ method: "to",
169
+ properties: {},
170
+ resolvedStart: 0,
171
+ duration: 2,
172
+ ease: "power1.inOut",
173
+ keyframes: {
174
+ format: "percentage",
175
+ keyframes: [0, 100, 200].map((x, i) => ({ percentage: i * 50, properties: { x, y: 0 } })),
176
+ },
177
+ });
178
+ usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
179
+
180
+ const { done, written } = drop({
181
+ ref: { type: "keyframe", pct: 50 },
182
+ at: { x: 150, y: 0 },
183
+ anim: keys,
184
+ selection,
185
+ iframe: previewWith(element, [], { x: 0, y: 0 }),
186
+ });
187
+ await done;
188
+
189
+ expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 200]);
190
+ });
191
+
192
+ it("a drop on a short step under 1% from the one before edits the dragged step", async () => {
193
+ const { element, selection } = box();
194
+ // Parsed at 50, 50.5 and 100%: a 0.05 s pop step in a 10 s list.
195
+ const { keys, live } = boxSteps([
196
+ [5, { x: 0, y: 0 }],
197
+ [0.05, { x: 100, y: 0 }],
198
+ [4.95, { x: 100, y: 0 }],
199
+ ]);
200
+ usePlayerStore.setState({ autoKeyframeEnabled: true, activeKeyframePct: null });
201
+
202
+ const { done, written } = drop({
203
+ ref: { type: "keyframe", pct: 50, step: 1 },
204
+ at: { x: 150, y: 0 },
205
+ anim: keys,
206
+ selection,
207
+ iframe: previewWith(element, [live(element)], { x: 0, y: 0 }),
208
+ });
209
+ await done;
210
+
211
+ expect(written().map((props) => (props as { x: number }).x)).toEqual([0, 150, 100]);
212
+ });
@@ -5,7 +5,17 @@ import { trackPreviewFeatureUsed, type PreviewMethod } from "../../utils/preview
5
5
  * GSAP source mutation routed through the (selection-bound) commit facade, which
6
6
  * handles the soft reload, undo snapshot, and save-failure feedback.
7
7
  */
8
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
9
+ import { assertGsapEditPersisted } from "../../hooks/gsapEditOutcome";
10
+ import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
11
+ import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
12
+ import { commitValueAtPlayhead } from "../../hooks/gsapValueAtPlayhead";
13
+ import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
14
+ import { usePlayerStore } from "../../player/store/playerStore";
15
+ import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
16
+ import type { DomEditSelection } from "./domEditing";
8
17
  import type { MotionNodeRef } from "./motionPathGeometry";
18
+ import { selectorFor } from "./motionPathSelection";
9
19
 
10
20
  export type CommitFn = (
11
21
  mutation: Record<string, unknown>,
@@ -41,6 +51,54 @@ export function commitNode(
41
51
  );
42
52
  }
43
53
 
54
+ type NodeDrop = {
55
+ ref: MotionNodeRef;
56
+ at: { x: number; y: number };
57
+ animId: string;
58
+ anim: GsapAnimation | undefined;
59
+ selection: DomEditSelection | null;
60
+ iframe: HTMLIFrameElement | null;
61
+ commitMutation: CommitFn;
62
+ };
63
+
64
+ /** What a node drop does, for its undo entry and its failure report. */
65
+ export function nodeDropLabel(ref: MotionNodeRef): string {
66
+ if (ref.type !== "keyframe") return "Move waypoint";
67
+ return usePlayerStore.getState().autoKeyframeEnabled ? "Move keyframe" : "Move animation path";
68
+ }
69
+
70
+ /** A dropped keyframe goes through the layer drag's writer, GSAP's live values backfilling others
71
+ * (auto-keyframe off, #1808: the whole path shifts); a waypoint moves in place. */
72
+ export function commitNodeDrop(drop: NodeDrop): Promise<void> {
73
+ const { ref, at, anim, selection, iframe, commitMutation } = drop;
74
+ if (ref.type !== "keyframe" || !anim || !selection)
75
+ return commitNode(ref, at.x, at.y, drop.animId, commitMutation);
76
+ const writes = observeGsapGesture((_sel, mutation, options) => commitMutation(mutation, options));
77
+ const callbacks = { commitMutation: writes.commit! };
78
+ let done: Promise<unknown>;
79
+ const store = usePlayerStore.getState();
80
+ const step = ref.step == null ? undefined : anim.keyframes?.keyframes[ref.step];
81
+ const pct = step?.percentage ?? ref.pct;
82
+ if (store.autoKeyframeEnabled) {
83
+ const selected = store.activeKeyframePct;
84
+ store.setActiveKeyframePct(pct);
85
+ const live = readGsapPositionFromIframe(iframe, selectorFor(selection) ?? "");
86
+ done = commitValueAtPlayhead(selection, anim, at, iframe, callbacks, {
87
+ label: nodeDropLabel(ref),
88
+ backfill: live ?? undefined,
89
+ })
90
+ .then(assertGsapEditPersisted)
91
+ .catch((error: unknown) => {
92
+ usePlayerStore.getState().setActiveKeyframePct(selected);
93
+ throw error;
94
+ });
95
+ } else {
96
+ const label = nodeDropLabel(ref);
97
+ done = commitWholePropertyOffset(selection, anim, at, pct, iframe, callbacks, label);
98
+ }
99
+ return done.then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
100
+ }
101
+
44
102
  export function commitAddWaypoint(
45
103
  animationId: string,
46
104
  index: number,
@@ -182,3 +182,16 @@ describe("start", () => {
182
182
  );
183
183
  });
184
184
  });
185
+
186
+ it("an array step's node carries its step, so a drop finds that keyframe whatever its time", () => {
187
+ const read = {
188
+ keyframes: [
189
+ { percentage: 0, properties: { x: 60 }, step: 0 },
190
+ { percentage: 100, properties: { x: 120 }, step: 1 },
191
+ ],
192
+ };
193
+ expect(buildMotionPathGeometry(read)!.nodes.map((n) => n.ref)).toEqual([
194
+ { type: "keyframe", pct: 0, step: 0 },
195
+ { type: "keyframe", pct: 100, step: 1 },
196
+ ]);
197
+ });
@@ -8,7 +8,7 @@ import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
8
8
 
9
9
  /** Which source edit a dragged node maps to. */
10
10
  export type MotionNodeRef =
11
- | { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative %
11
+ | { type: "keyframe"; pct: number; step?: number } // x/y keyframe at this tween-relative %, array slot
12
12
  | { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index
13
13
 
14
14
  /** An offset on the path and the layout width/height set there, when one is. */
@@ -141,7 +141,9 @@ export function buildMotionPathGeometry(
141
141
  if (!at) return;
142
142
  nodes.push({
143
143
  ...at,
144
- ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
144
+ ref: isArc
145
+ ? { type: "waypoint", index: i }
146
+ : { type: "keyframe", pct: kf.percentage, ...(kf.step == null ? {} : { step: kf.step }) },
145
147
  });
146
148
  });
147
149
 
@@ -12,7 +12,8 @@ import { computeDraggedGsapPosition } from "./draggedGsapPosition";
12
12
  import type { CommitMutation } from "./gsapScriptCommitTypes";
13
13
  import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
14
14
  import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
15
- import { GsapEditBlockedError } from "./gsapEditOutcome";
15
+ import { GsapEditBlockedError, type PlayheadEditRefusal } from "./gsapEditOutcome";
16
+ import { isTweenConfigKey } from "@hyperframes/parsers/gsap-constants";
16
17
  export {
17
18
  findExistingPositionWrite,
18
19
  findRotationSetAnimation,
@@ -117,6 +118,36 @@ export async function materializeIfDynamic(
117
118
  throw new GsapEditBlockedError("source-uneditable", "geometry-unresolved-source");
118
119
  }
119
120
 
121
+ /** Why a percentage keyframe can't stand in for this array step entry, or null when it can. */
122
+ function stepBlock([key, value]: [string, number | string]): PlayheadEditRefusal | null {
123
+ if (key === "delay") return "array-step-delay";
124
+ if (/^on[A-Z]/.test(key)) return "array-step-callback";
125
+ if (isTweenConfigKey(key)) return "array-step-config";
126
+ if (typeof value === "number") return null;
127
+ return STEP_VALUE_BLOCKS.find(([pattern]) => pattern.test(value))?.[1] ?? null;
128
+ }
129
+
130
+ /** First match wins: code is computed, even when it calls random(). */
131
+ const STEP_VALUE_BLOCKS: Array<[RegExp, PlayheadEditRefusal]> = [
132
+ [/^__raw:/, "array-step-computed"],
133
+ [/random\(/, "array-step-random"],
134
+ [/[-+*/]=/, "array-step-relative"],
135
+ ];
136
+
137
+ /** Why a step list can't be rewritten as percentage keyframes, or null. */
138
+ export function stepListBlock(anim: GsapAnimation): PlayheadEditRefusal | null {
139
+ const data = anim.keyframes;
140
+ if (data?.format !== "object-array") return null;
141
+ const entries = data.keyframes.flatMap((kf) => Object.entries(kf.properties));
142
+ return entries.map(stepBlock).find(Boolean) ?? null;
143
+ }
144
+
145
+ /** Whole-offset writers rewrite every step as a keyframe; refuse a list that holds more than values. */
146
+ export function refuseStepListRewrite(anim: GsapAnimation): void {
147
+ const step = stepListBlock(anim);
148
+ if (step) throw new GsapEditBlockedError("keyframes-uneditable", step);
149
+ }
150
+
120
151
  // ── Drag → GSAP position math ──────────────────────────────────────────────
121
152
 
122
153
  /**
@@ -330,6 +361,7 @@ export async function commitWholePathOffset(
330
361
  // fallow-ignore-next-line code-duplication
331
362
  let effectiveAnim = anim;
332
363
  if (anim.keyframes) {
364
+ refuseStepListRewrite(anim);
333
365
  const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
334
366
  if (newId) effectiveAnim = { ...anim, id: newId };
335
367
  }
@@ -15,7 +15,7 @@ export type GsapEditBlockReason =
15
15
  * true for `provenance-runtime-dynamic`; the others are parser or source-match
16
16
  * limits, where the animation may well be plain authored source.
17
17
  *
18
- * Telemetry only. Nothing branches on it.
18
+ * Telemetry, plus the sharper message a few details get (GSAP_EDIT_DETAIL_COPY).
19
19
  */
20
20
  export type GsapEditBlockDetail =
21
21
  | "provenance-runtime-dynamic"
@@ -33,6 +33,12 @@ export type GsapEditBlockDetail =
33
33
  export type PlayheadEditRefusal =
34
34
  | "eased-keyframes"
35
35
  | "simple-array-keyframes"
36
+ | "array-step-delay"
37
+ | "array-step-callback"
38
+ | "array-step-config"
39
+ | "array-step-computed"
40
+ | "array-step-relative"
41
+ | "array-step-random"
36
42
  | "unknown-ease"
37
43
  | "implicit-end-unknown"
38
44
  | "not-a-tween"
@@ -74,12 +80,31 @@ export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
74
80
  "These layers are animated in different files. Move each file's layers separately.",
75
81
  };
76
82
 
83
+ const STEP_LIST = "This animation's keyframes have a step";
84
+ const IN_CODE = "Edit them in the Code tab.";
85
+ /** A refusal a person can act on more precisely than its reason's message: what blocks it, then what to do. */
86
+ const GSAP_EDIT_DETAIL_COPY: Partial<Record<GsapEditBlockDetail, string>> = {
87
+ "array-step-delay": `${STEP_LIST} with its own delay, which Studio can't keep while it edits them. ${IN_CODE}`,
88
+ "array-step-callback": `${STEP_LIST} that runs code as it plays (like onComplete), which an edit would run again. ${IN_CODE}`,
89
+ "array-step-config": `${STEP_LIST} with its own tween setting (like repeat or stagger), which Studio can't keep while it edits them. ${IN_CODE}`,
90
+ "array-step-computed": `${STEP_LIST} whose value comes from code, which Studio can't keep while it edits them. ${IN_CODE}`,
91
+ "array-step-relative": `${STEP_LIST} with a relative value (like "+=40"), which an edit would apply again. ${IN_CODE}`,
92
+ "array-step-random": `${STEP_LIST} with a random() value, which an edit would roll again. ${IN_CODE}`,
93
+ };
94
+
95
+ export function gsapEditBlockMessage(
96
+ reason: GsapEditBlockReason,
97
+ detail?: GsapEditBlockDetail,
98
+ ): string {
99
+ return (detail && GSAP_EDIT_DETAIL_COPY[detail]) ?? GSAP_EDIT_BLOCK_COPY[reason];
100
+ }
101
+
77
102
  export class GsapEditBlockedError extends Error {
78
103
  constructor(
79
104
  readonly reason: GsapEditBlockReason,
80
105
  readonly detail?: GsapEditBlockDetail,
81
106
  ) {
82
- super(GSAP_EDIT_BLOCK_COPY[reason]);
107
+ super(gsapEditBlockMessage(reason, detail));
83
108
  this.name = "GsapEditBlockedError";
84
109
  }
85
110
  }
@@ -22,7 +22,7 @@ export function liveTween(
22
22
  vars: tween.vars,
23
23
  _from: from,
24
24
  ...(head && { _pt: { d: { _pt: head } } }),
25
- ...(parts && { timeline: { getChildren: () => parts } }),
25
+ ...(parts && { timeline: { getChildren: () => parts, duration: () => tween.duration } }),
26
26
  };
27
27
  }
28
28
 
@@ -47,6 +47,33 @@ export function previewWith(
47
47
  export const tween = (fields: Partial<GsapAnimation>): GsapAnimation =>
48
48
  ({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation;
49
49
 
50
+ /** `#box`'s position as a keyframe step list, parsed and run by GSAP (step `[duration, props]`). */
51
+ export function boxSteps(steps: Array<[number, Record<string, number | string>]>) {
52
+ const duration = steps.reduce((sum, [d]) => sum + d, 0);
53
+ let end = 0;
54
+ const keyframes = steps.map(([d, properties]) => {
55
+ end += d;
56
+ return { percentage: Math.round((end / duration) * 1000) / 10, properties };
57
+ });
58
+ const keys = tween({
59
+ id: "#box-to-0-position",
60
+ method: "to",
61
+ properties: {},
62
+ resolvedStart: 0,
63
+ duration,
64
+ keyframes: { format: "object-array", keyframes },
65
+ });
66
+ let start = 0;
67
+ const parts = steps.map(([d]) => {
68
+ const at = start;
69
+ start += d;
70
+ return { startTime: () => at, duration: () => d };
71
+ });
72
+ const live = (el: Element) =>
73
+ liveTween(el, { start: 0, duration, vars: { keyframes: [] } }, { parts });
74
+ return { keys, live };
75
+ }
76
+
50
77
  /** A `to` tween on `#el` from 0, grouped as the parser would; duration 0 is an immediate-render hold. */
51
78
  export function elTween(
52
79
  id: string,