From 9ab071843e8f1829cd38044562b58bcf1fecc998 Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Fri, 9 Oct 2026 23:26:30 -0700 Subject: [PATCH 1/2] Reject images over 8,192 pixels per side with a message naming their size Browser formats, HEIC, and TIFF are checked from their header before decoding; SVG at its raster size; RAW once LibRaw has decoded it. The device now takes the adapter's texture limit, so an oversized RAW reaches OpenLight's check instead of failing on WebGPU's default limit. Co-Authored-By: Claude Opus 5.5 --- API.md | 2 +- bun.lock | 3 +++ package.json | 1 + src/core/image/decode/index.ts | 20 ++++++++++++--- src/core/image/decode/raw.ts | 3 +++ src/core/image/decode/size.ts | 33 ++++++++++++++++++++++++ src/core/image/decode/svg.ts | 10 +++++--- src/main.tsx | 12 ++++++++- tests/loaders.e2e.ts | 46 ++++++++++++++++++++++++++++++++++ 9 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 src/core/image/decode/size.ts diff --git a/API.md b/API.md index 7542027e..ac3c963a 100644 --- a/API.md +++ b/API.md @@ -34,7 +34,7 @@ These methods return promises; await them before editing. All but `loadUrl`, whi | `loadUrl(url)` | Fetches an image and loads it. A URL on another origin must allow CORS; the response's content type identifies an image whose URL has no extension. | | `importXmp(file)` | Applies supported Camera Raw adjustments as one undoable edit. | -File loads are queued; `loadUrl` starts at once, and when two opens overlap the later one wins. XMP and `.cube` imports are skipped if no document is ready; an invalid XMP import can reject without blocking later loads. Image, scene, and `.cube` failures do not reject the loading promise. Without a document open, a failure leaves the workspace in its error state; with one, that document stays open and keeps its history. Either way `getState().failure` names the file and the error, and settings loaded in the same batch are skipped. Check `getState().documentId` or `failure` to confirm success. Loading completion does not guarantee the preview has rendered. +File loads are queued; `loadUrl` starts at once, and when two opens overlap the later one wins. XMP and `.cube` imports are skipped if no document is ready; an invalid XMP import can reject without blocking later loads. Image, scene, and `.cube` failures do not reject the loading promise. Without a document open, a failure leaves the workspace in its error state; with one, that document stays open and keeps its history. Either way `getState().failure` names the file and the error, and settings loaded in the same batch are skipped. Check `getState().documentId` or `failure` to confirm success. An image over 8,192 pixels on either side fails to open, with an error naming its size; most formats are checked from their header before decoding, RAW once decoded. Loading completion does not guarantee the preview has rendered. ## Editing diff --git a/bun.lock b/bun.lock index 29da01cc..d73a3910 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "class-variance-authority": "^0.7.1", "cn": "^0.4.0", "idb": "^8.0.3", + "image-size": "2.0.4", "raw-webgpu": "0.1.1", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -270,6 +271,8 @@ "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], + "image-size": ["image-size@2.0.4", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], diff --git a/package.json b/package.json index 2bb36b81..f5d33742 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "class-variance-authority": "^0.7.1", "cn": "^0.4.0", "idb": "^8.0.3", + "image-size": "2.0.4", "raw-webgpu": "0.1.1", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/src/core/image/decode/index.ts b/src/core/image/decode/index.ts index 072abfdb..d804af43 100644 --- a/src/core/image/decode/index.ts +++ b/src/core/image/decode/index.ts @@ -1,5 +1,6 @@ import type { Gpu, Target } from "vgpu"; import { createImageSource, type ImageSource } from "@/core/image"; +import { checkHeader } from "./size"; import type { Decoder } from "./types"; import { uploadDecoded } from "./upload"; @@ -21,11 +22,22 @@ function pixelFormat(load: () => Promise) { }; } -const native = pixelFormat(async () => createImageBitmap); +/** A format whose header declares its size, checked before decoding. */ +function sized(decode: Format["decode"]) { + return async (gpu: Gpu, file: File) => { + await checkHeader(file); + return decode(gpu, file); + }; +} + +const native = sized(pixelFormat(async () => createImageBitmap)); const svg = pixelFormat(async () => (await import("./svg")).decodeSvg); -const heic = pixelFormat(async () => (await import("./heic")).decodeHeic); -const tiff = async (gpu: Gpu, file: File) => - (await import("./tiff")).decodeTiff(gpu, file); +const heic = sized( + pixelFormat(async () => (await import("./heic")).decodeHeic), +); +const tiff = sized(async (gpu: Gpu, file: File) => + (await import("./tiff")).decodeTiff(gpu, file), +); const raw = async (gpu: Gpu, file: File) => (await import("./raw")).decodeRaw(gpu, file); diff --git a/src/core/image/decode/raw.ts b/src/core/image/decode/raw.ts index 6348be43..8eed0bbd 100644 --- a/src/core/image/decode/raw.ts +++ b/src/core/image/decode/raw.ts @@ -2,6 +2,7 @@ import { createRawDecoder } from "raw-webgpu"; import { type Gpu, type Target, target } from "vgpu"; import { createImageSource, type RawDevelopment } from "@/core/image"; import { weakMemo } from "@/lib/weak-memo"; +import { checkSize } from "./size"; /** One decoder per GPU, released with its device. */ const decoder = weakMemo((gpu: Gpu) => createRawDecoder(gpu.gpu)); @@ -11,6 +12,8 @@ export async function decodeRaw(gpu: Gpu, file: File) { const source = await decoder(gpu).load(file); let original: Target | undefined; try { + // Only LibRaw reads a RAW's size, so the check waits for the decoded source. + checkSize(...source.size); original = target(gpu, { size: source.size, format: "rgba16float" }); const initial = source.createDevelopPass(); try { diff --git a/src/core/image/decode/size.ts b/src/core/image/decode/size.ts new file mode 100644 index 00000000..a3157113 --- /dev/null +++ b/src/core/image/decode/size.ts @@ -0,0 +1,33 @@ +/** The longest side OpenLight opens: WebGPU guarantees textures this large on every device. */ +const maxImageSide = 8192; + +const count = new Intl.NumberFormat("en-US"); + +/** Rejects an image larger than OpenLight opens. */ +export function checkSize(width: number, height: number) { + if (Math.max(width, height) <= maxImageSide) { + return; + } + throw new Error( + `OpenLight opens images up to ${count.format(maxImageSide)} pixels per side; this one is ${count.format(width)} × ${count.format(height)}.`, + ); +} + +/** The size the header declares; a header it can't read is the decoder's to report. */ +async function declaredSize(file: Blob) { + const { imageSize } = await import("image-size"); + const bytes = new Uint8Array(await file.arrayBuffer()); + try { + return imageSize(bytes); + } catch { + return undefined; + } +} + +/** Checks the size its header declares, without decoding a pixel. */ +export async function checkHeader(file: Blob) { + const size = await declaredSize(file); + if (size) { + checkSize(size.width, size.height); + } +} diff --git a/src/core/image/decode/svg.ts b/src/core/image/decode/svg.ts index 93375dad..867b83f7 100644 --- a/src/core/image/decode/svg.ts +++ b/src/core/image/decode/svg.ts @@ -1,3 +1,5 @@ +import { checkSize } from "./size"; + const fallbackSize = 1024; /** Rasterizes through an , since SVGs without intrinsic size can't go straight to a bitmap. */ @@ -6,10 +8,10 @@ export async function decodeSvg(file: Blob) { image.src = URL.createObjectURL(file); await image.decode(); URL.revokeObjectURL(image.src); - const canvas = new OffscreenCanvas( - image.naturalWidth || fallbackSize, - image.naturalHeight || fallbackSize, - ); + const width = image.naturalWidth || fallbackSize; + const height = image.naturalHeight || fallbackSize; + checkSize(width, height); + const canvas = new OffscreenCanvas(width, height); canvas.getContext("2d")?.drawImage(image, 0, 0, canvas.width, canvas.height); return canvas.transferToImageBitmap(); } diff --git a/src/main.tsx b/src/main.tsx index f48dcbd8..14c3d1d2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -45,13 +45,23 @@ class GpuBoundary extends Component< } } +/** The adapter's own texture limit, rather than WebGPU's default, so an oversized RAW decodes far enough to report its size. */ +async function gpuOptions() { + const adapter = await navigator.gpu?.requestAdapter(); + if (!adapter) { + return undefined; + } + const { maxTextureDimension2D } = adapter.limits; + return { requiredLimits: { maxTextureDimension2D } }; +} + const root = document.getElementById("root"); if (root) { createRoot(root).render( <> - + diff --git a/tests/loaders.e2e.ts b/tests/loaders.e2e.ts index eba492af..86c2bd62 100644 --- a/tests/loaders.e2e.ts +++ b/tests/loaders.e2e.ts @@ -1,6 +1,52 @@ import { expect, openPhoto, test } from "./fixtures"; import { readImage } from "./images"; +test("an image beyond the size limit fails to open, saying how large it is", async ({ + page, +}) => { + await openPhoto(page); + const png = await page.evaluate(async () => { + const canvas = new OffscreenCanvas(9000, 1); + canvas.getContext("2d"); + const blob = await canvas.convertToBlob(); + return [...new Uint8Array(await blob.arrayBuffer())]; + }); + const svg = + ''; + const cases = [ + { + file: { + name: "wide.png", + mimeType: "image/png", + buffer: Buffer.from(png), + }, + size: "9,000 × 1", + }, + { + file: { + name: "tall.svg", + mimeType: "image/svg+xml", + buffer: Buffer.from(svg), + }, + size: "10 × 8,193", + }, + ]; + for (const { file, size } of cases) { + const chooser = page.waitForEvent("filechooser"); + await page.getByRole("button", { name: "Open an image or scene" }).click(); + await (await chooser).setFiles(file); + await expect( + page.getByText( + `Couldn't open ${file.name}: Error: OpenLight opens images up to 8,192 pixels per side; this one is ${size}.`, + ), + ).toBeVisible(); + await page.getByRole("button", { name: "Dismiss", exact: true }).click(); + } + expect(await page.evaluate(() => window.openlight.getState().file)).toBe( + "photo.svg", + ); +}); + test("a photo takes XMP settings, survives a failed open, and exports while another replaces it", async ({ page, }) => { From 27f34916cf03ddb7d792805ec9afc3f059e2308f Mon Sep 17 00:00:00 2001 From: Rodrigo Date: Sun, 11 Oct 2026 23:31:38 -0700 Subject: [PATCH 2/2] Limit image size by the GPU's texture limit instead of 8,192 pixels The device already takes the adapter's maxTextureDimension2D, usually 16,384 pixels, so the check now reads it from the device rather than a constant. Headers are still checked before decoding; SVG and unreadable headers are checked once decoded. RAW is left to raw-webgpu, which rejects a sensor beyond the device limit before uploading it. Co-Authored-By: Claude Opus 5.5 --- API.md | 2 +- src/core/image/decode/index.ts | 6 ++++-- src/core/image/decode/raw.ts | 3 --- src/core/image/decode/size.ts | 16 ++++++++-------- src/core/image/decode/svg.ts | 10 ++++------ src/main.tsx | 2 +- tests/loaders.e2e.ts | 19 +++++++++++-------- 7 files changed, 29 insertions(+), 29 deletions(-) diff --git a/API.md b/API.md index ac3c963a..0d376fc8 100644 --- a/API.md +++ b/API.md @@ -34,7 +34,7 @@ These methods return promises; await them before editing. All but `loadUrl`, whi | `loadUrl(url)` | Fetches an image and loads it. A URL on another origin must allow CORS; the response's content type identifies an image whose URL has no extension. | | `importXmp(file)` | Applies supported Camera Raw adjustments as one undoable edit. | -File loads are queued; `loadUrl` starts at once, and when two opens overlap the later one wins. XMP and `.cube` imports are skipped if no document is ready; an invalid XMP import can reject without blocking later loads. Image, scene, and `.cube` failures do not reject the loading promise. Without a document open, a failure leaves the workspace in its error state; with one, that document stays open and keeps its history. Either way `getState().failure` names the file and the error, and settings loaded in the same batch are skipped. Check `getState().documentId` or `failure` to confirm success. An image over 8,192 pixels on either side fails to open, with an error naming its size; most formats are checked from their header before decoding, RAW once decoded. Loading completion does not guarantee the preview has rendered. +File loads are queued; `loadUrl` starts at once, and when two opens overlap the later one wins. XMP and `.cube` imports are skipped if no document is ready; an invalid XMP import can reject without blocking later loads. Image, scene, and `.cube` failures do not reject the loading promise. Without a document open, a failure leaves the workspace in its error state; with one, that document stays open and keeps its history. Either way `getState().failure` names the file and the error, and settings loaded in the same batch are skipped. Check `getState().documentId` or `failure` to confirm success. An image larger on either side than the GPU's texture limit (`maxTextureDimension2D`: at least 8,192 pixels, usually 16,384) fails to open with an error; most formats are checked from their header before decoding. Loading completion does not guarantee the preview has rendered. ## Editing diff --git a/src/core/image/decode/index.ts b/src/core/image/decode/index.ts index d804af43..826f9473 100644 --- a/src/core/image/decode/index.ts +++ b/src/core/image/decode/index.ts @@ -1,6 +1,6 @@ import type { Gpu, Target } from "vgpu"; import { createImageSource, type ImageSource } from "@/core/image"; -import { checkHeader } from "./size"; +import { checkHeader, checkSize } from "./size"; import type { Decoder } from "./types"; import { uploadDecoded } from "./upload"; @@ -17,6 +17,8 @@ function pixelFormat(load: () => Promise) { return async (gpu: Gpu, file: File) => { const decoder = await load(); const decoded = await decoder(file); + // Covers SVG, whose raster size is known only here, and headers the check couldn't read. + checkSize(gpu, decoded.width, decoded.height); const { image, primaries } = uploadDecoded(gpu, decoded); return createImageSource(image, { primaries }); }; @@ -25,7 +27,7 @@ function pixelFormat(load: () => Promise) { /** A format whose header declares its size, checked before decoding. */ function sized(decode: Format["decode"]) { return async (gpu: Gpu, file: File) => { - await checkHeader(file); + await checkHeader(gpu, file); return decode(gpu, file); }; } diff --git a/src/core/image/decode/raw.ts b/src/core/image/decode/raw.ts index 8eed0bbd..6348be43 100644 --- a/src/core/image/decode/raw.ts +++ b/src/core/image/decode/raw.ts @@ -2,7 +2,6 @@ import { createRawDecoder } from "raw-webgpu"; import { type Gpu, type Target, target } from "vgpu"; import { createImageSource, type RawDevelopment } from "@/core/image"; import { weakMemo } from "@/lib/weak-memo"; -import { checkSize } from "./size"; /** One decoder per GPU, released with its device. */ const decoder = weakMemo((gpu: Gpu) => createRawDecoder(gpu.gpu)); @@ -12,8 +11,6 @@ export async function decodeRaw(gpu: Gpu, file: File) { const source = await decoder(gpu).load(file); let original: Target | undefined; try { - // Only LibRaw reads a RAW's size, so the check waits for the decoded source. - checkSize(...source.size); original = target(gpu, { size: source.size, format: "rgba16float" }); const initial = source.createDevelopPass(); try { diff --git a/src/core/image/decode/size.ts b/src/core/image/decode/size.ts index a3157113..38fc78d8 100644 --- a/src/core/image/decode/size.ts +++ b/src/core/image/decode/size.ts @@ -1,15 +1,15 @@ -/** The longest side OpenLight opens: WebGPU guarantees textures this large on every device. */ -const maxImageSide = 8192; +import type { Gpu } from "vgpu"; const count = new Intl.NumberFormat("en-US"); -/** Rejects an image larger than OpenLight opens. */ -export function checkSize(width: number, height: number) { - if (Math.max(width, height) <= maxImageSide) { +/** Rejects an image larger than the device's textures hold. */ +export function checkSize(gpu: Gpu, width: number, height: number) { + const limit = gpu.gpu.limits.maxTextureDimension2D; + if (Math.max(width, height) <= limit) { return; } throw new Error( - `OpenLight opens images up to ${count.format(maxImageSide)} pixels per side; this one is ${count.format(width)} × ${count.format(height)}.`, + `This device opens images up to ${count.format(limit)} pixels per side; this one is ${count.format(width)} × ${count.format(height)}.`, ); } @@ -25,9 +25,9 @@ async function declaredSize(file: Blob) { } /** Checks the size its header declares, without decoding a pixel. */ -export async function checkHeader(file: Blob) { +export async function checkHeader(gpu: Gpu, file: Blob) { const size = await declaredSize(file); if (size) { - checkSize(size.width, size.height); + checkSize(gpu, size.width, size.height); } } diff --git a/src/core/image/decode/svg.ts b/src/core/image/decode/svg.ts index 867b83f7..93375dad 100644 --- a/src/core/image/decode/svg.ts +++ b/src/core/image/decode/svg.ts @@ -1,5 +1,3 @@ -import { checkSize } from "./size"; - const fallbackSize = 1024; /** Rasterizes through an , since SVGs without intrinsic size can't go straight to a bitmap. */ @@ -8,10 +6,10 @@ export async function decodeSvg(file: Blob) { image.src = URL.createObjectURL(file); await image.decode(); URL.revokeObjectURL(image.src); - const width = image.naturalWidth || fallbackSize; - const height = image.naturalHeight || fallbackSize; - checkSize(width, height); - const canvas = new OffscreenCanvas(width, height); + const canvas = new OffscreenCanvas( + image.naturalWidth || fallbackSize, + image.naturalHeight || fallbackSize, + ); canvas.getContext("2d")?.drawImage(image, 0, 0, canvas.width, canvas.height); return canvas.transferToImageBitmap(); } diff --git a/src/main.tsx b/src/main.tsx index 14c3d1d2..58450cee 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -45,7 +45,7 @@ class GpuBoundary extends Component< } } -/** The adapter's own texture limit, rather than WebGPU's default, so an oversized RAW decodes far enough to report its size. */ +/** The adapter's own texture limit rather than WebGPU's default 8,192, so images open as large as the GPU holds. */ async function gpuOptions() { const adapter = await navigator.gpu?.requestAdapter(); if (!adapter) { diff --git a/tests/loaders.e2e.ts b/tests/loaders.e2e.ts index 86c2bd62..3e1dfa61 100644 --- a/tests/loaders.e2e.ts +++ b/tests/loaders.e2e.ts @@ -5,14 +5,17 @@ test("an image beyond the size limit fails to open, saying how large it is", asy page, }) => { await openPhoto(page); - const png = await page.evaluate(async () => { - const canvas = new OffscreenCanvas(9000, 1); + const { limit, png } = await page.evaluate(async () => { + const adapter = await navigator.gpu.requestAdapter(); + const limit = adapter?.limits.maxTextureDimension2D ?? 0; + const canvas = new OffscreenCanvas(limit + 1, 1); canvas.getContext("2d"); const blob = await canvas.convertToBlob(); - return [...new Uint8Array(await blob.arrayBuffer())]; + return { limit, png: [...new Uint8Array(await blob.arrayBuffer())] }; }); - const svg = - ''; + const count = new Intl.NumberFormat("en-US"); + const over = count.format(limit + 1); + const svg = ``; const cases = [ { file: { @@ -20,7 +23,7 @@ test("an image beyond the size limit fails to open, saying how large it is", asy mimeType: "image/png", buffer: Buffer.from(png), }, - size: "9,000 × 1", + size: `${over} × 1`, }, { file: { @@ -28,7 +31,7 @@ test("an image beyond the size limit fails to open, saying how large it is", asy mimeType: "image/svg+xml", buffer: Buffer.from(svg), }, - size: "10 × 8,193", + size: `10 × ${over}`, }, ]; for (const { file, size } of cases) { @@ -37,7 +40,7 @@ test("an image beyond the size limit fails to open, saying how large it is", asy await (await chooser).setFiles(file); await expect( page.getByText( - `Couldn't open ${file.name}: Error: OpenLight opens images up to 8,192 pixels per side; this one is ${size}.`, + `Couldn't open ${file.name}: Error: This device opens images up to ${count.format(limit)} pixels per side; this one is ${size}.`, ), ).toBeVisible(); await page.getByRole("button", { name: "Dismiss", exact: true }).click();