Last active
December 15, 2025 10:42
-
-
Save haltakov/17727f59a98d050a21a0f9e5704861cf to your computer and use it in GitHub Desktop.
Thumbnail Creation With Cloudflare Workers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| interface R2EventMessage { | |
| account: string; | |
| action: "PutObject" | "CopyObject" | "CompleteMultipartUpload" | "DeleteObject"; | |
| bucket: string; | |
| object?: { key: string; size?: number; eTag?: string }; | |
| eventTime: string; | |
| copySource?: { bucket: string; object: string }; | |
| } | |
| type Env = { | |
| BUCKET: R2Bucket; | |
| IMAGES: Images; | |
| }; | |
| const OUTPUT_FORMAT = "image/avif"; | |
| const ALLOWED_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif", ".heic", ".heif"]; | |
| const THUMBNAIL_SIZE = 300; | |
| export default { | |
| // Queue consumer entrypoint | |
| async queue(batch: MessageBatch<string>, env: Env, ctx: ExecutionContext) { | |
| console.log(`[Queue] Processing batch of ${batch.messages.length} message(s)`); | |
| // Process all messages in parallel | |
| const results = await Promise.allSettled( | |
| batch.messages.map(async (msg) => { | |
| let evt: R2EventMessage | undefined; | |
| let key: string | undefined; | |
| try { | |
| // Parse message body - handle both string and object cases | |
| if (typeof msg.body === "string") { | |
| evt = JSON.parse(msg.body) as R2EventMessage; | |
| } else { | |
| evt = msg.body as R2EventMessage; | |
| } | |
| // Only act on object-create-ish events with a key | |
| if (!evt.object?.key) { | |
| console.log(`[Skip] No object key in event: ${JSON.stringify(evt)}`); | |
| msg.ack(); | |
| return; | |
| } | |
| key = evt.object.key; | |
| // TypeScript: key is definitely defined here after the check above | |
| const fileKey = key; | |
| console.log(`[Processing] Starting processing for file: ${fileKey}`); | |
| // Ignore thumbnails | |
| if (fileKey.includes("/thumbs/")) { | |
| console.log(`[Skip] File is a thumbnail: ${fileKey}`); | |
| msg.ack(); | |
| return; | |
| } | |
| // Ignore non-image files by extension | |
| if (!ALLOWED_EXTENSIONS.some((ext) => fileKey.toLowerCase().endsWith(ext))) { | |
| console.log(`[Skip] File extension not allowed: ${fileKey} (allowed: ${ALLOWED_EXTENSIONS.join(", ")})`); | |
| msg.ack(); | |
| return; | |
| } | |
| // Read image | |
| console.log(`[Processing] Reading object from R2: ${fileKey}`); | |
| const obj = await env.BUCKET.get(fileKey); | |
| if (!obj || !("body" in obj)) { | |
| console.log(`[Skip] Object not found or has no body: ${fileKey}`); | |
| msg.ack(); | |
| return; | |
| } | |
| // Ignore non-image files by content type | |
| const contentType = obj.httpMetadata?.contentType ?? "application/octet-stream"; | |
| if (!contentType.startsWith("image/")) { | |
| console.log(`[Skip] Content type is not an image: ${fileKey} (content-type: ${contentType})`); | |
| msg.ack(); | |
| return; | |
| } | |
| console.log(`[Processing] Image details - Size: ${obj.size} bytes, Content-Type: ${contentType}`); | |
| // Load once into memory to reuse for info + both transforms | |
| const bytes = await new Response(obj.body).arrayBuffer(); | |
| console.log(`[Processing] Loaded ${bytes.byteLength} bytes into memory`); | |
| // Get original width/height to decide whether to constrain width or height | |
| const info = await env.IMAGES.info(new Blob([bytes]).stream()); | |
| const { width = 0, height = 0 } = info ?? {}; | |
| const isLandscapeOrSquare = width >= height; | |
| console.log( | |
| `[Processing] Image dimensions: ${width}x${height} (${ | |
| isLandscapeOrSquare ? "landscape/square" : "portrait" | |
| })` | |
| ); | |
| // Generate the 2 thumbnails | |
| const { thumbRegular, thumbRetina } = getThumbnailFileKeys(fileKey); | |
| console.log(`[Thumbnails] Will create thumbnails at:`); | |
| console.log(` - Regular (${THUMBNAIL_SIZE}px): ${thumbRegular}`); | |
| console.log(` - Retina (${THUMBNAIL_SIZE * 2}px): ${thumbRetina}`); | |
| // Create both thumbnails in parallel | |
| await Promise.all([ | |
| createThumbnail(bytes, thumbRegular, isLandscapeOrSquare, THUMBNAIL_SIZE, env), | |
| createThumbnail(bytes, thumbRetina, isLandscapeOrSquare, THUMBNAIL_SIZE * 2, env), | |
| ]); | |
| console.log(`[Thumbnails] ✓ Created regular thumbnail: ${thumbRegular}`); | |
| console.log(`[Thumbnails] ✓ Created retina thumbnail: ${thumbRetina}`); | |
| console.log(`[Success] Completed processing for: ${fileKey}`); | |
| msg.ack(); | |
| } catch (err) { | |
| const errorMessage = err.message; | |
| const errorStack = err instanceof Error ? err.stack : undefined; | |
| const errorDetails = { | |
| error: errorMessage, | |
| stack: errorStack, | |
| messageBody: msg.body, | |
| }; | |
| console.error(`[Error] Failed to process message:`, errorDetails); | |
| // Let Queues retry the whole batch on throw; here we nack just this message | |
| msg.retry(); | |
| throw err; // Re-throw to mark as rejected in Promise.allSettled | |
| } | |
| }) | |
| ); | |
| // Log summary of results | |
| const successful = results.filter((r) => r.status === "fulfilled").length; | |
| const failed = results.filter((r) => r.status === "rejected").length; | |
| console.log(`[Queue] Finished processing batch - Success: ${successful}, Failed: ${failed}`); | |
| }, | |
| }; | |
| async function createThumbnail(bytes: ArrayBuffer, key: string, isLandscapeOrSquare: boolean, size: number, env: Env) { | |
| try { | |
| console.log(`[Thumbnail] Creating thumbnail: ${key} (size: ${size}px, format: ${OUTPUT_FORMAT})`); | |
| // Transform the image | |
| const input = env.IMAGES.input(new Blob([bytes]).stream()); | |
| const transformed = isLandscapeOrSquare ? input.transform({ width: size }) : input.transform({ height: size }); | |
| // Get the output as a Response object - need to call .response() to get the actual Response | |
| const response = (await transformed.output({ format: OUTPUT_FORMAT, quality: 50 })).response(); | |
| // Check if the response is valid | |
| if (!response.ok) { | |
| throw new Error(`Images API returned error: ${response.status} ${response.statusText}`); | |
| } | |
| // Get the transformed image as an array buffer | |
| const transformedBytes = await response.arrayBuffer(); | |
| // Save back to R2 under thumbs/… keeping original path structure | |
| await env.BUCKET.put(key, transformedBytes, { | |
| httpMetadata: { | |
| contentType: OUTPUT_FORMAT, | |
| cacheControl: "public, max-age=31536000, immutable", | |
| }, | |
| }); | |
| console.log(`[Thumbnail] ✓ Saved thumbnail to R2: ${key}`); | |
| } catch (err) { | |
| const errorMessage = err.message; | |
| console.error(`[Thumbnail] ✗ Failed to create thumbnail ${key}:`, errorMessage); | |
| throw err; // Re-throw to let caller handle retry logic | |
| } | |
| } | |
| function getThumbnailFileKeys(fileKey: string) { | |
| const fileKeyParts = fileKey.split("/"); | |
| // Add the thumbs directory | |
| fileKeyParts.splice(-1, 0, "thumbs"); | |
| // Remove the extension | |
| const filenameWithoutExtension = fileKeyParts?.pop()?.replace(/\.[^/.]+$/, "") ?? ""; | |
| // Return the thumbnail file keys | |
| return { | |
| thumbRegular: `${fileKeyParts.join("/")}/${filenameWithoutExtension}.avif`, | |
| thumbRetina: `${fileKeyParts.join("/")}/${filenameWithoutExtension}@2x.avif`, | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment