diff --git a/packages/studio/src/components/sidebar/AssetContextMenu.tsx b/packages/studio/src/components/sidebar/AssetContextMenu.tsx
new file mode 100644
index 000000000..07b5417a8
--- /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 4272c543d..c41d20f28 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,78 +23,11 @@ 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,
-}: {
- serveUrl: string;
- name: string;
- isImage: boolean;
- isVideo: boolean;
- isAudio: boolean;
-}) {
- return (
- <>
- {isImage && (
-
{
- (e.target as HTMLImageElement).style.display = "none";
- }}
- />
- )}
- {isVideo && }
- {isAudio && (
-
- )}
- {!isImage && !isVideo && !isAudio && (
-
- )}
- >
- );
-}
-
-function AssetCard({
+function AudioRow({
projectId,
asset,
+ used,
+ meta,
onCopy,
isCopied,
onDelete,
@@ -91,19 +35,232 @@ function AssetCard({
}: {
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 [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 [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 (
+ <>
+ 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}
+ />
+ )}
+ {confirmDelete && (
+ {
+ onDelete?.(asset);
+ setConfirmDelete(false);
+ }}
+ onCancel={() => setConfirmDelete(false)}
+ />
+ )}
+ >
+ );
+}
+
+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 [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
+ 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 +278,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 && (
+

{
+ (e.target as HTMLImageElement).style.display = "none";
+ }}
+ />
+ )}
+ {isVideo &&
}
+ {isVideo && hovered && (
+
+ )}
+
+
+
{name}
- {isCopied ? (
- Copied!
- ) : (
- {asset}
+ {extension}
+ {used && (
+
+ in use
+
)}
- >
- )}
-
+
+
+ ) : (
+ <>
+
+ {isImage && (
+

{
+ (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 +390,33 @@ 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<
+ Map
+ >(new Map());
+
+ useEffect(() => {
+ fetch(`/api/projects/${projectId}/preview/.media/manifest.jsonl`)
+ .then((r) => (r.ok ? r.text() : ""))
+ .then((text) => {
+ const m = new Map<
+ string,
+ { description?: string; duration?: number; width?: number; height?: number }
+ >();
+ 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 +435,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 && (
-
+ )}
+
+ {/* Filter chips — panel-input style */}
+ {mediaAssets.length > 0 && (
+
+
+ {FILTER_ORDER.map((cat) =>
+ counts[cat] > 0 ? (
+
+ ) : null,
+ )}
+
+ )}
+
{/* Asset list */}
-
+
{mediaAssets.length === 0 ? (
) : (
- 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 000000000..069c47491
--- /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 73a143f70..da533487e 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;