@hyperframes/studio 0.7.79 → 0.7.80

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 (50) hide show
  1. package/dist/assets/{hyperframes-player-mFah2TZE.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-Bqj3h_1a.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-DlZMDyYs.js → index-BhdJN49y.js} +200 -200
  4. package/dist/assets/{index-hmnoiSEV.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +652 -465
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +2 -1
  12. package/src/components/editor/AnimationCard.test.tsx +225 -18
  13. package/src/components/editor/AnimationCard.tsx +24 -3
  14. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  15. package/src/components/editor/EaseCurveSection.tsx +77 -15
  16. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  17. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  18. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +54 -19
  20. package/src/components/editor/PropertyPanel.tsx +4 -0
  21. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  22. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  23. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  24. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  27. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  28. package/src/components/editor/propertyPanelTypes.ts +2 -0
  29. package/src/contexts/DomEditContext.tsx +4 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +132 -43
  31. package/src/hooks/gsapKeyframeCacheHelpers.ts +150 -50
  32. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  33. package/src/hooks/gsapTweenSynth.ts +50 -23
  34. package/src/hooks/keyframeCacheAstLoad.ts +4 -12
  35. package/src/hooks/useDomEditSession.test.tsx +152 -3
  36. package/src/hooks/useDomEditSession.ts +4 -53
  37. package/src/hooks/useGsapTweenCache.ts +40 -34
  38. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  39. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  40. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  41. package/src/player/components/TimelineClipDiamonds.test.tsx +18 -6
  42. package/src/player/components/TimelineDiamondConnectors.tsx +118 -85
  43. package/src/player/components/timelineDiamondTypes.ts +4 -3
  44. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  45. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  46. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  47. package/src/player/hooks/useExpandedTimelineElements.test.ts +29 -0
  48. package/src/player/hooks/useExpandedTimelineElements.ts +24 -0
  49. package/src/player/store/keyframeSlice.ts +15 -5
  50. package/dist/assets/index-gGVKuFg5.css +0 -1
@@ -5,7 +5,52 @@
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
7
  import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
8
- import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
8
+ import {
9
+ deduplicateKeyframes,
10
+ synthesizeFlatTweenKeyframes,
11
+ type MergeableKeyframe,
12
+ } from "./gsapTweenSynth";
13
+
14
+ /** Both cache maps mid-edit, before the single store publish. */
15
+ export interface KeyframeCacheDraft {
16
+ keyframeCache: Map<string, KeyframeCacheEntry>;
17
+ gsapAnimations: Map<string, GsapAnimation[]>;
18
+ }
19
+
20
+ function sameEntries<V>(before: ReadonlyMap<string, V>, after: ReadonlyMap<string, V>): boolean {
21
+ if (before.size !== after.size) return false;
22
+ for (const [key, value] of before) {
23
+ if (after.get(key) !== value) return false;
24
+ }
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * Every multi-key cache writer publishes through here: clone both maps once,
30
+ * let the caller edit the drafts, publish once. The per-key store setters turned
31
+ * one logical refresh into N notifications, and every subscriber that rendered
32
+ * in between saw a cache that was half the old file and half the new one. The
33
+ * identity check keeps an edit that changed nothing from allocating fresh maps
34
+ * and re-rendering every subscriber, which is the guard the per-key setters used
35
+ * to carry individually.
36
+ */
37
+ export function publishKeyframeCache(edit: (draft: KeyframeCacheDraft) => void): void {
38
+ const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
39
+ const draft: KeyframeCacheDraft = {
40
+ keyframeCache: new Map(keyframeCache),
41
+ gsapAnimations: new Map(gsapAnimations),
42
+ };
43
+ edit(draft);
44
+ if (
45
+ sameEntries(keyframeCache, draft.keyframeCache) &&
46
+ sameEntries(gsapAnimations, draft.gsapAnimations)
47
+ )
48
+ return;
49
+ usePlayerStore.setState({
50
+ keyframeCache: draft.keyframeCache,
51
+ gsapAnimations: draft.gsapAnimations,
52
+ });
53
+ }
9
54
 
10
55
  export function updateKeyframeCacheFromParsed(
11
56
  animations: GsapAnimation[],
@@ -14,9 +59,13 @@ export function updateKeyframeCacheFromParsed(
14
59
  mutation: Record<string, unknown>,
15
60
  doc?: Document | null,
16
61
  ): void {
17
- const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState();
62
+ const { elements, domClipChildren } = usePlayerStore.getState();
18
63
  const idsWithKeyframes = new Set<string>();
19
- const merged = new Map<string, KeyframeCacheEntry>();
64
+ // Attributed keyframes only: everything in here came from a parsed tween via
65
+ // toClipKeyframes, so the merge can rely on the source identity. It widens
66
+ // back into KeyframeCacheEntry on the way to the store, which also holds the
67
+ // runtime scan's unattributed keyframes.
68
+ const merged = new Map<string, KeyframeCacheEntry & { keyframes: MergeableKeyframe[] }>();
20
69
  const sourceAnimations = new Map<string, GsapAnimation[]>();
21
70
  for (const anim of animations) {
22
71
  const kfSource =
@@ -49,9 +98,9 @@ export function updateKeyframeCacheFromParsed(
49
98
 
50
99
  const existing = merged.get(id);
51
100
  if (existing) {
52
- // deduplicateKeyframes owns the same-% merge (including the easeAmbiguous
53
- // flag downstream lanes read); a second copy of that rule here is how the
54
- // two writers drift.
101
+ // deduplicateKeyframes owns the same-% merge (including the colliding
102
+ // animation targets downstream lanes read); a second copy of that rule
103
+ // here is how the two writers drift.
55
104
  existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
56
105
  } else {
57
106
  merged.set(id, {
@@ -62,15 +111,41 @@ export function updateKeyframeCacheFromParsed(
62
111
  }
63
112
  }
64
113
  }
65
- for (const [id, entry] of merged) {
66
- for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry);
67
- writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
68
- }
69
114
  const mutationSelector = (mutation as { targetSelector?: string }).targetSelector;
70
115
  const mutated = mutationSelector ? resolveSelectorElementIds(mutationSelector, doc) : [];
71
116
  const targetIds = mutated.length > 0 ? mutated : selectionId ? [selectionId] : [];
72
- for (const targetId of targetIds) {
73
- if (!idsWithKeyframes.has(targetId)) clearKeyframeCacheForElement(targetPath, targetId);
117
+ publishKeyframeCache((draft) => {
118
+ for (const [id, entry] of merged) {
119
+ writeElementIntoDraft(draft, targetPath, id, entry, sourceAnimations.get(id));
120
+ }
121
+ for (const targetId of targetIds) {
122
+ if (!idsWithKeyframes.has(targetId)) deleteElementFromDraft(draft, targetPath, targetId);
123
+ }
124
+ });
125
+ }
126
+
127
+ function writeElementIntoDraft(
128
+ draft: KeyframeCacheDraft,
129
+ sourceFile: string,
130
+ elementId: string,
131
+ entry: KeyframeCacheEntry,
132
+ animations: GsapAnimation[] | undefined,
133
+ ): void {
134
+ for (const key of elementCacheKeys(sourceFile, elementId)) {
135
+ draft.keyframeCache.set(key, entry);
136
+ if (animations) draft.gsapAnimations.set(key, animations);
137
+ else draft.gsapAnimations.delete(key);
138
+ }
139
+ }
140
+
141
+ function deleteElementFromDraft(
142
+ draft: KeyframeCacheDraft,
143
+ sourceFile: string,
144
+ elementId: string,
145
+ ): void {
146
+ for (const key of elementCacheKeys(sourceFile, elementId)) {
147
+ draft.keyframeCache.delete(key);
148
+ draft.gsapAnimations.delete(key);
74
149
  }
75
150
  }
76
151
 
@@ -83,50 +158,37 @@ export function updateKeyframeCacheFromParsed(
83
158
  * preview overlay) fall back to the bare id when an element has no
84
159
  * source-prefixed key — so a clear that drops only the prefixed keys leaves the
85
160
  * bare entry behind and those readers keep showing keyframes the element no
86
- * longer has. Each delete is guarded by `has` so an absent key doesn't allocate
87
- * a new cache map and re-render every subscriber.
161
+ * longer has. publishKeyframeCache skips the store write entirely when none of
162
+ * the keys were present, so an absent element doesn't re-render every
163
+ * subscriber.
88
164
  */
89
165
  export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
90
- const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } =
91
- usePlayerStore.getState();
92
- const keys = elementCacheKeys(sourceFile, elementId);
93
- for (const key of keys) {
94
- if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
95
- if (gsapAnimations.has(key)) setGsapAnimations(key, undefined);
96
- }
166
+ publishKeyframeCache((draft) => deleteElementFromDraft(draft, sourceFile, elementId));
97
167
  }
98
168
 
99
- /**
100
- * Clear every cached element of `sourceFile` before a full re-scan repopulates
101
- * it. Only the file's OWN prefixed keys name the ids to clear: every write sets
102
- * the prefixed key (see elementCacheKeys), so the file's elements are all
103
- * reachable that way, and clearKeyframeCacheForElement then takes the
104
- * index.html alias and the bare key with them — an element whose keyframes were
105
- * removed (and so is absent from the re-scan) leaves no stale bare entry
106
- * behind. Reading the alias prefix here instead would collect ids owned by
107
- * OTHER files, and several files re-scan concurrently, so this file's clear
108
- * would wipe the entries a sibling file had just written.
109
- */
110
- export function clearKeyframeCacheForFile(sourceFile: string): void {
111
- const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
169
+ function cachedElementIdsForFile(
170
+ sourceFile: string,
171
+ keyframeCache: ReadonlyMap<string, KeyframeCacheEntry>,
172
+ gsapAnimations: ReadonlyMap<string, GsapAnimation[]>,
173
+ ): Set<string> {
112
174
  const sfPrefix = `${sourceFile}#`;
113
175
  const ids = new Set<string>();
114
176
  for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
115
177
  if (!key.startsWith(sfPrefix)) continue;
116
178
  ids.add(key.slice(sfPrefix.length));
117
179
  }
118
- for (const id of ids) {
119
- clearKeyframeCacheForElement(sourceFile, id);
120
- }
180
+ return ids;
121
181
  }
122
182
 
123
183
  /**
124
184
  * Drop every cached element owned by a file that is no longer on screen. Each
125
- * file only ever clears its OWN entries (see clearKeyframeCacheForFile), so
185
+ * file only ever writes its OWN prefixed entries (see elementCacheKeys), so
126
186
  * switching composition left the previous composition's elements cached forever
127
- * 240 entries per switch on a 120-clip comp, in both keyframeCache and
128
- * gsapAnimations, with nothing to evict them. Called once before a re-scan, with
129
- * the full set of files that scan covers.
187
+ * (240 entries per switch on a 120-clip comp, in both keyframeCache and
188
+ * gsapAnimations, with nothing to evict them). Called once before a re-scan,
189
+ * with the full set of files that scan covers, and it publishes once: the prune
190
+ * runs immediately before the atomic repopulate, so a per-element publish here
191
+ * would reintroduce the empty-cache flash that repopulate was made to avoid.
130
192
  */
131
193
  export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
132
194
  const keep = new Set(files);
@@ -134,8 +196,8 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
134
196
  const stale = new Map<string, Set<string>>();
135
197
  for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
136
198
  const hash = key.indexOf("#");
137
- // Bare-id aliases carry no owner; clearKeyframeCacheForElement takes them
138
- // with their prefixed key, so skipping them here loses nothing.
199
+ // Bare-id aliases carry no owner; deleteElementFromDraft takes them with
200
+ // their prefixed key, so skipping them here loses nothing.
139
201
  if (hash < 0) continue;
140
202
  const sourceFile = key.slice(0, hash);
141
203
  if (keep.has(sourceFile)) continue;
@@ -143,9 +205,25 @@ export function pruneKeyframeCacheToFiles(files: readonly string[]): void {
143
205
  ids.add(key.slice(hash + 1));
144
206
  stale.set(sourceFile, ids);
145
207
  }
146
- for (const [sourceFile, ids] of stale) {
147
- for (const id of ids) clearKeyframeCacheForElement(sourceFile, id);
148
- }
208
+ publishKeyframeCache((draft) => {
209
+ for (const [sourceFile, ids] of stale) {
210
+ for (const id of ids) deleteElementFromDraft(draft, sourceFile, id);
211
+ }
212
+ });
213
+ }
214
+
215
+ /**
216
+ * The source-scoped key that names one element across panels and the store
217
+ * (focused ease segment, keyframe cache reads). Four call sites built this
218
+ * string by hand and two of them omitted the index.html fallback, so an element
219
+ * with no sourceFile was addressed as `#box` by one panel and `index.html#box`
220
+ * by another and the two never matched. One builder keeps them on one key.
221
+ */
222
+ export function scopedElementKey(element: {
223
+ sourceFile?: string | null;
224
+ id?: string | null;
225
+ }): string {
226
+ return `${element.sourceFile || "index.html"}#${element.id}`;
149
227
  }
150
228
 
151
229
  /** Every cache key a write for this element sets, in read-preference order. */
@@ -155,15 +233,37 @@ export function elementCacheKeys(sourceFile: string, elementId: string): string[
155
233
  : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
156
234
  }
157
235
 
236
+ /** Replace one file's complete cache snapshot with one atomic store publish. */
237
+ export function replaceKeyframeCacheForFile(
238
+ sourceFile: string,
239
+ entries: ReadonlyMap<string, KeyframeCacheEntry>,
240
+ animationsByElement: ReadonlyMap<string, GsapAnimation[]>,
241
+ ): void {
242
+ publishKeyframeCache((draft) => {
243
+ for (const id of cachedElementIdsForFile(
244
+ sourceFile,
245
+ draft.keyframeCache,
246
+ draft.gsapAnimations,
247
+ )) {
248
+ deleteElementFromDraft(draft, sourceFile, id);
249
+ }
250
+ for (const [id, entry] of entries) {
251
+ writeElementIntoDraft(draft, sourceFile, id, entry, animationsByElement.get(id));
252
+ }
253
+ });
254
+ }
255
+
158
256
  export function writeGsapAnimationsForElement(
159
257
  sourceFile: string,
160
258
  elementId: string,
161
259
  animations: GsapAnimation[] | undefined,
162
260
  ): void {
163
- const { setGsapAnimations } = usePlayerStore.getState();
164
- for (const key of elementCacheKeys(sourceFile, elementId)) {
165
- setGsapAnimations(key, animations);
166
- }
261
+ publishKeyframeCache((draft) => {
262
+ for (const key of elementCacheKeys(sourceFile, elementId)) {
263
+ if (animations) draft.gsapAnimations.set(key, animations);
264
+ else draft.gsapAnimations.delete(key);
265
+ }
266
+ });
167
267
  }
168
268
 
169
269
  function buildCacheKey(sourceFile: string, elementId: string): string {
@@ -54,31 +54,86 @@ describe("synthesizeFlatTweenKeyframes", () => {
54
54
  });
55
55
  });
56
56
 
57
- describe("deduplicateKeyframes ease ambiguity", () => {
58
- it("flags a same-% collision from different animations (different eases)", () => {
57
+ describe("deduplicateKeyframes colliding animation targets", () => {
58
+ it("records each animation's tween percentage in first-seen order", () => {
59
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" },
60
+ {
61
+ percentage: 45,
62
+ tweenPercentage: 20,
63
+ properties: { x: 10 },
64
+ ease: "power2.in",
65
+ animationId: "#a-position",
66
+ },
67
+ {
68
+ percentage: 45,
69
+ tweenPercentage: 80,
70
+ properties: { opacity: 1 },
71
+ ease: "power2.out",
72
+ animationId: "#a-visual",
73
+ },
62
74
  ]);
63
75
  const kf = merged.find((k) => k.percentage === 45);
64
- expect(kf?.easeAmbiguous).toBe(true);
76
+ expect(kf?.collidingAnimationTargets).toEqual([
77
+ { animationId: "#a-position", tweenPercentage: 20 },
78
+ { animationId: "#a-visual", tweenPercentage: 80 },
79
+ ]);
65
80
  });
66
81
 
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.
82
+ it("deduplicates three colliding animations while preserving first-seen order", () => {
70
83
  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" },
84
+ {
85
+ percentage: 45,
86
+ tweenPercentage: 20,
87
+ properties: { x: 10 },
88
+ ease: "power2.in",
89
+ animationId: "#a-position",
90
+ },
91
+ {
92
+ percentage: 45,
93
+ tweenPercentage: 80,
94
+ properties: { opacity: 1 },
95
+ ease: "power2.in",
96
+ animationId: "#a-visual",
97
+ },
98
+ {
99
+ percentage: 45,
100
+ tweenPercentage: 40,
101
+ properties: { y: 20 },
102
+ ease: "power2.out",
103
+ animationId: "#a-position",
104
+ },
105
+ {
106
+ percentage: 45,
107
+ tweenPercentage: 60,
108
+ properties: { scale: 2 },
109
+ ease: "power2.in",
110
+ animationId: "#a-scale",
111
+ },
112
+ ]);
113
+ expect(merged.find((k) => k.percentage === 45)?.collidingAnimationTargets).toEqual([
114
+ { animationId: "#a-position", tweenPercentage: 20 },
115
+ { animationId: "#a-visual", tweenPercentage: 80 },
116
+ { animationId: "#a-scale", tweenPercentage: 60 },
73
117
  ]);
74
- expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true);
75
118
  });
76
119
 
77
- it("does not flag a same-% collision within a single animation", () => {
120
+ it("leaves the collision set undefined within a single animation", () => {
78
121
  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" },
122
+ {
123
+ percentage: 45,
124
+ tweenPercentage: 20,
125
+ properties: { x: 10 },
126
+ ease: "power2.in",
127
+ animationId: "#a-position",
128
+ },
129
+ {
130
+ percentage: 45,
131
+ tweenPercentage: 80,
132
+ properties: { y: 20 },
133
+ ease: "power2.out",
134
+ animationId: "#a-position",
135
+ },
81
136
  ]);
82
- expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBeFalsy();
137
+ expect(merged.find((k) => k.percentage === 45)?.collidingAnimationTargets).toBeUndefined();
83
138
  });
84
139
  });
@@ -1,7 +1,7 @@
1
1
  import type {
2
2
  GsapAnimation,
3
3
  GsapKeyframesData,
4
- GsapPercentageKeyframe,
4
+ SourcedGsapPercentageKeyframe,
5
5
  } from "@hyperframes/core/gsap-parser";
6
6
  import { PROPERTY_DEFAULTS } from "./gsapShared";
7
7
 
@@ -22,33 +22,60 @@ export function isStaticPositionHold(anim: GsapAnimation): boolean {
22
22
  return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y");
23
23
  }
24
24
 
25
- export function deduplicateKeyframes<
26
- T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean },
27
- >(keyframes: T[]): T[] {
25
+ export interface AnimationKeyframeTarget {
26
+ animationId: string;
27
+ tweenPercentage: number;
28
+ }
29
+
30
+ function accumulateCollidingAnimationTargets(
31
+ keyframe: AnimationKeyframeTarget & {
32
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
33
+ },
34
+ incoming: AnimationKeyframeTarget,
35
+ ): void {
36
+ const primaryId = keyframe.animationId;
37
+ // One tween meeting itself is not a collision. Both identity fields are
38
+ // required by the parameter types rather than guarded at runtime: a keyframe
39
+ // that arrives without them cannot be attributed to a tween at all, and an
40
+ // early return here would silently record no collision and let the inline
41
+ // ease button edit an arbitrary one of the tweens that met at this
42
+ // percentage. The compiler now refuses the incomplete keyframe instead.
43
+ if (primaryId === incoming.animationId) return;
44
+ const collisionTargets = keyframe.collidingAnimationTargets;
45
+ if (collisionTargets?.some((target) => target.animationId === incoming.animationId)) return;
46
+ keyframe.collidingAnimationTargets = [
47
+ ...(collisionTargets === undefined || collisionTargets.length === 0
48
+ ? [{ animationId: primaryId, tweenPercentage: keyframe.tweenPercentage }]
49
+ : collisionTargets),
50
+ { animationId: incoming.animationId, tweenPercentage: incoming.tweenPercentage },
51
+ ];
52
+ }
53
+
54
+ /**
55
+ * What a keyframe looks like once it has been attributed to its source tween
56
+ * and is ready to be merged with the other tweens landing on the same row. The
57
+ * runtime scan produces unattributed keyframes and they never reach a merge, so
58
+ * they are deliberately not this type.
59
+ */
60
+ export type MergeableKeyframe = SourcedGsapPercentageKeyframe & {
61
+ propertyGroup?: string;
62
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
63
+ };
64
+
65
+ export function deduplicateKeyframes<T extends MergeableKeyframe>(keyframes: T[]): T[] {
28
66
  const byPct = new Map<number, T>();
29
67
  for (const kf of keyframes) {
30
68
  const existing = byPct.get(kf.percentage);
31
69
  if (existing) {
32
70
  existing.properties = { ...existing.properties, ...kf.properties };
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;
71
+ accumulateCollidingAnimationTargets(existing, kf);
72
+ // Whichever tween iterated last used to win `ease`, so the merged keyframe
73
+ // carried an arbitrary one of the colliding curves. Readers that show a
74
+ // single curve (drag readouts, lane hints, the inline ease button) then
75
+ // displayed one belonging to a different animation than the one an edit
76
+ // targets. A collision means "no single ease", and dropping it is the only
77
+ // honest answer; collidingAnimationTargets still names every tween there.
78
+ if ((existing.collidingAnimationTargets?.length ?? 0) > 1) delete existing.ease;
52
79
  else if (kf.ease) existing.ease = kf.ease;
53
80
  } else {
54
81
  byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
@@ -6,16 +6,13 @@
6
6
  import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
7
7
  import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
8
8
  import { usePlayerStore } from "../player/store/playerStore";
9
- import {
10
- clearKeyframeCacheForFile,
11
- elementCacheKeys,
12
- writeGsapAnimationsForElement,
13
- } from "./gsapKeyframeCacheHelpers";
9
+ import { replaceKeyframeCacheForFile } from "./gsapKeyframeCacheHelpers";
14
10
  import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
15
11
  import {
16
12
  deduplicateKeyframes,
17
13
  isStaticPositionHold,
18
14
  synthesizeFlatTweenKeyframes,
15
+ type MergeableKeyframe,
19
16
  } from "./gsapTweenSynth";
20
17
 
21
18
  export { resolveSelectorElementIds };
@@ -80,10 +77,8 @@ export async function populateKeyframeCacheFromAst(
80
77
  ): Promise<void> {
81
78
  const parsed = await fetchParsedAnimations(projectId, sf);
82
79
  if (!parsed) return;
83
- const { setKeyframeCache } = usePlayerStore.getState();
84
- clearKeyframeCacheForFile(sf);
85
80
  const { elements, domClipChildren } = usePlayerStore.getState();
86
- const mergedByElement = new Map<string, GsapKeyframesData>();
81
+ const mergedByElement = new Map<string, GsapKeyframesData<MergeableKeyframe>>();
87
82
  const sourceByElement = new Map<string, GsapAnimation[]>();
88
83
  for (const anim of parsed.animations) {
89
84
  if (anim.hasUnresolvedKeyframes) continue;
@@ -108,8 +103,5 @@ export async function populateKeyframeCacheFromAst(
108
103
  }
109
104
  }
110
105
  }
111
- for (const [id, kfData] of mergedByElement) {
112
- for (const key of elementCacheKeys(sf, id)) setKeyframeCache(key, kfData);
113
- writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
114
- }
106
+ replaceKeyframeCacheForFile(sf, mergedByElement, sourceByElement);
115
107
  }