diff --git a/packages/studio/src/components/sidebar/AssetsTab.test.ts b/packages/studio/src/components/sidebar/AssetsTab.test.ts index 74b5c916e5..c451333bd0 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.test.ts +++ b/packages/studio/src/components/sidebar/AssetsTab.test.ts @@ -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" }, diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index a26a46fc6e..4226e88377 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -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" ? ( -