mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): redesign Asset tab + fix beat analysis auto-trigger (#1684)
* feat(media-use): core infrastructure — manifest, cache, adopt, probe Foundation for media-use — the media resolution layer for HyperFrames. - manifest.mjs: JSONL read/write/find for .media/manifest.jsonl - index-gen.mjs: regenerate agent-readable index.md from manifest - cache.mjs: content-addressed global cache at ~/.media/ (SHA-256, sentinel) - freeze.mjs: download URL or copy local file to .media/ - probe.mjs: extract duration/dimensions via ffprobe - adopt.mjs: scan assets/ directory, register existing files with metadata - 19 passing tests (manifest round-trip, cache, promote, index generation) * fix(media-use): oxfmt formatting + cap freeze download size Format adopt/cache/probe/manifest.test (CI oxfmt --check gate). Cap freezeUrl downloads at 256MB so a hostile/runaway URL can't fill the disk (addresses CodeQL #670: network data written to file). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(media-use): resolve engine + all providers + brand from frame.md - resolve.mjs: cheapest-first cascade - BGM/SFX via heygen --headers, Image/Icon via heygen asset search - Brand tokens from frame.md / design.md (local, no API) - SKILL.md: full agent docs + hyperframes.dev/design redirect - Router skill + workflow skill references * fix(media-use): oxfmt formatting for resolve + providers Format brand/heygen-search/providers/sfx providers + resolve.mjs (CI oxfmt --check gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(media-use): align providers with the real heygen CLI surface (v0.1.6) Verified live against the official Go `heygen` CLI v0.1.6 with a valid key: - Caller attribution: pass `--headers 'X-HeyGen-Client-Source: media-use'` (the allowlisted flag the CLI added for media-use in v0.1.6). The old `--x-source media-use` was never a real flag and broke every call. - Command is `asset search` (the `list` leaf was dropped in v0.1.6), not `asset search list`. - `--min-score` is sent server-side: honored by `audio sounds list`, but the `asset search` backend rejects it and returns no score field, so only the audio providers pass it (image/icon don't). - Drop hardcoded `ext` so resolve.mjs derives it from the URL: catalog icons are .png (not .svg), some BGM is .wav (not .mp3). Also: surface CLI/auth failures on stderr instead of swallowing them as 'no results', carry icon width/height through, and document the heygen CLI install + >= v0.1.6 requirement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(studio): redesign Asset tab + fix beat analysis auto-trigger Asset tab: categorized sections, filter chips, text search, audio spectrum visualizer, "in use" badge, manifest metadata, panel tokens. Beat fix: only run analysis when a beats file exists on disk. * fix(studio): oxfmt formatting for AssetsTab CI oxfmt --check gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b2dc353725
commit
92befea305
@@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[200]"
|
||||
onClick={onClose}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[140px] text-xs"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy(asset);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Copy path
|
||||
</button>
|
||||
{onRename && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(asset);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteConfirm({
|
||||
name,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
name: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-red-400 truncate">Delete {name}?</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isVideo && <VideoFrameThumbnail src={serveUrl} />}
|
||||
{isAudio && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-purple-400"
|
||||
>
|
||||
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{!isImage && !isVideo && !isAudio && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-neutral-600"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<number[]>([]);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const actxRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
|
||||
const animRef = useRef<number>(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 (
|
||||
<>
|
||||
<div
|
||||
draggable
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className={`w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center transition-all ${
|
||||
playing
|
||||
? "bg-panel-accent/15 text-panel-accent"
|
||||
: "text-panel-text-5 group-hover:text-panel-text-3"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlay();
|
||||
}}
|
||||
>
|
||||
{playing ? (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="6,4 20,12 6,20" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-[12px] font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{!playing && (
|
||||
<span className="text-[11px] text-panel-text-5 flex-shrink-0">
|
||||
{meta?.duration ? `${meta.duration}s · ` : ""}
|
||||
{subtype}
|
||||
</span>
|
||||
)}
|
||||
{used && (
|
||||
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded flex-shrink-0">
|
||||
in use
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{bars.length > 0 && (
|
||||
<div className="flex items-end gap-[2px] h-[14px] mt-0.5">
|
||||
{bars.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[1px]"
|
||||
style={{
|
||||
height: `${Math.max(10, v * 100)}%`,
|
||||
background: `linear-gradient(to top, rgba(60, 230, 172, ${0.3 + v * 0.5}), rgba(60, 230, 172, ${0.5 + v * 0.5}))`,
|
||||
transition: "height 80ms ease-out",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
asset={asset}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onCopy={onCopy}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
/>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<DeleteConfirm
|
||||
name={name}
|
||||
onConfirm={() => {
|
||||
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"
|
||||
}`
|
||||
}`}
|
||||
>
|
||||
<div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
<AssetThumbnail
|
||||
serveUrl={serveUrl}
|
||||
name={name}
|
||||
isImage={IMAGE_EXT.test(asset)}
|
||||
isVideo={isVideo}
|
||||
isAudio={AUDIO_EXT.test(asset)}
|
||||
/>
|
||||
{isVideo && hovered && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renaming ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={renameName}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">
|
||||
{size === "large" ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={`${thumbW} ${thumbH} rounded overflow-hidden bg-neutral-900 relative`}>
|
||||
{isImage && (
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isVideo && <VideoFrameThumbnail src={serveUrl} />}
|
||||
{isVideo && hovered && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-xs font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{isCopied ? (
|
||||
<span className="text-[9px] text-studio-accent">Copied!</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
|
||||
<span className="text-[10px] text-neutral-600">{extension}</span>
|
||||
{used && (
|
||||
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
|
||||
in use
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-[50px] h-[32px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 flex items-center justify-center">
|
||||
{isImage && (
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isImage && (
|
||||
<span className="text-[9px] font-medium text-neutral-700">{extension}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={`text-xs font-medium truncate block ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-neutral-600 truncate">{extension}</span>
|
||||
{used && (
|
||||
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
|
||||
in use
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed inset-0 z-[200]"
|
||||
onClick={() => setContextMenu(null)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[140px] text-xs"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy(asset);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Copy path
|
||||
</button>
|
||||
{onRename && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRenameName(name);
|
||||
setRenaming(true);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(true);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{confirmDelete && (
|
||||
<div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-red-400 truncate">Delete {name}?</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(asset);
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
asset={asset}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onCopy={onCopy}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -288,6 +390,33 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<MediaCategory | "all">("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [manifest, setManifest] = useState<
|
||||
Map<string, { description?: string; duration?: number; width?: number; height?: number }>
|
||||
>(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<string>();
|
||||
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<MediaCategory, string[]> = { 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<string, number> = { 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 (
|
||||
<div
|
||||
@@ -318,44 +496,111 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Import button */}
|
||||
{onImport && (
|
||||
<div className="px-3 py-2 border-b border-neutral-800/40 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-[11px] rounded-lg border border-dashed border-neutral-700/50 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600 transition-colors"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
{/* Header — matches design panel Section pattern */}
|
||||
<div className="px-4 pt-2.5 pb-1.5 flex-shrink-0">
|
||||
{/* Import */}
|
||||
{onImport && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
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"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
<svg
|
||||
width="11"
|
||||
height="11"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
Import media
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*,image/*,audio/*,font/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
onImport(e.target.files);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
{mediaAssets.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] mb-2">
|
||||
<svg width="12" height="12" viewBox="0 0 256 256" fill="none" className="flex-shrink-0">
|
||||
<circle
|
||||
cx="116"
|
||||
cy="116"
|
||||
r="76"
|
||||
stroke="currentColor"
|
||||
strokeWidth="22"
|
||||
className="text-panel-text-5"
|
||||
/>
|
||||
<line
|
||||
x1="170"
|
||||
y1="170"
|
||||
x2="232"
|
||||
y2="232"
|
||||
stroke="currentColor"
|
||||
strokeWidth="22"
|
||||
strokeLinecap="round"
|
||||
className="text-panel-text-5"
|
||||
/>
|
||||
</svg>
|
||||
Import media
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*,image/*,audio/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
onImport(e.target.files);
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter chips — panel-input style */}
|
||||
{mediaAssets.length > 0 && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
onClick={() => setActiveFilter("all")}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
|
||||
activeFilter === "all"
|
||||
? "bg-panel-accent/15 text-panel-accent"
|
||||
: "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
|
||||
}`}
|
||||
>
|
||||
All {counts.all}
|
||||
</button>
|
||||
{FILTER_ORDER.map((cat) =>
|
||||
counts[cat] > 0 ? (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveFilter(activeFilter === cat ? "all" : cat)}
|
||||
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
|
||||
activeFilter === cat
|
||||
? "bg-panel-accent/15 text-panel-accent"
|
||||
: "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_LABELS[cat]} {counts[cat]}
|
||||
</button>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Asset list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto mt-1">
|
||||
{mediaAssets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
||||
<svg
|
||||
@@ -378,16 +623,59 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
<p className="text-[10px] text-neutral-600 text-center">Drop media files here</p>
|
||||
</div>
|
||||
) : (
|
||||
mediaAssets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset}
|
||||
projectId={projectId}
|
||||
asset={asset}
|
||||
onCopy={handleCopyPath}
|
||||
isCopied={copiedPath === asset}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
/>
|
||||
visibleCategories.map((cat) => (
|
||||
<div key={cat} className="mb-1">
|
||||
{activeFilter === "all" && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-t border-panel-border">
|
||||
<h3 className="text-[12px] font-semibold text-panel-text-1">
|
||||
{CATEGORY_LABELS[cat]}
|
||||
</h3>
|
||||
<span className="text-[11px] text-panel-text-5">{categorized[cat].length}</span>
|
||||
</div>
|
||||
)}
|
||||
{cat === "audio" &&
|
||||
categorized[cat].map((a) => (
|
||||
<AudioRow
|
||||
key={a}
|
||||
projectId={projectId}
|
||||
asset={a}
|
||||
used={usedPaths.has(a)}
|
||||
meta={manifest.get(a)}
|
||||
onCopy={handleCopyPath}
|
||||
isCopied={copiedPath === a}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
/>
|
||||
))}
|
||||
{(cat === "images" || cat === "video") &&
|
||||
categorized[cat].map((a) => (
|
||||
<ImageCard
|
||||
key={a}
|
||||
projectId={projectId}
|
||||
asset={a}
|
||||
used={usedPaths.has(a)}
|
||||
onCopy={handleCopyPath}
|
||||
isCopied={copiedPath === a}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
size={categorized[cat].length <= 4 ? "large" : "small"}
|
||||
/>
|
||||
))}
|
||||
{cat === "fonts" &&
|
||||
categorized[cat].map((a) => (
|
||||
<ImageCard
|
||||
key={a}
|
||||
projectId={projectId}
|
||||
asset={a}
|
||||
used={usedPaths.has(a)}
|
||||
onCopy={handleCopyPath}
|
||||
isCopied={copiedPath === a}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<MediaCategory, string> = {
|
||||
audio: "Audio",
|
||||
images: "Images",
|
||||
video: "Video",
|
||||
fonts: "Fonts",
|
||||
};
|
||||
|
||||
export const FILTER_ORDER: MediaCategory[] = ["audio", "images", "video", "fonts"];
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user