diff --git a/packages/studio/src/components/sidebar/AssetContextMenu.tsx b/packages/studio/src/components/sidebar/AssetContextMenu.tsx new file mode 100644 index 0000000000..07b5417a80 --- /dev/null +++ b/packages/studio/src/components/sidebar/AssetContextMenu.tsx @@ -0,0 +1,97 @@ +export function ContextMenu({ + x, + y, + asset, + onClose, + onCopy, + onDelete, + onRename, +}: { + x: number; + y: number; + asset: string; + onClose: () => void; + onCopy: (path: string) => void; + onDelete?: (path: string) => void; + onRename?: (oldPath: string, newPath: string) => void; +}) { + return ( +
{ + e.preventDefault(); + onClose(); + }} + > +
+ + {onRename && ( + + )} + {onDelete && ( + + )} +
+
+ ); +} + +export function DeleteConfirm({ + name, + onConfirm, + onCancel, +}: { + name: string; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( +
+ Delete {name}? +
+ + +
+
+ ); +} diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index 4272c543dc..45b8843d2b 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -1,8 +1,19 @@ -import { memo, useState, useCallback, useRef } from "react"; +import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react"; import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; -import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes"; +import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes"; import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; import { copyTextToClipboard } from "../../utils/clipboard"; +import { ContextMenu, DeleteConfirm } from "./AssetContextMenu"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { + type MediaCategory, + getCategory, + getAudioSubtype, + basename, + ext, + CATEGORY_LABELS, + FILTER_ORDER, +} from "./assetHelpers"; interface AssetsTabProps { projectId: string; @@ -12,98 +23,243 @@ interface AssetsTabProps { onRename?: (oldPath: string, newPath: string) => void; } -/** Inline thumbnail content — rendered inside the container div in AssetCard. */ -function AssetThumbnail({ - serveUrl, - name, - isImage, - isVideo, - isAudio, +function AudioRow({ + projectId, + asset, + used, + meta, + onCopy, + isCopied, + onDelete, + onRename, }: { - serveUrl: string; - name: string; - isImage: boolean; - isVideo: boolean; - isAudio: boolean; + projectId: string; + asset: string; + used: boolean; + meta?: { description?: string; duration?: number }; + onCopy: (path: string) => void; + isCopied: boolean; + onDelete?: (path: string) => void; + onRename?: (oldPath: string, newPath: string) => void; }) { + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [playing, setPlaying] = useState(false); + const [bars, setBars] = useState([]); + const audioRef = useRef(null); + const actxRef = useRef(null); + const analyserRef = useRef(null); + const sourceRef = useRef(null); + const animRef = useRef(0); + const name = basename(asset); + const subtype = getAudioSubtype(asset); + const serveUrl = `/api/projects/${projectId}/preview/${asset}`; + + useEffect(() => { + return () => { + cancelAnimationFrame(animRef.current); + audioRef.current?.pause(); + actxRef.current?.close(); + }; + }, []); + + useEffect(() => { + if (playing) { + const barCount = 24; + const loop = () => { + const analyser = analyserRef.current; + if (!analyser) { + animRef.current = requestAnimationFrame(loop); + return; + } + const data = new Uint8Array(analyser.frequencyBinCount); + analyser.getByteFrequencyData(data); + const step = Math.floor(data.length / barCount); + const next: number[] = []; + for (let i = 0; i < barCount; i++) { + let sum = 0; + for (let j = 0; j < step; j++) sum += data[i * step + j]; + next.push(sum / step / 255); + } + setBars(next); + if (audioRef.current && !audioRef.current.paused) + animRef.current = requestAnimationFrame(loop); + }; + animRef.current = requestAnimationFrame(loop); + } else { + setBars([]); + } + return () => cancelAnimationFrame(animRef.current); + }, [playing]); + + const togglePlay = useCallback(async () => { + if (playing) { + audioRef.current?.pause(); + setPlaying(false); + cancelAnimationFrame(animRef.current); + return; + } + + if (!actxRef.current) { + actxRef.current = new AudioContext(); + analyserRef.current = actxRef.current.createAnalyser(); + analyserRef.current.fftSize = 256; + analyserRef.current.smoothingTimeConstant = 0.7; + } + + if (!audioRef.current) { + const el = new Audio(); + el.onended = () => { + setPlaying(false); + cancelAnimationFrame(animRef.current); + }; + audioRef.current = el; + sourceRef.current = actxRef.current.createMediaElementSource(el); + sourceRef.current.connect(analyserRef.current!); + analyserRef.current!.connect(actxRef.current.destination); + el.src = serveUrl; + } + + if (actxRef.current.state === "suspended") await actxRef.current.resume(); + audioRef.current.currentTime = 0; + await audioRef.current.play(); + setPlaying(true); + }, [serveUrl, playing]); + return ( <> - {isImage && ( - {name} { - (e.target as HTMLImageElement).style.display = "none"; +
onCopy(asset)} + onDragStart={(e) => { + e.dataTransfer.effectAllowed = "copy"; + e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset })); + e.dataTransfer.setData("text/plain", asset); + }} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }} + className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${ + playing + ? "bg-panel-accent/[0.06]" + : isCopied + ? "bg-panel-accent/10" + : "hover:bg-panel-surface-hover" + }`} + > + +
+
+ + {name} + + {!playing && ( + + {meta?.duration ? `${meta.duration}s · ` : ""}{subtype} + + )} + {used && ( + + in use + + )} +
+ {bars.length > 0 && ( +
+ {bars.map((v, i) => ( +
+ ))} +
+ )}
+
+ + {contextMenu && ( + setContextMenu(null)} + onCopy={onCopy} + onDelete={onDelete} + onRename={onRename} + /> )} - {!isImage && !isVideo && !isAudio && ( -
- - - - -
+ {confirmDelete && ( + { + onDelete?.(asset); + setConfirmDelete(false); + }} + onCancel={() => setConfirmDelete(false)} + /> )} ); } -function AssetCard({ +function ImageCard({ projectId, asset, + used, onCopy, isCopied, onDelete, onRename, + size, }: { projectId: string; asset: string; + used: boolean; onCopy: (path: string) => void; isCopied: boolean; onDelete?: (path: string) => void; onRename?: (oldPath: string, newPath: string) => void; + size: "large" | "small"; }) { - const [hovered, setHovered] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const [renaming, setRenaming] = useState(false); - const [renameName, setRenameName] = useState(""); - const [confirmDelete, setConfirmDelete] = useState(false); - const name = asset.split("/").pop() ?? asset; + const [hovered, setHovered] = useState(false); + const name = basename(asset); + const extension = ext(asset); const serveUrl = `/api/projects/${projectId}/preview/${asset}`; const isVideo = VIDEO_EXT.test(asset); + const isImage = IMAGE_EXT.test(asset); + + const thumbW = size === "large" ? "w-full" : "w-[50px]"; + const thumbH = size === "large" ? "h-[100px]" : "h-[32px]"; return ( <> @@ -121,158 +277,103 @@ function AssetCard({ }} onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)} - className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${ - isCopied - ? "bg-studio-accent/10 border-l-2 border-studio-accent" - : "border-l-2 border-transparent hover:bg-neutral-800/50" + className={`transition-colors cursor-pointer ${ + size === "large" + ? `px-2.5 py-1 ${isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/30"}` + : `px-2.5 py-1.5 flex items-center gap-2.5 ${ + isCopied + ? "bg-studio-accent/10 border-l-2 border-studio-accent" + : "border-l-2 border-transparent hover:bg-neutral-800/50" + }` }`} > -
- - {isVideo && hovered && ( -
-
- {renaming ? ( - setRenameName(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - const trimmed = renameName.trim(); - if (trimmed && trimmed !== name) { - const dir = asset.includes("/") - ? asset.slice(0, asset.lastIndexOf("/") + 1) - : ""; - onRename?.(asset, dir + trimmed); - } - setRenaming(false); - } else if (e.key === "Escape") { - setRenaming(false); - } - }} - onBlur={() => { - const trimmed = renameName.trim(); - if (trimmed && trimmed !== name) { - const dir = asset.includes("/") ? asset.slice(0, asset.lastIndexOf("/") + 1) : ""; - onRename?.(asset, dir + trimmed); - } - setRenaming(false); - }} - onClick={(e) => e.stopPropagation()} - 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" - spellCheck={false} - /> - ) : ( - <> - + {size === "large" ? ( +
+
+ {isImage && ( + {name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {isVideo && } + {isVideo && hovered && ( +
+
+ {name} - {isCopied ? ( - Copied! - ) : ( - {asset} + {extension} + {used && ( + + in use + )} - - )} -
+
+
+ ) : ( + <> +
+ {isImage && ( + {name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {!isImage && ( + {extension} + )} +
+
+ + {name} + +
+ {extension} + {used && ( + + in use + + )} +
+
+ + )}
- {/* Context menu */} {contextMenu && ( -
setContextMenu(null)} - onContextMenu={(e) => { - e.preventDefault(); - setContextMenu(null); - }} - > -
- - {onRename && ( - - )} - {onDelete && ( - - )} -
-
- )} - - {/* Delete confirmation */} - {confirmDelete && ( -
- Delete {name}? -
- - -
-
+ setContextMenu(null)} + onCopy={onCopy} + onDelete={onDelete} + onRename={onRename} + /> )} ); @@ -288,6 +389,26 @@ export const AssetsTab = memo(function AssetsTab({ const fileInputRef = useRef(null); const [dragOver, setDragOver] = useState(false); const [copiedPath, setCopiedPath] = useState(null); + const [activeFilter, setActiveFilter] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [manifest, setManifest] = useState>(new Map()); + + useEffect(() => { + fetch(`/api/projects/${projectId}/preview/.media/manifest.jsonl`) + .then((r) => (r.ok ? r.text() : "")) + .then((text) => { + const m = new Map(); + for (const line of text.split("\n")) { + if (!line.trim()) continue; + try { + const rec = JSON.parse(line); + if (rec.path) m.set(rec.path, rec); + } catch { /* skip */ } + } + setManifest(m); + }) + .catch(() => {}); + }, [projectId, assets]); const handleDrop = useCallback( (e: React.DragEvent) => { @@ -306,7 +427,56 @@ export const AssetsTab = memo(function AssetsTab({ } }, []); - const mediaAssets = assets.filter((a) => MEDIA_EXT.test(a)); + const elements = usePlayerStore((s) => s.elements); + const usedPaths = useMemo(() => { + const paths = new Set(); + for (const el of elements) { + if (el.src) { + const src = el.src.replace(/^\/api\/projects\/[^/]+\/preview\//, ""); + paths.add(src); + } + } + return paths; + }, [elements]); + + const mediaAssets = useMemo(() => { + const all = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)); + if (!searchQuery) return all; + const q = searchQuery.toLowerCase(); + return all.filter((a) => { + if (basename(a).toLowerCase().includes(q)) return true; + const rec = manifest.get(a); + return rec?.description?.toLowerCase().includes(q); + }); + }, [assets, searchQuery, manifest]); + + const categorized = useMemo(() => { + const groups: Record = { audio: [], images: [], video: [], fonts: [] }; + for (const a of mediaAssets) { + const cat = getCategory(a); + if (cat) groups[cat].push(a); + } + // Sort: used assets first within each category + for (const cat of FILTER_ORDER) { + groups[cat].sort((a, b) => { + const aUsed = usedPaths.has(a) ? 0 : 1; + const bUsed = usedPaths.has(b) ? 0 : 1; + return aUsed - bUsed; + }); + } + return groups; + }, [mediaAssets, usedPaths]); + + const counts = useMemo(() => { + const c: Record = { all: mediaAssets.length }; + for (const cat of FILTER_ORDER) c[cat] = categorized[cat].length; + return c; + }, [mediaAssets, categorized]); + + const visibleCategories = + activeFilter === "all" + ? FILTER_ORDER.filter((c) => categorized[c].length > 0) + : [activeFilter as MediaCategory].filter((c) => categorized[c].length > 0); return (
setDragOver(false)} onDrop={handleDrop} > - {/* Import button */} - {onImport && ( -
- - { - if (e.target.files?.length) { - onImport(e.target.files); - e.target.value = ""; - } - }} - /> -
- )} + setSearchQuery(e.target.value)} + placeholder="Search assets..." + className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5" + /> +
+ )} + + {/* Filter chips — panel-input style */} + {mediaAssets.length > 0 && ( +
+ + {FILTER_ORDER.map((cat) => + counts[cat] > 0 ? ( + + ) : null, + )} +
+ )} + {/* Asset list */} -
+
{mediaAssets.length === 0 ? (
Drop media files here

) : ( - mediaAssets.map((asset) => ( - + visibleCategories.map((cat) => ( +
+ {activeFilter === "all" && ( +
+

+ {CATEGORY_LABELS[cat]} +

+ {categorized[cat].length} +
+ )} + {cat === "audio" && + categorized[cat].map((a) => ( + + ))} + {(cat === "images" || cat === "video") && + categorized[cat].map((a) => ( + + ))} + {cat === "fonts" && + categorized[cat].map((a) => ( + + ))} +
)) )}
diff --git a/packages/studio/src/components/sidebar/assetHelpers.ts b/packages/studio/src/components/sidebar/assetHelpers.ts new file mode 100644 index 0000000000..069c47491c --- /dev/null +++ b/packages/studio/src/components/sidebar/assetHelpers.ts @@ -0,0 +1,40 @@ +import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes"; + +export type MediaCategory = "audio" | "images" | "video" | "fonts"; + +export function getCategory(path: string): MediaCategory | null { + if (AUDIO_EXT.test(path)) return "audio"; + if (IMAGE_EXT.test(path)) return "images"; + if (VIDEO_EXT.test(path)) return "video"; + if (FONT_EXT.test(path)) return "fonts"; + return null; +} + +export function getAudioSubtype(path: string): string { + const lower = path.toLowerCase(); + if (lower.includes("/bgm/") || lower.includes("/music/")) return "BGM"; + if (lower.includes("/sfx/") || lower.includes("/sound")) return "SFX"; + if (lower.includes("/voice/") || lower.includes("/narrat")) return "Voice"; + return "Audio"; +} + +export function basename(path: string): string { + const name = path.split("/").pop() ?? path; + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(0, dot) : name; +} + +export function ext(path: string): string { + const name = path.split("/").pop() ?? path; + const dot = name.lastIndexOf("."); + return dot > 0 ? name.slice(dot + 1).toUpperCase() : ""; +} + +export const CATEGORY_LABELS: Record = { + audio: "Audio", + images: "Images", + video: "Video", + fonts: "Fonts", +}; + +export const FILTER_ORDER: MediaCategory[] = ["audio", "images", "video", "fonts"]; diff --git a/packages/studio/src/hooks/useMusicBeatAnalysis.ts b/packages/studio/src/hooks/useMusicBeatAnalysis.ts index 73a143f700..da533487e5 100644 --- a/packages/studio/src/hooks/useMusicBeatAnalysis.ts +++ b/packages/studio/src/hooks/useMusicBeatAnalysis.ts @@ -92,33 +92,48 @@ export function useMusicBeatAnalysis(): void { return; } let cancelled = false; - - let promise = analysisCache.get(musicSrc); - if (!promise) { - promise = analyzeMusicFromUrl(musicSrc); - cacheAnalysis(musicSrc, promise); - } - const beatPath = beatFilePathForSrc(musicSrc); - promise - .then(async (analysis) => { + const io = ioRef.current; + + // Only run expensive audio decode + beat analysis when the user has an + // explicit beats file saved. Without one, skip entirely — no surprise + // green lines on the timeline after dragging unrelated assets. + (async () => { + if (!beatPath || !io) return; + let hasSavedBeats = false; + try { + const content = await io.readOptionalProjectFile(beatPath); + const parsed = content ? parseBeats(content) : null; + hasSavedBeats = !!(parsed && parsed.times.length > 0); + } catch { + /* no file */ + } + if (cancelled) return; + if (!hasSavedBeats) { + setBeatAnalysis(null); + return; + } + + let promise = analysisCache.get(musicSrc); + if (!promise) { + promise = analyzeMusicFromUrl(musicSrc); + cacheAnalysis(musicSrc, promise); + } + try { + const analysis = await promise; const detected = { times: analysis.beatTimes, strengths: analysis.beatStrengths }; - const io = ioRef.current; - if (!io) return; - const { times, strengths, hasFile } = await resolveBeats(beatPath, detected, io); + const { times, strengths } = await resolveBeats(beatPath, detected, io); if (cancelled) return; setBeatEdits(null); resetBeatHistory(); setBeatAnalysis({ ...analysis, beatTimes: times, beatStrengths: strengths }); - // Seed a missing file through the SAME debounced writer the edits use, so - // the initial write can't race a near-simultaneous edit's persist. - if (beatPath && !hasFile && times.length > 0) usePlayerStore.getState().beatPersist?.(); - }) - .catch(() => { - if (cancelled) return; - setBeatAnalysis(null); - analysisCache.delete(musicSrc); - }); + } catch { + if (!cancelled) { + setBeatAnalysis(null); + analysisCache.delete(musicSrc); + } + } + })(); return () => { cancelled = true;