@hyperframes/studio 0.7.56 → 0.7.57

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 (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
@@ -77,6 +77,21 @@ function timelineElement(input: {
77
77
  };
78
78
  }
79
79
 
80
+ /** Mount a harness component under act() and return its unmount hook. */
81
+ function mountHarness(node: React.ReactElement): { unmount: () => void } {
82
+ const host = document.createElement("div");
83
+ document.body.append(host);
84
+ const root = createRoot(host);
85
+ act(() => {
86
+ root.render(node);
87
+ });
88
+ return {
89
+ unmount: () => {
90
+ act(() => root.unmount());
91
+ },
92
+ };
93
+ }
94
+
80
95
  function renderTimelineEditingHook(input: {
81
96
  timelineElements: TimelineElement[];
82
97
  iframe: HTMLIFrameElement;
@@ -92,17 +107,20 @@ function renderTimelineEditingHook(input: {
92
107
  reloadPreview?: () => void;
93
108
  sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
94
109
  forceReloadSdkSession?: () => void;
110
+ showToast?: (message: string, kind?: string) => void;
95
111
  }): {
96
112
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
97
113
  resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
98
114
  groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"];
99
115
  groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"];
116
+ del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"];
100
117
  unmount: () => void;
101
118
  } {
102
119
  let move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"] | null = null;
103
120
  let resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"] | null = null;
104
121
  let groupMove: ReturnType<typeof useTimelineEditing>["handleTimelineGroupMove"] | null = null;
105
122
  let groupResize: ReturnType<typeof useTimelineEditing>["handleTimelineGroupResize"] | null = null;
123
+ let del: ReturnType<typeof useTimelineEditing>["handleTimelineElementDelete"] | null = null;
106
124
 
107
125
  function Harness() {
108
126
  const commitRef = useRef(input.onZIndexCommit);
@@ -111,7 +129,7 @@ function renderTimelineEditingHook(input: {
111
129
  projectId: input.projectId ?? null,
112
130
  activeCompPath: "index.html",
113
131
  timelineElements: input.timelineElements,
114
- showToast: vi.fn(),
132
+ showToast: input.showToast ?? vi.fn(),
115
133
  writeProjectFile: input.writeProjectFile ?? vi.fn(),
116
134
  recordEdit: input.recordEdit ?? vi.fn(),
117
135
  domEditSaveTimestampRef: { current: 0 },
@@ -127,29 +145,17 @@ function renderTimelineEditingHook(input: {
127
145
  resize = hook.handleTimelineElementResize;
128
146
  groupMove = hook.handleTimelineGroupMove;
129
147
  groupResize = hook.handleTimelineGroupResize;
148
+ del = hook.handleTimelineElementDelete;
130
149
  return null;
131
150
  }
132
151
 
133
- const host = document.createElement("div");
134
- document.body.append(host);
135
- const root = createRoot(host);
136
- act(() => {
137
- root.render(<Harness />);
138
- });
139
-
152
+ const { unmount } = mountHarness(<Harness />);
140
153
  if (!move) throw new Error("Expected hook to expose move handler");
141
154
  if (!resize) throw new Error("Expected hook to expose resize handler");
142
155
  if (!groupMove) throw new Error("Expected hook to expose group move handler");
143
156
  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
- };
157
+ if (!del) throw new Error("Expected hook to expose delete handler");
158
+ return { move, resize, groupMove, groupResize, del, unmount };
153
159
  }
154
160
 
155
161
  type TimelineRecordEdit = NonNullable<
@@ -198,20 +204,9 @@ function renderTimelineEditingHookWithLifecycle(input: {
198
204
  return null;
199
205
  }
200
206
 
201
- const host = document.createElement("div");
202
- document.body.append(host);
203
- const root = createRoot(host);
204
- act(() => {
205
- root.render(<Harness />);
206
- });
207
-
207
+ const { unmount } = mountHarness(<Harness />);
208
208
  if (!move) throw new Error("Expected hook to expose move handler");
209
- return {
210
- move,
211
- unmount: () => {
212
- act(() => root.unmount());
213
- },
214
- };
209
+ return { move, unmount };
215
210
  }
216
211
 
217
212
  function jsonResponse(body: unknown): Response {
@@ -233,137 +228,118 @@ async function flushAsyncWork(): Promise<void> {
233
228
  }
234
229
  }
235
230
 
231
+ /**
232
+ * Stub global fetch for project "p1": serves file contents (a single source
233
+ * string, or a path → content map) and answers the GSAP-mutation endpoint
234
+ * with `gsapBody`. Returns the mock for call inspection.
235
+ */
236
+ function stubProjectFetch(
237
+ files: string | Record<string, string>,
238
+ gsapBody: unknown = { ok: true },
239
+ ) {
240
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
241
+ const url = requestUrl(input);
242
+ if (url.includes("/api/projects/p1/files/")) {
243
+ if (typeof files === "string") return jsonResponse({ content: files });
244
+ const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
245
+ return jsonResponse({ content: files[path] });
246
+ }
247
+ if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse(gsapBody);
248
+ throw new Error(`Unexpected fetch: ${url}`);
249
+ });
250
+ vi.stubGlobal("fetch", fetchMock);
251
+ return fetchMock;
252
+ }
253
+
254
+ const ROOT_DURATION_FALLBACK_SOURCE = [
255
+ `<div data-composition-id="main" data-duration="4">`,
256
+ ` <div id="clip" data-hf-id="hf-clip" data-start="0" data-duration="2"></div>`,
257
+ `</div>`,
258
+ ].join("\n");
259
+
260
+ /** Shared setup for the SDK-fallback root-duration tests: one 2s clip in a 4s comp. */
261
+ async function setupRootDurationFallback() {
262
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
263
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
264
+ const sdkSession = await openComposition(ROOT_DURATION_FALLBACK_SOURCE);
265
+ const setTimingSpy = vi.spyOn(sdkSession, "setTiming");
266
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
267
+ const recordEdit = vi.fn<TimelineRecordEdit>(async () => {});
268
+ const forceReloadSdkSession = vi.fn();
269
+ const reloadPreview = vi.fn();
270
+ const iframeWindow = iframe.contentWindow;
271
+ if (!iframeWindow) throw new Error("Expected iframe window");
272
+ const postMessageSpy = vi.spyOn(iframeWindow, "postMessage");
273
+ stubProjectFetch(ROOT_DURATION_FALLBACK_SOURCE, { ok: true, mutated: false });
274
+ usePlayerStore.getState().setDuration(4);
275
+ const hook = renderTimelineEditingHook({
276
+ timelineElements: [clip],
277
+ iframe,
278
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
279
+ projectId: "p1",
280
+ writeProjectFile,
281
+ recordEdit,
282
+ sdkSession,
283
+ forceReloadSdkSession,
284
+ reloadPreview,
285
+ });
286
+ return {
287
+ hook,
288
+ clip,
289
+ setTimingSpy,
290
+ writeProjectFile,
291
+ forceReloadSdkSession,
292
+ reloadPreview,
293
+ postMessageSpy,
294
+ };
295
+ }
296
+
297
+ /** Shared assertions: the fallback path grew the root to 5s and did ONE full reload. */
298
+ function expectRootDurationExtendedViaFallback(
299
+ ctx: Awaited<ReturnType<typeof setupRootDurationFallback>>,
300
+ ): void {
301
+ expect(ctx.setTimingSpy).not.toHaveBeenCalled();
302
+ expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain(
303
+ 'data-composition-id="main" data-duration="5"',
304
+ );
305
+ expect(usePlayerStore.getState().duration).toBe(5);
306
+ expect(ctx.forceReloadSdkSession).toHaveBeenCalledTimes(1);
307
+ // The GSAP endpoint returned no rewritten scriptText, so the timing sync
308
+ // escalates from the flash-free soft reload to ONE full reload. The root
309
+ // duration travels via the persisted content-driven `data-duration` (above),
310
+ // not a `set-root-duration` postMessage.
311
+ expect(ctx.reloadPreview).toHaveBeenCalledTimes(1);
312
+ expect(ctx.postMessageSpy).not.toHaveBeenCalledWith(
313
+ expect.objectContaining({ action: "set-root-duration" }),
314
+ "*",
315
+ );
316
+ }
317
+
236
318
  describe("useTimelineEditing timeline z-index reorder", () => {
237
319
  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
- });
320
+ const ctx = await setupRootDurationFallback();
277
321
 
278
322
  await act(async () => {
279
- await move(clip, { start: 3, track: clip.track });
323
+ await ctx.hook.move(ctx.clip, { start: 3, track: ctx.clip.track });
280
324
  });
281
325
 
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
- );
326
+ expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-start="3"');
327
+ expectRootDurationExtendedViaFallback(ctx);
299
328
 
300
- unmount();
329
+ ctx.hook.unmount();
301
330
  });
302
331
 
303
332
  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
- });
333
+ const ctx = await setupRootDurationFallback();
343
334
 
344
335
  await act(async () => {
345
- await resize(clip, { start: 0, duration: 5, playbackStart: undefined });
336
+ await ctx.hook.resize(ctx.clip, { start: 0, duration: 5, playbackStart: undefined });
346
337
  });
347
338
 
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
- );
339
+ expect(ctx.writeProjectFile.mock.calls[0]![1]).toContain('data-duration="5"></div>');
340
+ expectRootDurationExtendedViaFallback(ctx);
365
341
 
366
- unmount();
342
+ ctx.hook.unmount();
367
343
  });
368
344
 
369
345
  it("routes a vertical drag through the shared z-index commit without writing track-index", async () => {
@@ -638,24 +614,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
638
614
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
639
615
  const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
640
616
  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);
617
+ const fetchMock = stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
659
618
  const { move, unmount } = renderTimelineEditingHook({
660
619
  timelineElements: [clip],
661
620
  iframe,
@@ -685,6 +644,41 @@ describe("useTimelineEditing timeline z-index reorder", () => {
685
644
  unmount();
686
645
  });
687
646
 
647
+ it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
648
+ // Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
649
+ // pure lane change routed through onMoveElement (no onMoveElements wired)
650
+ // wrote NOTHING — the lane snapped back on the next reload.
651
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
652
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
653
+ const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockResolvedValue(undefined);
654
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
655
+ stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
656
+ const { move, unmount } = renderTimelineEditingHook({
657
+ timelineElements: [clip],
658
+ iframe,
659
+ onZIndexCommit: commit,
660
+ projectId: "p1",
661
+ writeProjectFile,
662
+ recordEdit: vi.fn(async () => {}),
663
+ });
664
+
665
+ await act(async () => {
666
+ // Vertical-only: same start, new track (already authored-space on this path).
667
+ await move(clip, { start: clip.start, track: 2 });
668
+ });
669
+
670
+ const doc = iframe.contentDocument;
671
+ if (!doc) throw new Error("Expected iframe document");
672
+ // Live DOM patched so a pre-reload re-discovery doesn't snap the lane back...
673
+ expect(doc.getElementById("clip")?.getAttribute("data-track-index")).toBe("2");
674
+ // ...and the file write carries the new data-track-index with start intact.
675
+ expect(writeProjectFile).toHaveBeenCalled();
676
+ expect(writeProjectFile.mock.calls[0]![1]).toContain('data-track-index="2"');
677
+ expect(writeProjectFile.mock.calls[0]![1]).toContain('data-start="0"');
678
+
679
+ unmount();
680
+ });
681
+
688
682
  it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
689
683
  const iframe = createPreviewIframe([
690
684
  { id: "clip", track: 0, style: "position: relative; z-index: 0" },
@@ -697,17 +691,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
697
691
  });
698
692
  const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockReturnValue(commitGate);
699
693
  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);
694
+ stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
711
695
  const { move, unmount } = renderTimelineEditingHook({
712
696
  timelineElements: [clip],
713
697
  iframe,
@@ -766,15 +750,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
766
750
  ];
767
751
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
768
752
  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
- );
753
+ stubProjectFetch(source);
778
754
  const { groupMove, unmount } = renderTimelineEditingHook({
779
755
  timelineElements: clips,
780
756
  iframe,
@@ -823,18 +799,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
823
799
  });
824
800
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
825
801
  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
- );
802
+ stubProjectFetch(files);
838
803
  const { groupMove, unmount } = renderTimelineEditingHook({
839
804
  timelineElements: [a, b],
840
805
  iframe,
@@ -875,15 +840,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
875
840
  releaseCommit = resolve;
876
841
  });
877
842
  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
- );
843
+ stubProjectFetch(source);
887
844
  const { groupMove, unmount } = renderTimelineEditingHook({
888
845
  timelineElements: [clip],
889
846
  iframe,
@@ -913,13 +870,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
913
870
  it("matches the single-clip move output when a group move contains one clip", async () => {
914
871
  const source = '<div id="clip" data-start="0" data-duration="1"></div>';
915
872
  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);
873
+ stubProjectFetch(source);
923
874
 
924
875
  const singleWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
925
876
  const single = renderTimelineEditingHook({
@@ -952,3 +903,218 @@ describe("useTimelineEditing timeline z-index reorder", () => {
952
903
  group.unmount();
953
904
  });
954
905
  });
906
+
907
+ describe("useTimelineEditing duration rollback on failed persist", () => {
908
+ const ROLLBACK_SOURCE = [
909
+ `<div data-composition-id="main" data-duration="4">`,
910
+ ` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
911
+ `</div>`,
912
+ ].join("\n");
913
+
914
+ /** Iframe with a comp root so the optimistic sync (and its rollback) can patch data-duration. */
915
+ function createRootedIframe(source: string = ROLLBACK_SOURCE): HTMLIFrameElement {
916
+ const iframe = document.createElement("iframe");
917
+ document.body.append(iframe);
918
+ const doc = iframe.contentDocument;
919
+ if (!doc) throw new Error("Expected iframe document");
920
+ doc.body.innerHTML = source;
921
+ return iframe;
922
+ }
923
+
924
+ function rootDurationAttr(iframe: HTMLIFrameElement): string | null | undefined {
925
+ return iframe.contentDocument
926
+ ?.querySelector("[data-composition-id]")
927
+ ?.getAttribute("data-duration");
928
+ }
929
+
930
+ function setupFailedPersist() {
931
+ const iframe = createRootedIframe();
932
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0 });
933
+ const writeError = new Error("write failed");
934
+ const writeProjectFile = vi
935
+ .fn<(...args: unknown[]) => Promise<void>>()
936
+ .mockRejectedValue(writeError);
937
+ stubProjectFetch(ROLLBACK_SOURCE);
938
+ usePlayerStore.getState().setDuration(4);
939
+ vi.spyOn(console, "error").mockImplementation(() => {});
940
+ const hook = renderTimelineEditingHook({
941
+ timelineElements: [clip],
942
+ iframe,
943
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
944
+ projectId: "p1",
945
+ writeProjectFile,
946
+ recordEdit: vi.fn(async () => {}),
947
+ reloadPreview: vi.fn(),
948
+ });
949
+ return { iframe, clip, hook, writeError };
950
+ }
951
+
952
+ it("rolls back the store duration and live root when a move persist fails", async () => {
953
+ const { iframe, clip, hook, writeError } = setupFailedPersist();
954
+
955
+ let rejection: unknown;
956
+ await act(async () => {
957
+ // Move past the end: the optimistic sync grows the readout to 5s.
958
+ await hook.move(clip, { start: 3, track: clip.track }).catch((error) => {
959
+ rejection = error;
960
+ });
961
+ await flushAsyncWork();
962
+ });
963
+
964
+ expect(rejection).toBe(writeError);
965
+ expect(usePlayerStore.getState().duration).toBe(4);
966
+ expect(rootDurationAttr(iframe)).toBe("4");
967
+
968
+ hook.unmount();
969
+ });
970
+
971
+ it("rolls back the store duration and live root when a resize persist fails", async () => {
972
+ const { iframe, clip, hook, writeError } = setupFailedPersist();
973
+
974
+ let rejection: unknown;
975
+ await act(async () => {
976
+ await hook
977
+ .resize(clip, { start: 0, duration: 6, playbackStart: undefined })
978
+ .catch((error) => {
979
+ rejection = error;
980
+ });
981
+ await flushAsyncWork();
982
+ });
983
+
984
+ expect(rejection).toBe(writeError);
985
+ expect(usePlayerStore.getState().duration).toBe(4);
986
+ expect(rootDurationAttr(iframe)).toBe("4");
987
+
988
+ hook.unmount();
989
+ });
990
+
991
+ it("rolls back the store duration and live root when a group move persist fails", async () => {
992
+ const { iframe, clip, hook, writeError } = setupFailedPersist();
993
+
994
+ let rejection: unknown;
995
+ await act(async () => {
996
+ await hook.groupMove([{ element: clip, start: 3.5 }]).catch((error) => {
997
+ rejection = error;
998
+ });
999
+ await flushAsyncWork();
1000
+ });
1001
+
1002
+ expect(rejection).toBe(writeError);
1003
+ expect(usePlayerStore.getState().duration).toBe(4);
1004
+ expect(rootDurationAttr(iframe)).toBe("4");
1005
+
1006
+ hook.unmount();
1007
+ });
1008
+
1009
+ it("rolls back the store duration and live root when a group resize persist fails", async () => {
1010
+ const { iframe, clip, hook, writeError } = setupFailedPersist();
1011
+
1012
+ let rejection: unknown;
1013
+ await act(async () => {
1014
+ await hook.groupResize([{ element: clip, start: 0, duration: 7 }]).catch((error) => {
1015
+ rejection = error;
1016
+ });
1017
+ await flushAsyncWork();
1018
+ });
1019
+
1020
+ expect(rejection).toBe(writeError);
1021
+ expect(usePlayerStore.getState().duration).toBe(4);
1022
+ expect(rootDurationAttr(iframe)).toBe("4");
1023
+
1024
+ hook.unmount();
1025
+ });
1026
+
1027
+ it("rolls back the store duration and live root when a delete persist fails", async () => {
1028
+ // Two clips; deleting the furthest one shrinks the content-driven duration
1029
+ // optimistically (4s -> 2s), so a failed write must roll that shrink back.
1030
+ const DELETE_SOURCE = [
1031
+ `<div data-composition-id="main" data-duration="4">`,
1032
+ ` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
1033
+ ` <div id="tail" data-start="2" data-duration="2" data-track-index="0"></div>`,
1034
+ `</div>`,
1035
+ ].join("\n");
1036
+ const DELETE_REMOVED_SOURCE = [
1037
+ `<div data-composition-id="main" data-duration="4">`,
1038
+ ` <div id="clip" data-start="0" data-duration="2" data-track-index="0"></div>`,
1039
+ `</div>`,
1040
+ ].join("\n");
1041
+
1042
+ const iframe = createRootedIframe(DELETE_SOURCE);
1043
+ const tail = timelineElement({ id: "tail", track: 0, zIndex: 0, start: 2, duration: 2 });
1044
+ const writeError = new Error("write failed");
1045
+ const writeProjectFile = vi
1046
+ .fn<(...args: unknown[]) => Promise<void>>()
1047
+ .mockRejectedValue(writeError);
1048
+ // The delete path reads the file, then asks the server-side remove-element
1049
+ // mutation for the post-removal source before persisting it.
1050
+ const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
1051
+ const url = requestUrl(input);
1052
+ if (url.includes("/api/projects/p1/files/")) {
1053
+ return jsonResponse({ content: DELETE_SOURCE });
1054
+ }
1055
+ if (url.includes("/api/projects/p1/file-mutations/remove-element/")) {
1056
+ return jsonResponse({ changed: true, content: DELETE_REMOVED_SOURCE });
1057
+ }
1058
+ throw new Error(`Unexpected fetch: ${url}`);
1059
+ });
1060
+ vi.stubGlobal("fetch", fetchMock);
1061
+ usePlayerStore.getState().setDuration(4);
1062
+ const showToast = vi.fn();
1063
+ const hook = renderTimelineEditingHook({
1064
+ timelineElements: [tail],
1065
+ iframe,
1066
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1067
+ projectId: "p1",
1068
+ writeProjectFile,
1069
+ recordEdit: vi.fn(async () => {}),
1070
+ reloadPreview: vi.fn(),
1071
+ showToast,
1072
+ });
1073
+
1074
+ await act(async () => {
1075
+ // Unlike move/resize, the delete handler swallows the persist failure
1076
+ // into a toast, so the promise resolves.
1077
+ await hook.del(tail);
1078
+ await flushAsyncWork();
1079
+ });
1080
+
1081
+ // The optimistic shrink reached the persist attempt (root patched to the
1082
+ // furthest remaining clip end, 2s)...
1083
+ expect(writeProjectFile).toHaveBeenCalledTimes(1);
1084
+ expect(String(writeProjectFile.mock.calls[0]![1])).toContain(
1085
+ 'data-composition-id="main" data-duration="2"',
1086
+ );
1087
+ expect(showToast).toHaveBeenCalledWith("write failed");
1088
+ // ...and the failed write rolled the readout AND the live root back.
1089
+ expect(usePlayerStore.getState().duration).toBe(4);
1090
+ expect(rootDurationAttr(iframe)).toBe("4");
1091
+
1092
+ hook.unmount();
1093
+ });
1094
+
1095
+ it("keeps the grown duration when the persist succeeds", async () => {
1096
+ const { iframe, clip, hook } = setupFailedPersist();
1097
+ // Same harness, but with a write that succeeds this time.
1098
+ hook.unmount();
1099
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
1100
+ const succeeding = renderTimelineEditingHook({
1101
+ timelineElements: [clip],
1102
+ iframe,
1103
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1104
+ projectId: "p1",
1105
+ writeProjectFile,
1106
+ recordEdit: vi.fn(async () => {}),
1107
+ reloadPreview: vi.fn(),
1108
+ });
1109
+
1110
+ await act(async () => {
1111
+ await succeeding.move(clip, { start: 3, track: clip.track });
1112
+ await flushAsyncWork();
1113
+ });
1114
+
1115
+ expect(usePlayerStore.getState().duration).toBe(5);
1116
+ expect(rootDurationAttr(iframe)).toBe("5");
1117
+
1118
+ succeeding.unmount();
1119
+ });
1120
+ });