@hyperframes/studio 0.6.121 → 0.7.0

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 (98) hide show
  1. package/dist/assets/index-CbNg8Ghm.js +374 -0
  2. package/dist/assets/{index-DSLrl2tB.js → index-CccCJ2B6.js} +24 -24
  3. package/dist/assets/{index-CLsc2FW3.js → index-DsxCdV8b.js} +1 -1
  4. package/dist/assets/index-DzWIinxk.css +1 -0
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3757 -2232
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -39
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditSession.ts +0 -4
  61. package/src/hooks/useDomSelection.ts +12 -5
  62. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  63. package/src/hooks/useEnableKeyframes.ts +298 -45
  64. package/src/hooks/useGestureCommit.ts +121 -53
  65. package/src/hooks/useGestureRecording.ts +61 -42
  66. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  67. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  68. package/src/hooks/useGsapAnimationOps.ts +1 -1
  69. package/src/hooks/useGsapAwareEditing.ts +38 -40
  70. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  71. package/src/hooks/useGsapScriptCommits.ts +102 -6
  72. package/src/hooks/useGsapTweenCache.ts +7 -1
  73. package/src/hooks/useRazorSplit.ts +21 -2
  74. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  75. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  76. package/src/hooks/useServerConnection.ts +4 -1
  77. package/src/hooks/useStudioContextValue.ts +3 -2
  78. package/src/hooks/useStudioUrlState.ts +97 -37
  79. package/src/player/components/ShortcutsPanel.tsx +8 -0
  80. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  81. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  82. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  83. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  84. package/src/player/lib/mediaProbe.ts +7 -1
  85. package/src/player/store/playerStore.ts +38 -1
  86. package/src/telemetry/config.ts +3 -2
  87. package/src/utils/editDebugLog.ts +16 -0
  88. package/src/utils/generateId.ts +7 -0
  89. package/src/utils/gsapSoftReload.test.ts +217 -13
  90. package/src/utils/gsapSoftReload.ts +228 -50
  91. package/src/utils/studioHelpers.test.ts +31 -1
  92. package/src/utils/studioHelpers.ts +24 -0
  93. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  94. package/src/utils/studioPreviewHelpers.ts +30 -6
  95. package/src/utils/studioTelemetry.ts +3 -1
  96. package/dist/assets/index-20PAVS4k.js +0 -269
  97. package/dist/assets/index-BvEhf7SS.css +0 -1
  98. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -0,0 +1,462 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { patchRuntimeTweenInPlace } from "./gsapRuntimePatch";
3
+
4
+ /**
5
+ * The helper patches ONE tween's values in `window.__timelines[compKey]` in place,
6
+ * `invalidate()`s it, and re-seeks via `__player.seek(currentTime)` so a value-only
7
+ * edit is reflected without re-running the composition. It must return `false`
8
+ * (caller falls back to a soft reload) whenever it can't confidently apply.
9
+ *
10
+ * The fixtures below mimic the runtime timeline shape the reader scans:
11
+ * a timeline with `getChildren(deep)`, child tweens with `vars`/`targets`/
12
+ * `duration`/`startTime`/`invalidate`, and an `__player` with `getTime`/`seek`.
13
+ */
14
+
15
+ type TweenSpec = {
16
+ vars: Record<string, unknown>;
17
+ targetIds: string[];
18
+ duration: number;
19
+ startTime?: number;
20
+ };
21
+
22
+ function makeTween(spec: TweenSpec, el: { id: string }) {
23
+ const invalidate = vi.fn();
24
+ return {
25
+ vars: spec.vars,
26
+ targets: () => spec.targetIds.map((id) => (id === el.id ? el : { id })),
27
+ duration: () => spec.duration,
28
+ startTime: () => spec.startTime ?? 0,
29
+ invalidate,
30
+ };
31
+ }
32
+
33
+ // A preview iframe whose runtime timeline holds the given tweens under `compKey`,
34
+ // resolves `#<el.id>`, and exposes a `__player` clock that re-renders the timeline
35
+ // when `seek` is called (so we can assert the post-seek interpolated value).
36
+ function fakeIframe(
37
+ el: { id: string },
38
+ tweens: ReturnType<typeof makeTween>[],
39
+ opts: {
40
+ compKey?: string;
41
+ extraTimelines?: Record<string, unknown>;
42
+ now?: number;
43
+ onSeek?: (t: number) => void;
44
+ } = {},
45
+ ): {
46
+ iframe: HTMLIFrameElement;
47
+ seek: ReturnType<typeof vi.fn>;
48
+ timeline: { getChildren: () => unknown[] };
49
+ } {
50
+ const compKey = opts.compKey ?? "index.html";
51
+ const now = opts.now ?? 0;
52
+ const timeline = {
53
+ getChildren: () => tweens,
54
+ duration: () => 14.6,
55
+ time: () => now,
56
+ };
57
+ const seek = vi.fn((t: number) => opts.onSeek?.(t));
58
+ const iframe = {
59
+ contentWindow: {
60
+ __timelines: { [compKey]: timeline, ...(opts.extraTimelines ?? {}) },
61
+ __player: { getTime: () => now, seek },
62
+ },
63
+ contentDocument: { querySelector: (sel: string) => (sel === `#${el.id}` ? el : null) },
64
+ } as unknown as HTMLIFrameElement;
65
+ return { iframe, seek, timeline };
66
+ }
67
+
68
+ describe("patchRuntimeTweenInPlace — set tweens", () => {
69
+ it("patches a tl.set x/y; a simulated re-seek reflects the NEW x/y (not the old)", () => {
70
+ const el = { id: "box" };
71
+ // Model the runtime applying the set's vars to the element on seek.
72
+ const rendered: { x: number; y: number } = { x: 0, y: 0 };
73
+ const setTween = makeTween(
74
+ { vars: { x: 0, y: 0 }, targetIds: ["box"], duration: 0, startTime: 0 },
75
+ el,
76
+ );
77
+ const { iframe, seek } = fakeIframe(el, [setTween], {
78
+ onSeek: () => {
79
+ rendered.x = setTween.vars.x as number;
80
+ rendered.y = setTween.vars.y as number;
81
+ },
82
+ });
83
+
84
+ const ok = patchRuntimeTweenInPlace(iframe, "#box", {
85
+ kind: "set",
86
+ props: { x: 120, y: -40 },
87
+ });
88
+
89
+ expect(ok).toBe(true);
90
+ expect(setTween.vars.x).toBe(120);
91
+ expect(setTween.vars.y).toBe(-40);
92
+ expect(setTween.invalidate).toHaveBeenCalled();
93
+ expect(seek).toHaveBeenCalledTimes(1);
94
+ expect(rendered).toEqual({ x: 120, y: -40 });
95
+ });
96
+
97
+ it("patches only the rotation channel, leaving x/y untouched", () => {
98
+ const el = { id: "knob" };
99
+ const setTween = makeTween(
100
+ { vars: { x: 10, y: 20, rotation: 0 }, targetIds: ["knob"], duration: 0 },
101
+ el,
102
+ );
103
+ const { iframe } = fakeIframe(el, [setTween]);
104
+
105
+ const ok = patchRuntimeTweenInPlace(iframe, "#knob", {
106
+ kind: "set",
107
+ props: { rotation: 45 },
108
+ });
109
+
110
+ expect(ok).toBe(true);
111
+ expect(setTween.vars.rotation).toBe(45);
112
+ expect(setTween.vars.x).toBe(10);
113
+ expect(setTween.vars.y).toBe(20);
114
+ });
115
+
116
+ it("patches only the scale channels", () => {
117
+ const el = { id: "card" };
118
+ const setTween = makeTween(
119
+ { vars: { scaleX: 1, scaleY: 1, opacity: 1 }, targetIds: ["card"], duration: 0 },
120
+ el,
121
+ );
122
+ const { iframe } = fakeIframe(el, [setTween]);
123
+
124
+ const ok = patchRuntimeTweenInPlace(iframe, "#card", {
125
+ kind: "set",
126
+ props: { scaleX: 2, scaleY: 1.5 },
127
+ });
128
+
129
+ expect(ok).toBe(true);
130
+ expect(setTween.vars.scaleX).toBe(2);
131
+ expect(setTween.vars.scaleY).toBe(1.5);
132
+ expect(setTween.vars.opacity).toBe(1);
133
+ });
134
+ });
135
+
136
+ describe("patchRuntimeTweenInPlace — channel-aware set resolution", () => {
137
+ it("patches the {x,y} set, not a co-located rotation-only set", () => {
138
+ const el = { id: "dual" };
139
+ const posSet = makeTween({ vars: { x: 0, y: 0 }, targetIds: ["dual"], duration: 0 }, el);
140
+ const rotSet = makeTween({ vars: { rotation: 0 }, targetIds: ["dual"], duration: 0 }, el);
141
+ // rotation set listed FIRST — channel-blind resolution would grab it.
142
+ const { iframe } = fakeIframe(el, [rotSet, posSet]);
143
+
144
+ const ok = patchRuntimeTweenInPlace(iframe, "#dual", {
145
+ kind: "set",
146
+ props: { x: 33, y: 44 },
147
+ });
148
+
149
+ expect(ok).toBe(true);
150
+ expect(posSet.vars).toMatchObject({ x: 33, y: 44 });
151
+ // The rotation set must be untouched (no x/y written into it).
152
+ expect(rotSet.vars).toEqual({ rotation: 0 });
153
+ expect(rotSet.invalidate).not.toHaveBeenCalled();
154
+ expect(posSet.invalidate).toHaveBeenCalled();
155
+ });
156
+
157
+ it("patches the rotation set, not a co-located {x,y} set", () => {
158
+ const el = { id: "dual2" };
159
+ const posSet = makeTween({ vars: { x: 5, y: 6 }, targetIds: ["dual2"], duration: 0 }, el);
160
+ const rotSet = makeTween({ vars: { rotation: 0 }, targetIds: ["dual2"], duration: 0 }, el);
161
+ // position set listed FIRST.
162
+ const { iframe } = fakeIframe(el, [posSet, rotSet]);
163
+
164
+ const ok = patchRuntimeTweenInPlace(iframe, "#dual2", {
165
+ kind: "set",
166
+ props: { rotation: 90 },
167
+ });
168
+
169
+ expect(ok).toBe(true);
170
+ expect(rotSet.vars).toMatchObject({ rotation: 90 });
171
+ expect(posSet.vars).toEqual({ x: 5, y: 6 });
172
+ expect(posSet.invalidate).not.toHaveBeenCalled();
173
+ expect(rotSet.invalidate).toHaveBeenCalled();
174
+ });
175
+
176
+ it("falls back to the only set when none carries the requested channel", () => {
177
+ // Back-compat: a single {x,y} set, patched with {x,y} that obviously matches,
178
+ // plus a set lacking the channel entirely still resolves to a match. Here the
179
+ // only set carries opacity; patching opacity must still land on it.
180
+ const el = { id: "solo" };
181
+ const set = makeTween({ vars: { opacity: 1 }, targetIds: ["solo"], duration: 0 }, el);
182
+ const { iframe } = fakeIframe(el, [set]);
183
+
184
+ const ok = patchRuntimeTweenInPlace(iframe, "#solo", {
185
+ kind: "set",
186
+ props: { opacity: 0.5 },
187
+ });
188
+
189
+ expect(ok).toBe(true);
190
+ expect(set.vars).toMatchObject({ opacity: 0.5 });
191
+ });
192
+ });
193
+
194
+ describe("patchRuntimeTweenInPlace — keyframe tweens", () => {
195
+ it("rebuilds the keyframes; a moved keyframe updates, others unchanged", () => {
196
+ const el = { id: "puck" };
197
+ const kfTween = makeTween(
198
+ {
199
+ vars: {
200
+ keyframes: [
201
+ { x: 0, y: 0 },
202
+ { x: 100, y: 50 },
203
+ { x: 200, y: 0 },
204
+ ],
205
+ duration: 3,
206
+ ease: "power1.inOut",
207
+ },
208
+ targetIds: ["puck"],
209
+ duration: 3,
210
+ startTime: 1,
211
+ },
212
+ el,
213
+ );
214
+ const { iframe, seek } = fakeIframe(el, [kfTween], { now: 2 });
215
+
216
+ const ok = patchRuntimeTweenInPlace(iframe, "#puck", {
217
+ kind: "keyframes",
218
+ keyframes: [
219
+ { x: 0, y: 0 },
220
+ { x: 140, y: 90 },
221
+ { x: 200, y: 0 },
222
+ ],
223
+ });
224
+
225
+ expect(ok).toBe(true);
226
+ const kfs = kfTween.vars.keyframes as Array<Record<string, number>>;
227
+ expect(kfs[1]).toEqual({ x: 140, y: 90 });
228
+ expect(kfs[0]).toEqual({ x: 0, y: 0 });
229
+ expect(kfs[2]).toEqual({ x: 200, y: 0 });
230
+ expect(kfTween.invalidate).toHaveBeenCalled();
231
+ expect(seek).toHaveBeenCalledTimes(1);
232
+ });
233
+
234
+ it("preserves the existing ease when rebuilding keyframes", () => {
235
+ const el = { id: "puck2" };
236
+ const kfTween = makeTween(
237
+ {
238
+ vars: {
239
+ keyframes: [
240
+ { x: 0, y: 0 },
241
+ { x: 100, y: 0 },
242
+ ],
243
+ duration: 2,
244
+ ease: "back.out",
245
+ },
246
+ targetIds: ["puck2"],
247
+ duration: 2,
248
+ startTime: 0,
249
+ },
250
+ el,
251
+ );
252
+ const { iframe } = fakeIframe(el, [kfTween], { now: 1 });
253
+
254
+ const ok = patchRuntimeTweenInPlace(iframe, "#puck2", {
255
+ kind: "keyframes",
256
+ keyframes: [
257
+ { x: 0, y: 0 },
258
+ { x: 250, y: 10 },
259
+ ],
260
+ });
261
+
262
+ expect(ok).toBe(true);
263
+ expect(kfTween.vars.ease).toBe("back.out");
264
+ });
265
+ });
266
+
267
+ describe("patchRuntimeTweenInPlace — defensive false returns", () => {
268
+ it("returns false when the selector has no matching tween", () => {
269
+ const el = { id: "lonely" };
270
+ const otherTween = makeTween(
271
+ { vars: { x: 0 }, targetIds: ["someone-else"], duration: 0 },
272
+ { id: "someone-else" },
273
+ );
274
+ const { iframe, seek } = fakeIframe(el, [otherTween]);
275
+
276
+ const ok = patchRuntimeTweenInPlace(iframe, "#lonely", { kind: "set", props: { x: 50 } });
277
+
278
+ expect(ok).toBe(false);
279
+ expect(seek).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it("returns false when the selector resolves to no element", () => {
283
+ const el = { id: "present" };
284
+ const setTween = makeTween({ vars: { x: 0 }, targetIds: ["present"], duration: 0 }, el);
285
+ const { iframe } = fakeIframe(el, [setTween]);
286
+
287
+ const ok = patchRuntimeTweenInPlace(iframe, "#missing", { kind: "set", props: { x: 50 } });
288
+ expect(ok).toBe(false);
289
+ });
290
+
291
+ it("returns false for a motionPath arc tween (defers to soft reload)", () => {
292
+ const el = { id: "flyer" };
293
+ const arcTween = makeTween(
294
+ {
295
+ vars: {
296
+ motionPath: {
297
+ path: [
298
+ { x: 0, y: 0 },
299
+ { x: 100, y: -50 },
300
+ { x: 200, y: 0 },
301
+ ],
302
+ curviness: 1.5,
303
+ },
304
+ duration: 4,
305
+ },
306
+ targetIds: ["flyer"],
307
+ duration: 4,
308
+ startTime: 0,
309
+ },
310
+ el,
311
+ );
312
+ const { iframe, seek } = fakeIframe(el, [arcTween], { now: 1 });
313
+
314
+ const ok = patchRuntimeTweenInPlace(iframe, "#flyer", {
315
+ kind: "keyframes",
316
+ keyframes: [
317
+ { x: 0, y: 0 },
318
+ { x: 120, y: -30 },
319
+ ],
320
+ });
321
+
322
+ expect(ok).toBe(false);
323
+ expect(arcTween.invalidate).not.toHaveBeenCalled();
324
+ expect(seek).not.toHaveBeenCalled();
325
+ });
326
+
327
+ it("returns false for a dynamic/computed keyframe value (string expression)", () => {
328
+ const el = { id: "dyn" };
329
+ const kfTween = makeTween(
330
+ {
331
+ vars: {
332
+ keyframes: [
333
+ { x: 0, y: 0 },
334
+ { x: 100, y: 0 },
335
+ ],
336
+ duration: 2,
337
+ },
338
+ targetIds: ["dyn"],
339
+ duration: 2,
340
+ startTime: 0,
341
+ },
342
+ el,
343
+ );
344
+ const { iframe } = fakeIframe(el, [kfTween], { now: 1 });
345
+
346
+ // A non-finite/string value in the requested change can't be safely expressed
347
+ // as a static keyframe → defer to soft reload.
348
+ const ok = patchRuntimeTweenInPlace(iframe, "#dyn", {
349
+ kind: "keyframes",
350
+ keyframes: [
351
+ { x: 0, y: 0 },
352
+ // @ts-expect-error — intentionally dynamic/computed value
353
+ { x: "+=random(50,100)", y: 0 },
354
+ ],
355
+ });
356
+
357
+ expect(ok).toBe(false);
358
+ });
359
+
360
+ it("returns false for a keyframes change against a set-only tween (shape mismatch)", () => {
361
+ const el = { id: "static" };
362
+ const setTween = makeTween({ vars: { x: 0, y: 0 }, targetIds: ["static"], duration: 0 }, el);
363
+ const { iframe } = fakeIframe(el, [setTween]);
364
+
365
+ const ok = patchRuntimeTweenInPlace(iframe, "#static", {
366
+ kind: "keyframes",
367
+ keyframes: [
368
+ { x: 0, y: 0 },
369
+ { x: 50, y: 0 },
370
+ ],
371
+ });
372
+ expect(ok).toBe(false);
373
+ });
374
+
375
+ it("returns false rather than overwriting a dynamic string set value", () => {
376
+ // The existing set value is a computed GSAP expression ("+=100"). Patching it
377
+ // with a plain number would silently drop the dynamic intent → defer.
378
+ const el = { id: "expr" };
379
+ const setTween = makeTween(
380
+ { vars: { x: "+=100", y: 0 }, targetIds: ["expr"], duration: 0 },
381
+ el,
382
+ );
383
+ const { iframe, seek } = fakeIframe(el, [setTween]);
384
+
385
+ const ok = patchRuntimeTweenInPlace(iframe, "#expr", {
386
+ kind: "set",
387
+ props: { x: 50, y: 10 },
388
+ });
389
+
390
+ expect(ok).toBe(false);
391
+ // Declined → the dynamic expression survives, untouched.
392
+ expect(setTween.vars.x).toBe("+=100");
393
+ expect(setTween.invalidate).not.toHaveBeenCalled();
394
+ expect(seek).not.toHaveBeenCalled();
395
+ });
396
+
397
+ it("never throws — returns false on internal error", () => {
398
+ const el = { id: "boom" };
399
+ const explodingTween = {
400
+ get vars() {
401
+ throw new Error("boom");
402
+ },
403
+ targets: () => [el],
404
+ duration: () => 0,
405
+ startTime: () => 0,
406
+ invalidate: vi.fn(),
407
+ };
408
+ const timeline = {
409
+ getChildren: () => {
410
+ throw new Error("kaboom");
411
+ },
412
+ duration: () => 1,
413
+ time: () => 0,
414
+ };
415
+ const iframe = {
416
+ contentWindow: {
417
+ __timelines: { "index.html": timeline },
418
+ __player: { getTime: () => 0, seek: vi.fn() },
419
+ },
420
+ contentDocument: { querySelector: (sel: string) => (sel === "#boom" ? el : null) },
421
+ } as unknown as HTMLIFrameElement;
422
+ void explodingTween;
423
+
424
+ expect(() =>
425
+ patchRuntimeTweenInPlace(iframe, "#boom", { kind: "set", props: { x: 1 } }),
426
+ ).not.toThrow();
427
+ expect(patchRuntimeTweenInPlace(iframe, "#boom", { kind: "set", props: { x: 1 } })).toBe(false);
428
+ });
429
+ });
430
+
431
+ describe("patchRuntimeTweenInPlace — composition isolation", () => {
432
+ it("patches only the tween in the element's owning timeline, not others", () => {
433
+ const el = { id: "owned" };
434
+ const ownTween = makeTween({ vars: { x: 0, y: 0 }, targetIds: ["owned"], duration: 0 }, el);
435
+ // Another composition's timeline holds a tween for a DIFFERENT element with the
436
+ // same channel — it must be left untouched.
437
+ const otherTween = makeTween(
438
+ { vars: { x: 999, y: 999 }, targetIds: ["someone-else"], duration: 0 },
439
+ { id: "someone-else" },
440
+ );
441
+ const otherTimeline = {
442
+ getChildren: () => [otherTween],
443
+ duration: () => 5,
444
+ time: () => 0,
445
+ };
446
+
447
+ const { iframe } = fakeIframe(el, [ownTween], {
448
+ compKey: "subscene",
449
+ extraTimelines: { playground: otherTimeline, __proxied: true },
450
+ });
451
+
452
+ const ok = patchRuntimeTweenInPlace(iframe, "#owned", {
453
+ kind: "set",
454
+ props: { x: 7, y: 8 },
455
+ });
456
+
457
+ expect(ok).toBe(true);
458
+ expect(ownTween.vars).toMatchObject({ x: 7, y: 8 });
459
+ expect(otherTween.vars).toMatchObject({ x: 999, y: 999 });
460
+ expect(otherTween.invalidate).not.toHaveBeenCalled();
461
+ });
462
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Patch ONE tween's values in the preview iframe's runtime timeline in place, so a
3
+ * value-only manual edit (a `tl.set` position/rotation/scale, or a keyframe
4
+ * value/position change) is reflected INSTANTLY — without re-running the whole
5
+ * composition.
6
+ *
7
+ * Defensive by default: the caller (`runCommit`) falls back to the existing soft
8
+ * reload whenever this returns `false`. It returns `false` (never silently
9
+ * mis-patches) when the tween can't be confidently located or the requested change
10
+ * can't be safely expressed (dynamic/computed values, motionPath arcs, shape
11
+ * mismatch), and never throws.
12
+ *
13
+ * "Which tween" is resolved by the same all-timelines scan `readRuntimeKeyframes`
14
+ * uses (`resolveRuntimeTween`), so read and write agree on the target.
15
+ */
16
+ import { resolveRuntimeTween, type RuntimeTween } from "./gsapRuntimeKeyframes";
17
+
18
+ /** Value-only channels a `tl.set(...)` patch may touch. */
19
+ export interface SetPatchProps {
20
+ x?: number;
21
+ y?: number;
22
+ rotation?: number;
23
+ scaleX?: number;
24
+ scaleY?: number;
25
+ scale?: number;
26
+ opacity?: number;
27
+ }
28
+
29
+ /** A single keyframe step's numeric channels (the GSAP array-keyframe form). */
30
+ export type KeyframeStep = Record<string, number>;
31
+
32
+ export type RuntimeTweenChange =
33
+ | { kind: "set"; props: SetPatchProps }
34
+ | { kind: "keyframes"; keyframes: KeyframeStep[] };
35
+
36
+ const SET_CHANNELS: Array<keyof SetPatchProps> = [
37
+ "x",
38
+ "y",
39
+ "rotation",
40
+ "scaleX",
41
+ "scaleY",
42
+ "scale",
43
+ "opacity",
44
+ ];
45
+
46
+ type IframeWindow = Window & {
47
+ __player?: { getTime?: () => number; seek?: (t: number) => void };
48
+ };
49
+
50
+ function playerOf(iframe: HTMLIFrameElement): IframeWindow["__player"] | null {
51
+ try {
52
+ return (iframe.contentWindow as IframeWindow | null)?.__player ?? null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** Every step value must be a finite number — string/computed values can't round-trip. */
59
+ function keyframesAreStatic(keyframes: KeyframeStep[]): boolean {
60
+ if (keyframes.length === 0) return false;
61
+ for (const step of keyframes) {
62
+ if (!step || typeof step !== "object") return false;
63
+ for (const v of Object.values(step)) {
64
+ if (typeof v !== "number" || !Number.isFinite(v)) return false;
65
+ }
66
+ }
67
+ return true;
68
+ }
69
+
70
+ function patchSet(tween: RuntimeTween, props: SetPatchProps): boolean {
71
+ const vars = tween.vars;
72
+ if (!vars) return false;
73
+ // A `set` carrying a motionPath isn't a plain value set — defer.
74
+ if ("motionPath" in vars) return false;
75
+ let touched = false;
76
+ for (const ch of SET_CHANNELS) {
77
+ const next = props[ch];
78
+ if (next === undefined) continue;
79
+ if (typeof next !== "number" || !Number.isFinite(next)) return false;
80
+ // A string value here is a dynamic/computed GSAP expression (e.g. "+=100",
81
+ // "random(...)"). Overwriting it with a plain number would silently drop the
82
+ // dynamic intent — decline so the caller soft-reloads from the (edited) source.
83
+ if (typeof vars[ch] === "string") return false;
84
+ vars[ch] = next;
85
+ touched = true;
86
+ }
87
+ return touched;
88
+ }
89
+
90
+ function patchKeyframes(tween: RuntimeTween, keyframes: KeyframeStep[]): boolean {
91
+ const vars = tween.vars;
92
+ if (!vars) return false;
93
+ // motionPath arc tweens express motion as a path, not channel keyframes — defer.
94
+ if ("motionPath" in vars) return false;
95
+ // Only the array-keyframe form is patched in place; the existing tween must
96
+ // already carry array keyframes (shape match), or this is a structural change.
97
+ if (!Array.isArray(vars.keyframes)) return false;
98
+ if (!keyframesAreStatic(keyframes)) return false;
99
+ vars.keyframes = keyframes.map((step) => ({ ...step }));
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * Update one tween's values in `window.__timelines` in place + re-seek to the
105
+ * current playhead. Returns `true` on a confident patch, `false` otherwise
106
+ * (caller falls back to a soft reload).
107
+ */
108
+ export function patchRuntimeTweenInPlace(
109
+ iframe: HTMLIFrameElement | null,
110
+ selector: string,
111
+ change: RuntimeTweenChange,
112
+ compositionId?: string,
113
+ ): boolean {
114
+ if (!iframe) return false;
115
+ try {
116
+ // For a `set` patch, hand the resolver the channels actually being written so
117
+ // it picks the set whose vars carry them — an element can have separate
118
+ // {x,y} and {rotation} sets, and a position patch must not corrupt the
119
+ // rotation set (channel-blind resolution would return the first match).
120
+ const channels =
121
+ change.kind === "set"
122
+ ? Object.keys(change.props).filter(
123
+ (k) => change.props[k as keyof SetPatchProps] !== undefined,
124
+ )
125
+ : undefined;
126
+ const resolved = resolveRuntimeTween(
127
+ iframe,
128
+ selector,
129
+ change.kind === "set" ? "set" : "keyframe",
130
+ compositionId,
131
+ channels,
132
+ );
133
+ if (!resolved) return false;
134
+ const { tween, timeline } = resolved;
135
+
136
+ const applied =
137
+ change.kind === "set"
138
+ ? patchSet(tween, change.props)
139
+ : patchKeyframes(tween, change.keyframes);
140
+ if (!applied) return false;
141
+
142
+ // Recompute the tween (and its timeline) from the new vars, then re-render at
143
+ // the current playhead. `invalidate()` makes GSAP re-read vars on the next render.
144
+ tween.invalidate?.();
145
+ timeline.invalidate?.();
146
+
147
+ const player = playerOf(iframe);
148
+ const currentTime =
149
+ typeof player?.getTime === "function"
150
+ ? player.getTime()
151
+ : typeof timeline.time === "function"
152
+ ? timeline.time()
153
+ : 0;
154
+ player?.seek?.(Number.isFinite(currentTime) ? currentTime : 0);
155
+ return true;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
+ import type { RuntimeTweenChange } from "./gsapRuntimePatch";
5
6
 
6
7
  export interface MutationResult {
7
8
  ok: boolean;
@@ -26,6 +27,15 @@ export interface CommitMutationOptions {
26
27
  * (and under distinct keys) run concurrently as before.
27
28
  */
28
29
  serializeKey?: string;
30
+ /**
31
+ * Value-only edit fast path. When present, `runCommit` first tries to patch the
32
+ * one changed tween in the preview's runtime timeline in place
33
+ * (`patchRuntimeTweenInPlace`) — instant, no composition re-run, no iframe
34
+ * remount. On a successful patch the reload is skipped entirely (panels still
35
+ * refresh); when the patch can't be confidently applied it falls back to the
36
+ * existing soft/full reload path. Structural edits omit this and reload as before.
37
+ */
38
+ instantPatch?: { selector: string; change: RuntimeTweenChange };
29
39
  }
30
40
 
31
41
  export type CommitMutation = (
@@ -38,7 +48,7 @@ export type SafeGsapCommitMutation = (
38
48
  selection: DomEditSelection,
39
49
  mutation: Record<string, unknown>,
40
50
  options: CommitMutationOptions,
41
- ) => void;
51
+ ) => Promise<void>;
42
52
 
43
53
  export type TrackGsapSaveFailure = (
44
54
  error: unknown,