@hyperframes/studio 0.7.56 → 0.7.58

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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
5
5
  import { openComposition } from "@hyperframes/sdk";
6
6
  import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { usePlayerStore, type TimelineElement } from "../player";
8
+ import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
8
9
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
9
10
  import { useTimelineEditing } from "./useTimelineEditing";
10
11
 
@@ -77,6 +78,21 @@ function timelineElement(input: {
77
78
  };
78
79
  }
79
80
 
81
+ /** Mount a harness component under act() and return its unmount hook. */
82
+ function mountHarness(node: React.ReactElement): { unmount: () => void } {
83
+ const host = document.createElement("div");
84
+ document.body.append(host);
85
+ const root = createRoot(host);
86
+ act(() => {
87
+ root.render(node);
88
+ });
89
+ return {
90
+ unmount: () => {
91
+ act(() => root.unmount());
92
+ },
93
+ };
94
+ }
95
+
80
96
  function renderTimelineEditingHook(input: {
81
97
  timelineElements: TimelineElement[];
82
98
  iframe: HTMLIFrameElement;
@@ -92,17 +108,20 @@ function renderTimelineEditingHook(input: {
92
108
  reloadPreview?: () => void;
93
109
  sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
94
110
  forceReloadSdkSession?: () => void;
111
+ showToast?: (message: string, kind?: string) => void;
95
112
  }): {
96
113
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
97
114
  resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
98
115
  groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
99
116
  groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
117
+ del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
100
118
  unmount: () => void;
101
119
  } {
102
120
  let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
103
121
  let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
104
122
  let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
105
123
  let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
124
+ let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
106
125
 
107
126
  function Harness() {
108
127
  const commitRef = useRef(input.onZIndexCommit);
@@ -111,7 +130,7 @@ function renderTimelineEditingHook(input: {
111
130
  projectId: input.projectId ?? null,
112
131
  activeCompPath: "index.html",
113
132
  timelineElements: input.timelineElements,
114
- showToast: vi.fn(),
133
+ showToast: input.showToast ?? vi.fn(),
115
134
  writeProjectFile: input.writeProjectFile ?? vi.fn(),
116
135
  recordEdit: input.recordEdit ?? vi.fn(),
117
136
  domEditSaveTimestampRef: { current: 0 },
@@ -127,29 +146,17 @@ function renderTimelineEditingHook(input: {
127
146
  resize = hook.handleTimelineElementResize;
128
147
  groupMove = hook.handleTimelineGroupMove;
129
148
  groupResize = hook.handleTimelineGroupResize;
149
+ del = hook.handleTimelineElementDelete;
130
150
  return null;
131
151
  }
132
152
 
133
- const host = document.createElement("div");
134
- document.body.append(host);
135
- const root = createRoot(host);
136
- act(() => {
137
- root.render(<Harness />);
138
- });
139
-
153
+ const { unmount } = mountHarness(<Harness />);
140
154
  if (!move) throw new Error("Expected hook to expose move handler");
141
155
  if (!resize) throw new Error("Expected hook to expose resize handler");
142
156
  if (!groupMove) throw new Error("Expected hook to expose group move handler");
143
157
  if (!groupResize) throw new Error("Expected hook to expose group resize handler");
144
- return {
145
- move,
146
- resize,
147
- groupMove,
148
- groupResize,
149
- unmount: () => {
150
- act(() => root.unmount());
151
- },
152
- };
158
+ if (!del) throw new Error("Expected hook to expose delete handler");
159
+ return { move, resize, groupMove, groupResize, del, unmount };
153
160
  }
154
161
 
155
162
  type TimelineRecordEdit = NonNullable<
@@ -159,7 +166,11 @@ type TimelineRecordEdit = NonNullable<
159
166
  function renderTimelineEditingHookWithLifecycle(input: {
160
167
  timelineElements: TimelineElement[];
161
168
  iframe: HTMLIFrameElement;
162
- commitDomEditPatchBatches: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
169
+ commitDomEditPatchBatches: ReturnType<
170
+ typeof vi.fn<
171
+ (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
172
+ >
173
+ >;
163
174
  }): {
164
175
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
165
176
  unmount: () => void;
@@ -198,172 +209,209 @@ function renderTimelineEditingHookWithLifecycle(input: {
198
209
  return null;
199
210
  }
200
211
 
201
- const host = document.createElement("div");
202
- document.body.append(host);
203
- const root = createRoot(host);
204
- act(() => {
205
- root.render(<Harness />);
212
+ const { unmount } = mountHarness(<Harness />);
213
+ if (!move) throw new Error("Expected hook to expose move handler");
214
+ return { move, unmount };
215
+ }
216
+
217
+ async function flushAsyncWork(): Promise<void> {
218
+ for (let i = 0; i < 8; i += 1) {
219
+ await Promise.resolve();
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Stub global fetch for project "p1": serves file contents (a single source
225
+ * string, or a path → content map) and answers the GSAP-mutation endpoint
226
+ * with `gsapBody`. Returns the mock for call inspection.
227
+ */
228
+ function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unknown) {
229
+ // Keep this test server's capability, file-read, and mutation routes together;
230
+ // splitting the fixture would obscure the request sequence asserted by callers.
231
+ // fallow-ignore-next-line complexity
232
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
233
+ const url = requestUrl(input);
234
+ if (url.includes("/api/projects/p1/gsap-mutation-capabilities")) {
235
+ return jsonResponse({ atomicOwnershipPairs: true });
236
+ }
237
+ if (url.includes("/api/projects/p1/files/")) {
238
+ if (typeof files === "string") return jsonResponse({ content: files });
239
+ const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
240
+ return jsonResponse({ content: files[path] });
241
+ }
242
+ if (url.includes("/api/projects/p1/gsap-mutations/")) {
243
+ const path = decodeURIComponent(url.split("/gsap-mutations/")[1] ?? "index.html");
244
+ const content = typeof files === "string" ? files : (files[path] ?? "");
245
+ return jsonResponse(
246
+ gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
247
+ );
248
+ }
249
+ throw new Error(`Unexpected fetch: ${url}`);
206
250
  });
251
+ vi.stubGlobal("fetch", fetchMock);
252
+ return fetchMock;
253
+ }
207
254
 
208
- if (!move) throw new Error("Expected hook to expose move handler");
209
- return {
210
- move,
211
- unmount: () => {
212
- act(() => root.unmount());
213
- },
214
- };
255
+ /**
256
+ * One clip on track 0 + the editing hook wired to project "p1" — shared setup
257
+ * for the single-clip move-path tests (horizontal, vertical-only, track-only,
258
+ * diagonal) so their arrange blocks aren't clones of each other.
259
+ */
260
+ function setupSingleClipHarness(options?: {
261
+ source?: string;
262
+ clipStyle?: string;
263
+ onZIndexCommit?: (entries: ZIndexEntry[]) => Promise<void>;
264
+ }) {
265
+ const iframe = createPreviewIframe([{ id: "clip", track: 0, style: options?.clipStyle }]);
266
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
267
+ const commit =
268
+ options?.onZIndexCommit ??
269
+ vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
270
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
271
+ const reloadPreview = vi.fn();
272
+ const fetchMock = stubProjectFetch(
273
+ options?.source ?? '<div id="clip" data-start="0" data-track-index="0"></div>',
274
+ );
275
+ const hook = renderTimelineEditingHook({
276
+ timelineElements: [clip],
277
+ iframe,
278
+ onZIndexCommit: commit,
279
+ projectId: "p1",
280
+ writeProjectFile,
281
+ recordEdit: vi.fn(async () => {}),
282
+ reloadPreview,
283
+ });
284
+ return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
285
+ }
286
+
287
+ /** Assert a lane write landed in both the live iframe DOM and the persisted file. */
288
+ function expectLanePersisted(
289
+ iframe: HTMLIFrameElement,
290
+ writeProjectFile: { mock: { calls: unknown[][] } },
291
+ track: string,
292
+ ): void {
293
+ const doc = iframe.contentDocument;
294
+ if (!doc) throw new Error("Expected iframe document");
295
+ expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe(track);
296
+ expect(writeProjectFile.mock.calls[0]![1]).toContain(`data-track-index="${track}"`);
215
297
  }
216
298
 
217
- function jsonResponse(body: unknown): Response {
218
- return new Response(JSON.stringify(body), {
219
- status: 200,
220
- headers: { "content-type": "application/json" },
299
+ /**
300
+ * Two 1s clips — a@0s on track 0, b@1s on track 1 — plus the matching iframe.
301
+ * Shared by the group-move tests; `bSourceFile` puts b in its own file for the
302
+ * cross-file partition test.
303
+ */
304
+ function makeTwoClipPair(bSourceFile?: string) {
305
+ const iframe = createPreviewIframe([
306
+ { id: "a", track: 0 },
307
+ { id: "b", track: 1 },
308
+ ]);
309
+ const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
310
+ const b = timelineElement({
311
+ id: "b",
312
+ track: 1,
313
+ zIndex: 0,
314
+ start: 1,
315
+ duration: 1,
316
+ sourceFile: bSourceFile,
221
317
  });
318
+ return { iframe, a, b };
222
319
  }
223
320
 
224
- function requestUrl(input: Parameters<typeof fetch>[0]): string {
225
- if (typeof input === "string") return input;
226
- if (input instanceof URL) return input.toString();
227
- return input.url;
321
+ const ROOT_DURATION_FALLBACK_SOURCE = [
322
+ `<div data-composition-id="main" data-duration="4">`,
323
+ ` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
324
+ `</div>`,
325
+ ].join("\n");
326
+
327
+ /** Shared setup for the SDK-fallback root-duration tests: one 2s clip in a 4s comp. */
328
+ async function setupRootDurationFallback() {
329
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
330
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
331
+ const sdkSession = await openComposition(ROOT_DURATION_FALLBACK_SOURCE);
332
+ const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
333
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
334
+ const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
335
+ const forceReloadSdkSession = vi.fn();
336
+ const reloadPreview = vi.fn();
337
+ const iframeWindow = iframe.contentWindow;
338
+ if (!iframeWindow) throw new Error("Expected iframe window");
339
+ const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
340
+ stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, {
341
+ mutated: false,
342
+ scriptText: null,
343
+ before: ROOT_DURATION_FALLBACK_SOURCE,
344
+ after: ROOT_DURATION_FALLBACK_SOURCE,
345
+ });
346
+ usePlayerStore.getState().setDuration(4);
347
+ const hook = renderTimelineEditingHook({
348
+ timelineElements: [clip],
349
+ iframe,
350
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
351
+ projectId: "p1",
352
+ writeProjectFile,
353
+ recordEdit,
354
+ sdkSession,
355
+ forceReloadSdkSession,
356
+ reloadPreview,
357
+ });
358
+ return {
359
+ hook,
360
+ clip,
361
+ setTimingSpy,
362
+ writeProjectFile,
363
+ forceReloadSdkSession,
364
+ reloadPreview,
365
+ postMessageSpy,
366
+ };
228
367
  }
229
368
 
230
- async function flushAsyncWork(): Promise<void> {
231
- for (let i = 0; i < 8; i += 1) {
232
- await Promise.resolve();
233
- }
369
+ /** Shared assertions: the fallback path grew the root to 5s and did ONE full reload. */
370
+ function expectRootDurationExtendedViaFallback(
371
+ ctx: Awaited<ReturnType<typeof setupRootDurationFallback>>,
372
+ ): void {
373
+ expect(ctx.setTimingSpy).not.toHaveBeenCalled();
374
+ expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain(
375
+ 'data-composition-id="main" data-duration="5"',
376
+ );
377
+ expect(usePlayerStore.getState().duration).toBe(5);
378
+ expect(ctx.forceReloadSdkSession).toHaveBeenCalledTimes(1);
379
+ // The GSAP endpoint returned no rewritten scriptText, so the timing sync
380
+ // escalates from the flash-free soft reload to ONE full reload. The root
381
+ // duration travels via the persisted content-driven `data-duration` (above),
382
+ // not a `set-root-duration` postMessage.
383
+ expect(ctx.reloadPreview).toHaveBeenCalledTimes(1);
384
+ expect(ctx.postMessageSpy).not.toHaveBeenCalledWith(
385
+ expect.objectContaining({ action: "set-root-duration" }),
386
+ "*",
387
+ );
234
388
  }
235
389
 
236
390
  describe("useTimelineEditing timeline z-index reorder", () => {
237
391
  it("extends root duration through the fallback path when an SDK-backed move passes the end", async () => {
238
- const source = [
239
- `<div data-composition-id="main" data-duration="4">`,
240
- ` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
241
- `</div>`,
242
- ].join("\n");
243
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
244
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
245
- const sdkSession = await openComposition(source);
246
- const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
247
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
248
- const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
249
- const forceReloadSdkSession = vi.fn();
250
- const reloadPreview = vi.fn();
251
- const iframeWindow = iframe.contentWindow;
252
- if (!iframeWindow) throw new Error("Expected iframe window");
253
- const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
254
- vi.stubGlobal(
255
- "fetch",
256
- vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
257
- const url = requestUrl(input);
258
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
259
- if (url.includes("/api/projects/p1/gsap-mutations/")) {
260
- return jsonResponse({ ok: true, mutated: false });
261
- }
262
- throw new Error(`Unexpected fetch: ${url}`);
263
- }),
264
- );
265
- usePlayerStore.getState().setDuration(4);
266
- const { move, unmount } = renderTimelineEditingHook({
267
- timelineElements: [clip],
268
- iframe,
269
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
270
- projectId: "p1",
271
- writeProjectFile,
272
- recordEdit,
273
- sdkSession,
274
- forceReloadSdkSession,
275
- reloadPreview,
276
- });
392
+ const ctx = await setupRootDurationFallback();
277
393
 
278
394
  await act(async () => {
279
- await move(clip, { start: 3, track: clip.track });
395
+ await ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track });
280
396
  });
281
397
 
282
- expect(setTimingSpy).not.toHaveBeenCalled();
283
- expect(writeProjectFile.mock.calls[0]![1]).toContain(
284
- 'data-composition-id="main" data-duration="5"',
285
- );
286
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
287
- expect(usePlayerStore.getState().duration).toBe(5);
288
- expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
289
- expect(reloadPreview).not.toHaveBeenCalled();
290
- expect(postMessageSpy).toHaveBeenCalledWith(
291
- {
292
- source: "hf-parent",
293
- type: "control",
294
- action: "set-root-duration",
295
- durationSeconds: 5,
296
- },
297
- "*",
298
- );
398
+ expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
399
+ expectRootDurationExtendedViaFallback(ctx);
299
400
 
300
- unmount();
401
+ ctx.hook.unmount();
301
402
  });
302
403
 
303
404
  it("extends root duration through the fallback path when an SDK-backed resize passes the end", async () => {
304
- const source = [
305
- `<div data-composition-id="main" data-duration="4">`,
306
- ` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
307
- `</div>`,
308
- ].join("\n");
309
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
310
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
311
- const sdkSession = await openComposition(source);
312
- const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
313
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
314
- const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
315
- const forceReloadSdkSession = vi.fn();
316
- const reloadPreview = vi.fn();
317
- const iframeWindow = iframe.contentWindow;
318
- if (!iframeWindow) throw new Error("Expected iframe window");
319
- const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
320
- vi.stubGlobal(
321
- "fetch",
322
- vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
323
- const url = requestUrl(input);
324
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
325
- if (url.includes("/api/projects/p1/gsap-mutations/")) {
326
- return jsonResponse({ ok: true, mutated: false });
327
- }
328
- throw new Error(`Unexpected fetch: ${url}`);
329
- }),
330
- );
331
- usePlayerStore.getState().setDuration(4);
332
- const { resize, unmount } = renderTimelineEditingHook({
333
- timelineElements: [clip],
334
- iframe,
335
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
336
- projectId: "p1",
337
- writeProjectFile,
338
- recordEdit,
339
- sdkSession,
340
- forceReloadSdkSession,
341
- reloadPreview,
342
- });
405
+ const ctx = await setupRootDurationFallback();
343
406
 
344
407
  await act(async () => {
345
- await resize(clip, { start: 0, duration: 5, playbackStart: undefined });
408
+ await ctx.hook.resize(ctx.clip, { start: 0, duration: 5, playbackStart: undefined });
346
409
  });
347
410
 
348
- expect(setTimingSpy).not.toHaveBeenCalled();
349
- expect(writeProjectFile.mock.calls[0]![1]).toContain(
350
- 'data-composition-id="main" data-duration="5"',
351
- );
352
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
353
- expect(usePlayerStore.getState().duration).toBe(5);
354
- expect(forceReloadSdkSession).toHaveBeenCalledTimes(1);
355
- expect(reloadPreview).not.toHaveBeenCalled();
356
- expect(postMessageSpy).toHaveBeenCalledWith(
357
- {
358
- source: "hf-parent",
359
- type: "control",
360
- action: "set-root-duration",
361
- durationSeconds: 5,
362
- },
363
- "*",
364
- );
411
+ expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
412
+ expectRootDurationExtendedViaFallback(ctx);
365
413
 
366
- unmount();
414
+ ctx.hook.unmount();
367
415
  });
368
416
 
369
417
  it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
@@ -481,7 +529,9 @@ describe("useTimelineEditing timeline z-index reorder", () => {
481
529
  ]);
482
530
  const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
483
531
  const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
484
- const commitDomEditPatchBatches = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
532
+ const commitDomEditPatchBatches = vi.fn<
533
+ (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
534
+ >(async () => ({ durable: true, allMatched: true, changed: true }));
485
535
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
486
536
  timelineElements: [front, back],
487
537
  iframe,
@@ -526,7 +576,9 @@ describe("useTimelineEditing timeline z-index reorder", () => {
526
576
  ]);
527
577
  const saveError = new Error("save failed");
528
578
  const commitDomEditPatchBatches = vi
529
- .fn<(...args: unknown[]) => Promise<void>>()
579
+ .fn<
580
+ (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
581
+ >()
530
582
  .mockRejectedValueOnce(saveError);
531
583
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
532
584
  timelineElements: [front, back],
@@ -590,8 +642,12 @@ describe("useTimelineEditing timeline z-index reorder", () => {
590
642
  releaseBatch = resolve;
591
643
  });
592
644
  const commitDomEditPatchBatches = vi
593
- .fn<(...args: unknown[]) => Promise<void>>()
594
- .mockReturnValueOnce(batchSave);
645
+ .fn<
646
+ (...args: unknown[]) => Promise<{ durable: boolean; allMatched: boolean; changed: boolean }>
647
+ >()
648
+ .mockReturnValueOnce(
649
+ batchSave.then(() => ({ durable: true, allMatched: true, changed: true })),
650
+ );
595
651
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
596
652
  timelineElements: [front, back],
597
653
  iframe,
@@ -632,90 +688,59 @@ describe("useTimelineEditing timeline z-index reorder", () => {
632
688
  });
633
689
 
634
690
  it("keeps horizontal-only drag on the timing and GSAP shift path without z-index writes", async () => {
635
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
636
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
637
- const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
638
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
639
- const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
640
- const reloadPreview = vi.fn();
641
- const fetchMock = vi.fn(
642
- async (
643
- input: Parameters<typeof fetch>[0],
644
- _init?: Parameters<typeof fetch>[1],
645
- ): Promise<Response> => {
646
- const url = requestUrl(input);
647
- if (url.includes("/api/projects/p1/files/")) {
648
- return jsonResponse({
649
- content: '<div id="clip" data-start="0" data-track-index="0"></div>',
650
- });
651
- }
652
- if (url.includes("/api/projects/p1/gsap-mutations/")) {
653
- return jsonResponse({ ok: true });
654
- }
655
- throw new Error(`Unexpected fetch: ${url}`);
656
- },
657
- );
658
- vi.stubGlobal("fetch", fetchMock);
659
- const { move, unmount } = renderTimelineEditingHook({
660
- timelineElements: [clip],
661
- iframe,
662
- onZIndexCommit: commit,
663
- projectId: "p1",
664
- writeProjectFile,
665
- recordEdit,
666
- reloadPreview,
667
- });
691
+ const h = setupSingleClipHarness();
668
692
 
669
693
  await act(async () => {
670
- await move(clip, { start: 1.25, track: clip.track });
694
+ await h.move(h.clip, { start: 1.25, track: h.clip.track });
671
695
  });
672
696
 
673
- const doc = iframe.contentDocument;
697
+ const doc = h.iframe.contentDocument;
674
698
  if (!doc) throw new Error("Expected iframe document");
675
699
  expect(doc.getElementById("clip")?.getAttribute("data-start")).toBe("1.25");
676
700
  expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("0");
677
- expect(commit).not.toHaveBeenCalled();
678
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
679
- expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
680
- expect(writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
701
+ expect(h.commit).not.toHaveBeenCalled();
702
+ expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="1.25"');
703
+ expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="0"');
704
+ expect(h.writeProjectFile.mock.calls[0]![1]).not.toContain("z-index");
681
705
  expect(
682
- fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
706
+ h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
683
707
  ).toBe(true);
684
708
 
685
- unmount();
709
+ h.unmount();
710
+ });
711
+
712
+ it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
713
+ // Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
714
+ // pure lane change routed through onMoveElement (no onMoveElements wired)
715
+ // wrote NOTHING — the lane snapped back on the next reload.
716
+ const h = setupSingleClipHarness();
717
+
718
+ await act(async () => {
719
+ // Vertical-only: same start, new track (already authored-space on this path).
720
+ await h.move(h.clip, { start: h.clip.start, track: 2 });
721
+ });
722
+
723
+ // Live DOM patched (no pre-reload lane snap-back) and the file write
724
+ // carries the new data-track-index with start intact.
725
+ expectLanePersisted(h.iframe, h.writeProjectFile, "2");
726
+ expect(h.writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
727
+
728
+ h.unmount();
686
729
  });
687
730
 
688
731
  it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
689
- const iframe = createPreviewIframe([
690
- { id: "clip", track: 0, style: "position: relative; z-index: 0" },
691
- ]);
692
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
693
732
  // Gate the z-index commit so we can observe whether the timing write waits.
694
733
  let releaseCommit!: () => void;
695
734
  const commitGate = new Promise<void>((resolve) => {
696
735
  releaseCommit = resolve;
697
736
  });
698
- const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockReturnValue(commitGate);
699
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
700
- const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
701
- const url = requestUrl(input);
702
- if (url.includes("/api/projects/p1/files/")) {
703
- return jsonResponse({
704
- content: '<div id="clip" data-start="0" data-track-index="0"></div>',
705
- });
706
- }
707
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
708
- throw new Error(`Unexpected fetch: ${url}`);
709
- });
710
- vi.stubGlobal("fetch", fetchMock);
711
- const { move, unmount } = renderTimelineEditingHook({
712
- timelineElements: [clip],
713
- iframe,
714
- onZIndexCommit: commit,
715
- projectId: "p1",
716
- writeProjectFile,
717
- recordEdit: vi.fn(async () => {}),
737
+ const h = setupSingleClipHarness({
738
+ clipStyle: "position: relative; z-index: 0",
739
+ onZIndexCommit: vi
740
+ .fn<(entries: ZIndexEntry[]) => Promise<void>>()
741
+ .mockReturnValue(commitGate),
718
742
  });
743
+ const { clip, commit, writeProjectFile, move, unmount } = h;
719
744
 
720
745
  // Diagonal drag: both a time move (start change) and a restack (z-index change).
721
746
  let movePromise!: Promise<unknown>;
@@ -766,15 +791,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
766
791
  ];
767
792
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
768
793
  const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
769
- vi.stubGlobal(
770
- "fetch",
771
- vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
772
- const url = requestUrl(input);
773
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
774
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
775
- throw new Error(`Unexpected fetch: ${url}`);
776
- }),
777
- );
794
+ stubProjectFetch(source);
778
795
  const { groupMove, unmount } = renderTimelineEditingHook({
779
796
  timelineElements: clips,
780
797
  iframe,
@@ -808,33 +825,10 @@ describe("useTimelineEditing timeline z-index reorder", () => {
808
825
  "index.html": '<div id="a" data-start="0" data-duration="1"></div>',
809
826
  "scene.html": '<div id="b" data-start="1" data-duration="1"></div>',
810
827
  };
811
- const iframe = createPreviewIframe([
812
- { id: "a", track: 0 },
813
- { id: "b", track: 1 },
814
- ]);
815
- const a = timelineElement({ id: "a", track: 0, zIndex: 0, start: 0, duration: 1 });
816
- const b = timelineElement({
817
- id: "b",
818
- track: 1,
819
- zIndex: 0,
820
- start: 1,
821
- duration: 1,
822
- sourceFile: "scene.html",
823
- });
828
+ const { iframe, a, b } = makeTwoClipPair("scene.html");
824
829
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
825
830
  const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
826
- vi.stubGlobal(
827
- "fetch",
828
- vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
829
- const url = requestUrl(input);
830
- if (url.includes("/api/projects/p1/files/")) {
831
- const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
832
- return jsonResponse({ content: files[path] });
833
- }
834
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
835
- throw new Error(`Unexpected fetch: ${url}`);
836
- }),
837
- );
831
+ stubProjectFetch(files);
838
832
  const { groupMove, unmount } = renderTimelineEditingHook({
839
833
  timelineElements: [a, b],
840
834
  iframe,
@@ -875,15 +869,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
875
869
  releaseCommit = resolve;
876
870
  });
877
871
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
878
- vi.stubGlobal(
879
- "fetch",
880
- vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
881
- const url = requestUrl(input);
882
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
883
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
884
- throw new Error(`Unexpected fetch: ${url}`);
885
- }),
886
- );
872
+ stubProjectFetch(source);
887
873
  const { groupMove, unmount } = renderTimelineEditingHook({
888
874
  timelineElements: [clip],
889
875
  iframe,
@@ -910,16 +896,152 @@ describe("useTimelineEditing timeline z-index reorder", () => {
910
896
  unmount();
911
897
  });
912
898
 
899
+ it("skips the GSAP fallback and reload for a TRACK-ONLY group move (z-mirror lane move)", async () => {
900
+ // The mirrored z-order lane move persists {start: unchanged, track: new}.
901
+ // Nothing timing-related changed — data-track-index is never read by the
902
+ // renderer — so the persist must NOT be followed by the GSAP round-trip or
903
+ // the full preview reload (the reload is what blinked the canvas).
904
+ const h = setupSingleClipHarness({
905
+ source: '<div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>',
906
+ });
907
+
908
+ await act(async () => {
909
+ await h.groupMove([{ element: h.clip, start: h.clip.start, track: 2 }]);
910
+ await flushAsyncWork();
911
+ });
912
+
913
+ // The lane write still persisted (live DOM + file)...
914
+ expectLanePersisted(h.iframe, h.writeProjectFile, "2");
915
+ expect(h.writeProjectFile).toHaveBeenCalledTimes(1);
916
+ // ...but no GSAP mutation ran and the preview was NOT reloaded.
917
+ expect(
918
+ h.fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
919
+ ).toBe(false);
920
+ expect(h.reloadPreview).not.toHaveBeenCalled();
921
+
922
+ h.unmount();
923
+ });
924
+
925
+ it("rebinds the preview in place (no blink) for a time-move of a no-domId clip", async () => {
926
+ // The gap-close blink path: "Close gap" issues pure TIME moves through
927
+ // handleTimelineGroupMove. A selector-addressed clip (no domId — e.g. a
928
+ // .sub caption) has no id-addressed GSAP tweens, so the mutation step has
929
+ // nothing to rewrite and used to fall through to a full reloadPreview().
930
+ // The sync must instead rebind the runtime timing in place — seek + rebind
931
+ // re-derive the clip windows from the already-patched DOM — WITHOUT
932
+ // re-executing any script (a comp's init-style scripts, e.g. a three.js
933
+ // setup, must never run twice) and without the full-reload blink.
934
+ const iframe = document.createElement("iframe");
935
+ document.body.append(iframe);
936
+ const doc = iframe.contentDocument;
937
+ if (!doc) throw new Error("Expected iframe document");
938
+ doc.body.innerHTML =
939
+ '<div class="cap" data-start="2" data-duration="1" data-track-index="0"></div>';
940
+ const liveScript = doc.createElement("script");
941
+ liveScript.textContent =
942
+ 'window.__timelines = window.__timelines || {}; window.__timelines["root"] = { kill: function () {} };';
943
+ doc.body.appendChild(liveScript);
944
+ const win = iframe.contentWindow as unknown as Record<string, unknown>;
945
+ win.gsap = { timeline: vi.fn(), set: vi.fn() };
946
+ win.__timelines = { root: { kill: vi.fn() } };
947
+ win.__hfForceTimelineRebind = vi.fn();
948
+ win.__player = { getTime: () => 0, seek: vi.fn() };
949
+
950
+ const cap: TimelineElement = {
951
+ ...timelineElement({ id: "cap", track: 0, zIndex: 0, start: 2, duration: 1 }),
952
+ domId: undefined,
953
+ selector: ".cap",
954
+ selectorIndex: 0,
955
+ };
956
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
957
+ const reloadPreview = vi.fn();
958
+ const fetchMock = stubProjectFetch(
959
+ '<div class="cap" data-hf-id="hf-cap" data-start="2" data-duration="1" data-track-index="0"></div>',
960
+ );
961
+ const { groupMove, unmount } = renderTimelineEditingHook({
962
+ timelineElements: [cap],
963
+ iframe,
964
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
965
+ projectId: "p1",
966
+ writeProjectFile,
967
+ recordEdit: vi.fn(async () => {}),
968
+ reloadPreview,
969
+ });
970
+
971
+ await act(async () => {
972
+ // Mirror timelineGapCommit: a pure time move keeping the current lane.
973
+ await groupMove([{ element: cap, start: 1, track: cap.track }]);
974
+ await flushAsyncWork();
975
+ });
976
+
977
+ // The move persisted (live DOM + file)...
978
+ expect(doc.querySelector(".cap")?.getAttribute("data-start")).toBe("1");
979
+ expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="1"');
980
+ // ...no GSAP mutation ran (nothing id-addressed to rewrite)...
981
+ expect(
982
+ fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
983
+ ).toBe(false);
984
+ // ...and the preview was rebound in place, NOT full-reloaded (blink),
985
+ // with the live script element left untouched (no re-execution).
986
+ expect(reloadPreview).not.toHaveBeenCalled();
987
+ expect(win.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
988
+ expect(doc.body.contains(liveScript)).toBe(true);
989
+ expect(doc.querySelectorAll("script")).toHaveLength(1);
990
+
991
+ unmount();
992
+ });
993
+
994
+ it("keeps the GSAP fallback + reload for a MIXED batch (any start change)", async () => {
995
+ // One clip changes lane only, the other shifts in time — the batch is not
996
+ // track-only, so the existing behavior (GSAP shift + full reload when no
997
+ // rewritten scriptText comes back — this stub iframe has no runtime rebind
998
+ // hook, so the in-place rebind can't apply) must be preserved.
999
+ const source = [
1000
+ '<div id="a" data-start="0" data-duration="1" data-track-index="0"></div>',
1001
+ '<div id="b" data-start="1" data-duration="1" data-track-index="1"></div>',
1002
+ ].join("\n");
1003
+ const { iframe, a, b } = makeTwoClipPair();
1004
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1005
+ const reloadPreview = vi.fn();
1006
+ const fetchMock = stubProjectFetch(source, {
1007
+ mutated: false,
1008
+ scriptText: null,
1009
+ before: source,
1010
+ after: source,
1011
+ });
1012
+ const { groupMove, unmount } = renderTimelineEditingHook({
1013
+ timelineElements: [a, b],
1014
+ iframe,
1015
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1016
+ projectId: "p1",
1017
+ writeProjectFile,
1018
+ recordEdit: vi.fn(async () => {}),
1019
+ reloadPreview,
1020
+ });
1021
+
1022
+ await act(async () => {
1023
+ await groupMove([
1024
+ { element: a, start: a.start, track: 2 },
1025
+ { element: b, start: 1.5, track: b.track },
1026
+ ]);
1027
+ await flushAsyncWork();
1028
+ });
1029
+
1030
+ expect(writeProjectFile).toHaveBeenCalledTimes(1);
1031
+ // The time-shifted clip still goes through the GSAP shift endpoint...
1032
+ expect(
1033
+ fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("gsap-mutations")),
1034
+ ).toBe(true);
1035
+ // ...and with no rewritten scriptText the sync escalates to one full reload.
1036
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
1037
+
1038
+ unmount();
1039
+ });
1040
+
913
1041
  it("matches the single-clip move output when a group move contains one clip", async () => {
914
1042
  const source = '<div id="clip" data-start="0" data-duration="1"></div>';
915
1043
  const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
916
- const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
917
- const url = requestUrl(input);
918
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
919
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
920
- throw new Error(`Unexpected fetch: ${url}`);
921
- });
922
- vi.stubGlobal("fetch", fetchMock);
1044
+ stubProjectFetch(source);
923
1045
 
924
1046
  const singleWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
925
1047
  const single = renderTimelineEditingHook({
@@ -952,3 +1074,195 @@ describe("useTimelineEditing timeline z-index reorder", () => {
952
1074
  group.unmount();
953
1075
  });
954
1076
  });
1077
+
1078
+ describe("useTimelineEditing duration rollback on failed persist", () => {
1079
+ const ROLLBACK_SOURCE = [
1080
+ `<div data-composition-id="main" data-duration="4">`,
1081
+ ` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
1082
+ `</div>`,
1083
+ ].join("\n");
1084
+
1085
+ /** Iframe with a comp root so the optimistic sync (and its rollback) can patch data-duration. */
1086
+ function createRootedIframe(source: string = ROLLBACK_SOURCE): HTMLIFrameElement {
1087
+ const iframe = document.createElement("iframe");
1088
+ document.body.append(iframe);
1089
+ const doc = iframe.contentDocument;
1090
+ if (!doc) throw new Error("Expected iframe document");
1091
+ doc.body.innerHTML = source;
1092
+ return iframe;
1093
+ }
1094
+
1095
+ function rootDurationAttr(iframe: HTMLIFrameElement): string | null | undefined {
1096
+ return iframe.contentDocument
1097
+ ?.querySelector("[data-composition-id]")
1098
+ ?.getAttribute("data-duration");
1099
+ }
1100
+
1101
+ function setupFailedPersist() {
1102
+ const iframe = createRootedIframe();
1103
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
1104
+ const writeError = new Error("write failed");
1105
+ const writeProjectFile = vi
1106
+ .fn<(...args: unknown[]) => Promise<void>>()
1107
+ .mockRejectedValue(writeError);
1108
+ stubProjectFetch(ROLLBACK_SOURCE);
1109
+ usePlayerStore.getState().setDuration(4);
1110
+ vi.spyOn(console, "error").mockImplementation(() => {});
1111
+ const hook = renderTimelineEditingHook({
1112
+ timelineElements: [clip],
1113
+ iframe,
1114
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1115
+ projectId: "p1",
1116
+ writeProjectFile,
1117
+ recordEdit: vi.fn(async () => {}),
1118
+ reloadPreview: vi.fn(),
1119
+ });
1120
+ return { iframe, clip, hook, writeError };
1121
+ }
1122
+
1123
+ /**
1124
+ * Run a persist that must reject with the harness's writeError, then assert
1125
+ * the store duration AND the live root rolled back to the pre-edit 4s —
1126
+ * the shared act/assert core of the four failed-persist tests below.
1127
+ */
1128
+ async function expectPersistRollback(
1129
+ ctx: ReturnType<typeof setupFailedPersist>,
1130
+ run: () => Promise<unknown>,
1131
+ ): Promise<void> {
1132
+ let rejection: unknown;
1133
+ await act(async () => {
1134
+ await run().catch((error) => {
1135
+ rejection = error;
1136
+ });
1137
+ await flushAsyncWork();
1138
+ });
1139
+ expect(rejection).toBe(ctx.writeError);
1140
+ expect(usePlayerStore.getState().duration).toBe(4);
1141
+ expect(rootDurationAttr(ctx.iframe)).toBe("4");
1142
+ }
1143
+
1144
+ it("rolls back the store duration and live root when a move persist fails", async () => {
1145
+ const ctx = setupFailedPersist();
1146
+ // Move past the end: the optimistic sync grows the readout to 5s.
1147
+ await expectPersistRollback(ctx, () =>
1148
+ ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track }),
1149
+ );
1150
+ ctx.hook.unmount();
1151
+ });
1152
+
1153
+ it("rolls back the store duration and live root when a resize persist fails", async () => {
1154
+ const ctx = setupFailedPersist();
1155
+ await expectPersistRollback(ctx, () =>
1156
+ ctx.hook.resize(ctx.clip, { start: 0, duration: 6, playbackStart: undefined }),
1157
+ );
1158
+ ctx.hook.unmount();
1159
+ });
1160
+
1161
+ it("rolls back the store duration and live root when a group move persist fails", async () => {
1162
+ const ctx = setupFailedPersist();
1163
+ await expectPersistRollback(ctx, () => ctx.hook.groupMove([{ element: ctx.clip, start: 3.5 }]));
1164
+ ctx.hook.unmount();
1165
+ });
1166
+
1167
+ it("rolls back the store duration and live root when a group resize persist fails", async () => {
1168
+ const ctx = setupFailedPersist();
1169
+ await expectPersistRollback(ctx, () =>
1170
+ ctx.hook.groupResize([{ element: ctx.clip, start: 0, duration: 7 }]),
1171
+ );
1172
+ ctx.hook.unmount();
1173
+ });
1174
+
1175
+ it("rolls back the store duration and live root when a delete persist fails", async () => {
1176
+ // Two clips; deleting the furthest one shrinks the content-driven duration
1177
+ // optimistically (4s -> 2s), so a failed write must roll that shrink back.
1178
+ const DELETE_SOURCE = [
1179
+ `<div data-composition-id="main" data-duration="4">`,
1180
+ ` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
1181
+ ` <div id="tail" data-start="2" data-duration="2" data-track-index="0"></div>`,
1182
+ `</div>`,
1183
+ ].join("\n");
1184
+ const DELETE_REMOVED_SOURCE = [
1185
+ `<div data-composition-id="main" data-duration="4">`,
1186
+ ` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
1187
+ `</div>`,
1188
+ ].join("\n");
1189
+
1190
+ const iframe = createRootedIframe(DELETE_SOURCE);
1191
+ const tail = timelineElement({ id: "tail", track: 0, zIndex: 0, start: 2, duration: 2 });
1192
+ const writeError = new Error("write failed");
1193
+ const writeProjectFile = vi
1194
+ .fn<(...args: unknown[]) => Promise<void>>()
1195
+ .mockRejectedValue(writeError);
1196
+ // The delete path reads the file, then asks the server-side remove-element
1197
+ // mutation for the post-removal source before persisting it.
1198
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
1199
+ const url = requestUrl(input);
1200
+ if (url.includes("/api/projects/p1/files/")) {
1201
+ return jsonResponse({ content: DELETE_SOURCE });
1202
+ }
1203
+ if (url.includes("/api/projects/p1/file-mutations/remove-element/")) {
1204
+ return jsonResponse({ changed: true, content: DELETE_REMOVED_SOURCE });
1205
+ }
1206
+ throw new Error(`Unexpected fetch: ${url}`);
1207
+ });
1208
+ vi.stubGlobal("fetch", fetchMock);
1209
+ usePlayerStore.getState().setDuration(4);
1210
+ const showToast = vi.fn();
1211
+ const hook = renderTimelineEditingHook({
1212
+ timelineElements: [tail],
1213
+ iframe,
1214
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1215
+ projectId: "p1",
1216
+ writeProjectFile,
1217
+ recordEdit: vi.fn(async () => {}),
1218
+ reloadPreview: vi.fn(),
1219
+ showToast,
1220
+ });
1221
+
1222
+ await act(async () => {
1223
+ // Unlike move/resize, the delete handler swallows the persist failure
1224
+ // into a toast, so the promise resolves.
1225
+ await hook.del(tail);
1226
+ await flushAsyncWork();
1227
+ });
1228
+
1229
+ // The optimistic shrink reached the persist attempt (root patched to the
1230
+ // furthest remaining clip end, 2s)...
1231
+ expect(writeProjectFile).toHaveBeenCalledTimes(1);
1232
+ expect(String(writeProjectFile.mock.calls[0]![1])).toContain(
1233
+ 'data-composition-id="main" data-duration="2"',
1234
+ );
1235
+ expect(showToast).toHaveBeenCalledWith("write failed");
1236
+ // ...and the failed write rolled the readout AND the live root back.
1237
+ expect(usePlayerStore.getState().duration).toBe(4);
1238
+ expect(rootDurationAttr(iframe)).toBe("4");
1239
+
1240
+ hook.unmount();
1241
+ });
1242
+
1243
+ it("keeps the grown duration when the persist succeeds", async () => {
1244
+ const { iframe, clip, hook } = setupFailedPersist();
1245
+ // Same harness, but with a write that succeeds this time.
1246
+ hook.unmount();
1247
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1248
+ const succeeding = renderTimelineEditingHook({
1249
+ timelineElements: [clip],
1250
+ iframe,
1251
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1252
+ projectId: "p1",
1253
+ writeProjectFile,
1254
+ recordEdit: vi.fn(async () => {}),
1255
+ reloadPreview: vi.fn(),
1256
+ });
1257
+
1258
+ await act(async () => {
1259
+ await succeeding.move(clip, { start: 3, track: clip.track });
1260
+ await flushAsyncWork();
1261
+ });
1262
+
1263
+ expect(usePlayerStore.getState().duration).toBe(5);
1264
+ expect(rootDurationAttr(iframe)).toBe("5");
1265
+
1266
+ succeeding.unmount();
1267
+ });
1268
+ });