@hyperframes/studio 0.7.55 → 0.7.57
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--Z69cEkE.js +459 -0
- package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/index-DfmYkU44.js +423 -0
- package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +172 -144
- package/dist/index.html +2 -2
- package/dist/index.js +43613 -40346
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +126 -125
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +53 -2
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +260 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +343 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +56 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +23 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +125 -1
- package/src/hooks/timelineEditingHelpers.ts +53 -292
- package/src/hooks/timelineMoveAdapter.test.ts +81 -0
- package/src/hooks/timelineMoveAdapter.ts +39 -0
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +245 -1
- package/src/hooks/useDomEditCommits.ts +162 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
- package/src/hooks/useElementLifecycleOps.ts +53 -71
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +148 -18
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +398 -241
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +167 -132
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +55 -5
- package/src/player/components/Timeline.test.ts +183 -23
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +218 -523
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
- package/src/player/components/timelineClipDragCommit.ts +498 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +42 -2
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +207 -75
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
- package/src/player/components/useTimelineStackingSync.ts +94 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +52 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +5 -2
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +21 -81
- package/src/player/lib/timelineElementHelpers.ts +73 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +99 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +67 -29
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
import { computeThumbnailStrip } from "./thumbnailUtils";
|
|
4
|
+
|
|
5
|
+
interface ImageThumbnailProps {
|
|
6
|
+
imageSrc: string;
|
|
7
|
+
label: string;
|
|
8
|
+
labelColor: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Renders a film-strip of a still image for a timeline clip. The image is a
|
|
13
|
+
* fixed-width tile (sized by its natural aspect ratio) repeated to fill the
|
|
14
|
+
* clip width — matching VideoThumbnail's visual pattern. Loading is lazy
|
|
15
|
+
* (IntersectionObserver) with the same shimmer fallback while decoding.
|
|
16
|
+
*/
|
|
17
|
+
export const ImageThumbnail = memo(function ImageThumbnail({
|
|
18
|
+
imageSrc,
|
|
19
|
+
label,
|
|
20
|
+
labelColor,
|
|
21
|
+
}: ImageThumbnailProps) {
|
|
22
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
23
|
+
const [visible, setVisible] = useState(false);
|
|
24
|
+
const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
|
|
25
|
+
const [aspect, setAspect] = useState(16 / 9);
|
|
26
|
+
const ioRef = useRef<IntersectionObserver | null>(null);
|
|
27
|
+
const roRef = useRef<ResizeObserver | null>(null);
|
|
28
|
+
|
|
29
|
+
const setContainerRef = useCallback((el: HTMLDivElement | null) => {
|
|
30
|
+
ioRef.current?.disconnect();
|
|
31
|
+
roRef.current?.disconnect();
|
|
32
|
+
if (!el) return;
|
|
33
|
+
|
|
34
|
+
const measured = el.parentElement?.clientWidth || el.clientWidth;
|
|
35
|
+
setContainerWidth(measured);
|
|
36
|
+
|
|
37
|
+
ioRef.current = new IntersectionObserver(
|
|
38
|
+
([entry]) => {
|
|
39
|
+
if (entry.isIntersecting) {
|
|
40
|
+
setVisible(true);
|
|
41
|
+
ioRef.current?.disconnect();
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{ rootMargin: "200px" },
|
|
45
|
+
);
|
|
46
|
+
// fallow-ignore-next-line code-duplication
|
|
47
|
+
ioRef.current.observe(el);
|
|
48
|
+
|
|
49
|
+
const target = el.parentElement || el;
|
|
50
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
51
|
+
setContainerWidth(entry.contentRect.width);
|
|
52
|
+
});
|
|
53
|
+
roRef.current.observe(target);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useMountEffect(() => () => {
|
|
57
|
+
ioRef.current?.disconnect();
|
|
58
|
+
roRef.current?.disconnect();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Probe the image once visible — measures the natural aspect ratio so the
|
|
62
|
+
// tile width matches, and flips to the error state (plain clip background)
|
|
63
|
+
// if the src can't load. The browser cache makes the tile <img>s free.
|
|
64
|
+
//
|
|
65
|
+
// SVG handling: SVGs without intrinsic width/height report naturalWidth=0 on
|
|
66
|
+
// load (treat as success with the 16:9 default aspect) and may fire onerror
|
|
67
|
+
// in some environments even though the file is valid and can be displayed —
|
|
68
|
+
// fall back to loaded-at-16:9 rather than hiding the strip entirely.
|
|
69
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!visible) return;
|
|
72
|
+
let cancelled = false;
|
|
73
|
+
setStatus("loading");
|
|
74
|
+
|
|
75
|
+
const isSvg = /\.svg($|\?)/i.test(imageSrc);
|
|
76
|
+
|
|
77
|
+
const probe = new Image();
|
|
78
|
+
probe.onload = () => {
|
|
79
|
+
if (cancelled) return;
|
|
80
|
+
if (probe.naturalWidth > 0 && probe.naturalHeight > 0) {
|
|
81
|
+
setAspect(probe.naturalWidth / probe.naturalHeight);
|
|
82
|
+
}
|
|
83
|
+
// naturalWidth===0 (e.g. SVG with no intrinsic dimensions) falls through
|
|
84
|
+
// to "loaded" with the default 16:9 aspect already set in state.
|
|
85
|
+
setStatus("loaded");
|
|
86
|
+
};
|
|
87
|
+
probe.onerror = () => {
|
|
88
|
+
if (cancelled) return;
|
|
89
|
+
// SVGs can fail the probe in certain browser/sandbox environments even
|
|
90
|
+
// though the <img> tiles themselves render fine (different security
|
|
91
|
+
// context). Show the strip at the 16:9 fallback rather than blanking.
|
|
92
|
+
if (isSvg) {
|
|
93
|
+
setStatus("loaded");
|
|
94
|
+
} else {
|
|
95
|
+
setStatus("error");
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
probe.src = imageSrc;
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
cancelled = true;
|
|
102
|
+
probe.onload = null;
|
|
103
|
+
probe.onerror = null;
|
|
104
|
+
probe.src = "";
|
|
105
|
+
};
|
|
106
|
+
}, [visible, imageSrc]);
|
|
107
|
+
|
|
108
|
+
const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
112
|
+
{visible && status === "loaded" && (
|
|
113
|
+
<div className="absolute inset-0 flex">
|
|
114
|
+
{Array.from({ length: frameCount }).map((_, i) => (
|
|
115
|
+
<div
|
|
116
|
+
key={i}
|
|
117
|
+
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
|
|
118
|
+
style={{ width: frameW }}
|
|
119
|
+
>
|
|
120
|
+
<img
|
|
121
|
+
src={imageSrc}
|
|
122
|
+
alt=""
|
|
123
|
+
draggable={false}
|
|
124
|
+
loading="lazy"
|
|
125
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{visible && status === "loading" && (
|
|
133
|
+
<div
|
|
134
|
+
className="absolute inset-0 animate-pulse"
|
|
135
|
+
style={{
|
|
136
|
+
background:
|
|
137
|
+
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{label && (
|
|
143
|
+
<div
|
|
144
|
+
className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
|
|
145
|
+
style={{
|
|
146
|
+
background:
|
|
147
|
+
"linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
<span
|
|
151
|
+
className="text-[9px] font-semibold truncate block leading-tight"
|
|
152
|
+
style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
|
|
153
|
+
>
|
|
154
|
+
{label}
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
});
|
|
@@ -9,10 +9,8 @@ import { Tooltip } from "../../components/ui";
|
|
|
9
9
|
import { ShortcutsPanel } from "./ShortcutsPanel";
|
|
10
10
|
import { SpeedMenu } from "./SpeedMenu";
|
|
11
11
|
import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
|
|
12
|
-
import { useState } from "react";
|
|
13
12
|
|
|
14
13
|
export { resolveSeekPercent };
|
|
15
|
-
type TimeDisplayMode = "time" | "frame";
|
|
16
14
|
|
|
17
15
|
/* ── Icon sub-components ─────────────────────────────────────────── */
|
|
18
16
|
|
|
@@ -369,7 +367,8 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
369
367
|
const outPoint = usePlayerStore((s) => s.outPoint);
|
|
370
368
|
const setInPoint = usePlayerStore.getState().setInPoint;
|
|
371
369
|
const setOutPoint = usePlayerStore.getState().setOutPoint;
|
|
372
|
-
const
|
|
370
|
+
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
371
|
+
const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;
|
|
373
372
|
|
|
374
373
|
const progressFillRef = useRef<HTMLDivElement>(null);
|
|
375
374
|
const progressThumbRef = useRef<HTMLDivElement>(null);
|
|
@@ -428,10 +427,11 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
428
427
|
|
|
429
428
|
return (
|
|
430
429
|
<div
|
|
430
|
+
// No own background/border: the transport blends into the preview
|
|
431
|
+
// panel's surface — buttons carry their own chrome.
|
|
431
432
|
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
|
|
432
433
|
aria-disabled={disabled || undefined}
|
|
433
434
|
style={{
|
|
434
|
-
borderTop: "1px solid rgba(255,255,255,0.04)",
|
|
435
435
|
paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
|
|
436
436
|
}}
|
|
437
437
|
>
|
|
@@ -456,7 +456,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
456
456
|
>
|
|
457
457
|
<button
|
|
458
458
|
type="button"
|
|
459
|
-
onClick={() => setTimeDisplayMode(
|
|
459
|
+
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
|
|
460
460
|
disabled={disabled}
|
|
461
461
|
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
|
|
462
462
|
style={{ color: "#A1A1AA", cursor: "pointer" }}
|
|
@@ -2,20 +2,49 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Shared playhead visual used by TimelineCanvas (real playhead) and
|
|
4
4
|
* TimelineEditorNotice (animated illustration).
|
|
5
|
+
*
|
|
6
|
+
* The vertical line + glow span the full track height; the grab-handle HEAD is
|
|
7
|
+
* `position: sticky; top: 0` so it pins to the top of the (vertically) scrolling
|
|
8
|
+
* track area — the ruler is sticky too, so the head stays visible and grabbable
|
|
9
|
+
* no matter how far the tracks are scrolled. The head is OUTLINE-only at rest and
|
|
10
|
+
* FILLED while the playhead is actively held/scrubbed (`scrubbing`).
|
|
5
11
|
*/
|
|
12
|
+
import { PLAYHEAD_HEAD_W } from "./timelineLayout";
|
|
13
|
+
|
|
6
14
|
interface PlayheadIndicatorProps {
|
|
7
15
|
/** CSS color, defaults to the HF accent variable */
|
|
8
16
|
color?: string;
|
|
9
17
|
/** Glow shadow color, defaults to translucent accent */
|
|
10
18
|
glowColor?: string;
|
|
19
|
+
/** Whether the playhead is being actively scrubbed — fills the head. */
|
|
20
|
+
scrubbing?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* When false, the head chip is rendered in normal flow (top:1) instead of the
|
|
23
|
+
* sticky pin — used by the static illustration where there is no scroll area.
|
|
24
|
+
*/
|
|
25
|
+
stickyHead?: boolean;
|
|
11
26
|
}
|
|
12
27
|
|
|
13
28
|
export function PlayheadIndicator({
|
|
14
29
|
color = "var(--hf-accent, #3CE6AC)",
|
|
15
30
|
glowColor = "rgba(60,230,172,0.14)",
|
|
31
|
+
scrubbing = false,
|
|
32
|
+
stickyHead = true,
|
|
16
33
|
}: PlayheadIndicatorProps) {
|
|
34
|
+
// Head chip dimensions — used to compute the centering offset and the
|
|
35
|
+
// point where the vertical line starts (so it begins at the head's bottom
|
|
36
|
+
// edge rather than running through the hollow diamond center). The width is
|
|
37
|
+
// the shared PLAYHEAD_HEAD_W constant: getTimelinePlayheadLeft shifts the
|
|
38
|
+
// wrapper by -PLAYHEAD_HEAD_W/2 so the 1px line (centered at 50% of the
|
|
39
|
+
// wrapper) lands exactly on GUTTER + time * pps — the ruler ticks' center x.
|
|
40
|
+
const HEAD_W = PLAYHEAD_HEAD_W;
|
|
41
|
+
const HEAD_H = 9;
|
|
42
|
+
// marginTop(1) + HEAD_H = where the line should start.
|
|
43
|
+
const HEAD_TOTAL_H = 1 + HEAD_H;
|
|
44
|
+
|
|
17
45
|
return (
|
|
18
46
|
<>
|
|
47
|
+
{/* Glow — spans full height, centered on the line. */}
|
|
19
48
|
<div
|
|
20
49
|
aria-hidden="true"
|
|
21
50
|
className="absolute top-0 bottom-0"
|
|
@@ -26,23 +55,44 @@ export function PlayheadIndicator({
|
|
|
26
55
|
background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
|
|
27
56
|
}}
|
|
28
57
|
/>
|
|
58
|
+
{/* Vertical line — starts at the bottom edge of the head chip so nothing
|
|
59
|
+
shows through the hollow diamond center. */}
|
|
29
60
|
<div
|
|
30
|
-
className="absolute
|
|
61
|
+
className="absolute bottom-0"
|
|
31
62
|
style={{
|
|
32
63
|
left: "50%",
|
|
64
|
+
top: HEAD_TOTAL_H,
|
|
33
65
|
width: 1,
|
|
34
66
|
marginLeft: -0.5,
|
|
35
67
|
background: color,
|
|
36
68
|
boxShadow: `0 0 6px ${glowColor}`,
|
|
37
69
|
}}
|
|
38
70
|
/>
|
|
39
|
-
|
|
71
|
+
{/* Head chip — sticky so it pins to the ruler while tracks scroll.
|
|
72
|
+
Centering logic: wrapper width = HEAD_W (chip forces it). The line sits
|
|
73
|
+
at wrapper.left + HEAD_W/2 (left:"50%" of wrapper). The sticky element's
|
|
74
|
+
natural flow position is wrapper.left; so placing it there with no
|
|
75
|
+
horizontal translate puts its LEFT edge at wrapper.left and its CENTER
|
|
76
|
+
at wrapper.left + HEAD_W/2 — exactly on the line. */}
|
|
77
|
+
<div
|
|
78
|
+
className={stickyHead ? "sticky" : "absolute"}
|
|
79
|
+
style={{
|
|
80
|
+
left: 0,
|
|
81
|
+
top: stickyHead ? 0 : 1,
|
|
82
|
+
// Zero height keeps it from covering rows (sticky strip trick).
|
|
83
|
+
height: stickyHead ? 0 : undefined,
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
40
86
|
<div
|
|
41
87
|
style={{
|
|
42
|
-
width:
|
|
43
|
-
height:
|
|
88
|
+
width: HEAD_W,
|
|
89
|
+
height: HEAD_H,
|
|
44
90
|
borderRadius: 2,
|
|
45
|
-
|
|
91
|
+
marginTop: 1,
|
|
92
|
+
// Outline-only at rest, filled while scrubbing.
|
|
93
|
+
background: scrubbing ? color : "transparent",
|
|
94
|
+
border: `1.5px solid ${color}`,
|
|
95
|
+
boxSizing: "border-box",
|
|
46
96
|
boxShadow: `0 1px 3px rgba(0,0,0,0.55), 0 0 5px ${glowColor}`,
|
|
47
97
|
transform: "rotate(45deg)",
|
|
48
98
|
}}
|
|
@@ -18,7 +18,16 @@ import {
|
|
|
18
18
|
shouldHandleTimelineDeleteKey,
|
|
19
19
|
shouldAutoScrollTimeline,
|
|
20
20
|
} from "./Timeline";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
FIT_ZOOM_HEADROOM,
|
|
23
|
+
GUTTER,
|
|
24
|
+
MIN_TIMELINE_EXTENT_S,
|
|
25
|
+
PLAYHEAD_HEAD_W,
|
|
26
|
+
RULER_H,
|
|
27
|
+
TRACK_H,
|
|
28
|
+
getTimelineDisplayContentWidth,
|
|
29
|
+
getTimelineFitPps,
|
|
30
|
+
} from "./timelineLayout";
|
|
22
31
|
import { formatTime } from "../lib/time";
|
|
23
32
|
import { usePlayerStore } from "../store/playerStore";
|
|
24
33
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
@@ -320,33 +329,87 @@ describe("generateTicks", () => {
|
|
|
320
329
|
const { major } = generateTicks(180, 80);
|
|
321
330
|
expect(major[1] - major[0]).toBe(2);
|
|
322
331
|
});
|
|
332
|
+
|
|
333
|
+
it("picks 'nice' NLE steps across zoom levels (no 7s-style intervals)", () => {
|
|
334
|
+
// step = first nice interval whose px spacing >= 88 at that pps.
|
|
335
|
+
const cases: Array<[number, number]> = [
|
|
336
|
+
[2, 60], // 60s * 2pps = 120px
|
|
337
|
+
[10, 10], // 10s * 10pps = 100px
|
|
338
|
+
[20, 5], // 5s * 20pps = 100px
|
|
339
|
+
[50, 2], // 2s * 50pps = 100px
|
|
340
|
+
[100, 1], // 1s * 100pps = 100px
|
|
341
|
+
];
|
|
342
|
+
for (const [pps, expected] of cases) {
|
|
343
|
+
const { major } = generateTicks(600, pps);
|
|
344
|
+
expect(major[1] - major[0]).toBe(expected);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("uses minute/hour steps when zoomed far out instead of colliding 10m labels", () => {
|
|
349
|
+
// 0.05 pps → 600s step would be 30px apart (labels collide); 1800s = 90px.
|
|
350
|
+
const { major } = generateTicks(7200, 0.05);
|
|
351
|
+
expect(major[1] - major[0]).toBe(1800);
|
|
352
|
+
expect(major).toContain(3600);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("does not drift on long rulers (ticks are exact multiples of the step)", () => {
|
|
356
|
+
const { major } = generateTicks(600, 100); // 1s step, 601 ticks
|
|
357
|
+
expect(major[599]).toBe(599);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("frame display mode (frameRate provided)", () => {
|
|
361
|
+
it("snaps sub-frame steps up to one whole frame (no duplicate frame labels)", () => {
|
|
362
|
+
// 4400 pps would pick a 0.02s step = 0.6 frames at 30fps → snapped to 1 frame.
|
|
363
|
+
const { major } = generateTicks(2, 4400, 30);
|
|
364
|
+
const frames = major.map((t) => Math.round(t * 30));
|
|
365
|
+
// Frame labels are consecutive integers — no duplicates, no gaps.
|
|
366
|
+
frames.forEach((f, i) => expect(f).toBe(i));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("keeps major AND minor ticks on whole frames", () => {
|
|
370
|
+
// 200 pps → 0.5s step (15 frames); quarters (3.75f) are rejected in
|
|
371
|
+
// frame mode in favour of fifths (3f).
|
|
372
|
+
const { major, minor } = generateTicks(20, 200, 30);
|
|
373
|
+
expect(major[1]).toBeCloseTo(0.5);
|
|
374
|
+
expect(minor).toContain(0.1); // 3 frames
|
|
375
|
+
for (const t of [...major, ...minor]) {
|
|
376
|
+
const frames = t * 30;
|
|
377
|
+
expect(Math.abs(frames - Math.round(frames))).toBeLessThan(1e-3);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("leaves whole-second steps unchanged", () => {
|
|
382
|
+
const { major } = generateTicks(60, 100, 30);
|
|
383
|
+
expect(major[1] - major[0]).toBe(1);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
323
386
|
});
|
|
324
387
|
|
|
325
388
|
describe("formatTime", () => {
|
|
326
|
-
it("formats 0 seconds as
|
|
327
|
-
expect(formatTime(0)).toBe("
|
|
389
|
+
it("formats 0 seconds as 00:00", () => {
|
|
390
|
+
expect(formatTime(0)).toBe("00:00");
|
|
328
391
|
});
|
|
329
392
|
|
|
330
393
|
// fallow-ignore-next-line code-duplication
|
|
331
394
|
it("formats seconds below a minute", () => {
|
|
332
|
-
expect(formatTime(5)).toBe("
|
|
333
|
-
expect(formatTime(30)).toBe("
|
|
334
|
-
expect(formatTime(59)).toBe("
|
|
395
|
+
expect(formatTime(5)).toBe("00:05");
|
|
396
|
+
expect(formatTime(30)).toBe("00:30");
|
|
397
|
+
expect(formatTime(59)).toBe("00:59");
|
|
335
398
|
});
|
|
336
399
|
|
|
337
400
|
it("formats exactly one minute", () => {
|
|
338
|
-
expect(formatTime(60)).toBe("
|
|
401
|
+
expect(formatTime(60)).toBe("01:00");
|
|
339
402
|
});
|
|
340
403
|
|
|
341
404
|
it("formats minutes and seconds", () => {
|
|
342
|
-
expect(formatTime(90)).toBe("
|
|
343
|
-
expect(formatTime(125)).toBe("
|
|
405
|
+
expect(formatTime(90)).toBe("01:30");
|
|
406
|
+
expect(formatTime(125)).toBe("02:05");
|
|
344
407
|
});
|
|
345
408
|
|
|
346
409
|
it("floors fractional seconds", () => {
|
|
347
|
-
expect(formatTime(5.7)).toBe("
|
|
348
|
-
expect(formatTime(59.9)).toBe("
|
|
349
|
-
expect(formatTime(90.5)).toBe("
|
|
410
|
+
expect(formatTime(5.7)).toBe("00:05");
|
|
411
|
+
expect(formatTime(59.9)).toBe("00:59");
|
|
412
|
+
expect(formatTime(90.5)).toBe("01:30");
|
|
350
413
|
});
|
|
351
414
|
|
|
352
415
|
it("handles large values", () => {
|
|
@@ -354,16 +417,16 @@ describe("formatTime", () => {
|
|
|
354
417
|
expect(formatTime(3661)).toBe("61:01");
|
|
355
418
|
});
|
|
356
419
|
|
|
357
|
-
it("zero-pads seconds to two digits", () => {
|
|
358
|
-
expect(formatTime(1)).toBe("
|
|
359
|
-
expect(formatTime(9)).toBe("
|
|
360
|
-
expect(formatTime(61)).toBe("
|
|
420
|
+
it("zero-pads minutes and seconds to two digits", () => {
|
|
421
|
+
expect(formatTime(1)).toBe("00:01");
|
|
422
|
+
expect(formatTime(9)).toBe("00:09");
|
|
423
|
+
expect(formatTime(61)).toBe("01:01");
|
|
361
424
|
});
|
|
362
425
|
});
|
|
363
426
|
|
|
364
427
|
describe("formatTimelineTickLabel", () => {
|
|
365
428
|
it("uses minute-second labels for normal timeline intervals", () => {
|
|
366
|
-
expect(formatTimelineTickLabel(90, 180, 5)).toBe("
|
|
429
|
+
expect(formatTimelineTickLabel(90, 180, 5)).toBe("01:30");
|
|
367
430
|
});
|
|
368
431
|
|
|
369
432
|
it("uses hour labels for long timelines", () => {
|
|
@@ -371,7 +434,7 @@ describe("formatTimelineTickLabel", () => {
|
|
|
371
434
|
});
|
|
372
435
|
|
|
373
436
|
it("shows subsecond labels when the major ruler interval is below one second", () => {
|
|
374
|
-
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("
|
|
437
|
+
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("00:01.5");
|
|
375
438
|
});
|
|
376
439
|
});
|
|
377
440
|
|
|
@@ -390,6 +453,94 @@ describe("shouldAutoScrollTimeline", () => {
|
|
|
390
453
|
});
|
|
391
454
|
});
|
|
392
455
|
|
|
456
|
+
describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
|
|
457
|
+
const viewport = 632; // usable width = 632 - GUTTER - 2 = 598
|
|
458
|
+
|
|
459
|
+
it("computes fit pps against the 60s floor for short compositions", () => {
|
|
460
|
+
// A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
|
|
461
|
+
// (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
|
|
462
|
+
const pps = getTimelineFitPps(viewport, 10);
|
|
463
|
+
expect(pps).toBeCloseTo((viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S);
|
|
464
|
+
expect(10 * pps).toBeCloseTo((viewport - GUTTER - 2) / 6);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
|
|
468
|
+
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
|
|
469
|
+
(viewport - GUTTER - 2) / (60 * FIT_ZOOM_HEADROOM),
|
|
470
|
+
);
|
|
471
|
+
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
|
|
472
|
+
(viewport - GUTTER - 2) / (120 * FIT_ZOOM_HEADROOM),
|
|
473
|
+
);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
|
|
477
|
+
const usable = viewport - GUTTER - 2;
|
|
478
|
+
const pps = getTimelineFitPps(viewport, 120);
|
|
479
|
+
// Composition content occupies usable/1.2 px; the remaining ~17% is empty
|
|
480
|
+
// droppable ruler/lane surface past the end.
|
|
481
|
+
expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
|
|
482
|
+
expect(120 * pps).toBeLessThan(usable);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("falls back to 100 pps before the viewport is measured", () => {
|
|
486
|
+
expect(getTimelineFitPps(0, 10)).toBe(100);
|
|
487
|
+
expect(getTimelineFitPps(GUTTER, 10)).toBe(100);
|
|
488
|
+
expect(getTimelineFitPps(Number.NaN, 10)).toBe(100);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("uses the floor for zero/invalid durations", () => {
|
|
492
|
+
expect(getTimelineFitPps(viewport, 0)).toBeCloseTo(
|
|
493
|
+
(viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S,
|
|
494
|
+
);
|
|
495
|
+
expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo(
|
|
496
|
+
(viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S,
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("getTimelineDisplayContentWidth", () => {
|
|
502
|
+
it("always spans at least MIN_TIMELINE_EXTENT_S seconds of content", () => {
|
|
503
|
+
// 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered.
|
|
504
|
+
expect(
|
|
505
|
+
getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 400, pps: 20 }),
|
|
506
|
+
).toBe(MIN_TIMELINE_EXTENT_S * 20);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("still fills the viewport when that is larger than the 60s floor", () => {
|
|
510
|
+
expect(
|
|
511
|
+
getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }),
|
|
512
|
+
).toBe(2000 - GUTTER - 2);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("tracks a drag ghost past every other bound (drag-to-extend)", () => {
|
|
516
|
+
expect(
|
|
517
|
+
getTimelineDisplayContentWidth({
|
|
518
|
+
trackContentWidth: 500,
|
|
519
|
+
viewportWidth: 400,
|
|
520
|
+
pps: 5,
|
|
521
|
+
dragGhostEndPx: 5000,
|
|
522
|
+
}),
|
|
523
|
+
).toBe(5000);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("tracks a resize (trim) ghost past every other bound (trim-to-extend)", () => {
|
|
527
|
+
expect(
|
|
528
|
+
getTimelineDisplayContentWidth({
|
|
529
|
+
trackContentWidth: 500,
|
|
530
|
+
viewportWidth: 400,
|
|
531
|
+
pps: 5,
|
|
532
|
+
resizeGhostEndPx: 4200,
|
|
533
|
+
}),
|
|
534
|
+
).toBe(4200);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("keeps long content authoritative", () => {
|
|
538
|
+
expect(
|
|
539
|
+
getTimelineDisplayContentWidth({ trackContentWidth: 9000, viewportWidth: 400, pps: 50 }),
|
|
540
|
+
).toBe(9000);
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
393
544
|
describe("getTimelineScrollLeftForZoomTransition", () => {
|
|
394
545
|
it("resets horizontal scroll when switching from manual zoom back to fit", () => {
|
|
395
546
|
expect(getTimelineScrollLeftForZoomTransition("manual", "fit", 480)).toBe(0);
|
|
@@ -448,13 +599,20 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
|
|
|
448
599
|
});
|
|
449
600
|
|
|
450
601
|
describe("getTimelinePlayheadLeft", () => {
|
|
451
|
-
it("
|
|
452
|
-
|
|
602
|
+
it("offsets the wrapper by half the head width so the line CENTER = GUTTER + t*pps", () => {
|
|
603
|
+
// Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
|
|
604
|
+
// equal GUTTER + t*pps at any zoom.
|
|
605
|
+
expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20);
|
|
606
|
+
expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => {
|
|
610
|
+
expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER);
|
|
453
611
|
});
|
|
454
612
|
|
|
455
613
|
it("guards invalid input", () => {
|
|
456
|
-
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(
|
|
457
|
-
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(
|
|
614
|
+
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
|
|
615
|
+
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
|
|
458
616
|
});
|
|
459
617
|
});
|
|
460
618
|
|
|
@@ -524,7 +682,9 @@ describe("resolveTimelineAssetDrop", () => {
|
|
|
524
682
|
trackOrder: [0, 3, 7],
|
|
525
683
|
},
|
|
526
684
|
432,
|
|
527
|
-
|
|
685
|
+
// clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) +
|
|
686
|
+
// TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3.
|
|
687
|
+
368,
|
|
528
688
|
),
|
|
529
689
|
).toEqual({ start: 3, track: 3 });
|
|
530
690
|
});
|