Skip to content

Instantly share code, notes, and snippets.

@haltakov
Last active December 15, 2025 10:42
Show Gist options
  • Select an option

  • Save haltakov/17727f59a98d050a21a0f9e5704861cf to your computer and use it in GitHub Desktop.

Select an option

Save haltakov/17727f59a98d050a21a0f9e5704861cf to your computer and use it in GitHub Desktop.
Thumbnail Creation With Cloudflare Workers
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