@hyperframes/studio 0.8.127 → 0.8.129

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 (119) hide show
  1. package/dist/assets/{hyperframes-player-uqzDaxVL.js → hyperframes-player-DfEcBl9r.js} +1 -1
  2. package/dist/assets/{index-DXSYlqmV.js → index-9ZaZFCMZ.js} +1 -1
  3. package/dist/assets/{index-CLBNYlDY.js → index-Bmy-KVib.js} +1 -1
  4. package/dist/assets/{index-COhbL1xq.js → index-C9YkEyN4.js} +189 -189
  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 +1396 -917
  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/commitPreflightHandles.test.tsx +32 -0
  20. package/src/components/editor/domEditingLayers.test.ts +45 -18
  21. package/src/components/editor/domEditingLayers.ts +11 -9
  22. package/src/components/editor/domEditingTypes.ts +1 -0
  23. package/src/components/editor/keyframeRetime.ts +11 -4
  24. package/src/components/editor/probeSourceElement.ts +17 -1
  25. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  27. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  28. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  29. package/src/hooks/gsapDragCommit.ts +8 -5
  30. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  31. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  32. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  33. package/src/hooks/gsapMutationClient.ts +35 -7
  34. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  35. package/src/hooks/gsapParsedTween.ts +5 -6
  36. package/src/hooks/gsapPositionDetection.ts +5 -2
  37. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  39. package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
  40. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  41. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  42. package/src/hooks/gsapShared.test.ts +48 -35
  43. package/src/hooks/gsapShared.ts +30 -32
  44. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
  45. package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
  46. package/src/hooks/gsapValueAtPlayhead.ts +3 -4
  47. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  48. package/src/hooks/timelineEditingHelpers.ts +40 -14
  49. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  50. package/src/hooks/timelineMoveAdapter.ts +3 -5
  51. package/src/hooks/timelineTimingSync.test.ts +83 -125
  52. package/src/hooks/timelineTimingSync.ts +82 -63
  53. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  54. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  55. package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
  56. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  57. package/src/hooks/useDomSelection.test.ts +27 -0
  58. package/src/hooks/useDomSelection.ts +12 -13
  59. package/src/hooks/useDomSelectionTypes.ts +1 -0
  60. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  61. package/src/hooks/useEditHistoryActions.ts +9 -6
  62. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  63. package/src/hooks/useEnableKeyframes.ts +54 -71
  64. package/src/hooks/useGestureCommit.ts +2 -1
  65. package/src/hooks/useGsapTweenCache.ts +2 -17
  66. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  67. package/src/hooks/usePersistentEditHistory.ts +13 -2
  68. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  69. package/src/hooks/usePreviewPersistence.ts +26 -4
  70. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  72. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  73. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  74. package/src/hooks/useTimelineEditing.ts +21 -295
  75. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  77. package/src/player/components/Timeline.test.ts +12 -4
  78. package/src/player/components/TimelineClip.tsx +3 -0
  79. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  80. package/src/player/components/TimelineLanes.test.tsx +61 -5
  81. package/src/player/components/TimelineLanes.tsx +20 -28
  82. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  83. package/src/player/components/timelineCallbacks.ts +1 -4
  84. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  85. package/src/player/components/timelineClipDragCommit.ts +39 -28
  86. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  87. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  88. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  89. package/src/player/components/timelineEditing.ts +1 -3
  90. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  91. package/src/player/components/timelineLinkSelection.ts +1 -35
  92. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  93. package/src/player/components/trackHeaderLaneState.ts +9 -2
  94. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  95. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  96. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  97. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  98. package/src/player/components/useTimelineClipDrag.ts +0 -3
  99. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  100. package/src/player/store/batchElementUpdates.test.ts +43 -0
  101. package/src/player/store/batchElementUpdates.ts +9 -0
  102. package/src/player/store/elementPatchQueue.ts +35 -0
  103. package/src/player/store/playerStore.ts +5 -6
  104. package/src/utils/domEditHelpers.ts +5 -0
  105. package/src/utils/globalTimeCompiler.test.ts +0 -4
  106. package/src/utils/globalTimeCompiler.ts +6 -4
  107. package/src/utils/gsapKeyframeEases.ts +79 -0
  108. package/src/utils/gsapLiveRetime.realGsap.test.ts +544 -0
  109. package/src/utils/gsapLiveRetime.ts +286 -0
  110. package/src/utils/gsapSoftReload.ts +16 -7
  111. package/src/utils/gsapUndoRestore.test.ts +43 -0
  112. package/src/utils/gsapUndoRestore.ts +6 -3
  113. package/src/utils/sameData.ts +9 -0
  114. package/src/utils/studioFileHistory.ts +1 -0
  115. package/src/utils/studioPendingEdits.ts +15 -1
  116. package/dist/chunk-246VB2G3.js.map +0 -1
  117. package/dist/chunk-3F47EFCT.js.map +0 -1
  118. package/dist/chunk-5EKWJZLM.js.map +0 -1
  119. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -0,0 +1,343 @@
1
+ import { useCallback, type MutableRefObject } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
4
+ import {
5
+ applyTimelineStackingReorder,
6
+ patchIframeDomTiming,
7
+ persistTimelineEdit,
8
+ formatTimelineAttributeNumber,
9
+ formatTimelineMediaOffset,
10
+ extendRootDurationIfNeeded,
11
+ buildTimelineMoveTimingPatch,
12
+ buildTimelineResizeTimingPatch,
13
+ type PersistTimelineEditInput,
14
+ } from "./timelineEditingHelpers";
15
+ import { playbackStartAttributeForElement } from "../player/lib/timelineElementHelpers";
16
+ import {
17
+ captureDurationRollback,
18
+ finishClipTimingFallback,
19
+ sdkTimingGsapSync,
20
+ readFileContent,
21
+ syncPreviewContentDuration,
22
+ timingGestureStep,
23
+ } from "./timelineTimingSync";
24
+ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
25
+ import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
26
+ import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
27
+ import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
28
+
29
+ type UseTimelineClipTimingEditingOptions = Pick<
30
+ UseTimelineEditingOptions,
31
+ | "activeCompPath"
32
+ | "timelineElements"
33
+ | "showToast"
34
+ | "writeProjectFile"
35
+ | "recordEdit"
36
+ | "reloadPreview"
37
+ | "previewIframeRef"
38
+ | "pendingTimelineEditPathRef"
39
+ | "isRecordingRef"
40
+ | "sdkSession"
41
+ | "publishSdkSession"
42
+ | "forceReloadSdkSession"
43
+ | "invalidateGsapCache"
44
+ | "handleDomZIndexReorderCommitRef"
45
+ > & {
46
+ editQueueRef: MutableRefObject<Promise<unknown>>;
47
+ projectIdRef: MutableRefObject<string | null>;
48
+ };
49
+
50
+ /** One clip's move and resize: the timing write, then its tween rewrite, as one undo step. */
51
+ export function useTimelineClipTimingEditing({
52
+ activeCompPath,
53
+ timelineElements,
54
+ showToast,
55
+ writeProjectFile,
56
+ recordEdit,
57
+ reloadPreview,
58
+ previewIframeRef,
59
+ pendingTimelineEditPathRef,
60
+ isRecordingRef,
61
+ sdkSession,
62
+ publishSdkSession,
63
+ forceReloadSdkSession,
64
+ invalidateGsapCache,
65
+ handleDomZIndexReorderCommitRef,
66
+ editQueueRef,
67
+ projectIdRef,
68
+ }: UseTimelineClipTimingEditingOptions) {
69
+ const enqueueEdit = useCallback(
70
+ (
71
+ element: TimelineElement,
72
+ label: string,
73
+ buildPatches: PersistTimelineEditInput["buildPatches"],
74
+ step: ReturnType<typeof timingGestureStep>,
75
+ ): Promise<void> => {
76
+ if (isRecordingRef?.current) {
77
+ showToast("Cannot edit timeline while recording", "error");
78
+ return Promise.resolve();
79
+ }
80
+ const pid = projectIdRef.current;
81
+ if (!pid) return Promise.resolve();
82
+ const queued = editQueueRef.current
83
+ .then(() =>
84
+ persistTimelineEdit({
85
+ projectId: pid,
86
+ element,
87
+ activeCompPath,
88
+ label,
89
+ buildPatches,
90
+ writeProjectFile,
91
+ recordEdit,
92
+ pendingTimelineEditPathRef,
93
+ ...step,
94
+ }),
95
+ )
96
+ .then(() => {
97
+ forceReloadSdkSession?.();
98
+ });
99
+ editQueueRef.current = queued.catch((error) => {
100
+ console.error(`[Timeline] Failed to persist: ${label}`, error);
101
+ });
102
+ return queued;
103
+ },
104
+ [
105
+ activeCompPath,
106
+ recordEdit,
107
+ writeProjectFile,
108
+ pendingTimelineEditPathRef,
109
+ showToast,
110
+ isRecordingRef,
111
+ forceReloadSdkSession,
112
+ editQueueRef,
113
+ projectIdRef,
114
+ ],
115
+ );
116
+ const handleTimelineElementMove = useCallback(
117
+ // fallow-ignore-next-line complexity
118
+ (element: TimelineElement, updates: TimelineMoveUpdates) => {
119
+ // fallow-ignore-next-line complexity
120
+ const commitMove = () => {
121
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
122
+ const startChanged = updates.start !== element.start;
123
+ // A vertical-only lane move (start unchanged, authored track already in updates.track) must
124
+ // persist like any other move, or the lane snaps back on reload.
125
+ const trackChanged = updates.track !== element.track;
126
+ const authoredStart = toAuthoredStart(element, updates.start);
127
+ if (startChanged || trackChanged) {
128
+ const liveAttrs: Array<[string, string]> = [];
129
+ if (startChanged)
130
+ liveAttrs.push(["data-start", formatTimelineAttributeNumber(authoredStart)]);
131
+ if (trackChanged) {
132
+ liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
133
+ }
134
+ patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
135
+ }
136
+
137
+ const reorderDone = applyTimelineStackingReorder({
138
+ element,
139
+ stackingReorder: updates.stackingReorder,
140
+ timelineElements,
141
+ iframe: previewIframeRef.current,
142
+ activeCompPath,
143
+ commit: handleDomZIndexReorderCommitRef?.current,
144
+ });
145
+
146
+ if (!startChanged && !trackChanged) return reorderDone;
147
+
148
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
149
+ // persist can roll the readout + live root back (see captureDurationRollback).
150
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
151
+ // Read before the readout sync below: the SDK path (setTiming) can't grow the root duration.
152
+ const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
153
+ // Optimistic duration readout from the just-patched live DOM (grows and shrinks).
154
+ syncPreviewContentDuration(previewIframeRef.current);
155
+
156
+ const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
157
+ // Persist lane changes too — data-start-only writes let reload snap the lane back.
158
+ const track = trackChanged ? updates.track : undefined;
159
+ return buildTimelineMoveTimingPatch(
160
+ original,
161
+ target,
162
+ authoredStart,
163
+ element.duration,
164
+ track,
165
+ );
166
+ };
167
+ const step = timingGestureStep("timeline-move");
168
+ const finishMoveGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
169
+ // One GSAP sync per edit: the SDK commit's own (sdkGsap), else the server rewrite here.
170
+ finishClipTimingFallback({
171
+ iframe: previewIframeRef.current,
172
+ reloadPreview,
173
+ projectId: projectIdRef.current,
174
+ targetPath,
175
+ domId: element.domId,
176
+ label: "Move timeline clip",
177
+ coalesceKey: step.coalesceKey,
178
+ recordEdit,
179
+ writeProjectFile,
180
+ edit: { kind: "shift", delta: updates.start - element.start },
181
+ sdkGsap,
182
+ }).finally(() => invalidateGsapCache?.());
183
+ const moveFallback = () =>
184
+ enqueueEdit(element, "Move timeline clip", buildMovePatches, step).then(() =>
185
+ finishMoveGsapSync(),
186
+ );
187
+ return reorderDone
188
+ .then(() => {
189
+ // SDK setTiming writes start only; a lane change needs the fallback's track patch.
190
+ if (sdkSession && element.hfId && !element.link && !needsExtension && !trackChanged) {
191
+ return sdkTimingPersist(
192
+ element.hfId,
193
+ targetPath,
194
+ { start: authoredStart },
195
+ sdkSession,
196
+ {
197
+ editHistory: { recordEdit },
198
+ writeProjectFile,
199
+ reloadPreview,
200
+ compositionPath: activeCompPath,
201
+ // Capture on-disk bytes as the undo `before` so undoing a timing move
202
+ // restores the file verbatim, not a normalized full-DOM re-emit.
203
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
204
+ publishSession: publishSdkSession,
205
+ },
206
+ { label: "Move timeline clip", ...step, skipRefresh: true },
207
+ ).then((result) => {
208
+ if (!cutoverCommittedOrThrow(result)) return moveFallback();
209
+ return finishMoveGsapSync(sdkTimingGsapSync(result));
210
+ });
211
+ }
212
+ return moveFallback();
213
+ })
214
+ .catch((error) => {
215
+ // Failed persist: revert the optimistic duration readout + live root.
216
+ rollbackDuration();
217
+ showToast(getStudioSaveErrorMessage(error), "error");
218
+ throw error;
219
+ });
220
+ };
221
+ return updates.stackingReorder ? serializeZLaneGesture(commitMove) : commitMove();
222
+ },
223
+ [
224
+ previewIframeRef,
225
+ enqueueEdit,
226
+ activeCompPath,
227
+ sdkSession,
228
+ publishSdkSession,
229
+ recordEdit,
230
+ writeProjectFile,
231
+ reloadPreview,
232
+ timelineElements,
233
+ handleDomZIndexReorderCommitRef,
234
+ showToast,
235
+ invalidateGsapCache,
236
+ projectIdRef,
237
+ ],
238
+ );
239
+
240
+ const handleTimelineElementResize = useCallback(
241
+ // fallow-ignore-next-line complexity
242
+ (
243
+ element: TimelineElement,
244
+ updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
245
+ ) => {
246
+ const authoredStart = toAuthoredStart(element, updates.start);
247
+ const liveAttrs: Array<[string, string]> = [
248
+ ["data-start", formatTimelineAttributeNumber(authoredStart)],
249
+ ["data-duration", formatTimelineAttributeNumber(updates.duration)],
250
+ ];
251
+ if (updates.playbackStart != null) {
252
+ const liveAttr = playbackStartAttributeForElement(element);
253
+ liveAttrs.push([liveAttr, formatTimelineMediaOffset(updates.playbackStart)]);
254
+ }
255
+ patchIframeDomTiming(previewIframeRef.current, element, liveAttrs, activeCompPath);
256
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
257
+ // persist can roll the readout + live root back (see captureDurationRollback).
258
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
259
+ // Read before the readout sync below: the SDK path (setTiming) can't grow the root duration.
260
+ const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
261
+ // Optimistic duration readout from the just-patched live DOM (grows and shrinks).
262
+ syncPreviewContentDuration(previewIframeRef.current);
263
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
264
+ const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
265
+ return buildTimelineResizeTimingPatch(original, target, element, updates);
266
+ };
267
+ const hasPbsAdjustment =
268
+ updates.playbackStart != null ||
269
+ (updates.start !== element.start && element.playbackStart != null);
270
+ // Server-path fallback: after persisting the attr patch, scale GSAP tween
271
+ // positions/durations on the server, then soft-reload with the rewritten
272
+ // script (timing-only resize) — same no-flash path as move; full reload is
273
+ // the fallback.
274
+ const step = timingGestureStep("timeline-resize");
275
+ const finishResizeGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
276
+ finishClipTimingFallback({
277
+ iframe: previewIframeRef.current,
278
+ reloadPreview,
279
+ projectId: projectIdRef.current,
280
+ targetPath,
281
+ domId: element.domId,
282
+ label: "Resize timeline clip",
283
+ coalesceKey: step.coalesceKey,
284
+ recordEdit,
285
+ writeProjectFile,
286
+ edit: {
287
+ kind: "scale",
288
+ from: { start: toCompositionTime(element, element.start), duration: element.duration },
289
+ to: { start: toCompositionTime(element, updates.start), duration: updates.duration },
290
+ },
291
+ sdkGsap,
292
+ }).finally(() => invalidateGsapCache?.());
293
+ const resizeFallback = () =>
294
+ enqueueEdit(element, "Resize timeline clip", buildResizePatches, step).then(() =>
295
+ finishResizeGsapSync(),
296
+ );
297
+ const persistDone =
298
+ sdkSession && element.hfId && !element.link && !hasPbsAdjustment && !needsExtension
299
+ ? sdkTimingPersist(
300
+ element.hfId,
301
+ targetPath,
302
+ { start: authoredStart, duration: updates.duration },
303
+ sdkSession,
304
+ {
305
+ editHistory: { recordEdit },
306
+ writeProjectFile,
307
+ reloadPreview,
308
+ compositionPath: activeCompPath,
309
+ // Capture on-disk bytes as the undo `before` so undoing a timing
310
+ // resize restores the file verbatim, not a normalized full-DOM re-emit.
311
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
312
+ publishSession: publishSdkSession,
313
+ },
314
+ { label: "Resize timeline clip", ...step, skipRefresh: true },
315
+ ).then((result) => {
316
+ if (!cutoverCommittedOrThrow(result)) return resizeFallback();
317
+ return finishResizeGsapSync(sdkTimingGsapSync(result));
318
+ })
319
+ : resizeFallback();
320
+ return persistDone.catch((error) => {
321
+ // Failed persist: revert the optimistic duration readout + live root.
322
+ rollbackDuration();
323
+ showToast(getStudioSaveErrorMessage(error), "error");
324
+ throw error;
325
+ });
326
+ },
327
+ [
328
+ previewIframeRef,
329
+ enqueueEdit,
330
+ activeCompPath,
331
+ sdkSession,
332
+ publishSdkSession,
333
+ recordEdit,
334
+ writeProjectFile,
335
+ reloadPreview,
336
+ showToast,
337
+ invalidateGsapCache,
338
+ projectIdRef,
339
+ ],
340
+ );
341
+
342
+ return { handleTimelineElementMove, handleTimelineElementResize };
343
+ }
@@ -62,6 +62,7 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
62
62
  function mountDeleteHarness(overrides: {
63
63
  handleTimelineGroupMove: DeleteOpsOptions["handleTimelineGroupMove"];
64
64
  showToast?: DeleteOpsOptions["showToast"];
65
+ recordEdit?: DeleteOpsOptions["recordEdit"];
65
66
  }) {
66
67
  const elements = [el("hf-a", 0, 2), el("hf-b", 2, 2), el("hf-c", 4, 2)];
67
68
  let hook: ReturnType<typeof useTimelineDeleteOps> | null = null;
@@ -72,7 +73,7 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
72
73
  timelineElements: elements,
73
74
  showToast: overrides.showToast ?? vi.fn(),
74
75
  writeProjectFile: vi.fn().mockResolvedValue(undefined),
75
- recordEdit: vi.fn().mockResolvedValue(undefined),
76
+ recordEdit: overrides.recordEdit ?? vi.fn().mockResolvedValue(undefined),
76
77
  reloadPreview: vi.fn(),
77
78
  previewIframeRef: { current: null },
78
79
  handleTimelineGroupMove: overrides.handleTimelineGroupMove,
@@ -120,6 +121,24 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
120
121
  );
121
122
  });
122
123
 
124
+ it("holds the delete's undo step open under the key its ripple joins, however late the ripple lands", async () => {
125
+ stubRemoveElementsFetch(html, html.replace(/<div data-hf-id="hf-b"[^\n]*\n/, ""));
126
+ const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
127
+ const recordEdit = vi.fn().mockResolvedValue(undefined);
128
+ const { b, getHook } = mountDeleteHarness({ handleTimelineGroupMove, recordEdit });
129
+
130
+ await act(async () => {
131
+ await getHook().handleTimelineElementDelete(b);
132
+ });
133
+
134
+ const [[deleted]] = recordEdit.mock.calls;
135
+ expect(deleted).toMatchObject({ coalesceMs: Infinity });
136
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(
137
+ expect.anything(),
138
+ expect.objectContaining({ coalesceKey: deleted.coalesceKey }),
139
+ );
140
+ });
141
+
123
142
  it("removes a multi-clip selection with one request, however many clips it holds", async () => {
124
143
  const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
125
144
  const { elements, getHook } = mountDeleteHarness({ handleTimelineGroupMove });
@@ -9,7 +9,7 @@ import { saveServerRewriteWithHistory, type RecordEditInput } from "../utils/stu
9
9
  import { studioWriteHeaders } from "../utils/studioFileVersion";
10
10
  import { getTimelineElementLabel } from "../utils/studioHelpers";
11
11
  import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
12
- import { captureDurationRollback } from "./timelineTimingSync";
12
+ import { captureDurationRollback, timingGestureStep } from "./timelineTimingSync";
13
13
  import { setLinkInSource } from "../components/editor/mediaLinkEdits";
14
14
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
15
15
  import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
@@ -57,11 +57,6 @@ interface UseTimelineDeleteOpsOptions {
57
57
  ) => Promise<void>;
58
58
  }
59
59
 
60
- // Per-gesture-unique coalesce key — a monotonic counter, not Date.now() /
61
- // Math.random() (determinism rules), mirroring gapCloseGestureSeq in
62
- // timelineGapCommit.ts.
63
- let deleteGestureSeq = 0;
64
-
65
60
  function unlinkInSource(source: string, survivors: readonly TimelineElement[]): string {
66
61
  const targets = survivors.map(buildPatchTarget).filter((target) => target !== null);
67
62
  return targets.length > 0 ? setLinkInSource(source, targets, null) : source;
@@ -107,10 +102,8 @@ export function useTimelineDeleteOps({
107
102
  (candidate) => (candidate.sourceFile || activeCompPath || "index.html") === targetPath,
108
103
  );
109
104
  try {
110
- // Shared with the ripple move below so a folded ripple is one undo
111
- // step with the delete, not two (editHistory.ts coalesces by key +
112
- // window across separate recordEdit calls, not by label).
113
- const coalesceKey = `main-track-ripple-delete:${deleteGestureSeq++}`;
105
+ // Shared with the ripple move below so a folded ripple is one undo step with the delete.
106
+ const step = timingGestureStep("main-track-ripple-delete");
114
107
  const deleteHistoryLabel = "Delete timeline clip";
115
108
  let rollbackDuration = () => {};
116
109
  try {
@@ -118,7 +111,7 @@ export function useTimelineDeleteOps({
118
111
  projectId: pid,
119
112
  path: targetPath,
120
113
  label: deleteHistoryLabel,
121
- coalesceKey,
114
+ ...step,
122
115
  writeFile: writeProjectFile,
123
116
  recordEdit,
124
117
  rewrite: async (originalContent) => {
@@ -189,8 +182,7 @@ export function useTimelineDeleteOps({
189
182
  if (rippleChanges) {
190
183
  try {
191
184
  await handleTimelineGroupMove(rippleChanges, {
192
- coalesceKey,
193
- coalesceMs: Number.POSITIVE_INFINITY,
185
+ coalesceKey: step.coalesceKey,
194
186
  // Coalescing keeps the LAST entry's label; without this the undo
195
187
  // toast reads "Undid Move timeline clips" after a delete, naming
196
188
  // the ripple's mechanics instead of what the user actually did.
@@ -306,9 +306,10 @@ function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unk
306
306
  (url) => jsonResponse({ content: fileContent(pathAfter(url, "/files/")) }),
307
307
  ],
308
308
  [
309
- "/api/projects/p1/gsap-mutations/",
309
+ "/api/projects/p1/gsap-mutations",
310
310
  (url) => {
311
- const content = fileContent(pathAfter(url, "/gsap-mutations/")) ?? "";
311
+ const content =
312
+ fileContent(pathAfter(url, url.includes("-batch/") ? "-batch/" : "s/")) ?? "";
312
313
  return jsonResponse(
313
314
  gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
314
315
  );
@@ -1166,6 +1167,41 @@ describe("useTimelineEditing timeline z-index reorder", () => {
1166
1167
  expect(groupWrite.mock.calls[0]![1]).toBe(singleWrite.mock.calls[0]![1]);
1167
1168
  group.unmount();
1168
1169
  });
1170
+
1171
+ it("gives each single-clip move and resize its own undo step, which its GSAP rewrite joins", async () => {
1172
+ const source = '<div id="clip" data-start="0" data-duration="1"></div>';
1173
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
1174
+ stubProjectFetch(source, {
1175
+ mutated: true,
1176
+ scriptText: null,
1177
+ before: "tweens at 0",
1178
+ after: source,
1179
+ });
1180
+ const recordEdit = vi.fn(async (_edit: { coalesceKey?: string; coalesceMs?: number }) => {});
1181
+ const h = renderTimelineEditingHook({
1182
+ timelineElements: [clip],
1183
+ iframe: createPreviewIframe([{ id: "clip", track: 0 }]),
1184
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1185
+ projectId: "p1",
1186
+ writeProjectFile: vi.fn(async () => {}),
1187
+ recordEdit,
1188
+ });
1189
+
1190
+ await act(async () => {
1191
+ await h.move(clip, { start: 0.5, track: clip.track });
1192
+ await h.move(clip, { start: 1, track: clip.track });
1193
+ await h.resize(clip, { start: 0, duration: 2 });
1194
+ });
1195
+
1196
+ const steps = recordEdit.mock.calls.map(([edit]) => edit);
1197
+ expect(steps.map((edit) => edit.coalesceMs)).toEqual(Array(6).fill(Infinity));
1198
+ const [move, fold, again, foldAgain, resize, resizeFold] = steps.map(
1199
+ (edit) => edit.coalesceKey,
1200
+ );
1201
+ expect([fold, foldAgain, resizeFold]).toEqual([move, again, resize]);
1202
+ expect(new Set([move, again, resize]).size).toBe(3);
1203
+ h.unmount();
1204
+ });
1169
1205
  });
1170
1206
 
1171
1207
  describe("useTimelineEditing duration rollback on failed persist", () => {
@@ -1936,8 +1972,11 @@ function setupNestedHarness() {
1936
1972
  const fetchMock = stubProjectFetch(NESTED_FILES);
1937
1973
  const scaleCalls = () =>
1938
1974
  fetchMock.mock.calls
1939
- .filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
1940
- .map((call) => JSON.parse(String((call[1] as RequestInit).body)))
1975
+ .filter((call) => requestUrl(call[0]).includes("/gsap-mutations"))
1976
+ .flatMap((call) => {
1977
+ const body = JSON.parse(String((call[1] as RequestInit).body));
1978
+ return body.mutations ?? [body];
1979
+ })
1941
1980
  .filter((body) => body.type === "scale-positions");
1942
1981
  usePlayerStore.getState().setDuration(20);
1943
1982
  const hook = renderTimelineEditingHook({
@@ -2285,6 +2324,19 @@ describe("clip timing edits sync GSAP exactly once", () => {
2285
2324
  applyServerMutation(pathAfter(url, "/gsap-mutations/"), JSON.parse(String(init?.body))),
2286
2325
  );
2287
2326
  }
2327
+ if (url.includes("/gsap-mutations-batch/")) {
2328
+ const path = pathAfter(url, "/gsap-mutations-batch/");
2329
+ const steps = (
2330
+ JSON.parse(String(init?.body)) as { mutations: Record<string, unknown>[] }
2331
+ ).mutations.map((mutation) => applyServerMutation(path, mutation));
2332
+ const last = steps[steps.length - 1]!;
2333
+ return jsonResponse({
2334
+ ...last,
2335
+ before: steps[0]!.before,
2336
+ mutated: steps[0]!.before !== last.after,
2337
+ changed: steps[0]!.before !== last.after,
2338
+ });
2339
+ }
2288
2340
  if (url.includes("/files/"))
2289
2341
  return jsonResponse({ content: files[pathAfter(url, "/files/")] });
2290
2342
  throw new Error(`Unexpected fetch: ${url}`);
@@ -2404,6 +2456,13 @@ describe("clip timing edits sync GSAP exactly once", () => {
2404
2456
  `tl.from("#scene h1", { y: 20, duration: 1 }, 3.5);`,
2405
2457
  `tl.to("#side", { x: 5, duration: 1 }, 3);`,
2406
2458
  ]);
2459
+ if (!withSdk) {
2460
+ const gsapUrls = h.fetchMock.mock.calls
2461
+ .map((call) => requestUrl(call[0]))
2462
+ .filter((url) => url.includes("/gsap-mutations"));
2463
+ // Both clips live in one file, so their rewrites travel as one request.
2464
+ expect(gsapUrls).toEqual([expect.stringContaining("/gsap-mutations-batch/")]);
2465
+ }
2407
2466
  h.hook.unmount();
2408
2467
  });
2409
2468
  }