@hyperframes/studio 0.7.55 → 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 (274) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/index-DfmYkU44.js +423 -0
  4. package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  7. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  8. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  9. package/dist/index.d.ts +172 -144
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +43613 -40346
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +106 -96
  15. package/src/components/EditorShell.tsx +253 -0
  16. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  17. package/src/components/StudioLeftSidebar.tsx +13 -3
  18. package/src/components/StudioRightPanel.tsx +8 -3
  19. package/src/components/TimelineToolbar.test.tsx +2 -2
  20. package/src/components/TimelineToolbar.tsx +179 -121
  21. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  22. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  23. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  24. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  25. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  26. package/src/components/editor/DomEditOverlay.tsx +126 -125
  27. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  28. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  29. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  30. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  31. package/src/components/editor/LayersPanel.test.ts +66 -2
  32. package/src/components/editor/LayersPanel.tsx +53 -2
  33. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  34. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  35. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  36. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  37. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  38. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  39. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  40. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  41. package/src/components/editor/domEditNudge.test.ts +80 -0
  42. package/src/components/editor/domEditNudge.ts +44 -0
  43. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  44. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  45. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  46. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  47. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  48. package/src/components/editor/domEditResizeLocal.ts +125 -0
  49. package/src/components/editor/domEditingDom.ts +1 -1
  50. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  51. package/src/components/editor/manualOffsetDrag.ts +55 -12
  52. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  53. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  54. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  55. package/src/components/editor/resizeDraft.ts +108 -0
  56. package/src/components/editor/snapEngine.test.ts +12 -78
  57. package/src/components/editor/snapEngine.ts +13 -53
  58. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  59. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  60. package/src/components/editor/useDomEditNudge.ts +254 -0
  61. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  62. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  63. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  64. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  65. package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
  66. package/src/components/nle/NLEContext.test.ts +144 -0
  67. package/src/components/nle/NLEContext.tsx +328 -0
  68. package/src/components/nle/NLEPreview.tsx +1 -1
  69. package/src/components/nle/PreviewOverlays.tsx +260 -0
  70. package/src/components/nle/PreviewPane.tsx +163 -0
  71. package/src/components/nle/TimelinePane.test.ts +60 -0
  72. package/src/components/nle/TimelinePane.tsx +293 -0
  73. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  74. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  75. package/src/components/nle/useCompositionStack.ts +17 -9
  76. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  77. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  78. package/src/components/sidebar/AssetCard.tsx +343 -0
  79. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  80. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  81. package/src/components/sidebar/AssetsTab.tsx +71 -206
  82. package/src/components/sidebar/AudioRow.tsx +56 -2
  83. package/src/components/sidebar/BlocksTab.tsx +8 -1
  84. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  85. package/src/components/sidebar/assetHelpers.ts +29 -0
  86. package/src/contexts/DomEditContext.tsx +9 -0
  87. package/src/contexts/StudioContext.tsx +9 -8
  88. package/src/contexts/TimelineEditContext.tsx +3 -7
  89. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  90. package/src/hooks/domEditCommitTypes.ts +23 -1
  91. package/src/hooks/domSelectionTestHarness.ts +14 -0
  92. package/src/hooks/gestureTransaction.test.ts +311 -0
  93. package/src/hooks/gestureTransaction.ts +199 -0
  94. package/src/hooks/gsapDragCommit.test.ts +141 -41
  95. package/src/hooks/gsapDragCommit.ts +64 -74
  96. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  97. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  98. package/src/hooks/gsapResizeIntercept.ts +38 -10
  99. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  100. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  101. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  102. package/src/hooks/gsapShared.test.ts +19 -1
  103. package/src/hooks/gsapShared.ts +14 -0
  104. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  105. package/src/hooks/timelineEditingGsap.ts +14 -0
  106. package/src/hooks/timelineEditingHelpers.test.ts +125 -1
  107. package/src/hooks/timelineEditingHelpers.ts +53 -292
  108. package/src/hooks/timelineMoveAdapter.test.ts +81 -0
  109. package/src/hooks/timelineMoveAdapter.ts +39 -0
  110. package/src/hooks/timelineTimingSync.test.ts +150 -0
  111. package/src/hooks/timelineTimingSync.ts +412 -0
  112. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  113. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  114. package/src/hooks/useAppHotkeys.ts +16 -25
  115. package/src/hooks/useBlockHandlers.ts +55 -27
  116. package/src/hooks/useCaptionDetection.ts +2 -0
  117. package/src/hooks/useCompositionDimensions.ts +32 -5
  118. package/src/hooks/useContextMenuDismiss.ts +31 -7
  119. package/src/hooks/useDomEditCommits.test.tsx +245 -1
  120. package/src/hooks/useDomEditCommits.ts +162 -3
  121. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  122. package/src/hooks/useDomEditSession.ts +2 -2
  123. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  124. package/src/hooks/useDomEditTextCommits.ts +8 -3
  125. package/src/hooks/useDomEditWiring.ts +7 -9
  126. package/src/hooks/useDomGeometryCommits.ts +17 -2
  127. package/src/hooks/useDomSelection.test.ts +2 -36
  128. package/src/hooks/useDomSelection.ts +81 -51
  129. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  130. package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
  131. package/src/hooks/useElementLifecycleOps.ts +53 -71
  132. package/src/hooks/useElementPicker.ts +7 -8
  133. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  134. package/src/hooks/useEnableKeyframes.ts +73 -29
  135. package/src/hooks/useGestureCommit.test.tsx +130 -0
  136. package/src/hooks/useGestureCommit.ts +32 -8
  137. package/src/hooks/useGroupCommits.ts +7 -28
  138. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  139. package/src/hooks/useGsapAwareEditing.ts +115 -20
  140. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  141. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  142. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  143. package/src/hooks/useGsapScriptCommits.ts +134 -50
  144. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  145. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  146. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  147. package/src/hooks/usePersistentEditHistory.ts +92 -58
  148. package/src/hooks/usePreviewPersistence.ts +25 -7
  149. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  150. package/src/hooks/useRazorSplit.test.ts +269 -0
  151. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  152. package/src/hooks/useRazorSplit.ts +107 -36
  153. package/src/hooks/useRenderClipContent.ts +24 -6
  154. package/src/hooks/useStudioContextValue.ts +15 -5
  155. package/src/hooks/useStudioUrlState.ts +2 -4
  156. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  157. package/src/hooks/useTimelineEditing.test.tsx +398 -241
  158. package/src/hooks/useTimelineEditing.ts +181 -265
  159. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  160. package/src/hooks/useTimelineGroupEditing.ts +167 -132
  161. package/src/index.ts +1 -1
  162. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  163. package/src/player/components/ImageThumbnail.tsx +160 -0
  164. package/src/player/components/PlayerControls.tsx +5 -5
  165. package/src/player/components/PlayheadIndicator.tsx +55 -5
  166. package/src/player/components/Timeline.test.ts +183 -23
  167. package/src/player/components/Timeline.tsx +194 -194
  168. package/src/player/components/TimelineCanvas.tsx +218 -523
  169. package/src/player/components/TimelineClip.tsx +4 -1
  170. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  171. package/src/player/components/TimelineEmptyState.tsx +3 -1
  172. package/src/player/components/TimelineLanes.tsx +487 -0
  173. package/src/player/components/TimelineOverlays.tsx +122 -0
  174. package/src/player/components/TimelineRuler.tsx +58 -48
  175. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  176. package/src/player/components/VideoThumbnail.tsx +28 -7
  177. package/src/player/components/thumbnailUtils.test.ts +127 -0
  178. package/src/player/components/thumbnailUtils.ts +54 -0
  179. package/src/player/components/timelineCallbacks.ts +18 -18
  180. package/src/player/components/timelineClipChildren.tsx +39 -0
  181. package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
  182. package/src/player/components/timelineClipDragCommit.ts +498 -0
  183. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  184. package/src/player/components/timelineClipDragPreview.ts +323 -38
  185. package/src/player/components/timelineClipDragTypes.ts +65 -0
  186. package/src/player/components/timelineCollision.test.ts +42 -2
  187. package/src/player/components/timelineDragDrop.ts +69 -60
  188. package/src/player/components/timelineEditCapabilities.ts +52 -0
  189. package/src/player/components/timelineEditing.test.ts +1 -1
  190. package/src/player/components/timelineEditing.ts +76 -59
  191. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  192. package/src/player/components/timelineGroupEditing.ts +133 -0
  193. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  194. package/src/player/components/timelineLayout.test.ts +95 -23
  195. package/src/player/components/timelineLayout.ts +207 -75
  196. package/src/player/components/timelineMarquee.test.ts +197 -0
  197. package/src/player/components/timelineMarquee.ts +112 -0
  198. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  199. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  200. package/src/player/components/timelineRevealScroll.ts +91 -0
  201. package/src/player/components/timelineStackingSync.test.ts +60 -0
  202. package/src/player/components/timelineStackingSync.ts +27 -2
  203. package/src/player/components/timelineTheme.ts +6 -1
  204. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  205. package/src/player/components/timelineZones.test.ts +122 -310
  206. package/src/player/components/timelineZones.ts +82 -135
  207. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  208. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  209. package/src/player/components/useTimelineClipDrag.ts +380 -386
  210. package/src/player/components/useTimelineEditPinning.ts +121 -0
  211. package/src/player/components/useTimelineGeometry.ts +129 -0
  212. package/src/player/components/useTimelinePlayhead.ts +4 -16
  213. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  214. package/src/player/components/useTimelineRevealClip.ts +56 -0
  215. package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
  216. package/src/player/components/useTimelineStackingSync.ts +94 -0
  217. package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
  218. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  219. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  220. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  221. package/src/player/hooks/useTimelinePlayer.ts +52 -53
  222. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  224. package/src/player/index.ts +5 -2
  225. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  226. package/src/player/lib/runtimeProtocol.ts +65 -0
  227. package/src/player/lib/time.test.ts +19 -19
  228. package/src/player/lib/time.ts +6 -2
  229. package/src/player/lib/timelineDOM.test.ts +25 -98
  230. package/src/player/lib/timelineDOM.ts +21 -81
  231. package/src/player/lib/timelineElementHelpers.ts +73 -5
  232. package/src/player/lib/timelineIframeHelpers.ts +4 -4
  233. package/src/player/store/playerStore.test.ts +38 -13
  234. package/src/player/store/playerStore.ts +99 -3
  235. package/src/styles/studio.css +27 -0
  236. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  237. package/src/utils/assetPreviewDismiss.ts +29 -0
  238. package/src/utils/assetPreviewStore.ts +33 -0
  239. package/src/utils/blockInstaller.ts +10 -2
  240. package/src/utils/editHistory.test.ts +35 -0
  241. package/src/utils/gsapSoftReload.test.ts +119 -1
  242. package/src/utils/gsapSoftReload.ts +153 -0
  243. package/src/utils/mediaTypes.ts +3 -2
  244. package/src/utils/resizeDebug.ts +55 -0
  245. package/src/utils/rootDuration.test.ts +90 -1
  246. package/src/utils/rootDuration.ts +76 -13
  247. package/src/utils/sdkCutover.ts +4 -0
  248. package/src/utils/studioFileHistory.ts +37 -7
  249. package/src/utils/studioHelpers.ts +62 -0
  250. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  251. package/src/utils/studioPreviewHelpers.ts +10 -0
  252. package/src/utils/studioTelemetry.ts +4 -1
  253. package/src/utils/studioUrlState.test.ts +0 -1
  254. package/src/utils/timelineAssetDrop.test.ts +144 -52
  255. package/src/utils/timelineAssetDrop.ts +67 -29
  256. package/src/utils/timelineDiscovery.ts +0 -17
  257. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  258. package/dist/assets/index-BRwkMj0w.js +0 -423
  259. package/dist/assets/index-Dq7FEg0K.css +0 -1
  260. package/dist/chunk-SOTCF4DF.js.map +0 -1
  261. package/src/components/StudioPreviewArea.tsx +0 -500
  262. package/src/components/nle/NLELayout.test.ts +0 -12
  263. package/src/components/nle/NLELayout.tsx +0 -591
  264. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  265. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  266. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  267. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  268. package/src/player/components/timelineSnapTargets.ts +0 -164
  269. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  270. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  271. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  272. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  273. package/src/utils/timelineDiscovery.test.ts +0 -90
  274. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -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<
@@ -159,7 +165,7 @@ type TimelineRecordEdit = NonNullable<
159
165
  function renderTimelineEditingHookWithLifecycle(input: {
160
166
  timelineElements: TimelineElement[];
161
167
  iframe: HTMLIFrameElement;
162
- commitPositionPatchToHtml: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
168
+ commitDomEditPatchBatches: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
163
169
  }): {
164
170
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
165
171
  unmount: () => void;
@@ -176,7 +182,7 @@ function renderTimelineEditingHookWithLifecycle(input: {
176
182
  projectIdRef: { current: "p1" },
177
183
  reloadPreview: vi.fn(),
178
184
  clearDomSelection: vi.fn(),
179
- commitPositionPatchToHtml: input.commitPositionPatchToHtml,
185
+ commitDomEditPatchBatches: input.commitDomEditPatchBatches,
180
186
  });
181
187
  const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit);
182
188
  commitRef.current = lifecycle.handleDomZIndexReorderCommit;
@@ -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 () => {
@@ -481,11 +457,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
481
457
  ]);
482
458
  const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
483
459
  const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
484
- const commitPositionPatchToHtml = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
460
+ const commitDomEditPatchBatches = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
485
461
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
486
462
  timelineElements: [front, back],
487
463
  iframe,
488
- commitPositionPatchToHtml,
464
+ commitDomEditPatchBatches,
489
465
  });
490
466
 
491
467
  await act(async () => {
@@ -501,8 +477,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
501
477
  await flushAsyncWork();
502
478
  });
503
479
 
504
- expect(commitPositionPatchToHtml).toHaveBeenCalled();
505
- expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
480
+ expect(commitDomEditPatchBatches).toHaveBeenCalled();
481
+ const batch = commitDomEditPatchBatches.mock.calls[0]![0] as Array<{
482
+ patches: Array<{ operations: unknown[] }>;
483
+ }>;
484
+ expect(batch[0]?.patches[0]?.operations).toEqual([
506
485
  { type: "inline-style", property: "z-index", value: "2" },
507
486
  { type: "inline-style", property: "position", value: "relative" },
508
487
  ]);
@@ -522,14 +501,13 @@ describe("useTimelineEditing timeline z-index reorder", () => {
522
501
  { ...back, hasExplicitZIndex: false },
523
502
  ]);
524
503
  const saveError = new Error("save failed");
525
- const commitPositionPatchToHtml = vi
504
+ const commitDomEditPatchBatches = vi
526
505
  .fn<(...args: unknown[]) => Promise<void>>()
527
- .mockResolvedValueOnce(undefined)
528
506
  .mockRejectedValueOnce(saveError);
529
507
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
530
508
  timelineElements: [front, back],
531
509
  iframe,
532
- commitPositionPatchToHtml,
510
+ commitDomEditPatchBatches,
533
511
  });
534
512
  const doc = iframe.contentDocument;
535
513
  if (!doc) throw new Error("Expected iframe document");
@@ -576,29 +554,24 @@ describe("useTimelineEditing timeline z-index reorder", () => {
576
554
  unmount();
577
555
  });
578
556
 
579
- it("waits for every lifecycle z-index save before resolving a reorder", async () => {
557
+ it("waits for the lifecycle z-index batch before resolving a reorder", async () => {
580
558
  const iframe = createPreviewIframe([
581
559
  { id: "front", track: 0, style: "position: relative; z-index: 1" },
582
560
  { id: "back", track: 1, style: "position: relative; z-index: 0" },
583
561
  ]);
584
562
  const front = timelineElement({ id: "front", track: 0, zIndex: 1 });
585
563
  const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
586
- let releaseFirst!: () => void;
587
- let releaseSecond!: () => void;
588
- const firstSave = new Promise<void>((resolve) => {
589
- releaseFirst = resolve;
590
- });
591
- const secondSave = new Promise<void>((resolve) => {
592
- releaseSecond = resolve;
564
+ let releaseBatch!: () => void;
565
+ const batchSave = new Promise<void>((resolve) => {
566
+ releaseBatch = resolve;
593
567
  });
594
- const commitPositionPatchToHtml = vi
568
+ const commitDomEditPatchBatches = vi
595
569
  .fn<(...args: unknown[]) => Promise<void>>()
596
- .mockReturnValueOnce(firstSave)
597
- .mockReturnValueOnce(secondSave);
570
+ .mockReturnValueOnce(batchSave);
598
571
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
599
572
  timelineElements: [front, back],
600
573
  iframe,
601
- commitPositionPatchToHtml,
574
+ commitDomEditPatchBatches,
602
575
  });
603
576
  let settled = false;
604
577
 
@@ -621,17 +594,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
621
594
  await flushAsyncWork();
622
595
  });
623
596
 
624
- expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(2);
597
+ expect(commitDomEditPatchBatches).toHaveBeenCalledTimes(1);
625
598
  expect(settled).toBe(false);
626
599
 
627
600
  await act(async () => {
628
- releaseFirst();
629
- await flushAsyncWork();
630
- });
631
- expect(settled).toBe(false);
632
-
633
- await act(async () => {
634
- releaseSecond();
601
+ releaseBatch();
635
602
  await movePromise;
636
603
  await flushAsyncWork();
637
604
  });
@@ -647,24 +614,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
647
614
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
648
615
  const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
649
616
  const reloadPreview = vi.fn();
650
- const fetchMock = vi.fn(
651
- async (
652
- input: Parameters<typeof fetch>[0],
653
- _init?: Parameters<typeof fetch>[1],
654
- ): Promise<Response> => {
655
- const url = requestUrl(input);
656
- if (url.includes("/api/projects/p1/files/")) {
657
- return jsonResponse({
658
- content: '<div id="clip" data-start="0" data-track-index="0"></div>',
659
- });
660
- }
661
- if (url.includes("/api/projects/p1/gsap-mutations/")) {
662
- return jsonResponse({ ok: true });
663
- }
664
- throw new Error(`Unexpected fetch: ${url}`);
665
- },
666
- );
667
- vi.stubGlobal("fetch", fetchMock);
617
+ const fetchMock = stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
668
618
  const { move, unmount } = renderTimelineEditingHook({
669
619
  timelineElements: [clip],
670
620
  iframe,
@@ -694,6 +644,41 @@ describe("useTimelineEditing timeline z-index reorder", () => {
694
644
  unmount();
695
645
  });
696
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
+
697
682
  it("orders the timing write after the z-index commit so a diagonal drag can't clobber the restack", async () => {
698
683
  const iframe = createPreviewIframe([
699
684
  { id: "clip", track: 0, style: "position: relative; z-index: 0" },
@@ -706,17 +691,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
706
691
  });
707
692
  const commit = vi.fn<(entries: ZIndexEntry[]) => Promise<void>>().mockReturnValue(commitGate);
708
693
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
709
- const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
710
- const url = requestUrl(input);
711
- if (url.includes("/api/projects/p1/files/")) {
712
- return jsonResponse({
713
- content: '<div id="clip" data-start="0" data-track-index="0"></div>',
714
- });
715
- }
716
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
717
- throw new Error(`Unexpected fetch: ${url}`);
718
- });
719
- vi.stubGlobal("fetch", fetchMock);
694
+ stubProjectFetch('<div id="clip" data-start="0" data-track-index="0"></div>');
720
695
  const { move, unmount } = renderTimelineEditingHook({
721
696
  timelineElements: [clip],
722
697
  iframe,
@@ -775,15 +750,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
775
750
  ];
776
751
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
777
752
  const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
778
- vi.stubGlobal(
779
- "fetch",
780
- vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
781
- const url = requestUrl(input);
782
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
783
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
784
- throw new Error(`Unexpected fetch: ${url}`);
785
- }),
786
- );
753
+ stubProjectFetch(source);
787
754
  const { groupMove, unmount } = renderTimelineEditingHook({
788
755
  timelineElements: clips,
789
756
  iframe,
@@ -832,18 +799,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
832
799
  });
833
800
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
834
801
  const recordEdit = vi.fn<TimelineRecordEdit>(async (_entry) => {});
835
- vi.stubGlobal(
836
- "fetch",
837
- vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
838
- const url = requestUrl(input);
839
- if (url.includes("/api/projects/p1/files/")) {
840
- const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
841
- return jsonResponse({ content: files[path] });
842
- }
843
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
844
- throw new Error(`Unexpected fetch: ${url}`);
845
- }),
846
- );
802
+ stubProjectFetch(files);
847
803
  const { groupMove, unmount } = renderTimelineEditingHook({
848
804
  timelineElements: [a, b],
849
805
  iframe,
@@ -884,15 +840,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
884
840
  releaseCommit = resolve;
885
841
  });
886
842
  const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
887
- vi.stubGlobal(
888
- "fetch",
889
- vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
890
- const url = requestUrl(input);
891
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
892
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
893
- throw new Error(`Unexpected fetch: ${url}`);
894
- }),
895
- );
843
+ stubProjectFetch(source);
896
844
  const { groupMove, unmount } = renderTimelineEditingHook({
897
845
  timelineElements: [clip],
898
846
  iframe,
@@ -922,13 +870,7 @@ describe("useTimelineEditing timeline z-index reorder", () => {
922
870
  it("matches the single-clip move output when a group move contains one clip", async () => {
923
871
  const source = '<div id="clip" data-start="0" data-duration="1"></div>';
924
872
  const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 0, duration: 1 });
925
- const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
926
- const url = requestUrl(input);
927
- if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: source });
928
- if (url.includes("/api/projects/p1/gsap-mutations/")) return jsonResponse({ ok: true });
929
- throw new Error(`Unexpected fetch: ${url}`);
930
- });
931
- vi.stubGlobal("fetch", fetchMock);
873
+ stubProjectFetch(source);
932
874
 
933
875
  const singleWrite = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
934
876
  const single = renderTimelineEditingHook({
@@ -961,3 +903,218 @@ describe("useTimelineEditing timeline z-index reorder", () => {
961
903
  group.unmount();
962
904
  });
963
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
+ });