diff --git a/.changeset/fix-media-upload-oom.md b/.changeset/fix-media-upload-oom.md new file mode 100644 index 0000000..db9d63a --- /dev/null +++ b/.changeset/fix-media-upload-oom.md @@ -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 diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index d74351b..31f146a 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -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> = 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({ diff --git a/packages/core/src/components/InlinePortableTextEditor.tsx b/packages/core/src/components/InlinePortableTextEditor.tsx index 412a26c..3db577b 100644 --- a/packages/core/src/components/InlinePortableTextEditor.tsx +++ b/packages/core/src/components/InlinePortableTextEditor.tsx @@ -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; diff --git a/packages/core/src/media/placeholder.ts b/packages/core/src/media/placeholder.ts index 189c031..51393c1 100644 --- a/packages/core/src/media/placeholder.ts +++ b/packages/core/src/media/placeholder.ts @@ -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 = { /** 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 { 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; diff --git a/packages/core/src/media/thumbnail.ts b/packages/core/src/media/thumbnail.ts new file mode 100644 index 0000000..67769fc --- /dev/null +++ b/packages/core/src/media/thumbnail.ts @@ -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)), + }; +} diff --git a/packages/core/src/visual-editing/toolbar.ts b/packages/core/src/visual-editing/toolbar.ts index 6bef282..bde3025 100644 --- a/packages/core/src/visual-editing/toolbar.ts +++ b/packages/core/src/visual-editing/toolbar.ts @@ -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(); }) diff --git a/packages/core/tests/unit/media/placeholder.test.ts b/packages/core/tests/unit/media/placeholder.test.ts index 62a7d00..bf89ad1 100644 --- a/packages/core/tests/unit/media/placeholder.test.ts +++ b/packages/core/tests/unit/media/placeholder.test.ts @@ -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(); + }); }); diff --git a/packages/core/tests/unit/media/thumbnail.test.ts b/packages/core/tests/unit/media/thumbnail.test.ts new file mode 100644 index 0000000..53f836b --- /dev/null +++ b/packages/core/tests/unit/media/thumbnail.test.ts @@ -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); + }); +});