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 0000000000..6e430bd151 Binary files /dev/null and b/docs/images/image-thumbnail-evidence/after.png differ diff --git a/docs/images/image-thumbnail-evidence/before.png b/docs/images/image-thumbnail-evidence/before.png new file mode 100644 index 0000000000..cde9f5fbe5 Binary files /dev/null and b/docs/images/image-thumbnail-evidence/before.png differ 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 = ""; + } +}