@hyperframes/studio 0.7.75 → 0.7.77

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 (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  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/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. package/src/utils/keyframeSelection.ts +0 -29
@@ -53,14 +53,87 @@ export function isInstantHold(animation: GsapAnimation): boolean {
53
53
  * Returns `#id` if the selection has an id, otherwise the raw selector,
54
54
  * or null if neither exists.
55
55
  */
56
+ /**
57
+ * A CSS-valid selector for an element id. `#id` for a valid CSS identifier,
58
+ * otherwise an `[id="..."]` attribute selector. IDs that start with a digit
59
+ * (e.g. "01-hook-hero-word") make `#id` an invalid selector, so
60
+ * `document.querySelector("#01-...")` / GSAP's `querySelectorAll` throw a
61
+ * SyntaxError — which surfaces as a masked cross-origin "Script error." and
62
+ * crashes the preview the moment such a target is committed (e.g. dragging).
63
+ */
64
+ // Conservative: matches only ids that are unquestionably safe as a `#id`
65
+ // selector — ASCII identifier, starts with a letter/underscore (or a single
66
+ // leading hyphen), no dots/colons/spaces/digits-first. Anything it rejects
67
+ // (digit-leading like "01-hook-...", dots, spaces, non-ASCII, …) falls through
68
+ // to the attribute selector below, which is always valid. It can only ever err
69
+ // toward the safe form, never toward a `#id` that throws — and, unlike
70
+ // `CSS.escape`, it needs no browser global (this runs in node tests too).
71
+ const SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/;
72
+
73
+ /**
74
+ * How close (in tween-%) a playhead has to be to count as sitting ON an existing
75
+ * keyframe. Every "is there already a keyframe here?" test shares this: with two
76
+ * different tolerances in play, one path decided "no keyframe here, append one"
77
+ * while another decided "yes, edit that one", and a drag near a waypoint left two
78
+ * keyframes a fraction of a percent apart.
79
+ */
80
+ export const KEYFRAME_PCT_MATCH = 1;
81
+
82
+ export function idSelector(id: string): string {
83
+ // A `#id` selector is only valid for a CSS identifier. IDs that start with a
84
+ // digit (e.g. "01-hook-hero-word") make `document.querySelector("#01-...")` and
85
+ // GSAP's `querySelectorAll` throw a SyntaxError — surfacing as a masked
86
+ // cross-origin "Script error." that crashes the preview the moment such a
87
+ // target is committed (e.g. dragging the element). Address those via an
88
+ // attribute selector instead (quotes/backslashes escaped for the string).
89
+ return SAFE_HASH_ID.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
90
+ }
91
+
92
+ /**
93
+ * Inverse of {@link idSelector}: the element id a target selector addresses, or
94
+ * null for a selector that is not id-based (a class, a tag, a descendant path).
95
+ *
96
+ * Both shapes have to be read back, not just `#id`. Every writer emits through
97
+ * `idSelector`, so a digit-leading, dotted or otherwise CSS-unsafe id lands in
98
+ * the source as `[id="01-hook-hero"]`. A reader that only matched `#id` saw no
99
+ * id at all for those elements and skipped them — which is how the post-commit
100
+ * keyframe-cache refresh silently stopped running for exactly the ids
101
+ * `idSelector` was added to support.
102
+ */
103
+ export function idFromSelector(selector: string | undefined | null): string | null {
104
+ if (!selector) return null;
105
+ const hash = selector.match(/^#([\w-]+)/);
106
+ if (hash) return hash[1] ?? null;
107
+ const attribute = selector.match(/^\[id="((?:\\.|[^"\\])*)"\]/);
108
+ if (!attribute) return null;
109
+ // Undo the quote/backslash escaping idSelector applies.
110
+ return (attribute[1] ?? "").replace(/\\(["\\])/g, "$1");
111
+ }
112
+
56
113
  export function selectorFromSelection(selection: DomEditSelection): string | null {
57
- if (selection.id) return `#${selection.id}`;
114
+ if (selection.id) return idSelector(selection.id);
58
115
  if (selection.selector) return selection.selector;
59
116
  return null;
60
117
  }
61
118
 
62
119
  // ── Percentage computation ────────────────────────────────────────────────────
63
120
 
121
+ /**
122
+ * Resolve the timing basis used by editor keyframes. The timeline renders a
123
+ * duration-less tween across its owning clip, so mutations must use that same
124
+ * duration instead of silently falling back to GSAP's 0.5s default.
125
+ */
126
+ export function resolveEditableTweenDuration(
127
+ animation: GsapAnimation,
128
+ selection: DomEditSelection,
129
+ ): number {
130
+ const clipDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "");
131
+ return resolveTweenDuration(
132
+ animation,
133
+ Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 0.5,
134
+ );
135
+ }
136
+
64
137
  /**
65
138
  * Compute the current playback percentage within an element's animation range.
66
139
  * Uses the animation's resolved timing if available, otherwise falls back to
@@ -73,7 +146,7 @@ export function computeElementPercentage(
73
146
  ): number {
74
147
  if (animation) {
75
148
  const start = resolveTweenStart(animation);
76
- const duration = resolveTweenDuration(animation);
149
+ const duration = resolveEditableTweenDuration(animation, selection);
77
150
  if (duration <= 0) return 0;
78
151
  if (start !== null) {
79
152
  return absoluteToPercentage(currentTime, start, duration);
@@ -81,9 +154,7 @@ export function computeElementPercentage(
81
154
  }
82
155
  const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
83
156
  const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
84
- return elDuration > 0
85
- ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
86
- : 0;
157
+ return absoluteToPercentage(currentTime, elStart, elDuration);
87
158
  }
88
159
 
89
160
  // ── Iframe accessors ──────────────────────────────────────────────────────────
@@ -118,6 +189,16 @@ export interface ParsedPercentageKeyframes {
118
189
  easeEach?: string;
119
190
  }
120
191
 
192
+ function collectAnimatableKeyframeProperties(entry: object): Record<string, number | string> {
193
+ const properties: Record<string, number | string> = {};
194
+ for (const [property, value] of Object.entries(entry)) {
195
+ if (property === "ease") continue;
196
+ if (typeof value === "number") properties[property] = Math.round(value * 1000) / 1000;
197
+ else if (typeof value === "string") properties[property] = value;
198
+ }
199
+ return properties;
200
+ }
201
+
121
202
  /**
122
203
  * Parse a GSAP percentage-keyframe object (`{ "0%": { x: 10 }, "100%": { x: 200 } }`)
123
204
  * into a sorted array of `{ percentage, properties }` entries.
@@ -146,12 +227,7 @@ export function parsePercentageKeyframes(
146
227
  steps.forEach((entry, i) => {
147
228
  if (!entry || typeof entry !== "object") return;
148
229
  const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
149
- const properties: Record<string, number | string> = {};
150
- for (const [pk, pv] of Object.entries(entry as Record<string, unknown>)) {
151
- if (pk === "ease") continue;
152
- if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
153
- else if (typeof pv === "string") properties[pk] = pv;
154
- }
230
+ const properties = collectAnimatableKeyframeProperties(entry);
155
231
  if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties });
156
232
  });
157
233
  return keyframes.length > 0 ? { keyframes } : null;
@@ -165,12 +241,7 @@ export function parsePercentageKeyframes(
165
241
  const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
166
242
  if (!pctMatch || !val || typeof val !== "object") continue;
167
243
  const percentage = parseFloat(pctMatch[1]);
168
- const properties: Record<string, number | string> = {};
169
- for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
170
- if (pk === "ease") continue;
171
- if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
172
- else if (typeof pv === "string") properties[pk] = pv;
173
- }
244
+ const properties = collectAnimatableKeyframeProperties(val);
174
245
  if (Object.keys(properties).length > 0) {
175
246
  keyframes.push({ percentage, properties });
176
247
  }
@@ -187,3 +258,57 @@ export function parsePercentageKeyframes(
187
258
  export function toAbsoluteTime(tweenPos: number, tweenDur: number, percentage: number): number {
188
259
  return tweenPos + (percentage / 100) * tweenDur;
189
260
  }
261
+
262
+ /**
263
+ * An absolute time as a percentage of a timeline clip, at the one precision every
264
+ * keyframe-cache writer must share. 0.001% keeps a beat-snapped keyframe centered
265
+ * on the beat dot, and because selection keys embed this number, a writer that
266
+ * rounds coarser would orphan a live selection the moment it rewrites the cache.
267
+ * A zero-length clip has no percentage to give, so the tween-% passes through.
268
+ */
269
+ export function toClipPercentage(
270
+ absoluteTime: number,
271
+ clipStart: number,
272
+ clipDuration: number,
273
+ fallbackPercentage: number,
274
+ ): number {
275
+ if (clipDuration <= 0) return fallbackPercentage;
276
+ return Math.round(((absoluteTime - clipStart) / clipDuration) * 100000) / 1000;
277
+ }
278
+
279
+ /**
280
+ * One keyframe-cache row per tween keyframe: the percentage re-based onto the
281
+ * clip, the original tween percentage kept alongside it, and the animation
282
+ * identity every lane and selection key needs. Shared by the cache writers so
283
+ * they cannot drift in precision or in which identity fields they record.
284
+ */
285
+ export function toClipKeyframes<T extends { percentage: number }>(
286
+ source: readonly T[],
287
+ anim: GsapAnimation,
288
+ clipStart: number,
289
+ clipDuration: number,
290
+ ): Array<
291
+ T & {
292
+ tweenPercentage: number;
293
+ propertyGroup: GsapAnimation["propertyGroup"];
294
+ animationId: string;
295
+ }
296
+ > {
297
+ const tweenStart = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
298
+ // A duration-less tween spans the clip, the same rule the edit paths use
299
+ // (resolveEditableTweenDuration). A fixed 1s here put its keyframes at a
300
+ // percentage no editor agreed with.
301
+ const tweenDuration = anim.duration ?? clipDuration;
302
+ return source.map((keyframe) => ({
303
+ ...keyframe,
304
+ percentage: toClipPercentage(
305
+ toAbsoluteTime(tweenStart, tweenDuration, keyframe.percentage),
306
+ clipStart,
307
+ clipDuration,
308
+ keyframe.percentage,
309
+ ),
310
+ tweenPercentage: keyframe.percentage,
311
+ propertyGroup: anim.propertyGroup,
312
+ animationId: anim.id,
313
+ }));
314
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
- import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
3
+ import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
4
4
 
5
5
  function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
6
6
  return {
@@ -53,3 +53,32 @@ describe("synthesizeFlatTweenKeyframes", () => {
53
53
  expect(out).not.toBeNull();
54
54
  });
55
55
  });
56
+
57
+ describe("deduplicateKeyframes ease ambiguity", () => {
58
+ it("flags a same-% collision from different animations (different eases)", () => {
59
+ const merged = deduplicateKeyframes([
60
+ { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
61
+ { percentage: 45, properties: { opacity: 1 }, ease: "power2.out", animationId: "#a-visual" },
62
+ ]);
63
+ const kf = merged.find((k) => k.percentage === 45);
64
+ expect(kf?.easeAmbiguous).toBe(true);
65
+ });
66
+
67
+ it("flags a cross-animation collision even when the raw eases match", () => {
68
+ // The button can still only target one arbitrary animation, and each may
69
+ // inherit a different easeEach/animation ease that raw comparison misses.
70
+ const merged = deduplicateKeyframes([
71
+ { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
72
+ { percentage: 45, properties: { opacity: 1 }, ease: "power2.in", animationId: "#a-visual" },
73
+ ]);
74
+ expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true);
75
+ });
76
+
77
+ it("does not flag a same-% collision within a single animation", () => {
78
+ const merged = deduplicateKeyframes([
79
+ { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
80
+ { percentage: 45, properties: { y: 20 }, ease: "power2.out", animationId: "#a-position" },
81
+ ]);
82
+ expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBeFalsy();
83
+ });
84
+ });
@@ -5,15 +5,51 @@ import type {
5
5
  } from "@hyperframes/core/gsap-parser";
6
6
  import { PROPERTY_DEFAULTS } from "./gsapShared";
7
7
 
8
- export function deduplicateKeyframes(
9
- keyframes: GsapPercentageKeyframe[],
10
- ): GsapPercentageKeyframe[] {
11
- const byPct = new Map<number, GsapPercentageKeyframe>();
8
+ /**
9
+ * A static position hold (only x/y, no real motion) is a `set`, not a keyframe —
10
+ * it must not synthesize a diamond. Covers both `tl.set(...)` and the
11
+ * `tl.to({ duration: 0, immediateRender: true })` hold that remove-all-keyframes
12
+ * collapses to (otherwise shown as a stray 0% keyframe).
13
+ *
14
+ * Single owner: the collapsed keyframe cache and the expanded property lanes'
15
+ * `gsapAnimations` map MUST agree on it, or a hold draws a phantom expanded lane
16
+ * with no matching collapsed diamond.
17
+ */
18
+ export function isStaticPositionHold(anim: GsapAnimation): boolean {
19
+ if (anim.keyframes) return false;
20
+ if (anim.method !== "set" && (anim.duration ?? 0) !== 0) return false;
21
+ const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender");
22
+ return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y");
23
+ }
24
+
25
+ export function deduplicateKeyframes<
26
+ T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean },
27
+ >(keyframes: T[]): T[] {
28
+ const byPct = new Map<number, T>();
12
29
  for (const kf of keyframes) {
13
30
  const existing = byPct.get(kf.percentage);
14
31
  if (existing) {
15
32
  existing.properties = { ...existing.properties, ...kf.properties };
16
- if (kf.ease) existing.ease = kf.ease;
33
+ // Two DIFFERENT source animations with a keyframe at the same clip %: a
34
+ // single inline ease button can only target one of them, and which one is
35
+ // arbitrary (each may also inherit a different easeEach/animation ease, so
36
+ // comparing raw keyframe eases isn't enough). Flag it so the collapsed row
37
+ // hides the button there and the user edits per-lane instead.
38
+ if (
39
+ existing.animationId !== undefined &&
40
+ kf.animationId !== undefined &&
41
+ existing.animationId !== kf.animationId
42
+ ) {
43
+ existing.easeAmbiguous = true;
44
+ }
45
+ // Whichever tween iterated last used to win `ease`, so the merged
46
+ // keyframe carried an arbitrary one of the colliding curves. Readers that
47
+ // do not check easeAmbiguous (drag readouts, lane hints) then showed a
48
+ // curve belonging to a different animation than the one an edit targets.
49
+ // Drop it instead: ambiguous means "no single ease", and the flag is the
50
+ // only honest answer.
51
+ if (existing.easeAmbiguous) delete existing.ease;
52
+ else if (kf.ease) existing.ease = kf.ease;
17
53
  } else {
18
54
  byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
19
55
  }
@@ -41,29 +77,40 @@ export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframes
41
77
  const fromProps = anim.fromProperties;
42
78
  if (!toProps || Object.keys(toProps).length === 0) return null;
43
79
 
44
- const startProps: Record<string, number | string> = {};
45
- const endProps: Record<string, number | string> = {};
80
+ const rawStart: Record<string, number | string> = {};
81
+ const rawEnd: Record<string, number | string> = {};
46
82
 
47
83
  if (anim.method === "from") {
48
84
  for (const [k, v] of Object.entries(toProps)) {
49
- startProps[k] = v;
50
- endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
85
+ rawStart[k] = v;
86
+ rawEnd[k] = PROPERTY_DEFAULTS[k] ?? 0;
51
87
  }
52
88
  } else if (anim.method === "fromTo" && fromProps) {
53
- Object.assign(startProps, fromProps);
54
- Object.assign(endProps, toProps);
89
+ Object.assign(rawStart, fromProps);
90
+ Object.assign(rawEnd, toProps);
55
91
  } else {
56
92
  for (const [k, v] of Object.entries(toProps)) {
57
- startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
58
- endProps[k] = v;
93
+ rawStart[k] = PROPERTY_DEFAULTS[k] ?? 0;
94
+ rawEnd[k] = v;
59
95
  }
60
96
  }
61
97
 
98
+ // Only numeric props are keyframe-interpolatable — a flat tween of a
99
+ // non-numeric prop (e.g. backgroundColor: "#fff") can't be a 2-keyframe lane.
100
+ const numericKeys = Object.keys(rawEnd).filter(
101
+ (k) => typeof rawStart[k] === "number" && typeof rawEnd[k] === "number",
102
+ );
103
+ if (numericKeys.length === 0) return null;
104
+ const startProps = Object.fromEntries(numericKeys.map((k) => [k, rawStart[k]]));
105
+ const endProps = Object.fromEntries(numericKeys.map((k) => [k, rawEnd[k]]));
106
+
62
107
  return {
63
108
  format: "percentage",
64
109
  keyframes: [
65
110
  { percentage: 0, properties: startProps },
66
- { percentage: 100, properties: endProps },
111
+ // Segment ease lives on the destination keyframe (Figma/AE model) so the
112
+ // lane + cache surface it; also kept data-level for useGsapTweenCache.
113
+ { percentage: 100, properties: endProps, ...(anim.ease ? { ease: anim.ease } : {}) },
67
114
  ],
68
115
  ...(anim.ease ? { ease: anim.ease } : {}),
69
116
  };
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Reading a composition file's GSAP tweens into the keyframe cache: fetch,
3
+ * selector -> element id resolution, and the clip-relative timing basis.
4
+ * Split from useGsapTweenCache to keep that file under the 600-line limit.
5
+ */
6
+ import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
7
+ import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
8
+ import { usePlayerStore } from "../player/store/playerStore";
9
+ import {
10
+ clearKeyframeCacheForFile,
11
+ elementCacheKeys,
12
+ writeGsapAnimationsForElement,
13
+ } from "./gsapKeyframeCacheHelpers";
14
+ import { idFromSelector, toClipKeyframes } from "./gsapShared";
15
+ import {
16
+ deduplicateKeyframes,
17
+ isStaticPositionHold,
18
+ synthesizeFlatTweenKeyframes,
19
+ } from "./gsapTweenSynth";
20
+
21
+ /**
22
+ * Resolve a tween's target selector to the ids of the element(s) it animates.
23
+ * A bare `#id` resolves directly; anything else (a class like `.dot`, a group
24
+ * `.a, .b`, or a descendant selector) is matched against the live preview DOM so
25
+ * class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every
26
+ * element they animate — not just one parsed from the string. Falls back to a
27
+ * leading `#id` when there's no DOM (so the cache still populates pre-iframe).
28
+ */
29
+ // fallow-ignore-next-line complexity
30
+ export function resolveSelectorElementIds(
31
+ selector: string,
32
+ doc: Document | null | undefined,
33
+ ): string[] {
34
+ // A whole-selector id match (either shape) addresses exactly one element.
35
+ const bareId = /^(#[\w-]+|\[id="(?:\\.|[^"\\])*"\])$/.test(selector)
36
+ ? idFromSelector(selector)
37
+ : null;
38
+ if (bareId) return [bareId];
39
+ if (!doc) {
40
+ const lead = idFromSelector(selector);
41
+ return lead ? [lead] : [];
42
+ }
43
+ const ids = new Set<string>();
44
+ for (const part of selector.split(",")) {
45
+ const sel = part.trim();
46
+ if (!sel) continue;
47
+ try {
48
+ for (const el of Array.from(doc.querySelectorAll(sel))) {
49
+ if (el.id) ids.add(el.id);
50
+ }
51
+ } catch {
52
+ const lead = idFromSelector(sel);
53
+ if (lead) ids.add(lead);
54
+ }
55
+ }
56
+ return Array.from(ids);
57
+ }
58
+ /**
59
+ * The slice of the parse response callers actually read. The endpoint returns
60
+ * the full `ParsedGsap` (preamble/postamble and all), but nothing downstream of
61
+ * this fetch touches the source-text fields, so the guard below only has to
62
+ * vouch for what gets used.
63
+ */
64
+ type ParsedGsapAnimations = Pick<
65
+ ParsedGsap,
66
+ "animations" | "multipleTimelines" | "unsupportedTimelinePattern"
67
+ >;
68
+
69
+ /**
70
+ * A proxy, an error page, or a stale server can answer 200 with something that
71
+ * has no `animations` array — the case where the old blind cast crashed on
72
+ * `.animations.filter`.
73
+ */
74
+ function hasAnimations(value: unknown): value is ParsedGsapAnimations {
75
+ return (
76
+ typeof value === "object" &&
77
+ value !== null &&
78
+ "animations" in value &&
79
+ Array.isArray(value.animations)
80
+ );
81
+ }
82
+
83
+ export async function fetchParsedAnimations(
84
+ projectId: string,
85
+ sourceFile: string,
86
+ ): Promise<ParsedGsapAnimations | null> {
87
+ try {
88
+ const res = await fetch(
89
+ `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
90
+ // Always re-read the freshly-parsed source; no per-call timestamp (which
91
+ // would defeat caching forever and is a deterministic-render no-no).
92
+ { cache: "no-store" },
93
+ );
94
+ if (!res.ok) return null;
95
+ const parsed: unknown = await res.json();
96
+ if (!hasAnimations(parsed)) return null;
97
+ // Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
98
+ // hold an element's first keyframe before its tween). They must not surface as
99
+ // user animations — otherwise they pollute the keyframe cache / timeline diamonds.
100
+ return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
108
+ * inside a scene) aren't timeline clips themselves — they're derived at expand time
109
+ * — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
110
+ * and clip-relative keyframe percentages blew past 100% (rendering off the clip).
111
+ * Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
112
+ * data-composition-src is stripped in the rendered DOM, so we can't query it).
113
+ */
114
+ export function resolveClipTimingBasis(
115
+ elementId: string,
116
+ sourceFile: string,
117
+ elements: ReadonlyArray<{
118
+ domId?: string;
119
+ key?: string;
120
+ id: string;
121
+ start: number;
122
+ duration: number;
123
+ }>,
124
+ domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
125
+ ): { elStart: number; elDuration: number } {
126
+ const direct = elements.find(
127
+ (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
128
+ );
129
+ if (direct) return { elStart: direct.start, elDuration: direct.duration };
130
+ const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
131
+ const host = hostId
132
+ ? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
133
+ : undefined;
134
+ return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
135
+ }
136
+
137
+ /**
138
+ * Read one composition file's tweens into the keyframe cache. Split out of the
139
+ * hook so the effect can run it per file without re-nesting the whole body.
140
+ */
141
+ // fallow-ignore-next-line complexity
142
+ export async function populateKeyframeCacheFromAst(
143
+ projectId: string,
144
+ sf: string,
145
+ doc: Document | null | undefined,
146
+ ): Promise<void> {
147
+ const parsed = await fetchParsedAnimations(projectId, sf);
148
+ if (!parsed) return;
149
+ const { setKeyframeCache } = usePlayerStore.getState();
150
+ clearKeyframeCacheForFile(sf);
151
+ const { elements, domClipChildren } = usePlayerStore.getState();
152
+ const mergedByElement = new Map<string, GsapKeyframesData>();
153
+ const sourceByElement = new Map<string, GsapAnimation[]>();
154
+ for (const anim of parsed.animations) {
155
+ if (anim.hasUnresolvedKeyframes) continue;
156
+ if (isStaticPositionHold(anim)) continue;
157
+ const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
158
+ if (!kfData) continue;
159
+ // Attribute the tween to every element it animates (handles class /
160
+ // group / descendant selectors, not just `#id`).
161
+ for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
162
+ // kfData is already resolved (real keyframes OR a synthesized flat
163
+ // tween), so a flat tween joins the store like a keyframed one. No
164
+ // property-group filter: this map must cover every tween the cache
165
+ // below records, or expanded lanes have nothing to render.
166
+ sourceByElement.set(id, [...(sourceByElement.get(id) ?? []), anim]);
167
+ const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
168
+ const clipKeyframes = toClipKeyframes(kfData.keyframes, anim, elStart, elDuration);
169
+ const existing = mergedByElement.get(id);
170
+ if (existing) {
171
+ existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
172
+ } else {
173
+ mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
174
+ }
175
+ }
176
+ }
177
+ for (const [id, kfData] of mergedByElement) {
178
+ for (const key of elementCacheKeys(sf, id)) setKeyframeCache(key, kfData);
179
+ writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
180
+ }
181
+ }
@@ -12,6 +12,7 @@ import {
12
12
  import type { TimelineElement } from "../player/store/playerStore";
13
13
  import { usePlayerStore } from "../player/store/playerStore";
14
14
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
15
+ import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
15
16
 
16
17
  afterEach(() => {
17
18
  usePlayerStore.getState().reset();
@@ -342,4 +343,83 @@ describe("deleteSelectedKeyframes", () => {
342
343
  expect(options[1]).not.toHaveProperty("softReload");
343
344
  expect(options[2]).not.toHaveProperty("skipReload");
344
345
  });
346
+
347
+ it("deletes two expanded lanes through their own animation and tween percentages", () => {
348
+ usePlayerStore.setState({
349
+ selectedElementId: "card",
350
+ selectedKeyframes: new Set([
351
+ timelineKeyframeSelectionKey("card", {
352
+ percentage: 30,
353
+ tweenPercentage: 20,
354
+ propertyGroup: "position",
355
+ animationId: "card-position",
356
+ }),
357
+ timelineKeyframeSelectionKey("card", {
358
+ percentage: 70,
359
+ tweenPercentage: 80,
360
+ propertyGroup: "visual",
361
+ animationId: "card-visual",
362
+ }),
363
+ ]),
364
+ });
365
+ const handleGsapRemoveKeyframe =
366
+ vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
367
+
368
+ deleteSelectedKeyframes({
369
+ selectedGsapAnimations: [
370
+ { id: "card-position", keyframes: {} },
371
+ { id: "card-visual", keyframes: {} },
372
+ ],
373
+ handleGsapRemoveKeyframe,
374
+ });
375
+
376
+ expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(2);
377
+ expect(
378
+ handleGsapRemoveKeyframe.mock.calls.map(([animationId, percentage]) => [
379
+ animationId,
380
+ percentage,
381
+ ]),
382
+ ).toEqual([
383
+ ["card-position", 20],
384
+ ["card-visual", 80],
385
+ ]);
386
+ expect(handleGsapRemoveKeyframe.mock.calls[0]?.[2]).toEqual(
387
+ expect.objectContaining({ skipReload: true }),
388
+ );
389
+ expect(handleGsapRemoveKeyframe.mock.calls[1]?.[2]).toEqual(
390
+ expect.objectContaining({ softReload: true }),
391
+ );
392
+ });
393
+
394
+ it("drops keyframes that belong to other elements", () => {
395
+ // A stale selection from a previously active element must not delete
396
+ // anything on the element that is active now.
397
+ usePlayerStore.setState({
398
+ selectedElementId: "card",
399
+ selectedKeyframes: new Set([
400
+ timelineKeyframeSelectionKey("card", {
401
+ percentage: 30,
402
+ tweenPercentage: 20,
403
+ propertyGroup: "position",
404
+ animationId: "card-position",
405
+ }),
406
+ timelineKeyframeSelectionKey("other", {
407
+ percentage: 70,
408
+ tweenPercentage: 80,
409
+ propertyGroup: "position",
410
+ animationId: "card-position",
411
+ }),
412
+ ]),
413
+ });
414
+ const handleGsapRemoveKeyframe =
415
+ vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
416
+
417
+ deleteSelectedKeyframes({
418
+ selectedGsapAnimations: [{ id: "card-position", keyframes: {} }],
419
+ handleGsapRemoveKeyframe,
420
+ });
421
+
422
+ expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(1);
423
+ expect(handleGsapRemoveKeyframe.mock.calls[0]?.[1]).toBe(20);
424
+ });
345
425
  });
@@ -4,7 +4,7 @@ import type {
4
4
  TimelineGroupMoveChange,
5
5
  } from "./useTimelineGroupEditing";
6
6
 
7
- interface MoveEdit {
7
+ export interface TimelineMoveEdit {
8
8
  element: TimelineElement;
9
9
  updates: Pick<TimelineElement, "start" | "track">;
10
10
  }
@@ -18,8 +18,15 @@ interface AtomicMoveDeps {
18
18
 
19
19
  export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert";
20
20
 
21
+ export type TimelineMoveEditsHandler = (
22
+ edits: TimelineMoveEdit[],
23
+ coalesceKey?: string,
24
+ operation?: TimelineMoveOperation,
25
+ coalesceMs?: number,
26
+ ) => Promise<void>;
27
+
21
28
  export function persistTimelineMoveEditsAtomically(
22
- edits: MoveEdit[],
29
+ edits: TimelineMoveEdit[],
23
30
  coalesceKey: string | undefined,
24
31
  operation: TimelineMoveOperation,
25
32
  deps: AtomicMoveDeps,
@@ -92,14 +92,14 @@ export interface UseDomEditWiringParams {
92
92
  animId: string,
93
93
  fromPercentage: number,
94
94
  toPercentage: number,
95
- ) => void;
95
+ ) => Promise<boolean>;
96
96
  resizeKeyframedTween: (
97
97
  sel: DomEditSelection,
98
98
  animId: string,
99
99
  position: number,
100
100
  duration: number,
101
101
  pctRemap: Array<{ from: number; to: number }>,
102
- ) => void;
102
+ ) => Promise<boolean>;
103
103
  convertToKeyframes: (
104
104
  sel: DomEditSelection,
105
105
  animId: string,