@hyperframes/studio 0.8.115 → 0.8.116

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 (149) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-BoUM6Xqb.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-CAvZ0AA-.js → index-DYNaXPVf.js} +201 -201
  4. package/dist/assets/{index-CVuorIMv.js → index-Dr3hNpvS.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 +104 -22
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1131 -1044
  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/domGroupMoveBy.test.tsx +252 -0
  16. package/src/components/editor/domGroupMoveBy.ts +95 -0
  17. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  18. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  19. package/src/components/editor/manualEditingAvailability.ts +8 -0
  20. package/src/components/editor/manualOffsetDrag.ts +5 -6
  21. package/src/components/editor/motionPathSelection.test.ts +6 -31
  22. package/src/components/editor/probeSourceElement.ts +2 -1
  23. package/src/components/editor/propertyPanelFont.tsx +3 -2
  24. package/src/components/editor/useColorGradingController.ts +2 -1
  25. package/src/components/editor/useFxCarveNodes.ts +2 -1
  26. package/src/components/editor/useFxLevelling.ts +2 -1
  27. package/src/components/feedback/projectProvenance.ts +2 -1
  28. package/src/components/nle/NLEContext.test.ts +1 -0
  29. package/src/components/nle/NLEContext.tsx +2 -1
  30. package/src/components/nle/NLEPreview.tsx +12 -10
  31. package/src/components/nle/PreviewPoster.tsx +2 -1
  32. package/src/components/renders/useFfmpegStatus.ts +2 -1
  33. package/src/components/renders/useRenderQueue.ts +5 -4
  34. package/src/components/sidebar/AssetsTab.tsx +2 -1
  35. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  36. package/src/contexts/DomEditContext.tsx +4 -0
  37. package/src/hooks/domSelectionTestHarness.ts +23 -0
  38. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  39. package/src/hooks/domStyleCommit.ts +8 -1
  40. package/src/hooks/draggedGsapPosition.ts +10 -0
  41. package/src/hooks/gsapDragCommit.test.ts +75 -164
  42. package/src/hooks/gsapDragCommit.ts +2 -105
  43. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  44. package/src/hooks/gsapEditOutcome.ts +22 -2
  45. package/src/hooks/gsapGroupPreflight.ts +34 -0
  46. package/src/hooks/gsapMutationClient.ts +4 -3
  47. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  48. package/src/hooks/gsapParsedTween.test.ts +62 -0
  49. package/src/hooks/gsapParsedTween.ts +243 -0
  50. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  51. package/src/hooks/gsapPositionDetection.ts +52 -33
  52. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  53. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  54. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  55. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  56. package/src/hooks/gsapResizeIntercept.ts +79 -169
  57. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  58. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  59. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  60. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  61. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  62. package/src/hooks/gsapScriptCommitTypes.ts +8 -0
  63. package/src/hooks/gsapShared.ts +7 -0
  64. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  65. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  66. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  67. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  68. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  69. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  70. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  71. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  72. package/src/hooks/newTweenTarget.test.ts +41 -76
  73. package/src/hooks/timelineTimingSync.test.ts +1 -0
  74. package/src/hooks/timelineTimingSync.ts +2 -1
  75. package/src/hooks/useAskAgentModal.ts +2 -1
  76. package/src/hooks/useBlockCatalog.ts +2 -1
  77. package/src/hooks/useCaptionDetection.ts +2 -1
  78. package/src/hooks/useCompositionContentLoader.ts +2 -1
  79. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  80. package/src/hooks/useDomEditSession.test.tsx +30 -1
  81. package/src/hooks/useDomEditSession.ts +10 -0
  82. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  83. package/src/hooks/useDomEditTextCommits.ts +8 -2
  84. package/src/hooks/useDomGeometryCommit.ts +1 -0
  85. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  86. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  87. package/src/hooks/useEditHistoryActions.ts +16 -5
  88. package/src/hooks/useElementLifecycleOps.ts +2 -1
  89. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  90. package/src/hooks/useEnableKeyframes.ts +2 -1
  91. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  92. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  94. package/src/hooks/useFileManager.ts +14 -10
  95. package/src/hooks/useFileTree.ts +3 -2
  96. package/src/hooks/useFrameCapture.ts +5 -1
  97. package/src/hooks/useFreezeFrame.ts +3 -2
  98. package/src/hooks/useGroupCommits.ts +2 -1
  99. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  100. package/src/hooks/useGsapAwareEditing.test.tsx +17 -29
  101. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  102. package/src/hooks/useGsapAwareEditing.ts +5 -0
  103. package/src/hooks/useGsapAwareGroupMove.ts +16 -16
  104. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  105. package/src/hooks/useGsapScriptCommits.test.tsx +3 -1
  106. package/src/hooks/useGsapScriptCommits.ts +12 -10
  107. package/src/hooks/useLintModal.ts +2 -1
  108. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  109. package/src/hooks/usePersistentEditHistory.ts +47 -8
  110. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  111. package/src/hooks/usePreviewPersistence.ts +13 -11
  112. package/src/hooks/useProjectFileWriter.ts +20 -19
  113. package/src/hooks/useRemoveBackground.ts +2 -1
  114. package/src/hooks/useSdkSession.ts +2 -1
  115. package/src/hooks/useServerConnection.ts +4 -3
  116. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  117. package/src/hooks/useThumbnailStill.test.ts +100 -0
  118. package/src/hooks/useThumbnailStill.ts +37 -0
  119. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  120. package/src/index.ts +4 -0
  121. package/src/player/components/AudioWaveform.tsx +3 -2
  122. package/src/player/components/CompositionThumbnail.tsx +2 -1
  123. package/src/player/components/clipMenuNormalize.ts +2 -1
  124. package/src/player/components/clipPeakMap.ts +2 -1
  125. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  126. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  127. package/src/player/lib/thumbnailScheduler.ts +10 -2
  128. package/src/player/sceneSwap.ts +2 -1
  129. package/src/telemetry/client.ts +2 -1
  130. package/src/utils/blockInstaller.ts +2 -1
  131. package/src/utils/elementGsap.test.ts +9 -0
  132. package/src/utils/elementGsap.ts +7 -0
  133. package/src/utils/gsapSoftReload.ts +2 -3
  134. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  135. package/src/utils/gsapUndoRestore.ts +3 -6
  136. package/src/utils/previewPrefetch.ts +2 -1
  137. package/src/utils/razorSplitTransaction.ts +12 -8
  138. package/src/utils/softReloadTargets.ts +1 -3
  139. package/src/utils/studioApiFetch.test.ts +75 -0
  140. package/src/utils/studioApiFetch.ts +13 -0
  141. package/src/utils/studioFileHistory.ts +4 -1
  142. package/src/utils/studioHelpers.ts +7 -1
  143. package/src/utils/studioPendingEdits.test.ts +23 -6
  144. package/src/utils/studioPendingEdits.ts +32 -8
  145. package/src/utils/studioTelemetry.ts +2 -1
  146. package/src/utils/timelineCompositionInsert.ts +3 -2
  147. package/src/webmcp/StudioAgentTools.tsx +2 -1
  148. package/dist/chunk-TXZMMOMA.js.map +0 -1
  149. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -1,8 +1,7 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
- import { PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants";
3
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
3
  import { usePlayerStore } from "../player/store/playerStore";
5
- import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
4
+ import { resolveTweenStart } from "../utils/globalTimeCompiler";
6
5
  import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared";
7
6
  import { roundTo3 } from "../utils/rounding";
8
7
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
@@ -10,8 +9,9 @@ import {
10
9
  type GsapDragCommitCallbacks,
11
10
  computeCurrentPercentage,
12
11
  parkPlayheadOnKeyframe,
13
- materializeIfDynamic,
14
12
  } from "./gsapDragCommit";
13
+ import type { GsapEditOutcome } from "./gsapEditOutcome";
14
+ import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
15
15
 
16
16
  /**
17
17
  * The tween's keyframes with one inserted at `percentage`. Any existing keyframe
@@ -38,237 +38,26 @@ export function buildTemporalArcKeyframes(
38
38
  ].sort((a, b) => a.percentage - b.percentage);
39
39
  }
40
40
 
41
- async function extendTweenAndAddKeyframe(
41
+ /** commitGsapPositionFromDrag's refusal, decided without writing. */
42
+ export function gsapPositionFromDragOutcome(
42
43
  selection: DomEditSelection,
43
44
  anim: GsapAnimation,
44
- properties: Record<string, number>,
45
- targetTime: number,
46
- tweenStart: number,
47
- tweenDuration: number,
48
- callbacks: GsapDragCommitCallbacks,
49
- beforeReload?: () => void,
50
- backfillDefaults?: Record<string, number>,
51
- ): Promise<void> {
52
- const tweenEnd = tweenStart + tweenDuration;
53
- const newStart = Math.min(targetTime, tweenStart);
54
- const newEnd = Math.max(targetTime, tweenEnd);
55
- const newDuration = Math.max(0.01, newEnd - newStart);
56
- const existingKfs = anim.keyframes?.keyframes ?? [];
57
- const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
58
- [];
59
- for (const kf of existingKfs) {
60
- const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
61
- const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
62
- const props: Record<string, number | string> = { ...kf.properties };
63
- for (const k of Object.keys(properties)) {
64
- if (!(k in props) && backfillDefaults?.[k] != null) props[k] = backfillDefaults[k];
65
- }
66
- remappedKfs.push({ percentage: newPct, properties: props });
67
- }
68
-
69
- const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
70
- remappedKfs.push({ percentage: targetPct, properties });
71
-
72
- remappedKfs.sort((a, b) => a.percentage - b.percentage);
73
-
74
- await callbacks.commitMutation(
75
- selection,
76
- {
77
- type: "replace-with-keyframes",
78
- animationId: anim.id,
79
- targetSelector: anim.targetSelector,
80
- position: roundTo3(newStart),
81
- duration: roundTo3(newDuration),
82
- keyframes: remappedKfs,
83
- },
84
- {
85
- label: `Move layer (extended keyframe)`,
86
- keyframeAction: "add",
87
- softReload: true,
88
- beforeReload,
89
- },
90
- );
91
- }
92
-
93
- // fallow-ignore-next-line complexity
94
- async function commitKeyframedPosition(
95
- selection: DomEditSelection,
96
- anim: GsapAnimation,
97
- properties: Record<string, number>,
98
- callbacks: GsapDragCommitCallbacks,
99
- beforeReload?: () => void,
100
- backfillDefaults?: Record<string, number>,
101
- ): Promise<void> {
102
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
103
- const computedPct = computeCurrentPercentage(selection, anim);
104
- const pct = activeKeyframePct ?? computedPct;
105
- await callbacks.commitMutation(
106
- selection,
107
- {
108
- type: "add-keyframe",
109
- animationId: anim.id,
110
- percentage: pct,
111
- properties,
112
- ...(backfillDefaults ? { backfillDefaults } : {}),
113
- },
114
- { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
115
- );
116
- if (activeKeyframePct != null) {
117
- setActiveKeyframePct(null);
118
- parkPlayheadOnKeyframe(anim, pct);
119
- }
120
- }
121
-
122
- interface DragRuntimeGsap {
123
- getProperty: (target: Element, key: string) => unknown;
124
- set: (target: Element, vars: Record<string, unknown>) => void;
125
- }
126
- interface DragRuntimeTimeline {
127
- seek: (time: number) => void;
128
- }
129
- interface DragRuntime {
130
- gsapLib: DragRuntimeGsap;
131
- el: Element;
132
- mainTl: DragRuntimeTimeline;
133
- }
134
-
135
- function resolveDragRuntime(
136
- iframe: HTMLIFrameElement | null | undefined,
137
- selector: string | undefined,
138
- ): DragRuntime | null {
139
- if (!iframe || !selector) return null;
140
- const win = iframe.contentWindow as
141
- | (Window & {
142
- gsap?: Partial<DragRuntimeGsap>;
143
- __timelines?: Record<string, Partial<DragRuntimeTimeline>>;
144
- })
145
- | null;
146
- const gsap = win?.gsap;
147
- if (typeof gsap?.getProperty !== "function" || typeof gsap.set !== "function") return null;
148
- let el: Element | null = null;
149
- try {
150
- el = iframe.contentDocument?.querySelector(selector) ?? null;
151
- } catch {
152
- return null;
153
- }
154
- if (!el) return null;
155
- const timelines = win?.__timelines;
156
- const mainTl = timelines ? Object.values(timelines)[0] : undefined;
157
- if (typeof mainTl?.seek !== "function") return null;
158
- return {
159
- gsapLib: gsap as DragRuntimeGsap,
160
- el,
161
- mainTl: mainTl as DragRuntimeTimeline,
162
- };
163
- }
164
-
165
- // fallow-ignore-next-line complexity
166
- async function commitFlatViaKeyframes(
167
- selection: DomEditSelection,
168
- anim: GsapAnimation,
169
- properties: Record<string, number>,
170
- callbacks: GsapDragCommitCallbacks,
171
- beforeReload?: () => void,
172
- iframe?: HTMLIFrameElement | null,
173
- selector?: string,
174
- backfillDefaults?: Record<string, number>,
175
- ): Promise<void> {
176
- const ct = usePlayerStore.getState().currentTime;
177
- const ts = resolveTweenStart(anim);
178
- const td = resolveEditableTweenDuration(anim, selection);
179
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
180
- const outsideRange =
181
- activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
182
-
183
- const resolvedFromValues: Record<string, number | string> = {};
184
- const runtime = resolveDragRuntime(iframe, selector);
185
- if (runtime && ts !== null) {
186
- const { gsapLib, el, mainTl } = runtime;
187
- const draggedValues: Record<string, number> = {};
188
- const channelsTheClearWipes = new Set([
189
- ...Object.keys(properties),
190
- ...PROPERTY_GROUPS.position,
191
- ]);
192
- for (const key of channelsTheClearWipes) {
193
- const v = Number(gsapLib.getProperty(el, key));
194
- if (Number.isFinite(v)) draggedValues[key] = v;
195
- }
196
- try {
197
- gsapLib.set(el, { clearProps: Object.keys(properties).join(",") });
198
- mainTl.seek(ts);
199
- for (const key of Object.keys(properties)) {
200
- const v = Number(gsapLib.getProperty(el, key));
201
- if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
202
- }
203
- mainTl.seek(ct);
204
- } catch {
205
- for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key];
206
- } finally {
207
- if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues);
208
- }
209
- }
210
-
211
- if (outsideRange && ts !== null) {
212
- const coalesceKey = `gsap:convert-drag:${anim.id}`;
213
- await callbacks.commitMutation(
214
- selection,
215
- {
216
- type: "convert-to-keyframes",
217
- animationId: anim.id,
218
- ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
219
- },
220
- { label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
221
- );
222
- const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
223
- // By id first: a target with several tweens (two `to`s on the same selector)
224
- // matches the selector lookup on whichever one happens to be first, and the
225
- // extend-and-add below would then rewrite a tween the drag never touched.
226
- const converted =
227
- fresh.find((a) => a.id === anim.id && a.keyframes) ??
228
- fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ??
229
- anim;
230
- const convertedStart = resolveTweenStart(converted) ?? ts;
231
- const convertedDur = resolveTweenDuration(converted) || td;
232
- await extendTweenAndAddKeyframe(
233
- selection,
234
- converted,
235
- properties,
236
- ct,
237
- convertedStart,
238
- convertedDur,
239
- callbacks,
240
- beforeReload,
241
- );
242
- return;
243
- }
244
-
245
- const coalesceKey = `gsap:convert-drag:${anim.id}`;
246
- // fallow-ignore-next-line code-duplication
247
- await callbacks.commitMutation(
248
- selection,
249
- {
250
- type: "convert-to-keyframes",
251
- animationId: anim.id,
252
- ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
253
- },
254
- { label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
255
- );
256
- const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
257
- const editedSelected = activeKeyframePct != null;
258
- if (editedSelected) setActiveKeyframePct(null);
259
-
260
- await callbacks.commitMutation(
261
- selection,
262
- {
263
- type: "add-keyframe",
264
- animationId: anim.id,
265
- percentage: pct,
266
- properties,
267
- ...(backfillDefaults ? { backfillDefaults } : {}),
268
- },
269
- { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
45
+ studioOffset: { x: number; y: number },
46
+ gsapPos: { x: number; y: number },
47
+ iframe: HTMLIFrameElement | null,
48
+ ): GsapEditOutcome {
49
+ if (anim.arcPath?.enabled) return { status: "persisted" };
50
+ const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
51
+ selection.element,
52
+ studioOffset,
53
+ gsapPos,
270
54
  );
271
- if (editedSelected) parkPlayheadOnKeyframe(anim, pct);
55
+ const plan = planValueEdit(selection, anim, { x: newX, y: newY }, iframe, {
56
+ backfill: { x: baseGsapX, y: baseGsapY },
57
+ });
58
+ return plan.ok
59
+ ? { status: "persisted" }
60
+ : { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
272
61
  }
273
62
 
274
63
  // fallow-ignore-next-line code-duplication
@@ -279,9 +68,8 @@ export async function commitGsapPositionFromDrag(
279
68
  studioOffset: { x: number; y: number },
280
69
  gsapPos: { x: number; y: number },
281
70
  iframe: HTMLIFrameElement | null,
282
- selector: string,
283
71
  callbacks: GsapDragCommitCallbacks,
284
- ): Promise<void> {
72
+ ): Promise<GsapEditOutcome> {
285
73
  const el = selection.element;
286
74
  // fallow-ignore-next-line code-duplication
287
75
  const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
@@ -298,8 +86,6 @@ export async function commitGsapPositionFromDrag(
298
86
  el.removeAttribute("data-hf-drag-initial-offset-y");
299
87
  };
300
88
 
301
- const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
302
- const ct = usePlayerStore.getState().currentTime;
303
89
  if (anim.arcPath?.enabled) {
304
90
  const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
305
91
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
@@ -324,14 +110,15 @@ export async function commitGsapPositionFromDrag(
324
110
  );
325
111
  setActiveKeyframePct(null);
326
112
  parkPlayheadOnKeyframe(anim, pct);
327
- return;
113
+ return { status: "persisted" };
328
114
  }
329
115
 
330
116
  const tweenStart = resolveTweenStart(anim);
331
117
  // Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s
332
118
  // default here would collapse a duration-less arc's window on drag.
333
119
  const tweenDuration = resolveEditableTweenDuration(anim, selection);
334
- if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
120
+ if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2)
121
+ return { status: "persisted" };
335
122
  const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
336
123
  await callbacks.commitMutation(
337
124
  selection,
@@ -351,161 +138,11 @@ export async function commitGsapPositionFromDrag(
351
138
  beforeReload: restoreOffset,
352
139
  },
353
140
  );
354
- return;
355
- }
356
- if (anim.keyframes) {
357
- const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
358
- const effectiveAnim = newId ? { ...anim, id: newId } : anim;
359
- const dragProps: Record<string, number> = { x: newX, y: newY };
360
-
361
- const ts = resolveTweenStart(effectiveAnim);
362
- const td = resolveEditableTweenDuration(effectiveAnim, selection);
363
- const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
364
- const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
365
- if (outsideRange && !hasSelectedKeyframe) {
366
- await extendTweenAndAddKeyframe(
367
- selection,
368
- effectiveAnim,
369
- dragProps,
370
- ct,
371
- ts,
372
- td,
373
- callbacks,
374
- restoreOffset,
375
- backfillDefaults,
376
- );
377
- } else {
378
- await commitKeyframedPosition(
379
- selection,
380
- effectiveAnim,
381
- dragProps,
382
- callbacks,
383
- restoreOffset,
384
- backfillDefaults,
385
- );
386
- }
387
- } else if (anim.method === "from" || anim.method === "fromTo") {
388
- const ct = usePlayerStore.getState().currentTime;
389
- const ts = resolveTweenStart(anim);
390
- const td = resolveEditableTweenDuration(anim, selection);
391
- const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null;
392
- const outsideRange =
393
- !hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
394
- const dragProps: Record<string, number> = { x: newX, y: newY };
395
-
396
- if (outsideRange && ts !== null) {
397
- await callbacks.commitMutation(
398
- selection,
399
- { type: "split-into-property-groups", animationId: anim.id },
400
- { label: "Split from() for drag", skipReload: true },
401
- );
402
-
403
- const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
404
- const existingPosAnim = allAnims.find(
405
- (a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
406
- );
407
-
408
- if (existingPosAnim?.keyframes) {
409
- const posTs = resolveTweenStart(existingPosAnim);
410
- const posTd = resolveEditableTweenDuration(existingPosAnim, selection);
411
- if (posTs !== null) {
412
- await extendTweenAndAddKeyframe(
413
- selection,
414
- existingPosAnim,
415
- { x: newX, y: newY },
416
- ct,
417
- posTs,
418
- posTd,
419
- callbacks,
420
- restoreOffset,
421
- backfillDefaults,
422
- );
423
- return;
424
- }
425
- }
426
-
427
- const newStart = Math.min(ct, ts);
428
- const newEnd = Math.max(ct, ts + td);
429
- const newDuration = Math.max(0.01, newEnd - newStart);
430
- const dragBefore = ct < ts;
431
- const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
432
- const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
433
-
434
- const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
435
- [];
436
- if (dragBefore) {
437
- keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
438
- if (origStartPct > 0.5 && origStartPct < 99.5) {
439
- keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
440
- }
441
- keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
442
- } else {
443
- keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
444
- if (origEndPct > 0.5 && origEndPct < 99.5) {
445
- keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
446
- }
447
- keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
448
- }
449
- keyframes.sort((a, b) => a.percentage - b.percentage);
450
-
451
- const baseKf = {
452
- targetSelector: anim.targetSelector,
453
- position: roundTo3(newStart),
454
- duration: roundTo3(newDuration),
455
- keyframes,
456
- };
457
- await callbacks.commitMutation(
458
- selection,
459
- existingPosAnim
460
- ? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf }
461
- : { type: "add-with-keyframes", ...baseKf },
462
- {
463
- label: "Move layer (from extended)",
464
- keyframeAction: "add",
465
- softReload: true,
466
- beforeReload: restoreOffset,
467
- },
468
- );
469
- } else {
470
- const coalesceKey = `gsap:convert-drag:${anim.id}`;
471
- await callbacks.commitMutation(
472
- selection,
473
- {
474
- type: "convert-to-keyframes",
475
- animationId: anim.id,
476
- },
477
- { label: "Convert from() for drag", skipReload: true, coalesceKey },
478
- );
479
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
480
- const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
481
- if (activeKeyframePct != null) setActiveKeyframePct(null);
482
- await callbacks.commitMutation(
483
- selection,
484
- {
485
- type: "add-keyframe",
486
- animationId: anim.id,
487
- percentage: pct,
488
- properties: dragProps,
489
- ...(backfillDefaults ? { backfillDefaults } : {}),
490
- },
491
- {
492
- label: `Move layer (keyframe ${pct}%)`,
493
- softReload: true,
494
- beforeReload: restoreOffset,
495
- coalesceKey,
496
- },
497
- );
498
- }
499
- } else {
500
- await commitFlatViaKeyframes(
501
- selection,
502
- anim,
503
- { x: newX, y: newY },
504
- callbacks,
505
- restoreOffset,
506
- iframe,
507
- selector,
508
- backfillDefaults,
509
- );
141
+ return { status: "persisted" };
510
142
  }
143
+ return commitValueAtPlayhead(selection, anim, { x: newX, y: newY }, iframe, callbacks, {
144
+ label: "Move layer",
145
+ backfill: { x: baseGsapX, y: baseGsapY },
146
+ beforeReload: restoreOffset,
147
+ });
511
148
  }
@@ -1,6 +1,11 @@
1
1
  import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
 
3
- export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
3
+ export type GsapEditBlockReason =
4
+ | "no-selector"
5
+ | "unroll-required"
6
+ | "source-uneditable"
7
+ | "keyframes-uneditable"
8
+ | "mixed-files";
4
9
 
5
10
  /**
6
11
  * Which of the nine situations produced a block. The user-facing `reason` stays
@@ -21,7 +26,18 @@ export type GsapEditBlockDetail =
21
26
  | "no-position-tween"
22
27
  | "live-rotation-no-source-tween"
23
28
  | "live-resize-no-source-tween"
24
- | "zero-duration-tween";
29
+ | "zero-duration-tween"
30
+ | PlayheadEditRefusal;
31
+
32
+ /** Why an edit at the playhead could not be written as keyframes of the file's tween. */
33
+ export type PlayheadEditRefusal =
34
+ | "eased-keyframes"
35
+ | "simple-array-keyframes"
36
+ | "unknown-ease"
37
+ | "implicit-end-unknown"
38
+ | "not-a-tween"
39
+ | "no-timing"
40
+ | "shared-tween";
25
41
 
26
42
  export type GsapEditOutcome =
27
43
  | {
@@ -52,6 +68,10 @@ export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
52
68
  "unroll-required":
53
69
  "This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
54
70
  "source-uneditable": "This animation is computed at runtime. Edit the animation in the Code tab.",
71
+ "keyframes-uneditable":
72
+ "Studio can't add this edit to the animation's keyframes. Edit this animation in the Code tab.",
73
+ "mixed-files":
74
+ "These layers are animated in different files. Move each file's layers separately.",
55
75
  };
56
76
 
57
77
  export class GsapEditBlockedError extends Error {
@@ -0,0 +1,34 @@
1
+ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
2
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
+ import { GsapEditBlockedError } from "./gsapEditOutcome";
4
+ import { gsapWriteFile } from "./gsapScriptCommitTypes";
5
+
6
+ /** The first member a group's preflight refused, else one from a second file. */
7
+ export function firstPreflightFailure(
8
+ results: PromiseSettledResult<void>[],
9
+ updates: DomEditGroupPathOffsetCommit[],
10
+ savedOnElement: Map<DomEditSelection, boolean>,
11
+ activeCompPath: string | null | undefined,
12
+ ): { error: unknown; selection: DomEditSelection } | null {
13
+ for (const [index, result] of results.entries()) {
14
+ if (result.status !== "rejected") continue;
15
+ const selection = updates[index]?.selection;
16
+ if (selection) return { error: result.reason, selection };
17
+ }
18
+ return secondFile(updates, savedOnElement, activeCompPath);
19
+ }
20
+
21
+ /** The group's script writes go out as one batch to one file, so members from two files refuse. */
22
+ function secondFile(
23
+ updates: DomEditGroupPathOffsetCommit[],
24
+ savedOnElement: Map<DomEditSelection, boolean>,
25
+ activeCompPath: string | null | undefined,
26
+ ): { error: unknown; selection: DomEditSelection } | null {
27
+ const scripted = updates.filter(({ selection }) => !savedOnElement.has(selection));
28
+ const fileOf = (s: DomEditSelection) => gsapWriteFile(s, activeCompPath);
29
+ const file = scripted[0] && fileOf(scripted[0].selection);
30
+ const other = scripted.find(({ selection }) => fileOf(selection) !== file);
31
+ return other
32
+ ? { error: new GsapEditBlockedError("mixed-files"), selection: other.selection }
33
+ : null;
34
+ }
@@ -1,11 +1,12 @@
1
1
  import { studioWriteHeaders } from "../utils/studioFileVersion";
2
+ import { studioApiFetch } from "../utils/studioApiFetch";
2
3
 
3
4
  export class GsapPreviewConvergenceError extends Error {}
4
5
  export class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
5
6
 
6
7
  /** Verify rollback ownership support before any GSAP mutation can land. */
7
8
  export async function requireGsapOwnershipProtocol(projectId: string): Promise<void> {
8
- const response = await fetch(
9
+ const response = await studioApiFetch(
9
10
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-capabilities`,
10
11
  );
11
12
  if (!response.ok) {
@@ -29,7 +30,7 @@ export async function rollbackOwnedMutation(
29
30
  if (targetPath.includes("\0") || targetPath.includes("..")) {
30
31
  throw new Error(`Unsafe path: ${targetPath}`);
31
32
  }
32
- const response = await fetch(
33
+ const response = await studioApiFetch(
33
34
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
34
35
  {
35
36
  method: "POST",
@@ -95,7 +96,7 @@ export async function postGsapMutation(
95
96
  ): Promise<GsapMutationStatus> {
96
97
  let response: Response;
97
98
  try {
98
- response = await fetch(
99
+ response = await studioApiFetch(
99
100
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
100
101
  {
101
102
  method: "POST",
@@ -0,0 +1,75 @@
1
+ import { vi } from "vitest";
2
+ import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
3
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
+ import { usePlayerStore } from "../player/store/playerStore";
5
+
6
+ /** A property's `[start, end]` as GSAP parsed it from the file when the tween first rendered. */
7
+ type Ends = Record<string, [number, number]>;
8
+
9
+ /** A GSAP 3 tween as the preview runtime holds it, with the property tweens the parse readers walk. */
10
+ export function liveTween(
11
+ target: Element,
12
+ tween: { start: number; duration: number; vars: Record<string, unknown>; ends?: Ends },
13
+ { from = false, parts }: { from?: boolean; parts?: unknown[] } = {},
14
+ ) {
15
+ let head: Record<string, unknown> | undefined;
16
+ for (const [p, [s, e]] of Object.entries(tween.ends ?? {}).reverse())
17
+ head = from ? { p, s: e, c: s - e, _next: head } : { p, s, c: e - s, _next: head };
18
+ return {
19
+ targets: () => [target],
20
+ startTime: () => tween.start,
21
+ duration: () => tween.duration,
22
+ vars: tween.vars,
23
+ _from: from,
24
+ ...(head && { _pt: { d: { _pt: head } } }),
25
+ ...(parts && { timeline: { getChildren: () => parts } }),
26
+ };
27
+ }
28
+
29
+ /** A preview iframe whose one timeline holds `tweens`, answering `getProperty` from `live`. */
30
+ export function previewWith(
31
+ element: Element,
32
+ tweens: unknown[],
33
+ live: Record<string, number> = {},
34
+ ): HTMLIFrameElement {
35
+ return {
36
+ contentWindow: {
37
+ __timelines: { main: { getChildren: () => tweens, duration: () => 10 } },
38
+ gsap: {
39
+ getProperty: (_el: Element, prop: string) => live[prop] ?? 0,
40
+ defaults: () => ({ ease: "power1.out" }),
41
+ },
42
+ },
43
+ contentDocument: element.ownerDocument,
44
+ } as unknown as HTMLIFrameElement;
45
+ }
46
+
47
+ export const tween = (fields: Partial<GsapAnimation>): GsapAnimation =>
48
+ ({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation;
49
+
50
+ /** A `to` tween on `#el` from 0, grouped as the parser would; duration 0 is an immediate-render hold. */
51
+ export function elTween(
52
+ id: string,
53
+ properties: Record<string, number>,
54
+ duration: number,
55
+ ease?: string,
56
+ ): GsapAnimation {
57
+ return {
58
+ id,
59
+ targetSelector: "#el",
60
+ propertyGroup: classifyTweenPropertyGroup(properties),
61
+ method: "to",
62
+ properties,
63
+ position: 0,
64
+ resolvedStart: 0,
65
+ duration,
66
+ ...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : ease && { ease }),
67
+ } as unknown as GsapAnimation;
68
+ }
69
+
70
+ /** Per-test cleanup for the intercept sweeps: mocks, the playhead store and the document. */
71
+ export function resetGsapEditState(): void {
72
+ vi.restoreAllMocks();
73
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
74
+ document.body.innerHTML = "";
75
+ }