@hyperframes/studio 0.8.126 → 0.8.128

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
  2. package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
  3. package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
  4. package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +2184 -1745
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
  20. package/src/components/editor/MotionPathOverlay.tsx +20 -26
  21. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  22. package/src/components/editor/domEditingLayers.test.ts +45 -18
  23. package/src/components/editor/domEditingLayers.ts +11 -9
  24. package/src/components/editor/domEditingTypes.ts +1 -0
  25. package/src/components/editor/keyframeRetime.ts +11 -4
  26. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  27. package/src/components/editor/motionPathCommit.ts +58 -0
  28. package/src/components/editor/motionPathGeometry.test.ts +13 -0
  29. package/src/components/editor/motionPathGeometry.ts +4 -2
  30. package/src/components/editor/probeSourceElement.ts +17 -1
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  32. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  33. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  34. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  35. package/src/hooks/gsapDragCommit.ts +41 -6
  36. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  37. package/src/hooks/gsapEditOutcome.ts +27 -2
  38. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  39. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  40. package/src/hooks/gsapMutationClient.ts +35 -7
  41. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  42. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  43. package/src/hooks/gsapParsedTween.ts +27 -7
  44. package/src/hooks/gsapPositionDetection.ts +5 -2
  45. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  46. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  47. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  48. package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
  49. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  50. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  51. package/src/hooks/gsapShared.test.ts +49 -35
  52. package/src/hooks/gsapShared.ts +35 -33
  53. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
  54. package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
  55. package/src/hooks/gsapValueAtPlayhead.ts +49 -10
  56. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  58. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  59. package/src/hooks/timelineEditingHelpers.ts +40 -14
  60. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  61. package/src/hooks/timelineMoveAdapter.ts +3 -5
  62. package/src/hooks/timelineTimingSync.test.ts +83 -125
  63. package/src/hooks/timelineTimingSync.ts +62 -42
  64. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  65. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  66. package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
  67. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  68. package/src/hooks/useDomSelection.test.ts +27 -0
  69. package/src/hooks/useDomSelection.ts +12 -13
  70. package/src/hooks/useDomSelectionTypes.ts +1 -0
  71. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  72. package/src/hooks/useEditHistoryActions.ts +9 -6
  73. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  74. package/src/hooks/useEnableKeyframes.ts +54 -71
  75. package/src/hooks/useGestureCommit.ts +2 -1
  76. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  77. package/src/hooks/useGsapTweenCache.ts +2 -17
  78. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  79. package/src/hooks/usePersistentEditHistory.ts +13 -2
  80. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  81. package/src/hooks/usePreviewPersistence.ts +26 -4
  82. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  83. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  84. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  85. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  86. package/src/hooks/useTimelineEditing.ts +21 -295
  87. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  88. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  89. package/src/player/components/Timeline.test.ts +12 -4
  90. package/src/player/components/TimelineClip.tsx +3 -0
  91. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  92. package/src/player/components/TimelineLanes.test.tsx +61 -5
  93. package/src/player/components/TimelineLanes.tsx +20 -28
  94. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  95. package/src/player/components/timelineCallbacks.ts +1 -4
  96. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  97. package/src/player/components/timelineClipDragCommit.ts +39 -28
  98. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  99. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  100. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  101. package/src/player/components/timelineEditing.ts +1 -3
  102. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  103. package/src/player/components/timelineLinkSelection.ts +1 -35
  104. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  105. package/src/player/components/trackHeaderLaneState.ts +9 -2
  106. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  107. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  108. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  109. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  110. package/src/player/components/useTimelineClipDrag.ts +0 -3
  111. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  112. package/src/player/store/batchElementUpdates.test.ts +43 -0
  113. package/src/player/store/batchElementUpdates.ts +9 -0
  114. package/src/player/store/elementPatchQueue.ts +35 -0
  115. package/src/player/store/playerStore.ts +5 -6
  116. package/src/utils/domEditHelpers.ts +5 -0
  117. package/src/utils/globalTimeCompiler.test.ts +0 -4
  118. package/src/utils/globalTimeCompiler.ts +6 -4
  119. package/src/utils/gsapKeyframeEases.ts +79 -0
  120. package/src/utils/sameData.ts +9 -0
  121. package/src/utils/studioFileHistory.ts +1 -0
  122. package/src/utils/studioPendingEdits.ts +15 -1
  123. package/dist/chunk-246VB2G3.js.map +0 -1
  124. package/dist/chunk-3F47EFCT.js.map +0 -1
  125. package/dist/chunk-5EKWJZLM.js.map +0 -1
  126. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -1,8 +1,13 @@
1
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
1
+ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
  import { usePlayerStore } from "../player/store/playerStore";
4
- import { resolveTweenStart } from "../utils/globalTimeCompiler";
5
- import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
4
+ import {
5
+ isTimeWithinTween,
6
+ resolveTweenDuration,
7
+ resolveTweenStart,
8
+ } from "../utils/globalTimeCompiler";
9
+ import { KEYFRAME_PCT_MATCH, playsNear } from "./gsapShared";
10
+ import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
6
11
  import { roundTo3 } from "../utils/rounding";
7
12
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
8
13
  import {
@@ -19,7 +24,8 @@ import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
19
24
  * server takes a replace-with-keyframes list verbatim, so an append-only build
20
25
  * could hand it two keyframes a fraction of a percent apart. The invariant lives
21
26
  * here rather than in each caller's own pre-check, which is how the two callers
22
- * ended up with different tolerances in the first place.
27
+ * ended up with different tolerances in the first place. Arc writes are linear, so each
28
+ * keyframe moves to the time GSAP reached it and `percentage` is a time percentage.
23
29
  */
24
30
  export function buildTemporalArcKeyframes(
25
31
  anim: GsapAnimation,
@@ -27,7 +33,7 @@ export function buildTemporalArcKeyframes(
27
33
  properties: Record<string, number>,
28
34
  ) {
29
35
  return [
30
- ...(anim.keyframes?.keyframes ?? [])
36
+ ...linearArcKeyframes(anim)
31
37
  .filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH)
32
38
  .map((keyframe) => ({
33
39
  percentage: keyframe.percentage,
@@ -38,6 +44,46 @@ export function buildTemporalArcKeyframes(
38
44
  ].sort((a, b) => a.percentage - b.percentage);
39
45
  }
40
46
 
47
+ /** An arc's keyframes at the time percentages GSAP plays them, for a write with `ease: "none"`. */
48
+ export function linearArcKeyframes(anim: GsapAnimation): GsapPercentageKeyframe[] {
49
+ const runEase = runEaseOf(anim);
50
+ return (anim.keyframes?.keyframes ?? []).map((keyframe) => ({
51
+ ...keyframe,
52
+ percentage: roundTo3(timeAtProgress(runEase, keyframe.percentage)),
53
+ }));
54
+ }
55
+
56
+ /** Grow a keyframe tween's range to a playhead outside it and add a keyframe there; every
57
+ * existing keyframe keeps its absolute time under `writeEase`, the run ease the write keeps. */
58
+ export function buildExtendedKeyframes(
59
+ anim: GsapAnimation,
60
+ currentTime: number,
61
+ position: Record<string, number>,
62
+ sourceDuration = resolveTweenDuration(anim),
63
+ writeEase = runEaseOf(anim),
64
+ ): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
65
+ const oldStart = resolveTweenStart(anim) ?? 0;
66
+ const oldDuration = sourceDuration;
67
+ const newStart = Math.min(oldStart, currentTime);
68
+ const newEnd = Math.max(oldStart + oldDuration, currentTime);
69
+ const newDuration = roundTo3(newEnd - newStart);
70
+ const oldRunEase = runEaseOf(anim);
71
+ const toPct = (absoluteTime: number) => {
72
+ if (newDuration <= 0) return 0;
73
+ const progress = progressAtTime(writeEase, ((absoluteTime - newStart) / newDuration) * 100);
74
+ return Math.max(0, Math.min(100, Math.round(progress * 10) / 10));
75
+ };
76
+ const stops = anim.keyframes?.keyframes ?? [];
77
+ const rescaled: GsapPercentageKeyframe[] = stops.map((stop) => ({
78
+ percentage: toPct(oldStart + (timeAtProgress(oldRunEase, stop.percentage) / 100) * oldDuration),
79
+ properties: stop.properties,
80
+ ...(stop.ease ? { ease: stop.ease } : {}),
81
+ }));
82
+ const added: GsapPercentageKeyframe = { percentage: toPct(currentTime), properties: position };
83
+ const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage);
84
+ return { position: roundTo3(newStart), duration: newDuration, keyframes };
85
+ }
86
+
41
87
  /** commitGsapPositionFromDrag's refusal, decided without writing. */
42
88
  export function gsapPositionFromDragOutcome(
43
89
  selection: DomEditSelection,
@@ -87,15 +133,45 @@ export async function commitGsapPositionFromDrag(
87
133
  };
88
134
 
89
135
  if (anim.arcPath?.enabled) {
90
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
136
+ const { activeKeyframePct, currentTime, setActiveKeyframePct } = usePlayerStore.getState();
137
+ const tweenStart = resolveTweenStart(anim);
138
+ const tweenDuration = resolveTweenDuration(anim);
139
+ if (
140
+ activeKeyframePct === null &&
141
+ tweenStart !== null &&
142
+ !isTimeWithinTween(currentTime, tweenStart, tweenDuration)
143
+ ) {
144
+ const extended = buildExtendedKeyframes(
145
+ anim,
146
+ currentTime,
147
+ { x: newX, y: newY },
148
+ tweenDuration,
149
+ "none",
150
+ );
151
+ await callbacks.commitMutation(
152
+ selection,
153
+ {
154
+ type: "replace-with-keyframes",
155
+ animationId: anim.id,
156
+ targetSelector: anim.targetSelector,
157
+ ...extended,
158
+ ease: "none",
159
+ },
160
+ {
161
+ label: "Move layer (new keyframe)",
162
+ keyframeAction: "add",
163
+ softReload: true,
164
+ beforeReload: restoreOffset,
165
+ },
166
+ );
167
+ return { status: "persisted" };
168
+ }
91
169
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
92
170
  const keyframes = anim.keyframes?.keyframes ?? [];
93
- // Same tolerance as applyArcKeyframeAtPlayhead and isMotionPathEndpoint. A
94
- // tighter one here meant a drag that landed a fraction of a percent off an
95
- // authored waypoint skipped the update-point branch and appended instead.
96
- const pointIndex = keyframes.findIndex(
97
- (kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH,
98
- );
171
+ // A drag counts as on a waypoint when it plays within KEYFRAME_PCT_MATCH of it,
172
+ // so landing a fraction of a percent off an authored waypoint updates that point
173
+ // instead of appending a new one.
174
+ const pointIndex = keyframes.findIndex((kf) => playsNear(anim, kf.percentage, pct));
99
175
  if (pointIndex >= 0) {
100
176
  await callbacks.commitMutation(
101
177
  selection,
@@ -113,13 +189,10 @@ export async function commitGsapPositionFromDrag(
113
189
  return { status: "persisted" };
114
190
  }
115
191
 
116
- const tweenStart = resolveTweenStart(anim);
117
- // Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
118
- // default here would collapse a duration-less arc's window on drag.
119
- const tweenDuration = resolveEditableTweenDuration(anim, selection);
120
192
  if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2)
121
193
  return { status: "persisted" };
122
- const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
194
+ const timePct = roundTo3(timeAtProgress(runEaseOf(anim), pct));
195
+ const temporalKeyframes = buildTemporalArcKeyframes(anim, timePct, { x: newX, y: newY });
123
196
  await callbacks.commitMutation(
124
197
  selection,
125
198
  {
@@ -15,7 +15,7 @@ export type GsapEditBlockReason =
15
15
  * true for `provenance-runtime-dynamic`; the others are parser or source-match
16
16
  * limits, where the animation may well be plain authored source.
17
17
  *
18
- * Telemetry only. Nothing branches on it.
18
+ * Telemetry, plus the sharper message a few details get (GSAP_EDIT_DETAIL_COPY).
19
19
  */
20
20
  export type GsapEditBlockDetail =
21
21
  | "provenance-runtime-dynamic"
@@ -33,6 +33,12 @@ export type GsapEditBlockDetail =
33
33
  export type PlayheadEditRefusal =
34
34
  | "eased-keyframes"
35
35
  | "simple-array-keyframes"
36
+ | "array-step-delay"
37
+ | "array-step-callback"
38
+ | "array-step-config"
39
+ | "array-step-computed"
40
+ | "array-step-relative"
41
+ | "array-step-random"
36
42
  | "unknown-ease"
37
43
  | "implicit-end-unknown"
38
44
  | "not-a-tween"
@@ -74,12 +80,31 @@ export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
74
80
  "These layers are animated in different files. Move each file's layers separately.",
75
81
  };
76
82
 
83
+ const STEP_LIST = "This animation's keyframes have a step";
84
+ const IN_CODE = "Edit them in the Code tab.";
85
+ /** A refusal a person can act on more precisely than its reason's message: what blocks it, then what to do. */
86
+ const GSAP_EDIT_DETAIL_COPY: Partial<Record<GsapEditBlockDetail, string>> = {
87
+ "array-step-delay": `${STEP_LIST} with its own delay, which Studio can't keep while it edits them. ${IN_CODE}`,
88
+ "array-step-callback": `${STEP_LIST} that runs code as it plays (like onComplete), which an edit would run again. ${IN_CODE}`,
89
+ "array-step-config": `${STEP_LIST} with its own tween setting (like repeat or stagger), which Studio can't keep while it edits them. ${IN_CODE}`,
90
+ "array-step-computed": `${STEP_LIST} whose value comes from code, which Studio can't keep while it edits them. ${IN_CODE}`,
91
+ "array-step-relative": `${STEP_LIST} with a relative value (like "+=40"), which an edit would apply again. ${IN_CODE}`,
92
+ "array-step-random": `${STEP_LIST} with a random() value, which an edit would roll again. ${IN_CODE}`,
93
+ };
94
+
95
+ export function gsapEditBlockMessage(
96
+ reason: GsapEditBlockReason,
97
+ detail?: GsapEditBlockDetail,
98
+ ): string {
99
+ return (detail && GSAP_EDIT_DETAIL_COPY[detail]) ?? GSAP_EDIT_BLOCK_COPY[reason];
100
+ }
101
+
77
102
  export class GsapEditBlockedError extends Error {
78
103
  constructor(
79
104
  readonly reason: GsapEditBlockReason,
80
105
  readonly detail?: GsapEditBlockDetail,
81
106
  ) {
82
- super(GSAP_EDIT_BLOCK_COPY[reason]);
107
+ super(gsapEditBlockMessage(reason, detail));
83
108
  this.name = "GsapEditBlockedError";
84
109
  }
85
110
  }
@@ -78,6 +78,56 @@ describe("non-string cache keys", () => {
78
78
  });
79
79
  });
80
80
 
81
+ describe("replaceKeyframeCacheForFile re-reads", () => {
82
+ it("publishes nothing when a file re-reads to the same entries, and the change when one differs", () => {
83
+ const write = (x: number) =>
84
+ replaceKeyframeCacheForFile(
85
+ "comp.html",
86
+ new Map([
87
+ ["box", { format: "percentage", keyframes: [{ percentage: 0, properties: { x } }] }],
88
+ ]),
89
+ new Map([["box", [animWithKeyframes("box")]]]),
90
+ );
91
+ write(0);
92
+ let notified = 0;
93
+ const stop = usePlayerStore.subscribe(() => notified++);
94
+ write(0);
95
+ expect(notified).toBe(0);
96
+ write(5);
97
+ stop();
98
+ expect(notified).toBe(1);
99
+ expect(cache().get("comp.html#box")?.keyframes[0]?.properties).toEqual({ x: 5 });
100
+ });
101
+
102
+ it("leaves a sub-composition's entries alone when index.html re-reads", () => {
103
+ const one = (id: string) => new Map([[id, entry()]]);
104
+ const anims = (id: string) => new Map([[id, [animWithKeyframes(id)]]]);
105
+ replaceKeyframeCacheForFile("comp.html", one("box"), anims("box"));
106
+ replaceKeyframeCacheForFile("index.html", one("hero"), anims("hero"));
107
+ let notified = 0;
108
+ const stop = usePlayerStore.subscribe(() => notified++);
109
+ replaceKeyframeCacheForFile("index.html", one("hero"), anims("hero"));
110
+ stop();
111
+
112
+ expect(notified).toBe(0);
113
+ for (const key of ["comp.html#box", "index.html#box", "box", "index.html#hero", "hero"]) {
114
+ expect(cache().has(key)).toBe(true);
115
+ }
116
+ });
117
+
118
+ it("still drops index.html's own element that shares an id with a sub-composition", () => {
119
+ const one = (id: string) => new Map([[id, entry()]]);
120
+ const anims = (id: string) => new Map([[id, [animWithKeyframes(id)]]]);
121
+ const own = { format: "percentage", keyframes: [{ percentage: 0, properties: { x: 9 } }] };
122
+ replaceKeyframeCacheForFile("comp.html", one("title"), anims("title"));
123
+ replaceKeyframeCacheForFile("index.html", new Map([["title", own]]), anims("title"));
124
+ replaceKeyframeCacheForFile("index.html", new Map(), new Map());
125
+
126
+ expect(cache().has("index.html#title")).toBe(false);
127
+ expect(cache().has("comp.html#title")).toBe(true);
128
+ });
129
+ });
130
+
81
131
  describe("clearKeyframeCacheForElement", () => {
82
132
  it("drops the prefixed, index.html fallback, and bare key for a non-index source", () => {
83
133
  seed("comp.html#box");
@@ -5,6 +5,7 @@
5
5
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
7
  import { trackStudioEvent } from "../utils/studioTelemetry";
8
+ import { sameData } from "../utils/sameData";
8
9
  import { resolveClipTimingBasis, resolveSelectorElementIds, toClipKeyframes } from "./gsapShared";
9
10
  import {
10
11
  deduplicateKeyframes,
@@ -18,6 +19,14 @@ export interface KeyframeCacheDraft {
18
19
  gsapAnimations: Map<string, GsapAnimation[]>;
19
20
  }
20
21
 
22
+ // A rebuilt entry equal to the one shown keeps its object, so re-reading an unchanged file publishes nothing.
23
+ function keepEqualEntries<V>(before: ReadonlyMap<string, V>, draft: Map<string, V>): void {
24
+ for (const [key, value] of draft) {
25
+ const shown = before.get(key);
26
+ if (shown !== undefined && shown !== value && sameData(shown, value)) draft.set(key, shown);
27
+ }
28
+ }
29
+
21
30
  function sameEntries<V>(before: ReadonlyMap<string, V>, after: ReadonlyMap<string, V>): boolean {
22
31
  if (before.size !== after.size) return false;
23
32
  for (const [key, value] of before) {
@@ -42,6 +51,8 @@ export function publishKeyframeCache(edit: (draft: KeyframeCacheDraft) => void):
42
51
  gsapAnimations: new Map(gsapAnimations),
43
52
  };
44
53
  edit(draft);
54
+ keepEqualEntries(keyframeCache, draft.keyframeCache);
55
+ keepEqualEntries(gsapAnimations, draft.gsapAnimations);
45
56
  if (
46
57
  sameEntries(keyframeCache, draft.keyframeCache) &&
47
58
  sameEntries(gsapAnimations, draft.gsapAnimations)
@@ -175,8 +186,16 @@ function cachedElementIdsForFile(
175
186
  const sfPrefix = `${sourceFile}#`;
176
187
  const ids = new Set<string>();
177
188
  for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
178
- if (!key.startsWith(sfPrefix)) continue;
179
- ids.add(key.slice(sfPrefix.length));
189
+ if (key.startsWith(sfPrefix)) ids.add(key.slice(sfPrefix.length));
190
+ }
191
+ if (sourceFile !== "index.html") return ids;
192
+ // Another file's element also writes the `index.html#id` fallback, as the very same entry object.
193
+ for (const [key, entry] of keyframeCache) {
194
+ const hash = key.indexOf("#");
195
+ const id = key.slice(hash + 1);
196
+ if (hash > 0 && !key.startsWith(sfPrefix) && keyframeCache.get(sfPrefix + id) === entry) {
197
+ ids.delete(id);
198
+ }
180
199
  }
181
200
  return ids;
182
201
  }
@@ -88,9 +88,11 @@ function readMutationError(value: unknown, fallback: string): string {
88
88
  return fallback;
89
89
  }
90
90
 
91
+ type GsapMutationRoute = "gsap-mutations" | "gsap-mutations-batch";
92
+
91
93
  export function requestGsapMutation(
92
94
  projectId: string,
93
- route: "gsap-mutations" | "gsap-mutations-batch",
95
+ route: GsapMutationRoute,
94
96
  filePath: string,
95
97
  body: unknown,
96
98
  ): Promise<Response> {
@@ -104,23 +106,49 @@ export function requestGsapMutation(
104
106
  );
105
107
  }
106
108
 
107
- export async function postGsapMutation(
109
+ async function postOwnedGsapMutation(
110
+ route: GsapMutationRoute,
108
111
  projectId: string,
109
112
  filePath: string,
110
- mutation: Record<string, unknown>,
113
+ body: Record<string, unknown>,
111
114
  fallback: string,
112
115
  ): Promise<GsapMutationStatus> {
113
116
  let response: Response;
114
117
  try {
115
- response = await requestGsapMutation(projectId, "gsap-mutations", filePath, mutation);
118
+ response = await requestGsapMutation(projectId, route, filePath, body);
116
119
  } catch (error) {
117
120
  throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
118
121
  cause: error,
119
122
  });
120
123
  }
121
- const body: unknown = await response.json().catch(() => null);
124
+ const payload: unknown = await response.json().catch(() => null);
122
125
  if (!response.ok) {
123
- throw new GsapPreviewConvergenceError(readMutationError(body, fallback));
126
+ throw new GsapPreviewConvergenceError(readMutationError(payload, fallback));
124
127
  }
125
- return readMutationStatus(body);
128
+ return readMutationStatus(payload);
129
+ }
130
+
131
+ export function postGsapMutation(
132
+ projectId: string,
133
+ filePath: string,
134
+ mutation: Record<string, unknown>,
135
+ fallback: string,
136
+ ): Promise<GsapMutationStatus> {
137
+ return postOwnedGsapMutation("gsap-mutations", projectId, filePath, mutation, fallback);
138
+ }
139
+
140
+ /** Every mutation in one request: the file is parsed and written once, with one ownership pair. */
141
+ export function postGsapMutations(
142
+ projectId: string,
143
+ filePath: string,
144
+ mutations: readonly Record<string, unknown>[],
145
+ fallback: string,
146
+ ): Promise<GsapMutationStatus> {
147
+ return postOwnedGsapMutation(
148
+ "gsap-mutations-batch",
149
+ projectId,
150
+ filePath,
151
+ { mutations },
152
+ fallback,
153
+ );
126
154
  }
@@ -0,0 +1,67 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
3
+ import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
4
+ import { describe, expect, it } from "vitest";
5
+ import { resolveKeyframeRetime } from "../components/editor/keyframeRetime";
6
+ import { absoluteToPercentageForAnimation } from "../utils/globalTimeCompiler";
7
+ import { runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
8
+ import { buildExtendedKeyframes } from "./gsapDragPositionCommit";
9
+ import { keyframeEases } from "./gsapShared";
10
+
11
+ const script = (ease: string) =>
12
+ `const tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: { "0%": { x: 0 }, "50%": { x: 600 }, "100%": { x: 1200 } }, duration: 2, ease: "${ease}" }, 1);`;
13
+
14
+ const percentages = (anim: GsapAnimation) => anim.keyframes!.keyframes.map((k) => k.percentage);
15
+ const distinctInRange = (pcts: number[]) =>
16
+ pcts.every((p, i) => p >= 0 && p <= 100 && (i === 0 || p > pcts[i - 1]!));
17
+
18
+ describe.each(["back.out(1.7)", "back.in(1.7)", "elastic.out(1, 0.3)", "steps(4)"])(
19
+ "an outer %s, which does not map time to progress one to one",
20
+ (ease) => {
21
+ const src = script(ease);
22
+ const anim = parseGsapScriptAcorn(src).animations[0]!;
23
+
24
+ it("adding a keyframe past the end keeps every keyframe, distinct and in range, after a reparse", () => {
25
+ const out = buildExtendedKeyframes(anim, 5, { x: 1500 });
26
+ const written = replaceTweenWithKeyframesInScript(src, anim.id, {
27
+ targetSelector: "#x",
28
+ ...keyframeEases(anim),
29
+ ...out,
30
+ })!;
31
+ const back = parseGsapScriptAcorn(written).animations[0]!;
32
+ expect(distinctInRange(percentages(back))).toBe(true);
33
+ expect(back.keyframes!.keyframes.map((k) => k.properties.x)).toEqual([0, 600, 1200, 1500]);
34
+ });
35
+
36
+ it("a keyframe added inside the tween lands within 0-100%", () => {
37
+ const pct = absoluteToPercentageForAnimation(1 + 0.7 * 2, anim)!;
38
+ expect(pct).toBeGreaterThanOrEqual(0);
39
+ expect(pct).toBeLessThanOrEqual(100);
40
+ });
41
+
42
+ it("dragging the last keyframe later keeps every keyframe distinct and in range", () => {
43
+ const retime = resolveKeyframeRetime({
44
+ keyframes: anim.keyframes!.keyframes,
45
+ draggedTweenPct: 100,
46
+ tweenStart: 1,
47
+ tweenDuration: 2,
48
+ dropAbsTime: 4,
49
+ runEase: runEaseOf(anim),
50
+ });
51
+ expect(retime.kind).toBe("resize");
52
+ expect(distinctInRange(retime.pctRemap!.map((r) => r.to))).toBe(true);
53
+ });
54
+ },
55
+ );
56
+
57
+ it("extending a tween under steps(4) loses no keyframe", () => {
58
+ const src = `const tl = gsap.timeline({ paused: true });\ntl.to("#x", { keyframes: { "0%": { x: 0 }, "25%": { x: 60 }, "60%": { x: 120 }, "100%": { x: 30 } }, duration: 2, ease: "steps(4)" }, 1);`;
59
+ const anim = parseGsapScriptAcorn(src).animations[0]!;
60
+ const out = buildExtendedKeyframes(anim, 4, { x: 999 });
61
+ expect(distinctInRange(out.keyframes.map((k) => k.percentage))).toBe(true);
62
+ expect(out.keyframes.map((k) => k.properties.x)).toEqual([0, 60, 120, 30, 999]);
63
+ });
64
+
65
+ it("still times keyframes through an outer ease that rises one to one", () => {
66
+ expect(timeAtProgress("power2.in", 50)).toBeCloseTo(79.37, 1);
67
+ });
@@ -22,7 +22,7 @@ export function liveTween(
22
22
  vars: tween.vars,
23
23
  _from: from,
24
24
  ...(head && { _pt: { d: { _pt: head } } }),
25
- ...(parts && { timeline: { getChildren: () => parts } }),
25
+ ...(parts && { timeline: { getChildren: () => parts, duration: () => tween.duration } }),
26
26
  };
27
27
  }
28
28
 
@@ -47,6 +47,33 @@ export function previewWith(
47
47
  export const tween = (fields: Partial<GsapAnimation>): GsapAnimation =>
48
48
  ({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation;
49
49
 
50
+ /** `#box`'s position as a keyframe step list, parsed and run by GSAP (step `[duration, props]`). */
51
+ export function boxSteps(steps: Array<[number, Record<string, number | string>]>) {
52
+ const duration = steps.reduce((sum, [d]) => sum + d, 0);
53
+ let end = 0;
54
+ const keyframes = steps.map(([d, properties]) => {
55
+ end += d;
56
+ return { percentage: Math.round((end / duration) * 1000) / 10, properties };
57
+ });
58
+ const keys = tween({
59
+ id: "#box-to-0-position",
60
+ method: "to",
61
+ properties: {},
62
+ resolvedStart: 0,
63
+ duration,
64
+ keyframes: { format: "object-array", keyframes },
65
+ });
66
+ let start = 0;
67
+ const parts = steps.map(([d]) => {
68
+ const at = start;
69
+ start += d;
70
+ return { startTime: () => at, duration: () => d };
71
+ });
72
+ const live = (el: Element) =>
73
+ liveTween(el, { start: 0, duration, vars: { keyframes: [] } }, { parts });
74
+ return { keys, live };
75
+ }
76
+
50
77
  /** A `to` tween on `#el` from 0, grouped as the parser would; duration 0 is an immediate-render hold. */
51
78
  export function elTween(
52
79
  id: string,
@@ -1,6 +1,7 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import { elementTargets } from "../utils/elementGsap";
3
3
  import { resolveTweenStart } from "../utils/globalTimeCompiler";
4
+ import { KEYFRAME_PCT_MATCH } from "./gsapShared";
4
5
  import type { ImplicitEndValue } from "./gsapValueAtPlayhead";
5
6
  import type { ReadTween } from "./gsapRuntimeKeyframes";
6
7
 
@@ -23,7 +24,7 @@ interface ParsedTween {
23
24
  seek?: (time: number, suppressEvents?: boolean) => unknown;
24
25
  getChildren?: (nested?: boolean, tweens?: boolean, timelines?: boolean) => ParsedTween[];
25
26
  };
26
- timeline?: { getChildren?: () => ParsedTween[] };
27
+ timeline?: { getChildren?: () => ParsedTween[]; duration?: () => number };
27
28
  targets?: () => unknown[];
28
29
  startTime?: () => number;
29
30
  duration?: () => number;
@@ -246,14 +247,33 @@ const BUILT_IN_EASES = [
246
247
  export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation {
247
248
  const data = anim.keyframes;
248
249
  const parts = tween?.timeline?.getChildren?.() ?? [];
249
- const total = tween?.duration?.() ?? 0;
250
- if (data?.format !== "object-array" || parts.length !== data.keyframes.length || !(total > 0))
251
- return anim;
252
- const ends = parts.map((part) => ((part.startTime?.() ?? 0) + (part.duration?.() ?? 0)) / total);
253
- if (ends.some((end) => !Number.isFinite(end))) return anim;
250
+ if (data?.format !== "object-array" || parts.length !== data.keyframes.length) return anim;
251
+ const ends = parts.map((part) => (part.startTime?.() ?? 0) + (part.duration?.() ?? 0));
252
+ const total = Math.max(...ends);
253
+ if (!(total > 0) || ends.some((end) => !Number.isFinite(end))) return anim;
254
254
  const keyframes = data.keyframes.map((kf, i) => ({
255
255
  ...kf,
256
- percentage: Math.round(ends[i]! * 100000) / 1000,
256
+ percentage: Math.round((ends[i]! / total) * 100000) / 1000,
257
257
  }));
258
258
  return { ...anim, keyframes: { ...data, keyframes } };
259
259
  }
260
+
261
+ /** The keyframe nearest `pct` within {@link KEYFRAME_PCT_MATCH}, or -1: two steps can sit under 1% apart. */
262
+ export function nearestKeyframeIndex(keyframes: { percentage: number }[], pct: number): number {
263
+ let best = -1;
264
+ keyframes.forEach((kf, i) => {
265
+ const off = Math.abs(kf.percentage - pct);
266
+ if (
267
+ off <= KEYFRAME_PCT_MATCH &&
268
+ (best < 0 || off < Math.abs(keyframes[best]!.percentage - pct))
269
+ )
270
+ best = i;
271
+ });
272
+ return best;
273
+ }
274
+
275
+ export function exactKeyframePct(anim: GsapAnimation, tween: ParsedTween | null, pct: number) {
276
+ const authored = anim.keyframes?.keyframes ?? [];
277
+ const i = nearestKeyframeIndex(authored, pct);
278
+ return withExactStepTimes(anim, tween).keyframes?.keyframes[i]?.percentage ?? pct;
279
+ }
@@ -8,6 +8,7 @@
8
8
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
9
9
  import { usePlayerStore } from "../player/store/playerStore";
10
10
  import { getIframeGsap, KEYFRAME_PCT_MATCH, queryIframeElement } from "./gsapShared";
11
+ import { runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
11
12
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
12
13
 
13
14
  // fallow-ignore-next-line complexity
@@ -60,7 +61,7 @@ export function findGsapPositionAnimation(
60
61
 
61
62
  function playheadProximity(a: GsapAnimation, currentTime: number): number {
62
63
  const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
63
- const dur = a.duration ?? 0;
64
+ const dur = resolveTweenDuration(a);
64
65
  if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) return 50;
65
66
  return -Math.round(Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5);
66
67
  }
@@ -71,7 +72,9 @@ function statesValueAt(anim: GsapAnimation, time: number): boolean {
71
72
  if (!(duration > 0)) return false;
72
73
  const pct = ((time - (resolveTweenStart(anim) ?? 0)) / duration) * 100;
73
74
  const near = (keyframe: number) => Math.abs(keyframe - pct) <= KEYFRAME_PCT_MATCH;
74
- if (anim.keyframes) return anim.keyframes.keyframes.some((kf) => near(kf.percentage));
75
+ const runEase = runEaseOf(anim);
76
+ if (anim.keyframes)
77
+ return anim.keyframes.keyframes.some((kf) => near(timeAtProgress(runEase, kf.percentage)));
75
78
  if (anim.method === "from") return near(0);
76
79
  return near(100) || (anim.method === "fromTo" && near(0));
77
80
  }
@@ -582,6 +582,41 @@ describe("tryGsapDragIntercept — motion paths", () => {
582
582
  );
583
583
  });
584
584
 
585
+ it("adds a node at a playhead past a duration-less arc, keeping the other nodes' times", async () => {
586
+ usePlayerStore.setState({
587
+ autoKeyframeEnabled: true,
588
+ activeKeyframePct: null,
589
+ currentTime: 15.9,
590
+ });
591
+ const durationless = { ...motionPathAnim, duration: undefined } as GsapAnimation;
592
+ const commitMutation = vi.fn();
593
+ await tryGsapDragIntercept(
594
+ selection,
595
+ { x: -50, y: 30 },
596
+ [durationless],
597
+ fakeIframe("puck-b", [
598
+ { ...liveTween, vars: { motionPath: { path: [] } }, duration: () => 0.5 },
599
+ ]),
600
+ commitMutation,
601
+ );
602
+ // GSAP plays it 12.17-12.67 s; the nodes stay at 12.17, 12.42 and 12.67 s.
603
+ expect(commitMutation).toHaveBeenCalledWith(
604
+ selection,
605
+ expect.objectContaining({
606
+ type: "replace-with-keyframes",
607
+ position: 12.17,
608
+ duration: 3.73,
609
+ keyframes: [
610
+ { percentage: 0, properties: { x: -184, y: 326 } },
611
+ { percentage: 6.7, properties: { x: 416, y: 804 } },
612
+ { percentage: 13.4, properties: { x: 796, y: 237 } },
613
+ { percentage: 100, properties: { x: -50, y: 30 } },
614
+ ],
615
+ }),
616
+ expect.objectContaining({ label: "Move layer (new keyframe)", keyframeAction: "add" }),
617
+ );
618
+ });
619
+
585
620
  it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
586
621
  const { commitMutation, handled } = await dragMotionPath(50);
587
622
 
@@ -23,6 +23,7 @@ import {
23
23
  computeCurrentPercentage,
24
24
  findExistingPositionWrite,
25
25
  findRotationSetAnimation,
26
+ stepListBlock,
26
27
  } from "./gsapDragCommit";
27
28
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
28
29
  import { isGestureTransactionCommit } from "./gestureTransaction";
@@ -218,7 +219,12 @@ async function planDrag(
218
219
  if (!posAnim) {
219
220
  return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
220
221
  }
221
- if (route === "whole-path") return { status: "persisted" };
222
+ if (route === "whole-path") {
223
+ const step = stepListBlock(posAnim);
224
+ return step
225
+ ? { status: "blocked", reason: "keyframes-uneditable", detail: step }
226
+ : { status: "persisted" };
227
+ }
222
228
  const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
223
229
  return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
224
230
  }