@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
@@ -3,6 +3,7 @@ import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerS
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
5
5
  import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
6
+ import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
6
7
 
7
8
  export type { ClipManifestClip } from "../lib/playbackTypes";
8
9
  export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
@@ -15,7 +16,6 @@ export {
15
16
  parseTimelineFromDOM,
16
17
  readTimelineDurationFromDocument,
17
18
  resolveStandaloneRootCompositionSrc,
18
- resolveIframe,
19
19
  } from "../lib/timelineDOM";
20
20
  export {
21
21
  shouldIgnorePlaybackShortcutEvent,
@@ -36,6 +36,7 @@ import {
36
36
  mergeTimelineElementsPreservingDowngrades,
37
37
  parseTimelineFromDOM,
38
38
  } from "../lib/timelineDOM";
39
+ import { normalizeToZones } from "../components/timelineZones";
39
40
  import {
40
41
  setPreviewMediaMuted,
41
42
  setPreviewPlaybackRate,
@@ -90,12 +91,17 @@ export function useTimelinePlayer() {
90
91
  // applyCachedSourceDurations re-applies the cached probe duration: re-derived
91
92
  // elements (e.g. after a clip move) can arrive without sourceDuration, which
92
93
  // otherwise makes trimmed waveforms lose their window.
93
- const mergedElements = applyCachedSourceDurations(
94
- mergeTimelineElementsPreservingDowngrades(
95
- state.elements,
96
- elements,
97
- state.duration,
98
- resolvedDuration,
94
+ // Enforced CapCut zoning (overlay → main → audio): normalize track indices
95
+ // on every discovery. Idempotent — already-zoned input is returned as-is, so
96
+ // drops persist zoned indices and reloads re-zone to the same (no drift).
97
+ const mergedElements = normalizeToZones(
98
+ applyCachedSourceDurations(
99
+ mergeTimelineElementsPreservingDowngrades(
100
+ state.elements,
101
+ elements,
102
+ state.duration,
103
+ resolvedDuration,
104
+ ),
99
105
  ),
100
106
  );
101
107
 
@@ -207,49 +213,14 @@ export function useTimelinePlayer() {
207
213
  }
208
214
  }, []);
209
215
 
210
- const stopReverseLoop = useCallback(() => {
211
- cancelAnimationFrame(reverseRafRef.current);
212
- }, []);
213
-
214
- const startRAFLoop = useCallback(() => {
215
- // fallow-ignore-next-line complexity
216
- const tick = () => {
217
- const adapter = getAdapter();
218
- if (adapter) {
219
- const rawTime = adapter.getTime();
220
- const dur = adapter.getDuration();
221
- const time = dur > 0 ? Math.min(rawTime, dur) : rawTime;
222
- liveTime.notify(time); // direct DOM updates, no React re-render
223
- const { inPoint, outPoint } = usePlayerStore.getState();
224
- const rawLoopEnd = outPoint !== null ? Math.min(outPoint, dur) : dur;
225
- const rawLoopStart = inPoint !== null ? inPoint : 0;
226
- const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
227
- const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
228
- if (time >= loopEnd) {
229
- if (usePlayerStore.getState().loopEnabled && dur > 0) {
230
- // keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
231
- adapter.seek(loopStart, { keepPlaying: true });
232
- liveTime.notify(loopStart);
233
- adapter.play();
234
- setIsPlaying(true);
235
- rafRef.current = requestAnimationFrame(tick);
236
- return;
237
- }
238
- if (adapter.isPlaying()) adapter.pause();
239
- setCurrentTime(time); // sync Zustand once at end
240
- setIsPlaying(false);
241
- cancelAnimationFrame(rafRef.current);
242
- return;
243
- }
244
- }
245
- rafRef.current = requestAnimationFrame(tick);
246
- };
247
- rafRef.current = requestAnimationFrame(tick);
248
- }, [getAdapter, setCurrentTime, setIsPlaying]);
216
+ const { startRAFLoop, stopRAFLoop, stopReverseLoop } = useTimelinePlayerLoop({
217
+ rafRef,
218
+ reverseRafRef,
219
+ getAdapter,
220
+ setCurrentTime,
221
+ setIsPlaying,
222
+ });
249
223
 
250
- const stopRAFLoop = useCallback(() => {
251
- cancelAnimationFrame(rafRef.current);
252
- }, []);
253
224
  const applyPlaybackRate = useCallback((rate: number) => {
254
225
  const iframe = iframeRef.current;
255
226
  if (!iframe) return;
@@ -463,10 +434,24 @@ export function useTimelinePlayer() {
463
434
  applyPreviewAudioState,
464
435
  });
465
436
  const saveSeekPosition = useCallback(() => {
466
- const adapter = getAdapter();
467
- pendingSeekRef.current = adapter
468
- ? adapter.getTime()
469
- : (usePlayerStore.getState().currentTime ?? 0);
437
+ // Never DEGRADE the saved position. Overlapping reloads (e.g. an external
438
+ // file drop = upload reload + insert reload back-to-back) call this while
439
+ // the iframe from the FIRST reload is mid-teardown: getAdapter() can still
440
+ // return that dying document's adapter, whose getTime() reads 0 — and the
441
+ // store's currentTime can lag the visual playhead. Overwriting the
442
+ // still-unconsumed pendingSeek with either value is exactly how the
443
+ // playhead used to end up at 0 after a Finder drop (verified live via a
444
+ // currentTime write-trace). So: while a refresh is already in flight and a
445
+ // save exists, keep it; otherwise trust the live adapter, then the store.
446
+ const refreshInFlight = isRefreshingRef.current && pendingSeekRef.current != null;
447
+ if (!refreshInFlight) {
448
+ const adapter = getAdapter();
449
+ if (adapter) {
450
+ pendingSeekRef.current = adapter.getTime();
451
+ } else if (pendingSeekRef.current == null) {
452
+ pendingSeekRef.current = usePlayerStore.getState().currentTime ?? 0;
453
+ }
454
+ }
470
455
  isRefreshingRef.current = true;
471
456
  stopRAFLoop();
472
457
  stopReverseLoop();
@@ -476,6 +461,16 @@ export function useTimelinePlayer() {
476
461
  const iframe = iframeRef.current;
477
462
  if (!iframe) return;
478
463
  saveSeekPosition();
464
+ // Hide the iframe across the full reload so the user never sees the reloading
465
+ // document's RAW DOM (every clip stacked and visible) in the window between the
466
+ // new document parsing and the runtime initializing + seeking. initializeAdapter
467
+ // reveals it again right after its restore seek renders the correct frame.
468
+ // Tradeoff: this shows the parent stage background (a brief "freeze"/blank, on
469
+ // the order of the reload time ~100-300ms) INSTEAD of the all-clips flash. A
470
+ // blank is far less jarring than a burst of every asset appearing at once.
471
+ // Only the FULL-reload edits (drops/inserts) hit this — timing edits now take
472
+ // the soft-reload path and never touch refreshPlayer.
473
+ iframe.style.visibility = "hidden";
479
474
  const src = iframe.src;
480
475
  const url = new URL(src, window.location.origin);
481
476
  url.searchParams.set("_t", String(Date.now()));
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The forward playback loop for the timeline player.
3
+ *
4
+ * Owns the three requestAnimationFrame lifecycle callbacks that drive (and stop)
5
+ * playback:
6
+ * - startRAFLoop — the forward tick: advance liveTime, honour in/out loop
7
+ * points + loopEnabled, and pause + sync the store at the end.
8
+ * - stopRAFLoop — cancel the forward tick.
9
+ * - stopReverseLoop — cancel the reverse-shuttle tick (owned by the parent hook).
10
+ *
11
+ * Called unconditionally at the top level of useTimelinePlayer so its useCallback
12
+ * hooks run in a stable order; every dependency is passed in as an argument.
13
+ */
14
+
15
+ import { useCallback } from "react";
16
+ import { liveTime, usePlayerStore } from "../store/playerStore";
17
+ import type { PlaybackAdapter } from "../lib/playbackTypes";
18
+
19
+ interface UseTimelinePlayerLoopParams {
20
+ rafRef: React.MutableRefObject<number>;
21
+ reverseRafRef: React.MutableRefObject<number>;
22
+ getAdapter: () => PlaybackAdapter | null;
23
+ setCurrentTime: (v: number) => void;
24
+ setIsPlaying: (v: boolean) => void;
25
+ }
26
+
27
+ interface UseTimelinePlayerLoopResult {
28
+ startRAFLoop: () => void;
29
+ stopRAFLoop: () => void;
30
+ stopReverseLoop: () => void;
31
+ }
32
+
33
+ export function useTimelinePlayerLoop({
34
+ rafRef,
35
+ reverseRafRef,
36
+ getAdapter,
37
+ setCurrentTime,
38
+ setIsPlaying,
39
+ }: UseTimelinePlayerLoopParams): UseTimelinePlayerLoopResult {
40
+ const stopReverseLoop = useCallback(() => {
41
+ cancelAnimationFrame(reverseRafRef.current);
42
+ }, [reverseRafRef]);
43
+
44
+ const startRAFLoop = useCallback(() => {
45
+ // fallow-ignore-next-line complexity
46
+ const tick = () => {
47
+ const adapter = getAdapter();
48
+ if (adapter) {
49
+ const rawTime = adapter.getTime();
50
+ const dur = adapter.getDuration();
51
+ const time = dur > 0 ? Math.min(rawTime, dur) : rawTime;
52
+ liveTime.notify(time); // direct DOM updates, no React re-render
53
+ const { inPoint, outPoint } = usePlayerStore.getState();
54
+ const rawLoopEnd = outPoint !== null ? Math.min(outPoint, dur) : dur;
55
+ const rawLoopStart = inPoint !== null ? inPoint : 0;
56
+ const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
57
+ const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
58
+ if (time >= loopEnd) {
59
+ if (usePlayerStore.getState().loopEnabled && dur > 0) {
60
+ // keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
61
+ adapter.seek(loopStart, { keepPlaying: true });
62
+ liveTime.notify(loopStart);
63
+ adapter.play();
64
+ setIsPlaying(true);
65
+ rafRef.current = requestAnimationFrame(tick);
66
+ return;
67
+ }
68
+ if (adapter.isPlaying()) adapter.pause();
69
+ setCurrentTime(time); // sync Zustand once at end
70
+ setIsPlaying(false);
71
+ cancelAnimationFrame(rafRef.current);
72
+ return;
73
+ }
74
+ }
75
+ rafRef.current = requestAnimationFrame(tick);
76
+ };
77
+ rafRef.current = requestAnimationFrame(tick);
78
+ }, [rafRef, getAdapter, setCurrentTime, setIsPlaying]);
79
+
80
+ const stopRAFLoop = useCallback(() => {
81
+ cancelAnimationFrame(rafRef.current);
82
+ }, [rafRef]);
83
+
84
+ return { startRAFLoop, stopRAFLoop, stopReverseLoop };
85
+ }
@@ -0,0 +1,219 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ resolveReloadSeekTime,
5
+ resolveTimelineTotalDuration,
6
+ revealIframe,
7
+ } from "./useTimelineSyncCallbacks";
8
+ import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
9
+
10
+ // Minimal stand-in — revealIframe only touches `style.visibility`. Avoids
11
+ // depending on a DOM environment (this test file runs under node).
12
+ function fakeIframe(visibility: string): HTMLIFrameElement {
13
+ return { style: { visibility } } as unknown as HTMLIFrameElement;
14
+ }
15
+
16
+ describe("revealIframe", () => {
17
+ it("clears a hidden iframe's visibility (undoes refreshPlayer's hide)", () => {
18
+ const iframe = fakeIframe("hidden");
19
+ revealIframe(iframe);
20
+ expect(iframe.style.visibility).toBe("");
21
+ });
22
+
23
+ it("leaves an already-visible iframe untouched (idempotent)", () => {
24
+ const iframe = fakeIframe("");
25
+ revealIframe(iframe);
26
+ expect(iframe.style.visibility).toBe("");
27
+ });
28
+
29
+ it("no-ops on a null iframe", () => {
30
+ expect(() => revealIframe(null)).not.toThrow();
31
+ });
32
+ });
33
+
34
+ describe("resolveReloadSeekTime", () => {
35
+ it("restores the pending seek saved by refreshPlayer (the primary reload path)", () => {
36
+ expect(
37
+ resolveReloadSeekTime({
38
+ pendingSeek: 7.2,
39
+ requestedSeek: null,
40
+ storeCurrentTime: 7.2,
41
+ duration: 20,
42
+ }),
43
+ ).toBe(7.2);
44
+ });
45
+
46
+ it("honors a deep-link seek request when no pending seek exists", () => {
47
+ expect(
48
+ resolveReloadSeekTime({
49
+ pendingSeek: null,
50
+ requestedSeek: 12.5,
51
+ storeCurrentTime: 0,
52
+ duration: 20,
53
+ }),
54
+ ).toBe(12.5);
55
+ });
56
+
57
+ it("THE BUG: a second overlapping reload (pending seek already consumed) restores the store playhead, not 0", () => {
58
+ // Drop → reload #1 consumes pendingSeek and seeks/syncs to 7.2. A staggered
59
+ // second reload (refreshPreviewDocumentVersion 80/300ms bumps) then finds the
60
+ // slot empty — the old code reset the playhead to 0 here.
61
+ expect(
62
+ resolveReloadSeekTime({
63
+ pendingSeek: null,
64
+ requestedSeek: null,
65
+ storeCurrentTime: 7.2,
66
+ duration: 20,
67
+ }),
68
+ ).toBe(7.2);
69
+ });
70
+
71
+ it("fresh project load starts at 0 (store resets currentTime on project switch)", () => {
72
+ expect(
73
+ resolveReloadSeekTime({
74
+ pendingSeek: null,
75
+ requestedSeek: null,
76
+ storeCurrentTime: 0,
77
+ duration: 20,
78
+ }),
79
+ ).toBe(0);
80
+ });
81
+
82
+ it("clamps to duration when content shrank past the playhead (the one sanctioned move)", () => {
83
+ expect(
84
+ resolveReloadSeekTime({
85
+ pendingSeek: 18,
86
+ requestedSeek: null,
87
+ storeCurrentTime: 18,
88
+ duration: 9,
89
+ }),
90
+ ).toBe(9);
91
+ });
92
+
93
+ it("a pending seek of 0 is an explicit position, not a missing value", () => {
94
+ expect(
95
+ resolveReloadSeekTime({
96
+ pendingSeek: 0,
97
+ requestedSeek: 12,
98
+ storeCurrentTime: 5,
99
+ duration: 20,
100
+ }),
101
+ ).toBe(0);
102
+ });
103
+
104
+ it("returns the guarded target unclamped when the duration is non-finite (no seek(NaN))", () => {
105
+ // Mid-reload the adapter can report a NaN duration; Math.min(target, NaN) would
106
+ // be NaN and seek(NaN). The guarded target must pass through unclamped instead.
107
+ expect(
108
+ resolveReloadSeekTime({
109
+ pendingSeek: 7.2,
110
+ requestedSeek: null,
111
+ storeCurrentTime: 7.2,
112
+ duration: Number.NaN,
113
+ }),
114
+ ).toBe(7.2);
115
+ // A zero/negative duration is equally unusable — pass the target through.
116
+ expect(
117
+ resolveReloadSeekTime({
118
+ pendingSeek: 7.2,
119
+ requestedSeek: null,
120
+ storeCurrentTime: 7.2,
121
+ duration: 0,
122
+ }),
123
+ ).toBe(7.2);
124
+ });
125
+
126
+ it("guards against non-finite and negative targets", () => {
127
+ expect(
128
+ resolveReloadSeekTime({
129
+ pendingSeek: Number.NaN,
130
+ requestedSeek: null,
131
+ storeCurrentTime: 5,
132
+ duration: 20,
133
+ }),
134
+ ).toBe(0);
135
+ expect(
136
+ resolveReloadSeekTime({
137
+ pendingSeek: -3,
138
+ requestedSeek: null,
139
+ storeCurrentTime: 5,
140
+ duration: 20,
141
+ }),
142
+ ).toBe(0);
143
+ });
144
+ });
145
+
146
+ describe("resolveTimelineTotalDuration", () => {
147
+ it("THE BUG: a clip-manifest total shorter than the authored root duration never wins (stale '0:44/0:40')", () => {
148
+ // Fixture shape: root data-duration=44.5, furthest clip ends at 40. A runtime
149
+ // that measures the manifest from the furthest clip end reports 40s; playback
150
+ // still runs the full 44.5s, so the transport total must stay 44.5, not 40.
151
+ expect(
152
+ resolveTimelineTotalDuration({
153
+ manifestDurationSeconds: 40,
154
+ authoredRootDurationSeconds: 44.5,
155
+ }),
156
+ ).toBe(44.5);
157
+ });
158
+
159
+ it("lets clips extend the total PAST the authored root (content can grow the timeline)", () => {
160
+ expect(
161
+ resolveTimelineTotalDuration({
162
+ manifestDurationSeconds: 50,
163
+ authoredRootDurationSeconds: 44.5,
164
+ }),
165
+ ).toBe(50);
166
+ });
167
+
168
+ it("falls back to the manifest total when the root authors no duration", () => {
169
+ expect(
170
+ resolveTimelineTotalDuration({
171
+ manifestDurationSeconds: 40,
172
+ authoredRootDurationSeconds: 0,
173
+ }),
174
+ ).toBe(40);
175
+ });
176
+
177
+ it("uses the authored root when the manifest is loop-inflated / non-finite", () => {
178
+ expect(
179
+ resolveTimelineTotalDuration({
180
+ manifestDurationSeconds: Number.POSITIVE_INFINITY,
181
+ authoredRootDurationSeconds: 44.5,
182
+ }),
183
+ ).toBe(44.5);
184
+ expect(
185
+ resolveTimelineTotalDuration({
186
+ manifestDurationSeconds: 9000, // beyond the 7200s sanity cap
187
+ authoredRootDurationSeconds: 44.5,
188
+ }),
189
+ ).toBe(44.5);
190
+ });
191
+
192
+ it("returns 0 when neither source yields a usable duration", () => {
193
+ expect(
194
+ resolveTimelineTotalDuration({
195
+ manifestDurationSeconds: Number.NaN,
196
+ authoredRootDurationSeconds: -1,
197
+ }),
198
+ ).toBe(0);
199
+ });
200
+
201
+ it("floors the manifest at the authored root read from the fixture's DOM shape", () => {
202
+ // Reconstruct the fixture: root authored at 44.5s, last clip (v-letters)
203
+ // ends at 34 + 6 = 40s. readTimelineDurationFromDocument must report the
204
+ // authored 44.5, and flooring the 40s manifest total against it yields 44.5.
205
+ const doc = document.implementation.createHTMLDocument("fixture");
206
+ doc.body.innerHTML =
207
+ '<div id="main" data-composition-id="main" data-duration="44.5">' +
208
+ '<video class="clip" data-start="34" data-duration="6"></video>' +
209
+ "</div>";
210
+ const authoredRootDurationSeconds = readTimelineDurationFromDocument(doc);
211
+ expect(authoredRootDurationSeconds).toBe(44.5);
212
+ expect(
213
+ resolveTimelineTotalDuration({
214
+ manifestDurationSeconds: 1200 / 30, // durationInFrames measured from clip end
215
+ authoredRootDurationSeconds,
216
+ }),
217
+ ).toBe(44.5);
218
+ });
219
+ });
@@ -19,6 +19,7 @@ import {
19
19
  createImplicitTimelineLayersFromDOM,
20
20
  buildStandaloneRootTimelineElement,
21
21
  getTimelineElementSelector,
22
+ readTimelineDurationFromDocument,
22
23
  } from "../lib/timelineDOM";
23
24
  import {
24
25
  normalizePreviewViewport,
@@ -41,6 +42,76 @@ interface UseTimelineSyncCallbacksParams {
41
42
  applyPreviewAudioState: () => void;
42
43
  }
43
44
 
45
+ /**
46
+ * Where should the player seek when the preview (re)loads?
47
+ * Priority: explicit pending seek (saved by refreshPlayer right before a
48
+ * reload) → store-level seek request (deep-link `?t=` hydration) → the store's
49
+ * last known playhead. The last fallback makes the playhead RELOAD-INVARIANT:
50
+ * edits persist + reload the preview, sometimes more than once (App's
51
+ * refreshPreviewDocumentVersion staggers extra bumps at 80/300ms), and the
52
+ * consume-once pendingSeekRef meant any reload after the first found the slot
53
+ * empty and reset the playhead to 0 — the "dropped a file and the playhead
54
+ * jumped to 0" bug. Falling back to the store's playhead means every reload
55
+ * restores position; a fresh project load still starts at 0 because the store
56
+ * resets currentTime on project switch. Invariant: an edit NEVER moves the
57
+ * playhead (the clamp below is the one sanctioned move — content shrank past it).
58
+ */
59
+ /**
60
+ * Undo the `visibility: hidden` that refreshPlayer sets across a full reload.
61
+ * Safe to call when the iframe was never hidden (idempotent no-op). Every reload
62
+ * completion + failure path funnels through here so the preview can never get
63
+ * stuck invisible.
64
+ */
65
+ export function revealIframe(iframe: HTMLIFrameElement | null): void {
66
+ if (iframe && iframe.style.visibility === "hidden") {
67
+ iframe.style.visibility = "";
68
+ }
69
+ }
70
+
71
+ export function resolveReloadSeekTime(input: {
72
+ pendingSeek: number | null;
73
+ requestedSeek: number | null;
74
+ storeCurrentTime: number;
75
+ duration: number;
76
+ }): number {
77
+ const target = input.pendingSeek ?? input.requestedSeek ?? input.storeCurrentTime;
78
+ if (!Number.isFinite(target) || target <= 0) return 0;
79
+ // Only clamp to duration when it's a usable positive number. A non-finite or
80
+ // non-positive duration (e.g. the adapter reports NaN mid-reload) would turn
81
+ // Math.min(target, NaN) into NaN and seek(NaN); return the guarded target
82
+ // unclamped instead so the playhead lands at the intended position.
83
+ if (!Number.isFinite(input.duration) || input.duration <= 0) return target;
84
+ return Math.min(target, input.duration);
85
+ }
86
+
87
+ /** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */
88
+ function sanitizeDurationSeconds(value: number): number {
89
+ return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0;
90
+ }
91
+
92
+ /**
93
+ * The transport TOTAL a clip-manifest message should write to the store.
94
+ *
95
+ * The manifest's `durationInFrames` measures the runtime timeline; some runtimes
96
+ * report only the furthest clip end and ignore the root composition's authored
97
+ * `data-duration`. When that manifest total is SHORTER than the authored root
98
+ * duration, writing it makes the readout stale (playback still runs the full
99
+ * authored window — the user saw "0:44/0:40" on a root authored at 44.5s whose
100
+ * last clip ends at 40s). The authored root duration is the floor for the total,
101
+ * so the readout can never sit below what the file declares. A manifest total
102
+ * that is LONGER (clips extend past the root) still wins — content can only grow
103
+ * the timeline, never shrink it below the authored window.
104
+ */
105
+ export function resolveTimelineTotalDuration(input: {
106
+ manifestDurationSeconds: number;
107
+ authoredRootDurationSeconds: number;
108
+ }): number {
109
+ return Math.max(
110
+ sanitizeDurationSeconds(input.manifestDurationSeconds),
111
+ sanitizeDurationSeconds(input.authoredRootDurationSeconds),
112
+ );
113
+ }
114
+
44
115
  export function useTimelineSyncCallbacks({
45
116
  iframeRef,
46
117
  probeIntervalRef,
@@ -154,8 +225,14 @@ export function useTimelineSyncCallbacks({
154
225
  const rawDuration = data.durationInFrames / 30;
155
226
  // Clamp non-finite or absurdly large durations — the runtime can emit
156
227
  // Infinity when it detects a loop-inflated GSAP timeline without an
157
- // explicit data-duration on the root composition.
158
- const newDuration = Number.isFinite(rawDuration) && rawDuration < 7200 ? rawDuration : 0;
228
+ // explicit data-duration on the root composition. Floor the manifest total
229
+ // at the authored root `data-duration` so a runtime that measures only the
230
+ // furthest clip end (shorter than the authored window) can't leave a stale,
231
+ // too-short total in the transport (the "0:44/0:40" bug).
232
+ const newDuration = resolveTimelineTotalDuration({
233
+ manifestDurationSeconds: rawDuration,
234
+ authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
235
+ });
159
236
  const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration;
160
237
  const clampedEls =
161
238
  effectiveDuration > 0
@@ -218,12 +295,32 @@ export function useTimelineSyncCallbacks({
218
295
  // never reaches pendingSeekRef). Reconciling with the store here is what makes a
219
296
  // deep-linked `?t=` land instead of starting at 0.
220
297
  const storeSeek = usePlayerStore.getState().requestedSeekTime;
221
- const seekTo = pendingSeekRef.current ?? storeSeek;
298
+ const startTime = resolveReloadSeekTime({
299
+ pendingSeek: pendingSeekRef.current,
300
+ requestedSeek: storeSeek,
301
+ storeCurrentTime: usePlayerStore.getState().currentTime,
302
+ duration: adapter.getDuration(),
303
+ });
222
304
  pendingSeekRef.current = null;
223
305
  if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
224
- const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
225
306
 
307
+ // Force a REAL render at startTime, not a no-op. After a post-edit reload the
308
+ // freshly rebuilt GSAP timeline can already report being at `startTime`
309
+ // internally (the reload restores the same playhead), so a single
310
+ // `adapter.seek(startTime)` is a GSAP no-op — `tl.seek(t)` at the current time
311
+ // doesn't re-evaluate. That's why a just-dropped clip stayed invisible until
312
+ // the user nudged the playhead: its element's state was never applied at the
313
+ // restore position. Seeking to a DIFFERENT guard value first (a hair off, or 0
314
+ // when startTime is already ~0) guarantees the follow-up seek to `startTime`
315
+ // crosses a time boundary and re-renders every clip — including the new one.
316
+ const guardTime = startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001;
317
+ adapter.seek(guardTime);
226
318
  adapter.seek(startTime);
319
+ // The correct frame is now rendered — reveal the iframe that refreshPlayer hid
320
+ // for the reload, so the user sees the restored frame directly (never the raw
321
+ // all-clips DOM). Cleared unconditionally: any later failure path must not leave
322
+ // the preview stuck invisible.
323
+ revealIframe(iframeRef.current);
227
324
  // Keep non-React listeners such as the capture link and time display in sync
228
325
  // with the initial adapter seek on iframe load.
229
326
  liveTime.notify(startTime);
@@ -332,6 +429,9 @@ export function useTimelineSyncCallbacks({
332
429
  trySettle();
333
430
  }
334
431
  window.removeEventListener("message", onMessage);
432
+ // Never leave the preview stuck invisible if the runtime never settled
433
+ // (initializeAdapter reveals on success; this covers the give-up case).
434
+ revealIframe(iframeRef.current);
335
435
  }, 5000) as unknown as ReturnType<typeof setInterval>;
336
436
  }, [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState]);
337
437
 
@@ -6,11 +6,12 @@ export { VideoThumbnail } from "./components/VideoThumbnail";
6
6
  export { CompositionThumbnail } from "./components/CompositionThumbnail";
7
7
 
8
8
  // Hooks
9
- export { useTimelinePlayer, resolveIframe } from "./hooks/useTimelinePlayer";
9
+ export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
10
+ export { resolveIframe } from "./lib/timelineDOM";
10
11
 
11
12
  // Store
12
13
  export { usePlayerStore, liveTime } from "./store/playerStore";
13
- export type { TimelineElement, ZoomMode } from "./store/playerStore";
14
+ export type { SelectElementOptions, TimelineElement } from "./store/playerStore";
14
15
 
15
16
  // Utils
16
17
  export { formatTime } from "./lib/time";