@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
@@ -1,8 +1,13 @@
1
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
1
+ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
  import { usePlayerStore } from "../player/store/playerStore";
4
- import { resolveTweenStart } from "../utils/globalTimeCompiler";
5
- import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
4
+ import {
5
+ isTimeWithinTween,
6
+ resolveTweenDuration,
7
+ resolveTweenStart,
8
+ } from "../utils/globalTimeCompiler";
9
+ import { KEYFRAME_PCT_MATCH, playsNear } from "./gsapShared";
10
+ import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
6
11
  import { roundTo3 } from "../utils/rounding";
7
12
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
8
13
  import {
@@ -19,7 +24,8 @@ import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
19
24
  * server takes a replace-with-keyframes list verbatim, so an append-only build
20
25
  * could hand it two keyframes a fraction of a percent apart. The invariant lives
21
26
  * here rather than in each caller's own pre-check, which is how the two callers
22
- * ended up with different tolerances in the first place.
27
+ * ended up with different tolerances in the first place. Arc writes are linear, so each
28
+ * keyframe moves to the time GSAP reached it and `percentage` is a time percentage.
23
29
  */
24
30
  export function buildTemporalArcKeyframes(
25
31
  anim: GsapAnimation,
@@ -27,7 +33,7 @@ export function buildTemporalArcKeyframes(
27
33
  properties: Record<string, number>,
28
34
  ) {
29
35
  return [
30
- ...(anim.keyframes?.keyframes ?? [])
36
+ ...linearArcKeyframes(anim)
31
37
  .filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH)
32
38
  .map((keyframe) => ({
33
39
  percentage: keyframe.percentage,
@@ -38,6 +44,46 @@ export function buildTemporalArcKeyframes(
38
44
  ].sort((a, b) => a.percentage - b.percentage);
39
45
  }
40
46
 
47
+ /** An arc's keyframes at the time percentages GSAP plays them, for a write with `ease: "none"`. */
48
+ export function linearArcKeyframes(anim: GsapAnimation): GsapPercentageKeyframe[] {
49
+ const runEase = runEaseOf(anim);
50
+ return (anim.keyframes?.keyframes ?? []).map((keyframe) => ({
51
+ ...keyframe,
52
+ percentage: roundTo3(timeAtProgress(runEase, keyframe.percentage)),
53
+ }));
54
+ }
55
+
56
+ /** Grow a keyframe tween's range to a playhead outside it and add a keyframe there; every
57
+ * existing keyframe keeps its absolute time under `writeEase`, the run ease the write keeps. */
58
+ export function buildExtendedKeyframes(
59
+ anim: GsapAnimation,
60
+ currentTime: number,
61
+ position: Record<string, number>,
62
+ sourceDuration = resolveTweenDuration(anim),
63
+ writeEase = runEaseOf(anim),
64
+ ): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
65
+ const oldStart = resolveTweenStart(anim) ?? 0;
66
+ const oldDuration = sourceDuration;
67
+ const newStart = Math.min(oldStart, currentTime);
68
+ const newEnd = Math.max(oldStart + oldDuration, currentTime);
69
+ const newDuration = roundTo3(newEnd - newStart);
70
+ const oldRunEase = runEaseOf(anim);
71
+ const toPct = (absoluteTime: number) => {
72
+ if (newDuration <= 0) return 0;
73
+ const progress = progressAtTime(writeEase, ((absoluteTime - newStart) / newDuration) * 100);
74
+ return Math.max(0, Math.min(100, Math.round(progress * 10) / 10));
75
+ };
76
+ const stops = anim.keyframes?.keyframes ?? [];
77
+ const rescaled: GsapPercentageKeyframe[] = stops.map((stop) => ({
78
+ percentage: toPct(oldStart + (timeAtProgress(oldRunEase, stop.percentage) / 100) * oldDuration),
79
+ properties: stop.properties,
80
+ ...(stop.ease ? { ease: stop.ease } : {}),
81
+ }));
82
+ const added: GsapPercentageKeyframe = { percentage: toPct(currentTime), properties: position };
83
+ const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage);
84
+ return { position: roundTo3(newStart), duration: newDuration, keyframes };
85
+ }
86
+
41
87
  /** commitGsapPositionFromDrag's refusal, decided without writing. */
42
88
  export function gsapPositionFromDragOutcome(
43
89
  selection: DomEditSelection,
@@ -87,15 +133,45 @@ export async function commitGsapPositionFromDrag(
87
133
  };
88
134
 
89
135
  if (anim.arcPath?.enabled) {
90
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
136
+ const { activeKeyframePct, currentTime, setActiveKeyframePct } = usePlayerStore.getState();
137
+ const tweenStart = resolveTweenStart(anim);
138
+ const tweenDuration = resolveTweenDuration(anim);
139
+ if (
140
+ activeKeyframePct === null &&
141
+ tweenStart !== null &&
142
+ !isTimeWithinTween(currentTime, tweenStart, tweenDuration)
143
+ ) {
144
+ const extended = buildExtendedKeyframes(
145
+ anim,
146
+ currentTime,
147
+ { x: newX, y: newY },
148
+ tweenDuration,
149
+ "none",
150
+ );
151
+ await callbacks.commitMutation(
152
+ selection,
153
+ {
154
+ type: "replace-with-keyframes",
155
+ animationId: anim.id,
156
+ targetSelector: anim.targetSelector,
157
+ ...extended,
158
+ ease: "none",
159
+ },
160
+ {
161
+ label: "Move layer (new keyframe)",
162
+ keyframeAction: "add",
163
+ softReload: true,
164
+ beforeReload: restoreOffset,
165
+ },
166
+ );
167
+ return { status: "persisted" };
168
+ }
91
169
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
92
170
  const keyframes = anim.keyframes?.keyframes ?? [];
93
- // Same tolerance as applyArcKeyframeAtPlayhead and isMotionPathEndpoint. A
94
- // tighter one here meant a drag that landed a fraction of a percent off an
95
- // authored waypoint skipped the update-point branch and appended instead.
96
- const pointIndex = keyframes.findIndex(
97
- (kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH,
98
- );
171
+ // A drag counts as on a waypoint when it plays within KEYFRAME_PCT_MATCH of it,
172
+ // so landing a fraction of a percent off an authored waypoint updates that point
173
+ // instead of appending a new one.
174
+ const pointIndex = keyframes.findIndex((kf) => playsNear(anim, kf.percentage, pct));
99
175
  if (pointIndex >= 0) {
100
176
  await callbacks.commitMutation(
101
177
  selection,
@@ -113,13 +189,10 @@ export async function commitGsapPositionFromDrag(
113
189
  return { status: "persisted" };
114
190
  }
115
191
 
116
- const tweenStart = resolveTweenStart(anim);
117
- // Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
118
- // default here would collapse a duration-less arc's window on drag.
119
- const tweenDuration = resolveEditableTweenDuration(anim, selection);
120
192
  if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2)
121
193
  return { status: "persisted" };
122
- const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
194
+ const timePct = roundTo3(timeAtProgress(runEaseOf(anim), pct));
195
+ const temporalKeyframes = buildTemporalArcKeyframes(anim, timePct, { x: newX, y: newY });
123
196
  await callbacks.commitMutation(
124
197
  selection,
125
198
  {
@@ -78,6 +78,56 @@ describe("non-string cache keys", () => {
78
78
  });
79
79
  });
80
80
 
81
+ describe("replaceKeyframeCacheForFile re-reads", () => {
82
+ it("publishes nothing when a file re-reads to the same entries, and the change when one differs", () => {
83
+ const write = (x: number) =>
84
+ replaceKeyframeCacheForFile(
85
+ "comp.html",
86
+ new Map([
87
+ ["box", { format: "percentage", keyframes: [{ percentage: 0, properties: { x } }] }],
88
+ ]),
89
+ new Map([["box", [animWithKeyframes("box")]]]),
90
+ );
91
+ write(0);
92
+ let notified = 0;
93
+ const stop = usePlayerStore.subscribe(() => notified++);
94
+ write(0);
95
+ expect(notified).toBe(0);
96
+ write(5);
97
+ stop();
98
+ expect(notified).toBe(1);
99
+ expect(cache().get("comp.html#box")?.keyframes[0]?.properties).toEqual({ x: 5 });
100
+ });
101
+
102
+ it("leaves a sub-composition's entries alone when index.html re-reads", () => {
103
+ const one = (id: string) => new Map([[id, entry()]]);
104
+ const anims = (id: string) => new Map([[id, [animWithKeyframes(id)]]]);
105
+ replaceKeyframeCacheForFile("comp.html", one("box"), anims("box"));
106
+ replaceKeyframeCacheForFile("index.html", one("hero"), anims("hero"));
107
+ let notified = 0;
108
+ const stop = usePlayerStore.subscribe(() => notified++);
109
+ replaceKeyframeCacheForFile("index.html", one("hero"), anims("hero"));
110
+ stop();
111
+
112
+ expect(notified).toBe(0);
113
+ for (const key of ["comp.html#box", "index.html#box", "box", "index.html#hero", "hero"]) {
114
+ expect(cache().has(key)).toBe(true);
115
+ }
116
+ });
117
+
118
+ it("still drops index.html's own element that shares an id with a sub-composition", () => {
119
+ const one = (id: string) => new Map([[id, entry()]]);
120
+ const anims = (id: string) => new Map([[id, [animWithKeyframes(id)]]]);
121
+ const own = { format: "percentage", keyframes: [{ percentage: 0, properties: { x: 9 } }] };
122
+ replaceKeyframeCacheForFile("comp.html", one("title"), anims("title"));
123
+ replaceKeyframeCacheForFile("index.html", new Map([["title", own]]), anims("title"));
124
+ replaceKeyframeCacheForFile("index.html", new Map(), new Map());
125
+
126
+ expect(cache().has("index.html#title")).toBe(false);
127
+ expect(cache().has("comp.html#title")).toBe(true);
128
+ });
129
+ });
130
+
81
131
  describe("clearKeyframeCacheForElement", () => {
82
132
  it("drops the prefixed, index.html fallback, and bare key for a non-index source", () => {
83
133
  seed("comp.html#box");
@@ -5,6 +5,7 @@
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
7
  import { trackStudioEvent } from "../utils/studioTelemetry";
8
+ import { sameData } from "../utils/sameData";
8
9
  import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
9
10
  import {
10
11
  deduplicateKeyframes,
@@ -18,6 +19,14 @@ export interface KeyframeCacheDraft {
18
19
  gsapAnimations: Map<string, GsapAnimation[]>;
19
20
  }
20
21
 
22
+ // A rebuilt entry equal to the one shown keeps its object, so re-reading an unchanged file publishes nothing.
23
+ function keepEqualEntries<V>(before: ReadonlyMap<string, V>, draft: Map<string, V>): void {
24
+ for (const [key, value] of draft) {
25
+ const shown = before.get(key);
26
+ if (shown !== undefined && shown !== value && sameData(shown, value)) draft.set(key, shown);
27
+ }
28
+ }
29
+
21
30
  function sameEntries<V>(before: ReadonlyMap<string, V>, after: ReadonlyMap<string, V>): boolean {
22
31
  if (before.size !== after.size) return false;
23
32
  for (const [key, value] of before) {
@@ -42,6 +51,8 @@ export function publishKeyframeCache(edit: (draft: KeyframeCacheDraft) => void):
42
51
  gsapAnimations: new Map(gsapAnimations),
43
52
  };
44
53
  edit(draft);
54
+ keepEqualEntries(keyframeCache, draft.keyframeCache);
55
+ keepEqualEntries(gsapAnimations, draft.gsapAnimations);
45
56
  if (
46
57
  sameEntries(keyframeCache, draft.keyframeCache) &&
47
58
  sameEntries(gsapAnimations, draft.gsapAnimations)
@@ -175,8 +186,16 @@ function cachedElementIdsForFile(
175
186
  const sfPrefix = `${sourceFile}#`;
176
187
  const ids = new Set<string>();
177
188
  for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
178
- if (!key.startsWith(sfPrefix)) continue;
179
- ids.add(key.slice(sfPrefix.length));
189
+ if (key.startsWith(sfPrefix)) ids.add(key.slice(sfPrefix.length));
190
+ }
191
+ if (sourceFile !== "index.html") return ids;
192
+ // Another file's element also writes the `index.html#id` fallback, as the very same entry object.
193
+ for (const [key, entry] of keyframeCache) {
194
+ const hash = key.indexOf("#");
195
+ const id = key.slice(hash + 1);
196
+ if (hash > 0 && !key.startsWith(sfPrefix) && keyframeCache.get(sfPrefix + id) === entry) {
197
+ ids.delete(id);
198
+ }
180
199
  }
181
200
  return ids;
182
201
  }
@@ -88,9 +88,11 @@ function readMutationError(value: unknown, fallback: string): string {
88
88
  return fallback;
89
89
  }
90
90
 
91
+ type GsapMutationRoute = "gsap-mutations" | "gsap-mutations-batch";
92
+
91
93
  export function requestGsapMutation(
92
94
  projectId: string,
93
- route: "gsap-mutations" | "gsap-mutations-batch",
95
+ route: GsapMutationRoute,
94
96
  filePath: string,
95
97
  body: unknown,
96
98
  ): Promise<Response> {
@@ -104,23 +106,49 @@ export function requestGsapMutation(
104
106
  );
105
107
  }
106
108
 
107
- export async function postGsapMutation(
109
+ async function postOwnedGsapMutation(
110
+ route: GsapMutationRoute,
108
111
  projectId: string,
109
112
  filePath: string,
110
- mutation: Record<string, unknown>,
113
+ body: Record<string, unknown>,
111
114
  fallback: string,
112
115
  ): Promise<GsapMutationStatus> {
113
116
  let response: Response;
114
117
  try {
115
- response = await requestGsapMutation(projectId, "gsap-mutations", filePath, mutation);
118
+ response = await requestGsapMutation(projectId, route, filePath, body);
116
119
  } catch (error) {
117
120
  throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
118
121
  cause: error,
119
122
  });
120
123
  }
121
- const body: unknown = await response.json().catch(() => null);
124
+ const payload: unknown = await response.json().catch(() => null);
122
125
  if (!response.ok) {
123
- throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
126
+ throw new GsapPreviewConvergenceError(readMutationError(payload, fallback));
124
127
  }
125
- return readMutationStatus(body);
128
+ return readMutationStatus(payload);
129
+ }
130
+
131
+ export function postGsapMutation(
132
+ projectId: string,
133
+ filePath: string,
134
+ mutation: Record<string, unknown>,
135
+ fallback: string,
136
+ ): Promise<GsapMutationStatus> {
137
+ return postOwnedGsapMutation("gsap-mutations", projectId, filePath, mutation, fallback);
138
+ }
139
+
140
+ /** Every mutation in one request: the file is parsed and written once, with one ownership pair. */
141
+ export function postGsapMutations(
142
+ projectId: string,
143
+ filePath: string,
144
+ mutations: readonly Record<string, unknown>[],
145
+ fallback: string,
146
+ ): Promise<GsapMutationStatus> {
147
+ return postOwnedGsapMutation(
148
+ "gsap-mutations-batch",
149
+ projectId,
150
+ filePath,
151
+ { mutations },
152
+ fallback,
153
+ );
126
154
  }
@@ -0,0 +1,67 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
3
+ import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
4
+ import { describe, expect, it } from "vitest";
5
+ import { resolveKeyframeRetime } from "../components/editor/keyframeRetime";
6
+ import { absoluteToPercentageForAnimation } from "../utils/globalTimeCompiler";
7
+ import { runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
8
+ import { buildExtendedKeyframes } from "./gsapDragPositionCommit";
9
+ import { keyframeEases } from "./gsapShared";
10
+
11
+ const script = (ease: string) =>
12
+ `const tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: { "0%": { x: 0 }, "50%": { x: 600 }, "100%": { x: 1200 } }, duration: 2, ease: "${ease}" }, 1);`;
13
+
14
+ const percentages = (anim: GsapAnimation) => anim.keyframes!.keyframes.map((k) => k.percentage);
15
+ const distinctInRange = (pcts: number[]) =>
16
+ pcts.every((p, i) => p >= 0 && p <= 100 && (i === 0 || p > pcts[i - 1]!));
17
+
18
+ describe.each(["back.out(1.7)", "back.in(1.7)", "elastic.out(1, 0.3)", "steps(4)"])(
19
+ "an outer %s, which does not map time to progress one to one",
20
+ (ease) => {
21
+ const src = script(ease);
22
+ const anim = parseGsapScriptAcorn(src).animations[0]!;
23
+
24
+ it("adding a keyframe past the end keeps every keyframe, distinct and in range, after a reparse", () => {
25
+ const out = buildExtendedKeyframes(anim, 5, { x: 1500 });
26
+ const written = replaceTweenWithKeyframesInScript(src, anim.id, {
27
+ targetSelector: "#x",
28
+ ...keyframeEases(anim),
29
+ ...out,
30
+ })!;
31
+ const back = parseGsapScriptAcorn(written).animations[0]!;
32
+ expect(distinctInRange(percentages(back))).toBe(true);
33
+ expect(back.keyframes!.keyframes.map((k) => k.properties.x)).toEqual([0, 600, 1200, 1500]);
34
+ });
35
+
36
+ it("a keyframe added inside the tween lands within 0-100%", () => {
37
+ const pct = absoluteToPercentageForAnimation(1 + 0.7 * 2, anim)!;
38
+ expect(pct).toBeGreaterThanOrEqual(0);
39
+ expect(pct).toBeLessThanOrEqual(100);
40
+ });
41
+
42
+ it("dragging the last keyframe later keeps every keyframe distinct and in range", () => {
43
+ const retime = resolveKeyframeRetime({
44
+ keyframes: anim.keyframes!.keyframes,
45
+ draggedTweenPct: 100,
46
+ tweenStart: 1,
47
+ tweenDuration: 2,
48
+ dropAbsTime: 4,
49
+ runEase: runEaseOf(anim),
50
+ });
51
+ expect(retime.kind).toBe("resize");
52
+ expect(distinctInRange(retime.pctRemap!.map((r) => r.to))).toBe(true);
53
+ });
54
+ },
55
+ );
56
+
57
+ it("extending a tween under steps(4) loses no keyframe", () => {
58
+ const src = `const tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: { "0%": { x: 0 }, "25%": { x: 60 }, "60%": { x: 120 }, "100%": { x: 30 } }, duration: 2, ease: "steps(4)" }, 1);`;
59
+ const anim = parseGsapScriptAcorn(src).animations[0]!;
60
+ const out = buildExtendedKeyframes(anim, 4, { x: 999 });
61
+ expect(distinctInRange(out.keyframes.map((k) => k.percentage))).toBe(true);
62
+ expect(out.keyframes.map((k) => k.properties.x)).toEqual([0, 60, 120, 30, 999]);
63
+ });
64
+
65
+ it("still times keyframes through an outer ease that rises one to one", () => {
66
+ expect(timeAtProgress("power2.in", 50)).toBeCloseTo(79.37, 1);
67
+ });
@@ -247,14 +247,13 @@ const BUILT_IN_EASES = [
247
247
  export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
248
248
  const data = anim.keyframes;
249
249
  const parts = tween?.timeline?.getChildren?.() ?? [];
250
- const total = tween?.timeline?.duration?.() ?? 0;
251
- if (data?.format !== "object-array" || parts.length !== data.keyframes.length || !(total > 0))
252
- return anim;
253
- const ends = parts.map((part) => ((part.startTime?.() ?? 0) + (part.duration?.() ?? 0)) / total);
254
- if (ends.some((end) => !Number.isFinite(end))) return anim;
250
+ if (data?.format !== "object-array" || parts.length !== data.keyframes.length) return anim;
251
+ const ends = parts.map((part) => (part.startTime?.() ?? 0) + (part.duration?.() ?? 0));
252
+ const total = Math.max(...ends);
253
+ if (!(total > 0) || ends.some((end) => !Number.isFinite(end))) return anim;
255
254
  const keyframes = data.keyframes.map((kf, i) => ({
256
255
  ...kf,
257
- percentage: Math.round(ends[i]! * 100000) / 1000,
256
+ percentage: Math.round((ends[i]! / total) * 100000) / 1000,
258
257
  }));
259
258
  return { ...anim, keyframes: { ...data, keyframes } };
260
259
  }
@@ -8,6 +8,7 @@
8
8
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
9
9
  import { usePlayerStore } from "../player/store/playerStore";
10
10
  import { getIframeGsap, KEYFRAME_PCT_MATCH, queryIframeElement } from "./gsapShared";
11
+ import { runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
11
12
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
12
13
 
13
14
  // fallow-ignore-next-line complexity
@@ -60,7 +61,7 @@ export function findGsapPositionAnimation(
60
61
 
61
62
  function playheadProximity(a: GsapAnimation, currentTime: number): number {
62
63
  const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
63
- const dur = a.duration ?? 0;
64
+ const dur = resolveTweenDuration(a);
64
65
  if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) return 50;
65
66
  return -Math.round(Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5);
66
67
  }
@@ -71,7 +72,9 @@ function statesValueAt(anim: GsapAnimation, time: number): boolean {
71
72
  if (!(duration > 0)) return false;
72
73
  const pct = ((time - (resolveTweenStart(anim) ?? 0)) / duration) * 100;
73
74
  const near = (keyframe: number) => Math.abs(keyframe - pct) <= KEYFRAME_PCT_MATCH;
74
- if (anim.keyframes) return anim.keyframes.keyframes.some((kf) => near(kf.percentage));
75
+ const runEase = runEaseOf(anim);
76
+ if (anim.keyframes)
77
+ return anim.keyframes.keyframes.some((kf) => near(timeAtProgress(runEase, kf.percentage)));
75
78
  if (anim.method === "from") return near(0);
76
79
  return near(100) || (anim.method === "fromTo" && near(0));
77
80
  }
@@ -582,6 +582,41 @@ describe("tryGsapDragIntercept — motion paths", () => {
582
582
  );
583
583
  });
584
584
 
585
+ it("adds a node at a playhead past a duration-less arc, keeping the other nodes' times", async () => {
586
+ usePlayerStore.setState({
587
+ autoKeyframeEnabled: true,
588
+ activeKeyframePct: null,
589
+ currentTime: 15.9,
590
+ });
591
+ const durationless = { ...motionPathAnim, duration: undefined } as GsapAnimation;
592
+ const commitMutation = vi.fn();
593
+ await tryGsapDragIntercept(
594
+ selection,
595
+ { x: -50, y: 30 },
596
+ [durationless],
597
+ fakeIframe("puck-b", [
598
+ { ...liveTween, vars: { motionPath: { path: [] } }, duration: () => 0.5 },
599
+ ]),
600
+ commitMutation,
601
+ );
602
+ // GSAP plays it 12.17-12.67 s; the nodes stay at 12.17, 12.42 and 12.67 s.
603
+ expect(commitMutation).toHaveBeenCalledWith(
604
+ selection,
605
+ expect.objectContaining({
606
+ type: "replace-with-keyframes",
607
+ position: 12.17,
608
+ duration: 3.73,
609
+ keyframes: [
610
+ { percentage: 0, properties: { x: -184, y: 326 } },
611
+ { percentage: 6.7, properties: { x: 416, y: 804 } },
612
+ { percentage: 13.4, properties: { x: 796, y: 237 } },
613
+ { percentage: 100, properties: { x: -50, y: 30 } },
614
+ ],
615
+ }),
616
+ expect.objectContaining({ label: "Move layer (new keyframe)", keyframeAction: "add" }),
617
+ );
618
+ });
619
+
585
620
  it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
586
621
  const { commitMutation, handled } = await dragMotionPath(50);
587
622
 
@@ -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
+ });