From 79701a158d487550271aeb1958e1982cdbd379d3 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Wed, 25 Mar 2026 12:08:14 +0100 Subject: [PATCH] feat: optimize ENS avatar images with Sharp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add image optimization utility that resizes ENS avatars from 704×704 to 96×96 and converts to WebP format, reducing file sizes by ~95%. Includes graceful fallback to original image on optimization failure. Extracted from #509. Co-Authored-By: Sebastian <115311276+Roaring30s@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/api/image-optimization.ts | 92 +++++++++++++++++++++++++++++ package.json | 1 + pages/api/ens-data/image/[name].tsx | 24 +++++++- pnpm-lock.yaml | 10 ++-- 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 lib/api/image-optimization.ts diff --git a/lib/api/image-optimization.ts b/lib/api/image-optimization.ts new file mode 100644 index 00000000..2378aa4f --- /dev/null +++ b/lib/api/image-optimization.ts @@ -0,0 +1,92 @@ +import sharp from "sharp"; + +export interface OptimizeImageResult { + buffer: Buffer; + contentType: string; + originalSize: number; + optimizedSize: number; + originalDimensions?: { width: number; height: number }; + optimizedDimensions?: { width: number; height: number }; + format?: string; +} + +export interface OptimizeImageOptions { + width?: number; + height?: number; + quality?: number; + effort?: number; +} + +/** + * Optimizes an image by resizing and converting to WebP format + * @param imageBuffer - The original image buffer + * @param options - Optimization options + * @returns Optimized image buffer and metadata, or original buffer if optimization fails + */ +export async function optimizeImage( + imageBuffer: ArrayBuffer, + options: OptimizeImageOptions = {} +): Promise { + const { width = 96, height = 96, quality = 75, effort = 6 } = options; + + const originalSize = imageBuffer.byteLength; + const originalBuffer = Buffer.from(imageBuffer); + + // Get original image metadata + let originalMetadata; + try { + originalMetadata = await sharp(originalBuffer).metadata(); + } catch { + // Metadata extraction failed, but we can still proceed with optimization + originalMetadata = undefined; + } + + // Optimize image: resize and convert to WebP + try { + const optimizedBuffer = await sharp(originalBuffer) + .resize(width, height, { + fit: "cover", + withoutEnlargement: true, // Don't upscale small images + }) + .webp({ quality, effort }) + .toBuffer(); + + const optimizedMetadata = await sharp(optimizedBuffer).metadata(); + + return { + buffer: optimizedBuffer, + contentType: "image/webp", + originalSize, + optimizedSize: optimizedBuffer.length, + originalDimensions: originalMetadata + ? { + width: originalMetadata.width || 0, + height: originalMetadata.height || 0, + } + : undefined, + optimizedDimensions: optimizedMetadata + ? { + width: optimizedMetadata.width || 0, + height: optimizedMetadata.height || 0, + } + : undefined, + format: optimizedMetadata?.format, + }; + } catch { + // Fallback to original image if optimization fails + // This is expected for some edge cases (unsupported formats, corrupted images, etc. + // Return original image as fallback + return { + buffer: originalBuffer, + contentType: "image/jpeg", // Default fallback + originalSize, + optimizedSize: originalSize, + originalDimensions: originalMetadata + ? { + width: originalMetadata.width || 0, + height: originalMetadata.height || 0, + } + : undefined, + }; + } +} diff --git a/package.json b/package.json index 28af2dbe..41ccc121 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "4.0.1", "sanitize-html": "^2.17.0", + "sharp": "^0.34.5", "swr": "^2.3.7", "viem": "^2.38.5", "wagmi": "^2.19.1", diff --git a/pages/api/ens-data/image/[name].tsx b/pages/api/ens-data/image/[name].tsx index d2be5f8b..3322e9c4 100644 --- a/pages/api/ens-data/image/[name].tsx +++ b/pages/api/ens-data/image/[name].tsx @@ -5,6 +5,7 @@ import { methodNotAllowed, notFound, } from "@lib/api/errors"; +import { optimizeImage } from "@lib/api/image-optimization"; import { l1PublicClient } from "@lib/chains"; import { parseArweaveTxId, parseCid } from "livepeer/utils"; import { NextApiRequest, NextApiResponse } from "next"; @@ -48,9 +49,30 @@ const handler = async ( const arrayBuffer = await response.arrayBuffer(); + // Optimize image using utility + const optimizationResult = await optimizeImage(arrayBuffer, { + width: 96, + height: 96, + quality: 75, + effort: 6, + }); + + // Set appropriate content type (fallback to original if optimization failed) + if (optimizationResult.contentType === "image/jpeg") { + const originalContentType = + response.headers.get("content-type") || "image/jpeg"; + res.setHeader("Content-Type", originalContentType); + } else { + res.setHeader("Content-Type", optimizationResult.contentType); + } + + res.setHeader( + "Content-Length", + optimizationResult.buffer.length.toString() + ); res.setHeader("Cache-Control", getCacheControlHeader("week")); - return res.end(Buffer.from(arrayBuffer)); + return res.end(optimizationResult.buffer); } catch (e) { console.error(e); return notFound(res, "ENS avatar not found"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62f7ae0b..33778493 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,9 @@ importers: sanitize-html: specifier: ^2.17.0 version: 2.17.1 + sharp: + specifier: ^0.34.5 + version: 0.34.5 swr: specifier: ^2.3.7 version: 2.4.1(react@19.2.1) @@ -13143,8 +13146,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -19034,8 +19036,7 @@ snapshots: detect-indent@6.1.0: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} detect-newline@3.1.0: {} @@ -23779,7 +23780,6 @@ snapshots: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 - optional: true shebang-command@2.0.0: dependencies: