@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.
- package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-Cn_PpHqP.js} +3 -3
- package/dist/assets/index-C05SNrvY.js +360 -0
- package/dist/assets/{index-mIgSsKqs.js → index-CPS3Cvj8.js} +1 -1
- package/dist/assets/{index-BGovDksE.js → index-Clrb11lj.js} +1 -1
- package/dist/assets/index-DUR9qzQM.css +1 -0
- package/dist/index.d.ts +29 -16
- package/dist/index.html +2 -2
- package/dist/index.js +7283 -8606
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +11 -12
- package/src/App.tsx +132 -143
- package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
- package/src/captions/components/CaptionOverlayUtils.ts +2 -3
- package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
- package/src/captions/components/CaptionTimeline.tsx +1 -1
- package/src/captions/components/shared.tsx +2 -2
- package/src/components/AskAgentModal.tsx +5 -5
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +28 -7
- package/src/components/ExternalFileConflictBanner.tsx +16 -16
- package/src/components/LintModal.tsx +6 -16
- package/src/components/MediaPreview.tsx +2 -2
- package/src/components/SaveQueuePausedBanner.tsx +2 -2
- package/src/components/StudioHeader.tsx +2 -62
- package/src/components/StudioLeftSidebar.tsx +7 -4
- package/src/components/StudioOverlays.tsx +2 -6
- package/src/components/StudioRightPanel.tsx +4 -4
- package/src/components/StudioToast.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +7 -7
- package/src/components/dock/Dock.test.tsx +192 -0
- package/src/components/dock/Dock.tsx +264 -0
- package/src/components/dock/dock.css +23 -0
- package/src/components/dock/dockLayout.ts +58 -0
- package/src/components/dock/dockLayoutSchema.ts +66 -0
- package/src/components/dock/dockLayoutStore.ts +91 -0
- package/src/components/dock/panelRegistry.ts +67 -0
- package/src/components/editor/AnimationCard.tsx +4 -4
- package/src/components/editor/AnimationCardParts.tsx +6 -6
- package/src/components/editor/BlockParamsPanel.tsx +4 -4
- package/src/components/editor/BorderRadiusEditor.tsx +1 -1
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ComputedTweenNotice.tsx +1 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -1
- package/src/components/editor/EaseCurveSection.tsx +1 -1
- package/src/components/editor/EaseParamFields.tsx +5 -5
- package/src/components/editor/FileTree.tsx +4 -4
- package/src/components/editor/FileTreeIcons.tsx +1 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -13
- package/src/components/editor/InlineTextToolbar.tsx +1 -1
- package/src/components/editor/InspectorHeaderActions.tsx +3 -3
- package/src/components/editor/KeyframeDiamond.tsx +1 -1
- package/src/components/editor/KeyframeEaseList.tsx +1 -1
- package/src/components/editor/LayersPanel.tsx +3 -3
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/OffCanvasIndicators.tsx +1 -1
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/PromotableControl.tsx +2 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
- package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +27 -3
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/TopologyLens.tsx +4 -4
- package/src/components/editor/Transform3DCube.tsx +2 -2
- package/src/components/editor/audioFxRevealTarget.ts +1 -0
- package/src/components/editor/domEditing.test.ts +1 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
- package/src/components/editor/domEditingAgentPrompt.ts +9 -3
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/editor/propertyPanelColor.tsx +11 -11
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
- package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
- package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +1 -1
- package/src/components/editor/propertyPanelFill.tsx +4 -4
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
- package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
- package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
- package/src/components/editor/propertyPanelFont.tsx +5 -5
- package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.tsx +6 -6
- package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
- package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
- package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
- package/src/components/editor/propertyPanelSections.tsx +8 -8
- package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
- package/src/components/feedback/feedbackTrigger.ts +0 -1
- package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +12 -7
- package/src/components/nle/PreviewOverlays.tsx +6 -6
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +4 -2
- package/src/components/nle/TimelinePane.tsx +17 -3
- package/src/components/nle/TimelineResizeDivider.tsx +2 -2
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/components/panels/SlideshowPanel.tsx +2 -2
- package/src/components/panels/SlideshowSubPanels.tsx +20 -17
- package/src/components/panels/VariablesBindElement.tsx +2 -2
- package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
- package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/panels/VariablesValueControls.tsx +2 -2
- package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
- package/src/components/renders/RenderQueue.test.tsx +7 -0
- package/src/components/renders/RenderQueue.tsx +6 -6
- package/src/components/renders/RenderQueueItem.tsx +6 -6
- package/src/components/sidebar/AssetCard.tsx +8 -8
- package/src/components/sidebar/AssetContextMenu.tsx +8 -8
- package/src/components/sidebar/AssetsTab.tsx +2 -2
- package/src/components/sidebar/AudioRow.tsx +6 -6
- package/src/components/sidebar/BlocksTab.tsx +6 -6
- package/src/components/sidebar/CompositionsTab.tsx +5 -5
- package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
- package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
- package/src/components/sidebar/LeftSidebar.tsx +19 -5
- package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
- package/src/components/ui/Button.test.tsx +60 -0
- package/src/components/ui/Button.tsx +4 -4
- package/src/components/ui/SearchInput.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +1 -1
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -1
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useLintModal.test.tsx +65 -0
- package/src/hooks/useLintModal.ts +3 -0
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
- package/src/hooks/useSdkSession.ts +43 -10
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
- package/src/hooks/useTimelineAssetDropOps.ts +52 -11
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/main.tsx +9 -3
- package/src/player/components/AudioWaveform.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +2 -2
- package/src/player/components/ClipContextMenu.tsx +90 -10
- package/src/player/components/CompositionThumbnail.test.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +3 -4
- package/src/player/components/EditModal.tsx +1 -1
- package/src/player/components/ImageThumbnail.tsx +1 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
- package/src/player/components/LayerDisclosureRow.tsx +2 -2
- package/src/player/components/PlayerControls.tsx +1 -1
- package/src/player/components/ShortcutsPanel.tsx +9 -9
- package/src/player/components/SpeedMenu.tsx +3 -5
- package/src/player/components/Timeline.tsx +16 -15
- package/src/player/components/TimelineCanvas.tsx +45 -8
- package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
- package/src/player/components/TimelineEmptyState.tsx +5 -5
- package/src/player/components/TimelineFxButton.tsx +6 -6
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +5 -5
- package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +54 -1
- package/src/player/components/TimelineLanes.tsx +2 -5
- package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineRuler.tsx +1 -1
- package/src/player/components/TimelineShortcutHint.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
- package/src/player/components/TimelineTypes.ts +6 -1
- package/src/player/components/TrackGapContextMenu.tsx +1 -1
- package/src/player/components/VideoThumbnail.tsx +4 -2
- package/src/player/components/VolumeControl.tsx +2 -2
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineCallbacks.ts +8 -8
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.ts +2 -2
- package/src/player/components/timelineClipDragPreview.ts +51 -27
- package/src/player/components/timelineClipDragTypes.ts +3 -0
- package/src/player/components/timelineDragDrop.test.tsx +92 -1
- package/src/player/components/timelineDragDrop.ts +82 -33
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineSnapping.test.ts +27 -0
- package/src/player/components/timelineSnapping.ts +14 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/trackHeaderLabelRows.tsx +4 -4
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
- package/src/player/components/useTimelineClipDrag.ts +30 -3
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +52 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +2 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/styles/iconTokens.ts +20 -0
- package/src/styles/palette-reset.css +6 -0
- package/src/styles/studio.css +24 -21
- package/src/styles/tailwind-preset.shared.js +3 -0
- package/src/styles/theme.css +213 -0
- package/src/styles/theme.test.ts +196 -0
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +34 -6
- package/src/utils/timelineDropTrackInsert.test.ts +134 -0
- package/src/utils/timelineDropTrackInsert.ts +83 -0
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/dist/assets/index-Bgmx2h7W.js +0 -441
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
- package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
- package/src/components/storyboard/FramePoster.test.tsx +0 -61
- package/src/components/storyboard/FramePoster.tsx +0 -86
- package/src/components/storyboard/StoryboardDirection.tsx +0 -45
- package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
- package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
- package/src/components/storyboard/StoryboardGrid.tsx +0 -55
- package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
- package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
- package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
- package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
- package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
- package/src/components/storyboard/StoryboardView.tsx +0 -169
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
- package/src/components/storyboard/frameComments.test.ts +0 -95
- package/src/components/storyboard/frameComments.ts +0 -115
- package/src/components/storyboard/frameStatus.ts +0 -36
- package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
- package/src/components/storyboard/storyboardReviewStage.ts +0 -45
- package/src/components/storyboard/useFrameComments.ts +0 -98
- package/src/contexts/ViewModeContext.tsx +0 -142
- package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
- package/src/hooks/useProjectSignaturePoll.ts +0 -68
- package/src/hooks/useStoryboard.ts +0 -99
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- 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: {
|
|
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:
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
-
// (`
|
|
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("
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
154
|
+
syncManifestTimeline(
|
|
154
155
|
els,
|
|
155
|
-
|
|
156
|
-
|
|
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
|
+
});
|