@hyperframes/studio 0.7.5 → 0.7.7
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-BwFzbjZQ.js → index-B2YXvFxf.js} +1 -1
- package/dist/assets/index-BSkUuN8g.css +1 -0
- package/dist/assets/index-BeRh2hMe.js +375 -0
- package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
- package/dist/chunk-KZXYQYIU.js +876 -0
- package/dist/chunk-KZXYQYIU.js.map +1 -0
- package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
- package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.html +2 -2
- package/dist/index.js +3326 -2939
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/components/StudioPreviewArea.tsx +10 -3
- package/src/components/StudioRightPanel.tsx +4 -0
- package/src/components/editor/AnimationCard.tsx +77 -250
- package/src/components/editor/AnimationCardParts.tsx +220 -0
- package/src/components/editor/BlockParamsPanel.tsx +4 -8
- package/src/components/editor/DomEditOverlay.tsx +132 -48
- package/src/components/editor/EaseCurveSection.tsx +177 -101
- package/src/components/editor/GsapAnimationSection.tsx +4 -0
- package/src/components/editor/KeyframeEaseList.tsx +127 -0
- package/src/components/editor/OffCanvasIndicators.tsx +111 -0
- package/src/components/editor/PropertyPanel.tsx +63 -56
- package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
- package/src/components/editor/gsapAnimationConstants.ts +11 -32
- package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
- package/src/components/editor/manualOffsetDrag.ts +8 -0
- package/src/components/editor/marqueeCommit.ts +168 -0
- package/src/components/editor/propertyPanelHelpers.ts +7 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
- package/src/components/editor/snapTargetCollection.ts +0 -5
- package/src/components/editor/useMotionPathData.ts +2 -1
- package/src/components/panels/SlideshowPanel.tsx +0 -1
- package/src/components/sidebar/AssetContextMenu.tsx +97 -0
- package/src/components/sidebar/AssetsTab.tsx +542 -254
- package/src/components/sidebar/assetHelpers.ts +40 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/hooks/gsapDragCommit.ts +101 -0
- package/src/hooks/gsapDragPositionCommit.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.ts +28 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
- package/src/hooks/gsapRuntimeReaders.ts +1 -6
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useDomEditPreviewSync.ts +5 -0
- package/src/hooks/useDomEditSession.ts +43 -0
- package/src/hooks/useDomEditTextCommits.ts +3 -12
- package/src/hooks/useDomEditWiring.ts +3 -0
- package/src/hooks/useDomSelection.ts +46 -0
- package/src/hooks/useGestureCommit.ts +26 -7
- package/src/hooks/useGestureRecording.ts +2 -16
- package/src/hooks/useGsapAnimationOps.ts +2 -2
- package/src/hooks/useGsapAwareEditing.ts +0 -4
- package/src/hooks/useGsapScriptCommits.ts +0 -12
- package/src/hooks/useGsapTweenCache.test.ts +45 -1
- package/src/hooks/useGsapTweenCache.ts +151 -42
- package/src/hooks/useMusicBeatAnalysis.ts +36 -21
- package/src/hooks/useRazorSplit.ts +0 -3
- package/src/player/components/Player.tsx +0 -5
- package/src/player/components/timelineIcons.tsx +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +4 -14
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
- package/src/player/lib/timelineIframeHelpers.ts +2 -6
- package/src/telemetry/client.ts +2 -0
- package/src/utils/gestureSmoother.test.ts +48 -0
- package/src/utils/gestureSmoother.ts +46 -0
- package/src/utils/marqueeGeometry.test.ts +123 -0
- package/src/utils/marqueeGeometry.ts +172 -0
- package/src/utils/optimisticUpdate.ts +1 -2
- package/src/utils/sourcePatcher.ts +0 -10
- package/src/utils/velocityEaseFitter.test.ts +58 -0
- package/src/utils/velocityEaseFitter.ts +121 -0
- package/dist/assets/index-D_JGXmfx.js +0 -374
- package/dist/assets/index-DzWIinxk.css +0 -1
- package/src/utils/editDebugLog.ts +0 -16
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { memo, useState, useCallback, useRef } from "react";
|
|
1
|
+
import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react";
|
|
2
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
|
-
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT,
|
|
3
|
+
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
|
|
4
4
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
5
|
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
6
|
+
import { ContextMenu, DeleteConfirm } from "./AssetContextMenu";
|
|
7
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
|
+
import {
|
|
9
|
+
type MediaCategory,
|
|
10
|
+
getCategory,
|
|
11
|
+
getAudioSubtype,
|
|
12
|
+
basename,
|
|
13
|
+
ext,
|
|
14
|
+
CATEGORY_LABELS,
|
|
15
|
+
FILTER_ORDER,
|
|
16
|
+
} from "./assetHelpers";
|
|
6
17
|
|
|
7
18
|
interface AssetsTabProps {
|
|
8
19
|
projectId: string;
|
|
@@ -12,98 +23,244 @@ interface AssetsTabProps {
|
|
|
12
23
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
13
24
|
}
|
|
14
25
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
function AudioRow({
|
|
27
|
+
projectId,
|
|
28
|
+
asset,
|
|
29
|
+
used,
|
|
30
|
+
meta,
|
|
31
|
+
onCopy,
|
|
32
|
+
isCopied,
|
|
33
|
+
onDelete,
|
|
34
|
+
onRename,
|
|
22
35
|
}: {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
projectId: string;
|
|
37
|
+
asset: string;
|
|
38
|
+
used: boolean;
|
|
39
|
+
meta?: { description?: string; duration?: number };
|
|
40
|
+
onCopy: (path: string) => void;
|
|
41
|
+
isCopied: boolean;
|
|
42
|
+
onDelete?: (path: string) => void;
|
|
43
|
+
onRename?: (oldPath: string, newPath: string) => void;
|
|
28
44
|
}) {
|
|
45
|
+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
46
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
47
|
+
const [playing, setPlaying] = useState(false);
|
|
48
|
+
const [bars, setBars] = useState<number[]>([]);
|
|
49
|
+
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
50
|
+
const actxRef = useRef<AudioContext | null>(null);
|
|
51
|
+
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
52
|
+
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
|
53
|
+
const animRef = useRef<number>(0);
|
|
54
|
+
const name = basename(asset);
|
|
55
|
+
const subtype = getAudioSubtype(asset);
|
|
56
|
+
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
return () => {
|
|
60
|
+
cancelAnimationFrame(animRef.current);
|
|
61
|
+
audioRef.current?.pause();
|
|
62
|
+
actxRef.current?.close();
|
|
63
|
+
};
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (playing) {
|
|
68
|
+
const barCount = 24;
|
|
69
|
+
const loop = () => {
|
|
70
|
+
const analyser = analyserRef.current;
|
|
71
|
+
if (!analyser) {
|
|
72
|
+
animRef.current = requestAnimationFrame(loop);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const data = new Uint8Array(analyser.frequencyBinCount);
|
|
76
|
+
analyser.getByteFrequencyData(data);
|
|
77
|
+
const step = Math.floor(data.length / barCount);
|
|
78
|
+
const next: number[] = [];
|
|
79
|
+
for (let i = 0; i < barCount; i++) {
|
|
80
|
+
let sum = 0;
|
|
81
|
+
for (let j = 0; j < step; j++) sum += data[i * step + j];
|
|
82
|
+
next.push(sum / step / 255);
|
|
83
|
+
}
|
|
84
|
+
setBars(next);
|
|
85
|
+
if (audioRef.current && !audioRef.current.paused)
|
|
86
|
+
animRef.current = requestAnimationFrame(loop);
|
|
87
|
+
};
|
|
88
|
+
animRef.current = requestAnimationFrame(loop);
|
|
89
|
+
} else {
|
|
90
|
+
setBars([]);
|
|
91
|
+
}
|
|
92
|
+
return () => cancelAnimationFrame(animRef.current);
|
|
93
|
+
}, [playing]);
|
|
94
|
+
|
|
95
|
+
const togglePlay = useCallback(async () => {
|
|
96
|
+
if (playing) {
|
|
97
|
+
audioRef.current?.pause();
|
|
98
|
+
setPlaying(false);
|
|
99
|
+
cancelAnimationFrame(animRef.current);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!actxRef.current) {
|
|
104
|
+
actxRef.current = new AudioContext();
|
|
105
|
+
analyserRef.current = actxRef.current.createAnalyser();
|
|
106
|
+
analyserRef.current.fftSize = 256;
|
|
107
|
+
analyserRef.current.smoothingTimeConstant = 0.7;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!audioRef.current) {
|
|
111
|
+
const el = new Audio();
|
|
112
|
+
el.onended = () => {
|
|
113
|
+
setPlaying(false);
|
|
114
|
+
cancelAnimationFrame(animRef.current);
|
|
115
|
+
};
|
|
116
|
+
audioRef.current = el;
|
|
117
|
+
sourceRef.current = actxRef.current.createMediaElementSource(el);
|
|
118
|
+
sourceRef.current.connect(analyserRef.current!);
|
|
119
|
+
analyserRef.current!.connect(actxRef.current.destination);
|
|
120
|
+
el.src = serveUrl;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (actxRef.current.state === "suspended") await actxRef.current.resume();
|
|
124
|
+
audioRef.current.currentTime = 0;
|
|
125
|
+
await audioRef.current.play();
|
|
126
|
+
setPlaying(true);
|
|
127
|
+
}, [serveUrl, playing]);
|
|
128
|
+
|
|
29
129
|
return (
|
|
30
130
|
<>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
131
|
+
<div
|
|
132
|
+
draggable
|
|
133
|
+
onClick={() => onCopy(asset)}
|
|
134
|
+
onDragStart={(e) => {
|
|
135
|
+
e.dataTransfer.effectAllowed = "copy";
|
|
136
|
+
e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
|
|
137
|
+
e.dataTransfer.setData("text/plain", asset);
|
|
138
|
+
}}
|
|
139
|
+
onContextMenu={(e) => {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
setContextMenu({ x: e.clientX, y: e.clientY });
|
|
142
|
+
}}
|
|
143
|
+
className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${
|
|
144
|
+
playing
|
|
145
|
+
? "bg-panel-accent/[0.06]"
|
|
146
|
+
: isCopied
|
|
147
|
+
? "bg-panel-accent/10"
|
|
148
|
+
: "hover:bg-panel-surface-hover"
|
|
149
|
+
}`}
|
|
150
|
+
>
|
|
151
|
+
<button
|
|
152
|
+
className={`w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center transition-all ${
|
|
153
|
+
playing
|
|
154
|
+
? "bg-panel-accent/15 text-panel-accent"
|
|
155
|
+
: "text-panel-text-5 group-hover:text-panel-text-3"
|
|
156
|
+
}`}
|
|
157
|
+
onClick={(e) => {
|
|
158
|
+
e.stopPropagation();
|
|
159
|
+
togglePlay();
|
|
39
160
|
}}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
height="
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
>
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
161
|
+
>
|
|
162
|
+
{playing ? (
|
|
163
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
164
|
+
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
165
|
+
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
166
|
+
</svg>
|
|
167
|
+
) : (
|
|
168
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
169
|
+
<polygon points="6,4 20,12 6,20" />
|
|
170
|
+
</svg>
|
|
171
|
+
)}
|
|
172
|
+
</button>
|
|
173
|
+
<div className="min-w-0 flex-1">
|
|
174
|
+
<div className="flex items-center gap-1.5">
|
|
175
|
+
<span
|
|
176
|
+
className={`text-[12px] font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
177
|
+
>
|
|
178
|
+
{name}
|
|
179
|
+
</span>
|
|
180
|
+
{!playing && (
|
|
181
|
+
<span className="text-[11px] text-panel-text-5 flex-shrink-0">
|
|
182
|
+
{meta?.duration ? `${meta.duration}s · ` : ""}
|
|
183
|
+
{subtype}
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
{used && (
|
|
187
|
+
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded flex-shrink-0">
|
|
188
|
+
in use
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
{bars.length > 0 && (
|
|
193
|
+
<div className="flex items-end gap-[2px] h-[14px] mt-0.5">
|
|
194
|
+
{bars.map((v, i) => (
|
|
195
|
+
<div
|
|
196
|
+
key={i}
|
|
197
|
+
className="flex-1 rounded-[1px]"
|
|
198
|
+
style={{
|
|
199
|
+
height: `${Math.max(10, v * 100)}%`,
|
|
200
|
+
background: `linear-gradient(to top, rgba(60, 230, 172, ${0.3 + v * 0.5}), rgba(60, 230, 172, ${0.5 + v * 0.5}))`,
|
|
201
|
+
transition: "height 80ms ease-out",
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
58
207
|
</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
{contextMenu && (
|
|
211
|
+
<ContextMenu
|
|
212
|
+
x={contextMenu.x}
|
|
213
|
+
y={contextMenu.y}
|
|
214
|
+
asset={asset}
|
|
215
|
+
onClose={() => setContextMenu(null)}
|
|
216
|
+
onCopy={onCopy}
|
|
217
|
+
onDelete={onDelete}
|
|
218
|
+
onRename={onRename}
|
|
219
|
+
/>
|
|
59
220
|
)}
|
|
60
|
-
{
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
className="text-neutral-600"
|
|
70
|
-
>
|
|
71
|
-
<path
|
|
72
|
-
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
|
73
|
-
strokeLinecap="round"
|
|
74
|
-
strokeLinejoin="round"
|
|
75
|
-
/>
|
|
76
|
-
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
|
|
77
|
-
</svg>
|
|
78
|
-
</div>
|
|
221
|
+
{confirmDelete && (
|
|
222
|
+
<DeleteConfirm
|
|
223
|
+
name={name}
|
|
224
|
+
onConfirm={() => {
|
|
225
|
+
onDelete?.(asset);
|
|
226
|
+
setConfirmDelete(false);
|
|
227
|
+
}}
|
|
228
|
+
onCancel={() => setConfirmDelete(false)}
|
|
229
|
+
/>
|
|
79
230
|
)}
|
|
80
231
|
</>
|
|
81
232
|
);
|
|
82
233
|
}
|
|
83
234
|
|
|
84
|
-
function
|
|
235
|
+
function ImageCard({
|
|
85
236
|
projectId,
|
|
86
237
|
asset,
|
|
238
|
+
used,
|
|
87
239
|
onCopy,
|
|
88
240
|
isCopied,
|
|
89
241
|
onDelete,
|
|
90
242
|
onRename,
|
|
243
|
+
size,
|
|
91
244
|
}: {
|
|
92
245
|
projectId: string;
|
|
93
246
|
asset: string;
|
|
247
|
+
used: boolean;
|
|
94
248
|
onCopy: (path: string) => void;
|
|
95
249
|
isCopied: boolean;
|
|
96
250
|
onDelete?: (path: string) => void;
|
|
97
251
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
252
|
+
size: "large" | "small";
|
|
98
253
|
}) {
|
|
99
|
-
const [hovered, setHovered] = useState(false);
|
|
100
254
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
101
|
-
const [
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const name = asset.split("/").pop() ?? asset;
|
|
255
|
+
const [hovered, setHovered] = useState(false);
|
|
256
|
+
const name = basename(asset);
|
|
257
|
+
const extension = ext(asset);
|
|
105
258
|
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
|
106
259
|
const isVideo = VIDEO_EXT.test(asset);
|
|
260
|
+
const isImage = IMAGE_EXT.test(asset);
|
|
261
|
+
|
|
262
|
+
const thumbW = size === "large" ? "w-full" : "w-[50px]";
|
|
263
|
+
const thumbH = size === "large" ? "h-[100px]" : "h-[32px]";
|
|
107
264
|
|
|
108
265
|
return (
|
|
109
266
|
<>
|
|
@@ -121,158 +278,103 @@ function AssetCard({
|
|
|
121
278
|
}}
|
|
122
279
|
onPointerEnter={() => setHovered(true)}
|
|
123
280
|
onPointerLeave={() => setHovered(false)}
|
|
124
|
-
className={`
|
|
125
|
-
|
|
126
|
-
? "bg-studio-accent/10
|
|
127
|
-
:
|
|
281
|
+
className={`transition-colors cursor-pointer ${
|
|
282
|
+
size === "large"
|
|
283
|
+
? `px-2.5 py-1 ${isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/30"}`
|
|
284
|
+
: `px-2.5 py-1.5 flex items-center gap-2.5 ${
|
|
285
|
+
isCopied
|
|
286
|
+
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
|
287
|
+
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
288
|
+
}`
|
|
128
289
|
}`}
|
|
129
290
|
>
|
|
130
|
-
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const dir = asset.includes("/")
|
|
161
|
-
? asset.slice(0, asset.lastIndexOf("/") + 1)
|
|
162
|
-
: "";
|
|
163
|
-
onRename?.(asset, dir + trimmed);
|
|
164
|
-
}
|
|
165
|
-
setRenaming(false);
|
|
166
|
-
} else if (e.key === "Escape") {
|
|
167
|
-
setRenaming(false);
|
|
168
|
-
}
|
|
169
|
-
}}
|
|
170
|
-
onBlur={() => {
|
|
171
|
-
const trimmed = renameName.trim();
|
|
172
|
-
if (trimmed && trimmed !== name) {
|
|
173
|
-
const dir = asset.includes("/") ? asset.slice(0, asset.lastIndexOf("/") + 1) : "";
|
|
174
|
-
onRename?.(asset, dir + trimmed);
|
|
175
|
-
}
|
|
176
|
-
setRenaming(false);
|
|
177
|
-
}}
|
|
178
|
-
onClick={(e) => e.stopPropagation()}
|
|
179
|
-
className="w-full bg-neutral-800 text-neutral-200 text-[11px] px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-studio-accent"
|
|
180
|
-
spellCheck={false}
|
|
181
|
-
/>
|
|
182
|
-
) : (
|
|
183
|
-
<>
|
|
184
|
-
<span className="text-[11px] font-medium text-neutral-300 truncate block">
|
|
291
|
+
{size === "large" ? (
|
|
292
|
+
<div className="flex flex-col gap-1">
|
|
293
|
+
<div className={`${thumbW} ${thumbH} rounded overflow-hidden bg-neutral-900 relative`}>
|
|
294
|
+
{isImage && (
|
|
295
|
+
<img
|
|
296
|
+
src={serveUrl}
|
|
297
|
+
alt={name}
|
|
298
|
+
loading="lazy"
|
|
299
|
+
className="w-full h-full object-cover"
|
|
300
|
+
onError={(e) => {
|
|
301
|
+
(e.target as HTMLImageElement).style.display = "none";
|
|
302
|
+
}}
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
305
|
+
{isVideo && <VideoFrameThumbnail src={serveUrl} />}
|
|
306
|
+
{isVideo && hovered && (
|
|
307
|
+
<video
|
|
308
|
+
src={serveUrl}
|
|
309
|
+
autoPlay
|
|
310
|
+
muted
|
|
311
|
+
loop
|
|
312
|
+
playsInline
|
|
313
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
314
|
+
/>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
<div className="flex items-center gap-1.5">
|
|
318
|
+
<span
|
|
319
|
+
className={`text-xs font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
320
|
+
>
|
|
185
321
|
{name}
|
|
186
322
|
</span>
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
323
|
+
<span className="text-[10px] text-neutral-600">{extension}</span>
|
|
324
|
+
{used && (
|
|
325
|
+
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
|
|
326
|
+
in use
|
|
327
|
+
</span>
|
|
191
328
|
)}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
) : (
|
|
332
|
+
<>
|
|
333
|
+
<div className="w-[50px] h-[32px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 flex items-center justify-center">
|
|
334
|
+
{isImage && (
|
|
335
|
+
<img
|
|
336
|
+
src={serveUrl}
|
|
337
|
+
alt={name}
|
|
338
|
+
loading="lazy"
|
|
339
|
+
className="w-full h-full object-cover"
|
|
340
|
+
onError={(e) => {
|
|
341
|
+
(e.target as HTMLImageElement).style.display = "none";
|
|
342
|
+
}}
|
|
343
|
+
/>
|
|
344
|
+
)}
|
|
345
|
+
{!isImage && (
|
|
346
|
+
<span className="text-[9px] font-medium text-neutral-700">{extension}</span>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
<div className="min-w-0 flex-1">
|
|
350
|
+
<span
|
|
351
|
+
className={`text-xs font-medium truncate block ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
352
|
+
>
|
|
353
|
+
{name}
|
|
354
|
+
</span>
|
|
355
|
+
<div className="flex items-center gap-1.5">
|
|
356
|
+
<span className="text-[10px] text-neutral-600 truncate">{extension}</span>
|
|
357
|
+
{used && (
|
|
358
|
+
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
|
|
359
|
+
in use
|
|
360
|
+
</span>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</>
|
|
365
|
+
)}
|
|
195
366
|
</div>
|
|
196
367
|
|
|
197
|
-
{/* Context menu */}
|
|
198
368
|
{contextMenu && (
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[140px] text-xs"
|
|
209
|
-
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
210
|
-
>
|
|
211
|
-
<button
|
|
212
|
-
onClick={(e) => {
|
|
213
|
-
e.stopPropagation();
|
|
214
|
-
onCopy(asset);
|
|
215
|
-
setContextMenu(null);
|
|
216
|
-
}}
|
|
217
|
-
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
|
218
|
-
>
|
|
219
|
-
Copy path
|
|
220
|
-
</button>
|
|
221
|
-
{onRename && (
|
|
222
|
-
<button
|
|
223
|
-
onClick={(e) => {
|
|
224
|
-
e.stopPropagation();
|
|
225
|
-
setRenameName(name);
|
|
226
|
-
setRenaming(true);
|
|
227
|
-
setContextMenu(null);
|
|
228
|
-
}}
|
|
229
|
-
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
|
230
|
-
>
|
|
231
|
-
Rename
|
|
232
|
-
</button>
|
|
233
|
-
)}
|
|
234
|
-
{onDelete && (
|
|
235
|
-
<button
|
|
236
|
-
onClick={(e) => {
|
|
237
|
-
e.stopPropagation();
|
|
238
|
-
setConfirmDelete(true);
|
|
239
|
-
setContextMenu(null);
|
|
240
|
-
}}
|
|
241
|
-
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-neutral-800 transition-colors"
|
|
242
|
-
>
|
|
243
|
-
Delete
|
|
244
|
-
</button>
|
|
245
|
-
)}
|
|
246
|
-
</div>
|
|
247
|
-
</div>
|
|
248
|
-
)}
|
|
249
|
-
|
|
250
|
-
{/* Delete confirmation */}
|
|
251
|
-
{confirmDelete && (
|
|
252
|
-
<div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex items-center justify-between gap-2">
|
|
253
|
-
<span className="text-[10px] text-red-400 truncate">Delete {name}?</span>
|
|
254
|
-
<div className="flex items-center gap-1 flex-shrink-0">
|
|
255
|
-
<button
|
|
256
|
-
onClick={(e) => {
|
|
257
|
-
e.stopPropagation();
|
|
258
|
-
onDelete?.(asset);
|
|
259
|
-
setConfirmDelete(false);
|
|
260
|
-
}}
|
|
261
|
-
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 transition-colors"
|
|
262
|
-
>
|
|
263
|
-
Delete
|
|
264
|
-
</button>
|
|
265
|
-
<button
|
|
266
|
-
onClick={(e) => {
|
|
267
|
-
e.stopPropagation();
|
|
268
|
-
setConfirmDelete(false);
|
|
269
|
-
}}
|
|
270
|
-
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
|
|
271
|
-
>
|
|
272
|
-
Cancel
|
|
273
|
-
</button>
|
|
274
|
-
</div>
|
|
275
|
-
</div>
|
|
369
|
+
<ContextMenu
|
|
370
|
+
x={contextMenu.x}
|
|
371
|
+
y={contextMenu.y}
|
|
372
|
+
asset={asset}
|
|
373
|
+
onClose={() => setContextMenu(null)}
|
|
374
|
+
onCopy={onCopy}
|
|
375
|
+
onDelete={onDelete}
|
|
376
|
+
onRename={onRename}
|
|
377
|
+
/>
|
|
276
378
|
)}
|
|
277
379
|
</>
|
|
278
380
|
);
|
|
@@ -288,6 +390,33 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
288
390
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
289
391
|
const [dragOver, setDragOver] = useState(false);
|
|
290
392
|
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
|
393
|
+
const [activeFilter, setActiveFilter] = useState<MediaCategory | "all">("all");
|
|
394
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
395
|
+
const [manifest, setManifest] = useState<
|
|
396
|
+
Map<string, { description?: string; duration?: number; width?: number; height?: number }>
|
|
397
|
+
>(new Map());
|
|
398
|
+
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
fetch(`/api/projects/${projectId}/preview/.media/manifest.jsonl`)
|
|
401
|
+
.then((r) => (r.ok ? r.text() : ""))
|
|
402
|
+
.then((text) => {
|
|
403
|
+
const m = new Map<
|
|
404
|
+
string,
|
|
405
|
+
{ description?: string; duration?: number; width?: number; height?: number }
|
|
406
|
+
>();
|
|
407
|
+
for (const line of text.split("\n")) {
|
|
408
|
+
if (!line.trim()) continue;
|
|
409
|
+
try {
|
|
410
|
+
const rec = JSON.parse(line);
|
|
411
|
+
if (rec.path) m.set(rec.path, rec);
|
|
412
|
+
} catch {
|
|
413
|
+
/* skip */
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
setManifest(m);
|
|
417
|
+
})
|
|
418
|
+
.catch(() => {});
|
|
419
|
+
}, [projectId, assets]);
|
|
291
420
|
|
|
292
421
|
const handleDrop = useCallback(
|
|
293
422
|
(e: React.DragEvent) => {
|
|
@@ -306,7 +435,56 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
306
435
|
}
|
|
307
436
|
}, []);
|
|
308
437
|
|
|
309
|
-
const
|
|
438
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
439
|
+
const usedPaths = useMemo(() => {
|
|
440
|
+
const paths = new Set<string>();
|
|
441
|
+
for (const el of elements) {
|
|
442
|
+
if (el.src) {
|
|
443
|
+
const src = el.src.replace(/^\/api\/projects\/[^/]+\/preview\//, "");
|
|
444
|
+
paths.add(src);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return paths;
|
|
448
|
+
}, [elements]);
|
|
449
|
+
|
|
450
|
+
const mediaAssets = useMemo(() => {
|
|
451
|
+
const all = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a));
|
|
452
|
+
if (!searchQuery) return all;
|
|
453
|
+
const q = searchQuery.toLowerCase();
|
|
454
|
+
return all.filter((a) => {
|
|
455
|
+
if (basename(a).toLowerCase().includes(q)) return true;
|
|
456
|
+
const rec = manifest.get(a);
|
|
457
|
+
return rec?.description?.toLowerCase().includes(q);
|
|
458
|
+
});
|
|
459
|
+
}, [assets, searchQuery, manifest]);
|
|
460
|
+
|
|
461
|
+
const categorized = useMemo(() => {
|
|
462
|
+
const groups: Record<MediaCategory, string[]> = { audio: [], images: [], video: [], fonts: [] };
|
|
463
|
+
for (const a of mediaAssets) {
|
|
464
|
+
const cat = getCategory(a);
|
|
465
|
+
if (cat) groups[cat].push(a);
|
|
466
|
+
}
|
|
467
|
+
// Sort: used assets first within each category
|
|
468
|
+
for (const cat of FILTER_ORDER) {
|
|
469
|
+
groups[cat].sort((a, b) => {
|
|
470
|
+
const aUsed = usedPaths.has(a) ? 0 : 1;
|
|
471
|
+
const bUsed = usedPaths.has(b) ? 0 : 1;
|
|
472
|
+
return aUsed - bUsed;
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return groups;
|
|
476
|
+
}, [mediaAssets, usedPaths]);
|
|
477
|
+
|
|
478
|
+
const counts = useMemo(() => {
|
|
479
|
+
const c: Record<string, number> = { all: mediaAssets.length };
|
|
480
|
+
for (const cat of FILTER_ORDER) c[cat] = categorized[cat].length;
|
|
481
|
+
return c;
|
|
482
|
+
}, [mediaAssets, categorized]);
|
|
483
|
+
|
|
484
|
+
const visibleCategories =
|
|
485
|
+
activeFilter === "all"
|
|
486
|
+
? FILTER_ORDER.filter((c) => categorized[c].length > 0)
|
|
487
|
+
: [activeFilter as MediaCategory].filter((c) => categorized[c].length > 0);
|
|
310
488
|
|
|
311
489
|
return (
|
|
312
490
|
<div
|
|
@@ -318,44 +496,111 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
318
496
|
onDragLeave={() => setDragOver(false)}
|
|
319
497
|
onDrop={handleDrop}
|
|
320
498
|
>
|
|
321
|
-
{/*
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
width="12"
|
|
330
|
-
height="12"
|
|
331
|
-
viewBox="0 0 24 24"
|
|
332
|
-
fill="none"
|
|
333
|
-
stroke="currentColor"
|
|
334
|
-
strokeWidth="2.5"
|
|
335
|
-
strokeLinecap="round"
|
|
499
|
+
{/* Header — matches design panel Section pattern */}
|
|
500
|
+
<div className="px-4 pt-2.5 pb-1.5 flex-shrink-0">
|
|
501
|
+
{/* Import */}
|
|
502
|
+
{onImport && (
|
|
503
|
+
<>
|
|
504
|
+
<button
|
|
505
|
+
onClick={() => fileInputRef.current?.click()}
|
|
506
|
+
className="w-full flex items-center justify-center gap-1.5 rounded-md bg-panel-input px-3 py-[7px] text-[11px] font-medium text-panel-text-3 hover:text-panel-text-1 transition-colors mb-2.5"
|
|
336
507
|
>
|
|
337
|
-
<
|
|
508
|
+
<svg
|
|
509
|
+
width="11"
|
|
510
|
+
height="11"
|
|
511
|
+
viewBox="0 0 24 24"
|
|
512
|
+
fill="none"
|
|
513
|
+
stroke="currentColor"
|
|
514
|
+
strokeWidth="2.5"
|
|
515
|
+
strokeLinecap="round"
|
|
516
|
+
>
|
|
517
|
+
<path d="M12 5v14M5 12h14" />
|
|
518
|
+
</svg>
|
|
519
|
+
Import media
|
|
520
|
+
</button>
|
|
521
|
+
<input
|
|
522
|
+
ref={fileInputRef}
|
|
523
|
+
type="file"
|
|
524
|
+
accept="video/*,image/*,audio/*,font/*"
|
|
525
|
+
multiple
|
|
526
|
+
className="hidden"
|
|
527
|
+
onChange={(e) => {
|
|
528
|
+
if (e.target.files?.length) {
|
|
529
|
+
onImport(e.target.files);
|
|
530
|
+
e.target.value = "";
|
|
531
|
+
}
|
|
532
|
+
}}
|
|
533
|
+
/>
|
|
534
|
+
</>
|
|
535
|
+
)}
|
|
536
|
+
|
|
537
|
+
{/* Search */}
|
|
538
|
+
{mediaAssets.length > 0 && (
|
|
539
|
+
<div className="flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] mb-2">
|
|
540
|
+
<svg width="12" height="12" viewBox="0 0 256 256" fill="none" className="flex-shrink-0">
|
|
541
|
+
<circle
|
|
542
|
+
cx="116"
|
|
543
|
+
cy="116"
|
|
544
|
+
r="76"
|
|
545
|
+
stroke="currentColor"
|
|
546
|
+
strokeWidth="22"
|
|
547
|
+
className="text-panel-text-5"
|
|
548
|
+
/>
|
|
549
|
+
<line
|
|
550
|
+
x1="170"
|
|
551
|
+
y1="170"
|
|
552
|
+
x2="232"
|
|
553
|
+
y2="232"
|
|
554
|
+
stroke="currentColor"
|
|
555
|
+
strokeWidth="22"
|
|
556
|
+
strokeLinecap="round"
|
|
557
|
+
className="text-panel-text-5"
|
|
558
|
+
/>
|
|
338
559
|
</svg>
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
560
|
+
<input
|
|
561
|
+
type="text"
|
|
562
|
+
value={searchQuery}
|
|
563
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
564
|
+
placeholder="Search assets..."
|
|
565
|
+
className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5"
|
|
566
|
+
/>
|
|
567
|
+
</div>
|
|
568
|
+
)}
|
|
569
|
+
|
|
570
|
+
{/* Filter chips — panel-input style */}
|
|
571
|
+
{mediaAssets.length > 0 && (
|
|
572
|
+
<div className="flex gap-1.5 flex-wrap">
|
|
573
|
+
<button
|
|
574
|
+
onClick={() => setActiveFilter("all")}
|
|
575
|
+
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
|
|
576
|
+
activeFilter === "all"
|
|
577
|
+
? "bg-panel-accent/15 text-panel-accent"
|
|
578
|
+
: "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
|
|
579
|
+
}`}
|
|
580
|
+
>
|
|
581
|
+
All {counts.all}
|
|
582
|
+
</button>
|
|
583
|
+
{FILTER_ORDER.map((cat) =>
|
|
584
|
+
counts[cat] > 0 ? (
|
|
585
|
+
<button
|
|
586
|
+
key={cat}
|
|
587
|
+
onClick={() => setActiveFilter(activeFilter === cat ? "all" : cat)}
|
|
588
|
+
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
|
|
589
|
+
activeFilter === cat
|
|
590
|
+
? "bg-panel-accent/15 text-panel-accent"
|
|
591
|
+
: "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
|
|
592
|
+
}`}
|
|
593
|
+
>
|
|
594
|
+
{CATEGORY_LABELS[cat]} {counts[cat]}
|
|
595
|
+
</button>
|
|
596
|
+
) : null,
|
|
597
|
+
)}
|
|
598
|
+
</div>
|
|
599
|
+
)}
|
|
600
|
+
</div>
|
|
356
601
|
|
|
357
602
|
{/* Asset list */}
|
|
358
|
-
<div className="flex-1 overflow-y-auto">
|
|
603
|
+
<div className="flex-1 overflow-y-auto mt-1">
|
|
359
604
|
{mediaAssets.length === 0 ? (
|
|
360
605
|
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
|
361
606
|
<svg
|
|
@@ -378,16 +623,59 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
378
623
|
<p className="text-[10px] text-neutral-600 text-center">Drop media files here</p>
|
|
379
624
|
</div>
|
|
380
625
|
) : (
|
|
381
|
-
|
|
382
|
-
<
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
626
|
+
visibleCategories.map((cat) => (
|
|
627
|
+
<div key={cat} className="mb-1">
|
|
628
|
+
{activeFilter === "all" && (
|
|
629
|
+
<div className="flex items-center gap-2 px-4 py-2 border-t border-panel-border">
|
|
630
|
+
<h3 className="text-[12px] font-semibold text-panel-text-1">
|
|
631
|
+
{CATEGORY_LABELS[cat]}
|
|
632
|
+
</h3>
|
|
633
|
+
<span className="text-[11px] text-panel-text-5">{categorized[cat].length}</span>
|
|
634
|
+
</div>
|
|
635
|
+
)}
|
|
636
|
+
{cat === "audio" &&
|
|
637
|
+
categorized[cat].map((a) => (
|
|
638
|
+
<AudioRow
|
|
639
|
+
key={a}
|
|
640
|
+
projectId={projectId}
|
|
641
|
+
asset={a}
|
|
642
|
+
used={usedPaths.has(a)}
|
|
643
|
+
meta={manifest.get(a)}
|
|
644
|
+
onCopy={handleCopyPath}
|
|
645
|
+
isCopied={copiedPath === a}
|
|
646
|
+
onDelete={onDelete}
|
|
647
|
+
onRename={onRename}
|
|
648
|
+
/>
|
|
649
|
+
))}
|
|
650
|
+
{(cat === "images" || cat === "video") &&
|
|
651
|
+
categorized[cat].map((a) => (
|
|
652
|
+
<ImageCard
|
|
653
|
+
key={a}
|
|
654
|
+
projectId={projectId}
|
|
655
|
+
asset={a}
|
|
656
|
+
used={usedPaths.has(a)}
|
|
657
|
+
onCopy={handleCopyPath}
|
|
658
|
+
isCopied={copiedPath === a}
|
|
659
|
+
onDelete={onDelete}
|
|
660
|
+
onRename={onRename}
|
|
661
|
+
size={categorized[cat].length <= 4 ? "large" : "small"}
|
|
662
|
+
/>
|
|
663
|
+
))}
|
|
664
|
+
{cat === "fonts" &&
|
|
665
|
+
categorized[cat].map((a) => (
|
|
666
|
+
<ImageCard
|
|
667
|
+
key={a}
|
|
668
|
+
projectId={projectId}
|
|
669
|
+
asset={a}
|
|
670
|
+
used={usedPaths.has(a)}
|
|
671
|
+
onCopy={handleCopyPath}
|
|
672
|
+
isCopied={copiedPath === a}
|
|
673
|
+
onDelete={onDelete}
|
|
674
|
+
onRename={onRename}
|
|
675
|
+
size="small"
|
|
676
|
+
/>
|
|
677
|
+
))}
|
|
678
|
+
</div>
|
|
391
679
|
))
|
|
392
680
|
)}
|
|
393
681
|
</div>
|