@hyperframes/studio 0.8.115 → 0.8.117

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 (163) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
  3. package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +132 -41
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1184 -1084
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/DomEditOverlay.tsx +1 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  17. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  18. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  19. package/src/components/editor/domGroupMoveBy.ts +95 -0
  20. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  21. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  22. package/src/components/editor/manualEditingAvailability.ts +8 -0
  23. package/src/components/editor/manualOffsetDrag.ts +8 -9
  24. package/src/components/editor/motionPathSelection.test.ts +6 -31
  25. package/src/components/editor/plainTranslate.ts +5 -3
  26. package/src/components/editor/probeSourceElement.ts +2 -1
  27. package/src/components/editor/propertyPanelFont.tsx +3 -2
  28. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  29. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  30. package/src/components/editor/rotationDraft.ts +2 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -1
  32. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  33. package/src/components/editor/useFxCarveNodes.ts +2 -1
  34. package/src/components/editor/useFxLevelling.ts +2 -1
  35. package/src/components/feedback/projectProvenance.ts +2 -1
  36. package/src/components/nle/NLEContext.test.ts +1 -0
  37. package/src/components/nle/NLEContext.tsx +2 -1
  38. package/src/components/nle/NLEPreview.tsx +12 -10
  39. package/src/components/nle/PreviewPoster.tsx +2 -1
  40. package/src/components/renders/useFfmpegStatus.ts +2 -1
  41. package/src/components/renders/useRenderQueue.ts +5 -4
  42. package/src/components/sidebar/AssetsTab.tsx +2 -1
  43. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  44. package/src/contexts/DomEditContext.tsx +4 -0
  45. package/src/hooks/domSelectionTestHarness.ts +23 -0
  46. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  47. package/src/hooks/domStyleCommit.ts +8 -1
  48. package/src/hooks/draggedGsapPosition.ts +10 -0
  49. package/src/hooks/gsapDragCommit.test.ts +75 -164
  50. package/src/hooks/gsapDragCommit.ts +2 -105
  51. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  52. package/src/hooks/gsapEditOutcome.ts +22 -2
  53. package/src/hooks/gsapGroupPreflight.ts +34 -0
  54. package/src/hooks/gsapMutationClient.ts +4 -3
  55. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  56. package/src/hooks/gsapParsedTween.test.ts +62 -0
  57. package/src/hooks/gsapParsedTween.ts +243 -0
  58. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  59. package/src/hooks/gsapPositionDetection.ts +52 -33
  60. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  61. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  62. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  63. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  64. package/src/hooks/gsapResizeIntercept.ts +79 -169
  65. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  66. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  67. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  68. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  69. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  70. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  71. package/src/hooks/gsapScriptCommitTypes.ts +9 -1
  72. package/src/hooks/gsapShared.ts +7 -0
  73. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  74. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  75. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  76. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  77. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  78. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  79. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  80. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  81. package/src/hooks/newTweenTarget.test.ts +41 -76
  82. package/src/hooks/plainRotation.ts +5 -1
  83. package/src/hooks/timelineTimingSync.test.ts +1 -0
  84. package/src/hooks/timelineTimingSync.ts +2 -1
  85. package/src/hooks/useAskAgentModal.ts +2 -1
  86. package/src/hooks/useBlockCatalog.ts +2 -1
  87. package/src/hooks/useCaptionDetection.ts +2 -1
  88. package/src/hooks/useCompositionContentLoader.ts +2 -1
  89. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  90. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  91. package/src/hooks/useDomEditSession.test.tsx +30 -1
  92. package/src/hooks/useDomEditSession.ts +10 -0
  93. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  94. package/src/hooks/useDomEditTextCommits.ts +8 -2
  95. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  96. package/src/hooks/useDomGeometryCommit.ts +7 -9
  97. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  98. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  99. package/src/hooks/useEditHistoryActions.ts +16 -5
  100. package/src/hooks/useElementLifecycleOps.ts +2 -1
  101. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  102. package/src/hooks/useEnableKeyframes.ts +2 -1
  103. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  104. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  105. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  106. package/src/hooks/useFileManager.ts +14 -10
  107. package/src/hooks/useFileTree.ts +3 -2
  108. package/src/hooks/useFrameCapture.ts +5 -1
  109. package/src/hooks/useFreezeFrame.test.ts +39 -3
  110. package/src/hooks/useFreezeFrame.ts +7 -3
  111. package/src/hooks/useGroupCommits.ts +2 -1
  112. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  113. package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
  114. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  115. package/src/hooks/useGsapAwareEditing.ts +17 -8
  116. package/src/hooks/useGsapAwareGroupMove.ts +18 -18
  117. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  118. package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
  119. package/src/hooks/useGsapScriptCommits.ts +21 -17
  120. package/src/hooks/useLintModal.ts +2 -1
  121. package/src/hooks/usePersistentEditHistory.test.ts +302 -3
  122. package/src/hooks/usePersistentEditHistory.ts +50 -17
  123. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  124. package/src/hooks/usePreviewPersistence.ts +13 -11
  125. package/src/hooks/useProjectFileWriter.ts +20 -19
  126. package/src/hooks/useRemoveBackground.ts +2 -1
  127. package/src/hooks/useSdkSession.ts +2 -1
  128. package/src/hooks/useServerConnection.ts +4 -3
  129. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  130. package/src/hooks/useThumbnailStill.test.ts +100 -0
  131. package/src/hooks/useThumbnailStill.ts +37 -0
  132. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  133. package/src/index.ts +4 -0
  134. package/src/player/components/AudioWaveform.tsx +3 -2
  135. package/src/player/components/CompositionThumbnail.tsx +2 -1
  136. package/src/player/components/clipMenuNormalize.ts +2 -1
  137. package/src/player/components/clipPeakMap.ts +2 -1
  138. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  139. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  140. package/src/player/lib/thumbnailScheduler.ts +10 -2
  141. package/src/player/sceneSwap.ts +2 -1
  142. package/src/telemetry/client.ts +2 -1
  143. package/src/utils/blockInstaller.ts +2 -1
  144. package/src/utils/elementGsap.test.ts +9 -0
  145. package/src/utils/elementGsap.ts +7 -0
  146. package/src/utils/gsapSoftReload.test.ts +39 -0
  147. package/src/utils/gsapSoftReload.ts +9 -14
  148. package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
  149. package/src/utils/gsapUndoRestore.ts +3 -6
  150. package/src/utils/previewPrefetch.ts +2 -1
  151. package/src/utils/razorSplitTransaction.ts +12 -8
  152. package/src/utils/softReloadTargets.ts +5 -6
  153. package/src/utils/studioApiFetch.test.ts +75 -0
  154. package/src/utils/studioApiFetch.ts +13 -0
  155. package/src/utils/studioFileHistory.ts +5 -1
  156. package/src/utils/studioHelpers.ts +7 -1
  157. package/src/utils/studioPendingEdits.test.ts +23 -6
  158. package/src/utils/studioPendingEdits.ts +32 -8
  159. package/src/utils/studioTelemetry.ts +2 -1
  160. package/src/utils/timelineCompositionInsert.ts +3 -2
  161. package/src/webmcp/StudioAgentTools.tsx +2 -1
  162. package/dist/chunk-TXZMMOMA.js.map +0 -1
  163. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -0,0 +1,292 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
+ import { usePlayerStore } from "../player/store/playerStore";
4
+ import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeCompiler";
5
+ import { roundTo3 } from "../utils/rounding";
6
+ import {
7
+ materializeIfDynamic,
8
+ parkPlayheadOnKeyframe,
9
+ type GsapDragCommitCallbacks,
10
+ } from "./gsapDragCommit";
11
+ import type { GsapEditOutcome, PlayheadEditRefusal } from "./gsapEditOutcome";
12
+ import {
13
+ findParsedTween,
14
+ parsedImplicitEndValue,
15
+ parsedTweenEase,
16
+ withExactStepTimes,
17
+ withLiveTiming,
18
+ } from "./gsapParsedTween";
19
+ import { KEYFRAME_PCT_MATCH } from "./gsapShared";
20
+ import { tweenReach } from "./gsapTweenReach";
21
+
22
+ type Props = Record<string, number | string>;
23
+ interface Keyframe {
24
+ percentage: number;
25
+ properties: Props;
26
+ ease?: string;
27
+ auto?: boolean;
28
+ }
29
+
30
+ /** A tween's un-animated value of `prop` at its start or end, from GSAP's own parse; null when unknown. */
31
+ export type ImplicitEndValue = (prop: string, end: "start" | "end") => number | null;
32
+
33
+ export interface PlayheadEdit {
34
+ anim: GsapAnimation;
35
+ /** The playhead's composition time, or a keyframe the user selected in the lane. */
36
+ at: { time: number } | { percentage: number };
37
+ /** Every channel here must already be animated by `anim`. */
38
+ values: Record<string, number>;
39
+ implicitEndValue: ImplicitEndValue;
40
+ /** The flat tween's ease as GSAP resolved it, for a tween that authors none. */
41
+ parsedEase?: string | null;
42
+ /** A channel of the edit the tween does not animate yet, held at this value on its keyframes. */
43
+ backfill?: Record<string, number>;
44
+ /** Hold the backfill from the tween's start too, where GSAP would otherwise begin at the old value. */
45
+ holdFromStart?: boolean;
46
+ }
47
+
48
+ export type PlayheadEditPlan =
49
+ | {
50
+ ok: true;
51
+ mutation: {
52
+ type: "replace-with-keyframes";
53
+ animationId: string;
54
+ targetSelector: string;
55
+ position: number;
56
+ duration: number;
57
+ keyframes: Keyframe[];
58
+ easeEach?: string;
59
+ };
60
+ /** A keyframe was added, not only changed: keyframe usage counts it as an add. */
61
+ added: boolean;
62
+ }
63
+ | { ok: false; reason: PlayheadEditRefusal };
64
+
65
+ // GSAP 3.14 defaults: a percentage keyframe eases its segment power1.inOut; an array step is linear.
66
+ const PERCENTAGE_SEGMENT_EASE = "power1.inOut";
67
+ const ARRAY_STEP_EASE = "none";
68
+ const roundPct = (pct: number) => Math.round(pct * 1000) / 1000;
69
+ const isLinear = (ease: string | undefined) => !ease || ease === "none" || ease === "linear";
70
+
71
+ interface Normalized {
72
+ keyframes: Keyframe[];
73
+ easeEach?: string;
74
+ }
75
+
76
+ function refuse(reason: PlayheadEditRefusal): { ok: false; reason: PlayheadEditRefusal } {
77
+ return { ok: false, reason };
78
+ }
79
+
80
+ // fallow-ignore-next-line complexity
81
+ function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefusal } {
82
+ const { anim } = edit;
83
+ const data = anim.keyframes;
84
+ if (data) {
85
+ if (!isLinear(anim.ease) || !isLinear(data.ease)) return { reason: "eased-keyframes" };
86
+ if (data.format === "simple-array") return { reason: "simple-array-keyframes" };
87
+ const arrayStep = data.format === "object-array";
88
+ return {
89
+ keyframes: data.keyframes.map((kf) => ({
90
+ ...kf,
91
+ properties: { ...kf.properties },
92
+ ...(arrayStep ? { ease: kf.ease ?? ARRAY_STEP_EASE } : {}),
93
+ })),
94
+ ...(data.easeEach && !arrayStep ? { easeEach: data.easeEach } : {}),
95
+ };
96
+ }
97
+ // The flat tween's ease becomes easeEach: a top-level ease would leave each segment power1.inOut.
98
+ const easeEach = anim.ease ?? edit.parsedEase;
99
+ if (!easeEach) return { reason: "unknown-ease" };
100
+ const to = { percentage: 100, properties: { ...anim.properties } };
101
+ if (anim.method === "to") return { keyframes: [to], easeEach };
102
+ if (anim.method === "fromTo") {
103
+ return { keyframes: [{ percentage: 0, properties: { ...anim.fromProperties } }, to], easeEach };
104
+ }
105
+ if (anim.method === "from") {
106
+ const end: Props = {};
107
+ for (const prop of Object.keys(anim.properties)) {
108
+ const value = edit.implicitEndValue(prop, "end");
109
+ if (value == null) return { reason: "implicit-end-unknown" };
110
+ end[prop] = value;
111
+ }
112
+ return {
113
+ keyframes: [
114
+ { percentage: 0, properties: { ...anim.properties } },
115
+ { percentage: 100, properties: end },
116
+ ],
117
+ easeEach,
118
+ };
119
+ }
120
+ return { reason: "not-a-tween" };
121
+ }
122
+
123
+ function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") {
124
+ const carrying = keyframes.filter((kf) => kf.properties[prop] != null);
125
+ const kf = side === "first" ? carrying[0] : carrying.at(-1);
126
+ return kf ? { percentage: kf.percentage, value: kf.properties[prop]! } : null;
127
+ }
128
+
129
+ function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) {
130
+ const hit = keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH);
131
+ if (hit) {
132
+ Object.assign(hit.properties, properties);
133
+ return hit;
134
+ }
135
+ const added = { percentage, properties, ...(ease ? { ease } : {}) };
136
+ keyframes.push(added);
137
+ return added;
138
+ }
139
+
140
+ /** Every channel the tween animates, held at its value at the tween's authored start or end. */
141
+ function heldEnds(
142
+ norm: Normalized,
143
+ end: "start" | "end",
144
+ implicit: ImplicitEndValue,
145
+ backfilled: Record<string, number>,
146
+ ) {
147
+ const props = new Set(norm.keyframes.flatMap((kf) => Object.keys(kf.properties)));
148
+ const held: Props = {};
149
+ for (const prop of props) {
150
+ const known = valueAt(norm.keyframes, prop, end === "start" ? "first" : "last");
151
+ // A channel holds its last keyframe after it; only a start can be implicit.
152
+ const value =
153
+ known && (end === "end" || known.percentage <= 0)
154
+ ? known.value
155
+ : (backfilled[prop] ?? implicit(prop, "start"));
156
+ if (value == null) return null;
157
+ held[prop] = value;
158
+ }
159
+ return held;
160
+ }
161
+
162
+ /** On a keyframe, change it; between two, add one at the playhead; outside the tween, add one there
163
+ * and keep the authored ends. Values come from the file's tween or GSAP's parse, never the DOM. */
164
+ // fallow-ignore-next-line complexity
165
+ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
166
+ const { anim, values } = edit;
167
+ const start = resolveTweenStart(anim);
168
+ const duration = resolveTweenDuration(anim);
169
+ if (start == null || !(duration > 0)) return refuse("no-timing");
170
+ const norm = normalize(edit);
171
+ if ("reason" in norm) return refuse(norm.reason);
172
+ const keyframes = norm.keyframes;
173
+ const authored = keyframes.length;
174
+ // GSAP runs `scale` beside scaleX/scaleY and the longhands win, so a per-axis edit splits it.
175
+ if ("scaleX" in values || "scaleY" in values) {
176
+ for (const kf of keyframes) {
177
+ const { scale, ...rest } = kf.properties;
178
+ if (scale != null) kf.properties = { scaleX: scale, scaleY: scale, ...rest };
179
+ }
180
+ }
181
+ const backfilled: Record<string, number> = {};
182
+ for (const [prop, value] of Object.entries(edit.backfill ?? {})) {
183
+ if (!(prop in values) || keyframes.some((kf) => kf.properties[prop] != null)) continue;
184
+ if (edit.holdFromStart && !keyframes.some((kf) => kf.percentage <= 0))
185
+ keyframes.unshift({ percentage: 0, properties: {} });
186
+ for (const kf of keyframes) kf.properties[prop] = value;
187
+ backfilled[prop] = value;
188
+ }
189
+ if (Object.keys(values).some((prop) => !keyframes.some((kf) => kf.properties[prop] != null)))
190
+ return refuse("implicit-end-unknown");
191
+ let position = start;
192
+ let span = duration;
193
+
194
+ const pct =
195
+ "percentage" in edit.at
196
+ ? edit.at.percentage
197
+ : roundPct(((edit.at.time - start) / duration) * 100);
198
+ if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) {
199
+ const at = Math.min(100, Math.max(0, pct));
200
+ const next = [...keyframes]
201
+ .sort((a, b) => a.percentage - b.percentage)
202
+ .find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH);
203
+ // The new keyframe splits next's segment, so it takes next's ease.
204
+ upsert(keyframes, at, { ...values }, next?.ease);
205
+ } else {
206
+ const time = (edit.at as { time: number }).time;
207
+ const before = time < start;
208
+ const held = heldEnds(norm, before ? "start" : "end", edit.implicitEndValue, backfilled);
209
+ if (!held) return refuse("implicit-end-unknown");
210
+ position = before ? time : start;
211
+ span = before ? start + duration - time : time - start;
212
+ for (const kf of keyframes) {
213
+ kf.percentage = roundPct(
214
+ ((start + (kf.percentage / 100) * duration - position) / span) * 100,
215
+ );
216
+ }
217
+ const oldEdge = roundPct((((before ? start : start + duration) - position) / span) * 100);
218
+ // The span the tween gains is new and linear; the authored segments keep their eases.
219
+ const linear = isLinear(norm.easeEach ?? PERCENTAGE_SEGMENT_EASE) ? undefined : "none";
220
+ const edge = upsert(keyframes, oldEdge, held, linear);
221
+ if (before && linear) edge.ease ??= linear;
222
+ upsert(keyframes, before ? 0 : 100, { ...held, ...values }, before ? undefined : linear);
223
+ }
224
+
225
+ keyframes.sort((a, b) => a.percentage - b.percentage);
226
+ return {
227
+ ok: true,
228
+ mutation: {
229
+ type: "replace-with-keyframes",
230
+ animationId: anim.id,
231
+ targetSelector: anim.targetSelector,
232
+ position: roundTo3(position),
233
+ duration: roundTo3(span),
234
+ keyframes,
235
+ ...(norm.easeEach ? { easeEach: norm.easeEach } : {}),
236
+ },
237
+ added: keyframes.length > authored,
238
+ };
239
+ }
240
+
241
+ /** commitValueAtPlayhead's plan, writing nothing, so a group can plan every member first. */
242
+ export function planValueEdit(
243
+ selection: DomEditSelection,
244
+ anim: GsapAnimation,
245
+ values: Record<string, number>,
246
+ iframe: HTMLIFrameElement | null,
247
+ { backfill, holdFromStart }: Pick<PlayheadEdit, "backfill" | "holdFromStart"> = {},
248
+ ): PlayheadEditPlan {
249
+ // One keyframe of a tween its siblings share would move them all.
250
+ if (tweenReach(anim, selection.element) === "shared") return refuse("shared-tween");
251
+ const { activeKeyframePct, currentTime } = usePlayerStore.getState();
252
+ const tween = findParsedTween(iframe, selection.element, anim);
253
+ const timed = withExactStepTimes(anim, tween);
254
+ return planValueAtPlayhead({
255
+ anim: withLiveTiming(timed, tween),
256
+ at: activeKeyframePct != null ? { percentage: activeKeyframePct } : { time: currentTime },
257
+ values,
258
+ backfill,
259
+ holdFromStart,
260
+ implicitEndValue: parsedImplicitEndValue(tween),
261
+ parsedEase: parsedTweenEase(iframe, tween),
262
+ });
263
+ }
264
+
265
+ /** Writes `values` into `anim` at the playhead (or the keyframe selected in the lane). */
266
+ export async function commitValueAtPlayhead(
267
+ selection: DomEditSelection,
268
+ anim: GsapAnimation,
269
+ values: Record<string, number>,
270
+ iframe: HTMLIFrameElement | null,
271
+ callbacks: GsapDragCommitCallbacks,
272
+ options: Pick<PlayheadEdit, "backfill" | "holdFromStart"> & {
273
+ label: string;
274
+ beforeReload?: () => void;
275
+ },
276
+ ): Promise<GsapEditOutcome> {
277
+ await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
278
+ const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
279
+ const plan = planValueEdit(selection, anim, values, iframe, options);
280
+ if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
281
+ await callbacks.commitMutation(selection, plan.mutation, {
282
+ label: options.label,
283
+ softReload: true,
284
+ beforeReload: options.beforeReload,
285
+ ...(plan.added && { keyframeAction: "add" as const }),
286
+ });
287
+ if (activeKeyframePct != null) {
288
+ setActiveKeyframePct(null);
289
+ parkPlayheadOnKeyframe(anim, activeKeyframePct);
290
+ }
291
+ return { status: "persisted" };
292
+ }
@@ -26,6 +26,29 @@ function recordingCallbacks(): {
26
26
  };
27
27
  }
28
28
 
29
+ /** Rotates a 2s `#box` tween made of `fields` to 50deg at its end; returns what was committed. */
30
+ async function rotateTo50AtEnd(fields: Record<string, unknown>) {
31
+ const anim = {
32
+ id: "#box-rotate",
33
+ targetSelector: "#box",
34
+ method: "to",
35
+ resolvedStart: 0,
36
+ duration: 2,
37
+ ...fields,
38
+ } as unknown as GsapAnimation;
39
+ const { mutations, callbacks } = recordingCallbacks();
40
+ await commitWholePropertyOffset(
41
+ selection(),
42
+ anim,
43
+ { rotation: 50 },
44
+ 100,
45
+ null,
46
+ callbacks,
47
+ "Rotate",
48
+ );
49
+ return mutations;
50
+ }
51
+
29
52
  describe("commitWholePropertyOffset", () => {
30
53
  it("shifts every keyframe of a keyframed tween by the delta at the nearest keyframe", async () => {
31
54
  const anim = {
@@ -159,4 +182,24 @@ describe("commitWholePropertyOffset", () => {
159
182
  { type: "update-properties", animationId: "#box-hold", properties: { width: 300 } },
160
183
  ]);
161
184
  });
185
+
186
+ it("keeps a linear flat tween linear: its ease becomes the keyframes' easeEach", async () => {
187
+ // A tween-level ease leaves each percentage segment power1.inOut, so the box drifts mid-segment.
188
+ const mutations = await rotateTo50AtEnd({ ease: "none", properties: { rotation: 40 } });
189
+ expect(mutations[0]).toMatchObject({ type: "replace-with-keyframes", easeEach: "none" });
190
+ expect(mutations[0]).not.toHaveProperty("ease", "none");
191
+ });
192
+
193
+ it("keeps a keyframed tween's easeEach", async () => {
194
+ const mutations = await rotateTo50AtEnd({
195
+ keyframes: {
196
+ easeEach: "none",
197
+ keyframes: [
198
+ { percentage: 0, properties: { rotation: 10 } },
199
+ { percentage: 100, properties: { rotation: 40 } },
200
+ ],
201
+ },
202
+ });
203
+ expect(mutations[0]).toMatchObject({ easeEach: "none" });
204
+ });
162
205
  });
@@ -6,7 +6,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
8
8
  import { roundTo3 } from "../utils/rounding";
9
- import { PROPERTY_DEFAULTS } from "./gsapShared";
9
+ import { keyframeEases, PROPERTY_DEFAULTS } from "./gsapShared";
10
10
  import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
11
11
  import { materializeIfDynamic, type GsapDragCommitCallbacks } from "./gsapDragCommit";
12
12
 
@@ -38,7 +38,6 @@ export async function commitWholePropertyOffset(
38
38
 
39
39
  const ts = resolveTweenStart(effectiveAnim);
40
40
  const td = resolveTweenDuration(effectiveAnim);
41
- const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease;
42
41
  const keys = Object.keys(newValues);
43
42
  const at = (props: Record<string, number | string>, key: string) =>
44
43
  typeof props[key] === "number" ? (props[key] as number) : (PROPERTY_DEFAULTS[key] ?? 0);
@@ -79,7 +78,7 @@ export async function commitWholePropertyOffset(
79
78
  position: roundTo3(ts ?? 0),
80
79
  duration: roundTo3(td || 1),
81
80
  keyframes: shifted,
82
- ease,
81
+ ...keyframeEases(effectiveAnim),
83
82
  },
84
83
  { label, softReload: true },
85
84
  );
@@ -14,6 +14,7 @@ import {
14
14
  synthesizeFlatTweenKeyframes,
15
15
  type MergeableKeyframe,
16
16
  } from "./gsapTweenSynth";
17
+ import { studioApiFetch } from "../utils/studioApiFetch";
17
18
 
18
19
  export { resolveSelectorElementIds };
19
20
 
@@ -79,7 +80,7 @@ async function requestParsedAnimations(
79
80
  ): Promise<ParsedGsapAnimations | null> {
80
81
  if (!isPreviewBooted(projectId) && !(await whenPreviewBooted(projectId))) return null;
81
82
  try {
82
- const res = await fetch(
83
+ const res = await studioApiFetch(
83
84
  `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
84
85
  // Always revalidate; an unchanged file answers 304. No per-call timestamp
85
86
  // (a deterministic-render no-no).
@@ -13,57 +13,30 @@ import { afterEach, describe, expect, it, vi } from "vitest";
13
13
  import { parseGsapScript } from "@hyperframes/core/gsap-parser";
14
14
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
15
15
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
16
- import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
16
+ import { mountGroupSiblings, stableSelectionFor } from "./domSelectionTestHarness";
17
17
  import { resolveSelectorElementIds } from "./gsapShared";
18
18
  import { ensureElementAddressable } from "./gsapScriptCommitHelpers";
19
19
  import {
20
20
  commitStaticGsapPosition,
21
21
  commitStaticGsapRotation,
22
22
  commitStaticGsapSize,
23
- commitKeyframedSizeFromResize,
24
23
  commitWholePathOffset,
25
24
  findExistingPositionWrite,
26
25
  } from "./gsapDragCommit";
27
26
  import { promoteSetToKeyframes } from "./useEnableKeyframes";
27
+ import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
28
28
 
29
29
  afterEach(() => {
30
30
  document.body.innerHTML = "";
31
31
  });
32
32
 
33
- function selectionFor(el: HTMLElement): DomEditSelection {
34
- const selector = buildStableSelector(el);
35
- return {
36
- element: el,
37
- id: el.id || undefined,
38
- hfId: el.getAttribute("data-hf-id") || undefined,
39
- selector,
40
- selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
41
- sourceFile: "index.html",
42
- dataAttributes: { start: "0", duration: "2" },
43
- } as unknown as DomEditSelection;
44
- }
45
-
46
- /** Five class-only siblings, each with an id so attribution is nameable. */
47
- function mountGroupSiblings(): HTMLElement[] {
48
- document.body.innerHTML = `
49
- <div id="scene" class="clip" data-start="0" data-duration="2">
50
- <div class="group" id="group-0"></div>
51
- <div class="group" id="group-1"></div>
52
- <div class="group" id="group-2"></div>
53
- <div class="group" id="group-3"></div>
54
- <div class="group" id="group-4"></div>
55
- </div>
56
- `;
57
- return Array.from(document.querySelectorAll<HTMLElement>(".group"));
58
- }
59
-
60
33
  /**
61
34
  * The selection production hands these writers: the element HAS an id in the
62
35
  * DOM (so resolveSelectorElementIds can name it) but the SELECTION carries none,
63
36
  * which is the id-less shape buildStableSelector answers with a bare class.
64
37
  */
65
38
  function classOnlySelection(el: HTMLElement): DomEditSelection {
66
- return { ...selectionFor(el), id: undefined, selector: ".group" } as DomEditSelection;
39
+ return { ...stableSelectionFor(el), id: undefined, selector: ".group" } as DomEditSelection;
67
40
  }
68
41
 
69
42
  /** The elements a written targetSelector actually attributes the tween to. */
@@ -94,7 +67,7 @@ describe("ensureElementAddressable — add-animation button", () => {
94
67
  </div>
95
68
  `;
96
69
  const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
97
- const selection = selectionFor(el);
70
+ const selection = stableSelectionFor(el);
98
71
  expect(selection.selector).toBe(".group");
99
72
 
100
73
  const { selector, autoId } = ensureElementAddressable(selection);
@@ -109,20 +82,20 @@ describe("ensureElementAddressable — add-animation button", () => {
109
82
  document.body.innerHTML = `<div id="box" class="card"></div>`;
110
83
  const el = document.querySelector<HTMLElement>("#box")!;
111
84
 
112
- expect(ensureElementAddressable(selectionFor(el)).selector).toBe("#box");
85
+ expect(ensureElementAddressable(stableSelectionFor(el)).selector).toBe("#box");
113
86
  });
114
87
 
115
88
  it("keeps an already-unique class selector as authored", () => {
116
89
  document.body.innerHTML = `<div id="scene"><div class="header"></div></div>`;
117
90
  const el = document.querySelector<HTMLElement>(".header")!;
118
91
 
119
- expect(ensureElementAddressable(selectionFor(el)).selector).toBe(".header");
92
+ expect(ensureElementAddressable(stableSelectionFor(el)).selector).toBe(".header");
120
93
  });
121
94
 
122
95
  it("still mints an id when there is no live element to disambiguate against", () => {
123
96
  document.body.innerHTML = `<div id="scene"><div></div></div>`;
124
97
  const el = document.querySelector<HTMLElement>("#scene > div")!;
125
- const selection = { ...selectionFor(el), selector: undefined } as DomEditSelection;
98
+ const selection = { ...stableSelectionFor(el), selector: undefined } as DomEditSelection;
126
99
 
127
100
  const { selector, autoId } = ensureElementAddressable(selection);
128
101
 
@@ -131,25 +104,28 @@ describe("ensureElementAddressable — add-animation button", () => {
131
104
  });
132
105
  });
133
106
 
107
+ /** Moves the middle of five class-only siblings with no position yet; returns the selector written. */
108
+ async function positionMiddleSibling(): Promise<string> {
109
+ const groups = mountGroupSiblings(5, true);
110
+ const { mutations, callbacks } = recorder();
111
+ await commitStaticGsapPosition(
112
+ classOnlySelection(groups[2]!),
113
+ { x: 10, y: 10 },
114
+ { x: 0, y: 0 },
115
+ ".group",
116
+ null,
117
+ callbacks,
118
+ );
119
+ return writtenTargets(mutations)[0]!;
120
+ }
121
+
134
122
  describe("gsapDragCommit — new-tween targets", () => {
135
123
  it("commitStaticGsapPosition authors the new set against one element", async () => {
136
- const groups = mountGroupSiblings();
137
- const { mutations, callbacks } = recorder();
138
-
139
- await commitStaticGsapPosition(
140
- classOnlySelection(groups[2]!),
141
- { x: 10, y: 10 },
142
- { x: 0, y: 0 },
143
- ".group",
144
- null,
145
- callbacks,
146
- );
147
-
148
- expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-2"]);
124
+ expect(attributedTo(await positionMiddleSibling())).toEqual(["group-2"]);
149
125
  });
150
126
 
151
127
  it("commitStaticGsapRotation authors the new set against one element", async () => {
152
- const groups = mountGroupSiblings();
128
+ const groups = mountGroupSiblings(5, true);
153
129
  const { mutations, callbacks } = recorder();
154
130
 
155
131
  await commitStaticGsapRotation(classOnlySelection(groups[1]!), 42, ".group", null, callbacks);
@@ -158,7 +134,7 @@ describe("gsapDragCommit — new-tween targets", () => {
158
134
  });
159
135
 
160
136
  it("commitStaticGsapSize authors the new set against one element", async () => {
161
- const groups = mountGroupSiblings();
137
+ const groups = mountGroupSiblings(5, true);
162
138
  const { mutations, callbacks } = recorder();
163
139
 
164
140
  await commitStaticGsapSize(
@@ -172,34 +148,34 @@ describe("gsapDragCommit — new-tween targets", () => {
172
148
  expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-4"]);
173
149
  });
174
150
 
175
- it("commitKeyframedSizeFromResize authors the new keyframe tween against one element", async () => {
176
- const groups = mountGroupSiblings();
151
+ it("a keyframe edit refuses a tween its siblings share instead of moving them all", async () => {
152
+ const groups = mountGroupSiblings(5, true);
177
153
  const { mutations, callbacks } = recorder();
178
- const animatedTween = {
154
+ const sharedTween = {
179
155
  id: "t1",
180
156
  targetSelector: ".group",
181
157
  method: "to",
182
- properties: {},
158
+ properties: { width: 120 },
183
159
  resolvedStart: 0,
184
160
  duration: 2,
185
- keyframes: { keyframes: [{ percentage: 0, properties: { x: 0 } }] },
161
+ ease: "none",
186
162
  } as unknown as GsapAnimation;
187
163
 
188
- const handled = await commitKeyframedSizeFromResize(
164
+ const outcome = await commitValueAtPlayhead(
189
165
  classOnlySelection(groups[3]!),
190
- { width: 80, height: 40 },
191
- ".group",
166
+ sharedTween,
167
+ { width: 80 },
192
168
  null,
193
- animatedTween,
194
169
  callbacks,
170
+ { label: "Resize" },
195
171
  );
196
172
 
197
- expect(handled).toBe(true);
198
- expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-3"]);
173
+ expect(outcome).toMatchObject({ status: "blocked", detail: "shared-tween" });
174
+ expect(mutations).toEqual([]);
199
175
  });
200
176
 
201
177
  it("commitStaticGsapPosition replaces a corrupt keyframed hold against one element", async () => {
202
- const groups = mountGroupSiblings();
178
+ const groups = mountGroupSiblings(5, true);
203
179
  const { mutations, callbacks } = recorder();
204
180
  const corruptHold = {
205
181
  id: "hold-1",
@@ -225,7 +201,7 @@ describe("gsapDragCommit — new-tween targets", () => {
225
201
 
226
202
  describe("gsapDragCommit — retargeting an existing tween is left alone", () => {
227
203
  it("commitWholePathOffset keeps the tween's own group target", async () => {
228
- const groups = mountGroupSiblings();
204
+ const groups = mountGroupSiblings(5, true);
229
205
  const { mutations, callbacks } = recorder();
230
206
  const groupTween = {
231
207
  id: "t-group",
@@ -261,7 +237,7 @@ describe("gsapDragCommit — retargeting an existing tween is left alone", () =>
261
237
  */
262
238
  describe("a re-nudge updates its own previous write instead of stacking a second one", () => {
263
239
  it("finds the write the first nudge authored", async () => {
264
- const groups = mountGroupSiblings();
240
+ const groups = mountGroupSiblings(5, true);
265
241
  const selection = classOnlySelection(groups[2]!);
266
242
  const first = recorder();
267
243
 
@@ -309,7 +285,7 @@ gsap.set(${JSON.stringify(written)}, { x: 10, y: 10 });
309
285
  */
310
286
  describe("useEnableKeyframes — rewriting an existing tween keeps its group target", () => {
311
287
  it("promoteSetToKeyframes leaves a group-authored set aimed at the group", async () => {
312
- const groups = mountGroupSiblings();
288
+ const groups = mountGroupSiblings(5, true);
313
289
  const mutations: Array<Record<string, unknown>> = [];
314
290
  const setAnim = {
315
291
  id: "set-group",
@@ -339,18 +315,7 @@ describe("useEnableKeyframes — rewriting an existing tween keeps its group tar
339
315
 
340
316
  describe("the written selector survives the real writer and parser", () => {
341
317
  it("re-parses to a tween attributed to the one element it targeted", async () => {
342
- const groups = mountGroupSiblings();
343
- const { mutations, callbacks } = recorder();
344
-
345
- await commitStaticGsapPosition(
346
- classOnlySelection(groups[2]!),
347
- { x: 10, y: 10 },
348
- { x: 0, y: 0 },
349
- ".group",
350
- null,
351
- callbacks,
352
- );
353
- const written = writtenTargets(mutations)[0]!;
318
+ const written = await positionMiddleSibling();
354
319
 
355
320
  const script = `
356
321
  const tl = gsap.timeline({ paused: true });
@@ -27,7 +27,11 @@ export function savePlainRotation(
27
27
  ? buildClearRotationPatches(element)
28
28
  : [];
29
29
  if (patches.length) clearStudioRotation(element);
30
- const drawn = applyCssRotation(element, next.angle, patches.length ? undefined : next.plain);
30
+ const drawn = applyCssRotation(
31
+ element,
32
+ next.angle,
33
+ patches.length ? undefined : (next.plain ?? undefined),
34
+ );
31
35
  patches.push(...drawn);
32
36
  const turn = drawn.at(-1)!;
33
37
  return commitPositionPatchToHtml(selection, patches, {
@@ -593,6 +593,7 @@ describe("a timing edit's soft reload restores what GSAP wrote from the files",
593
593
 
594
594
  expect(fetchMock).toHaveBeenCalledWith(
595
595
  expect.stringContaining("/files/compositions%2Fsub.html"),
596
+ { credentials: "omit" },
596
597
  );
597
598
  expect(reloadPreview).not.toHaveBeenCalled();
598
599
  expect(wt.getAttribute("style")).toBe("left: 700px;");
@@ -28,12 +28,13 @@ import {
28
28
  serializeStudioFileMutations,
29
29
  type StudioProjectFileWriter,
30
30
  } from "../utils/studioFileMutationCoordinator";
31
+ import { studioApiFetch } from "../utils/studioApiFetch";
31
32
 
32
33
  export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
33
34
  if (targetPath.includes("\0") || targetPath.includes("..")) {
34
35
  throw new Error(`Unsafe path: ${targetPath}`);
35
36
  }
36
- const response = await fetch(
37
+ const response = await studioApiFetch(
37
38
  `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(targetPath)}`,
38
39
  );
39
40
  if (!response.ok) {