diff --git a/API.md b/API.md index 7542027..0d376fc 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 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/bun.lock b/bun.lock index 29da01c..d73a391 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 2bb36b8..f5d3374 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 072abfd..826f947 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, checkSize } from "./size"; import type { Decoder } from "./types"; import { uploadDecoded } from "./upload"; @@ -16,16 +17,29 @@ 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 }); }; } -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(gpu, 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/size.ts b/src/core/image/decode/size.ts new file mode 100644 index 0000000..38fc78d --- /dev/null +++ b/src/core/image/decode/size.ts @@ -0,0 +1,33 @@ +import type { Gpu } from "vgpu"; + +const count = new Intl.NumberFormat("en-US"); + +/** 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( + `This device opens images up to ${count.format(limit)} 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(gpu: Gpu, file: Blob) { + const size = await declaredSize(file); + if (size) { + checkSize(gpu, size.width, size.height); + } +} diff --git a/src/main.tsx b/src/main.tsx index f48dcbd..58450ce 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 8,192, so images open as large as the GPU holds. */ +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 eba492a..3e1dfa6 100644 --- a/tests/loaders.e2e.ts +++ b/tests/loaders.e2e.ts @@ -1,6 +1,55 @@ 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 { 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 { limit, png: [...new Uint8Array(await blob.arrayBuffer())] }; + }); + const count = new Intl.NumberFormat("en-US"); + const over = count.format(limit + 1); + const svg = ``; + const cases = [ + { + file: { + name: "wide.png", + mimeType: "image/png", + buffer: Buffer.from(png), + }, + size: `${over} × 1`, + }, + { + file: { + name: "tall.svg", + mimeType: "image/svg+xml", + buffer: Buffer.from(svg), + }, + size: `10 × ${over}`, + }, + ]; + 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: 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(); + } + 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, }) => {