@hyperframes/studio 0.8.50 → 0.8.53-desktop.0
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-CxxDxHTW.js → hyperframes-player-PTEnAHhg.js} +3 -3
- package/dist/assets/{index-DUAIWxkI.js → index--myXp8ui.js} +1 -1
- package/dist/assets/{index-Dt3qBI-9.js → index-BjslOS9g.js} +1 -1
- package/dist/assets/index-BnXZuIJD.css +1 -0
- package/dist/assets/index-CNyviK_l.js +390 -0
- package/dist/index.css +29 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +825 -24
- package/dist/index.html +2 -2
- package/dist/index.js +8911 -9853
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +13 -12
- package/src/App.tsx +145 -163
- package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
- package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
- package/src/captions/components/CaptionOverlay.tsx +8 -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 +9 -4
- package/src/components/EditorShell.tsx +59 -62
- package/src/components/ExternalFileConflictBanner.tsx +16 -16
- package/src/components/LintModal.tsx +6 -16
- package/src/components/MediaPreview.tsx +2 -2
- package/src/components/RightPanelTabs.test.tsx +151 -0
- package/src/components/RightPanelTabs.tsx +59 -0
- package/src/components/SaveQueuePausedBanner.tsx +2 -2
- package/src/components/StudioHeader.dom.test.tsx +181 -0
- package/src/components/StudioHeader.test.ts +0 -12
- package/src/components/StudioHeader.tsx +118 -221
- package/src/components/StudioLeftPanels.tsx +202 -0
- package/src/components/StudioOverlays.tsx +1 -1
- package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -194
- package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
- package/src/components/StudioToast.tsx +2 -2
- package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
- package/src/components/TimelineHistoryButtons.tsx +54 -0
- package/src/components/TimelineToolbar.test.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +8 -12
- package/src/components/dock/Dock.test.tsx +274 -0
- package/src/components/dock/Dock.tsx +291 -0
- package/src/components/dock/dock.css +28 -0
- package/src/components/dock/dockAccessibility.test.ts +166 -0
- package/src/components/dock/dockAccessibility.ts +98 -0
- package/src/components/dock/dockLayout.test.ts +134 -0
- package/src/components/dock/dockLayout.ts +105 -0
- package/src/components/dock/dockLayoutSchema.ts +66 -0
- package/src/components/dock/dockLayoutStore.test.ts +28 -0
- package/src/components/dock/dockLayoutStore.ts +108 -0
- package/src/components/dock/dockTestHarness.ts +11 -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.test.ts +97 -1
- package/src/components/editor/LayersPanel.tsx +7 -5
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/OffCanvasIndicators.tsx +1 -1
- package/src/components/editor/PreviewGuides.tsx +1 -1
- 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 +0 -3
- package/src/components/editor/SnapToolbar.tsx +2 -2
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/TopologyLens.test.tsx +28 -0
- package/src/components/editor/TopologyLens.tsx +7 -5
- 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 +4 -3
- 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/editor/useColorGradingController.test.ts +38 -0
- package/src/components/editor/useColorGradingController.ts +9 -1
- package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
- package/src/components/editor/useColorGradingPreviews.ts +11 -1
- package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
- package/src/components/editor/usePreviewCompositionRect.ts +21 -0
- 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/NLEContext.tsx +0 -43
- package/src/components/nle/NLEPreview.tsx +3 -3
- package/src/components/nle/PreviewOverlays.tsx +6 -6
- package/src/components/nle/PreviewPane.tsx +3 -3
- package/src/components/nle/TimelinePane.tsx +53 -67
- package/src/components/panels/SlideshowPanel.tsx +3 -3
- 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 +4 -4
- package/src/components/panels/VariablesValueControls.tsx +2 -2
- package/src/components/pointerTargetSize.test.tsx +8 -0
- package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
- package/src/components/renders/RenderQueue.test.tsx +7 -0
- package/src/components/renders/RenderQueue.tsx +7 -7
- package/src/components/renders/RenderQueueItem.tsx +6 -6
- package/src/components/renders/RenderQueuePanel.tsx +1 -1
- 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/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
- package/src/components/sidebar/CompositionsPanel.tsx +15 -0
- package/src/components/sidebar/CompositionsTab.tsx +5 -5
- package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
- package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
- package/src/components/sidebar/SidebarLintButton.test.tsx +53 -0
- package/src/components/sidebar/SidebarLintButton.tsx +50 -0
- package/src/components/timelineToolbarStyles.ts +5 -0
- package/src/components/ui/Button.test.tsx +252 -0
- package/src/components/ui/Button.tsx +89 -126
- package/src/components/ui/IconButton.tsx +38 -0
- package/src/components/ui/Input.tsx +115 -0
- package/src/components/ui/Menu.test.tsx +374 -0
- package/src/components/ui/Menu.tsx +213 -0
- package/src/components/ui/NumberField.tsx +122 -0
- package/src/components/ui/Popover.tsx +74 -0
- package/src/components/ui/SearchInput.tsx +2 -2
- package/src/components/ui/Select.tsx +100 -0
- package/src/components/ui/Slider.tsx +153 -0
- package/src/components/ui/Tabs.test.tsx +98 -0
- package/src/components/ui/Tabs.tsx +71 -0
- package/src/components/ui/Toggle.tsx +63 -0
- package/src/components/ui/Tooltip.test.tsx +110 -0
- package/src/components/ui/Tooltip.tsx +38 -112
- package/src/components/ui/cn.test.ts +30 -0
- package/src/components/ui/cn.ts +25 -0
- package/src/components/ui/index.ts +29 -2
- package/src/components/ui/mountHost.testHelpers.ts +24 -0
- package/src/components/ui/valueControls.test.tsx +524 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -57
- package/src/hooks/appHotkeysDispatch.ts +3 -4
- package/src/hooks/timelineEditPermission.ts +31 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
- package/src/hooks/useAppHotkeys.test.ts +0 -1
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
- package/src/hooks/useAppHotkeys.ts +21 -84
- package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useDomEditPreviewSync.ts +4 -6
- package/src/hooks/useDomEditSession.ts +0 -7
- package/src/hooks/useDomEditWiring.ts +4 -9
- package/src/hooks/useEditHistoryActions.test.tsx +90 -0
- package/src/hooks/useEditHistoryActions.ts +106 -0
- package/src/hooks/useFileManager.ts +21 -123
- package/src/hooks/useLintModal.test.tsx +65 -0
- package/src/hooks/useLintModal.ts +3 -0
- package/src/hooks/usePanelLayout.test.ts +108 -296
- package/src/hooks/usePanelLayout.ts +50 -255
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useProjectFileWriter.ts +150 -0
- package/src/hooks/useRightPanelIntents.test.ts +181 -0
- package/src/hooks/useRightPanelIntents.ts +63 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
- package/src/hooks/useSdkSession.ts +43 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
- package/src/hooks/useServerConnection.ts +43 -10
- package/src/hooks/useSlideshowTabState.test.ts +9 -31
- package/src/hooks/useSlideshowTabState.ts +6 -19
- package/src/hooks/useStudioContextValue.test.ts +6 -18
- package/src/hooks/useStudioContextValue.ts +5 -26
- package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
- package/src/hooks/useTimelineAssetDropOps.ts +52 -11
- package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
- package/src/hooks/useTimelineEditing.test.tsx +322 -3
- package/src/hooks/useTimelineEditing.ts +103 -16
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
- package/src/hostMount.test.tsx +222 -0
- package/src/index.ts +87 -0
- package/src/main.tsx +9 -3
- package/src/player/components/AudioWaveform.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +3 -3
- package/src/player/components/ClipContextMenu.tsx +3 -3
- 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 +3 -2
- package/src/player/components/TimelineCanvas.tsx +21 -14
- package/src/player/components/TimelineClip.tsx +0 -1
- 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/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 +32 -0
- package/src/player/components/TimelineLanes.tsx +2 -5
- package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +1 -1
- package/src/player/components/TimelineShortcutHint.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +2 -2
- package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
- package/src/player/components/TimelineTypes.ts +2 -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/timelineCallbacks.ts +8 -8
- package/src/player/components/timelineClipDragPreview.ts +51 -27
- package/src/player/components/timelineClipDragTypes.ts +3 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -1
- package/src/player/components/timelineDragDrop.ts +57 -15
- 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/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +30 -30
- package/src/player/components/trackHeaderLabelRows.tsx +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
- package/src/player/components/useTimelineClipDrag.ts +30 -3
- package/src/player/lib/describeClips.ts +3 -54
- package/src/player/lib/playbackShortcuts.ts +2 -0
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
- package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
- package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
- package/src/player/store/previewIframeStore.test.tsx +29 -0
- package/src/player/store/previewIframeStore.ts +25 -0
- package/src/renderQueueExports.test.tsx +58 -0
- package/src/styles/classCandidates.test.ts +105 -0
- package/src/styles/classCandidates.ts +281 -0
- package/src/styles/hex-baseline.json +84 -0
- package/src/styles/hexRatchet.test.ts +201 -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/styleSources.test.ts +20 -0
- package/src/styles/styleSources.ts +47 -0
- package/src/styles/tailwind-preset.shared.js +3 -0
- package/src/styles/theme.css +270 -0
- package/src/styles/theme.test.ts +183 -0
- package/src/styles/tokenGate.test.ts +109 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +11 -8
- package/src/utils/studioUiPreferences.test.ts +10 -16
- package/src/utils/studioUiPreferences.ts +7 -16
- package/src/utils/timelineDropTrackInsert.test.ts +134 -0
- package/src/utils/timelineDropTrackInsert.ts +83 -0
- package/src/utils/typingTarget.ts +4 -0
- package/dist/assets/index-n2ml4tUn.js +0 -442
- package/dist/assets/index-pO6p0H27.css +0 -1
- package/src/components/PanelTabButton.tsx +0 -31
- package/src/components/StudioLeftSidebar.tsx +0 -204
- package/src/components/nle/TimelineResizeDivider.tsx +0 -101
- package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
- package/src/components/sidebar/LeftSidebar.tsx +0 -334
- 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/useInspectorSplitResize.ts +0 -51
- package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
- package/src/hooks/useProjectSignaturePoll.ts +0 -68
- package/src/hooks/useStoryboard.ts +0 -99
- package/src/utils/fitPanels.test.ts +0 -164
- package/src/utils/fitPanels.ts +0 -151
|
@@ -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;
|
|
@@ -1,34 +1,9 @@
|
|
|
1
1
|
/** Plain-field clip facts from the player store; feeds the Ask-agent prompt and `studio_look`. */
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { elementAutomationLanes } from "../components/automationLaneData";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
/** `volume`, or `fx.<nodeId>.<param>`. */
|
|
8
|
-
target: string;
|
|
9
|
-
/** `t` is seconds from the start of the clip, not the composition. */
|
|
10
|
-
points: { t: number; v: number }[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ClipFact {
|
|
14
|
-
id: string;
|
|
15
|
-
label: string | null;
|
|
16
|
-
kind: string;
|
|
17
|
-
start: number;
|
|
18
|
-
duration: number;
|
|
19
|
-
end: number;
|
|
20
|
-
/** The `data-track-index` as written in the source file. */
|
|
21
|
-
trackIndex: number;
|
|
22
|
-
src: string | null;
|
|
23
|
-
sourceFile: string | null;
|
|
24
|
-
/** `null` when `data-volume` is not authored (the clip plays at 1). */
|
|
25
|
-
volume: number | null;
|
|
26
|
-
lanes: ClipLane[];
|
|
27
|
-
/** `null` at normal speed: not authored, or 1 (the manifest defaults it to 1). */
|
|
28
|
-
playbackRate: number | null;
|
|
29
|
-
audioGroup: string | null;
|
|
30
|
-
role: string | null;
|
|
31
|
-
}
|
|
4
|
+
import { byStart, formatClipLine, type ClipFact } from "@hyperframes/core/clip-facts";
|
|
5
|
+
export { byStart } from "@hyperframes/core/clip-facts";
|
|
6
|
+
export type { ClipFact } from "@hyperframes/core/clip-facts";
|
|
32
7
|
|
|
33
8
|
/** The store holds preview URLs; the agent edits project files, so drop the origin and preview prefix. */
|
|
34
9
|
function projectRelativeSrc(src: string): string {
|
|
@@ -57,36 +32,10 @@ export function describeClip(element: TimelineElement): ClipFact {
|
|
|
57
32
|
};
|
|
58
33
|
}
|
|
59
34
|
|
|
60
|
-
export const byStart = (a: ClipFact, b: ClipFact) =>
|
|
61
|
-
a.start - b.start || a.trackIndex - b.trackIndex;
|
|
62
|
-
|
|
63
35
|
export function describeClips(elements: readonly TimelineElement[]): ClipFact[] {
|
|
64
36
|
return elements.map(describeClip).sort(byStart);
|
|
65
37
|
}
|
|
66
38
|
|
|
67
|
-
const num = (n: number) => String(roundTo3(n));
|
|
68
|
-
|
|
69
|
-
function formatClipLine(clip: ClipFact): string {
|
|
70
|
-
const parts = [
|
|
71
|
-
`${clip.kind} "${clip.id}"`,
|
|
72
|
-
clip.src && `src=${clip.src}`,
|
|
73
|
-
`start=${num(clip.start)}`,
|
|
74
|
-
`duration=${num(clip.duration)}`,
|
|
75
|
-
`end=${num(clip.end)}`,
|
|
76
|
-
`track=${clip.trackIndex}`,
|
|
77
|
-
clip.volume !== null && `volume=${num(clip.volume)}`,
|
|
78
|
-
clip.playbackRate !== null && `rate=${num(clip.playbackRate)}`,
|
|
79
|
-
clip.audioGroup && `group=${clip.audioGroup}`,
|
|
80
|
-
clip.role && `role=${clip.role}`,
|
|
81
|
-
clip.sourceFile && `file=${clip.sourceFile}`,
|
|
82
|
-
...clip.lanes.map(
|
|
83
|
-
(lane) =>
|
|
84
|
-
`${lane.target}-lane=[${lane.points.map((p) => `${num(p.t)}:${num(p.v)}`).join(", ")}]`,
|
|
85
|
-
),
|
|
86
|
-
];
|
|
87
|
-
return `- ${parts.filter(Boolean).join(" ")}`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
39
|
const PROMPT_CLIP_CAP = 200;
|
|
91
40
|
|
|
92
41
|
/** The Ask-agent prompt's Timeline block; empty when the timeline has no clips. */
|
|
@@ -17,6 +17,8 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
|
|
17
17
|
"[role='checkbox']",
|
|
18
18
|
"[role='combobox']",
|
|
19
19
|
"[role='menuitem']",
|
|
20
|
+
// Base UI's menu radio item is a `<div>`, so `button` above no longer catches it.
|
|
21
|
+
"[role='menuitemradio']",
|
|
20
22
|
"[role='radio']",
|
|
21
23
|
"[role='slider']",
|
|
22
24
|
"[role='spinbutton']",
|
|
@@ -14,7 +14,7 @@ describe("timeline performance diagnostics", () => {
|
|
|
14
14
|
|
|
15
15
|
it("reads mounted resources without mutating the timeline", () => {
|
|
16
16
|
document.body.innerHTML = `
|
|
17
|
-
<div aria-label="Timeline" data-timeline-scheduler-queued="3"
|
|
17
|
+
<div aria-label="Timeline track view" data-timeline-scheduler-queued="3"
|
|
18
18
|
data-timeline-scheduler-active="2" data-timeline-cache-bytes="4096">
|
|
19
19
|
<div data-timeline-row><div data-clip="true"></div><div data-clip="true"></div></div>
|
|
20
20
|
<div data-timeline-row><div data-clip="true"></div></div>
|
|
@@ -41,7 +41,8 @@ describe("timeline performance diagnostics", () => {
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
it("returns the zero baseline after unmount or reset removes the DOM", () => {
|
|
44
|
-
document.body.innerHTML =
|
|
44
|
+
document.body.innerHTML =
|
|
45
|
+
'<div aria-label="Timeline track view"><div data-clip="true"></div></div>';
|
|
45
46
|
expect(readTimelinePerformanceDiagnostics().mountedClipRoots).toBe(1);
|
|
46
47
|
|
|
47
48
|
document.body.replaceChildren();
|
|
@@ -69,7 +69,7 @@ function sumDataAttribute(root: ParentNode, selector: string, dataKey: string):
|
|
|
69
69
|
export function readTimelinePerformanceDiagnostics(
|
|
70
70
|
root: ParentNode = document,
|
|
71
71
|
): Readonly<TimelinePerformanceDiagnostics> {
|
|
72
|
-
const timelineRoots = root.querySelectorAll<HTMLElement>('[aria-label="Timeline"]');
|
|
72
|
+
const timelineRoots = root.querySelectorAll<HTMLElement>('[aria-label="Timeline track view"]');
|
|
73
73
|
let mountedTimelineDescendants = 0;
|
|
74
74
|
for (const timelineRoot of timelineRoots) {
|
|
75
75
|
mountedTimelineDescendants += timelineRoot.querySelectorAll("*").length;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, StrictMode, useCallback, useEffect, useRef } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import { useOwnPreviewIframe, usePreviewIframeStore } from "./previewIframeStore";
|
|
6
|
+
|
|
7
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
8
|
+
|
|
9
|
+
// Mirrors NLEContext.tsx: a ref callback pushes the element on attach
|
|
10
|
+
// (~L126, onIframeLoad) and a separate mount/dep effect re-pushes the SAME
|
|
11
|
+
// ref value (~L304-306, deps include an unrelated counter like refreshKey).
|
|
12
|
+
function Child({ tick }: { tick: number }) {
|
|
13
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
14
|
+
const setIframe = usePreviewIframeStore((s) => s.setIframe);
|
|
15
|
+
|
|
16
|
+
const handleRef = useCallback(
|
|
17
|
+
(el: HTMLIFrameElement | null) => {
|
|
18
|
+
iframeRef.current = el;
|
|
19
|
+
setIframe(el);
|
|
20
|
+
},
|
|
21
|
+
[setIframe],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
setIframe(iframeRef.current);
|
|
26
|
+
}, [tick, setIframe]);
|
|
27
|
+
|
|
28
|
+
return <iframe ref={handleRef} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Owner({ tick }: { tick: number }) {
|
|
32
|
+
useOwnPreviewIframe();
|
|
33
|
+
return <Child tick={tick} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it("does not notify raw subscribers for a repeated setIframe(sameValue)", () => {
|
|
37
|
+
let notifyCount = 0;
|
|
38
|
+
const unsubscribe = usePreviewIframeStore.subscribe(() => {
|
|
39
|
+
notifyCount += 1;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const container = document.createElement("div");
|
|
43
|
+
const root = createRoot(container);
|
|
44
|
+
act(() => {
|
|
45
|
+
root.render(
|
|
46
|
+
<StrictMode>
|
|
47
|
+
<Owner tick={0} />
|
|
48
|
+
</StrictMode>,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
// A dep bump on the mount effect with the iframe DOM node unchanged -
|
|
52
|
+
// the shape NLEContext hits every time refreshKey moves without a new
|
|
53
|
+
// <iframe>.
|
|
54
|
+
act(() => {
|
|
55
|
+
root.render(
|
|
56
|
+
<StrictMode>
|
|
57
|
+
<Owner tick={1} />
|
|
58
|
+
</StrictMode>,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const iframe = usePreviewIframeStore.getState().iframe;
|
|
63
|
+
expect(iframe).not.toBeNull();
|
|
64
|
+
|
|
65
|
+
// 3 real Object.is-distinct transitions happen here: the ref-callback's
|
|
66
|
+
// null -> element attach, then StrictMode's mount-effect replay drives
|
|
67
|
+
// useOwnPreviewIframe's unmount cleanup (element -> null) and the
|
|
68
|
+
// re-run mount effect (null -> element) once each. Measured on the
|
|
69
|
+
// unfixed store: 7 (four of those are the SAME value re-pushed by the
|
|
70
|
+
// ref callback's own redundant effect run and the tick-bump update).
|
|
71
|
+
expect(notifyCount).toBe(3);
|
|
72
|
+
|
|
73
|
+
act(() => root.unmount());
|
|
74
|
+
unsubscribe();
|
|
75
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import { useOwnPreviewIframe, usePreviewIframeStore } from "./previewIframeStore";
|
|
6
|
+
|
|
7
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
8
|
+
|
|
9
|
+
it("clears the store on owner unmount so a remount starts from null", () => {
|
|
10
|
+
const seen: Array<HTMLIFrameElement | null> = [];
|
|
11
|
+
const Owner = () => {
|
|
12
|
+
seen.push(useOwnPreviewIframe());
|
|
13
|
+
return null;
|
|
14
|
+
};
|
|
15
|
+
const iframe = document.createElement("iframe");
|
|
16
|
+
const first = createRoot(document.createElement("div"));
|
|
17
|
+
act(() => first.render(<Owner />));
|
|
18
|
+
act(() => usePreviewIframeStore.getState().setIframe(iframe));
|
|
19
|
+
expect(seen.at(-1)).toBe(iframe);
|
|
20
|
+
|
|
21
|
+
act(() => first.unmount());
|
|
22
|
+
expect(usePreviewIframeStore.getState().iframe).toBeNull();
|
|
23
|
+
|
|
24
|
+
seen.length = 0;
|
|
25
|
+
const second = createRoot(document.createElement("div"));
|
|
26
|
+
act(() => second.render(<Owner />));
|
|
27
|
+
expect(seen).toEqual([null]);
|
|
28
|
+
act(() => second.unmount());
|
|
29
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
|
|
4
|
+
interface PreviewIframeState {
|
|
5
|
+
iframe: HTMLIFrameElement | null;
|
|
6
|
+
setIframe: (iframe: HTMLIFrameElement | null) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The one owner of "the live preview iframe element"; a promoted reload replaces it. */
|
|
10
|
+
export const usePreviewIframeStore = create<PreviewIframeState>((set) => ({
|
|
11
|
+
iframe: null,
|
|
12
|
+
// Return the same state reference on a no-op set so zustand skips the
|
|
13
|
+
// notify (Object.is check in setState) instead of merging a new object
|
|
14
|
+
// every time a caller re-pushes the current value.
|
|
15
|
+
setIframe: (iframe) => set((state) => (Object.is(state.iframe, iframe) ? state : { iframe })),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
/** Subscribe an effect to the live iframe by listing the result in its dependencies. */
|
|
19
|
+
export const useLivePreviewIframe = () => usePreviewIframeStore((s) => s.iframe);
|
|
20
|
+
|
|
21
|
+
/** The owner's hook: reads the live iframe and clears it on unmount so a remount starts from null. */
|
|
22
|
+
export function useOwnPreviewIframe() {
|
|
23
|
+
useMountEffect(() => () => usePreviewIframeStore.getState().setIframe(null));
|
|
24
|
+
return useLivePreviewIframe();
|
|
25
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Imports the render queue the way a host app does: by package name, through the declared exports.
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
RenderQueue,
|
|
8
|
+
getPersistedRenderSettings,
|
|
9
|
+
persistRenderSettings,
|
|
10
|
+
useRenderQueue,
|
|
11
|
+
type FfmpegStatus,
|
|
12
|
+
type RenderJob,
|
|
13
|
+
} from "@hyperframes/studio";
|
|
14
|
+
|
|
15
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
16
|
+
|
|
17
|
+
describe("render queue package exports", () => {
|
|
18
|
+
it("exposes the hook and settings helpers", () => {
|
|
19
|
+
expect(typeof useRenderQueue).toBe("function");
|
|
20
|
+
persistRenderSettings("webm", "high", 30);
|
|
21
|
+
expect(getPersistedRenderSettings()).toMatchObject({
|
|
22
|
+
format: "webm",
|
|
23
|
+
quality: "high",
|
|
24
|
+
fps: 30,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("mounts RenderQueue with a host-supplied job", async () => {
|
|
29
|
+
const job: RenderJob = {
|
|
30
|
+
id: "j1",
|
|
31
|
+
status: "complete",
|
|
32
|
+
progress: 100,
|
|
33
|
+
filename: "host-render.mp4",
|
|
34
|
+
createdAt: 0,
|
|
35
|
+
};
|
|
36
|
+
const ffmpeg: FfmpegStatus = { ok: true };
|
|
37
|
+
const el = document.createElement("div");
|
|
38
|
+
document.body.append(el);
|
|
39
|
+
const root = createRoot(el);
|
|
40
|
+
await act(async () => {
|
|
41
|
+
root.render(
|
|
42
|
+
<RenderQueue
|
|
43
|
+
jobs={[job]}
|
|
44
|
+
projectId="p"
|
|
45
|
+
onDelete={vi.fn()}
|
|
46
|
+
onClearCompleted={vi.fn()}
|
|
47
|
+
onStartRender={vi.fn()}
|
|
48
|
+
isRendering={false}
|
|
49
|
+
ffmpeg={ffmpeg}
|
|
50
|
+
ffmpegChecking={false}
|
|
51
|
+
onRecheckFfmpeg={vi.fn()}
|
|
52
|
+
/>,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
expect(el.textContent).toContain("host-render.mp4");
|
|
56
|
+
await act(async () => root.unmount());
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { extractClassCandidates } from "./classCandidates";
|
|
3
|
+
|
|
4
|
+
function bases(source: string): string[] {
|
|
5
|
+
return extractClassCandidates(source)
|
|
6
|
+
.map((candidate) => candidate.base)
|
|
7
|
+
.sort();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("extractClassCandidates", () => {
|
|
11
|
+
it("reads a plain className attribute", () => {
|
|
12
|
+
expect(bases(`<div className="text-center border-dashed" />`)).toEqual([
|
|
13
|
+
"border-dashed",
|
|
14
|
+
"text-center",
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("reads every string a class-building call is given", () => {
|
|
19
|
+
const source = `<div className={cn("flex", open && "bg-surface", { "text-accent": on })} />`;
|
|
20
|
+
|
|
21
|
+
expect(bases(source)).toEqual(["bg-surface", "flex", "text-accent"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("reads a class map that never touches a className literal", () => {
|
|
25
|
+
// The shape Studio's primitives use: the utility only reaches markup
|
|
26
|
+
// through a lookup, so an attribute-only reader would miss it entirely.
|
|
27
|
+
const source = `const sizeStyles: Record<ButtonSize, string> = {
|
|
28
|
+
sm: "h-7 px-2.5 rounded-button",
|
|
29
|
+
};`;
|
|
30
|
+
|
|
31
|
+
expect(bases(source)).toEqual(["h-7", "px-2.5", "rounded-button"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("reads a class map whose name says nothing about classes", () => {
|
|
35
|
+
// The defect this replaced: candidate-ness was decided by the binding's
|
|
36
|
+
// name, so the same classes were gated in `sizeStyles` and invisible in
|
|
37
|
+
// `buttonSizes`. What makes them classes is where they are used.
|
|
38
|
+
const source = `const fooSizes = { md: "h-ctl rounded-hologram" };
|
|
39
|
+
const Foo = ({ size }) => <span className={cn(fooSizes[size])} />;`;
|
|
40
|
+
|
|
41
|
+
expect(bases(source)).toEqual(["h-ctl", "rounded-hologram"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("follows an identifier that contains a dollar sign", () => {
|
|
45
|
+
const source = `const $cls = "flex-none";\nconst a = <div className={$cls} />;`;
|
|
46
|
+
|
|
47
|
+
expect(bases(source)).toEqual(["flex-none"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not read a lookup key's default value as a class", () => {
|
|
51
|
+
// `variant` is the key, not the class list, so the default it falls back
|
|
52
|
+
// to is a map entry name and never reaches markup.
|
|
53
|
+
const source = `const map = { ghost: "bg-transparent" };
|
|
54
|
+
const Foo = ({ variant = "ghost" }) => <span className={map[variant]} />;`;
|
|
55
|
+
|
|
56
|
+
expect(bases(source)).toEqual(["bg-transparent"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("leaves a lookup table alone when nothing uses it as a class", () => {
|
|
60
|
+
const source = `const labels = { md: "Medium size" };
|
|
61
|
+
const Foo = () => <span title={labels.md} />;`;
|
|
62
|
+
|
|
63
|
+
expect(bases(source)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("drops the template chunk that touches an interpolation", () => {
|
|
67
|
+
expect(bases("<div className={`w-${i} flex`} />")).toEqual(["flex"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps a chunk that whitespace separates from the interpolation", () => {
|
|
71
|
+
expect(bases("<div className={`${base} flex `} />")).toEqual(["flex"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("strips variant prefixes, including bracketed ones", () => {
|
|
75
|
+
const source = `<div className="hover:bg-surface/50 md:group-data-[open]:text-accent" />`;
|
|
76
|
+
|
|
77
|
+
expect(bases(source)).toEqual(["bg-surface/50", "text-accent"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("flags bracketed arbitrary values so the sweep can count them", () => {
|
|
81
|
+
const found = extractClassCandidates(`<div className="text-[11px] text-center" />`);
|
|
82
|
+
|
|
83
|
+
expect(found.filter((candidate) => candidate.arbitrary).map((c) => c.raw)).toEqual([
|
|
84
|
+
"text-[11px]",
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("ignores strings that are not class lists", () => {
|
|
89
|
+
const source = `const styles = getComputedStyle(el);
|
|
90
|
+
const label = "Export video";
|
|
91
|
+
log("failed to open the project file");`;
|
|
92
|
+
|
|
93
|
+
expect(bases(source)).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not read past the end of a class region", () => {
|
|
97
|
+
const source = `<div className="flex" title="Export video" />`;
|
|
98
|
+
|
|
99
|
+
expect(bases(source)).toEqual(["flex"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("survives an unterminated region without hanging", () => {
|
|
103
|
+
expect(bases(`<div className={cn("flex",`)).toEqual(["flex"]);
|
|
104
|
+
});
|
|
105
|
+
});
|