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:
5
.changeset/fix-media-upload-oom.md
Normal file
5
.changeset/fix-media-upload-oom.md
Normal 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
|
||||||
@@ -151,10 +151,22 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const width = widthStr ? parseInt(widthStr, 10) : undefined;
|
const width = widthStr ? parseInt(widthStr, 10) : undefined;
|
||||||
const height = heightStr ? parseInt(heightStr, 10) : undefined;
|
const height = heightStr ? parseInt(heightStr, 10) : undefined;
|
||||||
|
|
||||||
// Generate placeholder data for images
|
// Generate placeholder data for images.
|
||||||
const placeholder = file.type.startsWith("image/")
|
// If the client sent a thumbnail (small pre-resized image), use that
|
||||||
? await generatePlaceholder(buffer, file.type)
|
// instead of the full buffer to avoid OOM on memory-constrained runtimes.
|
||||||
: null;
|
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
|
// Create media record
|
||||||
const result = await emdash.handleMediaCreate({
|
const result = await emdash.handleMediaCreate({
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import Suggestion from "@tiptap/suggestion";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import { computeThumbnailSize } from "../media/thumbnail.js";
|
||||||
|
|
||||||
// ── Portable Text types ────────────────────────────────────────────
|
// ── Portable Text types ────────────────────────────────────────────
|
||||||
|
|
||||||
interface PTSpan {
|
interface PTSpan {
|
||||||
@@ -1112,13 +1114,40 @@ function InlineMediaPicker({
|
|||||||
const handleUpload = async (file: File) => {
|
const handleUpload = async (file: File) => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
// Detect dimensions
|
// Detect dimensions and generate a thumbnail for large images to
|
||||||
const dims = await new Promise<{ width?: number; height?: number }>((resolve) => {
|
// 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({});
|
if (!file.type.startsWith("image/")) return resolve({});
|
||||||
const img = new window.Image();
|
const img = new window.Image();
|
||||||
img.onload = () => {
|
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);
|
URL.revokeObjectURL(img.src);
|
||||||
|
resolve({ width: w, height: h });
|
||||||
};
|
};
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
resolve({});
|
resolve({});
|
||||||
@@ -1134,6 +1163,7 @@ function InlineMediaPicker({
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
if (dims.width) formData.append("width", String(dims.width));
|
if (dims.width) formData.append("width", String(dims.width));
|
||||||
if (dims.height) formData.append("height", String(dims.height));
|
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 res = await ecFetch(`${API_BASE}/media`, { method: "POST", body: formData });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const unwrapped = data.data ?? data;
|
const unwrapped = data.data ?? data;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { encode } from "blurhash";
|
import { encode } from "blurhash";
|
||||||
|
import { imageSize } from "image-size";
|
||||||
|
|
||||||
export interface PlaceholderData {
|
export interface PlaceholderData {
|
||||||
blurhash: string;
|
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. */
|
/** Max width for blurhash input. Encode is O(w*h*components), so downsample first. */
|
||||||
const MAX_ENCODE_WIDTH = 32;
|
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 {
|
interface DecodedImage {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
@@ -79,18 +83,45 @@ function extractDominantColor(data: Uint8Array, width: number, height: number):
|
|||||||
return `rgb(${avgR},${avgG},${avgB})`;
|
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.
|
* Generate blurhash and dominant color from an image buffer.
|
||||||
* Returns null for non-image MIME types or on failure.
|
* 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(
|
export async function generatePlaceholder(
|
||||||
buffer: Uint8Array,
|
buffer: Uint8Array,
|
||||||
mimeType: string,
|
mimeType: string,
|
||||||
|
dimensions?: { width: number; height: number },
|
||||||
): Promise<PlaceholderData | null> {
|
): Promise<PlaceholderData | null> {
|
||||||
const format = SUPPORTED_TYPES[mimeType];
|
const format = SUPPORTED_TYPES[mimeType];
|
||||||
if (!format) return null;
|
if (!format) return null;
|
||||||
|
|
||||||
try {
|
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 imageData = format === "jpeg" ? await decodeJpeg(buffer) : await decodePng(buffer);
|
||||||
const { width, height, data } = imageData;
|
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) {
|
dimPromise.then(function(dims) {
|
||||||
var formData = new FormData();
|
// Generate a thumbnail for large images to avoid OOM in server-side
|
||||||
formData.append("file", file);
|
// blurhash generation on memory-constrained runtimes (Workers).
|
||||||
if (dims.width) formData.append("width", String(dims.width));
|
// Thumbnail fits within a 64x64 box (scale by max dimension) so that
|
||||||
if (dims.height) formData.append("height", String(dims.height));
|
// 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", {
|
return thumbPromise.then(function(thumbnail) {
|
||||||
method: "POST",
|
var formData = new FormData();
|
||||||
credentials: "same-origin",
|
formData.append("file", file);
|
||||||
body: formData
|
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(); })
|
.then(function(r) { return r.json(); })
|
||||||
|
|||||||
@@ -78,4 +78,66 @@ describe("generatePlaceholder", () => {
|
|||||||
// Blurhash string length should be reasonable (not huge from 100x100)
|
// Blurhash string length should be reasonable (not huge from 100x100)
|
||||||
expect(result!.blurhash.length).toBeLessThan(50);
|
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