@hyperframes/studio 0.6.105 → 0.6.107

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 (35) hide show
  1. package/dist/assets/{index-BITwbxi-.css → index-BrhJl2JY.css} +1 -1
  2. package/dist/assets/index-CVHV-S-B.js +296 -0
  3. package/dist/assets/{index-NIdeStFw.js → index-P52mbTRb.js} +1 -1
  4. package/dist/assets/{index-By4Ku5HI.js → index-zIK7PWvk.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +5 -5
  7. package/src/components/StudioRightPanel.tsx +2 -0
  8. package/src/components/editor/AnimationCard.tsx +8 -24
  9. package/src/components/editor/ComputedTweenNotice.tsx +40 -0
  10. package/src/components/editor/GsapAnimationSection.tsx +5 -24
  11. package/src/components/editor/PropertyPanel.tsx +2 -0
  12. package/src/components/editor/gsapAnimationCallbacks.ts +33 -0
  13. package/src/components/editor/propertyPanelHelpers.ts +2 -0
  14. package/src/contexts/DomEditContext.tsx +4 -0
  15. package/src/hooks/gsapDragCommit.ts +90 -3
  16. package/src/hooks/gsapRuntimeBridge.ts +70 -19
  17. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  18. package/src/hooks/gsapRuntimeKeyframes.ts +198 -123
  19. package/src/hooks/gsapScriptCommitTypes.ts +11 -0
  20. package/src/hooks/serializeByKey.test.ts +83 -0
  21. package/src/hooks/serializeByKey.ts +33 -0
  22. package/src/hooks/useDomEditSession.ts +2 -0
  23. package/src/hooks/useDomGeometryCommits.ts +9 -1
  24. package/src/hooks/useGsapAnimationOps.ts +5 -1
  25. package/src/hooks/useGsapAwareEditing.ts +21 -0
  26. package/src/hooks/useGsapKeyframeOps.ts +21 -1
  27. package/src/hooks/useGsapScriptCommits.ts +33 -5
  28. package/src/hooks/useGsapTweenCache.ts +49 -7
  29. package/src/utils/sdkShadow.test.ts +187 -1
  30. package/src/utils/sdkShadow.ts +67 -10
  31. package/src/utils/sdkShadowGsapFidelity.ts +46 -18
  32. package/src/utils/sdkShadowGsapKeyframe.test.ts +265 -0
  33. package/src/utils/sdkShadowGsapKeyframe.ts +257 -0
  34. package/src/utils/sdkShadowNumeric.ts +11 -0
  35. package/dist/assets/index-CKd39gmy.js +0 -296
@@ -1,9 +1,15 @@
1
1
  /**
2
2
  * Read GSAP keyframe data from the live runtime in the preview iframe.
3
3
  * Used to discover dynamic keyframes that the AST parser can't resolve
4
- * (loops, variables, computed selectors).
4
+ * (data-driven loops, fetched values, computed selectors).
5
+ *
6
+ * Keyframe percentages returned here are TWEEN-RELATIVE (0–100 within the
7
+ * tween), matching the static parser. Callers convert to clip-relative via
8
+ * `toAbsoluteTime` + the element's clip start/duration. `scanAllRuntimeKeyframes`
9
+ * does that conversion itself when given a `clipById` map.
5
10
  */
6
- import { parsePercentageKeyframes } from "./gsapShared";
11
+ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
12
+ import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
7
13
  import { roundTo3 } from "../utils/rounding";
8
14
 
9
15
  interface RuntimeTween {
@@ -18,155 +24,224 @@ interface RuntimeTimeline {
18
24
  duration?: () => number;
19
25
  }
20
26
 
21
- export function readRuntimeKeyframes(
22
- iframe: HTMLIFrameElement | null,
23
- selector: string,
24
- compositionId?: string,
25
- ): {
26
- keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
27
+ type Pct = { percentage: number; properties: Record<string, number | string> };
28
+ type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
29
+
30
+ export interface RuntimeKeyframeEntry {
31
+ keyframes: Pct[];
27
32
  easeEach?: string;
28
- } | null {
29
- if (!iframe?.contentWindow) return null;
33
+ /** Present when the live tween uses motionPath — drives the Arc Motion panel. */
34
+ arcPath?: ArcPathConfig;
35
+ /** Absolute start time of the source tween (seconds). */
36
+ tweenStart: number;
37
+ /** Duration of the source tween (seconds). */
38
+ tweenDuration: number;
39
+ }
30
40
 
31
- let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
41
+ /** Clip start/duration per element id, to convert tween-relative % to clip-relative. */
42
+ export type ClipDims = Map<string, { start: number; duration: number }>;
43
+
44
+ const FLAT_SKIP_KEYS = new Set([
45
+ "ease",
46
+ "duration",
47
+ "delay",
48
+ "stagger",
49
+ "motionPath",
50
+ "overwrite",
51
+ "immediateRender",
52
+ "onComplete",
53
+ "onUpdate",
54
+ "onStart",
55
+ "keyframes",
56
+ ]);
57
+
58
+ function timelinesOf(iframe: HTMLIFrameElement | null): Record<string, RuntimeTimeline> | null {
59
+ if (!iframe?.contentWindow) return null;
32
60
  try {
33
- timelines = (
34
- iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
35
- ).__timelines;
61
+ return (
62
+ (iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> })
63
+ .__timelines ?? null
64
+ );
36
65
  } catch {
37
66
  return null;
38
67
  }
39
- if (!timelines) return null;
68
+ }
69
+
70
+ function isXY(p: unknown): p is { x: number; y: number } {
71
+ return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
72
+ }
73
+
74
+ /** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
75
+ function coordsFromMotionPath(mp: unknown): {
76
+ coords: Array<{ x: number; y: number }>;
77
+ curviness: number;
78
+ autoRotate: boolean | number;
79
+ isCubic: boolean;
80
+ } | null {
81
+ if (!mp || typeof mp !== "object") return null;
82
+ const obj = mp as Record<string, unknown>;
83
+ const pathVal = Array.isArray(mp) ? mp : obj.path;
84
+ if (!Array.isArray(pathVal)) return null;
85
+ const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
86
+ if (coords.length < 2) return null;
87
+ const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
88
+ const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
89
+ return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
90
+ }
91
+
92
+ /** Build an arcPath config from a live `vars.motionPath` value. */
93
+ export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
94
+ const parsed = coordsFromMotionPath(mp);
95
+ if (!parsed) return undefined;
96
+ return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
97
+ }
98
+
99
+ function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
100
+ const properties: Record<string, number | string> = {};
101
+ for (const [k, v] of Object.entries(vars)) {
102
+ if (FLAT_SKIP_KEYS.has(k)) continue;
103
+ if (typeof v === "number") properties[k] = roundTo3(v);
104
+ else if (typeof v === "string") properties[k] = v;
105
+ }
106
+ if (Object.keys(properties).length === 0) return null;
107
+ return [
108
+ { percentage: 0, properties },
109
+ { percentage: 100, properties },
110
+ ];
111
+ }
112
+
113
+ /** Tween-relative keyframes + optional arcPath for one live tween, or null. */
114
+ function readTween(vars: Record<string, unknown>): ReadTween | null {
115
+ if (vars.keyframes && typeof vars.keyframes === "object") {
116
+ const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
117
+ if (parsed) return parsed;
118
+ }
119
+ const mp = coordsFromMotionPath(vars.motionPath);
120
+ if (mp) {
121
+ const shape = buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
122
+ if (shape) {
123
+ const n = shape.waypoints.length;
124
+ const keyframes = shape.waypoints.map((wp, i) => ({
125
+ percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
126
+ properties: { x: wp.x, y: wp.y },
127
+ }));
128
+ return { keyframes, arcPath: shape.arcPath };
129
+ }
130
+ }
131
+ const flat = flatTweenKeyframes(vars);
132
+ return flat ? { keyframes: flat } : null;
133
+ }
134
+
135
+ function matchesElement(tween: RuntimeTween, el: Element): boolean {
136
+ if (!tween.targets) return false;
137
+ for (const t of tween.targets()) {
138
+ if (t === el || (el.id && (t as Element).id === el.id)) return true;
139
+ }
140
+ return false;
141
+ }
40
142
 
143
+ function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
144
+ const rawStart = typeof tween.startTime === "function" ? tween.startTime() : 0;
145
+ const rawDur = typeof tween.duration === "function" ? tween.duration() : 0;
146
+ return {
147
+ start: Number.isFinite(rawStart) ? rawStart : 0,
148
+ duration: Number.isFinite(rawDur) ? rawDur : 0,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
154
+ * Returns tween-relative percentages; callers convert to clip-relative.
155
+ */
156
+ export function readRuntimeKeyframes(
157
+ iframe: HTMLIFrameElement | null,
158
+ selector: string,
159
+ compositionId?: string,
160
+ ): ReadTween | null {
161
+ const timelines = timelinesOf(iframe);
162
+ if (!timelines) return null;
41
163
  const tlId = compositionId || Object.keys(timelines)[0];
42
164
  if (!tlId) return null;
43
165
  const timeline = timelines[tlId];
44
166
  if (!timeline?.getChildren) return null;
45
167
 
46
- let doc: Document | null = null;
168
+ let targetEl: Element | null = null;
47
169
  try {
48
- doc = iframe.contentDocument;
170
+ targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
49
171
  } catch {
50
172
  return null;
51
173
  }
52
- if (!doc) return null;
53
-
54
- const targetEl = doc.querySelector(selector);
55
174
  if (!targetEl) return null;
56
175
 
57
176
  for (const tween of timeline.getChildren(true)) {
58
- if (!tween.targets || !tween.vars) continue;
59
- let matches = false;
60
- for (const t of tween.targets()) {
61
- if (t === targetEl || (targetEl.id && t.id === targetEl.id)) {
62
- matches = true;
63
- break;
64
- }
65
- }
66
- if (!matches) continue;
67
-
68
- const vars = tween.vars;
69
- if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
70
-
71
- const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
72
- if (parsed) return parsed;
177
+ if (!tween.vars || !matchesElement(tween, targetEl)) continue;
178
+ const read = readTween(tween.vars);
179
+ if (read) return read;
73
180
  }
74
181
  return null;
75
182
  }
76
183
 
77
- // fallow-ignore-next-line complexity
78
- export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
79
- string,
80
- {
81
- keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
82
- easeEach?: string;
83
- }
84
- > {
85
- const result = new Map<
86
- string,
87
- {
88
- keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
89
- easeEach?: string;
90
- }
91
- >();
92
- if (!iframe?.contentWindow) return result;
184
+ /** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
185
+ function toClipRelative(
186
+ keyframes: Pct[],
187
+ tweenStart: number,
188
+ tweenDuration: number,
189
+ clip: { start: number; duration: number } | undefined,
190
+ ): Pct[] {
191
+ if (!clip || clip.duration <= 0) return keyframes;
192
+ return keyframes.map((kf) => {
193
+ const abs = toAbsoluteTime(tweenStart, tweenDuration, kf.percentage);
194
+ return { ...kf, percentage: Math.round(((abs - clip.start) / clip.duration) * 100000) / 1000 };
195
+ });
196
+ }
93
197
 
94
- let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
95
- try {
96
- timelines = (
97
- iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
98
- ).__timelines;
99
- } catch {
100
- return result;
198
+ function buildEntry(
199
+ read: ReadTween,
200
+ start: number,
201
+ duration: number,
202
+ clip: { start: number; duration: number } | undefined,
203
+ ): RuntimeKeyframeEntry {
204
+ return {
205
+ keyframes: toClipRelative(read.keyframes, start, duration, clip),
206
+ tweenStart: start,
207
+ tweenDuration: duration,
208
+ ...(read.easeEach ? { easeEach: read.easeEach } : {}),
209
+ ...(read.arcPath ? { arcPath: read.arcPath } : {}),
210
+ };
211
+ }
212
+
213
+ /** Record one tween's keyframes under each target id (first-tween-per-id wins). */
214
+ function addScanEntry(
215
+ result: Map<string, RuntimeKeyframeEntry>,
216
+ tween: RuntimeTween,
217
+ clipById?: ClipDims,
218
+ ): void {
219
+ if (!tween.targets || !tween.vars) return;
220
+ const read = readTween(tween.vars);
221
+ if (!read) return;
222
+ const { start, duration } = tweenTiming(tween);
223
+ for (const target of tween.targets()) {
224
+ const id = (target as HTMLElement).id;
225
+ if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id)));
101
226
  }
102
- if (!timelines) return result;
227
+ }
103
228
 
229
+ /**
230
+ * Scan every live tween, grouping keyframes by element id. Percentages are
231
+ * tween-relative unless `clipById` is supplied, in which case each entry's
232
+ * keyframes are converted to clip-relative. First keyframe-bearing tween per
233
+ * element wins (the common single-primary-tween case).
234
+ */
235
+ export function scanAllRuntimeKeyframes(
236
+ iframe: HTMLIFrameElement | null,
237
+ clipById?: ClipDims,
238
+ ): Map<string, RuntimeKeyframeEntry> {
239
+ const result = new Map<string, RuntimeKeyframeEntry>();
240
+ const timelines = timelinesOf(iframe);
241
+ if (!timelines) return result;
104
242
  for (const timeline of Object.values(timelines)) {
105
243
  if (!timeline?.getChildren) continue;
106
- const tlDuration = typeof timeline.duration === "function" ? timeline.duration() : 0;
107
-
108
- for (const tween of timeline.getChildren(true)) {
109
- if (!tween.targets || !tween.vars) continue;
110
- const vars = tween.vars;
111
-
112
- if (vars.keyframes && typeof vars.keyframes === "object") {
113
- const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
114
- if (parsed) {
115
- for (const target of tween.targets()) {
116
- const id = (target as HTMLElement).id;
117
- if (id && !result.has(id)) {
118
- result.set(id, parsed);
119
- }
120
- }
121
- continue;
122
- }
123
- }
124
-
125
- // Flat tweens: synthesize start + end keyframe entries
126
- if (!tlDuration || tlDuration <= 0) continue;
127
- const tweenStart = typeof tween.startTime === "function" ? tween.startTime() : undefined;
128
- if (typeof tweenStart !== "number" || !Number.isFinite(tweenStart)) continue;
129
- const tweenDur = typeof tween.duration === "function" ? tween.duration() : 0;
130
-
131
- const startPct = Math.round((tweenStart / tlDuration) * 1000) / 10;
132
- const endPct =
133
- tweenDur > 0 ? Math.round(((tweenStart + tweenDur) / tlDuration) * 1000) / 10 : startPct;
134
- const properties: Record<string, number | string> = {};
135
- const skip = new Set([
136
- "ease",
137
- "duration",
138
- "delay",
139
- "stagger",
140
- "motionPath",
141
- "overwrite",
142
- "immediateRender",
143
- "onComplete",
144
- "onUpdate",
145
- "onStart",
146
- ]);
147
- for (const [k, v] of Object.entries(vars)) {
148
- if (skip.has(k)) continue;
149
- if (typeof v === "number") properties[k] = roundTo3(v);
150
- else if (typeof v === "string") properties[k] = v;
151
- }
152
- if (Object.keys(properties).length === 0) continue;
153
-
154
- for (const target of tween.targets()) {
155
- const id = (target as HTMLElement).id;
156
- if (!id) continue;
157
- const existing = result.get(id);
158
- const entries = existing ?? { keyframes: [] };
159
- entries.keyframes.push({ percentage: startPct, properties });
160
- if (endPct !== startPct) {
161
- entries.keyframes.push({ percentage: endPct, properties });
162
- }
163
- if (!existing) result.set(id, entries);
164
- }
165
- }
166
- }
167
-
168
- for (const entry of result.values()) {
169
- entry.keyframes.sort((a, b) => a.percentage - b.percentage);
244
+ for (const tween of timeline.getChildren(true)) addScanEntry(result, tween, clipById);
170
245
  }
171
246
  return result;
172
247
  }
@@ -3,6 +3,7 @@ import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
5
  import type { ShadowGsapOp } from "../utils/sdkShadow";
6
+ import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
6
7
 
7
8
  export interface MutationResult {
8
9
  ok: boolean;
@@ -19,8 +20,18 @@ export interface CommitMutationOptions {
19
20
  softReload?: boolean;
20
21
  skipReload?: boolean;
21
22
  beforeReload?: () => void;
23
+ /**
24
+ * Serialize this commit against others sharing the same key. Used to chain
25
+ * per-animationId GSAP meta updates so overlapping read-modify-write POSTs to
26
+ * one file can't interleave — which would pair the shadow fidelity diff with a
27
+ * stale server result and report false ease mismatches. Commits without a key
28
+ * (and under distinct keys) run concurrently as before.
29
+ */
30
+ serializeKey?: string;
22
31
  /** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
23
32
  shadowGsapOp?: ShadowGsapOp;
33
+ /** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */
34
+ shadowKeyframeOp?: ShadowKeyframeOp;
24
35
  }
25
36
 
26
37
  export type CommitMutation = (
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createKeyedSerializer } from "./serializeByKey";
3
+
4
+ function deferred<T>() {
5
+ let resolve!: (value: T) => void;
6
+ let reject!: (reason?: unknown) => void;
7
+ const promise = new Promise<T>((res, rej) => {
8
+ resolve = res;
9
+ reject = rej;
10
+ });
11
+ return { promise, resolve, reject };
12
+ }
13
+
14
+ describe("createKeyedSerializer", () => {
15
+ it("runs same-key tasks strictly in order (second awaits the first)", async () => {
16
+ const run = createKeyedSerializer();
17
+ const order: string[] = [];
18
+ const first = deferred<void>();
19
+
20
+ const p1 = run("k", async () => {
21
+ order.push("1-start");
22
+ await first.promise;
23
+ order.push("1-end");
24
+ });
25
+ const p2 = run("k", async () => {
26
+ order.push("2-start");
27
+ });
28
+
29
+ // Second task must not start until the first finishes.
30
+ await Promise.resolve();
31
+ expect(order).toEqual(["1-start"]);
32
+
33
+ first.resolve();
34
+ await Promise.all([p1, p2]);
35
+ expect(order).toEqual(["1-start", "1-end", "2-start"]);
36
+ });
37
+
38
+ it("does not block tasks under different keys", async () => {
39
+ const run = createKeyedSerializer();
40
+ const order: string[] = [];
41
+ const blockA = deferred<void>();
42
+
43
+ const pa = run("a", async () => {
44
+ order.push("a-start");
45
+ await blockA.promise;
46
+ order.push("a-end");
47
+ });
48
+ const pb = run("b", async () => {
49
+ order.push("b-start");
50
+ order.push("b-end");
51
+ });
52
+
53
+ // Different key runs to completion while "a" is still blocked.
54
+ await pb;
55
+ expect(order).toEqual(["a-start", "b-start", "b-end"]);
56
+
57
+ blockA.resolve();
58
+ await pa;
59
+ expect(order).toEqual(["a-start", "b-start", "b-end", "a-end"]);
60
+ });
61
+
62
+ it("does not wedge a key when a prior task rejects", async () => {
63
+ const run = createKeyedSerializer();
64
+ const order: string[] = [];
65
+
66
+ const p1 = run("k", async () => {
67
+ order.push("1");
68
+ throw new Error("boom");
69
+ });
70
+ await expect(p1).rejects.toThrow("boom");
71
+
72
+ const p2 = run("k", async () => {
73
+ order.push("2");
74
+ });
75
+ await p2;
76
+ expect(order).toEqual(["1", "2"]);
77
+ });
78
+
79
+ it("propagates the task's resolved value to its caller", async () => {
80
+ const run = createKeyedSerializer();
81
+ await expect(run("k", async () => 42)).resolves.toBe(42);
82
+ });
83
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Per-key task serializer. Tasks sharing a key run strictly in order: a new
3
+ * task for a key awaits the prior task for that key before starting, so their
4
+ * effects (e.g. overlapping read-modify-write POSTs to one file) can't
5
+ * interleave. Tasks under different keys are independent and never block each
6
+ * other.
7
+ *
8
+ * Used to serialize GSAP meta-update commits per animationId so the shadow
9
+ * fidelity diff always pairs an op with the server result that includes it —
10
+ * without globally serializing unrelated commits.
11
+ */
12
+ export function createKeyedSerializer() {
13
+ const inFlight = new Map<string, Promise<unknown>>();
14
+
15
+ return function run<T>(key: string, task: () => Promise<T>): Promise<T> {
16
+ const prior = inFlight.get(key) ?? Promise.resolve();
17
+ // Chain onto the prior task regardless of how it settled; a rejected prior
18
+ // commit must not wedge the key forever.
19
+ const next = prior.then(task, task);
20
+ inFlight.set(key, next);
21
+ // Once this task settles, drop it from the map if nothing newer replaced it,
22
+ // so completed keys don't leak.
23
+ void next.then(
24
+ () => {
25
+ if (inFlight.get(key) === next) inFlight.delete(key);
26
+ },
27
+ () => {
28
+ if (inFlight.get(key) === next) inFlight.delete(key);
29
+ },
30
+ );
31
+ return next;
32
+ };
33
+ }
@@ -334,6 +334,7 @@ export function useDomEditSession({
334
334
  commitAnimatedProperty,
335
335
  handleSetArcPath,
336
336
  handleUpdateArcSegment,
337
+ handleUnroll,
337
338
  commitMutation,
338
339
  } = useGsapAwareEditing({
339
340
  domEditSelection,
@@ -420,6 +421,7 @@ export function useDomEditSession({
420
421
  commitAnimatedProperty,
421
422
  handleSetArcPath,
422
423
  handleUpdateArcSegment,
424
+ handleUnroll,
423
425
  invalidateGsapCache: bumpGsapCache,
424
426
  previewIframeRef,
425
427
  commitMutation,
@@ -42,7 +42,15 @@ export function useDomGeometryCommits({
42
42
  }: UseDomGeometryCommitsParams) {
43
43
  const handleDomPathOffsetCommit = useCallback(
44
44
  (selection: DomEditSelection, next: { x: number; y: number }) => {
45
- if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
45
+ const gsapBlocked = isElementGsapTargeted(previewIframeRef.current, selection.element);
46
+ console.log(
47
+ "[drag:7] handleDomPathOffsetCommit (CSS path)",
48
+ JSON.stringify({
49
+ sel: selection.id,
50
+ gsapBlocked,
51
+ }),
52
+ );
53
+ if (gsapBlocked) {
46
54
  const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
47
55
  showToast(error.message, "error");
48
56
  return Promise.reject(error);
@@ -40,10 +40,14 @@ export function useGsapAnimationOps({
40
40
  animationId,
41
41
  properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
42
42
  };
43
+ // coalesceKey groups rapid meta edits into one history entry. Request
44
+ // serialization is now handled per-file at the commitMutation chokepoint
45
+ // (useGsapScriptCommits), so no per-op serializeKey is needed here.
46
+ const metaKey = `gsap:${animationId}:meta`;
43
47
  commitMutationSafely(
44
48
  selection,
45
49
  { type: "update-meta", animationId, updates },
46
- { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp },
50
+ { label: "Edit GSAP animation", coalesceKey: metaKey, shadowGsapOp },
47
51
  );
48
52
  if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
49
53
  },
@@ -98,6 +98,17 @@ export function useGsapAwareEditing({
98
98
  const handleGsapAwarePathOffsetCommit = useCallback(
99
99
  async (selection: DomEditSelection, next: { x: number; y: number }) => {
100
100
  const hasGsapAnims = selectedGsapAnimations.length > 0;
101
+ console.log(
102
+ "[drag:3] handleGsapAwarePathOffsetCommit",
103
+ JSON.stringify({
104
+ sel: selection.id,
105
+ offset: next,
106
+ hasGsapAnims,
107
+ interceptEnabled: STUDIO_GSAP_DRAG_INTERCEPT_ENABLED,
108
+ animCount: selectedGsapAnimations.length,
109
+ animIds: selectedGsapAnimations.map((a) => a.id).slice(0, 5),
110
+ }),
111
+ );
101
112
  if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
102
113
  showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error");
103
114
  throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
@@ -230,6 +241,15 @@ export function useGsapAwareEditing({
230
241
  [domEditSelection, gsapCommitMutation],
231
242
  );
232
243
 
244
+ // Unroll all computed (helper/loop) tweens in the active timeline into literal
245
+ // tweens, so the clicked keyframe becomes directly editable. Visual no-op.
246
+ const handleUnroll = useCallback(() => {
247
+ void commitMutation(
248
+ { type: "unroll-timeline" },
249
+ { label: "Unroll to literal tweens", softReload: true },
250
+ );
251
+ }, [commitMutation]);
252
+
233
253
  return {
234
254
  handleGsapAwarePathOffsetCommit,
235
255
  handleGsapAwareBoxSizeCommit,
@@ -237,6 +257,7 @@ export function useGsapAwareEditing({
237
257
  commitAnimatedProperty,
238
258
  handleSetArcPath,
239
259
  handleUpdateArcSegment,
260
+ handleUnroll,
240
261
  commitMutation,
241
262
  };
242
263
  }
@@ -1,5 +1,6 @@
1
1
  import { useCallback } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
3
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
5
  import { executeOptimistic } from "../utils/optimisticUpdate";
5
6
  import type { KeyframeCacheEntry } from "../player/store/playerStore";
@@ -58,6 +59,13 @@ export function useGsapKeyframeOps({
58
59
  percentage,
59
60
  properties: { [property]: value },
60
61
  };
62
+ // Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint.
63
+ const shadowKeyframeOp: ShadowKeyframeOp = {
64
+ kind: "add",
65
+ animationId,
66
+ percentage,
67
+ properties: { [property]: value },
68
+ };
61
69
  void executeOptimisticKeyframeCacheUpdate({
62
70
  sourceFile,
63
71
  elementId: selection.id,
@@ -71,6 +79,7 @@ export function useGsapKeyframeOps({
71
79
  commitMutation(selection, mutation, {
72
80
  label: `Add keyframe at ${percentage}%`,
73
81
  softReload: true,
82
+ shadowKeyframeOp,
74
83
  }),
75
84
  }).catch((error) => {
76
85
  trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
@@ -86,10 +95,16 @@ export function useGsapKeyframeOps({
86
95
  percentage: number,
87
96
  properties: Record<string, number | string>,
88
97
  ) => {
98
+ const shadowKeyframeOp: ShadowKeyframeOp = {
99
+ kind: "add",
100
+ animationId,
101
+ percentage,
102
+ properties,
103
+ };
89
104
  return commitMutation(
90
105
  selection,
91
106
  { type: "add-keyframe", animationId, percentage, properties },
92
- { label: `Add keyframe at ${percentage}%`, softReload: true },
107
+ { label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp },
93
108
  );
94
109
  },
95
110
  [commitMutation],
@@ -99,6 +114,10 @@ export function useGsapKeyframeOps({
99
114
  (selection: DomEditSelection, animationId: string, percentage: number) => {
100
115
  const sourceFile = selection.sourceFile || activeCompPath || "index.html";
101
116
  const mutation = { type: "remove-keyframe", animationId, percentage };
117
+ // Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main,
118
+ // so the runner resolves percentage → keyframeIndex against the pre-op
119
+ // script and no-ops on ambiguity (duplicate-percentage keyframes).
120
+ const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage };
102
121
  void executeOptimisticKeyframeCacheUpdate({
103
122
  sourceFile,
104
123
  elementId: selection.id,
@@ -112,6 +131,7 @@ export function useGsapKeyframeOps({
112
131
  commitMutation(selection, mutation, {
113
132
  label: `Remove keyframe at ${percentage}%`,
114
133
  softReload: true,
134
+ shadowKeyframeOp,
115
135
  }),
116
136
  }).catch((error) => {
117
137
  trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);