@hyperframes/studio 0.7.55 → 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 (237) hide show
  1. package/dist/assets/{index-BXaqaVKt.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CPetwHFV.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 +40414 -37680
  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/DomEditCropHandles.test.tsx +74 -3
  21. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  22. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  23. package/src/components/editor/DomEditOverlay.tsx +158 -126
  24. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  25. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  26. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  27. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  28. package/src/components/editor/LayersPanel.test.ts +66 -2
  29. package/src/components/editor/LayersPanel.tsx +43 -1
  30. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  31. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  32. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  33. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  34. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  35. package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
  36. package/src/components/editor/domEditNudge.test.ts +80 -0
  37. package/src/components/editor/domEditNudge.ts +44 -0
  38. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  39. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  40. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  41. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  42. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  43. package/src/components/editor/domEditResizeLocal.ts +125 -0
  44. package/src/components/editor/domEditingDom.ts +1 -1
  45. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  46. package/src/components/editor/manualOffsetDrag.ts +55 -12
  47. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  48. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  49. package/src/components/editor/resizeDraft.ts +108 -0
  50. package/src/components/editor/snapEngine.test.ts +12 -78
  51. package/src/components/editor/snapEngine.ts +13 -53
  52. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  53. package/src/components/editor/useDomEditNudge.ts +254 -0
  54. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  55. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  56. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  57. package/src/components/nle/NLEContext.test.ts +144 -0
  58. package/src/components/nle/NLEContext.tsx +328 -0
  59. package/src/components/nle/NLEPreview.tsx +1 -1
  60. package/src/components/nle/PreviewOverlays.tsx +240 -0
  61. package/src/components/nle/PreviewPane.tsx +163 -0
  62. package/src/components/nle/TimelinePane.test.ts +60 -0
  63. package/src/components/nle/TimelinePane.tsx +293 -0
  64. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  65. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  66. package/src/components/nle/useCompositionStack.ts +17 -9
  67. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  68. package/src/components/sidebar/AssetCard.tsx +326 -0
  69. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  70. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  71. package/src/components/sidebar/AssetsTab.tsx +71 -206
  72. package/src/components/sidebar/AudioRow.tsx +39 -2
  73. package/src/components/sidebar/BlocksTab.tsx +8 -1
  74. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  75. package/src/components/sidebar/assetHelpers.ts +29 -0
  76. package/src/contexts/DomEditContext.tsx +9 -0
  77. package/src/contexts/StudioContext.tsx +9 -8
  78. package/src/contexts/TimelineEditContext.tsx +3 -7
  79. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  80. package/src/hooks/domEditCommitTypes.ts +12 -1
  81. package/src/hooks/domSelectionTestHarness.ts +14 -0
  82. package/src/hooks/gestureTransaction.test.ts +311 -0
  83. package/src/hooks/gestureTransaction.ts +199 -0
  84. package/src/hooks/gsapDragCommit.test.ts +141 -41
  85. package/src/hooks/gsapDragCommit.ts +64 -74
  86. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  87. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  88. package/src/hooks/gsapResizeIntercept.ts +38 -10
  89. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  90. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  91. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  92. package/src/hooks/gsapShared.test.ts +19 -1
  93. package/src/hooks/gsapShared.ts +14 -0
  94. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  95. package/src/hooks/timelineEditingGsap.ts +14 -0
  96. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  97. package/src/hooks/timelineEditingHelpers.ts +22 -48
  98. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  99. package/src/hooks/timelineMoveAdapter.ts +37 -0
  100. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  101. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  102. package/src/hooks/useAppHotkeys.ts +16 -25
  103. package/src/hooks/useBlockHandlers.ts +55 -27
  104. package/src/hooks/useContextMenuDismiss.ts +31 -7
  105. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  106. package/src/hooks/useDomEditCommits.ts +100 -3
  107. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  108. package/src/hooks/useDomEditSession.ts +2 -2
  109. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  110. package/src/hooks/useDomEditTextCommits.ts +8 -3
  111. package/src/hooks/useDomEditWiring.ts +7 -9
  112. package/src/hooks/useDomGeometryCommits.ts +17 -2
  113. package/src/hooks/useDomSelection.test.ts +2 -36
  114. package/src/hooks/useDomSelection.ts +81 -51
  115. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  116. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  117. package/src/hooks/useElementLifecycleOps.ts +40 -71
  118. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  119. package/src/hooks/useEnableKeyframes.ts +73 -29
  120. package/src/hooks/useGestureCommit.test.tsx +130 -0
  121. package/src/hooks/useGestureCommit.ts +32 -8
  122. package/src/hooks/useGroupCommits.ts +7 -28
  123. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  124. package/src/hooks/useGsapAwareEditing.ts +115 -20
  125. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  126. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  127. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  128. package/src/hooks/useGsapScriptCommits.ts +134 -50
  129. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  130. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  131. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  132. package/src/hooks/usePersistentEditHistory.ts +92 -58
  133. package/src/hooks/usePreviewPersistence.ts +25 -7
  134. package/src/hooks/useRazorSplit.history.test.tsx +148 -18
  135. package/src/hooks/useRazorSplit.test.ts +269 -0
  136. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  137. package/src/hooks/useRazorSplit.ts +107 -36
  138. package/src/hooks/useRenderClipContent.ts +24 -6
  139. package/src/hooks/useStudioContextValue.ts +15 -5
  140. package/src/hooks/useStudioUrlState.ts +2 -4
  141. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  142. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  143. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  144. package/src/index.ts +1 -1
  145. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  146. package/src/player/components/ImageThumbnail.tsx +160 -0
  147. package/src/player/components/PlayheadIndicator.tsx +50 -5
  148. package/src/player/components/Timeline.test.ts +102 -19
  149. package/src/player/components/Timeline.tsx +194 -194
  150. package/src/player/components/TimelineCanvas.tsx +207 -521
  151. package/src/player/components/TimelineClip.tsx +4 -1
  152. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  153. package/src/player/components/TimelineEmptyState.tsx +3 -1
  154. package/src/player/components/TimelineLanes.tsx +487 -0
  155. package/src/player/components/TimelineOverlays.tsx +122 -0
  156. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  157. package/src/player/components/VideoThumbnail.tsx +28 -7
  158. package/src/player/components/thumbnailUtils.test.ts +127 -0
  159. package/src/player/components/thumbnailUtils.ts +54 -0
  160. package/src/player/components/timelineCallbacks.ts +18 -18
  161. package/src/player/components/timelineClipChildren.tsx +39 -0
  162. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  163. package/src/player/components/timelineClipDragCommit.ts +401 -0
  164. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  165. package/src/player/components/timelineClipDragPreview.ts +323 -38
  166. package/src/player/components/timelineClipDragTypes.ts +65 -0
  167. package/src/player/components/timelineCollision.test.ts +42 -2
  168. package/src/player/components/timelineDragDrop.ts +69 -60
  169. package/src/player/components/timelineEditCapabilities.ts +52 -0
  170. package/src/player/components/timelineEditing.test.ts +1 -1
  171. package/src/player/components/timelineEditing.ts +76 -59
  172. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  173. package/src/player/components/timelineGroupEditing.ts +133 -0
  174. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  175. package/src/player/components/timelineLayout.test.ts +95 -23
  176. package/src/player/components/timelineLayout.ts +120 -61
  177. package/src/player/components/timelineMarquee.test.ts +197 -0
  178. package/src/player/components/timelineMarquee.ts +112 -0
  179. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  180. package/src/player/components/timelineTheme.ts +6 -1
  181. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  182. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  183. package/src/player/components/useTimelineClipDrag.ts +380 -386
  184. package/src/player/components/useTimelineEditPinning.ts +121 -0
  185. package/src/player/components/useTimelineGeometry.ts +129 -0
  186. package/src/player/components/useTimelinePlayhead.ts +4 -16
  187. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  188. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  189. package/src/player/components/useTimelineStackingSync.ts +83 -0
  190. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  191. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  192. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  193. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  194. package/src/player/index.ts +3 -2
  195. package/src/player/lib/time.test.ts +19 -19
  196. package/src/player/lib/time.ts +6 -2
  197. package/src/player/lib/timelineDOM.test.ts +25 -98
  198. package/src/player/lib/timelineDOM.ts +12 -81
  199. package/src/player/lib/timelineElementHelpers.ts +54 -5
  200. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  201. package/src/player/store/playerStore.ts +62 -3
  202. package/src/styles/studio.css +27 -0
  203. package/src/utils/assetPreviewStore.ts +33 -0
  204. package/src/utils/blockInstaller.ts +10 -2
  205. package/src/utils/editHistory.test.ts +35 -0
  206. package/src/utils/gsapSoftReload.test.ts +119 -1
  207. package/src/utils/gsapSoftReload.ts +153 -0
  208. package/src/utils/mediaTypes.ts +3 -2
  209. package/src/utils/resizeDebug.ts +55 -0
  210. package/src/utils/rootDuration.test.ts +90 -1
  211. package/src/utils/rootDuration.ts +76 -13
  212. package/src/utils/sdkCutover.ts +4 -0
  213. package/src/utils/studioFileHistory.ts +37 -7
  214. package/src/utils/studioHelpers.ts +62 -0
  215. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  216. package/src/utils/studioPreviewHelpers.ts +10 -0
  217. package/src/utils/studioTelemetry.ts +4 -1
  218. package/src/utils/studioUrlState.test.ts +0 -1
  219. package/src/utils/timelineAssetDrop.ts +62 -0
  220. package/src/utils/timelineDiscovery.ts +0 -17
  221. package/dist/assets/index-BRwkMj0w.js +0 -423
  222. package/dist/assets/index-Dq7FEg0K.css +0 -1
  223. package/dist/chunk-SOTCF4DF.js.map +0 -1
  224. package/src/components/StudioPreviewArea.tsx +0 -500
  225. package/src/components/nle/NLELayout.test.ts +0 -12
  226. package/src/components/nle/NLELayout.tsx +0 -591
  227. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  228. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  229. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  230. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  231. package/src/player/components/timelineSnapTargets.ts +0 -164
  232. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  233. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  234. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  235. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  236. package/src/utils/timelineDiscovery.test.ts +0 -90
  237. /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
+ }
@@ -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";
@@ -3,26 +3,26 @@ import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatT
3
3
 
4
4
  describe("formatTime", () => {
5
5
  it("formats zero seconds", () => {
6
- expect(formatTime(0)).toBe("0:00");
6
+ expect(formatTime(0)).toBe("00:00");
7
7
  });
8
8
 
9
9
  // fallow-ignore-next-line code-duplication
10
10
  it("formats seconds less than a minute", () => {
11
- expect(formatTime(5)).toBe("0:05");
12
- expect(formatTime(30)).toBe("0:30");
13
- expect(formatTime(59)).toBe("0:59");
11
+ expect(formatTime(5)).toBe("00:05");
12
+ expect(formatTime(30)).toBe("00:30");
13
+ expect(formatTime(59)).toBe("00:59");
14
14
  });
15
15
 
16
16
  it("formats exact minutes", () => {
17
- expect(formatTime(60)).toBe("1:00");
18
- expect(formatTime(120)).toBe("2:00");
17
+ expect(formatTime(60)).toBe("01:00");
18
+ expect(formatTime(120)).toBe("02:00");
19
19
  expect(formatTime(600)).toBe("10:00");
20
20
  });
21
21
 
22
22
  it("formats minutes and seconds", () => {
23
- expect(formatTime(65)).toBe("1:05");
24
- expect(formatTime(90)).toBe("1:30");
25
- expect(formatTime(125)).toBe("2:05");
23
+ expect(formatTime(65)).toBe("01:05");
24
+ expect(formatTime(90)).toBe("01:30");
25
+ expect(formatTime(125)).toBe("02:05");
26
26
  });
27
27
 
28
28
  it("formats large values (over an hour)", () => {
@@ -32,28 +32,28 @@ describe("formatTime", () => {
32
32
  });
33
33
 
34
34
  it("floors fractional seconds", () => {
35
- expect(formatTime(0.9)).toBe("0:00");
36
- expect(formatTime(1.5)).toBe("0:01");
37
- expect(formatTime(59.99)).toBe("0:59");
38
- expect(formatTime(60.5)).toBe("1:00");
35
+ expect(formatTime(0.9)).toBe("00:00");
36
+ expect(formatTime(1.5)).toBe("00:01");
37
+ expect(formatTime(59.99)).toBe("00:59");
38
+ expect(formatTime(60.5)).toBe("01:00");
39
39
  });
40
40
 
41
- it("pads single-digit seconds with leading zero", () => {
42
- expect(formatTime(1)).toBe("0:01");
43
- expect(formatTime(61)).toBe("1:01");
41
+ it("pads single-digit minutes and seconds with leading zeros", () => {
42
+ expect(formatTime(1)).toBe("00:01");
43
+ expect(formatTime(61)).toBe("01:01");
44
44
  expect(formatTime(609)).toBe("10:09");
45
45
  });
46
46
 
47
47
  it("guards against negative values", () => {
48
- expect(formatTime(-1)).toBe("0:00");
48
+ expect(formatTime(-1)).toBe("00:00");
49
49
  });
50
50
 
51
51
  it("guards against NaN", () => {
52
- expect(formatTime(NaN)).toBe("0:00");
52
+ expect(formatTime(NaN)).toBe("00:00");
53
53
  });
54
54
 
55
55
  it("guards against Infinity", () => {
56
- expect(formatTime(Infinity)).toBe("0:00");
56
+ expect(formatTime(Infinity)).toBe("00:00");
57
57
  });
58
58
  });
59
59
 
@@ -1,10 +1,14 @@
1
1
  export const STUDIO_PREVIEW_FPS = 30;
2
2
 
3
3
  export function formatTime(time: number): string {
4
- if (!Number.isFinite(time) || time < 0) return "0:00";
4
+ if (!Number.isFinite(time) || time < 0) return "00:00";
5
5
  const mins = Math.floor(time / 60);
6
6
  const secs = Math.floor(time % 60);
7
- return `${mins}:${secs.toString().padStart(2, "0")}`;
7
+ // Zero-pad minutes as well as seconds so every readout is a stable-width
8
+ // MM:SS (e.g. "00:44", not "0:44"). Minutes past 99 keep their extra digits
9
+ // ("120:00"); an hours-style H:MM:SS grouping is applied by callers that need
10
+ // it (see formatTimelineTickLabel), never here.
11
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
8
12
  }
9
13
 
10
14
  export function secondsToFrame(time: number, fps = STUDIO_PREVIEW_FPS): number {
@@ -4,8 +4,13 @@ import {
4
4
  createTimelineElementFromManifestClip,
5
5
  parseTimelineFromDOM,
6
6
  createImplicitTimelineLayersFromDOM,
7
- buildStandaloneRootTimelineElement,
7
+ mergeTimelineElementsPreservingDowngrades,
8
8
  } from "./timelineDOM";
9
+ import type { TimelineElement } from "../store/playerStore";
10
+
11
+ function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
12
+ return { id, tag: "img", start: 0, duration: 5, track: 0, ...extra };
13
+ }
9
14
 
10
15
  function makeDoc(html: string): Document {
11
16
  const d = document.implementation.createHTMLDocument();
@@ -13,29 +18,6 @@ function makeDoc(html: string): Document {
13
18
  return d;
14
19
  }
15
20
 
16
- function makeLiveDoc(html: string): Document {
17
- document.head.innerHTML = "";
18
- document.body.innerHTML = html;
19
- return document;
20
- }
21
-
22
- function mockComputedZIndex(doc: Document, zIndexById: ReadonlyMap<string, string>): void {
23
- const win = doc.defaultView;
24
- if (!win) throw new Error("Expected document window");
25
- const original = win.getComputedStyle.bind(win);
26
- Object.defineProperty(win, "getComputedStyle", {
27
- configurable: true,
28
- value: (element: Element, pseudoElt?: string | null) => {
29
- const style = original(element, pseudoElt);
30
- const zIndex = zIndexById.get(element.id);
31
- if (zIndex != null) {
32
- Object.defineProperty(style, "zIndex", { configurable: true, value: zIndex });
33
- }
34
- return style;
35
- },
36
- });
37
- }
38
-
39
21
  describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
40
22
  it("harvests hfId from a data-start element that has data-hf-id", () => {
41
23
  const doc = makeDoc(`
@@ -126,58 +108,6 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
126
108
 
127
109
  expect(element.hidden).toBe(true);
128
110
  });
129
-
130
- it("captures the effective z-index from the live element, not the runtime inline-only value", () => {
131
- // The runtime reports inline-only z-index (0 for CSS-rule authored z-index),
132
- // which must NOT override the live element's effective z-index — otherwise
133
- // the timeline collapses every CSS-styled clip to a z=0 tie and mis-orders.
134
- const doc = makeDoc(`
135
- <div data-composition-id="root">
136
- <div id="hero" class="clip" data-start="0" data-duration="5" style="z-index: 30"></div>
137
- </div>
138
- `);
139
- const hostEl = doc.getElementById("hero");
140
-
141
- const element = createTimelineElementFromManifestClip({
142
- clip: {
143
- id: "hero",
144
- label: "Hero",
145
- kind: "element",
146
- tagName: "div",
147
- start: 0,
148
- duration: 5,
149
- track: 0,
150
- zIndex: 0,
151
- compositionId: null,
152
- parentCompositionId: null,
153
- compositionSrc: null,
154
- assetUrl: null,
155
- },
156
- fallbackIndex: 0,
157
- doc,
158
- hostEl,
159
- });
160
-
161
- expect(element.zIndex).toBe(30);
162
- expect(element.hasExplicitZIndex).toBe(true);
163
- });
164
-
165
- it("marks parsed inline, CSS-rule, and auto z-index authorship accurately", () => {
166
- const doc = makeLiveDoc(`
167
- <div data-composition-id="root">
168
- <div id="inline" class="clip" data-start="0" data-duration="2" style="z-index: 3"></div>
169
- <div id="rule" class="clip" data-start="0" data-duration="2"></div>
170
- <div id="auto" class="clip" data-start="0" data-duration="2"></div>
171
- </div>
172
- `);
173
- mockComputedZIndex(doc, new Map([["rule", "12"]]));
174
-
175
- const elements = parseTimelineFromDOM(doc, 10);
176
-
177
- expect(elements.find((el) => el.id === "inline")?.hasExplicitZIndex).toBe(true);
178
- expect(elements.find((el) => el.id === "rule")?.hasExplicitZIndex).toBe(true);
179
- expect(elements.find((el) => el.id === "auto")?.hasExplicitZIndex).toBe(false);
180
- });
181
111
  });
182
112
 
183
113
  describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
@@ -211,31 +141,28 @@ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
211
141
 
212
142
  expect(layers).toEqual([]);
213
143
  });
144
+ });
214
145
 
215
- it("marks implicit layer CSS z-index authorship from computed style", () => {
216
- const doc = makeLiveDoc(`
217
- <div data-composition-id="root">
218
- <div id="layer" class="clip"></div>
219
- </div>
220
- `);
221
- mockComputedZIndex(doc, new Map([["layer", "8"]]));
222
-
223
- const layers = createImplicitTimelineLayersFromDOM(doc, 10);
224
-
225
- expect(layers[0]?.zIndex).toBe(8);
226
- expect(layers[0]?.hasExplicitZIndex).toBe(true);
146
+ describe("mergeTimelineElementsPreservingDowngrades genuine removal vs transient downgrade", () => {
147
+ it("drops a removed TOP-LEVEL element (undo of a split) instead of ghosting it", () => {
148
+ const current = [el("a"), el("a-split")]; // post-split store: original + clone
149
+ const next = [el("a")]; // fresh scan of the reverted file: clone gone
150
+ const merged = mergeTimelineElementsPreservingDowngrades(current, next, 30, 30);
151
+ expect(merged.map((e) => e.id)).toEqual(["a"]);
227
152
  });
228
- });
229
153
 
230
- describe("buildStandaloneRootTimelineElement", () => {
231
- it("marks the standalone root as auto z-index", () => {
232
- const root = buildStandaloneRootTimelineElement({
233
- compositionId: "root",
234
- tagName: "div",
235
- rootDuration: 10,
236
- iframeSrc: "/preview/comp/index.html",
237
- });
154
+ it("still preserves an enriched sub-composition child a bare re-scan drops", () => {
155
+ const current = [el("a"), el("sub-child", { compositionSrc: "sub.html" })];
156
+ const next = [el("a")]; // bare DOM scan misses the enriched sub-comp child
157
+ const merged = mergeTimelineElementsPreservingDowngrades(current, next, 30, 30);
158
+ expect(merged.map((e) => e.id).sort()).toEqual(["a", "sub-child"]);
159
+ });
238
160
 
239
- expect(root?.hasExplicitZIndex).toBe(false);
161
+ it("trusts the fresh scan fully when it is not shorter", () => {
162
+ const current = [el("a"), el("b", { compositionSrc: "sub.html" })];
163
+ const next = [el("a"), el("c")];
164
+ expect(
165
+ mergeTimelineElementsPreservingDowngrades(current, next, 30, 30).map((e) => e.id),
166
+ ).toEqual(["a", "c"]);
240
167
  });
241
168
  });