Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 18 additions & 4 deletions src/core/image/decode/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -16,16 +17,29 @@ function pixelFormat(load: () => Promise<Decoder>) {
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Rejected bitmaps remain open after decoding

When checkSize rejects a decoded SVG bitmap, pixelFormat skips uploadDecoded, which closes decoded bitmaps. Repeated failed opens retain large bitmap resources until garbage collection.

Learn more

pixelFormat handles both ImageBitmap outputs from SVG/native decoders and video frames from HEIC. It checks dimensions after decoding but before uploadDecoded, which normally closes those resources. When the dimension check throws, no code closes the bitmap or frames. Repeated oversized SVG opens thus leave their decoded bitmap resources pending garbage collection.

Example: Opening an SVG that rasterizes to 16,385 × 10 pixels on a 16,384-pixel GPU transfers it to an ImageBitmap, then rejects it without calling ImageBitmap.close().

Recommended fix: Ensure the dimension check runs before decoding where possible; for paths checked after decoding, close an ImageBitmap or all VideoFrame tiles on any validation failure.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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);

Expand Down
33 changes: 33 additions & 0 deletions src/core/image/decode/size.ts
Original file line number Diff line number Diff line change
@@ -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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Large images exhaust memory before rejection

For a large file, declaredSize() copies every byte before checking its dimensions. The copy can exhaust browser memory before an oversized image receives its size error.

Learn more

The size check runs before decoding, but file.arrayBuffer() still materializes the entire encoded image in memory. Browsers already hold the File, and large uncompressed images can consume hundreds of megabytes before any pixel decoding. This preflight can fail from memory pressure before the code reads the small header that declares an oversized image.

Example: An illustrative uncompressed 10,000 × 10,000 RGBA TIFF is roughly 400 MB. Checking its dimensions first copies roughly 400 MB into a buffer, even though its TIFF directory can identify its dimensions without loading the full image; the tab can fail before reporting 10,000 × 10,000.

Recommended fix: Parse dimensions from bounded file.slice(...).arrayBuffer() reads, expanding the range only for formats whose header needs more bytes. Avoid allocating a complete second copy solely for dimension validation.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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);
}
}
12 changes: 11 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<>
<GpuBoundary>
<GpuProvider>
<GpuProvider options={await gpuOptions()}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 GPU adapter failures blank the page

When requestAdapter() rejects, gpuOptions() prevents React from mounting. GpuBoundary never renders, so users see a blank page instead of the WebGPU error.

Learn more

The root render is suspended on a promise before React has mounted. The GPU fallback exists inside GpuBoundary, so it cannot catch an adapter request that rejects outside the component tree. A failed adapter initialization can therefore leave both the editor and its fallback absent.

Example: On a browser where navigator.gpu.requestAdapter() rejects because GPU initialization fails, await gpuOptions() rejects and createRoot(root).render(...) is never called. The previous tree mounted and could show the GPU failure screen.

Recommended fix: Move adapter acquisition into the provider or a component covered by GpuBoundary, and handle rejected adapter requests there. Keep the error fallback mounted even when preparing device limits fails.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

<TooltipProvider delay={500}>
<App />
</TooltipProvider>
Expand Down
49 changes: 49 additions & 0 deletions tests/loaders.e2e.ts
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="${limit + 1}"/>`;
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,
}) => {
Expand Down
Loading