@hyperframes/studio 0.7.54 → 0.7.56

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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -20,6 +20,14 @@ interface UseRazorSplitOptions {
20
20
  recordEdit: (input: RecordEditInput) => Promise<void>;
21
21
  domEditSaveTimestampRef: React.MutableRefObject<number>;
22
22
  reloadPreview: () => void;
23
+ /**
24
+ * Resync the in-memory SDK session after the server-side split write (the
25
+ * split-element / split-gsap endpoints write the file directly, so the SDK's
26
+ * linkedom doc is now stale). This reload is read-only; the split endpoint owns
27
+ * the final on-disk bytes and history baseline. Every other server-side-write
28
+ * timeline path (move / resize / delete / drop / visibility) also resyncs.
29
+ */
30
+ forceReloadSdkSession?: () => void;
23
31
  isRecordingRef?: React.RefObject<boolean>;
24
32
  }
25
33
 
@@ -101,6 +109,62 @@ async function splitGsapAnimations(
101
109
  };
102
110
  }
103
111
 
112
+ function getOriginalContent(originals: ReadonlyMap<string, string>, path: string): string {
113
+ const original = originals.get(path);
114
+ if (original === undefined) {
115
+ throw new Error(`Missing original contents for ${path}`);
116
+ }
117
+ return original;
118
+ }
119
+
120
+ async function restoreFilesToOriginal(
121
+ originals: ReadonlyMap<string, string>,
122
+ paths: Iterable<string>,
123
+ writeProjectFile: (path: string, content: string) => Promise<void>,
124
+ ): Promise<void> {
125
+ for (const path of paths) {
126
+ await writeProjectFile(path, getOriginalContent(originals, path));
127
+ }
128
+ }
129
+
130
+ async function readOriginalFiles(
131
+ pid: string,
132
+ elements: TimelineElement[],
133
+ activeCompPath: string | null,
134
+ ): Promise<Map<string, string>> {
135
+ const originals = new Map<string, string>();
136
+ for (const element of elements) {
137
+ const path = element.sourceFile || activeCompPath || "index.html";
138
+ if (!originals.has(path)) {
139
+ originals.set(path, await readFileContent(pid, path));
140
+ }
141
+ }
142
+ return originals;
143
+ }
144
+
145
+ async function splitElementsAtTime(
146
+ pid: string,
147
+ elements: TimelineElement[],
148
+ splitTime: number,
149
+ activeCompPath: string | null,
150
+ originals: ReadonlyMap<string, string>,
151
+ snapshots: Map<string, { before: string; after: string }>,
152
+ writeProjectFile: (path: string, content: string) => Promise<void>,
153
+ ): Promise<number> {
154
+ let count = 0;
155
+ for (const element of elements) {
156
+ const result = await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
157
+ if (!result.changed) continue;
158
+ snapshots.set(result.targetPath, {
159
+ before: getOriginalContent(originals, result.targetPath),
160
+ after: result.patchedContent,
161
+ });
162
+ await writeProjectFile(result.targetPath, result.patchedContent);
163
+ count++;
164
+ }
165
+ return count;
166
+ }
167
+
104
168
  // fallow-ignore-next-line complexity
105
169
  async function executeSplit(
106
170
  pid: string,
@@ -122,13 +186,23 @@ async function executeSplit(
122
186
  const originalContent = await readFileContent(pid, targetPath);
123
187
  const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
124
188
 
189
+ // An expanded sub-comp child arrives in MASTER-timeline coordinates — both its
190
+ // `start` and the incoming `splitTime` are offset by the host's master start
191
+ // (expandedParentStart) — but its `sourceFile` is the sub-comp, whose clips are
192
+ // authored in LOCAL time. Rebase both onto local time before the server patches
193
+ // the file, exactly as TimelinePane.handleSplitElement does for non-razor edits.
194
+ // Root-level clips (no expandedParentStart) are already local, so pass through.
195
+ const basis = element.expandedParentStart;
196
+ const localSplitTime = basis === undefined ? splitTime : Math.max(0, splitTime - basis);
197
+ const localElementStart = basis === undefined ? element.start : element.start - basis;
198
+
125
199
  const splitResult = await splitHtmlElement(
126
200
  pid,
127
201
  targetPath,
128
202
  patchTarget,
129
- splitTime,
203
+ localSplitTime,
130
204
  newId,
131
- element.start,
205
+ localElementStart,
132
206
  element.duration,
133
207
  );
134
208
  if (!splitResult.ok) throw new Error("Failed to split clip.");
@@ -147,8 +221,8 @@ async function executeSplit(
147
221
  targetPath,
148
222
  element.domId,
149
223
  newId,
150
- splitTime,
151
- element.start,
224
+ localSplitTime,
225
+ localElementStart,
152
226
  element.duration,
153
227
  );
154
228
  if (gsapResult.content) patchedContent = gsapResult.content;
@@ -172,6 +246,7 @@ export function useRazorSplit({
172
246
  recordEdit,
173
247
  domEditSaveTimestampRef,
174
248
  reloadPreview,
249
+ forceReloadSdkSession,
175
250
  isRecordingRef,
176
251
  }: UseRazorSplitOptions) {
177
252
  const projectIdRef = useRef(projectId);
@@ -208,6 +283,9 @@ export function useRazorSplit({
208
283
  recordEdit,
209
284
  });
210
285
 
286
+ // Server writes bypass the SDK session, so reopen it before refreshing
287
+ // the preview. The split response already owns the final persisted bytes.
288
+ forceReloadSdkSession?.();
211
289
  reloadPreview();
212
290
  trackStudioRazorSplit({ mode: "single", count: 1 });
213
291
  showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
@@ -229,6 +307,7 @@ export function useRazorSplit({
229
307
  writeProjectFile,
230
308
  domEditSaveTimestampRef,
231
309
  reloadPreview,
310
+ forceReloadSdkSession,
232
311
  isRecordingRef,
233
312
  ],
234
313
  );
@@ -247,51 +326,42 @@ export function useRazorSplit({
247
326
  const splittable = selectSplittableElements(elements, splitTime);
248
327
  if (splittable.length === 0) return;
249
328
 
329
+ let originals = new Map<string, string>();
330
+ const finalSnapshots = new Map<string, { before: string; after: string }>();
250
331
  try {
251
- const originals = new Map<string, string>();
252
- for (const el of splittable) {
253
- const path = el.sourceFile || activeCompPath || "index.html";
254
- if (!originals.has(path)) {
255
- originals.set(path, await readFileContent(pid, path));
256
- }
257
- }
258
-
259
- let splitCount = 0;
260
- const finalContent = new Map<string, string>();
261
-
262
- for (const element of splittable) {
263
- const result = await executeSplit(
264
- pid,
265
- element,
266
- splitTime,
267
- activeCompPath,
268
- writeProjectFile,
269
- );
270
- if (result.changed) {
271
- finalContent.set(result.targetPath, result.patchedContent);
272
- await writeProjectFile(result.targetPath, result.patchedContent);
273
- splitCount++;
274
- }
275
- }
276
-
332
+ originals = await readOriginalFiles(pid, splittable, activeCompPath);
333
+ const splitCount = await splitElementsAtTime(
334
+ pid,
335
+ splittable,
336
+ splitTime,
337
+ activeCompPath,
338
+ originals,
339
+ finalSnapshots,
340
+ writeProjectFile,
341
+ );
277
342
  if (splitCount === 0) return;
278
343
 
279
344
  domEditSaveTimestampRef.current = Date.now();
280
345
  await recordEdit({
281
346
  label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
282
347
  kind: "timeline",
283
- files: Object.fromEntries(
284
- [...finalContent].map(([path, after]) => [
285
- path,
286
- { before: originals.get(path) ?? "", after },
287
- ]),
288
- ),
348
+ files: Object.fromEntries(finalSnapshots),
289
349
  });
290
350
 
351
+ // Resync the stale SDK doc after the batched server write (see the
352
+ // single-split path above for why this precedes the reload).
353
+ forceReloadSdkSession?.();
291
354
  reloadPreview();
292
355
  trackStudioRazorSplit({ mode: "all", count: splitCount });
293
356
  showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
294
357
  } catch (error) {
358
+ // Best-effort rollback — a failing restore write must not swallow the
359
+ // original error's toast, which is what tells the user the split failed.
360
+ try {
361
+ await restoreFilesToOriginal(originals, finalSnapshots.keys(), writeProjectFile);
362
+ } catch {
363
+ /* leave disk as-is; the original failure is reported below */
364
+ }
295
365
  const message = error instanceof Error ? error.message : "Failed to split clips";
296
366
  showToast(message, "error");
297
367
  }
@@ -303,6 +373,7 @@ export function useRazorSplit({
303
373
  writeProjectFile,
304
374
  domEditSaveTimestampRef,
305
375
  reloadPreview,
376
+ forceReloadSdkSession,
306
377
  isRecordingRef,
307
378
  ],
308
379
  );
@@ -3,6 +3,8 @@ import { createElement } from "react";
3
3
  import { CompositionThumbnail, VideoThumbnail } from "../player";
4
4
  import type { TimelineElement } from "../player";
5
5
  import { AudioWaveform } from "../player/components/AudioWaveform";
6
+ import { ImageThumbnail } from "../player/components/ImageThumbnail";
7
+ import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
6
8
 
7
9
  export function normalizeCompositionSrc(
8
10
  compSrc: string,
@@ -51,8 +53,16 @@ function trimFractions(el: TimelineElement): { start?: number; end?: number } {
51
53
  */
52
54
  function renderAudioClip(el: TimelineElement, pid: string, labelColor: string): ReactNode {
53
55
  const srcRelative = resolvePreviewRelative(el.src, pid);
54
- const audioUrl = srcRelative ? `/api/projects/${pid}/preview/${srcRelative}` : (el.src ?? "");
55
- const waveformUrl = srcRelative ? `/api/projects/${pid}/waveform/${srcRelative}` : undefined;
56
+ // Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches
57
+ // what the assets panel loads a raw segment 404s. resolvePreviewRelative
58
+ // returns the DECODED path, so it must be re-encoded here.
59
+ const encodedRelative = srcRelative ? encodePreviewPath(srcRelative) : null;
60
+ const audioUrl = encodedRelative
61
+ ? `/api/projects/${pid}/preview/${encodedRelative}`
62
+ : (el.src ?? "");
63
+ const waveformUrl = encodedRelative
64
+ ? `/api/projects/${pid}/waveform/${encodedRelative}`
65
+ : undefined;
56
66
  const { start, end } = trimFractions(el);
57
67
  return createElement(AudioWaveform, {
58
68
  audioUrl,
@@ -100,7 +110,7 @@ export function useRenderClipContent({
100
110
  // instead of capturing the master at a time when the comp is fading in.
101
111
  if (compSrc) {
102
112
  return createElement(CompositionThumbnail, {
103
- previewUrl: `/api/projects/${pid}/preview/comp/${compSrc}`,
113
+ previewUrl: `/api/projects/${pid}/preview/comp/${encodePreviewPath(compSrc)}`,
104
114
  label: "",
105
115
  labelColor: style.label,
106
116
 
@@ -138,9 +148,17 @@ export function useRenderClipContent({
138
148
  !/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
139
149
 
140
150
  if ((el.tag === "video" || el.tag === "img") && el.src) {
141
- const mediaSrc = el.src.startsWith("http")
142
- ? el.src
143
- : `/api/projects/${pid}/preview/${el.src}`;
151
+ const mediaSrc = resolveMediaPreviewUrl(el.src, pid);
152
+ // Still images can't be decoded by VideoThumbnail's <video> extractor
153
+ // (the error event fires and the shimmer never resolves) — render the
154
+ // image itself as the strip.
155
+ if (el.tag === "img") {
156
+ return createElement(ImageThumbnail, {
157
+ imageSrc: mediaSrc,
158
+ label: "",
159
+ labelColor: style.label,
160
+ });
161
+ }
144
162
  return createElement(VideoThumbnail, {
145
163
  videoSrc: mediaSrc,
146
164
  label: "",
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
2
2
  import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
3
3
  import type { StudioContextValue } from "../contexts/StudioContext";
4
4
  import type { RightInspectorPanes } from "../utils/studioHelpers";
5
+ import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
6
+ import { usePlayerStore } from "../player";
5
7
 
6
8
  interface StudioContextInput {
7
9
  projectId: string;
@@ -34,8 +36,6 @@ interface StudioContextInput {
34
36
  waitForPendingDomEditSaves: () => Promise<void>;
35
37
  handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
36
38
  refreshPreviewDocumentVersion: () => void;
37
- timelineVisible: boolean;
38
- toggleTimelineVisibility: () => void;
39
39
  }
40
40
 
41
41
  // fallow-ignore-next-line complexity
@@ -61,8 +61,6 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
61
61
  waitForPendingDomEditSaves: input.waitForPendingDomEditSaves,
62
62
  handlePreviewIframeRef: input.handlePreviewIframeRef,
63
63
  refreshPreviewDocumentVersion: input.refreshPreviewDocumentVersion,
64
- timelineVisible: input.timelineVisible,
65
- toggleTimelineVisibility: input.toggleTimelineVisibility,
66
64
  };
67
65
  }
68
66
 
@@ -108,7 +106,7 @@ export function useInspectorState(
108
106
  }
109
107
 
110
108
  // fallow-ignore-next-line complexity
111
- export function useDragOverlay(onImportFiles: (files: FileList) => void) {
109
+ function useDragOverlay(onImportFiles: (files: FileList) => void) {
112
110
  const [active, setActive] = useState(false);
113
111
  const counterRef = useRef(0);
114
112
  const onDragOver = useCallback((e: DragEvent) => {
@@ -137,3 +135,15 @@ export function useDragOverlay(onImportFiles: (files: FileList) => void) {
137
135
  );
138
136
  return { active, onDragOver, onDragEnter, onDragLeave, onDrop };
139
137
  }
138
+
139
+ /** Global OS file drop: imports and places at the playhead position. */
140
+ export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandler) {
141
+ const onDrop = useCallback(
142
+ (files: FileList) => {
143
+ const start = usePlayerStore.getState().currentTime;
144
+ void handleTimelineFileDrop(Array.from(files), { start, track: 0 });
145
+ },
146
+ [handleTimelineFileDrop],
147
+ );
148
+ return useDragOverlay(onDrop);
149
+ }
@@ -20,7 +20,6 @@ interface UseStudioUrlStateParams {
20
20
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
21
21
  rightPanelTab: RightPanelTab;
22
22
  rightCollapsed: boolean;
23
- timelineVisible: boolean;
24
23
  activeCompPathHydrated: boolean;
25
24
  domEditSelection: DomEditSelection | null;
26
25
  buildDomSelectionFromTarget: (
@@ -66,7 +65,6 @@ export function useStudioUrlState({
66
65
  previewIframeRef,
67
66
  rightPanelTab,
68
67
  rightCollapsed,
69
- timelineVisible,
70
68
  activeCompPathHydrated,
71
69
  domEditSelection,
72
70
  buildDomSelectionFromTarget,
@@ -91,12 +89,12 @@ export function useStudioUrlState({
91
89
  currentTime: stableTimeRef.current,
92
90
  rightPanelTab,
93
91
  rightCollapsed,
94
- timelineVisible,
92
+ timelineVisible: null,
95
93
  selection: hydratedSelectionRef.current
96
94
  ? toPersistedSelection(domEditSelection)
97
95
  : pendingSelectionRef.current,
98
96
  }),
99
- [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible],
97
+ [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab],
100
98
  );
101
99
 
102
100
  // Resolve a URL selection to a live element and apply it. Shared by the initial
@@ -159,7 +159,7 @@ type TimelineRecordEdit = NonNullable<
159
159
  function renderTimelineEditingHookWithLifecycle(input: {
160
160
  timelineElements: TimelineElement[];
161
161
  iframe: HTMLIFrameElement;
162
- commitPositionPatchToHtml: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
162
+ commitDomEditPatchBatches: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<void>>>;
163
163
  }): {
164
164
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
165
165
  unmount: () => void;
@@ -176,7 +176,7 @@ function renderTimelineEditingHookWithLifecycle(input: {
176
176
  projectIdRef: { current: "p1" },
177
177
  reloadPreview: vi.fn(),
178
178
  clearDomSelection: vi.fn(),
179
- commitPositionPatchToHtml: input.commitPositionPatchToHtml,
179
+ commitDomEditPatchBatches: input.commitDomEditPatchBatches,
180
180
  });
181
181
  const commitRef = useRef(lifecycle.handleDomZIndexReorderCommit);
182
182
  commitRef.current = lifecycle.handleDomZIndexReorderCommit;
@@ -481,11 +481,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
481
481
  ]);
482
482
  const front = timelineElement({ id: "front", track: 0, zIndex: 0 });
483
483
  const back = timelineElement({ id: "back", track: 1, zIndex: 0 });
484
- const commitPositionPatchToHtml = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
484
+ const commitDomEditPatchBatches = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
485
485
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
486
486
  timelineElements: [front, back],
487
487
  iframe,
488
- commitPositionPatchToHtml,
488
+ commitDomEditPatchBatches,
489
489
  });
490
490
 
491
491
  await act(async () => {
@@ -501,8 +501,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
501
501
  await flushAsyncWork();
502
502
  });
503
503
 
504
- expect(commitPositionPatchToHtml).toHaveBeenCalled();
505
- expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
504
+ expect(commitDomEditPatchBatches).toHaveBeenCalled();
505
+ const batch = commitDomEditPatchBatches.mock.calls[0]![0] as Array<{
506
+ patches: Array<{ operations: unknown[] }>;
507
+ }>;
508
+ expect(batch[0]?.patches[0]?.operations).toEqual([
506
509
  { type: "inline-style", property: "z-index", value: "2" },
507
510
  { type: "inline-style", property: "position", value: "relative" },
508
511
  ]);
@@ -522,14 +525,13 @@ describe("useTimelineEditing timeline z-index reorder", () => {
522
525
  { ...back, hasExplicitZIndex: false },
523
526
  ]);
524
527
  const saveError = new Error("save failed");
525
- const commitPositionPatchToHtml = vi
528
+ const commitDomEditPatchBatches = vi
526
529
  .fn<(...args: unknown[]) => Promise<void>>()
527
- .mockResolvedValueOnce(undefined)
528
530
  .mockRejectedValueOnce(saveError);
529
531
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
530
532
  timelineElements: [front, back],
531
533
  iframe,
532
- commitPositionPatchToHtml,
534
+ commitDomEditPatchBatches,
533
535
  });
534
536
  const doc = iframe.contentDocument;
535
537
  if (!doc) throw new Error("Expected iframe document");
@@ -576,29 +578,24 @@ describe("useTimelineEditing timeline z-index reorder", () => {
576
578
  unmount();
577
579
  });
578
580
 
579
- it("waits for every lifecycle z-index save before resolving a reorder", async () => {
581
+ it("waits for the lifecycle z-index batch before resolving a reorder", async () => {
580
582
  const iframe = createPreviewIframe([
581
583
  { id: "front", track: 0, style: "position: relative; z-index: 1" },
582
584
  { id: "back", track: 1, style: "position: relative; z-index: 0" },
583
585
  ]);
584
586
  const front = timelineElement({ id: "front", track: 0, zIndex: 1 });
585
587
  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;
588
+ let releaseBatch!: () => void;
589
+ const batchSave = new Promise<void>((resolve) => {
590
+ releaseBatch = resolve;
590
591
  });
591
- const secondSave = new Promise<void>((resolve) => {
592
- releaseSecond = resolve;
593
- });
594
- const commitPositionPatchToHtml = vi
592
+ const commitDomEditPatchBatches = vi
595
593
  .fn<(...args: unknown[]) => Promise<void>>()
596
- .mockReturnValueOnce(firstSave)
597
- .mockReturnValueOnce(secondSave);
594
+ .mockReturnValueOnce(batchSave);
598
595
  const { move, unmount } = renderTimelineEditingHookWithLifecycle({
599
596
  timelineElements: [front, back],
600
597
  iframe,
601
- commitPositionPatchToHtml,
598
+ commitDomEditPatchBatches,
602
599
  });
603
600
  let settled = false;
604
601
 
@@ -621,17 +618,11 @@ describe("useTimelineEditing timeline z-index reorder", () => {
621
618
  await flushAsyncWork();
622
619
  });
623
620
 
624
- expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(2);
625
- expect(settled).toBe(false);
626
-
627
- await act(async () => {
628
- releaseFirst();
629
- await flushAsyncWork();
630
- });
621
+ expect(commitDomEditPatchBatches).toHaveBeenCalledTimes(1);
631
622
  expect(settled).toBe(false);
632
623
 
633
624
  await act(async () => {
634
- releaseSecond();
625
+ releaseBatch();
635
626
  await movePromise;
636
627
  await flushAsyncWork();
637
628
  });
@@ -22,6 +22,7 @@ export type TimelineZIndexReorderCommit = (
22
22
  sourceFile: string;
23
23
  key?: string;
24
24
  }>,
25
+ coalesceKey?: string,
25
26
  ) => Promise<void>;
26
27
 
27
28
  export interface UseTimelineEditingOptions {
@@ -43,3 +44,8 @@ export interface UseTimelineEditingOptions {
43
44
  forceReloadSdkSession?: () => void;
44
45
  handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
45
46
  }
47
+
48
+ export type TimelineFileDropHandler = (
49
+ files: File[],
50
+ placement?: { start: number; track: number },
51
+ ) => Promise<void>;
@@ -21,6 +21,7 @@ import {
21
21
  export interface TimelineGroupMoveChange {
22
22
  element: TimelineElement;
23
23
  start: number;
24
+ track?: number;
24
25
  }
25
26
 
26
27
  export interface TimelineGroupResizeChange {
@@ -152,9 +153,13 @@ export function useTimelineGroupEditing({
152
153
  (changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
153
154
  if (changes.length === 0) return Promise.resolve();
154
155
  for (const change of changes) {
155
- patchIframeDomTiming(previewIframeRef.current, change.element, [
156
+ const attrs: Array<[string, string]> = [
156
157
  ["data-start", formatTimelineAttributeNumber(change.start)],
157
- ]);
158
+ ];
159
+ if (change.track != null) {
160
+ attrs.push(["data-track-index", formatTimelineAttributeNumber(change.track)]);
161
+ }
162
+ patchIframeDomTiming(previewIframeRef.current, change.element, attrs);
158
163
  }
159
164
 
160
165
  const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
@@ -169,7 +174,10 @@ export function useTimelineGroupEditing({
169
174
  : null,
170
175
  );
171
176
  const canUseSdk =
172
- !needsExtension && sharedPath !== null && sdkChanges.every((change) => change !== null);
177
+ !needsExtension &&
178
+ sharedPath !== null &&
179
+ changes.every((change) => change.track == null) &&
180
+ sdkChanges.every((change) => change !== null);
173
181
  if (canUseSdk) {
174
182
  const handled = await sdkTimingBatchPersist(
175
183
  sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
@@ -194,7 +202,13 @@ export function useTimelineGroupEditing({
194
202
  changes.map((change) => ({
195
203
  element: change.element,
196
204
  buildPatches: (original, target) =>
197
- buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration),
205
+ buildTimelineMoveTimingPatch(
206
+ original,
207
+ target,
208
+ change.start,
209
+ change.element.duration,
210
+ change.track,
211
+ ),
198
212
  })),
199
213
  coalesceKey,
200
214
  );
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // NLE Layout
2
- export { NLELayout } from "./components/nle/NLELayout";
2
+ export { EditorShell } from "./components/EditorShell";
3
3
  export { NLEPreview } from "./components/nle/NLEPreview";
4
4
  export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
5
5
  export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";