Skip to content

Commit 31fe1ba

Browse files
committed
feat(studio): clip thumbnail modules
What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode with SVG/AVIF format fallbacks and rounded-corner clipping — plus VideoThumbnail updates. Why: the decode layer for timeline clip thumbnails, ahead of the visual refresh that renders them. How: new modules + one modified file; purely presentational. Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit clean.
1 parent 734f785 commit 31fe1ba

10 files changed

Lines changed: 719 additions & 20 deletions

File tree

packages/studio/src/components/nle/AssetPreviewOverlay.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import { useEffect, useCallback } from "react";
1212
import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
1313
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
14+
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
1415

1516
function basename(path: string): string {
1617
return path.split("/").pop() ?? path;
@@ -95,8 +96,7 @@ export function AssetPreviewOverlay() {
9596

9697
if (!previewAsset || !previewProjectId) return null;
9798

98-
const encodedAsset = previewAsset.split("/").map(encodeURIComponent).join("/");
99-
const serveUrl = `/api/projects/${previewProjectId}/preview/${encodedAsset}`;
99+
const serveUrl = resolveMediaPreviewUrl(previewAsset, previewProjectId);
100100
const name = basename(previewAsset);
101101

102102
return (

packages/studio/src/components/nle/useCompositionStack.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { useState, useCallback, useRef, useEffect } from "react";
33
import { usePlayerStore } from "../../player";
44
import type { CompositionLevel } from "./CompositionBreadcrumb";
5+
import { encodePreviewPath } from "../../player/components/thumbnailUtils";
56

67
interface UseCompositionStackOptions {
78
projectId: string;
@@ -84,7 +85,7 @@ export function useCompositionStack({
8485
.split("/")
8586
.pop()
8687
?.replace(/\.html$/, "") || resolvedPath;
87-
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
88+
const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(resolvedPath)}`;
8889
return [...prev, { id: resolvedPath, label, previewUrl }];
8990
});
9091
},
@@ -100,7 +101,7 @@ export function useCompositionStack({
100101
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
101102
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
102103
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
103-
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
104+
const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(activeCompositionPath)}`;
104105
usePlayerStore.getState().setElements([]);
105106
updateCompositionStack((prev) => {
106107
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;

packages/studio/src/components/sidebar/AssetCard.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
1111
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
1212
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
1313
import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
14+
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
1415

1516
/** Drag payload writer shared by the asset tile and the font row: copy effect
1617
* plus the timeline-asset MIME and a plain-text path fallback. */
@@ -40,25 +41,32 @@ function useProbedDuration(src: string, skip: boolean): number | null | undefine
4041
useEffect(() => {
4142
if (skip) return;
4243
let cancelled = false;
44+
let retryTimer: ReturnType<typeof setTimeout> | undefined;
45+
// The in-flight probe element, so unmount cleanup can abort its network
46+
// fetch (clearing `src`) instead of leaving it to finish in the background.
47+
let liveVid: HTMLVideoElement | null = null;
48+
49+
function teardown(vid: HTMLVideoElement) {
50+
vid.onloadedmetadata = null;
51+
vid.onerror = null;
52+
vid.src = "";
53+
}
4354

4455
function probe(attempt: number) {
4556
if (cancelled) return;
4657
const vid = document.createElement("video");
58+
liveVid = vid;
4759
vid.preload = "metadata";
4860
vid.muted = true;
4961
vid.onloadedmetadata = () => {
5062
const d = Number.isFinite(vid.duration) && vid.duration > 0 ? vid.duration : null;
63+
teardown(vid);
5164
if (!cancelled) setDuration(d);
52-
vid.onloadedmetadata = null;
53-
vid.onerror = null;
54-
vid.src = "";
5565
};
5666
vid.onerror = () => {
57-
vid.onloadedmetadata = null;
58-
vid.onerror = null;
59-
vid.src = "";
67+
teardown(vid);
6068
if (!cancelled) {
61-
if (attempt < 1) setTimeout(() => probe(attempt + 1), 50);
69+
if (attempt < 1) retryTimer = setTimeout(() => probe(attempt + 1), 50);
6270
else setDuration(null);
6371
}
6472
};
@@ -68,6 +76,8 @@ function useProbedDuration(src: string, skip: boolean): number | null | undefine
6876
probe(0);
6977
return () => {
7078
cancelled = true;
79+
if (retryTimer) clearTimeout(retryTimer);
80+
if (liveVid) teardown(liveVid);
7181
};
7282
}, [src, skip]);
7383
return duration;
@@ -111,7 +121,7 @@ export function AssetCard({
111121
const fullName = asset.split("/").pop() ?? asset;
112122
const name = basename(asset);
113123
const extension = ext(asset);
114-
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
124+
const serveUrl = resolveMediaPreviewUrl(asset, projectId);
115125
const isVideo = VIDEO_EXT.test(asset);
116126
const isImage = IMAGE_EXT.test(asset);
117127
const probedDuration = useProbedDuration(serveUrl, !isVideo || duration != null);

packages/studio/src/components/sidebar/AudioRow.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
55
import { usePlayerStore } from "../../player/store/playerStore";
66
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
77
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
8+
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
89

910
export function AudioRow({
1011
projectId,
@@ -37,7 +38,7 @@ export function AudioRow({
3738
const animRef = useRef<number>(0);
3839
const name = basename(asset);
3940
const subtype = getAudioSubtype(asset);
40-
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
41+
const serveUrl = resolveMediaPreviewUrl(asset, projectId);
4142

4243
// CapCut-style click behavior: drag-threshold gate.
4344
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// @vitest-environment happy-dom
2+
3+
import React, { act } from "react";
4+
import { createRoot, type Root } from "react-dom/client";
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
import { ImageThumbnail } from "./ImageThumbnail";
7+
8+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
9+
configurable: true,
10+
value: true,
11+
});
12+
13+
// --- Observer stubs: fire "intersecting" immediately on observe ---
14+
class MockIntersectionObserver {
15+
private cb: IntersectionObserverCallback;
16+
constructor(cb: IntersectionObserverCallback) {
17+
this.cb = cb;
18+
}
19+
observe() {
20+
this.cb(
21+
[{ isIntersecting: true } as IntersectionObserverEntry],
22+
this as unknown as IntersectionObserver,
23+
);
24+
}
25+
disconnect() {}
26+
unobserve() {}
27+
takeRecords(): IntersectionObserverEntry[] {
28+
return [];
29+
}
30+
}
31+
32+
class MockResizeObserver {
33+
observe() {}
34+
disconnect() {}
35+
unobserve() {}
36+
}
37+
38+
// --- Image stub: captures instances so tests fire load/error deterministically ---
39+
class MockImage {
40+
static instances: MockImage[] = [];
41+
onload: (() => void) | null = null;
42+
onerror: (() => void) | null = null;
43+
naturalWidth = 0;
44+
naturalHeight = 0;
45+
src = "";
46+
constructor() {
47+
MockImage.instances.push(this);
48+
}
49+
}
50+
51+
const originalIO = globalThis.IntersectionObserver;
52+
const originalRO = globalThis.ResizeObserver;
53+
const originalImage = globalThis.Image;
54+
55+
let host: HTMLDivElement;
56+
let root: Root | null = null;
57+
58+
beforeEach(() => {
59+
globalThis.IntersectionObserver =
60+
MockIntersectionObserver as unknown as typeof IntersectionObserver;
61+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
62+
globalThis.Image = MockImage as unknown as typeof Image;
63+
MockImage.instances = [];
64+
host = document.createElement("div");
65+
document.body.append(host);
66+
});
67+
68+
afterEach(() => {
69+
act(() => root?.unmount());
70+
root = null;
71+
globalThis.IntersectionObserver = originalIO;
72+
globalThis.ResizeObserver = originalRO;
73+
globalThis.Image = originalImage;
74+
document.body.innerHTML = "";
75+
});
76+
77+
function render(props: { imageSrc: string; label?: string; labelColor?: string }) {
78+
root = createRoot(host);
79+
act(() => {
80+
root!.render(
81+
<ImageThumbnail
82+
imageSrc={props.imageSrc}
83+
label={props.label ?? ""}
84+
labelColor={props.labelColor ?? "#fff"}
85+
/>,
86+
);
87+
});
88+
}
89+
90+
function lastProbe(): MockImage {
91+
const probe = MockImage.instances.at(-1);
92+
expect(probe).toBeDefined();
93+
return probe!;
94+
}
95+
96+
/** Assert at least one tile rendered and the first tile serves `expectedSrc`. */
97+
function expectFirstTileSrc(expectedSrc: string): void {
98+
const imgs = [...host.querySelectorAll("img")];
99+
expect(imgs.length).toBeGreaterThanOrEqual(1);
100+
expect(imgs[0].getAttribute("src")).toBe(expectedSrc);
101+
}
102+
103+
describe("ImageThumbnail", () => {
104+
it("shows the loading shimmer before the image resolves", () => {
105+
render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
106+
expect(host.querySelector(".animate-pulse")).not.toBeNull();
107+
expect(host.querySelectorAll("img").length).toBe(0);
108+
});
109+
110+
it("probes the resolved src and renders repeated object-cover tiles on load", () => {
111+
render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
112+
const probe = lastProbe();
113+
expect(probe.src).toBe("/api/projects/p/preview/assets/pic.png");
114+
115+
act(() => {
116+
probe.naturalWidth = 1920;
117+
probe.naturalHeight = 1080;
118+
probe.onload?.();
119+
});
120+
121+
const imgs = [...host.querySelectorAll("img")];
122+
expect(imgs.length).toBeGreaterThanOrEqual(1);
123+
for (const img of imgs) {
124+
expect(img.getAttribute("src")).toBe("/api/projects/p/preview/assets/pic.png");
125+
expect(img.className).toContain("object-cover");
126+
}
127+
expect(host.querySelector(".animate-pulse")).toBeNull();
128+
});
129+
130+
it("drops the shimmer and renders no tiles when a raster image fails to load", () => {
131+
render({ imageSrc: "/api/projects/p/preview/assets/missing.png" });
132+
act(() => lastProbe().onerror?.());
133+
expect(host.querySelectorAll("img").length).toBe(0);
134+
expect(host.querySelector(".animate-pulse")).toBeNull();
135+
});
136+
137+
it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", () => {
138+
render({ imageSrc: "/api/projects/p/preview/assets/logo.svg" });
139+
const probe = lastProbe();
140+
141+
act(() => {
142+
// naturalWidth stays 0 — SVG with no width/height attribute
143+
probe.onload?.();
144+
});
145+
146+
expectFirstTileSrc("/api/projects/p/preview/assets/logo.svg");
147+
expect(host.querySelector(".animate-pulse")).toBeNull();
148+
});
149+
150+
it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", () => {
151+
// Some browser/sandbox environments fire onerror for SVGs even though the
152+
// <img> element itself can render the file — we must not blank the strip.
153+
render({ imageSrc: "/api/projects/p/preview/assets/icon.svg" });
154+
155+
act(() => lastProbe().onerror?.());
156+
157+
expectFirstTileSrc("/api/projects/p/preview/assets/icon.svg");
158+
expect(host.querySelector(".animate-pulse")).toBeNull();
159+
});
160+
161+
it("renders the label above the strip when provided", () => {
162+
render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" });
163+
act(() => {
164+
const probe = lastProbe();
165+
probe.naturalWidth = 100;
166+
probe.naturalHeight = 100;
167+
probe.onload?.();
168+
});
169+
const label = [...host.querySelectorAll("span")].find((s) => s.textContent === "hero");
170+
expect(label).toBeDefined();
171+
expect(label!.closest(".z-10")).not.toBeNull();
172+
});
173+
});

0 commit comments

Comments
 (0)