@hyperframes/studio 0.7.76 → 0.7.78

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 (138) hide show
  1. package/dist/assets/{hyperframes-player-DsRdxz7A.js → hyperframes-player-uiAAPKvw.js} +1 -1
  2. package/dist/assets/index-BSZrK0bx.js +428 -0
  3. package/dist/assets/{index-Du1RoLiB.js → index-CDSTMtUs.js} +1 -1
  4. package/dist/assets/index-DpkO2Hvw.css +1 -0
  5. package/dist/assets/{index-BLICKger.js → index-r1tKKlU7.js} +1 -1
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +25030 -23214
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
  38. package/src/components/sidebar/CompositionsTab.tsx +66 -32
  39. package/src/contexts/TimelineEditContext.tsx +1 -1
  40. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  41. package/src/hooks/gsapDragCommit.ts +2 -2
  42. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  43. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  44. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  45. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  46. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  47. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  48. package/src/hooks/gsapShared.test.ts +109 -1
  49. package/src/hooks/gsapShared.ts +142 -17
  50. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  51. package/src/hooks/gsapTweenSynth.ts +61 -14
  52. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  53. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  54. package/src/hooks/timelineMoveAdapter.ts +9 -2
  55. package/src/hooks/useDomEditWiring.ts +2 -2
  56. package/src/hooks/useDomSelection.ts +4 -0
  57. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  58. package/src/hooks/useEnableKeyframes.ts +116 -56
  59. package/src/hooks/useGestureCommit.ts +2 -2
  60. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  61. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  62. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  63. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  64. package/src/hooks/useGsapScriptCommits.ts +2 -0
  65. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  66. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  67. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  68. package/src/hooks/useGsapTweenCache.ts +64 -178
  69. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  70. package/src/hooks/useStudioContextValue.test.ts +93 -0
  71. package/src/hooks/useStudioContextValue.ts +17 -3
  72. package/src/hooks/useStudioTestHooks.ts +63 -0
  73. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  74. package/src/hooks/useTimelineEditing.ts +43 -32
  75. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  77. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  78. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  79. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  80. package/src/player/components/Player.test.ts +143 -12
  81. package/src/player/components/Player.tsx +84 -43
  82. package/src/player/components/Timeline.test.ts +420 -90
  83. package/src/player/components/Timeline.tsx +80 -78
  84. package/src/player/components/TimelineCanvas.tsx +33 -21
  85. package/src/player/components/TimelineClip.tsx +3 -1
  86. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  87. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  88. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  89. package/src/player/components/TimelineLanes.tsx +153 -79
  90. package/src/player/components/TimelineOverlays.tsx +4 -18
  91. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  92. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  93. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  94. package/src/player/components/TimelineRuler.tsx +11 -21
  95. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  96. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  97. package/src/player/components/TrackClipCount.tsx +17 -0
  98. package/src/player/components/timelineCallbacks.ts +21 -6
  99. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  100. package/src/player/components/timelineClipDragPreview.ts +33 -15
  101. package/src/player/components/timelineCollision.ts +4 -13
  102. package/src/player/components/timelineDiamondTypes.ts +120 -0
  103. package/src/player/components/timelineDragDrop.ts +8 -3
  104. package/src/player/components/timelineEditing.test.ts +19 -27
  105. package/src/player/components/timelineEditing.ts +7 -8
  106. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  107. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  108. package/src/player/components/timelineLayout.test.ts +162 -9
  109. package/src/player/components/timelineLayout.ts +198 -38
  110. package/src/player/components/timelineMarquee.test.ts +76 -21
  111. package/src/player/components/timelineMarquee.ts +17 -14
  112. package/src/player/components/trackHeaderLaneState.ts +121 -0
  113. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  114. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  115. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  116. package/src/player/components/useTimelineClipDrag.ts +4 -1
  117. package/src/player/components/useTimelineGeometry.ts +4 -1
  118. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  119. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  120. package/src/player/components/useTimelinePlayhead.ts +21 -12
  121. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  122. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  123. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  124. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  125. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  126. package/src/player/lib/timelineElementHelpers.ts +26 -11
  127. package/src/player/store/keyframeSlice.ts +122 -0
  128. package/src/player/store/playerStore.test.ts +25 -0
  129. package/src/player/store/playerStore.ts +7 -43
  130. package/src/telemetry/events.test.ts +9 -3
  131. package/src/telemetry/events.ts +16 -7
  132. package/src/utils/globalTimeCompiler.test.ts +10 -0
  133. package/src/utils/globalTimeCompiler.ts +3 -3
  134. package/dist/assets/index-B37rWXo4.css +0 -1
  135. package/dist/assets/index-BdOfFsR8.js +0 -428
  136. package/src/player/components/TimelineDragGhost.tsx +0 -59
  137. package/src/utils/keyframeSelection.test.ts +0 -45
  138. package/src/utils/keyframeSelection.ts +0 -29
@@ -2,6 +2,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
  import { usePlayerStore } from "../player/store/playerStore";
4
4
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
5
+ import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
5
6
  import { roundTo3 } from "../utils/rounding";
6
7
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
7
8
  import {
@@ -11,6 +12,31 @@ import {
11
12
  materializeIfDynamic,
12
13
  } from "./gsapDragCommit";
13
14
 
15
+ /**
16
+ * The tween's keyframes with one inserted at `percentage`. Any existing keyframe
17
+ * within {@link KEYFRAME_PCT_MATCH} of the insert is REPLACED, not kept: the
18
+ * server takes a replace-with-keyframes list verbatim, so an append-only build
19
+ * could hand it two keyframes a fraction of a percent apart. The invariant lives
20
+ * here rather than in each caller's own pre-check, which is how the two callers
21
+ * ended up with different tolerances in the first place.
22
+ */
23
+ export function buildTemporalArcKeyframes(
24
+ anim: GsapAnimation,
25
+ percentage: number,
26
+ properties: Record<string, number>,
27
+ ) {
28
+ return [
29
+ ...(anim.keyframes?.keyframes ?? [])
30
+ .filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH)
31
+ .map((keyframe) => ({
32
+ percentage: keyframe.percentage,
33
+ properties: { ...keyframe.properties },
34
+ ...(keyframe.ease ? { ease: keyframe.ease } : {}),
35
+ })),
36
+ { percentage, properties },
37
+ ].sort((a, b) => a.percentage - b.percentage);
38
+ }
39
+
14
40
  async function extendTweenAndAddKeyframe(
15
41
  selection: DomEditSelection,
16
42
  anim: GsapAnimation,
@@ -143,7 +169,7 @@ async function commitFlatViaKeyframes(
143
169
  ): Promise<void> {
144
170
  const ct = usePlayerStore.getState().currentTime;
145
171
  const ts = resolveTweenStart(anim);
146
- const td = resolveTweenDuration(anim);
172
+ const td = resolveEditableTweenDuration(anim, selection);
147
173
  const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
148
174
  const outsideRange =
149
175
  activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
@@ -184,8 +210,13 @@ async function commitFlatViaKeyframes(
184
210
  { label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
185
211
  );
186
212
  const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
213
+ // By id first: a target with several tweens (two `to`s on the same selector)
214
+ // matches the selector lookup on whichever one happens to be first, and the
215
+ // extend-and-add below would then rewrite a tween the drag never touched.
187
216
  const converted =
188
- fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ?? anim;
217
+ fresh.find((a) => a.id === anim.id && a.keyframes) ??
218
+ fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ??
219
+ anim;
189
220
  const convertedStart = resolveTweenStart(converted) ?? ts;
190
221
  const convertedDur = resolveTweenDuration(converted) || td;
191
222
  await extendTweenAndAddKeyframe(
@@ -259,13 +290,61 @@ export async function commitGsapPositionFromDrag(
259
290
 
260
291
  const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
261
292
  const ct = usePlayerStore.getState().currentTime;
293
+ if (anim.arcPath?.enabled) {
294
+ const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
295
+ const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
296
+ const keyframes = anim.keyframes?.keyframes ?? [];
297
+ // Same tolerance as applyArcKeyframeAtPlayhead and isMotionPathEndpoint. A
298
+ // tighter one here meant a drag that landed a fraction of a percent off an
299
+ // authored waypoint skipped the update-point branch and appended instead.
300
+ const pointIndex = keyframes.findIndex(
301
+ (kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH,
302
+ );
303
+ if (pointIndex >= 0) {
304
+ await callbacks.commitMutation(
305
+ selection,
306
+ {
307
+ type: "update-motion-path-point",
308
+ animationId: anim.id,
309
+ pointIndex,
310
+ x: newX,
311
+ y: newY,
312
+ },
313
+ { label: "Move layer (waypoint)", softReload: true, beforeReload: restoreOffset },
314
+ );
315
+ setActiveKeyframePct(null);
316
+ parkPlayheadOnKeyframe(anim, pct);
317
+ return;
318
+ }
319
+
320
+ const tweenStart = resolveTweenStart(anim);
321
+ // Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
322
+ // default here would collapse a duration-less arc's window on drag.
323
+ const tweenDuration = resolveEditableTweenDuration(anim, selection);
324
+ if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
325
+ const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
326
+ await callbacks.commitMutation(
327
+ selection,
328
+ {
329
+ type: "replace-with-keyframes",
330
+ animationId: anim.id,
331
+ targetSelector: anim.targetSelector,
332
+ position: roundTo3(tweenStart),
333
+ duration: roundTo3(tweenDuration),
334
+ keyframes: temporalKeyframes,
335
+ ease: "none",
336
+ },
337
+ { label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset },
338
+ );
339
+ return;
340
+ }
262
341
  if (anim.keyframes) {
263
342
  const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
264
343
  const effectiveAnim = newId ? { ...anim, id: newId } : anim;
265
344
  const dragProps: Record<string, number> = { x: newX, y: newY };
266
345
 
267
346
  const ts = resolveTweenStart(effectiveAnim);
268
- const td = resolveTweenDuration(effectiveAnim);
347
+ const td = resolveEditableTweenDuration(effectiveAnim, selection);
269
348
  const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
270
349
  const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
271
350
  if (outsideRange && !hasSelectedKeyframe) {
@@ -293,7 +372,7 @@ export async function commitGsapPositionFromDrag(
293
372
  } else if (anim.method === "from" || anim.method === "fromTo") {
294
373
  const ct = usePlayerStore.getState().currentTime;
295
374
  const ts = resolveTweenStart(anim);
296
- const td = resolveTweenDuration(anim);
375
+ const td = resolveEditableTweenDuration(anim, selection);
297
376
  const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
298
377
  const outsideRange =
299
378
  !hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
@@ -313,7 +392,7 @@ export async function commitGsapPositionFromDrag(
313
392
 
314
393
  if (existingPosAnim?.keyframes) {
315
394
  const posTs = resolveTweenStart(existingPosAnim);
316
- const posTd = resolveTweenDuration(existingPosAnim);
395
+ const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
317
396
  if (posTs !== null) {
318
397
  await extendTweenAndAddKeyframe(
319
398
  selection,
@@ -4,6 +4,7 @@ import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerS
4
4
  import {
5
5
  clearKeyframeCacheForElement,
6
6
  clearKeyframeCacheForFile,
7
+ pruneKeyframeCacheToFiles,
7
8
  updateKeyframeCacheFromParsed,
8
9
  } from "./gsapKeyframeCacheHelpers";
9
10
 
@@ -28,7 +29,7 @@ const animWithKeyframes = (id: string): GsapAnimation => ({
28
29
  });
29
30
 
30
31
  beforeEach(() => {
31
- usePlayerStore.setState({ keyframeCache: new Map(), elements: [] });
32
+ usePlayerStore.setState({ keyframeCache: new Map(), gsapAnimations: new Map(), elements: [] });
32
33
  });
33
34
 
34
35
  describe("clearKeyframeCacheForElement", () => {
@@ -84,6 +85,20 @@ describe("clearKeyframeCacheForFile", () => {
84
85
  }
85
86
  });
86
87
 
88
+ // Several composition files re-scan concurrently, so a clear that walked the
89
+ // index.html alias would delete rows a sibling file had just written.
90
+ it("leaves an index.html-owned element alone when another file re-scans", () => {
91
+ seed("index.html#title");
92
+ seed("title");
93
+ seed("comp.html#a");
94
+
95
+ clearKeyframeCacheForFile("comp.html");
96
+
97
+ expect(cache().has("index.html#title")).toBe(true);
98
+ expect(cache().has("title")).toBe(true);
99
+ expect(cache().has("comp.html#a")).toBe(false);
100
+ });
101
+
87
102
  it("leaves entries that belong to a different source file", () => {
88
103
  seed("comp.html#a");
89
104
  seed("a");
@@ -97,7 +112,84 @@ describe("clearKeyframeCacheForFile", () => {
97
112
  });
98
113
  });
99
114
 
115
+ describe("pruneKeyframeCacheToFiles", () => {
116
+ // Switching composition leaves the previous comp's elements cached with no
117
+ // owner left to clear them: each file only ever clears its own entries.
118
+ it("drops every element of a file the next scan no longer covers", () => {
119
+ seed("index.html#stress-1");
120
+ seed("stress-1");
121
+ seed("index.html#stress-2");
122
+ seed("stress-2");
123
+ seed("kf200.html#kf200");
124
+ seed("index.html#kf200");
125
+ seed("kf200");
126
+
127
+ pruneKeyframeCacheToFiles(["kf200.html"]);
128
+
129
+ for (const key of ["index.html#stress-1", "stress-1", "index.html#stress-2", "stress-2"]) {
130
+ expect(cache().has(key)).toBe(false);
131
+ }
132
+ expect(cache().has("kf200.html#kf200")).toBe(true);
133
+ });
134
+
135
+ it("prunes gsapAnimations alongside keyframeCache", () => {
136
+ usePlayerStore.getState().setGsapAnimations("index.html#stress-1", [animWithKeyframes("t")]);
137
+ usePlayerStore.getState().setGsapAnimations("kf200.html#kf200", [animWithKeyframes("u")]);
138
+
139
+ pruneKeyframeCacheToFiles(["kf200.html"]);
140
+
141
+ const animations = usePlayerStore.getState().gsapAnimations;
142
+ expect(animations.has("index.html#stress-1")).toBe(false);
143
+ expect(animations.has("kf200.html#kf200")).toBe(true);
144
+ });
145
+
146
+ it("keeps everything when every cached file is still covered", () => {
147
+ seed("index.html#hero");
148
+ seed("comp.html#a");
149
+
150
+ pruneKeyframeCacheToFiles(["index.html", "comp.html"]);
151
+
152
+ expect(cache().has("index.html#hero")).toBe(true);
153
+ expect(cache().has("comp.html#a")).toBe(true);
154
+ });
155
+ });
156
+
100
157
  describe("updateKeyframeCacheFromParsed", () => {
158
+ it("serializes a multi-keyframe tween with a stable shape and animation identity", () => {
159
+ const animation: GsapAnimation = {
160
+ ...animWithKeyframes("hero"),
161
+ duration: 2,
162
+ resolvedStart: 3,
163
+ keyframes: {
164
+ format: "percentage",
165
+ keyframes: [
166
+ { percentage: 0, properties: { x: 0 } },
167
+ { percentage: 50, properties: { x: 100 }, ease: "power1.inOut" },
168
+ { percentage: 100, properties: { x: 200 } },
169
+ ],
170
+ easeEach: "power1.inOut",
171
+ },
172
+ };
173
+ usePlayerStore.setState({
174
+ elements: [
175
+ {
176
+ id: "hero-clip",
177
+ domId: "hero",
178
+ tag: "div",
179
+ start: 2,
180
+ duration: 4,
181
+ track: 0,
182
+ },
183
+ ],
184
+ });
185
+
186
+ updateKeyframeCacheFromParsed([animation], "scene.html", "hero", {});
187
+
188
+ expect(JSON.stringify(cache().get("scene.html#hero"))).toBe(
189
+ '{"format":"percentage","keyframes":[{"percentage":25,"properties":{"x":0},"tweenPercentage":0,"propertyGroup":"position","animationId":"hero"},{"percentage":50,"properties":{"x":100},"ease":"power1.inOut","tweenPercentage":50,"propertyGroup":"position","animationId":"hero"},{"percentage":75,"properties":{"x":200},"tweenPercentage":100,"propertyGroup":"position","animationId":"hero"}],"easeEach":"power1.inOut"}',
190
+ );
191
+ });
192
+
101
193
  it("clears the bare key when the selected element no longer has keyframes", () => {
102
194
  // Element previously had keyframes, so a bare entry exists (writes set both).
103
195
  seed("index.html#box");
@@ -118,4 +210,87 @@ describe("updateKeyframeCacheFromParsed", () => {
118
210
  expect(cache().has("index.html#hero")).toBe(true);
119
211
  expect(cache().has("hero")).toBe(true);
120
212
  });
213
+
214
+ it("caches flat tweens as clip-relative start and end keyframes", () => {
215
+ const animation: GsapAnimation = {
216
+ id: "flat-box",
217
+ targetSelector: "#box",
218
+ method: "to",
219
+ position: 1,
220
+ properties: { x: 420 },
221
+ duration: 2,
222
+ resolvedStart: 1,
223
+ ease: "power2.out",
224
+ propertyGroup: "position",
225
+ };
226
+ usePlayerStore.setState({
227
+ elements: [{ id: "box-clip", domId: "box", tag: "div", start: 1, duration: 2, track: 0 }],
228
+ });
229
+
230
+ updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
231
+
232
+ expect(cache().get("scene.html#box")).toEqual({
233
+ format: "percentage",
234
+ keyframes: [
235
+ {
236
+ percentage: 0,
237
+ properties: { x: 0 },
238
+ tweenPercentage: 0,
239
+ propertyGroup: "position",
240
+ animationId: "flat-box",
241
+ },
242
+ {
243
+ percentage: 100,
244
+ properties: { x: 420 },
245
+ ease: "power2.out",
246
+ tweenPercentage: 100,
247
+ propertyGroup: "position",
248
+ animationId: "flat-box",
249
+ },
250
+ ],
251
+ });
252
+ expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
253
+ });
254
+
255
+ it("records an ungrouped tween in gsapAnimations too, so the two stores agree", () => {
256
+ // `{ x, opacity }` spans two property groups, so the parser leaves
257
+ // propertyGroup undefined. Skipping it here used to cache diamonds with no
258
+ // source animation behind them: the collapsed row drew keyframes the
259
+ // expanded lanes could not render.
260
+ const animation: GsapAnimation = {
261
+ id: "mixed-box",
262
+ targetSelector: "#box",
263
+ method: "to",
264
+ position: 0,
265
+ properties: { x: 100, opacity: 0 },
266
+ duration: 1,
267
+ resolvedStart: 0,
268
+ };
269
+ usePlayerStore.setState({
270
+ elements: [{ id: "box-clip", domId: "box", tag: "div", start: 0, duration: 1, track: 0 }],
271
+ });
272
+
273
+ updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
274
+
275
+ expect(cache().has("scene.html#box")).toBe(true);
276
+ expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
277
+ });
278
+
279
+ it("does not cache a flat tween without animatable numeric properties", () => {
280
+ const animation: GsapAnimation = {
281
+ id: "flat-box",
282
+ targetSelector: "#box",
283
+ method: "to",
284
+ position: 0,
285
+ properties: { backgroundColor: "#fff" },
286
+ duration: 1,
287
+ propertyGroup: "visual",
288
+ };
289
+
290
+ updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
291
+
292
+ expect(cache().has("scene.html#box")).toBe(false);
293
+ expect(cache().has("box")).toBe(false);
294
+ expect(usePlayerStore.getState().gsapAnimations.has("scene.html#box")).toBe(false);
295
+ });
121
296
  });
@@ -4,7 +4,8 @@
4
4
  */
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
- import { toAbsoluteTime } from "./gsapShared";
7
+ import { idFromSelector, toClipKeyframes } from "./gsapShared";
8
+ import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
8
9
 
9
10
  export function updateKeyframeCacheFromParsed(
10
11
  animations: GsapAnimation[],
@@ -15,57 +16,52 @@ export function updateKeyframeCacheFromParsed(
15
16
  const { setKeyframeCache, elements } = usePlayerStore.getState();
16
17
  const idsWithKeyframes = new Set<string>();
17
18
  const merged = new Map<string, KeyframeCacheEntry>();
19
+ const sourceAnimations = new Map<string, GsapAnimation[]>();
18
20
  for (const anim of animations) {
19
- const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
20
- if (!id || !anim.keyframes) continue;
21
+ const id = idFromSelector(anim.targetSelector);
22
+ const kfSource =
23
+ anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? [];
24
+ if (!id || kfSource.length === 0) continue;
21
25
  idsWithKeyframes.add(id);
26
+ // Every tween that fed keyframeCache also lands in gsapAnimations, group or
27
+ // not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to
28
+ // cache diamonds with no source animation behind them, so the collapsed row
29
+ // drew keyframes the expanded lanes couldn't render. Lane consumers do the
30
+ // group filtering themselves (animationContributesLane).
31
+ sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
22
32
 
23
33
  // Convert tween-relative percentages to clip-relative so diamonds
24
34
  // render at the correct position within the timeline clip.
25
- const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
26
- const tweenDur = anim.duration ?? 1;
27
35
  const timelineEl = elements.find(
28
36
  (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
29
37
  );
30
- const elStart = timelineEl?.start ?? 0;
31
- const elDuration = timelineEl?.duration ?? 1;
32
- const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
33
- const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
34
- const clipPct =
35
- elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
36
- return {
37
- ...kf,
38
- percentage: clipPct,
39
- tweenPercentage: kf.percentage,
40
- propertyGroup: anim.propertyGroup,
41
- };
42
- });
38
+ const clipKeyframes = toClipKeyframes(
39
+ kfSource,
40
+ anim,
41
+ timelineEl?.start ?? 0,
42
+ timelineEl?.duration ?? 1,
43
+ );
43
44
 
44
45
  const existing = merged.get(id);
45
46
  if (existing) {
46
- const byPct = new Map<number, (typeof existing.keyframes)[0]>();
47
- for (const kf of [...existing.keyframes, ...clipKeyframes]) {
48
- const prev = byPct.get(kf.percentage);
49
- if (prev) {
50
- prev.properties = { ...prev.properties, ...kf.properties };
51
- if (kf.ease) prev.ease = kf.ease;
52
- } else {
53
- byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
54
- }
55
- }
56
- existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
47
+ // deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
48
+ // flag downstream lanes read); a second copy of that rule here is how the
49
+ // two writers drift.
50
+ existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
57
51
  } else {
58
- merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
52
+ merged.set(id, {
53
+ ...anim.keyframes,
54
+ format: anim.keyframes?.format ?? "percentage",
55
+ keyframes: clipKeyframes,
56
+ });
59
57
  }
60
58
  }
61
59
  for (const [id, entry] of merged) {
62
- setKeyframeCache(`${targetPath}#${id}`, entry);
63
- setKeyframeCache(id, entry);
64
- if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
60
+ for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
61
+ writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
65
62
  }
66
63
  const targetId =
67
- (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
68
- selectionId;
64
+ idFromSelector((mutation as { targetSelector?: string }).targetSelector) ?? selectionId;
69
65
  if (targetId && !idsWithKeyframes.has(targetId)) {
70
66
  clearKeyframeCacheForElement(targetPath, targetId);
71
67
  }
@@ -84,40 +80,85 @@ export function updateKeyframeCacheFromParsed(
84
80
  * a new cache map and re-render every subscriber.
85
81
  */
86
82
  export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
87
- const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
88
- const keys =
89
- sourceFile === "index.html"
90
- ? [`index.html#${elementId}`, elementId]
91
- : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
83
+ const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } =
84
+ usePlayerStore.getState();
85
+ const keys = elementCacheKeys(sourceFile, elementId);
92
86
  for (const key of keys) {
93
87
  if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
88
+ if (gsapAnimations.has(key)) setGsapAnimations(key, undefined);
94
89
  }
95
90
  }
96
91
 
97
92
  /**
98
93
  * Clear every cached element of `sourceFile` before a full re-scan repopulates
99
- * it. Collects the element ids that currently have a prefixed or index.html
100
- * fallback key for the file and drops each through clearKeyframeCacheForElement
101
- * so the bare key goes too an element whose keyframes were removed (and so is
102
- * absent from the re-scan) leaves no stale bare entry behind.
94
+ * it. Only the file's OWN prefixed keys name the ids to clear: every write sets
95
+ * the prefixed key (see elementCacheKeys), so the file's elements are all
96
+ * reachable that way, and clearKeyframeCacheForElement then takes the
97
+ * index.html alias and the bare key with them an element whose keyframes were
98
+ * removed (and so is absent from the re-scan) leaves no stale bare entry
99
+ * behind. Reading the alias prefix here instead would collect ids owned by
100
+ * OTHER files, and several files re-scan concurrently, so this file's clear
101
+ * would wipe the entries a sibling file had just written.
103
102
  */
104
103
  export function clearKeyframeCacheForFile(sourceFile: string): void {
105
- const { keyframeCache } = usePlayerStore.getState();
104
+ const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
106
105
  const sfPrefix = `${sourceFile}#`;
107
- const fallbackPrefix = "index.html#";
108
106
  const ids = new Set<string>();
109
- for (const key of keyframeCache.keys()) {
110
- const matchesFile =
111
- key.startsWith(sfPrefix) || (sourceFile !== "index.html" && key.startsWith(fallbackPrefix));
112
- if (!matchesFile) continue;
113
- const hashIdx = key.indexOf("#");
114
- if (hashIdx !== -1) ids.add(key.slice(hashIdx + 1));
107
+ for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
108
+ if (!key.startsWith(sfPrefix)) continue;
109
+ ids.add(key.slice(sfPrefix.length));
115
110
  }
116
111
  for (const id of ids) {
117
112
  clearKeyframeCacheForElement(sourceFile, id);
118
113
  }
119
114
  }
120
115
 
116
+ /**
117
+ * Drop every cached element owned by a file that is no longer on screen. Each
118
+ * file only ever clears its OWN entries (see clearKeyframeCacheForFile), so
119
+ * switching composition left the previous composition's elements cached forever
120
+ * — 240 entries per switch on a 120-clip comp, in both keyframeCache and
121
+ * gsapAnimations, with nothing to evict them. Called once before a re-scan, with
122
+ * the full set of files that scan covers.
123
+ */
124
+ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
125
+ const keep = new Set(files);
126
+ const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
127
+ const stale = new Map<string, Set<string>>();
128
+ for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
129
+ const hash = key.indexOf("#");
130
+ // Bare-id aliases carry no owner; clearKeyframeCacheForElement takes them
131
+ // with their prefixed key, so skipping them here loses nothing.
132
+ if (hash < 0) continue;
133
+ const sourceFile = key.slice(0, hash);
134
+ if (keep.has(sourceFile)) continue;
135
+ const ids = stale.get(sourceFile) ?? new Set<string>();
136
+ ids.add(key.slice(hash + 1));
137
+ stale.set(sourceFile, ids);
138
+ }
139
+ for (const [sourceFile, ids] of stale) {
140
+ for (const id of ids) clearKeyframeCacheForElement(sourceFile, id);
141
+ }
142
+ }
143
+
144
+ /** Every cache key a write for this element sets, in read-preference order. */
145
+ export function elementCacheKeys(sourceFile: string, elementId: string): string[] {
146
+ return sourceFile === "index.html"
147
+ ? [`index.html#${elementId}`, elementId]
148
+ : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
149
+ }
150
+
151
+ export function writeGsapAnimationsForElement(
152
+ sourceFile: string,
153
+ elementId: string,
154
+ animations: GsapAnimation[] | undefined,
155
+ ): void {
156
+ const { setGsapAnimations } = usePlayerStore.getState();
157
+ for (const key of elementCacheKeys(sourceFile, elementId)) {
158
+ setGsapAnimations(key, animations);
159
+ }
160
+ }
161
+
121
162
  function buildCacheKey(sourceFile: string, elementId: string): string {
122
163
  return `${sourceFile}#${elementId}`;
123
164
  }
@@ -280,3 +280,103 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
280
280
  expect(types).not.toContain("replace-with-keyframes");
281
281
  });
282
282
  });
283
+
284
+ describe("tryGsapDragIntercept — motion paths", () => {
285
+ const motionPathAnim = {
286
+ id: "#puck-b-to-12170-position",
287
+ targetSelector: "#puck-b",
288
+ propertyGroup: "position",
289
+ method: "to",
290
+ position: 12.17,
291
+ resolvedStart: 12.17,
292
+ duration: 16.055,
293
+ ease: "power1.inOut",
294
+ properties: {},
295
+ keyframes: {
296
+ keyframes: [
297
+ { percentage: 0, properties: { x: -184, y: 326 } },
298
+ { percentage: 50, properties: { x: 416, y: 804 } },
299
+ { percentage: 100, properties: { x: 796, y: 237 } },
300
+ ],
301
+ },
302
+ arcPath: {
303
+ enabled: true,
304
+ autoRotate: false,
305
+ segments: [{ curviness: 1 }, { curviness: 1 }],
306
+ },
307
+ } as unknown as GsapAnimation;
308
+ const liveTween = {
309
+ targets: () => [{ id: "puck-b" }],
310
+ vars: { motionPath: { path: [] }, duration: 16.055 },
311
+ duration: () => 16.055,
312
+ startTime: () => 12.17,
313
+ };
314
+
315
+ async function dragMotionPath(activeKeyframePct: number | null) {
316
+ usePlayerStore.setState({
317
+ autoKeyframeEnabled: true,
318
+ activeKeyframePct,
319
+ currentTime: 15.9,
320
+ });
321
+ const commitMutation = vi.fn();
322
+ const handled = await tryGsapDragIntercept(
323
+ selection,
324
+ { x: -50, y: 30 },
325
+ [motionPathAnim],
326
+ fakeIframe("puck-b", [liveTween]),
327
+ commitMutation,
328
+ );
329
+ return { commitMutation, handled };
330
+ }
331
+
332
+ afterEach(() => {
333
+ usePlayerStore.setState({ activeKeyframePct: null });
334
+ });
335
+
336
+ it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
337
+ const { commitMutation, handled } = await dragMotionPath(null);
338
+
339
+ expect(handled).toBe(true);
340
+ expect(commitMutation).toHaveBeenCalledWith(
341
+ selection,
342
+ {
343
+ type: "replace-with-keyframes",
344
+ animationId: motionPathAnim.id,
345
+ targetSelector: "#puck-b",
346
+ position: 12.17,
347
+ duration: 16.055,
348
+ keyframes: [
349
+ { percentage: 0, properties: { x: -184, y: 326 } },
350
+ { percentage: 23.233, properties: { x: -50, y: 30 } },
351
+ { percentage: 50, properties: { x: 416, y: 804 } },
352
+ { percentage: 100, properties: { x: 796, y: 237 } },
353
+ ],
354
+ ease: "none",
355
+ },
356
+ expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }),
357
+ );
358
+ expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
359
+ "add-motion-path-point",
360
+ );
361
+ });
362
+
363
+ it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
364
+ const { commitMutation, handled } = await dragMotionPath(50);
365
+
366
+ expect(handled).toBe(true);
367
+ expect(commitMutation).toHaveBeenCalledWith(
368
+ selection,
369
+ {
370
+ type: "update-motion-path-point",
371
+ animationId: motionPathAnim.id,
372
+ pointIndex: 1,
373
+ x: -50,
374
+ y: 30,
375
+ },
376
+ expect.objectContaining({ label: "Move layer (waypoint)", softReload: true }),
377
+ );
378
+ expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
379
+ "replace-with-keyframes",
380
+ );
381
+ });
382
+ });
@@ -2,12 +2,13 @@ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mu
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
 
4
4
  export { PROPERTY_DEFAULTS } from "./gsapShared";
5
+ import { idSelector } from "./gsapShared";
5
6
 
6
7
  export function ensureElementAddressable(selection: DomEditSelection): {
7
8
  selector: string;
8
9
  autoId?: string;
9
10
  } {
10
- if (selection.id) return { selector: `#${selection.id}` };
11
+ if (selection.id) return { selector: idSelector(selection.id) };
11
12
  if (selection.selector) return { selector: selection.selector };
12
13
 
13
14
  const el = selection.element;
@@ -20,7 +21,7 @@ export function ensureElementAddressable(selection: DomEditSelection): {
20
21
  id = `${tag}-${n}`;
21
22
  }
22
23
  el.setAttribute("id", id);
23
- return { selector: `#${id}`, autoId: id };
24
+ return { selector: idSelector(id), autoId: id };
24
25
  }
25
26
 
26
27
  export class GsapMutationHttpError extends Error {