@hyperframes/studio 0.8.126 → 0.8.128

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 (126) hide show
  1. package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
  2. package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
  3. package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
  4. package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +2184 -1745
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
  20. package/src/components/editor/MotionPathOverlay.tsx +20 -26
  21. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  22. package/src/components/editor/domEditingLayers.test.ts +45 -18
  23. package/src/components/editor/domEditingLayers.ts +11 -9
  24. package/src/components/editor/domEditingTypes.ts +1 -0
  25. package/src/components/editor/keyframeRetime.ts +11 -4
  26. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  27. package/src/components/editor/motionPathCommit.ts +58 -0
  28. package/src/components/editor/motionPathGeometry.test.ts +13 -0
  29. package/src/components/editor/motionPathGeometry.ts +4 -2
  30. package/src/components/editor/probeSourceElement.ts +17 -1
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  32. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  33. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  34. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  35. package/src/hooks/gsapDragCommit.ts +41 -6
  36. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  37. package/src/hooks/gsapEditOutcome.ts +27 -2
  38. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  39. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  40. package/src/hooks/gsapMutationClient.ts +35 -7
  41. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  42. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  43. package/src/hooks/gsapParsedTween.ts +27 -7
  44. package/src/hooks/gsapPositionDetection.ts +5 -2
  45. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  46. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  47. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  48. package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
  49. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  50. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  51. package/src/hooks/gsapShared.test.ts +49 -35
  52. package/src/hooks/gsapShared.ts +35 -33
  53. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
  54. package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
  55. package/src/hooks/gsapValueAtPlayhead.ts +49 -10
  56. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  58. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  59. package/src/hooks/timelineEditingHelpers.ts +40 -14
  60. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  61. package/src/hooks/timelineMoveAdapter.ts +3 -5
  62. package/src/hooks/timelineTimingSync.test.ts +83 -125
  63. package/src/hooks/timelineTimingSync.ts +62 -42
  64. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  65. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  66. package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
  67. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  68. package/src/hooks/useDomSelection.test.ts +27 -0
  69. package/src/hooks/useDomSelection.ts +12 -13
  70. package/src/hooks/useDomSelectionTypes.ts +1 -0
  71. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  72. package/src/hooks/useEditHistoryActions.ts +9 -6
  73. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  74. package/src/hooks/useEnableKeyframes.ts +54 -71
  75. package/src/hooks/useGestureCommit.ts +2 -1
  76. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  77. package/src/hooks/useGsapTweenCache.ts +2 -17
  78. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  79. package/src/hooks/usePersistentEditHistory.ts +13 -2
  80. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  81. package/src/hooks/usePreviewPersistence.ts +26 -4
  82. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  83. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  84. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  85. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  86. package/src/hooks/useTimelineEditing.ts +21 -295
  87. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  88. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  89. package/src/player/components/Timeline.test.ts +12 -4
  90. package/src/player/components/TimelineClip.tsx +3 -0
  91. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  92. package/src/player/components/TimelineLanes.test.tsx +61 -5
  93. package/src/player/components/TimelineLanes.tsx +20 -28
  94. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  95. package/src/player/components/timelineCallbacks.ts +1 -4
  96. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  97. package/src/player/components/timelineClipDragCommit.ts +39 -28
  98. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  99. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  100. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  101. package/src/player/components/timelineEditing.ts +1 -3
  102. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  103. package/src/player/components/timelineLinkSelection.ts +1 -35
  104. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  105. package/src/player/components/trackHeaderLaneState.ts +9 -2
  106. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  107. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  108. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  109. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  110. package/src/player/components/useTimelineClipDrag.ts +0 -3
  111. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  112. package/src/player/store/batchElementUpdates.test.ts +43 -0
  113. package/src/player/store/batchElementUpdates.ts +9 -0
  114. package/src/player/store/elementPatchQueue.ts +35 -0
  115. package/src/player/store/playerStore.ts +5 -6
  116. package/src/utils/domEditHelpers.ts +5 -0
  117. package/src/utils/globalTimeCompiler.test.ts +0 -4
  118. package/src/utils/globalTimeCompiler.ts +6 -4
  119. package/src/utils/gsapKeyframeEases.ts +79 -0
  120. package/src/utils/sameData.ts +9 -0
  121. package/src/utils/studioFileHistory.ts +1 -0
  122. package/src/utils/studioPendingEdits.ts +15 -1
  123. package/dist/chunk-246VB2G3.js.map +0 -1
  124. package/dist/chunk-3F47EFCT.js.map +0 -1
  125. package/dist/chunk-5EKWJZLM.js.map +0 -1
  126. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -1,9 +1,7 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- arcPathFromMotionPathValue,
4
- hasNonHoldTweenForElement,
5
- readRuntimeKeyframes,
6
- } from "./gsapRuntimeKeyframes";
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { hasNonHoldTweenForElement, readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
3
+ import { arcPathFromMotionPathValue } from "./gsapRuntimeMotionPath";
4
+ import { tweensTargeting, withTweenIndex } from "./gsapRuntimeTweenIndex";
7
5
 
8
6
  // Build a fake preview iframe whose runtime timeline holds the given child tweens
9
7
  // and resolves `selector` to `el`.
@@ -261,3 +259,79 @@ describe("arcPathFromMotionPathValue", () => {
261
259
  expect(arcPathFromMotionPathValue(null)).toBeUndefined();
262
260
  });
263
261
  });
262
+
263
+ describe("readRuntimeKeyframes — a motion path runs GSAP's default tween ease", () => {
264
+ const el = { id: "arc" };
265
+ const arcTween = (ease?: string) => ({
266
+ targets: () => [el],
267
+ vars: {
268
+ motionPath: {
269
+ path: [
270
+ { x: 0, y: 0 },
271
+ { x: 100, y: -50 },
272
+ { x: 200, y: 0 },
273
+ ],
274
+ },
275
+ ease,
276
+ },
277
+ duration: () => 2,
278
+ startTime: () => 0,
279
+ });
280
+
281
+ it("reads power1.out when the tween sets no ease, and the authored ease otherwise", () => {
282
+ const read = (ease?: string) =>
283
+ readRuntimeKeyframes(fakeIframe(el, [arcTween(ease)]), "#arc")?.runEase;
284
+ expect(read()).toBe("power1.out");
285
+ expect(read("none")).toBe("none");
286
+ });
287
+ });
288
+
289
+ describe("withTweenIndex — many elements, one scan of the tweens", () => {
290
+ const ids = Array.from({ length: 20 }, (_, i) => `clip-${i}`);
291
+ const tweens = ids.map((id) => ({
292
+ targets: vi.fn(() => [{ id }]),
293
+ vars: { x: 10, duration: 1 },
294
+ duration: () => 1,
295
+ startTime: () => 0,
296
+ }));
297
+ const timeline = { getChildren: () => tweens };
298
+ const preview = {
299
+ contentWindow: { __timelines: { "index.html": timeline } },
300
+ contentDocument: { querySelector: (sel: string) => ({ id: sel.slice(1) }) },
301
+ } as unknown as HTMLIFrameElement;
302
+ const askAll = () => ids.map((id) => hasNonHoldTweenForElement(preview, `#${id}`));
303
+
304
+ it("answers the same as the plain scan", () => {
305
+ const plain = askAll();
306
+ expect(withTweenIndex(askAll)).toEqual(plain);
307
+ expect(plain.every(Boolean)).toBe(true);
308
+ });
309
+
310
+ it("reads each tween's targets once per pass instead of once per element", () => {
311
+ for (const tween of tweens) tween.targets.mockClear();
312
+ withTweenIndex(askAll);
313
+ expect(tweens.every((tween) => tween.targets.mock.calls.length === 1)).toBe(true);
314
+ });
315
+
316
+ it("skips a timelines entry that is not an object", () => {
317
+ const odd = {
318
+ contentWindow: {
319
+ __timelines: { stray: null, label: "x", main: { getChildren: () => tweens } },
320
+ },
321
+ contentDocument: { querySelector: (sel: string) => ({ id: sel.slice(1) }) },
322
+ } as unknown as HTMLIFrameElement;
323
+ expect(withTweenIndex(() => hasNonHoldTweenForElement(odd, "#clip-3"))).toBe(true);
324
+ expect(withTweenIndex(() => hasNonHoldTweenForElement(odd, "#nobody"))).toBe(false);
325
+ });
326
+
327
+ it("returns the plain scan's tweens, once each and in timeline order", () => {
328
+ const el = { id: "clip-2" } as unknown as Element;
329
+ const byElement = { targets: () => [el], vars: {}, duration: () => 1 };
330
+ const byId = { targets: () => [{ id: "clip-2" }], vars: {}, duration: () => 1 };
331
+ const both = { targets: () => [el, { id: "clip-2" }], vars: {}, duration: () => 1 };
332
+ const tl = { getChildren: () => [byId, byElement, both] };
333
+ const plain = tweensTargeting(tl, el);
334
+ expect(withTweenIndex(() => tweensTargeting(tl, el))).toEqual(plain);
335
+ expect(plain).toEqual([byId, byElement, both]);
336
+ });
337
+ });
@@ -8,9 +8,12 @@
8
8
  * `toAbsoluteTime` + the element's clip start/duration. `scanAllRuntimeKeyframes`
9
9
  * does that conversion itself when given a `clipById` map.
10
10
  */
11
- import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
11
+ import type { ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
12
12
  import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
13
+ import { timeAtProgress } from "../utils/gsapKeyframeEases";
14
+ import { readMotionPathTween } from "./gsapRuntimeMotionPath";
13
15
  import { roundTo3 } from "../utils/rounding";
16
+ import { matchesElement, tweensTargeting } from "./gsapRuntimeTweenIndex";
14
17
  import { withParsedStart } from "./gsapParsedTween";
15
18
  import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
16
19
  import { gsapRendersTransform } from "../components/editor/gsapAnimatesProperty";
@@ -43,11 +46,12 @@ export interface RuntimeTimeline {
43
46
  to?: (targets: Element[], vars: GsapVars, position?: number) => RuntimeTween;
44
47
  }
45
48
 
46
- type Pct = { percentage: number; properties: Record<string, number | string> };
49
+ type Pct = { percentage: number; properties: Record<string, number | string>; step?: number };
47
50
  export type ReadTween = {
48
51
  keyframes: Pct[];
49
52
  easeEach?: string;
50
53
  arcPath?: ArcPathConfig;
54
+ runEase?: string;
51
55
  /** GSAP's start values when the first keyframe is after 0%. */
52
56
  start?: Record<string, number>;
53
57
  };
@@ -92,10 +96,6 @@ function timelinesOf(iframe: HTMLIFrameElement | null): Record<string, RuntimeTi
92
96
  }
93
97
  }
94
98
 
95
- function isXY(p: unknown): p is { x: number; y: number } {
96
- return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
97
- }
98
-
99
99
  /**
100
100
  * A tween we must skip when reading keyframes: a zero-duration `set`/hold (incl.
101
101
  * the studio pre-keyframe position hold, tagged `data: STUDIO_HOLD_MARKER`).
@@ -107,31 +107,6 @@ function isZeroDurationSet(duration: number): boolean {
107
107
  return !(duration > 0);
108
108
  }
109
109
 
110
- /** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
111
- function coordsFromMotionPath(mp: unknown): {
112
- coords: Array<{ x: number; y: number }>;
113
- curviness: number;
114
- autoRotate: boolean | number;
115
- isCubic: boolean;
116
- } | null {
117
- if (!mp || typeof mp !== "object") return null;
118
- const obj = mp as Record<string, unknown>;
119
- const pathVal = Array.isArray(mp) ? mp : obj.path;
120
- if (!Array.isArray(pathVal)) return null;
121
- const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
122
- if (coords.length < 2) return null;
123
- const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
124
- const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
125
- return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
126
- }
127
-
128
- /** Build an arcPath config from a live `vars.motionPath` value. */
129
- export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
130
- const parsed = coordsFromMotionPath(mp);
131
- if (!parsed) return undefined;
132
- return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
133
- }
134
-
135
110
  function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
136
111
  const properties: Record<string, number | string> = {};
137
112
  for (const [k, v] of Object.entries(vars)) {
@@ -146,36 +121,22 @@ function flatTweenKeyframes(vars: Record<string, unknown>): Pct[] | null {
146
121
  ];
147
122
  }
148
123
 
124
+ const stringOr = (value: unknown) => (typeof value === "string" ? value : undefined);
125
+
149
126
  /** Tween-relative keyframes + optional arcPath for one live tween, or null. */
150
127
  function readTween(vars: Record<string, unknown>): ReadTween | null {
128
+ const runEase = stringOr(vars.ease);
151
129
  if (vars.keyframes && typeof vars.keyframes === "object") {
152
130
  const parsed = parsePercentageKeyframes(vars.keyframes as Record<string, unknown>);
153
- if (parsed) return parsed;
154
- }
155
- const mp = coordsFromMotionPath(vars.motionPath);
156
- if (mp) {
157
- const shape = buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
158
- if (shape) {
159
- const n = shape.waypoints.length;
160
- const keyframes = shape.waypoints.map((wp, i) => ({
161
- percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
162
- properties: { x: wp.x, y: wp.y },
163
- }));
164
- return { keyframes, arcPath: shape.arcPath };
165
- }
131
+ const keyframesEase = stringOr((vars.keyframes as { ease?: unknown }).ease);
132
+ if (parsed) return { ...parsed, runEase: keyframesEase ?? runEase };
166
133
  }
134
+ const path = readMotionPathTween(vars, runEase);
135
+ if (path) return path;
167
136
  const flat = flatTweenKeyframes(vars);
168
137
  return flat ? { keyframes: flat } : null;
169
138
  }
170
139
 
171
- function matchesElement(tween: RuntimeTween, el: Element): boolean {
172
- if (!tween.targets) return false;
173
- for (const t of tween.targets()) {
174
- if (t === el || (el.id && (t as Element).id === el.id)) return true;
175
- }
176
- return false;
177
- }
178
-
179
140
  function tweenTiming(tween: RuntimeTween): { start: number; duration: number } {
180
141
  const rawStart = typeof tween.startTime === "function" ? tween.startTime() : 0;
181
142
  const rawDur = typeof tween.duration === "function" ? tween.duration() : 0;
@@ -438,10 +399,9 @@ export const GSAP_TRANSFORM_KEYS = new Set(
438
399
  export function gsapWritesChannels(el: Element, channels: string[]): boolean {
439
400
  const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
440
401
  return Object.values(win?.__timelines ?? {}).some((tl) =>
441
- (tl?.getChildren?.(true) ?? []).some(
402
+ tweensTargeting(tl, el).some(
442
403
  (tween) =>
443
404
  !!tween.vars &&
444
- matchesElement(tween, el) &&
445
405
  (channels.some((ch) => ch in tween.vars!) ||
446
406
  keyframeVarsCarryChannel(tween.vars, channels)),
447
407
  ),
@@ -515,9 +475,8 @@ function hasNonHoldTween(
515
475
  ): boolean {
516
476
  if (!timelines) return false;
517
477
  for (const tlId of compositionId ? [compositionId] : Object.keys(timelines)) {
518
- // fallow-ignore-next-line code-duplication
519
- for (const tween of timelines[tlId]?.getChildren?.(true) ?? []) {
520
- if (!tween.vars || !matchesElement(tween, targetEl)) continue;
478
+ for (const tween of tweensTargeting(timelines[tlId], targetEl)) {
479
+ if (!tween.vars) continue;
521
480
  const dur = typeof tween.duration === "function" ? tween.duration() : 0;
522
481
  if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
523
482
  if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true;
@@ -530,14 +489,15 @@ function hasNonHoldTween(
530
489
 
531
490
  /** Convert tween-relative keyframes to clip-relative % using the element's clip dims. */
532
491
  function toClipRelative(
533
- keyframes: Pct[],
492
+ read: ReadTween,
534
493
  tweenStart: number,
535
494
  tweenDuration: number,
536
495
  clip: { start: number; duration: number } | undefined,
537
496
  ): Pct[] {
497
+ const { keyframes, runEase } = read;
538
498
  if (!clip || clip.duration <= 0) return keyframes;
539
499
  return keyframes.map((kf) => {
540
- const abs = toAbsoluteTime(tweenStart, tweenDuration, kf.percentage);
500
+ const abs = toAbsoluteTime(tweenStart, tweenDuration, timeAtProgress(runEase, kf.percentage));
541
501
  return { ...kf, percentage: Math.round(((abs - clip.start) / clip.duration) * 100000) / 1000 };
542
502
  });
543
503
  }
@@ -549,7 +509,7 @@ function buildEntry(
549
509
  clip: { start: number; duration: number } | undefined,
550
510
  ): RuntimeKeyframeEntry {
551
511
  return {
552
- keyframes: toClipRelative(read.keyframes, start, duration, clip),
512
+ keyframes: toClipRelative(read, start, duration, clip),
553
513
  tweenStart: start,
554
514
  tweenDuration: duration,
555
515
  ...(read.easeEach ? { easeEach: read.easeEach } : {}),
@@ -0,0 +1,47 @@
1
+ import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
2
+ import { MOTION_PATH_RUN_EASE } from "../utils/gsapKeyframeEases";
3
+ import type { ReadTween } from "./gsapRuntimeKeyframes";
4
+
5
+ function isXY(p: unknown): p is { x: number; y: number } {
6
+ return !!p && typeof (p as any).x === "number" && typeof (p as any).y === "number";
7
+ }
8
+
9
+ /** Coordinates + curviness from a live `vars.motionPath` value (object or array form), or null. */
10
+ function coordsFromMotionPath(mp: unknown): {
11
+ coords: Array<{ x: number; y: number }>;
12
+ curviness: number;
13
+ autoRotate: boolean | number;
14
+ isCubic: boolean;
15
+ } | null {
16
+ if (!mp || typeof mp !== "object") return null;
17
+ const obj = mp as Record<string, unknown>;
18
+ const pathVal = Array.isArray(mp) ? mp : obj.path;
19
+ if (!Array.isArray(pathVal)) return null;
20
+ const coords = pathVal.filter(isXY).map((p) => ({ x: p.x, y: p.y }));
21
+ if (coords.length < 2) return null;
22
+ const curviness = typeof obj.curviness === "number" ? obj.curviness : 1;
23
+ const autoRotate = typeof obj.autoRotate === "number" ? obj.autoRotate : obj.autoRotate === true;
24
+ return { coords, curviness, autoRotate, isCubic: obj.type === "cubic" };
25
+ }
26
+
27
+ /** Build an arcPath config from a live `vars.motionPath` value. */
28
+ export function arcPathFromMotionPathValue(mp: unknown): ArcPathConfig | undefined {
29
+ const parsed = coordsFromMotionPath(mp);
30
+ if (!parsed) return undefined;
31
+ return buildArcPath(parsed.coords, parsed.curviness, parsed.autoRotate, parsed.isCubic)?.arcPath;
32
+ }
33
+
34
+ export function readMotionPathTween(
35
+ vars: Record<string, unknown>,
36
+ runEase?: string,
37
+ ): ReadTween | null {
38
+ const mp = coordsFromMotionPath(vars.motionPath);
39
+ const shape = mp && buildArcPath(mp.coords, mp.curviness, mp.autoRotate, mp.isCubic);
40
+ if (!shape) return null;
41
+ const n = shape.waypoints.length;
42
+ const keyframes = shape.waypoints.map((wp, i) => ({
43
+ percentage: n > 1 ? Math.round((i / (n - 1)) * 100) : 0,
44
+ properties: { x: wp.x, y: wp.y },
45
+ }));
46
+ return { keyframes, arcPath: shape.arcPath, runEase: runEase ?? MOTION_PATH_RUN_EASE };
47
+ }
@@ -0,0 +1,53 @@
1
+ import type { RuntimeTimeline, RuntimeTween } from "./gsapRuntimeKeyframes";
2
+
3
+ export function matchesElement(tween: RuntimeTween, el: Element): boolean {
4
+ if (!tween.targets) return false;
5
+ for (const t of tween.targets()) {
6
+ if (t === el || (el.id && (t as Element).id === el.id)) return true;
7
+ }
8
+ return false;
9
+ }
10
+
11
+ type TweenIndex = { byElement: Map<unknown, RuntimeTween[]>; byId: Map<string, RuntimeTween[]> };
12
+ let tweenIndexes: WeakMap<RuntimeTimeline, TweenIndex> | null = null;
13
+
14
+ /** Within `run`, finding the tweens on an element reads one index per timeline, not every tween. */
15
+ export function withTweenIndex<T>(run: () => T): T {
16
+ const outer = tweenIndexes;
17
+ tweenIndexes ??= new WeakMap();
18
+ try {
19
+ return run();
20
+ } finally {
21
+ tweenIndexes = outer;
22
+ }
23
+ }
24
+
25
+ function indexTweens(children: RuntimeTween[]): TweenIndex {
26
+ const index: TweenIndex = { byElement: new Map(), byId: new Map() };
27
+ const add = <K>(map: Map<K, RuntimeTween[]>, key: K, tween: RuntimeTween) => {
28
+ const list = map.get(key);
29
+ // A tween's targets are added together, so a repeat of its key is always the last entry.
30
+ if (!list) map.set(key, [tween]);
31
+ else if (list.at(-1) !== tween) list.push(tween);
32
+ };
33
+ for (const tween of children) {
34
+ for (const target of tween.targets?.() ?? []) {
35
+ add(index.byElement, target, tween);
36
+ const id = (target as Element).id;
37
+ if (id) add(index.byId, id, tween);
38
+ }
39
+ }
40
+ return index;
41
+ }
42
+
43
+ export function tweensTargeting(
44
+ timeline: RuntimeTimeline | undefined,
45
+ el: Element,
46
+ ): RuntimeTween[] {
47
+ if (!timeline || typeof timeline !== "object" || !tweenIndexes)
48
+ return (timeline?.getChildren?.(true) ?? []).filter((tween) => matchesElement(tween, el));
49
+ let index = tweenIndexes.get(timeline);
50
+ if (!index) tweenIndexes.set(timeline, (index = indexTweens(timeline.getChildren?.(true) ?? [])));
51
+ // The same rule as matchesElement: an element with an id matches every tween on that id.
52
+ return (el.id ? index.byId.get(el.id) : index.byElement.get(el)) ?? [];
53
+ }
@@ -1,33 +1,16 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
- import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
3
  import {
5
4
  idFromSelector,
6
5
  idSelector,
7
6
  isInstantHold,
8
7
  parsePercentageKeyframes,
9
8
  resolveClipTimingBasis,
10
- resolveEditableTweenDuration,
9
+ playsNear,
11
10
  toClipKeyframes,
12
11
  toClipPercentage,
13
12
  } from "./gsapShared";
14
13
 
15
- // Fixtures carry only the fields the function under test reads; the double-cast
16
- // is the documented way to stand in for the full runtime shape (CONTRIBUTING.md).
17
- const tween = (duration: number | undefined) => ({ duration }) as unknown as GsapAnimation;
18
-
19
- describe("resolveEditableTweenDuration", () => {
20
- const selection = { dataAttributes: { duration: "16.26" } } as unknown as DomEditSelection;
21
-
22
- it("uses the owning clip duration when the tween omits an outer duration", () => {
23
- expect(resolveEditableTweenDuration(tween(undefined), selection)).toBe(16.26);
24
- });
25
-
26
- it("keeps an explicitly-authored tween duration", () => {
27
- expect(resolveEditableTweenDuration(tween(4), selection)).toBe(4);
28
- });
29
- });
30
-
31
14
  describe("isInstantHold", () => {
32
15
  const animation = (method: GsapAnimation["method"], duration?: number) =>
33
16
  ({ method, duration }) as unknown as GsapAnimation;
@@ -54,44 +37,49 @@ describe("parsePercentageKeyframes", () => {
54
37
  ]);
55
38
  });
56
39
 
57
- it("parses GSAP array-form keyframes as evenly-distributed steps", () => {
58
- // Regression: a multi-point shuttle path authored as `keyframes: [...]` used to
59
- // read as null (no `N%` keys) → no motion path. Steps map to i/(n-1)*100%.
40
+ it("parses GSAP array-form keyframes at each step's end, as the parser does", () => {
41
+ // A multi-point shuttle path authored as `keyframes: [...]` has no `N%` keys;
42
+ // step i of n ends at (i+1)/n.
60
43
  const out = parsePercentageKeyframes([
61
44
  { x: 0, y: 0 },
62
45
  { x: 520, y: 120 },
63
46
  { x: 1040, y: 0 },
64
47
  { x: 1480, y: 160 },
65
48
  ] as unknown as Record<string, unknown>);
66
- expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 33.3, 66.7, 100]);
49
+ expect(out?.keyframes.map((k) => k.percentage)).toEqual([25, 50, 75, 100]);
50
+ expect(out?.keyframes.map((k) => k.step)).toEqual([0, 1, 2, 3]);
67
51
  expect(out?.keyframes[1]!.properties).toEqual({ x: 520, y: 120 });
52
+ const timed = parsePercentageKeyframes([
53
+ { x: 0, duration: 1 },
54
+ { x: 50, duration: 3 },
55
+ ] as unknown as Record<string, unknown>);
56
+ expect(timed?.keyframes.map((k) => k.percentage)).toEqual([25, 100]);
68
57
  });
69
58
 
70
- it("strips a per-entry ease without shifting the even index-spacing of the others", () => {
59
+ it("strips a per-entry ease without shifting the step spacing of the others", () => {
71
60
  // GSAP positions array keyframes by array index, so a `{ ease }` carried on an
72
61
  // entry is a segment ease (skipped as a property) — it must not change where
73
- // the surrounding keyframes land. 3 entries → 0 / 50 / 100, even though the
62
+ // the surrounding keyframes land. 3 entries → 33.3 / 66.7 / 100, even though the
74
63
  // middle entry also carries an ease.
75
64
  const out = parsePercentageKeyframes([
76
65
  { x: 0 },
77
66
  { x: 100, ease: "power2.in" },
78
67
  { x: 200 },
79
68
  ] as unknown as Record<string, unknown>);
80
- expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 50, 100]);
69
+ expect(out?.keyframes.map((k) => k.percentage)).toEqual([33.3, 66.7, 100]);
81
70
  expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 100 }, { x: 200 }]);
82
71
  });
83
72
 
84
- it("keeps even spacing when an interior array slot has no animatable prop", () => {
73
+ it("keeps the step spacing when an interior array slot has no animatable prop", () => {
85
74
  // A degenerate `{ ease }`-only slot contributes no output keyframe, but it is
86
75
  // still an array slot GSAP allocates a position to — so the remaining entries
87
- // keep their original i/(n-1) percentages (0 and 100 for a 3-slot array), not
88
- // 0/100 collapsed onto a 2-entry spacing.
76
+ // keep their 3-slot percentages (33.3 and 100), not a 2-entry spacing.
89
77
  const out = parsePercentageKeyframes([
90
78
  { x: 0 },
91
79
  { ease: "power2.in" },
92
80
  { x: 200 },
93
81
  ] as unknown as Record<string, unknown>);
94
- expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 100]);
82
+ expect(out?.keyframes.map((k) => k.percentage)).toEqual([33.3, 100]);
95
83
  expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 200 }]);
96
84
  });
97
85
 
@@ -156,12 +144,38 @@ describe("toClipKeyframes", () => {
156
144
  resolvedStart: 0,
157
145
  } as unknown as GsapAnimation;
158
146
 
159
- // A tween with no duration spans its clip everywhere else in Studio
160
- // (resolveEditableTweenDuration), so the cache rows have to agree: a fixed 1s
161
- // basis put the end keyframe at 25% of a 4s clip instead of 100%.
162
- it("spans the clip when the tween has no duration", () => {
147
+ it("places a duration-less tween's keyframes where GSAP plays them, not across the clip", () => {
163
148
  const rows = toClipKeyframes([{ percentage: 0 }, { percentage: 100 }], durationless, 0, 4);
164
- expect(rows.map((row) => row.percentage)).toEqual([0, 100]);
149
+ expect(rows.map((row) => row.percentage)).toEqual([0, 12.5]);
150
+ });
151
+
152
+ it("matches a keyframe by the time it plays, not its warped progress", () => {
153
+ const eased = {
154
+ ...durationless,
155
+ duration: 1,
156
+ ease: "power2.in",
157
+ keyframes: { format: "percentage", keyframes: [] },
158
+ } as unknown as GsapAnimation;
159
+ // At 20% of the time power2.in (cubic) shows 0.8% progress: close in progress, far in time.
160
+ expect(playsNear(eased, 0, 0.8)).toBe(false);
161
+ expect(playsNear(eased, 50, 50.5)).toBe(true);
162
+ });
163
+
164
+ it("places a keyframe where an outer ease makes GSAP reach it", () => {
165
+ const eased = {
166
+ ...durationless,
167
+ duration: 1,
168
+ ease: "power2.in",
169
+ keyframes: { format: "percentage", keyframes: [] },
170
+ } as unknown as GsapAnimation;
171
+ const rows = toClipKeyframes(
172
+ [{ percentage: 0 }, { percentage: 50 }, { percentage: 100 }],
173
+ eased,
174
+ 0,
175
+ 1,
176
+ );
177
+ // GSAP's power2 is cubic: it reaches 50% at the cube root of 0.5.
178
+ expect(rows.map((row) => row.percentage)).toEqual([0, 79.37, 100]);
165
179
  });
166
180
 
167
181
  it("keeps the tween percentage and the animation identity on every row", () => {
@@ -275,7 +289,7 @@ describe("sub-composition keyframe percentages", () => {
275
289
 
276
290
  it("puts a tween on the host's first frame at 0%, never below zero", () => {
277
291
  // A clip-relative percentage can never be negative; this one cached -12.
278
- expect(percentages(inner(0))).toEqual([0, 100]);
292
+ expect(percentages(inner(0))).toEqual([0, 4]);
279
293
  });
280
294
 
281
295
  it("puts the last tween's end keyframe at 100%", () => {
@@ -5,6 +5,8 @@
5
5
  */
6
6
  import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
7
7
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
8
+ import { getObjectArrayKeyframeTiming } from "@hyperframes/parsers/gsap-parser";
9
+ import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
8
10
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
9
11
  import {
10
12
  absoluteToPercentage,
@@ -88,6 +90,13 @@ const SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/;
88
90
  */
89
91
  export const KEYFRAME_PCT_MATCH = 1;
90
92
 
93
+ /** Whether the keyframe at `percentage` plays within KEYFRAME_PCT_MATCH of the time where GSAP shows `progress`. */
94
+ export function playsNear(anim: GsapAnimation, percentage: number, progress: number): boolean {
95
+ const runEase = runEaseOf(anim);
96
+ const gap = timeAtProgress(runEase, percentage) - timeAtProgress(runEase, progress);
97
+ return Math.abs(gap) <= KEYFRAME_PCT_MATCH;
98
+ }
99
+
91
100
  export function idSelector(id: string): string {
92
101
  // A `#id` selector is only valid for a CSS identifier. IDs that start with a
93
102
  // digit (e.g. "01-hook-hero-word") make `document.querySelector("#01-...")` and
@@ -332,22 +341,6 @@ export function tweenTargetsElement(
332
341
 
333
342
  // ── Percentage computation ────────────────────────────────────────────────────
334
343
 
335
- /**
336
- * Resolve the timing basis used by editor keyframes. The timeline renders a
337
- * duration-less tween across its owning clip, so mutations must use that same
338
- * duration instead of silently falling back to GSAP's 0.5s default.
339
- */
340
- export function resolveEditableTweenDuration(
341
- animation: GsapAnimation,
342
- selection: DomEditSelection,
343
- ): number {
344
- const clipDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "");
345
- return resolveTweenDuration(
346
- animation,
347
- Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 0.5,
348
- );
349
- }
350
-
351
344
  /**
352
345
  * Compute the current playback percentage within an element's animation range.
353
346
  * Uses the animation's resolved timing if available, otherwise falls back to
@@ -360,10 +353,13 @@ export function computeElementPercentage(
360
353
  ): number {
361
354
  if (animation) {
362
355
  const start = resolveTweenStart(animation);
363
- const duration = resolveEditableTweenDuration(animation, selection);
356
+ const duration = resolveTweenDuration(animation);
364
357
  if (duration <= 0) return 0;
365
358
  if (start !== null) {
366
- return absoluteToPercentage(currentTime, start, duration);
359
+ return progressAtTime(
360
+ runEaseOf(animation),
361
+ absoluteToPercentage(currentTime, start, duration),
362
+ );
367
363
  }
368
364
  }
369
365
  const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
@@ -399,7 +395,11 @@ export function queryIframeElement(
399
395
  // ── Keyframe parsing ──────────────────────────────────────────────────────────
400
396
 
401
397
  export interface ParsedPercentageKeyframes {
402
- keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
398
+ keyframes: Array<{
399
+ percentage: number;
400
+ properties: Record<string, number | string>;
401
+ step?: number;
402
+ }>;
403
403
  easeEach?: string;
404
404
  }
405
405
 
@@ -424,13 +424,9 @@ export function parsePercentageKeyframes(
424
424
  const keyframes: ParsedPercentageKeyframes["keyframes"] = [];
425
425
  let easeEach: string | undefined;
426
426
 
427
- // GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` — are spread
428
- // evenly across the tween by default: GSAP gives each entry an equal share of
429
- // the duration unless an entry carries its own `duration`/`delay`, which the
430
- // studio never emits. So entry i of n maps to i/(n-1)*100% (n=4 → 0/33.3/66.7/100).
431
- // Index spacing counts EVERY array slot, including a degenerate entry that
432
- // contributes no animatable prop (it's still a slot GSAP allocates a position
433
- // to), so dropping such an entry from the output below must NOT shift the others.
427
+ // GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` — place each step at
428
+ // its cumulative end, the parser's rule (getObjectArrayKeyframeTiming). Spacing counts
429
+ // EVERY array slot, so dropping an entry with no animatable prop must NOT shift the others.
434
430
  // A per-entry `ease` is a segment ease, not a keyframe value, so it's skipped as
435
431
  // a property; there is no array-form `easeEach` (that's an object-form sibling key).
436
432
  // (The object form further down uses explicit "0%" keys instead.) Without this
@@ -438,11 +434,16 @@ export function parsePercentageKeyframes(
438
434
  // motion path.
439
435
  if (Array.isArray(kfObj)) {
440
436
  const steps = kfObj as unknown[];
437
+ const timing = getObjectArrayKeyframeTiming(
438
+ steps.map((entry) => (entry as { duration?: unknown } | null)?.duration),
439
+ );
440
+ if (!timing) return null;
441
441
  steps.forEach((entry, i) => {
442
442
  if (!entry || typeof entry !== "object") return;
443
- const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
444
443
  const properties = collectAnimatableKeyframeProperties(entry);
445
- if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties });
444
+ if (Object.keys(properties).length > 0) {
445
+ keyframes.push({ percentage: timing.percentages[i]!, properties, step: i });
446
+ }
446
447
  });
447
448
  return keyframes.length > 0 ? { keyframes } : null;
448
449
  }
@@ -571,14 +572,15 @@ export function toClipKeyframes<T extends { percentage: number }>(
571
572
  }
572
573
  > {
573
574
  const tweenStart = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
574
- // A duration-less tween spans the clip, the same rule the edit paths use
575
- // (resolveEditableTweenDuration). A fixed 1s here put its keyframes at a
576
- // percentage no editor agreed with.
577
- const tweenDuration = anim.duration ?? clipDuration;
575
+ const tweenDuration = resolveTweenDuration(anim);
578
576
  return source.map((keyframe) => ({
579
577
  ...keyframe,
580
578
  percentage: toClipPercentage(
581
- toAbsoluteTime(tweenStart, tweenDuration, keyframe.percentage),
579
+ toAbsoluteTime(
580
+ tweenStart,
581
+ tweenDuration,
582
+ timeAtProgress(runEaseOf(anim), keyframe.percentage),
583
+ ),
582
584
  clipStart,
583
585
  clipDuration,
584
586
  keyframe.percentage,