@hyperframes/studio 0.8.49 → 0.8.51

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 (281) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-Cn_PpHqP.js} +3 -3
  2. package/dist/assets/index-C05SNrvY.js +360 -0
  3. package/dist/assets/{index-mIgSsKqs.js → index-CPS3Cvj8.js} +1 -1
  4. package/dist/assets/{index-BGovDksE.js → index-Clrb11lj.js} +1 -1
  5. package/dist/assets/index-DUR9qzQM.css +1 -0
  6. package/dist/index.d.ts +29 -16
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7283 -8606
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles/tailwind-preset.js.map +1 -1
  11. package/package.json +11 -12
  12. package/src/App.tsx +132 -143
  13. package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
  14. package/src/captions/components/CaptionOverlayUtils.ts +2 -3
  15. package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
  16. package/src/captions/components/CaptionTimeline.tsx +1 -1
  17. package/src/captions/components/shared.tsx +2 -2
  18. package/src/components/AskAgentModal.tsx +5 -5
  19. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  20. package/src/components/EditorShell.tsx +28 -7
  21. package/src/components/ExternalFileConflictBanner.tsx +16 -16
  22. package/src/components/LintModal.tsx +6 -16
  23. package/src/components/MediaPreview.tsx +2 -2
  24. package/src/components/SaveQueuePausedBanner.tsx +2 -2
  25. package/src/components/StudioHeader.tsx +2 -62
  26. package/src/components/StudioLeftSidebar.tsx +7 -4
  27. package/src/components/StudioOverlays.tsx +2 -6
  28. package/src/components/StudioRightPanel.tsx +4 -4
  29. package/src/components/StudioToast.tsx +2 -2
  30. package/src/components/TimelineToolbar.tsx +7 -7
  31. package/src/components/dock/Dock.test.tsx +192 -0
  32. package/src/components/dock/Dock.tsx +264 -0
  33. package/src/components/dock/dock.css +23 -0
  34. package/src/components/dock/dockLayout.ts +58 -0
  35. package/src/components/dock/dockLayoutSchema.ts +66 -0
  36. package/src/components/dock/dockLayoutStore.ts +91 -0
  37. package/src/components/dock/panelRegistry.ts +67 -0
  38. package/src/components/editor/AnimationCard.tsx +4 -4
  39. package/src/components/editor/AnimationCardParts.tsx +6 -6
  40. package/src/components/editor/BlockParamsPanel.tsx +4 -4
  41. package/src/components/editor/BorderRadiusEditor.tsx +1 -1
  42. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  43. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  44. package/src/components/editor/DomEditOverlay.tsx +1 -1
  45. package/src/components/editor/EaseCurveSection.tsx +1 -1
  46. package/src/components/editor/EaseParamFields.tsx +5 -5
  47. package/src/components/editor/FileTree.tsx +4 -4
  48. package/src/components/editor/FileTreeIcons.tsx +1 -1
  49. package/src/components/editor/FileTreeNodes.tsx +13 -13
  50. package/src/components/editor/InlineTextToolbar.tsx +1 -1
  51. package/src/components/editor/InspectorHeaderActions.tsx +3 -3
  52. package/src/components/editor/KeyframeDiamond.tsx +1 -1
  53. package/src/components/editor/KeyframeEaseList.tsx +1 -1
  54. package/src/components/editor/LayersPanel.tsx +3 -3
  55. package/src/components/editor/MarqueeOverlay.tsx +1 -1
  56. package/src/components/editor/OffCanvasIndicators.tsx +1 -1
  57. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  58. package/src/components/editor/PreviewGuides.tsx +125 -0
  59. package/src/components/editor/PromotableControl.tsx +2 -2
  60. package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
  61. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  62. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  63. package/src/components/editor/SnapToolbar.tsx +27 -3
  64. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  65. package/src/components/editor/TopologyLens.tsx +4 -4
  66. package/src/components/editor/Transform3DCube.tsx +2 -2
  67. package/src/components/editor/audioFxRevealTarget.ts +1 -0
  68. package/src/components/editor/domEditing.test.ts +1 -1
  69. package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
  70. package/src/components/editor/domEditingAgentPrompt.ts +9 -3
  71. package/src/components/editor/previewGuidesStore.ts +26 -0
  72. package/src/components/editor/propertyPanelColor.tsx +11 -11
  73. package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
  74. package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
  75. package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
  76. package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
  77. package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
  78. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  79. package/src/components/editor/propertyPanelFill.tsx +4 -4
  80. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  81. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
  82. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  83. package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
  84. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
  85. package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
  86. package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
  87. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
  88. package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
  89. package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
  90. package/src/components/editor/propertyPanelFont.tsx +5 -5
  91. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  92. package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
  93. package/src/components/editor/propertyPanelFxControls.tsx +6 -6
  94. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  95. package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
  96. package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
  97. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  98. package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
  99. package/src/components/editor/propertyPanelSections.tsx +8 -8
  100. package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
  101. package/src/components/feedback/feedbackTrigger.ts +0 -1
  102. package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
  103. package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
  104. package/src/components/nle/NLEPreview.test.ts +13 -0
  105. package/src/components/nle/NLEPreview.tsx +12 -7
  106. package/src/components/nle/PreviewOverlays.tsx +6 -6
  107. package/src/components/nle/PreviewPane.test.tsx +52 -0
  108. package/src/components/nle/PreviewPane.tsx +4 -2
  109. package/src/components/nle/TimelinePane.tsx +17 -3
  110. package/src/components/nle/TimelineResizeDivider.tsx +2 -2
  111. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  112. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  113. package/src/components/panels/SlideshowPanel.tsx +2 -2
  114. package/src/components/panels/SlideshowSubPanels.tsx +20 -17
  115. package/src/components/panels/VariablesBindElement.tsx +2 -2
  116. package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
  117. package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
  118. package/src/components/panels/VariablesPanel.tsx +3 -3
  119. package/src/components/panels/VariablesValueControls.tsx +2 -2
  120. package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
  121. package/src/components/renders/RenderQueue.test.tsx +7 -0
  122. package/src/components/renders/RenderQueue.tsx +6 -6
  123. package/src/components/renders/RenderQueueItem.tsx +6 -6
  124. package/src/components/sidebar/AssetCard.tsx +8 -8
  125. package/src/components/sidebar/AssetContextMenu.tsx +8 -8
  126. package/src/components/sidebar/AssetsTab.tsx +2 -2
  127. package/src/components/sidebar/AudioRow.tsx +6 -6
  128. package/src/components/sidebar/BlocksTab.tsx +6 -6
  129. package/src/components/sidebar/CompositionsTab.tsx +5 -5
  130. package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
  131. package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
  132. package/src/components/sidebar/LeftSidebar.tsx +19 -5
  133. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  134. package/src/components/ui/Button.test.tsx +60 -0
  135. package/src/components/ui/Button.tsx +4 -4
  136. package/src/components/ui/SearchInput.tsx +2 -2
  137. package/src/components/ui/Tooltip.tsx +1 -1
  138. package/src/hooks/appHotkeysDispatch.ts +11 -0
  139. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  140. package/src/hooks/timelineTrackVisibility.ts +8 -11
  141. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  142. package/src/hooks/useAppHotkeys.ts +4 -1
  143. package/src/hooks/useAskAgentModal.ts +2 -0
  144. package/src/hooks/useClipboard.test.ts +234 -0
  145. package/src/hooks/useClipboard.ts +264 -70
  146. package/src/hooks/useGroupCommits.test.ts +0 -1
  147. package/src/hooks/useLintModal.test.tsx +65 -0
  148. package/src/hooks/useLintModal.ts +3 -0
  149. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  150. package/src/hooks/useRazorSplit.test.ts +0 -2
  151. package/src/hooks/useRazorSplit.test.tsx +0 -1
  152. package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
  153. package/src/hooks/useSdkSession.ts +43 -10
  154. package/src/hooks/useStudioContextValue.ts +5 -19
  155. package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
  156. package/src/hooks/useTimelineAssetDropOps.ts +52 -11
  157. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  158. package/src/main.tsx +9 -3
  159. package/src/player/components/AudioWaveform.tsx +1 -1
  160. package/src/player/components/AutomationSelectionMenu.tsx +2 -2
  161. package/src/player/components/ClipContextMenu.tsx +90 -10
  162. package/src/player/components/CompositionThumbnail.test.ts +0 -3
  163. package/src/player/components/CompositionThumbnail.tsx +3 -4
  164. package/src/player/components/EditModal.tsx +1 -1
  165. package/src/player/components/ImageThumbnail.tsx +1 -1
  166. package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
  167. package/src/player/components/LayerDisclosureRow.tsx +2 -2
  168. package/src/player/components/PlayerControls.tsx +1 -1
  169. package/src/player/components/ShortcutsPanel.tsx +9 -9
  170. package/src/player/components/SpeedMenu.tsx +3 -5
  171. package/src/player/components/Timeline.tsx +16 -15
  172. package/src/player/components/TimelineCanvas.tsx +45 -8
  173. package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
  174. package/src/player/components/TimelineEmptyState.tsx +5 -5
  175. package/src/player/components/TimelineFxButton.tsx +6 -6
  176. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  177. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  178. package/src/player/components/TimelineGroupHeader.tsx +5 -5
  179. package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
  180. package/src/player/components/TimelineGroupRow.tsx +1 -1
  181. package/src/player/components/TimelineLanes.test.tsx +54 -1
  182. package/src/player/components/TimelineLanes.tsx +2 -5
  183. package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
  184. package/src/player/components/TimelineOverlays.tsx +12 -0
  185. package/src/player/components/TimelineRuler.tsx +1 -1
  186. package/src/player/components/TimelineShortcutHint.tsx +1 -1
  187. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  188. package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
  189. package/src/player/components/TimelineTypes.ts +6 -1
  190. package/src/player/components/TrackGapContextMenu.tsx +1 -1
  191. package/src/player/components/VideoThumbnail.tsx +4 -2
  192. package/src/player/components/VolumeControl.tsx +2 -2
  193. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  194. package/src/player/components/timelineCallbacks.ts +8 -8
  195. package/src/player/components/timelineClipChildren.test.ts +2 -2
  196. package/src/player/components/timelineClipChildren.tsx +2 -1
  197. package/src/player/components/timelineClipDragCommit.ts +2 -2
  198. package/src/player/components/timelineClipDragPreview.ts +51 -27
  199. package/src/player/components/timelineClipDragTypes.ts +3 -0
  200. package/src/player/components/timelineDragDrop.test.tsx +92 -1
  201. package/src/player/components/timelineDragDrop.ts +82 -33
  202. package/src/player/components/timelineEditCapabilities.ts +1 -2
  203. package/src/player/components/timelineEditing.test.ts +0 -16
  204. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  205. package/src/player/components/timelineGroupEditing.ts +0 -1
  206. package/src/player/components/timelineLaneProps.ts +3 -0
  207. package/src/player/components/timelineSnapping.test.ts +27 -0
  208. package/src/player/components/timelineSnapping.ts +14 -1
  209. package/src/player/components/timelineTrackDisplay.ts +1 -1
  210. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  211. package/src/player/components/trackHeaderLabelRows.tsx +4 -4
  212. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  213. package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
  214. package/src/player/components/useTimelineClipDrag.ts +30 -3
  215. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  216. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  217. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  218. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  219. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  220. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  221. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  222. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  223. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  224. package/src/player/lib/describeClips.test.ts +95 -0
  225. package/src/player/lib/describeClips.ts +52 -0
  226. package/src/player/lib/timelineDOM.test.ts +0 -80
  227. package/src/player/lib/timelineDOM.ts +1 -61
  228. package/src/player/lib/timelineElementHelpers.ts +2 -53
  229. package/src/player/store/playerStore.ts +5 -0
  230. package/src/player/store/timelineElement.ts +1 -3
  231. package/src/shims/canvasBrowserStub.js +50 -0
  232. package/src/styles/iconTokens.ts +20 -0
  233. package/src/styles/palette-reset.css +6 -0
  234. package/src/styles/studio.css +24 -21
  235. package/src/styles/tailwind-preset.shared.js +3 -0
  236. package/src/styles/theme.css +213 -0
  237. package/src/styles/theme.test.ts +196 -0
  238. package/src/utils/clipboardPayload.test.ts +42 -2
  239. package/src/utils/clipboardPayload.ts +57 -32
  240. package/src/utils/previewSafeMargins.test.ts +34 -0
  241. package/src/utils/previewSafeMargins.ts +10 -0
  242. package/src/utils/razorSplitTransaction.test.ts +0 -1
  243. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  244. package/src/utils/studioUiPreferences.test.ts +43 -0
  245. package/src/utils/studioUiPreferences.ts +34 -6
  246. package/src/utils/timelineDropTrackInsert.test.ts +134 -0
  247. package/src/utils/timelineDropTrackInsert.ts +83 -0
  248. package/src/utils/timelineElementSplit.test.ts +1 -2
  249. package/src/utils/timelineElementSplit.ts +0 -1
  250. package/src/webmcp/tools/lookTools.test.ts +40 -0
  251. package/src/webmcp/tools/lookTools.ts +32 -1
  252. package/dist/assets/index-B9LfWNyM.css +0 -1
  253. package/dist/assets/index-Bgmx2h7W.js +0 -441
  254. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  255. package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
  256. package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
  257. package/src/components/storyboard/FramePoster.test.tsx +0 -61
  258. package/src/components/storyboard/FramePoster.tsx +0 -86
  259. package/src/components/storyboard/StoryboardDirection.tsx +0 -45
  260. package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
  261. package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
  262. package/src/components/storyboard/StoryboardGrid.tsx +0 -55
  263. package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
  264. package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
  265. package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
  266. package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
  267. package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
  268. package/src/components/storyboard/StoryboardView.tsx +0 -169
  269. package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
  270. package/src/components/storyboard/frameComments.test.ts +0 -95
  271. package/src/components/storyboard/frameComments.ts +0 -115
  272. package/src/components/storyboard/frameStatus.ts +0 -36
  273. package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
  274. package/src/components/storyboard/storyboardReviewStage.ts +0 -45
  275. package/src/components/storyboard/useFrameComments.ts +0 -98
  276. package/src/contexts/ViewModeContext.tsx +0 -142
  277. package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
  278. package/src/hooks/useProjectSignaturePoll.ts +0 -68
  279. package/src/hooks/useStoryboard.ts +0 -99
  280. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  281. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -31,10 +31,14 @@ afterEach(() => {
31
31
  function renderResizeHarness(
32
32
  elements: TimelineElement[],
33
33
  selected: string[],
34
- options: { wireGroupResize?: boolean } = {},
34
+ options: {
35
+ wireGroupResize?: boolean;
36
+ snap?: boolean;
37
+ onSeek?: (time: number, seekOptions?: { keepPlaying?: boolean }) => void;
38
+ } = {},
35
39
  ) {
36
40
  usePlayerStore.getState().setElements(elements);
37
- usePlayerStore.setState({ timelineSnapEnabled: false });
41
+ usePlayerStore.setState({ timelineSnapEnabled: options.snap === true });
38
42
  usePlayerStore.getState().setSelectedElementIds(new Set(selected));
39
43
 
40
44
  const scroll = document.createElement("div");
@@ -61,6 +65,7 @@ function renderResizeHarness(
61
65
  onMoveElement,
62
66
  onBlockedEditAttempt,
63
67
  onResizeElements: options.wireGroupResize === false ? undefined : onResizeElements,
68
+ onSeek: options.onSeek,
64
69
  setShowPopover: vi.fn(),
65
70
  setRangeSelectionRef: { current: vi.fn() },
66
71
  sessionEpoch,
@@ -92,6 +97,9 @@ function renderResizeHarness(
92
97
  getResizeProjection() {
93
98
  return resizingClip?.groupPreview ?? [];
94
99
  },
100
+ getResizingClip() {
101
+ return resizingClip;
102
+ },
95
103
  getBlockedClip() {
96
104
  return blockedClipRef?.current ?? null;
97
105
  },
@@ -441,3 +449,129 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
441
449
  h.unmount();
442
450
  });
443
451
  });
452
+
453
+ describe("useTimelineClipDrag — trim guide and preview frame", () => {
454
+ /** Playhead at 1.25s that follows preview seeks; clip a's end edge grabbed and dragged once. */
455
+ function trimEndWithSeekingPlayhead(state: { isPlaying?: boolean } = {}) {
456
+ usePlayerStore.setState({ currentTime: 1.25, ...state });
457
+ const onSeek = vi.fn((t: number) => usePlayerStore.setState({ currentTime: t }));
458
+ const a = el("a", { start: 1, duration: 2 });
459
+ const h = renderResizeHarness([a], [], { onSeek });
460
+ h.startResize(a, "end");
461
+ h.movePointer(50);
462
+ return { onSeek, h };
463
+ }
464
+
465
+ /** Clip a (0-2s) with neighbour b starting at 5s, snapping on, a's end edge grabbed. */
466
+ function trimAEndBesideB() {
467
+ const a = el("a", { start: 0, duration: 2 });
468
+ const b = el("b", { start: 5, duration: 2 });
469
+ const h = renderResizeHarness([a, b], [], { snap: true });
470
+ h.startResize(a, "end");
471
+ return h;
472
+ }
473
+
474
+ it("publishes the snap target while a trimmed edge is on a neighbour edge", () => {
475
+ const h = trimAEndBesideB();
476
+ h.movePointer(296); // a's end lands at 4.96s, 4px from b's start
477
+ expect(h.getResizingClip()).toMatchObject({ snapTime: 5, snapType: "clip-edge" });
478
+ h.unmount();
479
+ });
480
+
481
+ it("keeps the guide when the trimmed edge sits exactly on the neighbour edge", () => {
482
+ const h = trimAEndBesideB();
483
+ h.movePointer(300);
484
+ expect(h.getResizingClip()).toMatchObject({ snapTime: 5, previewDuration: 5 });
485
+ h.unmount();
486
+ });
487
+
488
+ it("publishes no snap target when the trimmed edge is free", () => {
489
+ const h = trimAEndBesideB();
490
+ h.movePointer(150);
491
+ expect(h.getResizingClip()).toMatchObject({ snapTime: null, snapType: null });
492
+ h.unmount();
493
+ });
494
+
495
+ it("never snaps a trimmed edge to the playhead — the edge drives its own seek", () => {
496
+ usePlayerStore.setState({ currentTime: 5 }); // no clip edge or beat nearby, only the playhead
497
+ const a = el("a", { start: 0, duration: 2 });
498
+ const h = renderResizeHarness([a], [], { snap: true });
499
+ h.startResize(a, "end");
500
+ h.movePointer(296); // a's end lands at 4.96s, 4px from the playhead
501
+ expect(h.getResizingClip()).toMatchObject({
502
+ snapTime: null,
503
+ snapType: null,
504
+ previewDuration: 4.96,
505
+ });
506
+ h.unmount();
507
+ });
508
+
509
+ it("shows the frame at the dragged edge, then puts the playhead back on release", async () => {
510
+ usePlayerStore.setState({ currentTime: 1.25 });
511
+ const onSeek = vi.fn();
512
+ const a = el("a", { start: 1, duration: 2 });
513
+ const h = renderResizeHarness([a], [], { onSeek });
514
+ h.startResize(a, "end");
515
+ h.movePointer(50);
516
+ expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true });
517
+ await h.dropPointer();
518
+ expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true });
519
+ h.unmount();
520
+ });
521
+
522
+ it("previews the new in-point when the start edge is trimmed", () => {
523
+ const onSeek = vi.fn();
524
+ const a = el("a", { start: 1, duration: 2 });
525
+ const h = renderResizeHarness([a], [], { onSeek });
526
+ h.startResize(a, "start");
527
+ h.movePointer(50);
528
+ expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true });
529
+ h.unmount();
530
+ });
531
+
532
+ it("publishes the guide when the start edge snaps to a neighbour's end", () => {
533
+ // playbackStart leaves source to reveal when the in-point moves left.
534
+ const a = el("a", { start: 3, duration: 2, playbackStart: 5 });
535
+ const b = el("b", { start: 0, duration: 2 });
536
+ const h = renderResizeHarness([a, b], [], { snap: true });
537
+ h.startResize(a, "start");
538
+ h.movePointer(-96); // a's start lands at 2.04s, 4px from b's end
539
+ expect(h.getResizingClip()).toMatchObject({ snapTime: 2, previewStart: 2 });
540
+ h.unmount();
541
+ });
542
+
543
+ it("restores the playhead it had before the first preview seek, not a seeked time", async () => {
544
+ const { onSeek, h } = trimEndWithSeekingPlayhead();
545
+ h.movePointer(80);
546
+ h.movePointer(120);
547
+ await h.dropPointer();
548
+ expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true });
549
+ h.unmount();
550
+ });
551
+
552
+ it("leaves the playhead where playback is when a trim is released while playing", async () => {
553
+ const { onSeek, h } = trimEndWithSeekingPlayhead({ isPlaying: true });
554
+ h.movePointer(120);
555
+ const callsBeforeRelease = onSeek.mock.calls.length;
556
+ await h.dropPointer();
557
+ expect(onSeek).toHaveBeenCalledTimes(callsBeforeRelease);
558
+ expect(onSeek).not.toHaveBeenCalledWith(1.25, expect.anything());
559
+ usePlayerStore.setState({ isPlaying: false });
560
+ h.unmount();
561
+ });
562
+
563
+ it("draws no guide and seeks to the rendered edge when a group member clamps the trim", () => {
564
+ const onSeek = vi.fn();
565
+ const a = el("a", { start: 0, duration: 4 });
566
+ const b = el("b", { start: 0, duration: 1 });
567
+ const c = el("c", { start: 2, duration: 1 });
568
+ const h = renderResizeHarness([a, b, c], ["a", "b"], { snap: true, onSeek });
569
+ h.startResize(a, "end");
570
+ h.movePointer(-198); // a's raw end 2.02s snaps to c at 2s; b clamps the shared delta
571
+ const clip = h.getResizingClip()!;
572
+ expect(clip.previewStart + clip.previewDuration).toBeCloseTo(3.1, 3);
573
+ expect(clip).toMatchObject({ snapTime: null, snapType: null });
574
+ expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), { keepPlaying: true });
575
+ h.unmount();
576
+ });
577
+ });
@@ -17,6 +17,7 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
17
17
  import {
18
18
  computeDragPreview,
19
19
  computeResizePreview,
20
+ trimPreviewTime,
20
21
  previewGroupResize,
21
22
  type ResizePreviewResult,
22
23
  } from "./timelineClipDragPreview";
@@ -64,6 +65,8 @@ interface UseTimelineClipDragInput {
64
65
  ) => Promise<void> | void;
65
66
  onResizeElements?: NonNullable<TimelineEditCallbacks["onResizeElements"]>;
66
67
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void;
68
+ /** Seeks the preview; a trim shows the frame at its dragged edge. */
69
+ onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void;
67
70
  setShowPopover: (show: boolean) => void;
68
71
  /** Stable ref to the range selection setter — wired after mount to break circular dependency. */
69
72
  setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>;
@@ -92,6 +95,7 @@ export function useTimelineClipDrag({
92
95
  onResizeElement,
93
96
  onResizeElements,
94
97
  onBlockedEditAttempt,
98
+ onSeek,
95
99
  setShowPopover,
96
100
  setRangeSelectionRef,
97
101
  readZIndex,
@@ -149,11 +153,15 @@ export function useTimelineClipDrag({
149
153
  const dragAudioTracksRef = useRef<ReadonlySet<number> | null>(null);
150
154
 
151
155
  const buildSnapTargets = useCallback(
152
- (excludeElementKey: string | null, includeBeats: boolean): TimelineSnapTarget[] => {
156
+ (
157
+ excludeElementKey: string | null,
158
+ includeBeats: boolean,
159
+ includePlayhead = true,
160
+ ): TimelineSnapTarget[] => {
153
161
  // Magnet off ⇒ no targets and no scan; do NOT cache so a mid-gesture toggle
154
162
  // back on starts scanning immediately (preserves the existing skip).
155
163
  if (!snapContextRef.current.enabled) return [];
156
- const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}`;
164
+ const cacheKey = `${excludeElementKey ?? ""}|${includeBeats ? 1 : 0}|${includePlayhead ? 1 : 0}`;
157
165
  const cached = snapTargetsCacheRef.current.get(cacheKey);
158
166
  if (cached) return cached;
159
167
  const targets = collectTimelineSnapTargets({
@@ -161,6 +169,7 @@ export function useTimelineClipDrag({
161
169
  playheadTime: usePlayerStore.getState().currentTime,
162
170
  beatTimes: includeBeats ? snapContextRef.current.beatTimes : [],
163
171
  excludeElementKey,
172
+ includePlayhead,
164
173
  });
165
174
  snapTargetsCacheRef.current.set(cacheKey, targets);
166
175
  return targets;
@@ -244,6 +253,10 @@ export function useTimelineClipDrag({
244
253
  onResizeElementRef.current = onResizeElement;
245
254
  const onResizeElementsRef = useRef(onResizeElements);
246
255
  onResizeElementsRef.current = onResizeElements;
256
+ const onSeekRef = useRef(onSeek);
257
+ onSeekRef.current = onSeek;
258
+ // Playhead time before the first trim preview seek; restored when the gesture ends.
259
+ const trimSeekOriginRef = useRef<number | null>(null);
247
260
  const readZIndexRef = useRef(readZIndex);
248
261
  readZIndexRef.current = readZIndex;
249
262
  const onStackingPatchesRef = useRef(onStackingPatches);
@@ -292,10 +305,17 @@ export function useTimelineClipDrag({
292
305
  pps: ppsRef.current,
293
306
  buildSnapTargets,
294
307
  });
295
- const setResizeState = (v: ResizePreviewResult) =>
308
+ trimSeekOriginRef.current ??= usePlayerStore.getState().currentTime;
309
+ const setResizeState = (v: ResizePreviewResult) => {
310
+ // A trim never changes the play state: keepPlaying lets seek() decide,
311
+ // and it only resumes playback if it was already playing.
312
+ onSeekRef.current?.(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration), {
313
+ keepPlaying: true,
314
+ });
296
315
  publishResizingClip(
297
316
  resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
298
317
  );
318
+ };
299
319
 
300
320
  // Group resize: a capability-clean multi-selection resizes rigidly by one
301
321
  // shared, member-clamped delta (legacy main 36413da7f). The grabbed clip
@@ -338,6 +358,13 @@ export function useTimelineClipDrag({
338
358
  cancelAnimationFrame(clipDragScrollRaf.current);
339
359
  clipDragScrollRaf.current = 0;
340
360
  }
361
+ if (trimSeekOriginRef.current != null) {
362
+ // Paused: put the playhead back. Playing: leave it, a backward jump would rewind live playback.
363
+ if (!usePlayerStore.getState().isPlaying) {
364
+ onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true });
365
+ }
366
+ trimSeekOriginRef.current = null;
367
+ }
341
368
  // Gesture teardown: drop frozen caches so the next gesture reads fresh state.
342
369
  snapTargetsCacheRef.current.clear();
343
370
  dragAudioTracksRef.current = null;
@@ -98,7 +98,7 @@ function buildGroupInfo(
98
98
  id: groupId,
99
99
  label: membership.labelByGroup.get(groupId) ?? groupId,
100
100
  // Exactly x.5, which sub-composition child rows are now kept strictly below
101
- // (`useExpandedTimelineElements`) — they used to be able to land here and
101
+ // (`useTimelineRowElements`) — they used to be able to land here and
102
102
  // collide, duplicating the group header.
103
103
  anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
104
104
  memberTracks,
@@ -73,9 +73,6 @@ describe("live DOM manifest hydration", () => {
73
73
  canTrimEnd: true,
74
74
  });
75
75
  expect(getTimelineEditCapabilities({ ...input, timelineLocked: true }).canMove).toBe(false);
76
- expect(getTimelineEditCapabilities({ ...input, timingSource: "implicit" }).canTrimEnd).toBe(
77
- false,
78
- );
79
76
  });
80
77
  it("uses at most one fallback candidate snapshot per hydration pass", () => {
81
78
  const doc = documentWith(
@@ -3,6 +3,7 @@
3
3
  import { describe, expect, it } from "vitest";
4
4
  import {
5
5
  collectSubCompositionDomChildren,
6
+ collectTopLevelElementIds,
6
7
  collectSubCompositionHostState,
7
8
  } from "./timelineSyncHydration";
8
9
  import type { ClipManifestClip } from "../lib/playbackTypes";
@@ -94,3 +95,20 @@ describe("collectSubCompositionHostState", () => {
94
95
  expect(state.size).toBe(0);
95
96
  });
96
97
  });
98
+
99
+ describe("collectTopLevelElementIds", () => {
100
+ const docOf = (html: string) => new DOMParser().parseFromString(html, "text/html");
101
+
102
+ it("names the sub-composition host and a timed element, not what is nested inside them", () => {
103
+ const doc = docOf(`<div data-composition-id="root">
104
+ <div id="wrap"><div id="a" data-start="0" data-duration="2"><div id="inner" data-start="0"></div></div></div>
105
+ <div id="scene" data-composition-id="scene" data-composition-src="scene.html"><div id="box" data-start="0"></div></div>
106
+ </div>`);
107
+ expect([...collectTopLevelElementIds(doc)!].sort()).toEqual(["a", "scene"]);
108
+ });
109
+
110
+ it("is null when the document has no composition root", () => {
111
+ expect(collectTopLevelElementIds(docOf("<p>hi</p>"))).toBeNull();
112
+ expect(collectTopLevelElementIds(null)).toBeNull();
113
+ });
114
+ });
@@ -14,12 +14,12 @@ import { usePlayerStore } from "../store/playerStore";
14
14
  import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
15
15
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
16
16
  import type { ClipTree } from "@hyperframes/core/runtime/clipTree";
17
+ import { topLevelElements, type StructureNode } from "@hyperframes/parsers/top-level-elements";
17
18
  import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
18
19
  import { groupInfoFor } from "../lib/timelineGroupInfo";
19
20
  import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
20
21
  import {
21
22
  buildStandaloneRootTimelineElement,
22
- createImplicitTimelineLayersFromDOM,
23
23
  createTimelineElementFromManifestClip,
24
24
  getTimelineElementSelector,
25
25
  parseTimelineFromDOM,
@@ -137,6 +137,28 @@ export function collectSubCompositionDomChildren(
137
137
  return out;
138
138
  }
139
139
 
140
+ interface DomStructureNode extends StructureNode<DomStructureNode> {
141
+ id: string;
142
+ }
143
+
144
+ function toStructureNode(el: Element): DomStructureNode {
145
+ const attrs: Record<string, string> = {};
146
+ for (const attr of Array.from(el.attributes)) attrs[attr.name] = attr.value;
147
+ return {
148
+ tag: el.tagName,
149
+ attrs,
150
+ id: el.id,
151
+ children: Array.from(el.children).map(toStructureNode),
152
+ };
153
+ }
154
+
155
+ /** DOM ids of the timeline's rows, by the definition the structure lint shares; null when there is no readable root. */
156
+ export function collectTopLevelElementIds(doc: Document | null): Set<string> | null {
157
+ const root = doc?.querySelector("[data-composition-id]");
158
+ if (!root) return null;
159
+ return new Set(topLevelElements(toStructureNode(root)).flatMap((n) => (n.id ? [n.id] : [])));
160
+ }
161
+
140
162
  /** The host-element `data-*` state one element carries, or null when it has none. */
141
163
  function readSubCompositionHostState(el: Element): SubCompositionHostState | null {
142
164
  const state: SubCompositionHostState = {};
@@ -214,28 +236,25 @@ export function buildTimelineElementsFromClips(
214
236
  });
215
237
  }
216
238
 
217
- /**
218
- * The clamped manifest elements plus the layers that exist only in the DOM.
219
- * Both halves need the same resolved duration, which is why they land together.
220
- */
221
- export function withImplicitDomLayers(
222
- els: readonly TimelineElement[],
223
- iframeDoc: Document | null,
224
- effectiveDuration: number,
225
- ): TimelineElement[] {
226
- const clamped = clampElementsToDuration(els, effectiveDuration);
227
- if (!iframeDoc || effectiveDuration <= 0) return clamped;
228
- return [
229
- ...clamped,
230
- ...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clamped),
231
- ];
232
- }
233
-
234
239
  /**
235
240
  * Drop elements that start past the composition's end and trim the ones that
236
241
  * straddle it. A non-positive duration means "not known yet" — pass through
237
242
  * untouched rather than clamping everything to nothing.
238
243
  */
244
+ /** Commits the manifest elements, including none. An empty manifest carries a 1s floor, not a duration. */
245
+ export function syncManifestTimeline(
246
+ els: readonly TimelineElement[],
247
+ manifestDuration: number,
248
+ storeDuration: number,
249
+ sync: (els: TimelineElement[], duration?: number) => void,
250
+ ): void {
251
+ const hasDuration = manifestDuration > 0 && els.length > 0;
252
+ sync(
253
+ clampElementsToDuration(els, hasDuration ? manifestDuration : storeDuration),
254
+ hasDuration ? manifestDuration : undefined,
255
+ );
256
+ }
257
+
239
258
  function clampElementsToDuration(
240
259
  els: readonly TimelineElement[],
241
260
  effectiveDuration: number,
@@ -330,34 +330,17 @@ describe("findTimelineDomNodeForClip", () => {
330
330
  });
331
331
 
332
332
  describe("anonymous timeline identity", () => {
333
- it("adds root-level untimed DOM layers as implicit full-duration layers", () => {
333
+ it("does not invent rows for root-level untimed wrappers", () => {
334
334
  const doc = createDocument(`
335
335
  <div data-composition-id="compare" data-start="0" data-duration="18">
336
- <link rel="stylesheet" href="styles.css" />
337
- <div class="scene-shell">
338
- <div class="topline">Title</div>
339
- </div>
336
+ <div class="scene-shell"><div class="topline">Title</div></div>
340
337
  <video id="main-video" class="clip main-video" data-start="0" data-duration="18" data-track-index="1"></video>
341
- <script></script>
342
338
  </div>
343
339
  `);
344
340
 
345
341
  const elements = parseTimelineFromDOM(doc, 18);
346
342
 
347
- expect(elements).toEqual(
348
- expect.arrayContaining([
349
- expect.objectContaining({
350
- duration: 18,
351
- label: "Scene Shell",
352
- selector: ".scene-shell",
353
- start: 0,
354
- tag: "div",
355
- timingSource: "implicit",
356
- }),
357
- ]),
358
- );
359
- expect(elements.find((element) => element.tag === "link")).toBeUndefined();
360
- expect(elements.find((element) => element.tag === "script")).toBeUndefined();
343
+ expect(elements.map((element) => element.tag)).toEqual(["video"]);
361
344
  });
362
345
 
363
346
  it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { selectTimelineRowElements } from "./useTimelineRowElements";
4
+
5
+ const el = (id: string, domId?: string): TimelineElement => ({
6
+ id,
7
+ domId,
8
+ start: 0,
9
+ duration: 1,
10
+ track: 0,
11
+ tag: "div",
12
+ });
13
+
14
+ describe("selectTimelineRowElements", () => {
15
+ it("drops an element the shared definition does not call top-level", () => {
16
+ const rows = selectTimelineRowElements([el("a", "a"), el("b", "b")], new Set(["a"]));
17
+ expect(rows.map((r) => r.id)).toEqual(["a"]);
18
+ });
19
+
20
+ it("keeps everything until the document has been read", () => {
21
+ const all = [el("a", "a"), el("b", "b")];
22
+ expect(selectTimelineRowElements(all, null)).toBe(all);
23
+ });
24
+
25
+ it("keeps an element with no DOM id", () => {
26
+ expect(selectTimelineRowElements([el("k")], new Set())).toHaveLength(1);
27
+ });
28
+ });
@@ -0,0 +1,17 @@
1
+ import { useMemo } from "react";
2
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
+
4
+ /** Rows are the ids `topLevelElements` names, as the lint does; id-less ones are kept. */
5
+ export function selectTimelineRowElements(
6
+ elements: TimelineElement[],
7
+ topLevelIds: ReadonlySet<string> | null,
8
+ ): TimelineElement[] {
9
+ if (!topLevelIds) return elements;
10
+ return elements.filter((el) => el.domId === undefined || topLevelIds.has(el.domId));
11
+ }
12
+
13
+ export function useTimelineRowElements(): TimelineElement[] {
14
+ const elements = usePlayerStore((s) => s.elements);
15
+ const topLevelIds = usePlayerStore((s) => s.topLevelIds);
16
+ return useMemo(() => selectTimelineRowElements(elements, topLevelIds), [elements, topLevelIds]);
17
+ }
@@ -0,0 +1,41 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import type { TimelineElement } from "../store/playerStore";
7
+ import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ describe("processTimelineMessage with an empty manifest", () => {
12
+ it("commits zero rows without adopting the runtime 1s floor as the duration", () => {
13
+ const sync = vi.fn<(els: TimelineElement[], duration?: number) => void>();
14
+ let processTimelineMessage: ReturnType<
15
+ typeof useTimelineSyncCallbacks
16
+ >["processTimelineMessage"];
17
+ function Harness() {
18
+ ({ processTimelineMessage } = useTimelineSyncCallbacks({
19
+ iframeRef: { current: null },
20
+ probeIntervalRef: { current: undefined },
21
+ pendingSeekRef: { current: null },
22
+ isRefreshingRef: { current: false },
23
+ getAdapter: () => null,
24
+ syncTimelineElements: sync,
25
+ setDuration: () => {},
26
+ setCurrentTime: () => {},
27
+ requestTimelineReady: () => {},
28
+ setIsPlaying: () => {},
29
+ attachIframeShortcutListeners: () => {},
30
+ applyPreviewAudioState: () => {},
31
+ }));
32
+ return null;
33
+ }
34
+ const host = document.createElement("div");
35
+ act(() => createRoot(host).render(<Harness />));
36
+
37
+ processTimelineMessage!({ clips: [], durationInFrames: 30 });
38
+
39
+ expect(sync).toHaveBeenCalledWith([], undefined);
40
+ });
41
+ });
@@ -17,7 +17,9 @@ import { buildMissingCompositionElements } from "../lib/timelineIframeHelpers";
17
17
  import { acceptedRuntimeMessageFps } from "../lib/runtimeProtocol";
18
18
  import {
19
19
  buildTimelineElementsFromClips,
20
+ syncManifestTimeline,
20
21
  clipTreeParentMap,
22
+ collectTopLevelElementIds,
21
23
  collectSubCompositionDomChildren,
22
24
  collectSubCompositionHostState,
23
25
  hydrateTimelineFromPreview,
@@ -26,7 +28,6 @@ import {
26
28
  sanitizeDurationSeconds,
27
29
  seekAdapterToRestorePoint,
28
30
  syncAdapterDuration,
29
- withImplicitDomLayers,
30
31
  type RuntimeTimelineMessage,
31
32
  } from "./timelineSyncHydration";
32
33
 
@@ -114,9 +115,7 @@ export function useTimelineSyncCallbacks({
114
115
  // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
115
116
  const processTimelineMessage = useCallback(
116
117
  (data: RuntimeTimelineMessage) => {
117
- if (!data.clips || data.clips.length === 0) {
118
- return;
119
- }
118
+ if (!data.clips) return;
120
119
 
121
120
  usePlayerStore.getState().setClipManifest(data.clips);
122
121
 
@@ -131,11 +130,13 @@ export function useTimelineSyncCallbacks({
131
130
  const parentMap = clipTreeParentMap(iframeRef.current?.contentWindow ?? null);
132
131
  const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap);
133
132
  usePlayerStore.getState().setClipParentMap(parentMap);
133
+ usePlayerStore.getState().setTopLevelIds(collectTopLevelElementIds(iframeDoc));
134
134
  usePlayerStore.getState().setDomClipChildren(domClipChildren);
135
135
  usePlayerStore
136
136
  .getState()
137
137
  .setSubCompositionHostState(collectSubCompositionHostState(iframeDoc, data.clips));
138
138
  } catch {
139
+ usePlayerStore.getState().setTopLevelIds(null);
139
140
  // cross-origin or __clipTree not available — maps stay empty
140
141
  }
141
142
 
@@ -150,14 +151,12 @@ export function useTimelineSyncCallbacks({
150
151
  manifestDurationSeconds: data.durationInFrames / acceptedRuntimeMessageFps(data),
151
152
  authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
152
153
  });
153
- const timelineEls = withImplicitDomLayers(
154
+ syncManifestTimeline(
154
155
  els,
155
- iframeDoc,
156
- newDuration > 0 ? newDuration : usePlayerStore.getState().duration,
156
+ newDuration,
157
+ usePlayerStore.getState().duration,
158
+ syncTimelineElements,
157
159
  );
158
- if (timelineEls.length > 0) {
159
- syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
160
- }
161
160
  },
162
161
  [iframeRef, syncTimelineElements],
163
162
  );
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/timelineElement";
3
+ import { describeClips, formatTimelineBlock } from "./describeClips";
4
+
5
+ function audio(id: string, start: number, duration: number, extra: Partial<TimelineElement> = {}) {
6
+ return {
7
+ id,
8
+ domId: id,
9
+ tag: "audio",
10
+ kind: "audio",
11
+ start,
12
+ duration,
13
+ track: 0,
14
+ ...extra,
15
+ } satisfies TimelineElement;
16
+ }
17
+
18
+ // Shaped like claude-paper-launch: two voiceovers, clicks, and dozens of keystroke sounds, each on its own track.
19
+ function paperLaunchClips(): TimelineElement[] {
20
+ const clips = [
21
+ audio("vo", 29.6, 2.17, { src: "voiceover.mp3", authoredTrack: 8, volume: 0.009772 }),
22
+ audio("sfx-click-0", 1.6, 0.07, { src: "click.mp3", authoredTrack: 100, volume: 0.85 }),
23
+ audio("sfx-type-0", 7.8, 0.57, { src: "typenew.mp3", authoredTrack: 187, volume: 0.2 }),
24
+ ];
25
+ for (let i = 1; i <= 84; i++) {
26
+ clips.push(audio(`sfx-extra-${i}`, 8 + i * 0.1, 0.5, { authoredTrack: 200 + i, volume: 0.2 }));
27
+ }
28
+ return clips;
29
+ }
30
+
31
+ describe("formatTimelineBlock", () => {
32
+ it("lists every audio clip with its start and volume", () => {
33
+ const block = formatTimelineBlock(paperLaunchClips());
34
+ const audioLines = block.split("\n").filter((line) => line.startsWith("- audio "));
35
+ expect(audioLines).toHaveLength(87);
36
+ expect(
37
+ audioLines.every((line) => /start=[\d.]+/.test(line) && /volume=[\d.]+/.test(line)),
38
+ ).toBe(true);
39
+ expect(block).toContain(
40
+ '- audio "sfx-click-0" src=click.mp3 start=1.6 duration=0.07 end=1.67 track=100 volume=0.85',
41
+ );
42
+ expect(block).toContain(
43
+ '- audio "vo" src=voiceover.mp3 start=29.6 duration=2.17 end=31.77 track=8 volume=0.01',
44
+ );
45
+ });
46
+
47
+ it("orders clips by start time", () => {
48
+ const lines = formatTimelineBlock(paperLaunchClips()).split("\n");
49
+ expect(lines[1]).toContain('"sfx-click-0"');
50
+ });
51
+
52
+ it("prints volume automation points, rate and group", () => {
53
+ const block = formatTimelineBlock([
54
+ audio("bed", 8, 20, {
55
+ automation:
56
+ '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":0.1},{"t":10,"v":0.5}]}]}',
57
+ playbackRate: 0.5,
58
+ audioGroup: "music",
59
+ timelineRole: "music",
60
+ }),
61
+ ]);
62
+ expect(block).toContain("rate=0.5 group=music role=music volume-lane=[0:0.1, 10:0.5]");
63
+ });
64
+
65
+ it("prints volume 0 and a project-relative src, and omits rate at normal speed", () => {
66
+ const block = formatTimelineBlock([
67
+ audio("mute", 0, 1, {
68
+ src: "http://localhost:5190/api/projects/p1/preview/assets/a.mp3",
69
+ volume: 0,
70
+ playbackRate: 1,
71
+ }),
72
+ ]);
73
+ expect(block).toContain(
74
+ '- audio "mute" src=assets/a.mp3 start=0 duration=1 end=1 track=0 volume=0',
75
+ );
76
+ expect(block).not.toContain("rate=");
77
+ });
78
+
79
+ it("is empty for an empty timeline and caps long ones", () => {
80
+ expect(formatTimelineBlock([])).toBe("");
81
+ const many = Array.from({ length: 250 }, (_, i) => audio(`a${i}`, i, 1));
82
+ expect(formatTimelineBlock(many)).toContain("(50 more clips not listed)");
83
+ });
84
+ });
85
+
86
+ describe("describeClips", () => {
87
+ it("keeps 'not authored' distinct from zero for volume and rate", () => {
88
+ const [plain, silent] = describeClips([
89
+ audio("a", 0, 1),
90
+ audio("b", 1, 1, { volume: 0, playbackRate: 0 }),
91
+ ]);
92
+ expect([plain?.volume, plain?.playbackRate]).toEqual([null, null]);
93
+ expect([silent?.volume, silent?.playbackRate]).toEqual([0, 0]);
94
+ });
95
+ });