@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { filterByUsage, countUsage, deriveUsedPaths } from "./AssetsTab";
|
|
3
|
+
import { truncateMiddle, formatDuration } from "./assetHelpers";
|
|
3
4
|
import { globalAssetRows } from "./GlobalAssetsView";
|
|
4
5
|
|
|
5
6
|
const assets = ["bgm.mp3", "logo.png", "orphan.wav"];
|
|
@@ -47,6 +48,39 @@ describe("deriveUsedPaths", () => {
|
|
|
47
48
|
"assets/logo.png",
|
|
48
49
|
]);
|
|
49
50
|
});
|
|
51
|
+
|
|
52
|
+
it("handles fully-absolute URLs produced by the core runtime (toAbsoluteAssetUrl)", () => {
|
|
53
|
+
// The runtime calls new URL(raw, document.baseURI).toString() which produces
|
|
54
|
+
// "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4"
|
|
55
|
+
const used = deriveUsedPaths([
|
|
56
|
+
{ src: "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4" },
|
|
57
|
+
{ src: "http://localhost:3012/api/projects/abc123/preview/assets/logo.png" },
|
|
58
|
+
]);
|
|
59
|
+
expect(used.has("assets/clip.mp4")).toBe(true);
|
|
60
|
+
expect(used.has("assets/logo.png")).toBe(true);
|
|
61
|
+
expect(used.size).toBe(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("decodes percent-encoded filenames (spaces, parens) so they match the asset list", () => {
|
|
65
|
+
// Files with spaces/parens: "assets/my file (1).mp4" authored in HTML
|
|
66
|
+
// → runtime resolves to "http://…/assets/my%20file%20(1).mp4"
|
|
67
|
+
const used = deriveUsedPaths([
|
|
68
|
+
{ src: "http://localhost:3012/api/projects/p/preview/assets/my%20file%20(1).mp4" },
|
|
69
|
+
{ src: "/api/projects/p/preview/assets/track%20one.mp3" },
|
|
70
|
+
]);
|
|
71
|
+
expect(used.has("assets/my file (1).mp4")).toBe(true);
|
|
72
|
+
expect(used.has("assets/track one.mp3")).toBe(true);
|
|
73
|
+
expect(used.size).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("round-trips: absolute URL with spaces matches filterByUsage against plain asset list", () => {
|
|
77
|
+
const used = deriveUsedPaths([
|
|
78
|
+
{ src: "http://localhost:3012/api/projects/demo/preview/assets/my%20video.mp4" },
|
|
79
|
+
]);
|
|
80
|
+
expect(filterByUsage(["assets/my video.mp4", "assets/other.png"], used, "used")).toEqual([
|
|
81
|
+
"assets/my video.mp4",
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
50
84
|
});
|
|
51
85
|
|
|
52
86
|
describe("countUsage", () => {
|
|
@@ -59,6 +93,68 @@ describe("countUsage", () => {
|
|
|
59
93
|
});
|
|
60
94
|
});
|
|
61
95
|
|
|
96
|
+
describe("truncateMiddle", () => {
|
|
97
|
+
it("returns the original string when it fits within maxLen", () => {
|
|
98
|
+
expect(truncateMiddle("short.mp4", 20)).toBe("short.mp4");
|
|
99
|
+
expect(truncateMiddle("exact_length_str.mp4", 20)).toBe("exact_length_str.mp4");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("truncates longer strings with an ellipsis in the middle", () => {
|
|
103
|
+
const result = truncateMiddle("2a37eabf-long-uuid-887d8.mp4", 20);
|
|
104
|
+
expect(result.length).toBeLessThanOrEqual(20);
|
|
105
|
+
expect(result).toContain("…");
|
|
106
|
+
// Preserves head
|
|
107
|
+
expect(result.startsWith("2a37eabf-long-uuid-8")).toBe(false); // head is shortened
|
|
108
|
+
expect(result.startsWith("2a37eabf")).toBe(true);
|
|
109
|
+
// Preserves tail
|
|
110
|
+
expect(result.endsWith("887d8.mp4")).toBe(false); // tail portion only
|
|
111
|
+
expect(result.endsWith(".mp4")).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("preserves the full filename extension in the tail", () => {
|
|
115
|
+
const result = truncateMiddle("verylongnamehere12345.mp4", 14);
|
|
116
|
+
expect(result.endsWith(".mp4")).toBe(true);
|
|
117
|
+
expect(result.length).toBeLessThanOrEqual(14);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("handles maxLen of 1 (degenerate)", () => {
|
|
121
|
+
const result = truncateMiddle("abcdef", 1);
|
|
122
|
+
// head = 0, tail = 0 → just the ellipsis
|
|
123
|
+
expect(result).toBe("…");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("handles a string of exactly maxLen+1 chars", () => {
|
|
127
|
+
const result = truncateMiddle("abcdefgh", 7);
|
|
128
|
+
expect(result.length).toBeLessThanOrEqual(7);
|
|
129
|
+
expect(result).toContain("…");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("formatDuration", () => {
|
|
134
|
+
it("formats whole seconds as MM:SS", () => {
|
|
135
|
+
expect(formatDuration(28)).toBe("00:28");
|
|
136
|
+
expect(formatDuration(60)).toBe("01:00");
|
|
137
|
+
expect(formatDuration(90)).toBe("01:30");
|
|
138
|
+
expect(formatDuration(3661)).toBe("61:01");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rounds fractional seconds to nearest whole", () => {
|
|
142
|
+
expect(formatDuration(28.4)).toBe("00:28");
|
|
143
|
+
expect(formatDuration(28.6)).toBe("00:29");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns empty string for non-positive values", () => {
|
|
147
|
+
expect(formatDuration(0)).toBe("");
|
|
148
|
+
expect(formatDuration(-1)).toBe("");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns empty string for non-finite values", () => {
|
|
152
|
+
expect(formatDuration(NaN)).toBe("");
|
|
153
|
+
expect(formatDuration(Infinity)).toBe("");
|
|
154
|
+
expect(formatDuration(-Infinity)).toBe("");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
62
158
|
describe("globalAssetRows", () => {
|
|
63
159
|
const recs = [
|
|
64
160
|
{ id: "bgm_001", type: "bgm", description: "calm ambient" },
|
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
// fallow-ignore-file code-duplication
|
|
2
2
|
import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react";
|
|
3
|
-
import {
|
|
4
|
-
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
|
|
5
|
-
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
3
|
+
import { MEDIA_EXT, FONT_EXT } from "../../utils/mediaTypes";
|
|
6
4
|
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
7
|
-
import { ContextMenu } from "./AssetContextMenu";
|
|
8
5
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
9
|
-
import {
|
|
10
|
-
type MediaCategory,
|
|
11
|
-
getCategory,
|
|
12
|
-
basename,
|
|
13
|
-
ext,
|
|
14
|
-
CATEGORY_LABELS,
|
|
15
|
-
FILTER_ORDER,
|
|
16
|
-
} from "./assetHelpers";
|
|
6
|
+
import { type MediaCategory, getCategory, CATEGORY_LABELS, FILTER_ORDER } from "./assetHelpers";
|
|
17
7
|
import { AudioRow } from "./AudioRow";
|
|
18
8
|
import { GlobalAssetsView } from "./GlobalAssetsView";
|
|
9
|
+
import { AssetCard, FontRow } from "./AssetCard";
|
|
19
10
|
|
|
20
11
|
interface AssetsTabProps {
|
|
21
12
|
projectId: string;
|
|
@@ -23,155 +14,7 @@ interface AssetsTabProps {
|
|
|
23
14
|
onImport?: (files: FileList) => void;
|
|
24
15
|
onDelete?: (path: string) => void;
|
|
25
16
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// fallow-ignore-next-line complexity
|
|
29
|
-
function ImageCard({
|
|
30
|
-
projectId,
|
|
31
|
-
asset,
|
|
32
|
-
used,
|
|
33
|
-
onCopy,
|
|
34
|
-
isCopied,
|
|
35
|
-
onDelete,
|
|
36
|
-
onRename,
|
|
37
|
-
size,
|
|
38
|
-
}: {
|
|
39
|
-
projectId: string;
|
|
40
|
-
asset: string;
|
|
41
|
-
used: boolean;
|
|
42
|
-
onCopy: (path: string) => void;
|
|
43
|
-
isCopied: boolean;
|
|
44
|
-
onDelete?: (path: string) => void;
|
|
45
|
-
onRename?: (oldPath: string, newPath: string) => void;
|
|
46
|
-
size: "large" | "small";
|
|
47
|
-
}) {
|
|
48
|
-
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
49
|
-
const [hovered, setHovered] = useState(false);
|
|
50
|
-
const name = basename(asset);
|
|
51
|
-
const extension = ext(asset);
|
|
52
|
-
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
|
53
|
-
const isVideo = VIDEO_EXT.test(asset);
|
|
54
|
-
const isImage = IMAGE_EXT.test(asset);
|
|
55
|
-
|
|
56
|
-
const thumbW = size === "large" ? "w-full" : "w-[50px]";
|
|
57
|
-
const thumbH = size === "large" ? "h-[100px]" : "h-[32px]";
|
|
58
|
-
|
|
59
|
-
return (
|
|
60
|
-
<>
|
|
61
|
-
<div
|
|
62
|
-
draggable
|
|
63
|
-
onClick={() => onCopy(asset)}
|
|
64
|
-
onDragStart={(e) => {
|
|
65
|
-
e.dataTransfer.effectAllowed = "copy";
|
|
66
|
-
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
|
67
|
-
e.dataTransfer.setData("text/plain", asset);
|
|
68
|
-
}}
|
|
69
|
-
onContextMenu={(e) => {
|
|
70
|
-
e.preventDefault();
|
|
71
|
-
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
72
|
-
}}
|
|
73
|
-
onPointerEnter={() => setHovered(true)}
|
|
74
|
-
onPointerLeave={() => setHovered(false)}
|
|
75
|
-
className={`transition-colors cursor-pointer ${
|
|
76
|
-
size === "large"
|
|
77
|
-
? `px-2.5 py-1 ${isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/30"}`
|
|
78
|
-
: `px-2.5 py-1.5 flex items-center gap-2.5 ${
|
|
79
|
-
isCopied
|
|
80
|
-
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
|
81
|
-
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
82
|
-
}`
|
|
83
|
-
}`}
|
|
84
|
-
>
|
|
85
|
-
{size === "large" ? (
|
|
86
|
-
<div className="flex flex-col gap-1">
|
|
87
|
-
<div className={`${thumbW} ${thumbH} rounded overflow-hidden bg-neutral-900 relative`}>
|
|
88
|
-
{isImage && (
|
|
89
|
-
<img
|
|
90
|
-
src={serveUrl}
|
|
91
|
-
alt={name}
|
|
92
|
-
loading="lazy"
|
|
93
|
-
className="w-full h-full object-cover"
|
|
94
|
-
onError={(e) => {
|
|
95
|
-
(e.target as HTMLImageElement).style.display = "none";
|
|
96
|
-
}}
|
|
97
|
-
/>
|
|
98
|
-
)}
|
|
99
|
-
{isVideo && <VideoFrameThumbnail src={serveUrl} />}
|
|
100
|
-
{isVideo && hovered && (
|
|
101
|
-
<video
|
|
102
|
-
src={serveUrl}
|
|
103
|
-
autoPlay
|
|
104
|
-
muted
|
|
105
|
-
loop
|
|
106
|
-
playsInline
|
|
107
|
-
className="absolute inset-0 w-full h-full object-cover"
|
|
108
|
-
/>
|
|
109
|
-
)}
|
|
110
|
-
</div>
|
|
111
|
-
<div className="flex items-center gap-1.5">
|
|
112
|
-
<span
|
|
113
|
-
className={`text-xs font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
114
|
-
>
|
|
115
|
-
{name}
|
|
116
|
-
</span>
|
|
117
|
-
<span className="text-[10px] text-neutral-600">{extension}</span>
|
|
118
|
-
{used && (
|
|
119
|
-
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
|
|
120
|
-
in use
|
|
121
|
-
</span>
|
|
122
|
-
)}
|
|
123
|
-
</div>
|
|
124
|
-
</div>
|
|
125
|
-
) : (
|
|
126
|
-
<>
|
|
127
|
-
<div className="w-[50px] h-[32px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 flex items-center justify-center">
|
|
128
|
-
{isImage && (
|
|
129
|
-
<img
|
|
130
|
-
src={serveUrl}
|
|
131
|
-
alt={name}
|
|
132
|
-
loading="lazy"
|
|
133
|
-
className="w-full h-full object-cover"
|
|
134
|
-
onError={(e) => {
|
|
135
|
-
(e.target as HTMLImageElement).style.display = "none";
|
|
136
|
-
}}
|
|
137
|
-
/>
|
|
138
|
-
)}
|
|
139
|
-
{!isImage && (
|
|
140
|
-
<span className="text-[9px] font-medium text-neutral-700">{extension}</span>
|
|
141
|
-
)}
|
|
142
|
-
</div>
|
|
143
|
-
<div className="min-w-0 flex-1">
|
|
144
|
-
<span
|
|
145
|
-
className={`text-xs font-medium truncate block ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
146
|
-
>
|
|
147
|
-
{name}
|
|
148
|
-
</span>
|
|
149
|
-
<div className="flex items-center gap-1.5">
|
|
150
|
-
<span className="text-[10px] text-neutral-600 truncate">{extension}</span>
|
|
151
|
-
{used && (
|
|
152
|
-
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
|
|
153
|
-
in use
|
|
154
|
-
</span>
|
|
155
|
-
)}
|
|
156
|
-
</div>
|
|
157
|
-
</div>
|
|
158
|
-
</>
|
|
159
|
-
)}
|
|
160
|
-
</div>
|
|
161
|
-
|
|
162
|
-
{contextMenu && (
|
|
163
|
-
<ContextMenu
|
|
164
|
-
x={contextMenu.x}
|
|
165
|
-
y={contextMenu.y}
|
|
166
|
-
asset={asset}
|
|
167
|
-
onClose={() => setContextMenu(null)}
|
|
168
|
-
onCopy={onCopy}
|
|
169
|
-
onDelete={onDelete}
|
|
170
|
-
onRename={onRename}
|
|
171
|
-
/>
|
|
172
|
-
)}
|
|
173
|
-
</>
|
|
174
|
-
);
|
|
17
|
+
onAddAssetToTimeline?: (path: string) => void;
|
|
175
18
|
}
|
|
176
19
|
|
|
177
20
|
export type UsageFilter = "all" | "used" | "unused";
|
|
@@ -200,20 +43,48 @@ export function countUsage(
|
|
|
200
43
|
/**
|
|
201
44
|
* Project-relative asset paths referenced by composition elements — the set the
|
|
202
45
|
* "in use" badge, used-first sort, and usage filter all key on. Element src is
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
46
|
+
* populated from the core runtime's `resolveNodeAssetUrl` which calls
|
|
47
|
+
* `new URL(raw, document.baseURI).toString()`, turning authored relative paths
|
|
48
|
+
* into fully-absolute URLs with percent-encoded characters, e.g.
|
|
49
|
+
* "assets/my file (1).mp4"
|
|
50
|
+
* → "http://localhost:3012/api/projects/demo/preview/assets/my%20file%20(1).mp4"
|
|
51
|
+
*
|
|
52
|
+
* This function normalizes every src shape to the bare project-relative path so
|
|
53
|
+
* it matches the asset-list entries:
|
|
54
|
+
* - Absolute URL → strip origin + /api/projects/<id>/preview/ prefix, decode %XX
|
|
55
|
+
* - Server-relative /api/…preview/… → same strip + decode
|
|
56
|
+
* - Relative "./"-prefixed or bare → strip leading ./ or /
|
|
57
|
+
* - ?query / #hash → dropped
|
|
58
|
+
*
|
|
59
|
+
* Pure — unit-tested.
|
|
208
60
|
*/
|
|
209
61
|
export function deriveUsedPaths(elements: Array<{ src?: string }>): Set<string> {
|
|
210
62
|
const paths = new Set<string>();
|
|
211
63
|
for (const el of elements) {
|
|
212
64
|
if (!el.src) continue;
|
|
213
|
-
|
|
65
|
+
let s = el.src;
|
|
66
|
+
|
|
67
|
+
// Strip absolute origin if present (http://host/path → /path)
|
|
68
|
+
try {
|
|
69
|
+
const u = new URL(s);
|
|
70
|
+
s = u.pathname + (u.search ? u.search : "") + (u.hash ? u.hash : "");
|
|
71
|
+
} catch {
|
|
72
|
+
// Not a valid absolute URL — leave as-is (relative path)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
s = s
|
|
214
76
|
.replace(/^\/api\/projects\/[^/]+\/preview\//, "") // strip the dev serve prefix
|
|
215
77
|
.replace(/^\.?\//, "") // strip leading ./ or /
|
|
216
78
|
.split(/[?#]/)[0]; // drop query / hash
|
|
79
|
+
|
|
80
|
+
// Decode percent-encoded characters (spaces, parens, etc.) so the path
|
|
81
|
+
// matches the plain-text asset-list entries the server returns.
|
|
82
|
+
try {
|
|
83
|
+
s = decodeURIComponent(s);
|
|
84
|
+
} catch {
|
|
85
|
+
// Malformed encoding — use as-is
|
|
86
|
+
}
|
|
87
|
+
|
|
217
88
|
if (s) paths.add(s);
|
|
218
89
|
}
|
|
219
90
|
return paths;
|
|
@@ -225,6 +96,7 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
225
96
|
onImport,
|
|
226
97
|
onDelete,
|
|
227
98
|
onRename,
|
|
99
|
+
onAddAssetToTimeline,
|
|
228
100
|
}: AssetsTabProps) {
|
|
229
101
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
230
102
|
const [dragOver, setDragOver] = useState(false);
|
|
@@ -232,17 +104,11 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
232
104
|
const [activeFilter, setActiveFilter] = useState<MediaCategory | "all">("all");
|
|
233
105
|
const [usageFilter, setUsageFilter] = useState<"all" | "used" | "unused">("all");
|
|
234
106
|
const [searchQuery, setSearchQuery] = useState("");
|
|
235
|
-
// Cross-project view: the global media-use cache (~/.media). The view itself
|
|
236
|
-
// (GlobalAssetsView) owns its fetch — AssetsTab only tracks which scope is active.
|
|
237
107
|
const [viewMode, setViewMode] = useState<"local" | "global">("local");
|
|
238
108
|
const [manifest, setManifest] = useState<
|
|
239
109
|
Map<string, { description?: string; duration?: number; width?: number; height?: number }>
|
|
240
110
|
>(new Map());
|
|
241
111
|
|
|
242
|
-
// Projects whose media manifest 404'd — most don't have one. Cache the miss so
|
|
243
|
-
// we don't re-fetch (and spam the console) on every re-render; the effect was
|
|
244
|
-
// also keyed on the `assets` array reference, which changes each render, so it
|
|
245
|
-
// re-fired constantly. Key on a stable join + skip known-missing manifests.
|
|
246
112
|
const manifest404Ref = useRef<Set<string>>(new Set());
|
|
247
113
|
const assetsKey = assets.join("|");
|
|
248
114
|
useEffect(() => {
|
|
@@ -278,7 +144,6 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
278
144
|
cancelled = true;
|
|
279
145
|
};
|
|
280
146
|
}, [projectId, assetsKey]);
|
|
281
|
-
|
|
282
147
|
const handleDrop = useCallback(
|
|
283
148
|
(e: React.DragEvent) => {
|
|
284
149
|
e.preventDefault();
|
|
@@ -287,7 +152,6 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
287
152
|
},
|
|
288
153
|
[onImport],
|
|
289
154
|
);
|
|
290
|
-
|
|
291
155
|
const handleCopyPath = useCallback(async (path: string) => {
|
|
292
156
|
const copied = await copyTextToClipboard(path);
|
|
293
157
|
if (copied) {
|
|
@@ -295,22 +159,27 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
295
159
|
setTimeout(() => setCopiedPath(null), 1500);
|
|
296
160
|
}
|
|
297
161
|
}, []);
|
|
298
|
-
|
|
299
162
|
const elements = usePlayerStore((s) => s.elements);
|
|
300
163
|
const usedPaths = useMemo(() => deriveUsedPaths(elements), [elements]);
|
|
301
|
-
|
|
302
164
|
const mediaAssets = useMemo(() => {
|
|
303
165
|
const media = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a));
|
|
304
166
|
const all = filterByUsage(media, usedPaths, usageFilter);
|
|
305
167
|
if (!searchQuery) return all;
|
|
306
168
|
const q = searchQuery.toLowerCase();
|
|
307
169
|
return all.filter((a) => {
|
|
308
|
-
if (
|
|
170
|
+
if (
|
|
171
|
+
a
|
|
172
|
+
.split("/")
|
|
173
|
+
.pop()
|
|
174
|
+
?.replace(/\.[^.]*$/, "")
|
|
175
|
+
.toLowerCase()
|
|
176
|
+
.includes(q)
|
|
177
|
+
)
|
|
178
|
+
return true;
|
|
309
179
|
const rec = manifest.get(a);
|
|
310
180
|
return rec?.description?.toLowerCase().includes(q);
|
|
311
181
|
});
|
|
312
182
|
}, [assets, searchQuery, manifest, usageFilter, usedPaths]);
|
|
313
|
-
|
|
314
183
|
const categorized = useMemo(() => {
|
|
315
184
|
const groups: Record<MediaCategory, string[]> = { audio: [], images: [], video: [], fonts: [] };
|
|
316
185
|
for (const a of mediaAssets) {
|
|
@@ -327,15 +196,11 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
327
196
|
}
|
|
328
197
|
return groups;
|
|
329
198
|
}, [mediaAssets, usedPaths]);
|
|
330
|
-
|
|
331
199
|
const counts = useMemo(() => {
|
|
332
200
|
const c: Record<string, number> = { all: mediaAssets.length };
|
|
333
201
|
for (const cat of FILTER_ORDER) c[cat] = categorized[cat].length;
|
|
334
202
|
return c;
|
|
335
203
|
}, [mediaAssets, categorized]);
|
|
336
|
-
|
|
337
|
-
// Usage counts over the full media set (independent of the active usage filter,
|
|
338
|
-
// so the chips don't show their own filtered totals).
|
|
339
204
|
const usageCounts = useMemo(
|
|
340
205
|
() =>
|
|
341
206
|
countUsage(
|
|
@@ -344,12 +209,10 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
344
209
|
),
|
|
345
210
|
[assets, usedPaths],
|
|
346
211
|
);
|
|
347
|
-
|
|
348
212
|
const visibleCategories =
|
|
349
213
|
activeFilter === "all"
|
|
350
214
|
? FILTER_ORDER.filter((c) => categorized[c].length > 0)
|
|
351
215
|
: [activeFilter as MediaCategory].filter((c) => categorized[c].length > 0);
|
|
352
|
-
|
|
353
216
|
return (
|
|
354
217
|
<div
|
|
355
218
|
className={`flex-1 flex flex-col min-h-0 transition-colors ${dragOver ? "bg-studio-accent/[0.05]" : ""}`}
|
|
@@ -362,7 +225,7 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
362
225
|
>
|
|
363
226
|
{/* Header — matches design panel Section pattern */}
|
|
364
227
|
<div className="px-4 pt-2.5 pb-1.5 flex-shrink-0">
|
|
365
|
-
{/* Scope toggle
|
|
228
|
+
{/* Scope toggle */}
|
|
366
229
|
<div className="flex gap-1 mb-2.5 p-0.5 rounded-md bg-panel-input">
|
|
367
230
|
{(["local", "global"] as const).map((m) => (
|
|
368
231
|
<button
|
|
@@ -447,7 +310,7 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
447
310
|
</div>
|
|
448
311
|
)}
|
|
449
312
|
|
|
450
|
-
{/* Filter chips
|
|
313
|
+
{/* Filter chips */}
|
|
451
314
|
{viewMode === "local" && mediaAssets.length > 0 && (
|
|
452
315
|
<div className="flex gap-1.5 flex-wrap">
|
|
453
316
|
<button
|
|
@@ -475,7 +338,6 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
475
338
|
</button>
|
|
476
339
|
) : null,
|
|
477
340
|
)}
|
|
478
|
-
{/* Usage filter — show only assets the composition references, or only the unused ones */}
|
|
479
341
|
{usageCounts.used > 0 && usageCounts.unused > 0 && (
|
|
480
342
|
<>
|
|
481
343
|
<span className="w-px self-stretch bg-panel-input mx-0.5" aria-hidden="true" />
|
|
@@ -505,7 +367,6 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
505
367
|
)}
|
|
506
368
|
</div>
|
|
507
369
|
|
|
508
|
-
{/* Asset list */}
|
|
509
370
|
<div className="flex-1 overflow-y-auto mt-1">
|
|
510
371
|
{viewMode === "global" ? (
|
|
511
372
|
<GlobalAssetsView searchQuery={searchQuery} />
|
|
@@ -553,34 +414,38 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
553
414
|
isCopied={copiedPath === a}
|
|
554
415
|
onDelete={onDelete}
|
|
555
416
|
onRename={onRename}
|
|
417
|
+
onAddAssetToTimeline={onAddAssetToTimeline}
|
|
556
418
|
/>
|
|
557
419
|
))}
|
|
558
|
-
{(cat === "images" || cat === "video") &&
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
420
|
+
{(cat === "images" || cat === "video") && (
|
|
421
|
+
<div className="grid grid-cols-2 gap-1 px-2 pb-1">
|
|
422
|
+
{categorized[cat].map((a) => (
|
|
423
|
+
<AssetCard
|
|
424
|
+
key={a}
|
|
425
|
+
projectId={projectId}
|
|
426
|
+
asset={a}
|
|
427
|
+
used={usedPaths.has(a)}
|
|
428
|
+
duration={manifest.get(a)?.duration}
|
|
429
|
+
onCopy={handleCopyPath}
|
|
430
|
+
isCopied={copiedPath === a}
|
|
431
|
+
onDelete={onDelete}
|
|
432
|
+
onRename={onRename}
|
|
433
|
+
onAddAssetToTimeline={onAddAssetToTimeline}
|
|
434
|
+
/>
|
|
435
|
+
))}
|
|
436
|
+
</div>
|
|
437
|
+
)}
|
|
572
438
|
{cat === "fonts" &&
|
|
573
439
|
categorized[cat].map((a) => (
|
|
574
|
-
<
|
|
440
|
+
<FontRow
|
|
575
441
|
key={a}
|
|
576
|
-
projectId={projectId}
|
|
577
442
|
asset={a}
|
|
578
443
|
used={usedPaths.has(a)}
|
|
579
444
|
onCopy={handleCopyPath}
|
|
580
445
|
isCopied={copiedPath === a}
|
|
581
446
|
onDelete={onDelete}
|
|
582
447
|
onRename={onRename}
|
|
583
|
-
|
|
448
|
+
onAddAssetToTimeline={onAddAssetToTimeline}
|
|
584
449
|
/>
|
|
585
450
|
))}
|
|
586
451
|
</div>
|
|
@@ -2,6 +2,10 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
|
|
2
2
|
import { ContextMenu } from "./AssetContextMenu";
|
|
3
3
|
import { basename, getAudioSubtype } from "./assetHelpers";
|
|
4
4
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
|
+
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
7
|
+
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
|
|
8
|
+
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
|
|
5
9
|
|
|
6
10
|
export function AudioRow({
|
|
7
11
|
projectId,
|
|
@@ -12,6 +16,7 @@ export function AudioRow({
|
|
|
12
16
|
isCopied,
|
|
13
17
|
onDelete,
|
|
14
18
|
onRename,
|
|
19
|
+
onAddAssetToTimeline,
|
|
15
20
|
}: {
|
|
16
21
|
projectId: string;
|
|
17
22
|
asset: string;
|
|
@@ -21,6 +26,7 @@ export function AudioRow({
|
|
|
21
26
|
isCopied: boolean;
|
|
22
27
|
onDelete?: (path: string) => void;
|
|
23
28
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
29
|
+
onAddAssetToTimeline?: (path: string) => void;
|
|
24
30
|
}) {
|
|
25
31
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
26
32
|
const [playing, setPlaying] = useState(false);
|
|
@@ -32,7 +38,36 @@ export function AudioRow({
|
|
|
32
38
|
const animRef = useRef<number>(0);
|
|
33
39
|
const name = basename(asset);
|
|
34
40
|
const subtype = getAudioSubtype(asset);
|
|
35
|
-
const serveUrl =
|
|
41
|
+
const serveUrl = resolveMediaPreviewUrl(asset, projectId);
|
|
42
|
+
|
|
43
|
+
// CapCut-style click behavior: drag-threshold gate.
|
|
44
|
+
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
|
|
45
|
+
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
46
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
47
|
+
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
|
|
48
|
+
|
|
49
|
+
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
|
50
|
+
pointerDownRef.current = { x: e.clientX, y: e.clientY };
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const handlePointerUp = useCallback(
|
|
54
|
+
(e: React.PointerEvent) => {
|
|
55
|
+
const origin = pointerDownRef.current;
|
|
56
|
+
pointerDownRef.current = null;
|
|
57
|
+
if (!origin) return;
|
|
58
|
+
if (!isPointerClick(e.clientX - origin.x, e.clientY - origin.y)) return;
|
|
59
|
+
if (used) {
|
|
60
|
+
const clip = findClipForAsset(elements, asset);
|
|
61
|
+
if (clip) {
|
|
62
|
+
setSelectedElementId(clip.key ?? clip.id);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Not added → preview overlay (audio player)
|
|
67
|
+
setPreviewAsset(asset, projectId);
|
|
68
|
+
},
|
|
69
|
+
[used, elements, asset, projectId, setSelectedElementId, setPreviewAsset],
|
|
70
|
+
);
|
|
36
71
|
|
|
37
72
|
useEffect(() => {
|
|
38
73
|
return () => {
|
|
@@ -109,7 +144,8 @@ export function AudioRow({
|
|
|
109
144
|
<>
|
|
110
145
|
<div
|
|
111
146
|
draggable
|
|
112
|
-
|
|
147
|
+
onPointerDown={handlePointerDown}
|
|
148
|
+
onPointerUp={handlePointerUp}
|
|
113
149
|
onDragStart={(e) => {
|
|
114
150
|
e.dataTransfer.effectAllowed = "copy";
|
|
115
151
|
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
|
@@ -195,6 +231,7 @@ export function AudioRow({
|
|
|
195
231
|
onCopy={onCopy}
|
|
196
232
|
onDelete={onDelete}
|
|
197
233
|
onRename={onRename}
|
|
234
|
+
onAddAtPlayhead={onAddAssetToTimeline}
|
|
198
235
|
/>
|
|
199
236
|
)}
|
|
200
237
|
</>
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { usePlayerStore } from "../../player";
|
|
11
11
|
import { formatTime } from "../../player/lib/time";
|
|
12
12
|
import { useStudioShellContext } from "../../contexts/StudioContext";
|
|
13
|
+
import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
13
14
|
export interface BlockPreviewInfo {
|
|
14
15
|
videoUrl?: string;
|
|
15
16
|
posterUrl?: string;
|
|
@@ -383,10 +384,16 @@ function BlockCard({
|
|
|
383
384
|
return (
|
|
384
385
|
<div
|
|
385
386
|
className="group/card rounded-md overflow-hidden cursor-pointer transition-colors bg-neutral-900 hover:bg-neutral-800"
|
|
387
|
+
draggable
|
|
388
|
+
onDragStart={(e) => {
|
|
389
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
390
|
+
e.dataTransfer.setData(TIMELINE_BLOCK_MIME, JSON.stringify({ name }));
|
|
391
|
+
e.dataTransfer.setData("text/plain", name);
|
|
392
|
+
handleLeave(); // cancel the hover-preview timer so it doesn't fire mid-drag
|
|
393
|
+
}}
|
|
386
394
|
onPointerEnter={handleEnter}
|
|
387
395
|
onPointerLeave={handleLeave}
|
|
388
396
|
>
|
|
389
|
-
{/* Thumbnail */}
|
|
390
397
|
<div className="aspect-video w-full overflow-hidden relative">
|
|
391
398
|
{hovered && videoUrl ? (
|
|
392
399
|
<video
|
|
@@ -60,6 +60,7 @@ interface LeftSidebarProps {
|
|
|
60
60
|
onAddBlock?: (blockName: string) => void;
|
|
61
61
|
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
|
62
62
|
takeoverContent?: ReactNode;
|
|
63
|
+
onAddAssetToTimeline?: (path: string) => void;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export const LeftSidebar = memo(
|
|
@@ -92,6 +93,7 @@ export const LeftSidebar = memo(
|
|
|
92
93
|
onAddBlock,
|
|
93
94
|
onPreviewBlock,
|
|
94
95
|
takeoverContent,
|
|
96
|
+
onAddAssetToTimeline,
|
|
95
97
|
},
|
|
96
98
|
ref,
|
|
97
99
|
) {
|
|
@@ -111,7 +113,7 @@ export const LeftSidebar = memo(
|
|
|
111
113
|
|
|
112
114
|
return (
|
|
113
115
|
<div
|
|
114
|
-
className="flex flex-col h-full
|
|
116
|
+
className="flex flex-col h-full overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
|
|
115
117
|
style={{ width }}
|
|
116
118
|
>
|
|
117
119
|
{takeoverContent ? (
|
|
@@ -230,6 +232,7 @@ export const LeftSidebar = memo(
|
|
|
230
232
|
onImport={onImportFiles}
|
|
231
233
|
onDelete={onDeleteFile}
|
|
232
234
|
onRename={onRenameFile}
|
|
235
|
+
onAddAssetToTimeline={onAddAssetToTimeline}
|
|
233
236
|
/>
|
|
234
237
|
)}
|
|
235
238
|
{tab === "code" && (
|