@hyperframes/studio 0.6.122-alpha.0 → 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-i-MLqhmm.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-Baz2T-9U.js +0 -269
  97. package/dist/assets/index-BvEhf7SS.css +0 -1
  98. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -13,14 +13,22 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
13
  import { usePlayerStore } from "../player/store/playerStore";
14
14
 
15
15
  import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
16
+ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
16
17
  import {
17
- commitGsapPositionFromDrag,
18
+ commitStaticGsapPosition,
19
+ commitStaticGsapRotation,
20
+ commitStaticGsapSize,
21
+ commitWholePathOffset,
18
22
  computeCurrentPercentage,
23
+ findPositionSetAnimation,
24
+ findRotationSetAnimation,
25
+ findSizeSetAnimation,
19
26
  materializeIfDynamic,
20
27
  } from "./gsapDragCommit";
21
28
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
22
29
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
23
30
  import { getIframeGsap, queryIframeElement, selectorFromSelection } from "./gsapShared";
31
+ import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
24
32
  import { roundTo3 } from "../utils/rounding";
25
33
 
26
34
  // ── Runtime reads ──────────────────────────────────────────────────────────
@@ -196,17 +204,9 @@ export async function tryGsapDragIntercept(
196
204
  iframe: HTMLIFrameElement | null,
197
205
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
198
206
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
207
+ options?: { altKey?: boolean },
199
208
  ): Promise<boolean> {
200
209
  const selector = selectorFromSelection(selection);
201
- console.log(
202
- "[drag:4] tryGsapDragIntercept",
203
- JSON.stringify({
204
- sel: selection.id,
205
- selector,
206
- animCount: animations.length,
207
- groups: animations.map((a) => a.propertyGroup).filter(Boolean),
208
- }),
209
- );
210
210
  if (!selector) {
211
211
  return false;
212
212
  }
@@ -218,44 +218,62 @@ export async function tryGsapDragIntercept(
218
218
  commitMutation,
219
219
  fetchFallbackAnimations,
220
220
  );
221
- console.log(
222
- "[drag:4] resolveGroupTween('position') →",
223
- resolved
224
- ? JSON.stringify({ id: resolved.anim.id, group: resolved.anim.propertyGroup })
225
- : "null",
226
- );
227
221
 
228
222
  let posAnim = resolved?.anim ?? null;
223
+ let resolvedAnimations = resolved?.animations ?? animations;
229
224
  if (!posAnim) {
230
225
  posAnim = findGsapPositionAnimation(animations, selector);
231
226
  if (!posAnim && fetchFallbackAnimations) {
232
227
  const fresh = await fetchFallbackAnimations();
228
+ resolvedAnimations = fresh;
233
229
  posAnim = findGsapPositionAnimation(fresh, selector);
234
- console.log(
235
- "[drag:4] findGsapPositionAnimation (fetched) →",
236
- posAnim ? posAnim.id : "null",
237
- "freshCount:",
238
- fresh.length,
239
- );
240
230
  }
241
231
  }
242
- if (!posAnim) {
243
- return false;
232
+
233
+ const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
234
+
235
+ // STATIC case (single source of truth = GSAP timeline): the element has no LIVE
236
+ // keyframed/tweened position motion. Use the strict non-hold check — a leftover
237
+ // position-hold `set` (after a delete-all, or a stale parse that lags it) must
238
+ // NOT count as live motion. Either way the position belongs in a
239
+ // `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
240
+ // place (idempotent), else add a new one. This also covers the stale-cache
241
+ // phantom — committing a set is correct because the element genuinely has no live motion.
242
+ if (!hasNonHoldTweenForElement(iframe, selector)) {
243
+ const existingSet =
244
+ posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
245
+ ? posAnim
246
+ : findPositionSetAnimation(resolvedAnimations, selector);
247
+ await commitStaticGsapPosition(selection, offset, gsapPos, selector, existingSet, {
248
+ commitMutation,
249
+ fetchAnimations: fetchFallbackAnimations,
250
+ });
251
+ return true;
244
252
  }
245
253
 
246
- const gsapPos = readGsapPositionFromIframe(iframe, selector);
247
- if (!gsapPos) {
248
- return false;
254
+ if (!posAnim) return false;
255
+
256
+ // Verify the anim ID is still valid in the current file. The React-state
257
+ // `animations` list can lag behind the file after a prior mutation changed
258
+ // the tween's position/method (which changes the ID). Re-fetch to get the
259
+ // current ID and avoid a stale-ID remove that creates duplicate tweens.
260
+ if (fetchFallbackAnimations) {
261
+ const fresh = await fetchFallbackAnimations();
262
+ const freshMatch = fresh.find(
263
+ (a) =>
264
+ a.targetSelector === posAnim!.targetSelector && a.propertyGroup === posAnim!.propertyGroup,
265
+ );
266
+ if (freshMatch && freshMatch.id !== posAnim.id) {
267
+ posAnim = freshMatch;
268
+ }
249
269
  }
250
270
 
251
- console.log(
252
- "[drag:4] committing GSAP position drag",
253
- JSON.stringify({ posAnimId: posAnim.id, gsapPos }),
254
- );
255
- await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, {
256
- commitMutation,
257
- fetchAnimations: fetchFallbackAnimations,
258
- });
271
+ const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations };
272
+ if (options?.altKey) {
273
+ await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
274
+ } else {
275
+ await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
276
+ }
259
277
  return true;
260
278
  }
261
279
 
@@ -303,33 +321,14 @@ export async function tryGsapResizeIntercept(
303
321
  );
304
322
 
305
323
  let anim = resolved?.anim ?? null;
306
- if (!anim) {
307
- // No size-group tween exists — create one. Use the element's timing
308
- // from any existing animation, or fall back to element data attributes.
309
- const refAnim = animations[0];
310
- const elStart =
311
- refAnim?.resolvedStart ?? (Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0);
312
- const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "5") || 5;
313
- const ct = usePlayerStore.getState().currentTime;
314
- const pct = elDuration > 0 ? Math.round(((ct - elStart) / elDuration) * 1000) / 10 : 0;
324
+ if (!anim || anim.method === "set") {
315
325
  const sel = selectorFromSelection(selection);
316
326
  if (!sel) return false;
317
- await commitMutation(
318
- selection,
319
- {
320
- type: "add-with-keyframes",
321
- targetSelector: sel,
322
- position: roundTo3(elStart),
323
- duration: roundTo3(elDuration),
324
- keyframes: [
325
- {
326
- percentage: Math.max(0, Math.min(100, pct)),
327
- properties: { width: Math.round(size.width), height: Math.round(size.height) },
328
- },
329
- ],
330
- },
331
- { label: "Resize (new size keyframe)", softReload: true },
332
- );
327
+ const sizeSet = anim?.method === "set" ? anim : findSizeSetAnimation(animations, sel);
328
+ await commitStaticGsapSize(selection, size, sel, sizeSet, {
329
+ commitMutation,
330
+ fetchAnimations: fetchFallbackAnimations,
331
+ });
333
332
  return true;
334
333
  }
335
334
 
@@ -374,6 +373,13 @@ export async function tryGsapResizeIntercept(
374
373
  { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
375
374
  { label: "Convert to keyframes for resize", skipReload: true, coalesceKey },
376
375
  );
376
+ if (fetchFallbackAnimations) {
377
+ const fresh = await fetchFallbackAnimations();
378
+ const refreshed = fresh.find(
379
+ (a) => a.targetSelector === anim!.targetSelector && a.keyframes,
380
+ );
381
+ if (refreshed) anim = refreshed;
382
+ }
377
383
  }
378
384
  }
379
385
 
@@ -463,6 +469,9 @@ export async function tryGsapRotationIntercept(
463
469
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
464
470
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
465
471
  ): Promise<boolean> {
472
+ const selector = selectorFromSelection(selection);
473
+ if (!selector) return false;
474
+
466
475
  // Resolve the rotation-group tween, splitting legacy mixed tweens if needed.
467
476
  const resolved = await resolveGroupTween(
468
477
  "rotation",
@@ -471,6 +480,7 @@ export async function tryGsapRotationIntercept(
471
480
  commitMutation,
472
481
  fetchFallbackAnimations,
473
482
  );
483
+ const resolvedAnimations = resolved?.animations ?? animations;
474
484
 
475
485
  // Fallback: legacy heuristic for hand-written scripts
476
486
  let anim = resolved?.anim ?? null;
@@ -481,20 +491,27 @@ export async function tryGsapRotationIntercept(
481
491
  anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null;
482
492
  }
483
493
  }
484
- if (!anim) return false;
485
494
 
486
- const selector = selectorFromSelection(selection);
487
- if (!selector) return false;
495
+ // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
496
+ // pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
497
+ // gesture's live preview already gsap.set this value (single source of truth).
498
+ const newRotation = Math.round(angle);
488
499
 
489
- let gsapRotation = 0;
490
- const gsap = getIframeGsap(iframe);
491
- const rotEl = gsap ? queryIframeElement(iframe, selector) : null;
492
- if (gsap && rotEl) {
493
- gsapRotation = Number(gsap.getProperty(rotEl, "rotation")) || 0;
500
+ // STATIC case (single source of truth = GSAP timeline): no rotation tween, so the
501
+ // angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion —
502
+ // mirroring the static position set. Idempotent: re-rotate updates an existing
503
+ // rotation set in place, else add a new one. This replaces the old
504
+ // `--hf-studio-rotation` CSS-var fallback (the same dual-channel bug class).
505
+ if (!anim) {
506
+ const existingSet = findRotationSetAnimation(resolvedAnimations, selector);
507
+ await commitStaticGsapRotation(selection, newRotation, selector, existingSet, {
508
+ commitMutation,
509
+ fetchAnimations: fetchFallbackAnimations,
510
+ });
511
+ return true;
494
512
  }
495
513
 
496
514
  const pct = computeCurrentPercentage(selection, anim);
497
- const newRotation = Math.round(gsapRotation + angle);
498
515
 
499
516
  if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
500
517
  const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
@@ -1,5 +1,133 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { arcPathFromMotionPathValue } from "./gsapRuntimeKeyframes";
2
+ import {
3
+ arcPathFromMotionPathValue,
4
+ hasNonHoldTweenForElement,
5
+ readRuntimeKeyframes,
6
+ } from "./gsapRuntimeKeyframes";
7
+
8
+ // Build a fake preview iframe whose runtime timeline holds the given child tweens
9
+ // and resolves `selector` to `el`.
10
+ function fakeIframe(el: { id: string }, children: unknown[], now?: number): HTMLIFrameElement {
11
+ const timeline = {
12
+ getChildren: () => children,
13
+ duration: () => 14.6,
14
+ ...(now != null ? { time: () => now } : {}),
15
+ };
16
+ return {
17
+ contentWindow: { __timelines: { "index.html": timeline } },
18
+ contentDocument: { querySelector: (sel: string) => (sel === `#${el.id}` ? el : null) },
19
+ } as unknown as HTMLIFrameElement;
20
+ }
21
+
22
+ describe("readRuntimeKeyframes — zero-duration set must not shadow the keyframed tween", () => {
23
+ const el = { id: "puck-b" };
24
+ const holdSet = {
25
+ targets: () => [el],
26
+ // `data` is the STUDIO_HOLD_MARKER sentinel ("hf-hold") from core's gsapParser.
27
+ // TODO(core follow-up): re-export STUDIO_HOLD_MARKER via the @hyperframes/core/
28
+ // gsap-parser subpath so this fixture can import the const instead of the literal.
29
+ vars: { x: 0, y: 0, data: "hf-hold" },
30
+ duration: () => 0,
31
+ startTime: () => 0,
32
+ };
33
+ const kfTween = {
34
+ targets: () => [el],
35
+ vars: {
36
+ keyframes: [
37
+ { x: 0, y: 0 },
38
+ { x: -180, y: -60 },
39
+ { x: -320, y: 40 },
40
+ { x: -460, y: -20 },
41
+ ],
42
+ duration: 3.4,
43
+ ease: "power1.inOut",
44
+ },
45
+ duration: () => 3.4,
46
+ startTime: () => 1.0,
47
+ };
48
+
49
+ it("reads all 4 keyframes from the to() even when a hold-set precedes it", () => {
50
+ const read = readRuntimeKeyframes(fakeIframe(el, [holdSet, kfTween]), "#puck-b");
51
+ expect(read?.keyframes).toHaveLength(4);
52
+ });
53
+
54
+ it("returns null when the element only has a zero-duration set (no real motion)", () => {
55
+ expect(readRuntimeKeyframes(fakeIframe(el, [holdSet]), "#puck-b")).toBeNull();
56
+ });
57
+ });
58
+
59
+ describe("readRuntimeKeyframes — multiple tweens pick the one under the playhead", () => {
60
+ const el = { id: "puck-a" };
61
+ // Two non-overlapping gesture recordings → two separate keyframed tweens.
62
+ const gestureA = {
63
+ targets: () => [el],
64
+ vars: {
65
+ keyframes: [
66
+ { x: 0, y: 0 },
67
+ { x: -100, y: 50 },
68
+ ],
69
+ duration: 2.03,
70
+ },
71
+ duration: () => 2.03,
72
+ startTime: () => 1.033, // range [1.033, 3.063]
73
+ };
74
+ const gestureB = {
75
+ targets: () => [el],
76
+ vars: {
77
+ keyframes: [
78
+ { x: 10, y: 10 },
79
+ { x: 20, y: 20 },
80
+ { x: 30, y: 30 },
81
+ ],
82
+ duration: 1.129,
83
+ },
84
+ duration: () => 1.129,
85
+ startTime: () => 3.342, // range [3.342, 4.471]
86
+ };
87
+
88
+ it("playhead inside the SECOND tween reads the second tween (not the first)", () => {
89
+ const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 3.373), "#puck-a");
90
+ expect(read?.keyframes).toHaveLength(3); // gestureB
91
+ });
92
+
93
+ it("playhead inside the FIRST tween reads the first tween", () => {
94
+ const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 2.0), "#puck-a");
95
+ expect(read?.keyframes).toHaveLength(2); // gestureA
96
+ });
97
+
98
+ it("playhead outside every range falls back to the first keyframed tween", () => {
99
+ const read = readRuntimeKeyframes(fakeIframe(el, [gestureA, gestureB], 9.0), "#puck-a");
100
+ expect(read?.keyframes).toHaveLength(2); // gestureA (first)
101
+ });
102
+ });
103
+
104
+ describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-parse guard)", () => {
105
+ const el = { id: "puck-b" };
106
+ const holdSet = {
107
+ targets: () => [el],
108
+ vars: { x: 0, y: 0, data: "hf-hold" },
109
+ duration: () => 0,
110
+ startTime: () => 0,
111
+ };
112
+ const liveTween = {
113
+ targets: () => [el],
114
+ vars: { x: -120, y: 40, duration: 1 },
115
+ duration: () => 1,
116
+ startTime: () => 1,
117
+ };
118
+
119
+ it("true when a non-hold tween targets the element", () => {
120
+ expect(hasNonHoldTweenForElement(fakeIframe(el, [liveTween]), "#puck-b")).toBe(true);
121
+ });
122
+
123
+ it("false when only a zero-duration hold/set remains (post delete-all)", () => {
124
+ expect(hasNonHoldTweenForElement(fakeIframe(el, [holdSet]), "#puck-b")).toBe(false);
125
+ });
126
+
127
+ it("false when the element has no tweens at all", () => {
128
+ expect(hasNonHoldTweenForElement(fakeIframe(el, []), "#puck-b")).toBe(false);
129
+ });
130
+ });
3
131
 
4
132
  describe("arcPathFromMotionPathValue", () => {
5
133
  it("builds arc config from object form { path, curviness }", () => {
@@ -12,20 +12,23 @@ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-
12
12
  import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
13
13
  import { roundTo3 } from "../utils/rounding";
14
14
 
15
- interface RuntimeTween {
15
+ export interface RuntimeTween {
16
16
  targets?: () => Element[];
17
17
  vars?: Record<string, unknown>;
18
18
  duration?: () => number;
19
19
  startTime?: () => number;
20
+ invalidate?: () => RuntimeTween;
20
21
  }
21
22
 
22
- interface RuntimeTimeline {
23
+ export interface RuntimeTimeline {
23
24
  getChildren?: (deep: boolean) => RuntimeTween[];
24
25
  duration?: () => number;
26
+ time?: () => number;
27
+ invalidate?: () => RuntimeTimeline;
25
28
  }
26
29
 
27
30
  type Pct = { percentage: number; properties: Record<string, number | string> };
28
- type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
31
+ export type ReadTween = { keyframes: Pct[]; easeEach?: string; arcPath?: ArcPathConfig };
29
32
 
30
33
  export interface RuntimeKeyframeEntry {
31
34
  keyframes: Pct[];
@@ -71,6 +74,17 @@ function isXY(p: unknown): p is { x: number; y: number } {
71
74
  return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
72
75
  }
73
76
 
77
+ /**
78
+ * A tween we must skip when reading keyframes: a zero-duration `set`/hold (incl.
79
+ * the studio pre-keyframe position hold, tagged `data: STUDIO_HOLD_MARKER`).
80
+ * These sit before the real keyframed tween and otherwise shadow it — `readTween`
81
+ * would fall back to a degenerate 2-point flat path from the set's values, hiding
82
+ * the actual multi-keyframe motion. `!(duration > 0)` also rejects NaN durations.
83
+ */
84
+ function isZeroDurationSet(duration: number): boolean {
85
+ return !(duration > 0);
86
+ }
87
+
74
88
  /** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
75
89
  function coordsFromMotionPath(mp: unknown): {
76
90
  coords: Array<{ x: number; y: number }>;
@@ -149,6 +163,95 @@ function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
149
163
  };
150
164
  }
151
165
 
166
+ export interface ResolvedRuntimeTween {
167
+ /** The live GSAP tween targeting the selector. */
168
+ tween: RuntimeTween;
169
+ /** The composition timeline that owns it. */
170
+ timeline: RuntimeTimeline;
171
+ }
172
+
173
+ /**
174
+ * Whether a tween's `vars` carry at least one of `channels` as an OWN property.
175
+ * Used to disambiguate co-located `set`s: an element can have separate
176
+ * `tl.set("#el",{x,y})` and `tl.set("#el",{rotation})` tweens, and a position
177
+ * patch must land on the {x,y} set — never the rotation-only one.
178
+ */
179
+ function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: string[]): boolean {
180
+ if (!vars) return false;
181
+ for (const ch of channels) {
182
+ if (Object.prototype.hasOwnProperty.call(vars, ch)) return true;
183
+ }
184
+ return false;
185
+ }
186
+
187
+ /**
188
+ * Resolve the live tween targeting `selector` using the SAME all-timelines scan
189
+ * `readRuntimeKeyframes` uses, so read and write agree on "which tween". With
190
+ * `kind: "keyframe"` it skips zero-duration `set`s and prefers the tween whose
191
+ * range contains the playhead (matching the reader). With `kind: "set"` it picks
192
+ * the zero-duration `set`/hold instead. Returns null when none matches.
193
+ *
194
+ * `channels` disambiguates co-located `set`s (CHANNEL-BLIND otherwise): when
195
+ * provided with `kind: "set"`, a set carrying ONE of those channels wins, and a
196
+ * set carrying ONLY disjoint channels is skipped (so patching {x,y} never lands
197
+ * on a rotation-only set). With no channel-matching set, it falls back to the
198
+ * first matching set (back-compat). `channels` is ignored for `kind: "keyframe"`.
199
+ */
200
+ export function resolveRuntimeTween(
201
+ iframe: HTMLIFrameElement | null,
202
+ selector: string,
203
+ kind: "keyframe" | "set",
204
+ compositionId?: string,
205
+ channels?: string[],
206
+ ): ResolvedRuntimeTween | null {
207
+ const timelines = timelinesOf(iframe);
208
+ if (!timelines) return null;
209
+
210
+ let targetEl: Element | null = null;
211
+ try {
212
+ targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
213
+ } catch {
214
+ return null;
215
+ }
216
+ if (!targetEl) return null;
217
+
218
+ const tlIds = compositionId
219
+ ? [compositionId]
220
+ : Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function");
221
+
222
+ const wantChannels = kind === "set" && channels && channels.length > 0 ? channels : null;
223
+
224
+ let first: ResolvedRuntimeTween | null = null;
225
+ let channelMatch: ResolvedRuntimeTween | null = null;
226
+ for (const tlId of tlIds) {
227
+ const timeline = timelines[tlId];
228
+ if (!timeline?.getChildren) continue;
229
+ const now = typeof timeline.time === "function" ? timeline.time() : null;
230
+ for (const tween of timeline.getChildren(true)) {
231
+ if (!tween.vars || !matchesElement(tween, targetEl)) continue;
232
+ const dur = typeof tween.duration === "function" ? tween.duration() : 0;
233
+ const isSet = !(dur > 0);
234
+ if (kind === "set" ? !isSet : isSet) continue;
235
+ if (wantChannels) {
236
+ if (varsCarryChannel(tween.vars, wantChannels)) {
237
+ if (channelMatch === null) channelMatch = { tween, timeline };
238
+ } else if (first === null) {
239
+ // A set carrying only disjoint channels: remember as last-resort
240
+ // fallback, but never prefer it over a channel-matching set.
241
+ first = { tween, timeline };
242
+ }
243
+ continue;
244
+ }
245
+ if (first === null) first = { tween, timeline };
246
+ if (kind === "keyframe" && now != null) {
247
+ const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
248
+ if (now >= start - 1e-3 && now <= start + dur + 1e-3) return { tween, timeline };
249
+ }
250
+ }
251
+ }
252
+ return channelMatch ?? first;
253
+ }
254
+
152
255
  /**
153
256
  * Read keyframes (incl. motionPath arcs) for one selector from the live timeline.
154
257
  * Returns tween-relative percentages; callers convert to clip-relative.
@@ -160,10 +263,6 @@ export function readRuntimeKeyframes(
160
263
  ): ReadTween | null {
161
264
  const timelines = timelinesOf(iframe);
162
265
  if (!timelines) return null;
163
- const tlId = compositionId || Object.keys(timelines)[0];
164
- if (!tlId) return null;
165
- const timeline = timelines[tlId];
166
- if (!timeline?.getChildren) return null;
167
266
 
168
267
  let targetEl: Element | null = null;
169
268
  try {
@@ -173,12 +272,84 @@ export function readRuntimeKeyframes(
173
272
  }
174
273
  if (!targetEl) return null;
175
274
 
275
+ // Search the element's OWN composition timeline. With inlined subcompositions the
276
+ // preview has multiple timelines (one per composition), and the element belongs to
277
+ // exactly one — so we can't assume the first key (order isn't stable across soft
278
+ // reloads, which delete+re-add the rebuilt key). Scan every timeline for tweens
279
+ // targeting this element; only its composition's timeline matches. An explicit
280
+ // compositionId still pins the search. (`__proxied` and other non-timeline markers
281
+ // are skipped by the getChildren guard.)
282
+ const tlIds = compositionId
283
+ ? [compositionId]
284
+ : Object.keys(timelines).filter((k) => typeof timelines[k]?.getChildren === "function");
285
+ if (tlIds.length === 0) return null;
286
+
287
+ // The element can have MORE THAN ONE keyframed tween at disjoint time ranges
288
+ // (e.g. two non-overlapping gesture recordings → two separate `to()`s). The
289
+ // overlay must draw the segment under the PLAYHEAD, not blindly the first one
290
+ // — otherwise recording a second gesture leaves the path stuck on the first.
291
+ let firstRead: ReadTween | null = null;
292
+ for (const tlId of tlIds) {
293
+ const timeline = timelines[tlId];
294
+ if (!timeline?.getChildren) continue;
295
+ const now = typeof timeline.time === "function" ? timeline.time() : null;
296
+ for (const tween of timeline.getChildren(true)) {
297
+ if (!tween.vars || !matchesElement(tween, targetEl)) continue;
298
+ const dur = typeof tween.duration === "function" ? tween.duration() : 0;
299
+ if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
300
+ const read = readTween(tween.vars);
301
+ if (!read) continue;
302
+ if (firstRead === null) firstRead = read;
303
+ // Prefer the tween whose [start, start+dur] contains the playhead.
304
+ if (now != null) {
305
+ const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
306
+ if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read;
307
+ }
308
+ }
309
+ }
310
+ // Playhead outside every tween's range (or timeline has no clock): the element
311
+ // still has motion, so fall back to the first keyframed tween.
312
+ return firstRead;
313
+ }
314
+
315
+ /**
316
+ * Whether the live timeline has at least one NON-HOLD tween (non-zero duration,
317
+ * not the studio position-hold `set`) targeting `selector`. Stricter than a
318
+ * truthy `readRuntimeKeyframes`: that returns a flat read for any property-bearing
319
+ * tween, so it can't distinguish a real animation from a leftover hold/marker.
320
+ * The drag's stale-parse guard needs this exact distinction — after a delete-all
321
+ * only a hold may remain, and resurrecting the deleted tween from the stale parse
322
+ * must be avoided.
323
+ */
324
+ export function hasNonHoldTweenForElement(
325
+ iframe: HTMLIFrameElement | null,
326
+ selector: string,
327
+ compositionId?: string,
328
+ ): boolean {
329
+ const timelines = timelinesOf(iframe);
330
+ if (!timelines) return false;
331
+ const tlId =
332
+ compositionId ||
333
+ Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function");
334
+ if (!tlId) return false;
335
+ const timeline = timelines[tlId];
336
+ if (!timeline?.getChildren) return false;
337
+
338
+ let targetEl: Element | null = null;
339
+ try {
340
+ targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
341
+ } catch {
342
+ return false;
343
+ }
344
+ if (!targetEl) return false;
345
+
176
346
  for (const tween of timeline.getChildren(true)) {
177
347
  if (!tween.vars || !matchesElement(tween, targetEl)) continue;
178
- const read = readTween(tween.vars);
179
- if (read) return read;
348
+ const dur = typeof tween.duration === "function" ? tween.duration() : 0;
349
+ if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
350
+ if (readTween(tween.vars)) return true;
180
351
  }
181
- return null;
352
+ return false;
182
353
  }
183
354
 
184
355
  /** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
@@ -217,9 +388,10 @@ function addScanEntry(
217
388
  clipById?: ClipDims,
218
389
  ): void {
219
390
  if (!tween.targets || !tween.vars) return;
391
+ const { start, duration } = tweenTiming(tween);
392
+ if (isZeroDurationSet(duration)) return; // skip hold/set tweens (see isZeroDurationSet)
220
393
  const read = readTween(tween.vars);
221
394
  if (!read) return;
222
- const { start, duration } = tweenTiming(tween);
223
395
  for (const target of tween.targets()) {
224
396
  const id = (target as HTMLElement).id;
225
397
  if (id && !result.has(id)) result.set(id, buildEntry(read, start, duration, clipById?.get(id)));