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" ? ( -
-
- {isImage && ( - {name} { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - )} - {isVideo && } - {isVideo && hovered && ( + {/* Thumbnail */} +
+ {isImage && ( + {name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} + {isVideo && ( + <> + + {hovered && (
+ + {contextMenu && ( + 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 ( + <> +
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" + }`} + > +
+ {extension} +
+
+ + {name} + +
+ {extension} + {used && ( + + in use - {extension} - {used && ( - - in use - - )} -
+ )}
- ) : ( - <> -
- {isImage && ( - {name} { - (e.target as HTMLImageElement).style.display = "none"; - }} - /> - )} - {!isImage && ( - {extension} - )} -
-
- - {name} - -
- {extension} - {used && ( - - in use - - )} -
-
- - )} +
{contextMenu && ( @@ -560,26 +664,28 @@ export const AssetsTab = memo(function AssetsTab({ onAddAssetToTimeline={onAddAssetToTimeline} /> ))} - {(cat === "images" || cat === "video") && - categorized[cat].map((a) => ( - - ))} + {(cat === "images" || cat === "video") && ( +
+ {categorized[cat].map((a) => ( + + ))} +
+ )} {cat === "fonts" && categorized[cat].map((a) => ( - ))}