@hyperframes/studio 0.7.54 → 0.7.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
- package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
- package/dist/assets/index-CmVCjZjp.js +423 -0
- package/dist/assets/index-D-GyYi2d.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 +146 -144
- package/dist/index.html +2 -2
- package/dist/index.js +40582 -37803
- 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 +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- 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 +158 -126
- 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 +43 -1
- 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 +208 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- 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/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/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/nle/AssetPreviewOverlay.tsx +147 -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 +240 -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.tsx +326 -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 +39 -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 +12 -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 +34 -1
- package/src/hooks/timelineEditingHelpers.ts +22 -48
- package/src/hooks/timelineMoveAdapter.test.ts +77 -0
- package/src/hooks/timelineMoveAdapter.ts +37 -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/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +154 -1
- package/src/hooks/useDomEditCommits.ts +100 -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 +258 -0
- package/src/hooks/useElementLifecycleOps.ts +40 -71
- 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 +303 -0
- 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/useTimelineEditing.test.tsx +20 -29
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +18 -4
- 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/PlayheadIndicator.tsx +50 -5
- package/src/player/components/Timeline.test.ts +102 -19
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +207 -521
- 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/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 +943 -0
- package/src/player/components/timelineClipDragCommit.ts +401 -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 +477 -0
- package/src/player/components/timelineCollision.ts +263 -0
- 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 +120 -61
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- 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/useTimelineStackingSync.test.tsx +67 -0
- package/src/player/components/useTimelineStackingSync.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.ts +48 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/player/index.ts +3 -2
- 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 +12 -81
- package/src/player/lib/timelineElementHelpers.ts +54 -5
- package/src/player/lib/timelineIframeHelpers.ts +2 -0
- package/src/player/store/playerStore.ts +62 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- 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/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.ts +62 -0
- package/src/utils/timelineDiscovery.ts +0 -17
- package/src/utils/timelineInspector.test.ts +121 -0
- package/src/utils/timelineInspector.ts +32 -1
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/assets/index-pRhCpGPz.js +0 -423
- 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,122 @@
|
|
|
1
|
+
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
|
|
2
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
3
|
+
import type { TimelineRangeSelection } from "./timelineEditing";
|
|
4
|
+
import type { TimelineEditCallbacks } from "./timelineCallbacks";
|
|
5
|
+
import { EditPopover } from "./EditModal";
|
|
6
|
+
import {
|
|
7
|
+
KeyframeDiamondContextMenu,
|
|
8
|
+
type KeyframeDiamondContextMenuState,
|
|
9
|
+
} from "./KeyframeDiamondContextMenu";
|
|
10
|
+
import { ClipContextMenu } from "./ClipContextMenu";
|
|
11
|
+
import { TimelineShortcutHint } from "./TimelineShortcutHint";
|
|
12
|
+
|
|
13
|
+
interface ClipContextMenuState {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
element: TimelineElement;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TimelineOverlaysProps {
|
|
20
|
+
theme: TimelineTheme;
|
|
21
|
+
showShortcutHint: boolean;
|
|
22
|
+
showPopover: boolean;
|
|
23
|
+
rangeSelection: TimelineRangeSelection | null;
|
|
24
|
+
setShowPopover: (value: boolean) => void;
|
|
25
|
+
setRangeSelection: (value: TimelineRangeSelection | null) => void;
|
|
26
|
+
kfContextMenu: KeyframeDiamondContextMenuState | null;
|
|
27
|
+
setKfContextMenu: (value: KeyframeDiamondContextMenuState | null) => void;
|
|
28
|
+
onDeleteKeyframe: TimelineEditCallbacks["onDeleteKeyframe"];
|
|
29
|
+
onDeleteAllKeyframes: TimelineEditCallbacks["onDeleteAllKeyframes"];
|
|
30
|
+
onChangeKeyframeEase: TimelineEditCallbacks["onChangeKeyframeEase"];
|
|
31
|
+
onMoveKeyframeToPlayhead: TimelineEditCallbacks["onMoveKeyframeToPlayhead"];
|
|
32
|
+
keyframeCache: Map<string, KeyframeCacheEntry>;
|
|
33
|
+
clipContextMenu: ClipContextMenuState | null;
|
|
34
|
+
setClipContextMenu: (value: ClipContextMenuState | null) => void;
|
|
35
|
+
currentTime: number;
|
|
36
|
+
onSplitElement: TimelineEditCallbacks["onSplitElement"];
|
|
37
|
+
pinZoomBeforeEdit: () => void;
|
|
38
|
+
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The timeline's floating overlays, rendered as siblings above the scroll area:
|
|
42
|
+
// the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
|
|
43
|
+
// and the clip context menu.
|
|
44
|
+
export function TimelineOverlays({
|
|
45
|
+
theme,
|
|
46
|
+
showShortcutHint,
|
|
47
|
+
showPopover,
|
|
48
|
+
rangeSelection,
|
|
49
|
+
setShowPopover,
|
|
50
|
+
setRangeSelection,
|
|
51
|
+
kfContextMenu,
|
|
52
|
+
setKfContextMenu,
|
|
53
|
+
onDeleteKeyframe,
|
|
54
|
+
onDeleteAllKeyframes,
|
|
55
|
+
onChangeKeyframeEase,
|
|
56
|
+
onMoveKeyframeToPlayhead,
|
|
57
|
+
keyframeCache,
|
|
58
|
+
clipContextMenu,
|
|
59
|
+
setClipContextMenu,
|
|
60
|
+
currentTime,
|
|
61
|
+
onSplitElement,
|
|
62
|
+
pinZoomBeforeEdit,
|
|
63
|
+
onDeleteElement,
|
|
64
|
+
}: TimelineOverlaysProps) {
|
|
65
|
+
return (
|
|
66
|
+
<>
|
|
67
|
+
{showShortcutHint && !showPopover && !rangeSelection && (
|
|
68
|
+
<TimelineShortcutHint theme={theme} />
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{showPopover && rangeSelection && (
|
|
72
|
+
<EditPopover
|
|
73
|
+
rangeStart={rangeSelection.start}
|
|
74
|
+
rangeEnd={rangeSelection.end}
|
|
75
|
+
anchorX={rangeSelection.anchorX}
|
|
76
|
+
anchorY={rangeSelection.anchorY}
|
|
77
|
+
onClose={() => {
|
|
78
|
+
setShowPopover(false);
|
|
79
|
+
setRangeSelection(null);
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{kfContextMenu && (
|
|
85
|
+
<KeyframeDiamondContextMenu
|
|
86
|
+
state={kfContextMenu}
|
|
87
|
+
onClose={() => setKfContextMenu(null)}
|
|
88
|
+
onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)}
|
|
89
|
+
onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
|
|
90
|
+
onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
|
|
91
|
+
onMoveToPlayhead={
|
|
92
|
+
onMoveKeyframeToPlayhead
|
|
93
|
+
? (elId, pct) => onMoveKeyframeToPlayhead(elId, pct)
|
|
94
|
+
: undefined
|
|
95
|
+
}
|
|
96
|
+
onCopyProperties={(elId, pct) => {
|
|
97
|
+
const kfData = keyframeCache.get(elId);
|
|
98
|
+
const kf = kfData?.keyframes.find((k) => k.percentage === pct);
|
|
99
|
+
if (kf) {
|
|
100
|
+
void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2));
|
|
101
|
+
}
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{clipContextMenu && (
|
|
107
|
+
<ClipContextMenu
|
|
108
|
+
x={clipContextMenu.x}
|
|
109
|
+
y={clipContextMenu.y}
|
|
110
|
+
element={clipContextMenu.element}
|
|
111
|
+
currentTime={currentTime}
|
|
112
|
+
onClose={() => setClipContextMenu(null)}
|
|
113
|
+
onSplit={(el, time) => onSplitElement?.(el, time)}
|
|
114
|
+
onDelete={(el) => {
|
|
115
|
+
pinZoomBeforeEdit();
|
|
116
|
+
onDeleteElement?.(el);
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
</>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { VideoThumbnail } from "./VideoThumbnail";
|
|
6
|
+
|
|
7
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
8
|
+
configurable: true,
|
|
9
|
+
value: true,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Fire "intersecting" immediately on observe so the extraction effect runs.
|
|
13
|
+
class MockIntersectionObserver {
|
|
14
|
+
private cb: IntersectionObserverCallback;
|
|
15
|
+
constructor(cb: IntersectionObserverCallback) {
|
|
16
|
+
this.cb = cb;
|
|
17
|
+
}
|
|
18
|
+
observe() {
|
|
19
|
+
this.cb(
|
|
20
|
+
[{ isIntersecting: true } as IntersectionObserverEntry],
|
|
21
|
+
this as unknown as IntersectionObserver,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
disconnect() {}
|
|
25
|
+
unobserve() {}
|
|
26
|
+
takeRecords(): IntersectionObserverEntry[] {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class MockResizeObserver {
|
|
32
|
+
observe() {}
|
|
33
|
+
disconnect() {}
|
|
34
|
+
unobserve() {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const originalIO = globalThis.IntersectionObserver;
|
|
38
|
+
const originalRO = globalThis.ResizeObserver;
|
|
39
|
+
|
|
40
|
+
let host: HTMLDivElement;
|
|
41
|
+
let root: Root | null = null;
|
|
42
|
+
let createdVideos: HTMLVideoElement[];
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
globalThis.IntersectionObserver =
|
|
46
|
+
MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
|
47
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
48
|
+
|
|
49
|
+
createdVideos = [];
|
|
50
|
+
const origCreate = document.createElement.bind(document);
|
|
51
|
+
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
|
|
52
|
+
const el = origCreate(tag);
|
|
53
|
+
if (tag === "video") createdVideos.push(el as HTMLVideoElement);
|
|
54
|
+
return el;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// happy-dom's <video>/<canvas> don't decode media; stub the seam the
|
|
58
|
+
// extractor depends on so the effect can run deterministically.
|
|
59
|
+
vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
|
60
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
|
|
61
|
+
drawImage: () => {},
|
|
62
|
+
} as unknown as CanvasRenderingContext2D);
|
|
63
|
+
|
|
64
|
+
host = document.createElement("div");
|
|
65
|
+
document.body.append(host);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
act(() => root?.unmount());
|
|
70
|
+
root = null;
|
|
71
|
+
vi.restoreAllMocks();
|
|
72
|
+
globalThis.IntersectionObserver = originalIO;
|
|
73
|
+
globalThis.ResizeObserver = originalRO;
|
|
74
|
+
document.body.innerHTML = "";
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function render(videoSrc: string) {
|
|
78
|
+
root = createRoot(host);
|
|
79
|
+
act(() => {
|
|
80
|
+
root!.render(React.createElement(VideoThumbnail, { videoSrc, label: "", labelColor: "#fff" }));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function lastVideo(): HTMLVideoElement {
|
|
85
|
+
const v = createdVideos.at(-1);
|
|
86
|
+
expect(v).toBeDefined();
|
|
87
|
+
return v!;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("VideoThumbnail — tainted-canvas fallback", () => {
|
|
91
|
+
it("stops the extractor and drops the shimmer when toDataURL throws a SecurityError", () => {
|
|
92
|
+
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockImplementation(() => {
|
|
93
|
+
throw new DOMException("Tainted canvases may not be exported.", "SecurityError");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
render("https://cdn.example.com/no-cors.mp4");
|
|
97
|
+
|
|
98
|
+
// The effect ran once visible → a hidden <video> was created.
|
|
99
|
+
const video = lastVideo();
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
video.dispatchEvent(new Event("loadedmetadata"));
|
|
103
|
+
});
|
|
104
|
+
act(() => {
|
|
105
|
+
video.dispatchEvent(new Event("seeked"));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// No frame captured, and crucially the shimmer is gone (not spinning
|
|
109
|
+
// forever) — the clip falls back to its plain background.
|
|
110
|
+
expect(host.querySelectorAll("img").length).toBe(0);
|
|
111
|
+
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("drops the shimmer when a no-CORS load fires the video error event (0 frames) (#2214)", () => {
|
|
115
|
+
render("https://cdn.example.com/no-cors.mp4");
|
|
116
|
+
// Before the load resolves, the shimmer placeholder is up.
|
|
117
|
+
expect(host.querySelector(".animate-pulse")).not.toBeNull();
|
|
118
|
+
|
|
119
|
+
const video = lastVideo();
|
|
120
|
+
// crossOrigin="anonymous" against a CORS-less server fails the load outright —
|
|
121
|
+
// the error listener fires instead of loadedmetadata/seeked, so no frame is
|
|
122
|
+
// ever captured. The shimmer must stop rather than spin forever.
|
|
123
|
+
act(() => {
|
|
124
|
+
video.dispatchEvent(new Event("error"));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(host.querySelectorAll("img").length).toBe(0);
|
|
128
|
+
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("keeps streaming frames while the shimmer is up until a frame arrives", () => {
|
|
132
|
+
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(
|
|
133
|
+
"data:image/jpeg;base64,AAAA",
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
render("/api/projects/p/preview/assets/clip.mp4");
|
|
137
|
+
// Before any seek resolves, the shimmer placeholder is shown.
|
|
138
|
+
expect(host.querySelector(".animate-pulse")).not.toBeNull();
|
|
139
|
+
|
|
140
|
+
const video = lastVideo();
|
|
141
|
+
act(() => {
|
|
142
|
+
video.dispatchEvent(new Event("loadedmetadata"));
|
|
143
|
+
});
|
|
144
|
+
act(() => {
|
|
145
|
+
video.dispatchEvent(new Event("seeked"));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// A frame was captured, so tiles render and the shimmer clears.
|
|
149
|
+
expect(host.querySelectorAll("img").length).toBeGreaterThanOrEqual(1);
|
|
150
|
+
expect(host.querySelector(".animate-pulse")).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
import { computeThumbnailStrip, THUMBNAIL_CLIP_HEIGHT } from "./thumbnailUtils";
|
|
3
4
|
|
|
4
5
|
interface VideoThumbnailProps {
|
|
5
6
|
videoSrc: string;
|
|
@@ -8,7 +9,7 @@ interface VideoThumbnailProps {
|
|
|
8
9
|
duration?: number;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
const CLIP_HEIGHT =
|
|
12
|
+
const CLIP_HEIGHT = THUMBNAIL_CLIP_HEIGHT;
|
|
12
13
|
const MAX_UNIQUE_FRAMES: number = 6;
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -25,6 +26,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
25
26
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
26
27
|
const [visible, setVisible] = useState(false);
|
|
27
28
|
const [frames, setFrames] = useState<string[]>([]);
|
|
29
|
+
const [failed, setFailed] = useState(false);
|
|
28
30
|
const [aspect, setAspect] = useState(16 / 9);
|
|
29
31
|
const ioRef = useRef<IntersectionObserver | null>(null);
|
|
30
32
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
@@ -117,8 +119,22 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
117
119
|
|
|
118
120
|
video.addEventListener("seeked", () => {
|
|
119
121
|
if (cancelled) return;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
let dataUrl: string;
|
|
123
|
+
try {
|
|
124
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
125
|
+
dataUrl = canvas.toDataURL("image/jpeg", 0.6);
|
|
126
|
+
} catch {
|
|
127
|
+
// An external http(s) video served without CORS headers taints the
|
|
128
|
+
// canvas, so toDataURL throws a SecurityError. Stop the extractor
|
|
129
|
+
// cleanly and fall back to the no-thumbnail rendering (plain clip
|
|
130
|
+
// background), matching ImageThumbnail's error path — otherwise the
|
|
131
|
+
// shimmer placeholder would spin forever.
|
|
132
|
+
cancelled = true;
|
|
133
|
+
setFailed(true);
|
|
134
|
+
video.src = "";
|
|
135
|
+
video.load();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
122
138
|
// Stream each frame immediately
|
|
123
139
|
setFrames((prev) => [...prev, dataUrl]);
|
|
124
140
|
idx++;
|
|
@@ -126,7 +142,12 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
126
142
|
});
|
|
127
143
|
|
|
128
144
|
video.addEventListener("error", () => {
|
|
129
|
-
|
|
145
|
+
// A no-CORS load fails outright (crossOrigin="anonymous" rejects a video
|
|
146
|
+
// served without CORS headers), firing this instead of the taint path in
|
|
147
|
+
// "seeked" — so 0 frames are ever extracted. Keep whatever frames we have,
|
|
148
|
+
// but mark failed so the shimmer placeholder stops spinning forever and we
|
|
149
|
+
// fall back to the plain clip background (#2214).
|
|
150
|
+
setFailed(true);
|
|
130
151
|
});
|
|
131
152
|
|
|
132
153
|
video.src = videoSrc;
|
|
@@ -136,13 +157,13 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
136
157
|
cancelled = true;
|
|
137
158
|
extractingRef.current = false;
|
|
138
159
|
setFrames([]);
|
|
160
|
+
setFailed(false);
|
|
139
161
|
video.src = "";
|
|
140
162
|
video.load();
|
|
141
163
|
};
|
|
142
164
|
}, [visible, videoSrc, duration]);
|
|
143
165
|
|
|
144
|
-
const frameW =
|
|
145
|
-
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
166
|
+
const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect, CLIP_HEIGHT);
|
|
146
167
|
|
|
147
168
|
return (
|
|
148
169
|
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
@@ -168,7 +189,7 @@ export const VideoThumbnail = memo(function VideoThumbnail({
|
|
|
168
189
|
</div>
|
|
169
190
|
)}
|
|
170
191
|
|
|
171
|
-
{visible && frames.length === 0 && (
|
|
192
|
+
{visible && frames.length === 0 && !failed && (
|
|
172
193
|
<div
|
|
173
194
|
className="absolute inset-0 animate-pulse"
|
|
174
195
|
style={{
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
computeThumbnailStrip,
|
|
4
|
+
encodePreviewPath,
|
|
5
|
+
resolveMediaPreviewUrl,
|
|
6
|
+
THUMBNAIL_CLIP_HEIGHT,
|
|
7
|
+
} from "./thumbnailUtils";
|
|
8
|
+
|
|
9
|
+
describe("computeThumbnailStrip", () => {
|
|
10
|
+
it("sizes tiles by aspect ratio at the clip height", () => {
|
|
11
|
+
const { frameW } = computeThumbnailStrip(500, 16 / 9);
|
|
12
|
+
expect(frameW).toBe(Math.round(THUMBNAIL_CLIP_HEIGHT * (16 / 9)));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("repeats tiles to cover the container width", () => {
|
|
16
|
+
const { frameW, frameCount } = computeThumbnailStrip(500, 1);
|
|
17
|
+
expect(frameW).toBe(THUMBNAIL_CLIP_HEIGHT);
|
|
18
|
+
expect(frameCount).toBe(Math.ceil(500 / THUMBNAIL_CLIP_HEIGHT));
|
|
19
|
+
expect(frameCount * frameW).toBeGreaterThanOrEqual(500);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns one tile when the container width is unknown", () => {
|
|
23
|
+
expect(computeThumbnailStrip(0, 16 / 9).frameCount).toBe(1);
|
|
24
|
+
expect(computeThumbnailStrip(-10, 16 / 9).frameCount).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("falls back to 16:9 for degenerate aspects", () => {
|
|
28
|
+
const expected = Math.round(THUMBNAIL_CLIP_HEIGHT * (16 / 9));
|
|
29
|
+
expect(computeThumbnailStrip(300, 0).frameW).toBe(expected);
|
|
30
|
+
expect(computeThumbnailStrip(300, -2).frameW).toBe(expected);
|
|
31
|
+
expect(computeThumbnailStrip(300, Number.NaN).frameW).toBe(expected);
|
|
32
|
+
expect(computeThumbnailStrip(300, Number.POSITIVE_INFINITY).frameW).toBe(expected);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("never returns a zero-width tile (avoids divide-by-zero repeat counts)", () => {
|
|
36
|
+
const { frameW, frameCount } = computeThumbnailStrip(300, 0.001);
|
|
37
|
+
expect(frameW).toBeGreaterThanOrEqual(1);
|
|
38
|
+
expect(Number.isFinite(frameCount)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("honors a custom clip height", () => {
|
|
42
|
+
expect(computeThumbnailStrip(300, 2, 40).frameW).toBe(80);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("resolveMediaPreviewUrl", () => {
|
|
47
|
+
it("routes composition-relative paths through the project preview endpoint", () => {
|
|
48
|
+
expect(resolveMediaPreviewUrl("assets/image.png", "proj-1")).toBe(
|
|
49
|
+
"/api/projects/proj-1/preview/assets/image.png",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("passes absolute http(s) URLs through untouched", () => {
|
|
54
|
+
expect(resolveMediaPreviewUrl("http://cdn.example.com/a.mp4", "proj-1")).toBe(
|
|
55
|
+
"http://cdn.example.com/a.mp4",
|
|
56
|
+
);
|
|
57
|
+
expect(resolveMediaPreviewUrl("https://cdn.example.com/a.png", "proj-1")).toBe(
|
|
58
|
+
"https://cdn.example.com/a.png",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("percent-encodes spaces in filenames", () => {
|
|
63
|
+
expect(resolveMediaPreviewUrl("assets/my logo.png", "proj-1")).toBe(
|
|
64
|
+
"/api/projects/proj-1/preview/assets/my%20logo.png",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("percent-encodes parentheses in filenames", () => {
|
|
69
|
+
expect(resolveMediaPreviewUrl("assets/heygen-symbol-blue-logo (2).svg", "proj-1")).toBe(
|
|
70
|
+
"/api/projects/proj-1/preview/assets/heygen-symbol-blue-logo%20(2).svg",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("preserves slashes as path separators while encoding each segment", () => {
|
|
75
|
+
expect(resolveMediaPreviewUrl("sub dir/file (v2).mp4", "proj-2")).toBe(
|
|
76
|
+
"/api/projects/proj-2/preview/sub%20dir/file%20(v2).mp4",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("percent-encodes unicode characters in filenames", () => {
|
|
81
|
+
expect(resolveMediaPreviewUrl("assets/café logo.png", "proj-1")).toBe(
|
|
82
|
+
"/api/projects/proj-1/preview/assets/caf%C3%A9%20logo.png",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("leaves paths with no special characters unchanged", () => {
|
|
87
|
+
expect(resolveMediaPreviewUrl("assets/logo.svg", "proj-1")).toBe(
|
|
88
|
+
"/api/projects/proj-1/preview/assets/logo.svg",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("percent-encodes a U+202F narrow no-break space (macOS screenshot artifact)", () => {
|
|
93
|
+
// "Screenshot … 2.16.30 PM.png" — the char between the time and PM is a
|
|
94
|
+
// narrow no-break space; it must encode to %E2%80%AF, not route raw (404).
|
|
95
|
+
expect(resolveMediaPreviewUrl("assets/Screenshot 2.16.30 PM.png", "proj-1")).toBe(
|
|
96
|
+
"/api/projects/proj-1/preview/assets/Screenshot%202.16.30%E2%80%AFPM.png",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("passes data: URIs through untouched (never routes them through preview → HTTP 431)", () => {
|
|
101
|
+
const svg = "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=";
|
|
102
|
+
expect(resolveMediaPreviewUrl(svg, "proj-1")).toBe(svg);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("passes blob: URLs through untouched", () => {
|
|
106
|
+
const blob = "blob:http://localhost:5190/2b3c-4d5e";
|
|
107
|
+
expect(resolveMediaPreviewUrl(blob, "proj-1")).toBe(blob);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// The shared encoder used by every timeline media-URL builder (filmstrip, audio
|
|
112
|
+
// waveform, sub-composition preview) — must match the assets panel's per-segment
|
|
113
|
+
// encoding so those paths stop 404ing on non-ASCII filenames.
|
|
114
|
+
describe("encodePreviewPath", () => {
|
|
115
|
+
it("encodes spaces and parentheses per segment, preserving slashes", () => {
|
|
116
|
+
expect(encodePreviewPath("sub dir/file (v2).mp3")).toBe("sub%20dir/file%20(v2).mp3");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("encodes a U+202F narrow no-break space to %E2%80%AF", () => {
|
|
120
|
+
expect(encodePreviewPath("assets/clip 2.mp3")).toBe("assets/clip%202.mp3");
|
|
121
|
+
expect(encodePreviewPath(`assets/clip${" "}2.mp3`)).toBe("assets/clip%E2%80%AF2.mp3");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("leaves a plain path unchanged", () => {
|
|
125
|
+
expect(encodePreviewPath("assets/music.mp3")).toBe("assets/music.mp3");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Rendered height of a timeline-clip thumbnail strip, in CSS px. */
|
|
2
|
+
export const THUMBNAIL_CLIP_HEIGHT = 66;
|
|
3
|
+
|
|
4
|
+
export interface ThumbnailStripLayout {
|
|
5
|
+
/** Width of a single tile, in CSS px. */
|
|
6
|
+
frameW: number;
|
|
7
|
+
/** Number of tiles needed to fill the container. */
|
|
8
|
+
frameCount: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Compute the film-strip tile layout for a clip thumbnail: fixed-height tiles
|
|
13
|
+
* sized by the media's aspect ratio, repeated to fill the clip width.
|
|
14
|
+
* Degenerate aspects (0, negative, NaN, Infinity) fall back to 16:9.
|
|
15
|
+
*/
|
|
16
|
+
export function computeThumbnailStrip(
|
|
17
|
+
containerWidth: number,
|
|
18
|
+
aspect: number,
|
|
19
|
+
clipHeight: number = THUMBNAIL_CLIP_HEIGHT,
|
|
20
|
+
): ThumbnailStripLayout {
|
|
21
|
+
const safeAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 16 / 9;
|
|
22
|
+
const frameW = Math.max(1, Math.round(clipHeight * safeAspect));
|
|
23
|
+
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
24
|
+
return { frameW, frameCount };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Percent-encode each segment of a composition-relative media path so filenames
|
|
29
|
+
* containing spaces, parentheses, a U+202F narrow no-break space (the macOS
|
|
30
|
+
* screenshot artifact), or any other non-ASCII / URL-unsafe character yield a
|
|
31
|
+
* valid URL instead of a 404. Slashes are preserved as separators.
|
|
32
|
+
*
|
|
33
|
+
* Shared by every timeline media-URL builder (filmstrip thumbnails, audio
|
|
34
|
+
* waveform, sub-composition preview) so they encode identically to the assets
|
|
35
|
+
* panel — a raw segment 404s on the exact filenames the assets panel loads fine.
|
|
36
|
+
*/
|
|
37
|
+
export function encodePreviewPath(relativePath: string): string {
|
|
38
|
+
return relativePath.split("/").map(encodeURIComponent).join("/");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a timeline element's media src to a URL loadable from the studio
|
|
43
|
+
* (parent) document. Composition-relative paths (e.g. "assets/image.png") are
|
|
44
|
+
* routed through the project preview endpoint with each segment encoded.
|
|
45
|
+
*
|
|
46
|
+
* Already-loadable URLs pass through untouched: absolute http(s) URLs, plus
|
|
47
|
+
* `data:` and `blob:` URLs. Routing a `data:`/`blob:` URL through the preview
|
|
48
|
+
* endpoint would percent-encode the whole thing into a multi-KB path segment
|
|
49
|
+
* that the server rejects with HTTP 431 (Request Header Fields Too Large).
|
|
50
|
+
*/
|
|
51
|
+
export function resolveMediaPreviewUrl(src: string, projectId: string): string {
|
|
52
|
+
if (/^(?:https?:|data:|blob:)/i.test(src)) return src;
|
|
53
|
+
return `/api/projects/${projectId}/preview/${encodePreviewPath(src)}`;
|
|
54
|
+
}
|
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
// fallow-ignore-file code-duplication
|
|
2
2
|
// fallow-ignore-file dead-code
|
|
3
3
|
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
-
import type {
|
|
5
|
-
import type {
|
|
6
|
-
TimelineGroupCommitOptions,
|
|
7
|
-
TimelineGroupMoveChange,
|
|
8
|
-
TimelineGroupResizeChange,
|
|
9
|
-
} from "../../hooks/useTimelineGroupEditing";
|
|
4
|
+
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
|
|
5
|
+
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
|
10
6
|
|
|
11
7
|
/**
|
|
12
8
|
* Shared callback signatures for timeline editing operations.
|
|
@@ -31,26 +27,30 @@ export interface TimelineDropCallbacks {
|
|
|
31
27
|
export interface TimelineEditCallbacks {
|
|
32
28
|
onMoveElement?: (
|
|
33
29
|
element: TimelineElement,
|
|
34
|
-
updates: Pick<TimelineElement, "start" | "track"
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
updates: Pick<TimelineElement, "start" | "track">,
|
|
31
|
+
) => Promise<void> | void;
|
|
32
|
+
/** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
|
|
33
|
+
* `coalesceKey` (drag-commit gesture id) merges the move history entry with a
|
|
34
|
+
* lane change's follow-up z-reorder entry into one undo step. */
|
|
35
|
+
onMoveElements?: (
|
|
36
|
+
edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
|
|
37
|
+
coalesceKey?: string,
|
|
38
|
+
operation?: TimelineMoveOperation,
|
|
37
39
|
) => Promise<void> | void;
|
|
38
40
|
onResizeElement?: (
|
|
39
41
|
element: TimelineElement,
|
|
40
42
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
41
43
|
) => Promise<void> | void;
|
|
42
|
-
onMoveElements?: (
|
|
43
|
-
changes: TimelineGroupMoveChange[],
|
|
44
|
-
options?: TimelineGroupCommitOptions,
|
|
45
|
-
) => Promise<void> | void;
|
|
46
44
|
onResizeElements?: (
|
|
47
|
-
changes:
|
|
48
|
-
|
|
45
|
+
changes: Array<{
|
|
46
|
+
element: TimelineElement;
|
|
47
|
+
start: number;
|
|
48
|
+
duration: number;
|
|
49
|
+
playbackStart?: number;
|
|
50
|
+
}>,
|
|
51
|
+
options?: { coalesceKey?: string },
|
|
49
52
|
) => Promise<void> | void;
|
|
50
|
-
onPreviewMoveElements?: (changes: TimelineGroupMoveChange[]) => void;
|
|
51
|
-
onPreviewResizeElements?: (changes: TimelineGroupResizeChange[]) => void;
|
|
52
53
|
onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
|
|
53
|
-
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
|
|
54
54
|
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
55
55
|
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
56
56
|
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { TrackVisualStyle } from "./timelineIcons";
|
|
4
|
+
|
|
5
|
+
function ClipLintDot({ element }: { element: TimelineElement }) {
|
|
6
|
+
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
|
|
7
|
+
if (!lint || lint.count === 0) return null;
|
|
8
|
+
return (
|
|
9
|
+
<span
|
|
10
|
+
className="absolute w-1.5 h-1.5 rounded-full bg-amber-400"
|
|
11
|
+
style={{ top: 7, right: 7 }}
|
|
12
|
+
title={lint.messages.join("\n")}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderClipChildren(
|
|
18
|
+
element: TimelineElement,
|
|
19
|
+
clipStyle: TrackVisualStyle,
|
|
20
|
+
renderClipContent:
|
|
21
|
+
| ((element: TimelineElement, style: { clip: string; label: string }) => ReactNode)
|
|
22
|
+
| undefined,
|
|
23
|
+
renderClipOverlay: ((element: TimelineElement) => ReactNode) | undefined,
|
|
24
|
+
): ReactNode {
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
{renderClipOverlay?.(element)}
|
|
28
|
+
{!renderClipContent && <ClipLintDot element={element} />}
|
|
29
|
+
{renderClipContent && (
|
|
30
|
+
// borderRadius: inherit — the clip itself is overflow-visible (keyframe
|
|
31
|
+
// diamonds hang outside its bounds), so the thumbnail layer must clip
|
|
32
|
+
// itself to the clip's rounded corners or sharp corners poke out.
|
|
33
|
+
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: "inherit" }}>
|
|
34
|
+
{renderClipContent(element, clipStyle)}
|
|
35
|
+
</div>
|
|
36
|
+
)}
|
|
37
|
+
</>
|
|
38
|
+
);
|
|
39
|
+
}
|