diff --git a/packages/studio/src/player/components/CompositionThumbnail.tsx b/packages/studio/src/player/components/CompositionThumbnail.tsx index bd677182bb..a5c2743ca9 100644 --- a/packages/studio/src/player/components/CompositionThumbnail.tsx +++ b/packages/studio/src/player/components/CompositionThumbnail.tsx @@ -1,5 +1,7 @@ -import { memo, useCallback, useState, useRef } from "react"; -import { useMountEffect } from "../../hooks/useMountEffect"; +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { useThumbnailLease } from "../../hooks/useThumbnailLease"; +import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler"; +import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets"; interface CompositionThumbnailProps { previewUrl: string; @@ -11,6 +13,10 @@ interface CompositionThumbnailProps { duration?: number; width?: number; height?: number; + projectId?: string; + sessionEpoch?: number; + priority?: ThumbnailPriority; + rich?: boolean; } const CLIP_HEIGHT = 66; @@ -34,9 +40,8 @@ export function buildCompositionThumbnailUrl({ const thumbnailBase = previewUrl .replace("/preview/comp/", "/thumbnail/") .replace(/\/preview$/, "/thumbnail/index.html"); - const midTime = seekTime + duration / 2; const thumbnailUrl = new URL(thumbnailBase, origin); - thumbnailUrl.searchParams.set("t", midTime.toFixed(2)); + thumbnailUrl.searchParams.set("t", (seekTime + duration / 2).toFixed(2)); thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION); if (selector) { thumbnailUrl.searchParams.set("selector", selector); @@ -47,6 +52,23 @@ export function buildCompositionThumbnailUrl({ return thumbnailUrl.toString(); } +async function loadCompositionImage(url: string, signal: AbortSignal) { + const response = await fetch(url, { signal }); + if (!response.ok) throw new Error(`Composition thumbnail failed (${response.status})`); + const blob = await response.blob(); + if (signal.aborted) throw new DOMException("Aborted", "AbortError"); + const objectUrl = URL.createObjectURL(blob); + return { + value: { kind: "image" as const, url: objectUrl, aspect: 16 / 9 }, + weight: + TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth * + TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight * + 4, + dispose: () => URL.revokeObjectURL(objectUrl), + }; +} + +/** Server-rendered composition poster, deduplicated and budgeted by project/session. */ export const CompositionThumbnail = memo(function CompositionThumbnail({ previewUrl, label, @@ -55,31 +77,12 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({ selectorIndex, seekTime = 2, duration = 5, + projectId = previewUrl, + sessionEpoch = 0, + priority = "visible", }: CompositionThumbnailProps) { const [containerWidth, setContainerWidth] = useState(0); - const [loaded, setLoaded] = useState(false); - const [aspect, setAspect] = useState(16 / 9); - const roRef = useRef(null); - - const setContainerRef = useCallback((el: HTMLDivElement | null) => { - roRef.current?.disconnect(); - if (!el) return; - - const measured = el.parentElement?.clientWidth || el.clientWidth; - // fallow-ignore-next-line code-duplication - setContainerWidth(measured); - - const target = el.parentElement || el; - roRef.current = new ResizeObserver(([entry]) => { - setContainerWidth(entry.contentRect.width); - }); - roRef.current.observe(target); - }, []); - - useMountEffect(() => () => { - roRef.current?.disconnect(); - }); - + const observerRef = useRef(null); const url = buildCompositionThumbnailUrl({ previewUrl, seekTime, @@ -88,57 +91,59 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({ selectorIndex, origin: window.location.origin, }); - const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect)); - const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1; + const request = useMemo( + () => ({ + key: createThumbnailKey({ kind: "composition", url }), + projectId, + sessionEpoch, + kind: "composition" as const, + priority, + rich: true, + load: (signal: AbortSignal) => loadCompositionImage(url, signal), + }), + [priority, projectId, sessionEpoch, url], + ); + const snapshot = useThumbnailLease(request); + const value = + snapshot.status === "ready" && snapshot.value.kind === "image" ? snapshot.value : null; + const frameWidth = Math.max(48, Math.round(CLIP_HEIGHT * (value?.aspect ?? 16 / 9))); + const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameWidth)) : 1; + + const setContainerRef = useCallback((element: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + if (!element) return; + const target = element.parentElement ?? element; + setContainerWidth(target.clientWidth); + observerRef.current = new ResizeObserver(([entry]) => + setContainerWidth(entry.contentRect.width), + ); + observerRef.current.observe(target); + }, []); return (
- { - const img = e.currentTarget; - if (img.naturalWidth > 0 && img.naturalHeight > 0) { - setAspect(img.naturalWidth / img.naturalHeight); - } - setLoaded(true); - }} - className="hidden" - /> - - {loaded && ( -
- {Array.from({ length: frameCount }).map((_, i) => ( -
- -
+ {value && ( +
+ {Array.from({ length: frameCount }, (_, index) => ( + ))}
)} - + {snapshot.status === "loading" && ( +
+ )} {label && ( -
+
{label} diff --git a/packages/studio/src/player/components/ImageThumbnail.test.tsx b/packages/studio/src/player/components/ImageThumbnail.test.tsx index f1463a9b40..44b94f958a 100644 --- a/packages/studio/src/player/components/ImageThumbnail.test.tsx +++ b/packages/studio/src/player/components/ImageThumbnail.test.tsx @@ -3,6 +3,7 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { thumbnailScheduler } from "../lib/thumbnailScheduler"; import { ImageThumbnail } from "./ImageThumbnail"; Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { @@ -68,6 +69,7 @@ beforeEach(() => { afterEach(() => { act(() => root?.unmount()); root = null; + thumbnailScheduler.clear(); globalThis.IntersectionObserver = originalIO; globalThis.ResizeObserver = originalRO; globalThis.Image = originalImage; @@ -82,6 +84,10 @@ function render(props: { imageSrc: string; label?: string; labelColor?: string } imageSrc={props.imageSrc} label={props.label ?? ""} labelColor={props.labelColor ?? "#fff"} + projectId="p" + sessionEpoch={1} + priority="visible" + rich={false} />, ); }); @@ -93,6 +99,13 @@ function lastProbe(): MockImage { return probe!; } +async function resolveProbe(update: (probe: MockImage) => void): Promise { + await act(async () => { + update(lastProbe()); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + /** Assert at least one tile rendered and the first tile serves `expectedSrc`. */ function expectFirstTileSrc(expectedSrc: string): void { const imgs = [...host.querySelectorAll("img")]; @@ -107,15 +120,15 @@ describe("ImageThumbnail", () => { expect(host.querySelectorAll("img").length).toBe(0); }); - it("probes the resolved src and renders repeated object-cover tiles on load", () => { + it("probes the resolved src and renders repeated object-cover tiles on load", async () => { 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?.(); + await resolveProbe((current) => { + current.naturalWidth = 1920; + current.naturalHeight = 1080; + current.onload?.(); }); const imgs = [...host.querySelectorAll("img")]; @@ -127,18 +140,18 @@ describe("ImageThumbnail", () => { expect(host.querySelector(".animate-pulse")).toBeNull(); }); - it("drops the shimmer and renders no tiles when a raster image fails to load", () => { + it("drops the shimmer and renders no tiles when a raster image fails to load", async () => { render({ imageSrc: "/api/projects/p/preview/assets/missing.png" }); - act(() => lastProbe().onerror?.()); + await resolveProbe((probe) => probe.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)", () => { + it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", async () => { render({ imageSrc: "/api/projects/p/preview/assets/logo.svg" }); const probe = lastProbe(); - act(() => { + await resolveProbe(() => { // naturalWidth stays 0 — SVG with no width/height attribute probe.onload?.(); }); @@ -147,21 +160,20 @@ describe("ImageThumbnail", () => { expect(host.querySelector(".animate-pulse")).toBeNull(); }); - it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", () => { + it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", async () => { // Some browser/sandbox environments fire onerror for SVGs even though the // element itself can render the file — we must not blank the strip. render({ imageSrc: "/api/projects/p/preview/assets/icon.svg" }); - act(() => lastProbe().onerror?.()); + await resolveProbe((probe) => probe.onerror?.()); expectFirstTileSrc("/api/projects/p/preview/assets/icon.svg"); expect(host.querySelector(".animate-pulse")).toBeNull(); }); - it("renders the label above the strip when provided", () => { + it("renders the label above the strip when provided", async () => { render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" }); - act(() => { - const probe = lastProbe(); + await resolveProbe((probe) => { probe.naturalWidth = 100; probe.naturalHeight = 100; probe.onload?.(); diff --git a/packages/studio/src/player/components/ImageThumbnail.tsx b/packages/studio/src/player/components/ImageThumbnail.tsx index 33914ae2c1..41ee3cead8 100644 --- a/packages/studio/src/player/components/ImageThumbnail.tsx +++ b/packages/studio/src/player/components/ImageThumbnail.tsx @@ -1,155 +1,124 @@ -import { memo, useRef, useState, useCallback, useEffect } from "react"; -import { useMountEffect } from "../../hooks/useMountEffect"; +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { useThumbnailLease } from "../../hooks/useThumbnailLease"; +import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler"; +import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets"; import { computeThumbnailStrip } from "./thumbnailUtils"; interface ImageThumbnailProps { imageSrc: string; label: string; labelColor: string; + projectId?: string; + sessionEpoch?: number; + priority?: ThumbnailPriority; + rich?: boolean; } -/** - * Renders a film-strip of a still image for a timeline clip. The image is a - * fixed-width tile (sized by its natural aspect ratio) repeated to fill the - * clip width — matching VideoThumbnail's visual pattern. Loading is lazy - * (IntersectionObserver) with the same shimmer fallback while decoding. - */ +function probeImage(imageSrc: string, signal: AbortSignal) { + return new Promise<{ aspect: number }>((resolve, reject) => { + const image = new Image(); + const cleanup = () => { + image.onload = null; + image.onerror = null; + signal.removeEventListener("abort", onAbort); + }; + const onAbort = () => { + cleanup(); + image.src = ""; + reject(new DOMException("Aborted", "AbortError")); + }; + image.onload = () => { + cleanup(); + resolve({ + aspect: + image.naturalWidth > 0 && image.naturalHeight > 0 + ? image.naturalWidth / image.naturalHeight + : 16 / 9, + }); + }; + image.onerror = () => { + cleanup(); + if (/\.svg($|\?)/i.test(imageSrc)) resolve({ aspect: 16 / 9 }); + else reject(new Error("Image thumbnail failed to load")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + image.src = imageSrc; + }); +} + +/** A scheduler-backed still-image strip. Mounting is the sole work trigger. */ export const ImageThumbnail = memo(function ImageThumbnail({ imageSrc, label, labelColor, + projectId = imageSrc, + sessionEpoch = 0, + priority = "visible", + rich = false, }: ImageThumbnailProps) { const [containerWidth, setContainerWidth] = useState(0); - const [visible, setVisible] = useState(false); - const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); - const [aspect, setAspect] = useState(16 / 9); - const ioRef = useRef(null); - const roRef = useRef(null); - - const setContainerRef = useCallback((el: HTMLDivElement | null) => { - ioRef.current?.disconnect(); - roRef.current?.disconnect(); - if (!el) return; - - const measured = el.parentElement?.clientWidth || el.clientWidth; - setContainerWidth(measured); - - ioRef.current = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setVisible(true); - ioRef.current?.disconnect(); - } + const observerRef = useRef(null); + const request = useMemo( + () => ({ + key: createThumbnailKey({ kind: "image", source: imageSrc, rich: Number(rich) }), + projectId, + sessionEpoch, + kind: "image" as const, + priority, + rich, + load: async (signal: AbortSignal) => { + const { aspect } = await probeImage(imageSrc, signal); + return { + value: { kind: "image" as const, url: imageSrc, aspect }, + weight: + TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth * + TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight * + 4, + }; }, - { rootMargin: "200px" }, - ); - // fallow-ignore-next-line code-duplication - ioRef.current.observe(el); + }), + [imageSrc, priority, projectId, rich, sessionEpoch], + ); + const snapshot = useThumbnailLease(request); + const value = snapshot.status === "ready" ? snapshot.value : null; + const aspect = value?.kind === "image" ? value.aspect : 16 / 9; + const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect); - const target = el.parentElement || el; - roRef.current = new ResizeObserver(([entry]) => { - setContainerWidth(entry.contentRect.width); - }); - roRef.current.observe(target); + const setContainerRef = useCallback((element: HTMLDivElement | null) => { + observerRef.current?.disconnect(); + if (!element) return; + const target = element.parentElement ?? element; + setContainerWidth(target.clientWidth); + observerRef.current = new ResizeObserver(([entry]) => + setContainerWidth(entry.contentRect.width), + ); + observerRef.current.observe(target); }, []); - useMountEffect(() => () => { - ioRef.current?.disconnect(); - roRef.current?.disconnect(); - }); - - // Probe the image once visible — measures the natural aspect ratio so the - // tile width matches, and flips to the error state (plain clip background) - // if the src can't load. The browser cache makes the tile s free. - // - // SVG handling: SVGs without intrinsic width/height report naturalWidth=0 on - // load (treat as success with the 16:9 default aspect) and may fire onerror - // in some environments even though the file is valid and can be displayed — - // fall back to loaded-at-16:9 rather than hiding the strip entirely. - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (!visible) return; - let cancelled = false; - setStatus("loading"); - - const isSvg = /\.svg($|\?)/i.test(imageSrc); - - const probe = new Image(); - probe.onload = () => { - if (cancelled) return; - if (probe.naturalWidth > 0 && probe.naturalHeight > 0) { - setAspect(probe.naturalWidth / probe.naturalHeight); - } - // naturalWidth===0 (e.g. SVG with no intrinsic dimensions) falls through - // to "loaded" with the default 16:9 aspect already set in state. - setStatus("loaded"); - }; - probe.onerror = () => { - if (cancelled) return; - // SVGs can fail the probe in certain browser/sandbox environments even - // though the tiles themselves render fine (different security - // context). Show the strip at the 16:9 fallback rather than blanking. - if (isSvg) { - setStatus("loaded"); - } else { - setStatus("error"); - } - }; - probe.src = imageSrc; - - return () => { - cancelled = true; - probe.onload = null; - probe.onerror = null; - probe.src = ""; - }; - }, [visible, imageSrc]); - - const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect); - return (
- {visible && status === "loaded" && ( + {value?.kind === "image" && (
- {Array.from({ length: frameCount }).map((_, i) => ( -
( + - -
+ /> ))}
)} - - {visible && status === "loading" && ( -
+ {snapshot.status === "loading" && ( +
)} - {label && ( -
+
{label} diff --git a/packages/studio/src/player/components/VideoThumbnail.test.tsx b/packages/studio/src/player/components/VideoThumbnail.test.tsx index 82f49d8966..faef0ad115 100644 --- a/packages/studio/src/player/components/VideoThumbnail.test.tsx +++ b/packages/studio/src/player/components/VideoThumbnail.test.tsx @@ -2,65 +2,28 @@ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { thumbnailScheduler } from "../lib/thumbnailScheduler"; +import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder"; import { VideoThumbnail } from "./VideoThumbnail"; +vi.mock("../lib/thumbnailVideoDecoder", () => ({ decodeVideoThumbnail: vi.fn() })); + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true, }); -// Fire "intersecting" immediately on observe so the extraction effect runs. -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() {} } -const originalIO = globalThis.IntersectionObserver; -const originalRO = globalThis.ResizeObserver; - let host: HTMLDivElement; let root: Root | null = null; -let createdVideos: HTMLVideoElement[]; beforeEach(() => { - globalThis.IntersectionObserver = - MockIntersectionObserver as unknown as typeof IntersectionObserver; globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; - - createdVideos = []; - const origCreate = document.createElement.bind(document); - vi.spyOn(document, "createElement").mockImplementation((tag: string) => { - const el = origCreate(tag); - if (tag === "video") createdVideos.push(el as HTMLVideoElement); - return el; - }); - - // happy-dom's