fix: prevent media upload OOM on Workers for large images (#262)

* fix: prevent media upload OOM on Workers via client thumbnails + server safety net

Large image uploads (5MB+) crash Cloudflare Workers (128MB limit) because
generatePlaceholder() decodes entire images to raw RGBA pixels. A 4000x3000
JPEG becomes ~48MB RGBA, exceeding the isolate memory budget.

Two-layer fix:
- Client-side: browser generates a 64px canvas thumbnail for oversized images
  and sends it alongside the upload. Server generates blurhash from the
  thumbnail (~16KB RGBA) instead of decoding the full image.
- Server-side: reads dimensions from image headers via image-size and skips
  placeholder generation when estimated decoded size exceeds 32MB. This covers
  API/CLI uploads that don't provide thumbnails.

* chore: add changeset for media upload OOM fix

* fix: clamp upload thumbnail to 64x64 box for extreme aspect ratios

Naive sizing (thumbW=64, thumbH=(h/w)*64) could produce an enormous canvas
for very tall or very wide images — e.g. a 100x840000 image would allocate
a 64x537600 canvas client-side, reintroducing the memory blowup this feature
exists to prevent.

Extract computeThumbnailSize() that fits the image within a 64x64 box by
scaling against max(width, height), wrap canvas allocation and drawImage
in try/catch with a no-thumbnail fallback, and add unit tests covering
extreme aspect ratios.

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
This commit is contained in:
Benjamin Price
2026-04-06 16:14:39 +09:00
committed by GitHub
parent 73b71b4e59
commit 8c693b582d
8 changed files with 293 additions and 15 deletions

View File

@@ -0,0 +1,5 @@
---
"emdash": patch
---
Fix media upload OOM on Cloudflare Workers for large images by generating blurhash from client-provided thumbnails instead of decoding full-resolution images server-side

View File

@@ -151,10 +151,22 @@ export const POST: APIRoute = async ({ request, locals }) => {
const width = widthStr ? parseInt(widthStr, 10) : undefined;
const height = heightStr ? parseInt(heightStr, 10) : undefined;
// Generate placeholder data for images
const placeholder = file.type.startsWith("image/")
? await generatePlaceholder(buffer, file.type)
: null;
// Generate placeholder data for images.
// If the client sent a thumbnail (small pre-resized image), use that
// instead of the full buffer to avoid OOM on memory-constrained runtimes.
const thumbnailEntry = formData.get("thumbnail");
const thumbnail = thumbnailEntry instanceof File ? thumbnailEntry : null;
let placeholder: Awaited<ReturnType<typeof generatePlaceholder>> = null;
if (file.type.startsWith("image/")) {
if (thumbnail) {
const thumbBuffer = new Uint8Array(await thumbnail.arrayBuffer());
placeholder = await generatePlaceholder(thumbBuffer, thumbnail.type);
} else {
const clientDims = width && height ? { width, height } : undefined;
placeholder = await generatePlaceholder(buffer, file.type, clientDims);
}
}
// Create media record
const result = await emdash.handleMediaCreate({

View File

@@ -25,6 +25,8 @@ import Suggestion from "@tiptap/suggestion";
import * as React from "react";
import { createPortal } from "react-dom";
import { computeThumbnailSize } from "../media/thumbnail.js";
// ── Portable Text types ────────────────────────────────────────────
interface PTSpan {
@@ -1112,13 +1114,40 @@ function InlineMediaPicker({
const handleUpload = async (file: File) => {
setUploading(true);
try {
// Detect dimensions
const dims = await new Promise<{ width?: number; height?: number }>((resolve) => {
// Detect dimensions and generate a thumbnail for large images to
// avoid OOM in server-side blurhash generation on Workers.
const dims = await new Promise<{
width?: number;
height?: number;
thumbnail?: Blob;
}>((resolve) => {
if (!file.type.startsWith("image/")) return resolve({});
const img = new window.Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
const w = img.naturalWidth;
const h = img.naturalHeight;
// 32 MB RGBA threshold — matches server MAX_DECODED_BYTES
if (w * h * 4 > 32 * 1024 * 1024) {
const { width: thumbW, height: thumbH } = computeThumbnailSize(w, h);
try {
const canvas = document.createElement("canvas");
canvas.width = thumbW;
canvas.height = thumbH;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(img, 0, 0, thumbW, thumbH);
canvas.toBlob((blob) => {
URL.revokeObjectURL(img.src);
resolve({ width: w, height: h, thumbnail: blob ?? undefined });
}, "image/png");
return;
}
} catch {
// Canvas allocation or draw failed — fall through to no-thumbnail path
}
}
URL.revokeObjectURL(img.src);
resolve({ width: w, height: h });
};
img.onerror = () => {
resolve({});
@@ -1134,6 +1163,7 @@ function InlineMediaPicker({
formData.append("file", file);
if (dims.width) formData.append("width", String(dims.width));
if (dims.height) formData.append("height", String(dims.height));
if (dims.thumbnail) formData.append("thumbnail", dims.thumbnail, "thumb.png");
const res = await ecFetch(`${API_BASE}/media`, { method: "POST", body: formData });
const data = await res.json();
const unwrapped = data.data ?? data;

View File

@@ -7,6 +7,7 @@
*/
import { encode } from "blurhash";
import { imageSize } from "image-size";
export interface PlaceholderData {
blurhash: string;
@@ -22,6 +23,9 @@ const SUPPORTED_TYPES: Record<string, "jpeg" | "png"> = {
/** Max width for blurhash input. Encode is O(w*h*components), so downsample first. */
const MAX_ENCODE_WIDTH = 32;
/** Max decoded RGBA size (32 MB). Images exceeding this skip placeholder generation. */
const MAX_DECODED_BYTES = 32 * 1024 * 1024;
interface DecodedImage {
width: number;
height: number;
@@ -79,18 +83,45 @@ function extractDominantColor(data: Uint8Array, width: number, height: number):
return `rgb(${avgR},${avgG},${avgB})`;
}
/**
* Read image dimensions from headers without decoding pixel data.
*/
function getImageDimensions(buffer: Uint8Array): { width: number; height: number } | null {
try {
const result = imageSize(buffer);
if (result.width != null && result.height != null) {
return { width: result.width, height: result.height };
}
return null;
} catch {
return null;
}
}
/**
* Generate blurhash and dominant color from an image buffer.
* Returns null for non-image MIME types or on failure.
*
* @param dimensions - Optional pre-known dimensions. Used as a fallback when
* image-size cannot parse the buffer (e.g. truncated headers). When the
* decoded size (width * height * 4) exceeds MAX_DECODED_BYTES, placeholder
* generation is skipped to avoid OOM on memory-constrained runtimes.
*/
export async function generatePlaceholder(
buffer: Uint8Array,
mimeType: string,
dimensions?: { width: number; height: number },
): Promise<PlaceholderData | null> {
const format = SUPPORTED_TYPES[mimeType];
if (!format) return null;
try {
// Safety net: skip decode if the image would exceed the memory budget
const dims = getImageDimensions(buffer) ?? dimensions;
if (dims && dims.width * dims.height * 4 > MAX_DECODED_BYTES) {
return null;
}
const imageData = format === "jpeg" ? await decodeJpeg(buffer) : await decodePng(buffer);
const { width, height, data } = imageData;

View File

@@ -0,0 +1,32 @@
/**
* Thumbnail sizing for client-side placeholder generation.
*
* When the browser generates a thumbnail to send to the server for blurhash
* generation, the thumbnail dimensions must fit within a bounded box. Naively
* fixing one dimension and deriving the other from the aspect ratio can
* explode for extreme aspect ratios (e.g. a 100×840000 image would produce a
* 64×537600 canvas), defeating the purpose of the thumbnail.
*/
/** Max dimension (px) for client-generated upload thumbnails. */
export const THUMBNAIL_MAX_DIMENSION = 64;
/**
* Compute thumbnail dimensions that fit within a THUMBNAIL_MAX_DIMENSION box,
* preserving aspect ratio. Both output dimensions are clamped to at least 1.
* Never upscales (scale is capped at 1).
*/
export function computeThumbnailSize(
width: number,
height: number,
): { width: number; height: number } {
if (width <= 0 || height <= 0) {
return { width: 1, height: 1 };
}
const maxDim = Math.max(width, height);
const scale = Math.min(1, THUMBNAIL_MAX_DIMENSION / maxDim);
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}

View File

@@ -1149,15 +1149,64 @@ export function renderToolbar(config: ToolbarConfig): string {
});
dimPromise.then(function(dims) {
var formData = new FormData();
formData.append("file", file);
if (dims.width) formData.append("width", String(dims.width));
if (dims.height) formData.append("height", String(dims.height));
// Generate a thumbnail for large images to avoid OOM in server-side
// blurhash generation on memory-constrained runtimes (Workers).
// Thumbnail fits within a 64x64 box (scale by max dimension) so that
// extreme aspect ratios don't explode into a huge canvas client-side.
var thumbPromise;
if (dims.width && dims.height && dims.width * dims.height * 4 > 32 * 1024 * 1024) {
thumbPromise = new Promise(function(resolve) {
try {
var maxDim = Math.max(dims.width, dims.height);
var scale = Math.min(1, 64 / maxDim);
var thumbW = Math.max(1, Math.round(dims.width * scale));
var thumbH = Math.max(1, Math.round(dims.height * scale));
var canvas = document.createElement("canvas");
canvas.width = thumbW;
canvas.height = thumbH;
var ctx = canvas.getContext("2d");
if (ctx) {
var img = new Image();
img.onload = function() {
try {
ctx.drawImage(img, 0, 0, thumbW, thumbH);
canvas.toBlob(function(blob) {
URL.revokeObjectURL(img.src);
resolve(blob);
}, "image/png");
} catch (e) {
URL.revokeObjectURL(img.src);
resolve(null);
}
};
img.onerror = function() {
URL.revokeObjectURL(img.src);
resolve(null);
};
img.src = URL.createObjectURL(file);
} else {
resolve(null);
}
} catch (e) {
resolve(null);
}
});
} else {
thumbPromise = Promise.resolve(null);
}
return ecFetch("/_emdash/api/media", {
method: "POST",
credentials: "same-origin",
body: formData
return thumbPromise.then(function(thumbnail) {
var formData = new FormData();
formData.append("file", file);
if (dims.width) formData.append("width", String(dims.width));
if (dims.height) formData.append("height", String(dims.height));
if (thumbnail) formData.append("thumbnail", thumbnail, "thumb.png");
return ecFetch("/_emdash/api/media", {
method: "POST",
credentials: "same-origin",
body: formData
});
});
})
.then(function(r) { return r.json(); })

View File

@@ -78,4 +78,66 @@ describe("generatePlaceholder", () => {
// Blurhash string length should be reasonable (not huge from 100x100)
expect(result!.blurhash.length).toBeLessThan(50);
});
it("returns null when image dimensions from headers exceed memory budget", async () => {
// Minimal valid JPEG with SOF0 declaring 5000x4000 dimensions.
// SOF0 marker (FFC0) stores height (2 bytes) then width (2 bytes).
// 5000×4000×4 = 80 MB > 32 MB threshold.
const sof0 = new Uint8Array([
0xff,
0xd8, // SOI
0xff,
0xe0,
0x00,
0x10, // APP0 marker + length
0x4a,
0x46,
0x49,
0x46,
0x00, // "JFIF\0"
0x01,
0x01,
0x00,
0x00,
0x01,
0x00,
0x01,
0x00,
0x00, // JFIF fields
0xff,
0xc0,
0x00,
0x0b, // SOF0 marker + length
0x08, // precision
0x0f,
0xa0, // height = 4000
0x13,
0x88, // width = 5000
0x01, // number of components
0x01,
0x11,
0x00, // component
]);
const result = await generatePlaceholder(sof0, "image/jpeg");
expect(result).toBeNull();
});
it("returns null when fallback dimensions exceed memory budget", async () => {
// Unrecognizable buffer — image-size can't parse it, so fallback dims are used
const buffer = new Uint8Array([0x00, 0x01, 0x02, 0x03]);
const result = await generatePlaceholder(buffer, "image/jpeg", {
width: 5000,
height: 4000,
});
expect(result).toBeNull();
});
it("still generates placeholder for small images with dimensions param", async () => {
const result = await generatePlaceholder(new Uint8Array(JPEG_4x4), "image/jpeg", {
width: 4,
height: 4,
});
expect(result).not.toBeNull();
expect(result!.blurhash).toBeTruthy();
});
});

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { THUMBNAIL_MAX_DIMENSION, computeThumbnailSize } from "../../../src/media/thumbnail.js";
describe("computeThumbnailSize", () => {
it("scales a square image to the max dimension", () => {
expect(computeThumbnailSize(5000, 5000)).toEqual({
width: THUMBNAIL_MAX_DIMENSION,
height: THUMBNAIL_MAX_DIMENSION,
});
});
it("scales a wide image to fit within the bounding box", () => {
const result = computeThumbnailSize(4000, 2000);
expect(result.width).toBe(THUMBNAIL_MAX_DIMENSION);
expect(result.height).toBe(THUMBNAIL_MAX_DIMENSION / 2);
});
it("scales a tall image to fit within the bounding box", () => {
const result = computeThumbnailSize(2000, 4000);
expect(result.width).toBe(THUMBNAIL_MAX_DIMENSION / 2);
expect(result.height).toBe(THUMBNAIL_MAX_DIMENSION);
});
it("clamps extreme tall aspect ratios to the bounding box", () => {
// Without clamping, naive code would produce a 64×537600 canvas.
const result = computeThumbnailSize(100, 840_000);
expect(result.width).toBeLessThanOrEqual(THUMBNAIL_MAX_DIMENSION);
expect(result.height).toBeLessThanOrEqual(THUMBNAIL_MAX_DIMENSION);
expect(result.width).toBeGreaterThanOrEqual(1);
expect(result.height).toBe(THUMBNAIL_MAX_DIMENSION);
});
it("clamps extreme wide aspect ratios to the bounding box", () => {
const result = computeThumbnailSize(840_000, 100);
expect(result.width).toBe(THUMBNAIL_MAX_DIMENSION);
expect(result.height).toBeGreaterThanOrEqual(1);
expect(result.height).toBeLessThanOrEqual(THUMBNAIL_MAX_DIMENSION);
});
it("never upscales smaller images", () => {
expect(computeThumbnailSize(10, 20)).toEqual({ width: 10, height: 20 });
expect(computeThumbnailSize(1, 1)).toEqual({ width: 1, height: 1 });
});
it("returns a 1x1 fallback for zero or negative dimensions", () => {
expect(computeThumbnailSize(0, 100)).toEqual({ width: 1, height: 1 });
expect(computeThumbnailSize(100, 0)).toEqual({ width: 1, height: 1 });
expect(computeThumbnailSize(-5, 10)).toEqual({ width: 1, height: 1 });
});
it("rounds fractional dimensions", () => {
const result = computeThumbnailSize(300, 199);
expect(Number.isInteger(result.width)).toBe(true);
expect(Number.isInteger(result.height)).toBe(true);
});
});