@hyperframes/studio 0.8.127 → 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 (114) hide show
  1. package/dist/assets/{hyperframes-player-uqzDaxVL.js → hyperframes-player-CKCx0CFA.js} +1 -1
  2. package/dist/assets/{index-DXSYlqmV.js → index-CF31Pq1E.js} +1 -1
  3. package/dist/assets/{index-COhbL1xq.js → index-ClLzyuwb.js} +171 -171
  4. package/dist/assets/{index-CLBNYlDY.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 +1193 -869
  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/commitPreflightHandles.test.tsx +32 -0
  20. package/src/components/editor/domEditingLayers.test.ts +45 -18
  21. package/src/components/editor/domEditingLayers.ts +11 -9
  22. package/src/components/editor/domEditingTypes.ts +1 -0
  23. package/src/components/editor/keyframeRetime.ts +11 -4
  24. package/src/components/editor/probeSourceElement.ts +17 -1
  25. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  27. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  28. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  29. package/src/hooks/gsapDragCommit.ts +8 -5
  30. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  31. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  32. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  33. package/src/hooks/gsapMutationClient.ts +35 -7
  34. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  35. package/src/hooks/gsapParsedTween.ts +5 -6
  36. package/src/hooks/gsapPositionDetection.ts +5 -2
  37. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  39. package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
  40. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  41. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  42. package/src/hooks/gsapShared.test.ts +48 -35
  43. package/src/hooks/gsapShared.ts +30 -32
  44. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
  45. package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
  46. package/src/hooks/gsapValueAtPlayhead.ts +3 -4
  47. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  48. package/src/hooks/timelineEditingHelpers.ts +40 -14
  49. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  50. package/src/hooks/timelineMoveAdapter.ts +3 -5
  51. package/src/hooks/timelineTimingSync.test.ts +83 -125
  52. package/src/hooks/timelineTimingSync.ts +62 -42
  53. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  54. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  55. package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
  56. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  57. package/src/hooks/useDomSelection.test.ts +27 -0
  58. package/src/hooks/useDomSelection.ts +12 -13
  59. package/src/hooks/useDomSelectionTypes.ts +1 -0
  60. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  61. package/src/hooks/useEditHistoryActions.ts +9 -6
  62. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  63. package/src/hooks/useEnableKeyframes.ts +54 -71
  64. package/src/hooks/useGestureCommit.ts +2 -1
  65. package/src/hooks/useGsapTweenCache.ts +2 -17
  66. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  67. package/src/hooks/usePersistentEditHistory.ts +13 -2
  68. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  69. package/src/hooks/usePreviewPersistence.ts +26 -4
  70. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  72. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  73. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  74. package/src/hooks/useTimelineEditing.ts +21 -295
  75. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  77. package/src/player/components/Timeline.test.ts +12 -4
  78. package/src/player/components/TimelineClip.tsx +3 -0
  79. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  80. package/src/player/components/TimelineLanes.test.tsx +61 -5
  81. package/src/player/components/TimelineLanes.tsx +20 -28
  82. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  83. package/src/player/components/timelineCallbacks.ts +1 -4
  84. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  85. package/src/player/components/timelineClipDragCommit.ts +39 -28
  86. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  87. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  88. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  89. package/src/player/components/timelineEditing.ts +1 -3
  90. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  91. package/src/player/components/timelineLinkSelection.ts +1 -35
  92. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  93. package/src/player/components/trackHeaderLaneState.ts +9 -2
  94. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  95. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  96. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  97. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  98. package/src/player/components/useTimelineClipDrag.ts +0 -3
  99. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  100. package/src/player/store/batchElementUpdates.test.ts +43 -0
  101. package/src/player/store/batchElementUpdates.ts +9 -0
  102. package/src/player/store/elementPatchQueue.ts +35 -0
  103. package/src/player/store/playerStore.ts +5 -6
  104. package/src/utils/domEditHelpers.ts +5 -0
  105. package/src/utils/globalTimeCompiler.test.ts +0 -4
  106. package/src/utils/globalTimeCompiler.ts +6 -4
  107. package/src/utils/gsapKeyframeEases.ts +79 -0
  108. package/src/utils/sameData.ts +9 -0
  109. package/src/utils/studioFileHistory.ts +1 -0
  110. package/src/utils/studioPendingEdits.ts +15 -1
  111. package/dist/chunk-246VB2G3.js.map +0 -1
  112. package/dist/chunk-3F47EFCT.js.map +0 -1
  113. package/dist/chunk-5EKWJZLM.js.map +0 -1
  114. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -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";
@@ -48,6 +51,7 @@ 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,45 +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]);
67
50
  expect(out?.keyframes.map((k) => k.step)).toEqual([0, 1, 2, 3]);
68
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]);
69
57
  });
70
58
 
71
- 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", () => {
72
60
  // GSAP positions array keyframes by array index, so a `{ ease }` carried on an
73
61
  // entry is a segment ease (skipped as a property) — it must not change where
74
- // 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
75
63
  // middle entry also carries an ease.
76
64
  const out = parsePercentageKeyframes([
77
65
  { x: 0 },
78
66
  { x: 100, ease: "power2.in" },
79
67
  { x: 200 },
80
68
  ] as unknown as Record<string, unknown>);
81
- 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]);
82
70
  expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 100 }, { x: 200 }]);
83
71
  });
84
72
 
85
- 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", () => {
86
74
  // A degenerate `{ ease }`-only slot contributes no output keyframe, but it is
87
75
  // still an array slot GSAP allocates a position to — so the remaining entries
88
- // keep their original i/(n-1) percentages (0 and 100 for a 3-slot array), not
89
- // 0/100 collapsed onto a 2-entry spacing.
76
+ // keep their 3-slot percentages (33.3 and 100), not a 2-entry spacing.
90
77
  const out = parsePercentageKeyframes([
91
78
  { x: 0 },
92
79
  { ease: "power2.in" },
93
80
  { x: 200 },
94
81
  ] as unknown as Record<string, unknown>);
95
- expect(out?.keyframes.map((k) => k.percentage)).toEqual([0, 100]);
82
+ expect(out?.keyframes.map((k) => k.percentage)).toEqual([33.3, 100]);
96
83
  expect(out?.keyframes.map((k) => k.properties)).toEqual([{ x: 0 }, { x: 200 }]);
97
84
  });
98
85
 
@@ -157,12 +144,38 @@ describe("toClipKeyframes", () => {
157
144
  resolvedStart: 0,
158
145
  } as unknown as GsapAnimation;
159
146
 
160
- // A tween with no duration spans its clip everywhere else in Studio
161
- // (resolveEditableTweenDuration), so the cache rows have to agree: a fixed 1s
162
- // basis put the end keyframe at 25% of a 4s clip instead of 100%.
163
- 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", () => {
164
148
  const rows = toClipKeyframes([{ percentage: 0 }, { percentage: 100 }], durationless, 0, 4);
165
- 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]);
166
179
  });
167
180
 
168
181
  it("keeps the tween percentage and the animation identity on every row", () => {
@@ -276,7 +289,7 @@ describe("sub-composition keyframe percentages", () => {
276
289
 
277
290
  it("puts a tween on the host's first frame at 0%, never below zero", () => {
278
291
  // A clip-relative percentage can never be negative; this one cached -12.
279
- expect(percentages(inner(0))).toEqual([0, 100]);
292
+ expect(percentages(inner(0))).toEqual([0, 4]);
280
293
  });
281
294
 
282
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;
@@ -428,13 +424,9 @@ export function parsePercentageKeyframes(
428
424
  const keyframes: ParsedPercentageKeyframes["keyframes"] = [];
429
425
  let easeEach: string | undefined;
430
426
 
431
- // GSAP array-form keyframes — `keyframes: [{x,y}, {x,y}, ...]` — are spread
432
- // evenly across the tween by default: GSAP gives each entry an equal share of
433
- // the duration unless an entry carries its own `duration`/`delay`, which the
434
- // studio never emits. So entry i of n maps to i/(n-1)*100% (n=4 → 0/33.3/66.7/100).
435
- // Index spacing counts EVERY array slot, including a degenerate entry that
436
- // contributes no animatable prop (it's still a slot GSAP allocates a position
437
- // 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.
438
430
  // A per-entry `ease` is a segment ease, not a keyframe value, so it's skipped as
439
431
  // a property; there is no array-form `easeEach` (that's an object-form sibling key).
440
432
  // (The object form further down uses explicit "0%" keys instead.) Without this
@@ -442,11 +434,16 @@ export function parsePercentageKeyframes(
442
434
  // motion path.
443
435
  if (Array.isArray(kfObj)) {
444
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;
445
441
  steps.forEach((entry, i) => {
446
442
  if (!entry || typeof entry !== "object") return;
447
- const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
448
443
  const properties = collectAnimatableKeyframeProperties(entry);
449
- if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties, step: i });
444
+ if (Object.keys(properties).length > 0) {
445
+ keyframes.push({ percentage: timing.percentages[i]!, properties, step: i });
446
+ }
450
447
  });
451
448
  return keyframes.length > 0 ? { keyframes } : null;
452
449
  }
@@ -575,14 +572,15 @@ export function toClipKeyframes<T extends { percentage: number }>(
575
572
  }
576
573
  > {
577
574
  const tweenStart = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
578
- // A duration-less tween spans the clip, the same rule the edit paths use
579
- // (resolveEditableTweenDuration). A fixed 1s here put its keyframes at a
580
- // percentage no editor agreed with.
581
- const tweenDuration = anim.duration ?? clipDuration;
575
+ const tweenDuration = resolveTweenDuration(anim);
582
576
  return source.map((keyframe) => ({
583
577
  ...keyframe,
584
578
  percentage: toClipPercentage(
585
- toAbsoluteTime(tweenStart, tweenDuration, keyframe.percentage),
579
+ toAbsoluteTime(
580
+ tweenStart,
581
+ tweenDuration,
582
+ timeAtProgress(runEaseOf(anim), keyframe.percentage),
583
+ ),
586
584
  clipStart,
587
585
  clipDuration,
588
586
  keyframe.percentage,
@@ -1,14 +1,20 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { gsap } from "gsap";
3
3
  import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
4
- import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
4
+ import {
5
+ replaceTweenWithKeyframesInScript,
6
+ syncPositionHoldsBeforeKeyframes,
7
+ updateKeyframeInScript,
8
+ } from "@hyperframes/parsers/gsap-writer-acorn";
5
9
  import { afterEach, expect, it, vi } from "vitest";
6
10
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
11
  import { usePlayerStore } from "../player/store/playerStore";
8
12
  import { findParsedTween, parsedImplicitEndValue, parsedTweenEase } from "./gsapParsedTween";
9
13
  import { readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
10
14
  import { GsapEditBlockedError } from "./gsapEditOutcome";
15
+ import { toClipKeyframes } from "./gsapShared";
11
16
  import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
17
+ import { applyKeyframeAtPlayhead, type EnableKeyframesSession } from "./useEnableKeyframes";
12
18
 
13
19
  /** Runs a composition script as the preview does: a paused timeline, bound, then seeked to `at`. */
14
20
  function play(script: string, at: number) {
@@ -74,10 +80,10 @@ it("reads plain array nodes where the lane puts them, though GSAP fills in step
74
80
  });
75
81
 
76
82
  // Three default 0.5 s steps stretched over 3 s: GSAP reaches the middle one at 2 s, where the parse
77
- // places it at 50%.
83
+ // places it at 66.7%.
78
84
  it.each([
79
85
  ["at the playhead, timing array steps on their own timeline", undefined],
80
- ["selected in the lane, in place", 50],
86
+ ["selected in the lane, in place", 66.7],
81
87
  ])("changes the middle array keyframe %s", (_, selectedPct) => {
82
88
  const { plan, shown } = dragAndReplay(
83
89
  script("keyframes: [{ x: 60 }, { x: 120 }, { x: 180 }], duration: 3"),
@@ -244,6 +250,90 @@ it("writes an edit into a looping tween and keeps its repeat and yoyo, as main d
244
250
  expect(written).toMatch(/repeat: 1[\s\S]*yoyo: true/);
245
251
  });
246
252
 
253
+ it("keeps a delayed step list where GSAP plays it, before and after a keyframe edit", () => {
254
+ const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
255
+ const stepList = `var tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: [{ x: 60 }, { x: 120 }], ease: "none" }, 1);\nwindow.__timelines["t"] = tl;`;
256
+ const id = parseGsapScriptAcorn(stepList).animations[0]!.id;
257
+ const edited = syncPositionHoldsBeforeKeyframes(
258
+ updateKeyframeInScript(stepList, id, 100, { x: 130 }),
259
+ );
260
+ const xAt = (file: string, at: number) => {
261
+ const { timeline } = play(file, at);
262
+ const x = gsap.getProperty(box, "x");
263
+ timeline.kill();
264
+ gsap.set(box, { clearProps: "all" });
265
+ return x;
266
+ };
267
+ for (const file of [stepList, edited]) {
268
+ expect([xAt(file, 0.5), xAt(file, 1.25)]).toEqual([0, 30]);
269
+ const tween = parseGsapScriptAcorn(file).animations.find((a) => a.keyframes)!;
270
+ for (const { percentage, properties } of tween.keyframes!.keyframes) {
271
+ expect(xAt(file, 1 + percentage / 100)).toBeCloseTo(Number(properties.x), 3);
272
+ }
273
+ }
274
+ });
275
+
276
+ it.each([
277
+ [
278
+ "an outer power2.in",
279
+ `keyframes: { "0%": { x: 0 }, "50%": { x: 60 }, "100%": { x: 120 } }, duration: 1, ease: "power2.in"`,
280
+ ],
281
+ ["no duration", `keyframes: [{ x: 60 }, { x: 120 }], ease: "none"`],
282
+ ])("draws each diamond of a keyframed tween with %s where GSAP shows its value", (_, vars) => {
283
+ const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
284
+ const file = `var tl = gsap.timeline({ paused: true });\ntl.to("#x", { ${vars} }, 1);\nwindow.__timelines["t"] = tl;`;
285
+ const tween = parseGsapScriptAcornAnimation(file);
286
+ for (const row of toClipKeyframes(tween.keyframes!.keyframes, tween, 0, 4)) {
287
+ const { timeline } = play(file, (row.percentage / 100) * 4);
288
+ expect(gsap.getProperty(box, "x")).toBeCloseTo(Number(row.properties.x), 1);
289
+ timeline.kill();
290
+ gsap.set(box, { clearProps: "all" });
291
+ }
292
+ });
293
+
294
+ function parseGsapScriptAcornAnimation(file: string) {
295
+ return parseGsapScriptAcorn(file).animations.find((a) => a.keyframes)!;
296
+ }
297
+
298
+ it("keeps a step list's steps where GSAP plays them when an outer duration stretches it", () => {
299
+ const { plan, written } = dragAndReplay(
300
+ script(`keyframes: [{ x: 100 }, { x: 200 }], duration: 2, ease: "none"`),
301
+ 70,
302
+ 0.5,
303
+ );
304
+ expect(plan.ok).toBe(true);
305
+ const box = document.getElementById("x")!;
306
+ const xAt = (at: number) => {
307
+ const { timeline } = play(written!, at);
308
+ const x = gsap.getProperty(box, "x");
309
+ timeline.kill();
310
+ return x;
311
+ };
312
+ expect([xAt(0.5), xAt(1), xAt(2)]).toEqual([70, 100, 200]);
313
+ });
314
+
315
+ it("adds a keyframe where GSAP plays a tween whose duration is an expression, and never rewrites the expression", async () => {
316
+ const src = `var dur = () => 2;\nvar tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: { "0%": { x: 0 }, "100%": { x: 300 } }, duration: dur() }, 1);\nwindow.__timelines["t"] = tl;`;
317
+ const box = document.body.appendChild(document.createElement("div"));
318
+ box.id = "x";
319
+ const { timeline, iframe } = play(src, 2);
320
+ const anim = parseGsapScriptAcorn(src).animations[0]!;
321
+ const writes: Array<[string, unknown]> = [];
322
+ const session = {
323
+ commitMutation: async (mutation: unknown) => void writes.push(["replace", mutation]),
324
+ handleGsapRemoveKeyframe: () => void writes.push(["remove", null]),
325
+ handleGsapAddKeyframeBatch: async (_id: string, pct: number) => void writes.push(["add", pct]),
326
+ } as unknown as EnableKeyframesSession;
327
+ const selection = { id: "x", selector: "#x", element: box } as DomEditSelection;
328
+
329
+ await applyKeyframeAtPlayhead(session, selection, anim, 2, iframe);
330
+ await applyKeyframeAtPlayhead(session, selection, anim, 3.5, iframe);
331
+ timeline.kill();
332
+
333
+ expect(anim.durationUnresolved).toBe(true);
334
+ expect(writes).toEqual([["add", 50]]);
335
+ });
336
+
247
337
  it("leaves a step list with a runBackwards step to the runtime: a rewrite would play it differently", async () => {
248
338
  const flagged = script(
249
339
  "keyframes: [{ x: 60, duration: 1, runBackwards: true }, { x: 180, duration: 1 }], ease: 'none'",