mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
feat(studio): restyle assets panel to CapCut-style card grid
Replace the list-based ImageCard layout with a 2-column card grid for image and video assets. Each card shows: - Thumbnail filling the card (aspect-video, rounded, object-cover) - "Added" dark chip badge top-left when the asset is referenced in the timeline (reuses existing deriveUsedPaths / usedPaths machinery) - Duration badge top-right (MM:SS) sourced from the manifest cache; only shown for media with a known duration, never for images - Filename caption below the card truncated in the middle (e.g. "2a37e…887d8.mp4") so the extension is always visible - Hover video-preview overlay on video cards (unchanged behaviour) - Drag-to-timeline payload (TIMELINE_ASSET_MIME) untouched; durationSec now populated from manifest rather than hardcoded null Font assets keep the compact row layout (no meaningful aspect-ratio thumbnail); extracted to FontRow to remove the old size="small" prop. Exports two new pure helpers with unit tests: - truncateMiddle(str, maxLen) — middle-ellipsis truncation - formatDuration(seconds) — MM:SS formatter, empty string for ≤0/NaN
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterByUsage, countUsage, deriveUsedPaths } from "./AssetsTab";
|
||||
import {
|
||||
filterByUsage,
|
||||
countUsage,
|
||||
deriveUsedPaths,
|
||||
truncateMiddle,
|
||||
formatDuration,
|
||||
} from "./AssetsTab";
|
||||
import { globalAssetRows } from "./GlobalAssetsView";
|
||||
|
||||
const assets = ["bgm.mp3", "logo.png", "orphan.wav"];
|
||||
@@ -59,6 +65,68 @@ describe("countUsage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateMiddle", () => {
|
||||
it("returns the original string when it fits within maxLen", () => {
|
||||
expect(truncateMiddle("short.mp4", 20)).toBe("short.mp4");
|
||||
expect(truncateMiddle("exact_length_str.mp4", 20)).toBe("exact_length_str.mp4");
|
||||
});
|
||||
|
||||
it("truncates longer strings with an ellipsis in the middle", () => {
|
||||
const result = truncateMiddle("2a37eabf-long-uuid-887d8.mp4", 20);
|
||||
expect(result.length).toBeLessThanOrEqual(20);
|
||||
expect(result).toContain("…");
|
||||
// Preserves head
|
||||
expect(result.startsWith("2a37eabf-long-uuid-8")).toBe(false); // head is shortened
|
||||
expect(result.startsWith("2a37eabf")).toBe(true);
|
||||
// Preserves tail
|
||||
expect(result.endsWith("887d8.mp4")).toBe(false); // tail portion only
|
||||
expect(result.endsWith(".mp4")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves the full filename extension in the tail", () => {
|
||||
const result = truncateMiddle("verylongnamehere12345.mp4", 14);
|
||||
expect(result.endsWith(".mp4")).toBe(true);
|
||||
expect(result.length).toBeLessThanOrEqual(14);
|
||||
});
|
||||
|
||||
it("handles maxLen of 1 (degenerate)", () => {
|
||||
const result = truncateMiddle("abcdef", 1);
|
||||
// head = 0, tail = 0 → just the ellipsis
|
||||
expect(result).toBe("…");
|
||||
});
|
||||
|
||||
it("handles a string of exactly maxLen+1 chars", () => {
|
||||
const result = truncateMiddle("abcdefgh", 7);
|
||||
expect(result.length).toBeLessThanOrEqual(7);
|
||||
expect(result).toContain("…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("formats whole seconds as MM:SS", () => {
|
||||
expect(formatDuration(28)).toBe("00:28");
|
||||
expect(formatDuration(60)).toBe("01:00");
|
||||
expect(formatDuration(90)).toBe("01:30");
|
||||
expect(formatDuration(3661)).toBe("61:01");
|
||||
});
|
||||
|
||||
it("rounds fractional seconds to nearest whole", () => {
|
||||
expect(formatDuration(28.4)).toBe("00:28");
|
||||
expect(formatDuration(28.6)).toBe("00:29");
|
||||
});
|
||||
|
||||
it("returns empty string for non-positive values", () => {
|
||||
expect(formatDuration(0)).toBe("");
|
||||
expect(formatDuration(-1)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for non-finite values", () => {
|
||||
expect(formatDuration(NaN)).toBe("");
|
||||
expect(formatDuration(Infinity)).toBe("");
|
||||
expect(formatDuration(-Infinity)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("globalAssetRows", () => {
|
||||
const recs = [
|
||||
{ id: "bgm_001", type: "bgm", description: "calm ambient" },
|
||||
|
||||
@@ -18,6 +18,35 @@ import {
|
||||
import { AudioRow } from "./AudioRow";
|
||||
import { GlobalAssetsView } from "./GlobalAssetsView";
|
||||
|
||||
/**
|
||||
* Truncate a string to at most `maxLen` chars, preserving the start and end.
|
||||
* Middle characters are replaced with an ellipsis. If the string is short
|
||||
* enough it is returned unchanged.
|
||||
*
|
||||
* @example truncateMiddle("2a37eabf-long-uuid-887d8.mp4", 20) → "2a37eabf-…887d8.mp4"
|
||||
*
|
||||
* Pure — unit-tested.
|
||||
*/
|
||||
export function truncateMiddle(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
const keep = maxLen - 1; // 1 char for ellipsis
|
||||
const tail = Math.floor(keep / 3);
|
||||
const head = keep - tail;
|
||||
return str.slice(0, head) + "…" + str.slice(str.length - tail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration in seconds as MM:SS. Returns an empty string for
|
||||
* non-positive, NaN, or Infinity values. Pure — unit-tested.
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return "";
|
||||
const total = Math.round(seconds);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
interface AssetsTabProps {
|
||||
projectId: string;
|
||||
assets: string[];
|
||||
@@ -27,38 +56,46 @@ interface AssetsTabProps {
|
||||
onAddAssetToTimeline?: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail card for images and video assets. Renders in a 2-col grid.
|
||||
* Layout:
|
||||
* - Rounded thumbnail that fills the card; video previews show a poster
|
||||
* frame (VideoFrameThumbnail) + hover-preview video overlay.
|
||||
* - "Added" dark chip badge top-left when the asset is used in the timeline.
|
||||
* - Duration badge top-right for media with a known duration (MM:SS).
|
||||
* - Filename caption below the card, truncated in the middle.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
function ImageCard({
|
||||
function AssetCard({
|
||||
projectId,
|
||||
asset,
|
||||
used,
|
||||
duration,
|
||||
onCopy,
|
||||
isCopied,
|
||||
onDelete,
|
||||
onRename,
|
||||
onAddAssetToTimeline,
|
||||
size,
|
||||
}: {
|
||||
projectId: string;
|
||||
asset: string;
|
||||
used: boolean;
|
||||
duration?: number;
|
||||
onCopy: (path: string) => void;
|
||||
isCopied: boolean;
|
||||
onDelete?: (path: string) => void;
|
||||
onRename?: (oldPath: string, newPath: string) => void;
|
||||
onAddAssetToTimeline?: (path: string) => void;
|
||||
size: "large" | "small";
|
||||
}) {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const fullName = asset.split("/").pop() ?? asset; // filename with extension
|
||||
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]";
|
||||
const durationLabel = formatDuration(duration ?? 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -73,7 +110,7 @@ function ImageCard({
|
||||
source: "asset",
|
||||
path: asset,
|
||||
kind: isVideo ? "video" : "image",
|
||||
durationSec: null,
|
||||
durationSec: duration ?? null,
|
||||
label: name,
|
||||
});
|
||||
}}
|
||||
@@ -84,32 +121,27 @@ function ImageCard({
|
||||
}}
|
||||
onPointerEnter={() => setHovered(true)}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
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"
|
||||
}`
|
||||
className={`flex flex-col gap-1 cursor-pointer rounded-md p-1 transition-colors ${
|
||||
isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/40"
|
||||
}`}
|
||||
>
|
||||
{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 && (
|
||||
{/* Thumbnail */}
|
||||
<div className="w-full aspect-video 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} />
|
||||
{hovered && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
@@ -119,56 +151,128 @@ function ImageCard({
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isImage && !isVideo && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="text-[10px] font-medium text-neutral-600">{extension}</span>
|
||||
</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}
|
||||
)}
|
||||
|
||||
{/* "Added" badge — top-left */}
|
||||
{used && (
|
||||
<span className="absolute top-1 left-1 text-[9px] font-semibold leading-none px-1.5 py-[3px] rounded bg-neutral-950/80 text-panel-text-1">
|
||||
Added
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Duration badge — top-right, media only */}
|
||||
{durationLabel && (
|
||||
<span className="absolute top-1 right-1 text-[9px] font-medium leading-none px-1.5 py-[3px] rounded bg-neutral-950/80 text-panel-text-2 tabular-nums">
|
||||
{durationLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filename caption */}
|
||||
<span
|
||||
className={`text-[10px] leading-tight text-center block w-full ${
|
||||
used ? "text-panel-text-2" : "text-panel-text-4"
|
||||
}`}
|
||||
title={fullName}
|
||||
>
|
||||
{truncateMiddle(fullName, 22)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
asset={asset}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onCopy={onCopy}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
onAddAtPlayhead={onAddAssetToTimeline}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact row for font assets (no meaningful thumbnail; show ext badge + name).
|
||||
* Kept as a row rather than a card because font previews require font-loading
|
||||
* and an aspect-video thumbnail would just be empty.
|
||||
*/
|
||||
function FontRow({
|
||||
asset,
|
||||
used,
|
||||
onCopy,
|
||||
isCopied,
|
||||
onDelete,
|
||||
onRename,
|
||||
onAddAssetToTimeline,
|
||||
}: {
|
||||
asset: string;
|
||||
used: boolean;
|
||||
onCopy: (path: string) => void;
|
||||
isCopied: boolean;
|
||||
onDelete?: (path: string) => void;
|
||||
onRename?: (oldPath: string, newPath: string) => void;
|
||||
onAddAssetToTimeline?: (path: string) => void;
|
||||
}) {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const name = basename(asset);
|
||||
const extension = ext(asset);
|
||||
|
||||
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);
|
||||
beginDragSession({
|
||||
source: "asset",
|
||||
path: asset,
|
||||
kind: "image", // fonts treated as non-media for DnD kind
|
||||
durationSec: null,
|
||||
label: name,
|
||||
});
|
||||
}}
|
||||
onDragEnd={endDragSession}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
className={`px-2.5 py-1.5 flex items-center gap-2.5 cursor-pointer transition-colors ${
|
||||
isCopied
|
||||
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="w-[50px] h-[32px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 flex items-center justify-center">
|
||||
<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>
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
@@ -560,26 +664,28 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
onAddAssetToTimeline={onAddAssetToTimeline}
|
||||
/>
|
||||
))}
|
||||
{(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}
|
||||
onAddAssetToTimeline={onAddAssetToTimeline}
|
||||
size={categorized[cat].length <= 4 ? "large" : "small"}
|
||||
/>
|
||||
))}
|
||||
{(cat === "images" || cat === "video") && (
|
||||
<div className="grid grid-cols-2 gap-1 px-2 pb-1">
|
||||
{categorized[cat].map((a) => (
|
||||
<AssetCard
|
||||
key={a}
|
||||
projectId={projectId}
|
||||
asset={a}
|
||||
used={usedPaths.has(a)}
|
||||
duration={manifest.get(a)?.duration}
|
||||
onCopy={handleCopyPath}
|
||||
isCopied={copiedPath === a}
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
onAddAssetToTimeline={onAddAssetToTimeline}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{cat === "fonts" &&
|
||||
categorized[cat].map((a) => (
|
||||
<ImageCard
|
||||
<FontRow
|
||||
key={a}
|
||||
projectId={projectId}
|
||||
asset={a}
|
||||
used={usedPaths.has(a)}
|
||||
onCopy={handleCopyPath}
|
||||
@@ -587,7 +693,6 @@ export const AssetsTab = memo(function AssetsTab({
|
||||
onDelete={onDelete}
|
||||
onRename={onRename}
|
||||
onAddAssetToTimeline={onAddAssetToTimeline}
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user