diff --git a/packages/studio/src/hooks/useThumbnailStill.test.ts b/packages/studio/src/hooks/useThumbnailStill.test.ts new file mode 100644 index 00000000000..ae3adacaf4f --- /dev/null +++ b/packages/studio/src/hooks/useThumbnailStill.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment happy-dom + +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 "../player/lib/thumbnailScheduler"; +// By package name, as a host imports it: the test fails if the export goes. +import { useThumbnailStill, type ThumbnailStill } from "@hyperframes/studio"; + +Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + +class MockImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + naturalWidth = 320; + naturalHeight = 180; + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } +} + +const URL_ONE = "http://localhost:3000/api/projects/demo/thumbnail/index.html?t=1.00&revision=1"; +const originalImage = globalThis.Image; +const originalFetch = globalThis.fetch; +const originalCreate = URL.createObjectURL; +const originalRevoke = URL.revokeObjectURL; +let host: HTMLDivElement; +let root: Root | null = null; +let shown: ThumbnailStill | null = null; + +function Still({ url }: { url: string | null }) { + shown = useThumbnailStill(url, "demo"); + return null; +} + +const mount = (url: string | null) => + act(async () => { + root ??= createRoot(host); + root.render(React.createElement(Still, { url })); + }); + +beforeEach(() => { + globalThis.Image = MockImage as unknown as typeof Image; + globalThis.fetch = vi.fn(async () => new Response(new Blob(["still"]), { status: 200 })); + URL.createObjectURL = vi.fn(() => "blob:still"); + URL.revokeObjectURL = vi.fn(); + shown = null; + host = document.createElement("div"); + document.body.append(host); +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + thumbnailScheduler.setPreviewReloading(false); + thumbnailScheduler.invalidateProject("demo"); + globalThis.Image = originalImage; + globalThis.fetch = originalFetch; + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + document.body.replaceChildren(); +}); + +describe("useThumbnailStill", () => { + it("hands back the still's object URL once the scheduler has loaded it", async () => { + await mount(URL_ONE); + expect(globalThis.fetch).toHaveBeenCalledWith(URL_ONE, expect.anything()); + await vi.waitFor(() => expect(shown).toEqual({ status: "ready", url: "blob:still" })); + }); + + it("hands back the same object while nothing changes", async () => { + await mount(URL_ONE); + await vi.waitFor(() => expect(shown).toEqual({ status: "ready", url: "blob:still" })); + const first = shown; + await mount(URL_ONE); + expect(shown).toBe(first); + }); + + it("asks for nothing without a url", async () => { + await mount(null); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(shown).toEqual({ status: "loading" }); + }); + + it("says failed when the still cannot load, so a host can fall back", async () => { + globalThis.fetch = vi.fn(async () => new Response("", { status: 500 })); + await mount(URL_ONE); + await vi.waitFor(() => expect(shown).toEqual({ status: "failed" })); + }); + + it("holds the fetch while a preview reloads, and runs it once the reload is over", async () => { + thumbnailScheduler.setPreviewReloading(true); + await mount(URL_ONE); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(shown).toEqual({ status: "loading" }); + await act(async () => thumbnailScheduler.setPreviewReloading(false)); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(shown).toEqual({ status: "ready", url: "blob:still" })); + }); +}); diff --git a/packages/studio/src/hooks/useThumbnailStill.ts b/packages/studio/src/hooks/useThumbnailStill.ts new file mode 100644 index 00000000000..ad5cde88844 --- /dev/null +++ b/packages/studio/src/hooks/useThumbnailStill.ts @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { compositionThumbnailRequest } from "../player/components/CompositionThumbnail"; +import type { ThumbnailPriority } from "../player/lib/thumbnailScheduler"; +import { useThumbnailLease } from "./useThumbnailLease"; + +export interface UseThumbnailStillOptions { + sessionEpoch?: number; + priority?: ThumbnailPriority; +} + +export type ThumbnailStill = + | { status: "loading" } + | { status: "ready"; url: string } + | { status: "failed" }; + +const LOADING: ThumbnailStill = Object.freeze({ status: "loading" }); +const FAILED: ThumbnailStill = Object.freeze({ status: "failed" }); + +/** A host picture's server still through the thumbnail scheduler, so it yields to a preview reload. */ +export function useThumbnailStill( + url: string | null, + projectId: string, + { sessionEpoch = 0, priority = "visible" }: UseThumbnailStillOptions = {}, +): ThumbnailStill { + const request = useMemo( + () => (url ? compositionThumbnailRequest(url, projectId, { sessionEpoch, priority }) : null), + [priority, projectId, sessionEpoch, url], + ); + const snapshot = useThumbnailLease(request); + const ready = + snapshot.status === "ready" && snapshot.value.kind === "image" ? snapshot.value.url : null; + return useMemo( + () => + ready ? { status: "ready", url: ready } : snapshot.status === "error" ? FAILED : LOADING, + [ready, snapshot.status], + ); +} diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index ed1b257ae6f..ec83dd45a5a 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -76,6 +76,8 @@ export type { AudioWaveformProps } from "./player/components/AudioWaveform"; export { ImageThumbnail } from "./player/components/ImageThumbnail"; export type { ImageThumbnailProps } from "./player/components/ImageThumbnail"; export { useRenderClipContent } from "./hooks/useRenderClipContent"; +export { useThumbnailStill } from "./hooks/useThumbnailStill"; +export type { ThumbnailStill, UseThumbnailStillOptions } from "./hooks/useThumbnailStill"; export type { UseRenderClipContentOptions } from "./hooks/useRenderClipContent"; export type { ThumbnailPriority } from "./player/lib/thumbnailScheduler"; export type {