@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
@@ -6,21 +6,25 @@ import {
6
6
  buildTimelineMoveTimingPatch,
7
7
  buildTimelineResizeTimingPatch,
8
8
  extendRootDurationIfNeeded,
9
- finishTimelineTimingFallback,
10
- foldGsapMutationIntoHistory,
11
9
  formatTimelineAttributeNumber,
12
10
  patchIframeDomTiming,
13
11
  persistTimelineBatchEdit,
14
- readFileContent,
15
- scaleGsapPositions,
16
- shiftGsapPositions,
17
12
  type PersistTimelineBatchChange,
18
13
  type RecordEditInput,
19
14
  } from "./timelineEditingHelpers";
15
+ import {
16
+ captureDurationRollback,
17
+ finishGroupTimingGsapFallback,
18
+ readFileContent,
19
+ scaleGsapPositions,
20
+ shiftGsapPositions,
21
+ syncPreviewContentDuration,
22
+ } from "./timelineTimingSync";
20
23
 
21
24
  export interface TimelineGroupMoveChange {
22
25
  element: TimelineElement;
23
26
  start: number;
27
+ track?: number;
24
28
  }
25
29
 
26
30
  export interface TimelineGroupResizeChange {
@@ -74,6 +78,15 @@ function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): strin
74
78
  return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
75
79
  }
76
80
 
81
+ function toSdkTimingChanges<T extends { element: TimelineElement }>(
82
+ changes: readonly T[],
83
+ timingUpdate: (change: T) => { start: number; duration?: number },
84
+ ): Array<{ hfId: string; timingUpdate: { start: number; duration?: number } } | null> {
85
+ return changes.map((change) =>
86
+ change.element.hfId ? { hfId: change.element.hfId, timingUpdate: timingUpdate(change) } : null,
87
+ );
88
+ }
89
+
77
90
  function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
78
91
  return (
79
92
  change.playbackStart != null ||
@@ -148,45 +161,90 @@ export function useTimelineGroupEditing({
148
161
  ],
149
162
  );
150
163
 
164
+ // Shared SDK fast path for group move/resize: eligible when nothing needs the
165
+ // server (no root-duration growth, one shared file, every change SDK-addressable
166
+ // and `eligible` per the caller's own gate). Returns whether the SDK handled it;
167
+ // false → caller falls through to the server batch persist.
168
+ const trySdkBatchPersist = useCallback(
169
+ async (input: {
170
+ changes: readonly { element: TimelineElement }[];
171
+ sdkChanges: Array<{
172
+ hfId: string;
173
+ timingUpdate: { start: number; duration?: number };
174
+ } | null>;
175
+ eligible: boolean;
176
+ needsExtension: boolean;
177
+ label: string;
178
+ coalesceKey: string;
179
+ }): Promise<boolean> => {
180
+ const sharedPath = allChangesSharePath(input.changes, activeCompPath);
181
+ const canUseSdk =
182
+ !input.needsExtension &&
183
+ sharedPath !== null &&
184
+ input.eligible &&
185
+ input.sdkChanges.every((change) => change !== null);
186
+ if (!canUseSdk) return false;
187
+ return sdkTimingBatchPersist(
188
+ input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
189
+ sharedPath,
190
+ sdkSession,
191
+ {
192
+ editHistory: { recordEdit },
193
+ writeProjectFile,
194
+ reloadPreview,
195
+ domEditSaveTimestampRef,
196
+ compositionPath: activeCompPath,
197
+ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
198
+ },
199
+ { label: input.label, coalesceKey: input.coalesceKey },
200
+ );
201
+ },
202
+ [
203
+ activeCompPath,
204
+ domEditSaveTimestampRef,
205
+ projectIdRef,
206
+ recordEdit,
207
+ reloadPreview,
208
+ sdkSession,
209
+ writeProjectFile,
210
+ ],
211
+ );
212
+
151
213
  const handleTimelineGroupMove = useCallback(
152
214
  (changes: TimelineGroupMoveChange[], options?: TimelineGroupCommitOptions) => {
153
215
  if (changes.length === 0) return Promise.resolve();
154
216
  for (const change of changes) {
155
- patchIframeDomTiming(previewIframeRef.current, change.element, [
217
+ const attrs: Array<[string, string]> = [
156
218
  ["data-start", formatTimelineAttributeNumber(change.start)],
157
- ]);
219
+ ];
220
+ if (change.track != null) {
221
+ attrs.push(["data-track-index", formatTimelineAttributeNumber(change.track)]);
222
+ }
223
+ patchIframeDomTiming(previewIframeRef.current, change.element, attrs);
158
224
  }
159
225
 
160
226
  const maxEnd = Math.max(...changes.map((change) => change.start + change.element.duration));
227
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
228
+ // persist can roll the readout + live root back (see captureDurationRollback).
229
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
230
+ // needsExtension gates the SDK path (setTiming can't grow the root duration),
231
+ // so read the store BEFORE the readout sync below optimistically updates it.
161
232
  const needsExtension = extendRootDurationIfNeeded(maxEnd);
233
+ // Optimistic duration readout: content-driven (grow AND shrink), read from
234
+ // the just-patched live DOM. See syncPreviewContentDuration.
235
+ syncPreviewContentDuration(previewIframeRef.current);
162
236
  const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
163
237
  return enqueueGroupOperation("Move timeline clips", async (projectId) => {
164
238
  await options?.beforeTiming;
165
- const sharedPath = allChangesSharePath(changes, activeCompPath);
166
- const sdkChanges = changes.map((change) =>
167
- change.element.hfId
168
- ? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
169
- : null,
170
- );
171
- const canUseSdk =
172
- !needsExtension && sharedPath !== null && sdkChanges.every((change) => change !== null);
173
- if (canUseSdk) {
174
- const handled = await sdkTimingBatchPersist(
175
- sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
176
- sharedPath,
177
- sdkSession,
178
- {
179
- editHistory: { recordEdit },
180
- writeProjectFile,
181
- reloadPreview,
182
- domEditSaveTimestampRef,
183
- compositionPath: activeCompPath,
184
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
185
- },
186
- { label: "Move timeline clips", coalesceKey },
187
- );
188
- if (handled) return;
189
- }
239
+ const handledBySdk = await trySdkBatchPersist({
240
+ changes,
241
+ sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: change.start })),
242
+ eligible: changes.every((change) => change.track == null),
243
+ needsExtension,
244
+ label: "Move timeline clips",
245
+ coalesceKey,
246
+ });
247
+ if (handledBySdk) return;
190
248
 
191
249
  await persistServerBatch(
192
250
  projectId,
@@ -194,54 +252,49 @@ export function useTimelineGroupEditing({
194
252
  changes.map((change) => ({
195
253
  element: change.element,
196
254
  buildPatches: (original, target) =>
197
- buildTimelineMoveTimingPatch(original, target, change.start, change.element.duration),
255
+ buildTimelineMoveTimingPatch(
256
+ original,
257
+ target,
258
+ change.start,
259
+ change.element.duration,
260
+ change.track,
261
+ ),
198
262
  })),
199
263
  coalesceKey,
200
264
  );
201
- await finishTimelineTimingFallback({
265
+ await finishGroupTimingGsapFallback({
266
+ projectId,
202
267
  iframe: previewIframeRef.current,
203
- needsExtension,
204
- rootDurationSeconds: maxEnd,
205
268
  reloadPreview,
206
- gsapMutation: () =>
207
- foldGsapMutationIntoHistory({
208
- projectId,
209
- paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
210
- label: "Move timeline clips",
211
- coalesceKey,
212
- recordEdit,
213
- gsapMutation: async () => {
214
- let mutated = false;
215
- for (const change of changes) {
216
- const delta = change.start - change.element.start;
217
- const domId = change.element.domId;
218
- if (delta === 0 || !domId) continue;
219
- const status = await shiftGsapPositions(
220
- projectId,
221
- targetPathFor(change.element, activeCompPath),
222
- domId,
223
- delta,
224
- );
225
- mutated = mutated || status.mutated;
226
- }
227
- return { mutated };
228
- },
229
- }),
230
- onGsapError: (err) => console.error("[Timeline] Failed to shift GSAP positions", err),
269
+ label: "Move timeline clips",
270
+ errorLabel: "Failed to shift GSAP positions",
271
+ coalesceKey,
272
+ recordEdit,
273
+ activeCompPath,
274
+ changes,
275
+ resolveChangePath: (element) => targetPathFor(element, activeCompPath),
276
+ mutateChange: (change, changePath) => {
277
+ const delta = change.start - change.element.start;
278
+ const domId = change.element.domId;
279
+ if (delta === 0 || !domId) return null;
280
+ return shiftGsapPositions(projectId, changePath, domId, delta);
281
+ },
231
282
  });
283
+ }).catch((error) => {
284
+ // Failed persist: revert the optimistic duration readout + live root
285
+ // alongside the gesture owner's store rollback.
286
+ rollbackDuration();
287
+ throw error;
232
288
  });
233
289
  },
234
290
  [
235
291
  activeCompPath,
236
- domEditSaveTimestampRef,
237
292
  enqueueGroupOperation,
238
293
  persistServerBatch,
239
294
  previewIframeRef,
240
- projectIdRef,
241
295
  recordEdit,
242
296
  reloadPreview,
243
- sdkSession,
244
- writeProjectFile,
297
+ trySdkBatchPersist,
245
298
  ],
246
299
  );
247
300
 
@@ -264,41 +317,30 @@ export function useTimelineGroupEditing({
264
317
  }
265
318
 
266
319
  const maxEnd = Math.max(...changes.map((change) => change.start + change.duration));
320
+ // Snapshot the duration BEFORE the optimistic updates below so a failed
321
+ // persist can roll the readout + live root back (see captureDurationRollback).
322
+ const rollbackDuration = captureDurationRollback(previewIframeRef.current);
323
+ // needsExtension gates the SDK path (setTiming can't grow the root duration),
324
+ // so read the store BEFORE the readout sync below optimistically updates it.
267
325
  const needsExtension = extendRootDurationIfNeeded(maxEnd);
326
+ // Optimistic duration readout: content-driven (grow AND shrink), read from
327
+ // the just-patched live DOM. See syncPreviewContentDuration.
328
+ syncPreviewContentDuration(previewIframeRef.current);
268
329
  const coalesceKey = options?.coalesceKey ?? resizeCoalesceKey(changes);
269
330
  return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
270
331
  await options?.beforeTiming;
271
- const sharedPath = allChangesSharePath(changes, activeCompPath);
272
- const sdkChanges = changes.map((change) =>
273
- change.element.hfId
274
- ? {
275
- hfId: change.element.hfId,
276
- timingUpdate: { start: change.start, duration: change.duration },
277
- }
278
- : null,
279
- );
280
- const canUseSdk =
281
- !needsExtension &&
282
- sharedPath !== null &&
283
- changes.every((change) => !resizeHasPlaybackStartAdjustment(change)) &&
284
- sdkChanges.every((change) => change !== null);
285
- if (canUseSdk) {
286
- const handled = await sdkTimingBatchPersist(
287
- sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
288
- sharedPath,
289
- sdkSession,
290
- {
291
- editHistory: { recordEdit },
292
- writeProjectFile,
293
- reloadPreview,
294
- domEditSaveTimestampRef,
295
- compositionPath: activeCompPath,
296
- readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
297
- },
298
- { label: "Resize timeline clips", coalesceKey },
299
- );
300
- if (handled) return;
301
- }
332
+ const handledBySdk = await trySdkBatchPersist({
333
+ changes,
334
+ sdkChanges: toSdkTimingChanges(changes, (change) => ({
335
+ start: change.start,
336
+ duration: change.duration,
337
+ })),
338
+ eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
339
+ needsExtension,
340
+ label: "Resize timeline clips",
341
+ coalesceKey,
342
+ });
343
+ if (handledBySdk) return;
302
344
 
303
345
  await persistServerBatch(
304
346
  projectId,
@@ -314,55 +356,48 @@ export function useTimelineGroupEditing({
314
356
  })),
315
357
  coalesceKey,
316
358
  );
317
- await finishTimelineTimingFallback({
359
+ await finishGroupTimingGsapFallback({
360
+ projectId,
318
361
  iframe: previewIframeRef.current,
319
- needsExtension,
320
- rootDurationSeconds: maxEnd,
321
362
  reloadPreview,
322
- gsapMutation: () =>
323
- foldGsapMutationIntoHistory({
363
+ label: "Resize timeline clips",
364
+ errorLabel: "Failed to scale GSAP positions",
365
+ coalesceKey,
366
+ recordEdit,
367
+ activeCompPath,
368
+ changes,
369
+ resolveChangePath: (element) => targetPathFor(element, activeCompPath),
370
+ mutateChange: (change, changePath) => {
371
+ const domId = change.element.domId;
372
+ const timingChanged =
373
+ change.start !== change.element.start || change.duration !== change.element.duration;
374
+ if (!timingChanged || !domId) return null;
375
+ return scaleGsapPositions(
324
376
  projectId,
325
- paths: changes.map((change) => targetPathFor(change.element, activeCompPath)),
326
- label: "Resize timeline clips",
327
- coalesceKey,
328
- recordEdit,
329
- gsapMutation: async () => {
330
- let mutated = false;
331
- for (const change of changes) {
332
- const domId = change.element.domId;
333
- const timingChanged =
334
- change.start !== change.element.start ||
335
- change.duration !== change.element.duration;
336
- if (!timingChanged || !domId) continue;
337
- const status = await scaleGsapPositions(
338
- projectId,
339
- targetPathFor(change.element, activeCompPath),
340
- domId,
341
- change.element.start,
342
- change.element.duration,
343
- change.start,
344
- change.duration,
345
- );
346
- mutated = mutated || status.mutated;
347
- }
348
- return { mutated };
349
- },
350
- }),
351
- onGsapError: (err) => console.error("[Timeline] Failed to scale GSAP positions", err),
377
+ changePath,
378
+ domId,
379
+ change.element.start,
380
+ change.element.duration,
381
+ change.start,
382
+ change.duration,
383
+ );
384
+ },
352
385
  });
386
+ }).catch((error) => {
387
+ // Failed persist: revert the optimistic duration readout + live root
388
+ // alongside the gesture owner's store rollback.
389
+ rollbackDuration();
390
+ throw error;
353
391
  });
354
392
  },
355
393
  [
356
394
  activeCompPath,
357
- domEditSaveTimestampRef,
358
395
  enqueueGroupOperation,
359
396
  persistServerBatch,
360
397
  previewIframeRef,
361
- projectIdRef,
362
398
  recordEdit,
363
399
  reloadPreview,
364
- sdkSession,
365
- writeProjectFile,
400
+ trySdkBatchPersist,
366
401
  ],
367
402
  );
368
403
 
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";
@@ -0,0 +1,173 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { ImageThumbnail } from "./ImageThumbnail";
7
+
8
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
9
+ configurable: true,
10
+ value: true,
11
+ });
12
+
13
+ // --- Observer stubs: fire "intersecting" immediately on observe ---
14
+ class MockIntersectionObserver {
15
+ private cb: IntersectionObserverCallback;
16
+ constructor(cb: IntersectionObserverCallback) {
17
+ this.cb = cb;
18
+ }
19
+ observe() {
20
+ this.cb(
21
+ [{ isIntersecting: true } as IntersectionObserverEntry],
22
+ this as unknown as IntersectionObserver,
23
+ );
24
+ }
25
+ disconnect() {}
26
+ unobserve() {}
27
+ takeRecords(): IntersectionObserverEntry[] {
28
+ return [];
29
+ }
30
+ }
31
+
32
+ class MockResizeObserver {
33
+ observe() {}
34
+ disconnect() {}
35
+ unobserve() {}
36
+ }
37
+
38
+ // --- Image stub: captures instances so tests fire load/error deterministically ---
39
+ class MockImage {
40
+ static instances: MockImage[] = [];
41
+ onload: (() => void) | null = null;
42
+ onerror: (() => void) | null = null;
43
+ naturalWidth = 0;
44
+ naturalHeight = 0;
45
+ src = "";
46
+ constructor() {
47
+ MockImage.instances.push(this);
48
+ }
49
+ }
50
+
51
+ const originalIO = globalThis.IntersectionObserver;
52
+ const originalRO = globalThis.ResizeObserver;
53
+ const originalImage = globalThis.Image;
54
+
55
+ let host: HTMLDivElement;
56
+ let root: Root | null = null;
57
+
58
+ beforeEach(() => {
59
+ globalThis.IntersectionObserver =
60
+ MockIntersectionObserver as unknown as typeof IntersectionObserver;
61
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
62
+ globalThis.Image = MockImage as unknown as typeof Image;
63
+ MockImage.instances = [];
64
+ host = document.createElement("div");
65
+ document.body.append(host);
66
+ });
67
+
68
+ afterEach(() => {
69
+ act(() => root?.unmount());
70
+ root = null;
71
+ globalThis.IntersectionObserver = originalIO;
72
+ globalThis.ResizeObserver = originalRO;
73
+ globalThis.Image = originalImage;
74
+ document.body.innerHTML = "";
75
+ });
76
+
77
+ function render(props: { imageSrc: string; label?: string; labelColor?: string }) {
78
+ root = createRoot(host);
79
+ act(() => {
80
+ root!.render(
81
+ <ImageThumbnail
82
+ imageSrc={props.imageSrc}
83
+ label={props.label ?? ""}
84
+ labelColor={props.labelColor ?? "#fff"}
85
+ />,
86
+ );
87
+ });
88
+ }
89
+
90
+ function lastProbe(): MockImage {
91
+ const probe = MockImage.instances.at(-1);
92
+ expect(probe).toBeDefined();
93
+ return probe!;
94
+ }
95
+
96
+ /** Assert at least one tile rendered and the first tile serves `expectedSrc`. */
97
+ function expectFirstTileSrc(expectedSrc: string): void {
98
+ const imgs = [...host.querySelectorAll("img")];
99
+ expect(imgs.length).toBeGreaterThanOrEqual(1);
100
+ expect(imgs[0].getAttribute("src")).toBe(expectedSrc);
101
+ }
102
+
103
+ describe("ImageThumbnail", () => {
104
+ it("shows the loading shimmer before the image resolves", () => {
105
+ render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
106
+ expect(host.querySelector(".animate-pulse")).not.toBeNull();
107
+ expect(host.querySelectorAll("img").length).toBe(0);
108
+ });
109
+
110
+ it("probes the resolved src and renders repeated object-cover tiles on load", () => {
111
+ render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
112
+ const probe = lastProbe();
113
+ expect(probe.src).toBe("/api/projects/p/preview/assets/pic.png");
114
+
115
+ act(() => {
116
+ probe.naturalWidth = 1920;
117
+ probe.naturalHeight = 1080;
118
+ probe.onload?.();
119
+ });
120
+
121
+ const imgs = [...host.querySelectorAll("img")];
122
+ expect(imgs.length).toBeGreaterThanOrEqual(1);
123
+ for (const img of imgs) {
124
+ expect(img.getAttribute("src")).toBe("/api/projects/p/preview/assets/pic.png");
125
+ expect(img.className).toContain("object-cover");
126
+ }
127
+ expect(host.querySelector(".animate-pulse")).toBeNull();
128
+ });
129
+
130
+ it("drops the shimmer and renders no tiles when a raster image fails to load", () => {
131
+ render({ imageSrc: "/api/projects/p/preview/assets/missing.png" });
132
+ act(() => lastProbe().onerror?.());
133
+ expect(host.querySelectorAll("img").length).toBe(0);
134
+ expect(host.querySelector(".animate-pulse")).toBeNull();
135
+ });
136
+
137
+ it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", () => {
138
+ render({ imageSrc: "/api/projects/p/preview/assets/logo.svg" });
139
+ const probe = lastProbe();
140
+
141
+ act(() => {
142
+ // naturalWidth stays 0 — SVG with no width/height attribute
143
+ probe.onload?.();
144
+ });
145
+
146
+ expectFirstTileSrc("/api/projects/p/preview/assets/logo.svg");
147
+ expect(host.querySelector(".animate-pulse")).toBeNull();
148
+ });
149
+
150
+ it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", () => {
151
+ // Some browser/sandbox environments fire onerror for SVGs even though the
152
+ // <img> element itself can render the file — we must not blank the strip.
153
+ render({ imageSrc: "/api/projects/p/preview/assets/icon.svg" });
154
+
155
+ act(() => lastProbe().onerror?.());
156
+
157
+ expectFirstTileSrc("/api/projects/p/preview/assets/icon.svg");
158
+ expect(host.querySelector(".animate-pulse")).toBeNull();
159
+ });
160
+
161
+ it("renders the label above the strip when provided", () => {
162
+ render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" });
163
+ act(() => {
164
+ const probe = lastProbe();
165
+ probe.naturalWidth = 100;
166
+ probe.naturalHeight = 100;
167
+ probe.onload?.();
168
+ });
169
+ const label = [...host.querySelectorAll("span")].find((s) => s.textContent === "hero");
170
+ expect(label).toBeDefined();
171
+ expect(label!.closest(".z-10")).not.toBeNull();
172
+ });
173
+ });