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:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
32
packages/core/src/media/thumbnail.ts
Normal file
32
packages/core/src/media/thumbnail.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
@@ -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(); })
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
57
packages/core/tests/unit/media/thumbnail.test.ts
Normal file
57
packages/core/tests/unit/media/thumbnail.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user