Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/studio/src/components/nle/AssetPreviewOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { useEffect, useCallback } from "react";
import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";

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

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

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

return (
Expand Down
5 changes: 3 additions & 2 deletions packages/studio/src/components/nle/useCompositionStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { usePlayerStore } from "../../player";
import type { CompositionLevel } from "./CompositionBreadcrumb";
import { encodePreviewPath } from "../../player/components/thumbnailUtils";

interface UseCompositionStackOptions {
projectId: string;
Expand Down Expand Up @@ -84,7 +85,7 @@ export function useCompositionStack({
.split("/")
.pop()
?.replace(/\.html$/, "") || resolvedPath;
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(resolvedPath)}`;
return [...prev, { id: resolvedPath, label, previewUrl }];
});
},
Expand All @@ -100,7 +101,7 @@ export function useCompositionStack({
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
const previewUrl = `/api/projects/${projectId}/preview/comp/${encodePreviewPath(activeCompositionPath)}`;
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => {
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
Expand Down
26 changes: 18 additions & 8 deletions packages/studio/src/components/sidebar/AssetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";

/** Drag payload writer shared by the asset tile and the font row: copy effect
* plus the timeline-asset MIME and a plain-text path fallback. */
Expand Down Expand Up @@ -40,25 +41,32 @@ function useProbedDuration(src: string, skip: boolean): number | null | undefine
useEffect(() => {
if (skip) return;
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
// The in-flight probe element, so unmount cleanup can abort its network
// fetch (clearing `src`) instead of leaving it to finish in the background.
let liveVid: HTMLVideoElement | null = null;

function teardown(vid: HTMLVideoElement) {
vid.onloadedmetadata = null;
vid.onerror = null;
vid.src = "";
}

function probe(attempt: number) {
if (cancelled) return;
const vid = document.createElement("video");
liveVid = vid;
vid.preload = "metadata";
vid.muted = true;
vid.onloadedmetadata = () => {
const d = Number.isFinite(vid.duration) && vid.duration > 0 ? vid.duration : null;
teardown(vid);
if (!cancelled) setDuration(d);
vid.onloadedmetadata = null;
vid.onerror = null;
vid.src = "";
};
vid.onerror = () => {
vid.onloadedmetadata = null;
vid.onerror = null;
vid.src = "";
teardown(vid);
if (!cancelled) {
if (attempt < 1) setTimeout(() => probe(attempt + 1), 50);
if (attempt < 1) retryTimer = setTimeout(() => probe(attempt + 1), 50);
else setDuration(null);
}
};
Expand All @@ -68,6 +76,8 @@ function useProbedDuration(src: string, skip: boolean): number | null | undefine
probe(0);
return () => {
cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
if (liveVid) teardown(liveVid);
};
}, [src, skip]);
return duration;
Expand Down Expand Up @@ -111,7 +121,7 @@ export function AssetCard({
const fullName = asset.split("/").pop() ?? asset;
const name = basename(asset);
const extension = ext(asset);
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
const serveUrl = resolveMediaPreviewUrl(asset, projectId);
const isVideo = VIDEO_EXT.test(asset);
const isImage = IMAGE_EXT.test(asset);
const probedDuration = useProbedDuration(serveUrl, !isVideo || duration != null);
Expand Down
3 changes: 2 additions & 1 deletion packages/studio/src/components/sidebar/AudioRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
import { usePlayerStore } from "../../player/store/playerStore";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";

export function AudioRow({
projectId,
Expand Down Expand Up @@ -37,7 +38,7 @@ export function AudioRow({
const animRef = useRef<number>(0);
const name = basename(asset);
const subtype = getAudioSubtype(asset);
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
const serveUrl = resolveMediaPreviewUrl(asset, projectId);

// CapCut-style click behavior: drag-threshold gate.
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
Expand Down
173 changes: 173 additions & 0 deletions packages/studio/src/player/components/ImageThumbnail.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ImageThumbnail } from "./ImageThumbnail";

Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: true,
});

// --- Observer stubs: fire "intersecting" immediately on observe ---
class MockIntersectionObserver {
private cb: IntersectionObserverCallback;
constructor(cb: IntersectionObserverCallback) {
this.cb = cb;
}
observe() {
this.cb(
[{ isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
disconnect() {}
unobserve() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}

class MockResizeObserver {
observe() {}
disconnect() {}
unobserve() {}
}

// --- Image stub: captures instances so tests fire load/error deterministically ---
class MockImage {
static instances: MockImage[] = [];
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
naturalWidth = 0;
naturalHeight = 0;
src = "";
constructor() {
MockImage.instances.push(this);
}
}

const originalIO = globalThis.IntersectionObserver;
const originalRO = globalThis.ResizeObserver;
const originalImage = globalThis.Image;

let host: HTMLDivElement;
let root: Root | null = null;

beforeEach(() => {
globalThis.IntersectionObserver =
MockIntersectionObserver as unknown as typeof IntersectionObserver;
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
globalThis.Image = MockImage as unknown as typeof Image;
MockImage.instances = [];
host = document.createElement("div");
document.body.append(host);
});

afterEach(() => {
act(() => root?.unmount());
root = null;
globalThis.IntersectionObserver = originalIO;
globalThis.ResizeObserver = originalRO;
globalThis.Image = originalImage;
document.body.innerHTML = "";
});

function render(props: { imageSrc: string; label?: string; labelColor?: string }) {
root = createRoot(host);
act(() => {
root!.render(
<ImageThumbnail
imageSrc={props.imageSrc}
label={props.label ?? ""}
labelColor={props.labelColor ?? "#fff"}
/>,
);
});
}

function lastProbe(): MockImage {
const probe = MockImage.instances.at(-1);
expect(probe).toBeDefined();
return probe!;
}

/** Assert at least one tile rendered and the first tile serves `expectedSrc`. */
function expectFirstTileSrc(expectedSrc: string): void {
const imgs = [...host.querySelectorAll("img")];
expect(imgs.length).toBeGreaterThanOrEqual(1);
expect(imgs[0].getAttribute("src")).toBe(expectedSrc);
}

describe("ImageThumbnail", () => {
it("shows the loading shimmer before the image resolves", () => {
render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
expect(host.querySelector(".animate-pulse")).not.toBeNull();
expect(host.querySelectorAll("img").length).toBe(0);
});

it("probes the resolved src and renders repeated object-cover tiles on load", () => {
render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
const probe = lastProbe();
expect(probe.src).toBe("/api/projects/p/preview/assets/pic.png");

act(() => {
probe.naturalWidth = 1920;
probe.naturalHeight = 1080;
probe.onload?.();
});

const imgs = [...host.querySelectorAll("img")];
expect(imgs.length).toBeGreaterThanOrEqual(1);
for (const img of imgs) {
expect(img.getAttribute("src")).toBe("/api/projects/p/preview/assets/pic.png");
expect(img.className).toContain("object-cover");
}
expect(host.querySelector(".animate-pulse")).toBeNull();
});

it("drops the shimmer and renders no tiles when a raster image fails to load", () => {
render({ imageSrc: "/api/projects/p/preview/assets/missing.png" });
act(() => lastProbe().onerror?.());
expect(host.querySelectorAll("img").length).toBe(0);
expect(host.querySelector(".animate-pulse")).toBeNull();
});

it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", () => {
render({ imageSrc: "/api/projects/p/preview/assets/logo.svg" });
const probe = lastProbe();

act(() => {
// naturalWidth stays 0 — SVG with no width/height attribute
probe.onload?.();
});

expectFirstTileSrc("/api/projects/p/preview/assets/logo.svg");
expect(host.querySelector(".animate-pulse")).toBeNull();
});

it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", () => {
// Some browser/sandbox environments fire onerror for SVGs even though the
// <img> element itself can render the file — we must not blank the strip.
render({ imageSrc: "/api/projects/p/preview/assets/icon.svg" });

act(() => lastProbe().onerror?.());

expectFirstTileSrc("/api/projects/p/preview/assets/icon.svg");
expect(host.querySelector(".animate-pulse")).toBeNull();
});

it("renders the label above the strip when provided", () => {
render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" });
act(() => {
const probe = lastProbe();
probe.naturalWidth = 100;
probe.naturalHeight = 100;
probe.onload?.();
});
const label = [...host.querySelectorAll("span")].find((s) => s.textContent === "hero");
expect(label).toBeDefined();
expect(label!.closest(".z-10")).not.toBeNull();
});
});
Loading
Loading