From 91b21ae06f1f253f97ad8d9d3b472a97e63773bc Mon Sep 17 00:00:00 2001 From: James Russo Date: Sun, 27 Sep 2026 02:16:18 +0000 Subject: [PATCH] perf(studio): serve bounded JPEG timeline thumbnails (#4560) --- bun.lock | 1 + .../images/image-thumbnail-evidence/after.png | Bin 0 -> 26191 bytes .../image-thumbnail-evidence/before.png | Bin 0 -> 23346 bytes docs/plans/2026-09-26-image-thumbnails.md | 49 +++++++ packages/studio-server/package.json | 3 +- packages/studio-server/src/createStudioApi.ts | 2 + .../src/routes/imageThumbnail.test.ts | 126 ++++++++++++++++++ .../src/routes/imageThumbnail.ts | 109 +++++++++++++++ .../src/player/components/ImageThumbnail.tsx | 15 +-- .../player/lib/thumbnailImageDecoder.test.ts | 82 ++++++++++++ .../src/player/lib/thumbnailImageDecoder.ts | 73 ++++++++++ 11 files changed, 447 insertions(+), 13 deletions(-) create mode 100644 docs/images/image-thumbnail-evidence/after.png create mode 100644 docs/images/image-thumbnail-evidence/before.png create mode 100644 docs/plans/2026-09-26-image-thumbnails.md create mode 100644 packages/studio-server/src/routes/imageThumbnail.test.ts create mode 100644 packages/studio-server/src/routes/imageThumbnail.ts create mode 100644 packages/studio/src/player/lib/thumbnailImageDecoder.test.ts create mode 100644 packages/studio/src/player/lib/thumbnailImageDecoder.ts diff --git a/bun.lock b/bun.lock index f036f672dd..0bd6d3deaa 100644 --- a/bun.lock +++ b/bun.lock @@ -378,6 +378,7 @@ "linkedom": "^0.18.12", "postcss": "^8.5.8", "postcss-selector-parser": "^7.1.2", + "sharp": "^0.35.0", }, "devDependencies": { "@types/node": "^25.0.10", diff --git a/docs/images/image-thumbnail-evidence/after.png b/docs/images/image-thumbnail-evidence/after.png new file mode 100644 index 0000000000000000000000000000000000000000..6e430bd1516a7f345bf22d97678e26daaa3a4460 GIT binary patch literal 26191 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 literal 0 HcmV?d00001 diff --git a/docs/images/image-thumbnail-evidence/before.png b/docs/images/image-thumbnail-evidence/before.png new file mode 100644 index 0000000000000000000000000000000000000000..cde9f5fbe54cbbe579c1a29335dd98931309a7e6 GIT binary patch literal 23346 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 literal 0 HcmV?d00001 diff --git a/docs/plans/2026-09-26-image-thumbnails.md b/docs/plans/2026-09-26-image-thumbnails.md new file mode 100644 index 0000000000..5dd345133e --- /dev/null +++ b/docs/plans/2026-09-26-image-thumbnails.md @@ -0,0 +1,49 @@ +# Bounded timeline JPEG thumbnails + +Status: validating. Local only; PR publication authorized, merge/release excluded. +Risk tier 3: image processing across the project file-serving boundary. + +## Evidence and goal + +Base: main 640e99043, isolated worktree. Timeline image strips request original 2560x1920 photos but budget them as 240x135. A frozen local project exposes 43 original JPEGs in its timeline. No private media will be committed or uploaded. + +A client-side resizing experiment passed 35 tests and reduced retained pixel dimensions, but three-run cold-load readiness increased from a median 2.47s to about 3.8s. Offscreen encoding and bitmap resizing did not remove this regression. Reject that approach. + +Deliver bounded JPEG thumbnail responses to the existing scheduler, preserving original composition assets and export behavior. Require before/after browser evidence, correct aspect, no missing thumbnails, no material startup regression, cache invalidation, cancellation, and focused negative-path tests before publishing. + +## Reviewed plan + +1. Add a fixed-size JPEG route using existing project resolution/path containment and Sharp (already a CLI dependency). No arbitrary remote URLs, requested dimensions or output paths. Limit input size/pixels and concurrent work. Keep a bounded in-memory cache, invalidate on file stat changes, deduplicate concurrent requests. +2. Route only same-origin local preview JPEGs through it. Preserve other image types and external sources. Retain scheduler ownership and account for actual thumbnail pixels. Fall back to original sources on unsupported/unavailable thumbnail endpoints. +3. Test unknown projects, traversal/symlink escapes, non-images, corrupt/oversized files, concurrent requests, changed assets and HTTP validation. Test client fallback and cancellation. +4. Repeat frozen-project browser measurements and capture the actual component using synthetic public media. Run types, focused tests, lint/format and changed-file audit; review complete diff. + +## Invariants and review + +No user composition mutations, export substitution, private uploads or new remote processing. Reuse existing safe path resolution; reject traversal before opening a source. Read bounded input into memory so asynchronous processing does not reopen a retargetable path. Decoder metadata must confirm JPEG; reject other formats despite suffix. Cache is per API host, bounded by bytes and entries, includes file identity, and dies with host. Sharp failures return a bounded error and the client preserves original-image behavior. Cancellation must not leak object URLs. Avoid claiming decoded-pixel estimates equal process RSS. All work is reversible by reverting the PR; no data migration or persisted thumbnail artifacts. + +## Non-goals + +Photo lookahead, video preloading, PNG/WebP/GIF/SVG conversion, full-resolution rendering changes, server-side remote fetches, and general image transformation APIs are separate work. + +## Validation log + +Pending implementation and end-to-end comparison. Native dependency loading must be verified in supported CI hosts before merge. + +### Measurements and implementation review + +Three browser runs per variant, Chrome 147 headless at 1280x720, fixed composition/media, same Vite server and original/changed component alternated. Readiness is the harness threshold of at least 147 mounted timeline images decoded; it is not time-to-first-frame. Runtime clock was read from the actual composition player, after clicking Studio Play. Playback samples last ten seconds. Native-image dimensions are a resource-size estimate; summed Chrome process RSS is a separate noisy measurement. + +| Median | Original | Server thumbnails | +| --- | ---: | ---: | +| Readiness | 2342 ms | 2418 ms | +| RAF callbacks / ten seconds | 579 | 593 | +| Largest RAF gap | 208 ms | 58 ms | +| Gaps above 50 ms | 3 | 1 | +| Chrome process RSS | 2205 MiB | 2239 MiB | + +All three runs had 52 unique loaded timeline sources. Their decoded-pixel estimate was 956 MiB before and 4.4 MiB after. This is not a measured RAM reduction: RSS did not improve. Startup differs by 76 ms (3.2%); do not claim startup speedup. Frame pacing improved in these samples; machine load remains a limitation, and the measurements do not prove sustained whole-film FPS gains. The first cold server-cache sample in an earlier series took 2903 ms; cached samples took 2657 and 2519 ms. Composition photo downloads remain unchanged. + +Public evidence uses a synthetic test chart in the real ImageThumbnail component, with original and thumbnail images at identical display size. User media stays local. Sharp sizing/input-limit semantics checked against https://sharp.pixelplumbing.com/api-resize/ and https://sharp.pixelplumbing.com/api-constructor/. + +Review corrections: rejected client resize startup regression; bounded server work/cache and input sizes; preserved non-JPEG/external sources and old-server fallback; extracted path validation to lower route complexity; kept lockfile changes limited to the existing Sharp dependency mapping. Final local validation: 32 client/scheduler tests and 13 server/coordinator tests pass; Studio and studio-server type checks pass; lint/format and changed-file audit pass (duplication warnings remain). Server build passes. Implementation review additionally rejects non-JPEG magic bytes before invoking native decoding. PR CI and cross-platform native dependency checks remain pending. diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json index 82f6b09128..eb082c8395 100644 --- a/packages/studio-server/package.json +++ b/packages/studio-server/package.json @@ -139,7 +139,8 @@ "hono": "^4.0.0", "linkedom": "^0.18.12", "postcss": "^8.5.8", - "postcss-selector-parser": "^7.1.2" + "postcss-selector-parser": "^7.1.2", + "sharp": "^0.35.0" }, "devDependencies": { "@types/node": "^25.0.10", diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index ce8d625160..47f0a6a082 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -5,6 +5,7 @@ import { registerFileRoutes } from "./routes/files.js"; import { registerPreviewRoutes } from "./routes/preview.js"; import { registerLintRoutes } from "./routes/lint.js"; import { registerRenderRoutes } from "./routes/render.js"; +import { registerImageThumbnailRoutes } from "./routes/imageThumbnail.js"; import { registerThumbnailRoutes } from "./routes/thumbnail.js"; import { registerWaveformRoutes } from "./routes/waveform.js"; import { registerFontRoutes } from "./routes/fonts.js"; @@ -29,6 +30,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono { registerLintRoutes(api, adapter); registerRenderRoutes(api, adapter); registerThumbnailRoutes(api, adapter); + registerImageThumbnailRoutes(api, adapter); registerSelectionRoutes(api, adapter); registerMediaRoutes(api, adapter); registerWaveformRoutes(api, adapter); diff --git a/packages/studio-server/src/routes/imageThumbnail.test.ts b/packages/studio-server/src/routes/imageThumbnail.test.ts new file mode 100644 index 0000000000..e5ffca7111 --- /dev/null +++ b/packages/studio-server/src/routes/imageThumbnail.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Hono } from "hono"; +import { mkdtemp, rm, writeFile, symlink, open } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sharp from "sharp"; +import { registerImageThumbnailRoutes } from "./imageThumbnail"; +import type { StudioApiAdapter } from "../types"; + +let dir: string; +let outside: string; +let app: Hono; +const url = "http://localhost/projects/p/image-thumbnail/photo.jpg"; +async function photo(path: string, width = 2560, height = 1920) { + await sharp({ create: { width, height, channels: 3, background: "#6495ed" } }) + .jpeg() + .toFile(path); +} +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "hf-image-thumbnail-")); + outside = await mkdtemp(join(tmpdir(), "hf-image-outside-")); + const adapter: StudioApiAdapter = { + listProjects: () => [], + resolveProject: (id) => (id === "p" ? { id, dir } : null), + bundle: async () => null, + lint: async () => ({ findings: [] }), + runtimeUrl: "/runtime.js", + rendersDir: () => dir, + startRender: () => ({ id: "unused", status: "rendering", progress: 0, outputPath: "" }), + }; + app = new Hono(); + registerImageThumbnailRoutes(app, adapter); + await photo(join(dir, "photo.jpg")); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); +}); + +describe("JPEG timeline thumbnail route", () => { + it("returns a bounded JPEG, privately revalidated and unchanged on repeat requests", async () => { + const response = await app.request(url); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/jpeg"); + expect(response.headers.get("cache-control")).toBe("private, no-cache"); + const bytes = Buffer.from(await response.arrayBuffer()); + const metadata = await sharp(bytes).metadata(); + expect([metadata.width, metadata.height]).toEqual([180, 135]); + const repeat = await app.request(url); + expect(Buffer.from(await repeat.arrayBuffer())).toEqual(bytes); + const etag = response.headers.get("etag"); + if (!etag) throw new Error("missing etag"); + expect((await app.request(url, { headers: { "If-None-Match": etag } })).status).toBe(304); + }); + it("invalidates cached responses when the source changes", async () => { + const first = await app.request(url); + await photo(join(dir, "photo.jpg"), 1000, 2000); + const next = await app.request(url); + expect(next.headers.get("etag")).not.toBe(first.headers.get("etag")); + const metadata = await sharp(Buffer.from(await next.arrayBuffer())).metadata(); + expect([metadata.width, metadata.height]).toEqual([68, 135]); + }); + it("deduplicates concurrent requests and keeps their results readable", async () => { + const responses = await Promise.all(Array.from({ length: 8 }, () => app.request(url))); + const buffers = await Promise.all( + responses.map(async (response) => { + expect(response.status).toBe(200); + return Buffer.from(await response.arrayBuffer()); + }), + ); + expect(buffers.every((buffer) => buffer.equals(buffers[0]))).toBe(true); + }); + it("respects EXIF orientation and rejects excessive pixel counts", async () => { + const path = join(dir, "photo.jpg"); + const bytes = await sharp({ + create: { width: 2560, height: 1920, channels: 3, background: "#6495ed" }, + }) + .withMetadata({ orientation: 6 }) + .jpeg() + .toBuffer(); + await writeFile(path, bytes); + const response = await app.request(url); + const metadata = await sharp(Buffer.from(await response.arrayBuffer())).metadata(); + expect([metadata.width, metadata.height]).toEqual([101, 135]); + const header = bytes.indexOf(Buffer.from([0xff, 0xc0])); + expect(header).toBeGreaterThan(0); + bytes.writeUInt16BE(50000, header + 5); + bytes.writeUInt16BE(50000, header + 7); + await writeFile(path, bytes); + expect((await app.request(url)).status).toBe(422); + expect((await app.request(url.replace("photo.jpg", "missing.jpg"))).status).toBe(404); + }); + + it("does not upscale small JPEGs", async () => { + await photo(join(dir, "photo.jpg"), 40, 30); + const response = await app.request(url); + const metadata = await sharp(Buffer.from(await response.arrayBuffer())).metadata(); + expect([metadata.width, metadata.height]).toEqual([40, 30]); + }); + it("rejects unknown projects, encoded traversal and symlink escapes", async () => { + expect((await app.request(url.replace("/p/", "/unknown/"))).status).toBe(404); + expect((await app.request(url.replace("photo.jpg", "..%2Foutside.jpg"))).status).toBe(404); + await photo(join(outside, "private.jpg")); + await symlink(join(outside, "private.jpg"), join(dir, "link.jpg")); + expect((await app.request(url.replace("photo.jpg", "link.jpg"))).status).toBe(404); + }); + it("rejects other formats even if renamed to JPEG", async () => { + expect((await app.request(url.replace("photo.jpg", "photo.png"))).status).toBe(415); + await writeFile( + join(dir, "photo.jpg"), + await sharp({ create: { width: 10, height: 10, channels: 4, background: "#fff" } }) + .png() + .toBuffer(), + ); + expect((await app.request(url)).status).toBe(422); + }); + it("rejects malformed paths, corrupt images and oversized sources", async () => { + expect((await app.request(url.replace("photo.jpg", "%FF.jpg"))).status).toBe(400); + await writeFile(join(dir, "photo.jpg"), "not an image"); + expect((await app.request(url)).status).toBe(422); + const file = await open(join(dir, "photo.jpg"), "w"); + await file.truncate(65 * 1024 * 1024); + await file.close(); + expect((await app.request(url)).status).toBe(413); + }); +}); diff --git a/packages/studio-server/src/routes/imageThumbnail.ts b/packages/studio-server/src/routes/imageThumbnail.ts new file mode 100644 index 0000000000..10de006c42 --- /dev/null +++ b/packages/studio-server/src/routes/imageThumbnail.ts @@ -0,0 +1,109 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { open, stat } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import sharp from "sharp"; +import type { StudioApiAdapter } from "../types.js"; +import { pinWithinProject } from "../helpers/safePath.js"; +import { requestSubPath } from "../helpers/requestSubPath.js"; +import { ThumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator.js"; + +const MAX_SOURCE_BYTES = 64 * 1024 * 1024; +const MAX_CACHE_BYTES = 8 * 1024 * 1024; + +async function generateThumbnail(path: string, signal: AbortSignal): Promise { + const file = await open(path, "r"); + try { + const info = await file.stat(); + if (!info.isFile() || info.size > MAX_SOURCE_BYTES) throw new Error("Unsupported image"); + const source = Buffer.alloc(info.size); + let offset = 0; + while (offset < source.length) { + signal.throwIfAborted(); + const { bytesRead } = await file.read(source, offset, source.length - offset, offset); + if (!bytesRead) break; + offset += bytesRead; + } + if (source[0] !== 0xff || source[1] !== 0xd8) throw new Error("Unsupported image"); + const image = sharp(source.subarray(0, offset), { limitInputPixels: 40_000_000 }); + if ((await image.metadata()).format !== "jpeg") throw new Error("Unsupported image"); + signal.throwIfAborted(); + const result = await image + .rotate() + .resize(240, 135, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 80 }) + .toBuffer(); + signal.throwIfAborted(); + return result; + } finally { + await file.close(); + } +} + +async function resolveSource(adapter: StudioApiAdapter, id: string, url: string) { + const project = await adapter.resolveProject(id); + if (!project) throw new HTTPException(404); + let relative: string; + try { + relative = requestSubPath(url, "projects/:id/image-thumbnail"); + } catch { + throw new HTTPException(400); + } + if (!/\.jpe?g$/i.test(relative)) throw new HTTPException(415); + const path = pinWithinProject(project.dir, relative); + if (!path) throw new HTTPException(404); + const info = await stat(path).catch(() => { + throw new HTTPException(404); + }); + if (!info.isFile()) throw new HTTPException(404); + if (info.size > MAX_SOURCE_BYTES) throw new HTTPException(413); + return { path, info }; +} + +export function registerImageThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): void { + const coordinator = new ThumbnailGenerationCoordinator(2); + const cache = new Map(); + let cacheBytes = 0; + const remember = (key: string, image: Buffer) => { + cacheBytes -= cache.get(key)?.byteLength ?? 0; + cache.delete(key); + cache.set(key, image); + cacheBytes += image.byteLength; + while (cacheBytes > MAX_CACHE_BYTES || cache.size > 128) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cacheBytes -= cache.get(oldest)?.byteLength ?? 0; + cache.delete(oldest); + } + }; + api.get("/projects/:id/image-thumbnail/*", async (c) => { + try { + const { path, info } = await resolveSource(adapter, c.req.param("id"), c.req.url); + const key = createHash("sha256") + .update(JSON.stringify([path, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])) + .digest("hex"); + const etag = `"${key}"`; + c.header("ETag", etag); + c.header("Cache-Control", "private, no-cache"); + c.header("X-Content-Type-Options", "nosniff"); + if (c.req.header("If-None-Match") === etag) return c.body(null, 304); + let result = cache.get(key); + if (result) remember(key, result); + else { + if (coordinator.protectedKeys().size >= 32) return c.text("thumbnail queue full", 429); + result = + (await coordinator.acquire(key, c.req.raw.signal, async (signal) => { + const image = await generateThumbnail(path, signal); + remember(key, image); + return image; + })) ?? undefined; + } + if (!result) return c.text("image unavailable", 422); + c.header("Content-Type", "image/jpeg"); + return c.body(new Uint8Array(result)); + } catch (error) { + if (error instanceof HTTPException) return error.getResponse(); + return c.text("image unavailable", 422); + } + }); +} diff --git a/packages/studio/src/player/components/ImageThumbnail.tsx b/packages/studio/src/player/components/ImageThumbnail.tsx index c5121ac377..7204f7952e 100644 --- a/packages/studio/src/player/components/ImageThumbnail.tsx +++ b/packages/studio/src/player/components/ImageThumbnail.tsx @@ -2,8 +2,8 @@ import { memo, useCallback, useMemo, useRef, useState } from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; import { useThumbnailLease } from "../../hooks/useThumbnailLease"; import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler"; -import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets"; -import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils"; +import { decodeImageThumbnail } from "../lib/thumbnailImageDecoder"; +import { computeThumbnailStrip } from "./thumbnailUtils"; export interface ImageThumbnailProps { imageSrc: string; @@ -35,16 +35,7 @@ export const ImageThumbnail = memo(function ImageThumbnail({ kind: "image" as const, priority, rich, - load: async (signal: AbortSignal) => { - const aspect = await probeImageAspect(imageSrc, signal, true); - return { - value: { kind: "image" as const, url: imageSrc, aspect }, - weight: - TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth * - TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight * - 4, - }; - }, + load: (signal: AbortSignal) => decodeImageThumbnail(imageSrc, signal), }), [imageSrc, priority, projectId, rich, sessionEpoch], ); diff --git a/packages/studio/src/player/lib/thumbnailImageDecoder.test.ts b/packages/studio/src/player/lib/thumbnailImageDecoder.test.ts new file mode 100644 index 0000000000..75d963bf8d --- /dev/null +++ b/packages/studio/src/player/lib/thumbnailImageDecoder.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { decodeImageThumbnail } from "./thumbnailImageDecoder"; + +class TestImage { + static instances: TestImage[] = []; + naturalWidth = 180; + naturalHeight = 135; + src = ""; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + constructor() { + TestImage.instances.push(this); + } +} +beforeEach(() => { + TestImage.instances = []; + vi.stubGlobal("Image", TestImage); +}); +afterEach(() => vi.unstubAllGlobals()); +function latest() { + const image = TestImage.instances.at(-1); + if (!image) throw new Error("missing image"); + return image; +} + +describe("timeline image thumbnails", () => { + it("requests a bounded local JPEG and accounts for its actual decoded dimensions", async () => { + const result = decodeImageThumbnail( + "/api/projects/p/preview/photo.jpg?v=2", + new AbortController().signal, + ); + expect(latest().src).toBe( + new URL("/api/projects/p/image-thumbnail/photo.jpg?v=2", location.href).href, + ); + latest().onload?.(); + expect(await result).toEqual({ + value: { + kind: "image", + url: new URL("/api/projects/p/image-thumbnail/photo.jpg?v=2", location.href).href, + aspect: 4 / 3, + }, + weight: 180 * 135 * 4, + }); + expect(latest().src).toBe(""); + }); + it.each([ + "https://remote.test/photo.jpg", + "/api/projects/p/preview/image.png", + "/api/projects/p/preview/image.gif", + "/api/projects/p/preview/image.webp", + "/api/projects/p/preview/image.svg", + "/other/photo.jpg", + ])("preserves %s", async (source) => { + const result = decodeImageThumbnail(source, new AbortController().signal); + expect(latest().src).toBe(source); + latest().onload?.(); + expect((await result).value).toMatchObject({ url: source }); + }); + it("falls back for an older server and accounts for original dimensions", async () => { + const result = decodeImageThumbnail( + "/api/projects/p/preview/photo.jpg", + new AbortController().signal, + ); + latest().onerror?.(); + await Promise.resolve(); + expect(latest().src).toBe("/api/projects/p/preview/photo.jpg"); + latest().naturalWidth = 2560; + latest().naturalHeight = 1920; + latest().onload?.(); + expect((await result).weight).toBe(2560 * 1920 * 4); + }); + it("does not retry a cancelled request", async () => { + const controller = new AbortController(); + const result = decodeImageThumbnail("/api/projects/p/preview/photo.jpg", controller.signal); + controller.abort(); + await expect(result).rejects.toMatchObject({ name: "AbortError" }); + expect(TestImage.instances).toHaveLength(1); + expect(latest().src).toBe(""); + expect(latest().onload).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/lib/thumbnailImageDecoder.ts b/packages/studio/src/player/lib/thumbnailImageDecoder.ts new file mode 100644 index 0000000000..f3b6775e7b --- /dev/null +++ b/packages/studio/src/player/lib/thumbnailImageDecoder.ts @@ -0,0 +1,73 @@ +import type { ThumbnailLoadedResult } from "./thumbnailScheduler"; +import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets"; + +function thumbnailSource(source: string): string { + const url = new URL(source, window.location.href); + if ( + url.origin !== window.location.origin || + !/^\/api\/projects\/[^/]+\/preview\/.+\.jpe?g$/i.test(url.pathname) + ) + return source; + url.pathname = url.pathname.replace( + /^(\/api\/projects\/[^/]+)\/preview\//, + "$1/image-thumbnail/", + ); + return url.href; +} + +function loadImage(source: string, signal: AbortSignal): Promise { + return new Promise((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")); + }; + if (signal.aborted) { + onAbort(); + return; + } + image.onload = () => { + cleanup(); + resolve(image); + }; + image.onerror = () => { + cleanup(); + if (/\.svg($|\?)/i.test(source)) resolve(image); + else { + image.src = ""; + reject(new Error("Image thumbnail failed to load")); + } + }; + signal.addEventListener("abort", onAbort, { once: true }); + image.src = source; + }); +} + +export async function decodeImageThumbnail( + source: string, + signal: AbortSignal, +): Promise { + let url = thumbnailSource(source); + let image: HTMLImageElement; + try { + image = await loadImage(url, signal); + } catch (error) { + if (signal.aborted || url === source) throw error; + url = source; + image = await loadImage(source, signal); + } + try { + signal.throwIfAborted(); + const width = image.naturalWidth || TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth; + const height = image.naturalHeight || TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight; + return { value: { kind: "image", url, aspect: width / height }, weight: width * height * 4 }; + } finally { + image.src = ""; + } +}