-
Notifications
You must be signed in to change notification settings - Fork 18
feat: optimize ENS avatar images with Sharp #601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<OptimizeImageResult> { | ||||||
| const { width = 96, height = 96, quality = 75, effort = 6 } = options; | ||||||
|
|
||||||
| const originalSize = imageBuffer.byteLength; | ||||||
| const originalBuffer = Buffer.from(imageBuffer); | ||||||
|
|
||||||
|
Comment on lines
+30
to
+34
|
||||||
| // Get original image metadata | ||||||
| let originalMetadata; | ||||||
| try { | ||||||
| originalMetadata = await sharp(originalBuffer).metadata(); | ||||||
| } catch { | ||||||
|
Comment on lines
+35
to
+39
|
||||||
| // 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 { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
| // Fallback to original image if optimization fails | ||||||
| // This is expected for some edge cases (unsupported formats, corrupted images, etc. | ||||||
|
||||||
| // This is expected for some edge cases (unsupported formats, corrupted images, etc. | |
| // This is expected for some edge cases (unsupported formats, corrupted images, etc.) |
Copilot
AI
Mar 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the fallback path, contentType is hard-coded to image/jpeg. If this utility is reused elsewhere, callers may inadvertently serve non-JPEG bytes with the wrong Content-Type. Prefer returning contentType: null/undefined on fallback (forcing the caller to supply the original), or accept the original content-type as an input and echo it back when optimization fails.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 ( | |||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Ensure the upstream request succeeded | |
| if (!response.ok) { | |
| if (response.status === 404) { | |
| return notFound(res, "ENS avatar not found"); | |
| } | |
| return internalError( | |
| res, | |
| new Error( | |
| `Failed to fetch ENS avatar: ${response.status} ${response.statusText}` | |
| ) | |
| ); | |
| } | |
| // Ensure the upstream response is an image | |
| const upstreamContentType = | |
| response.headers.get("content-type")?.toLowerCase() || ""; | |
| if (!upstreamContentType.startsWith("image/")) { | |
| return notFound(res, "ENS avatar not found"); | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Copilot
AI
Mar 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This endpoint now always returns WebP on success. To avoid breaking clients/crawlers that don't send Accept: image/webp, consider negotiating based on the request Accept header (and returning the original bytes otherwise), and set Vary: Accept when the response format can change.
Copilot
AI
Mar 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using optimizationResult.contentType === "image/jpeg" as a sentinel for "optimization failed" is brittle and couples the route to the utility's implementation details. Consider returning an explicit flag (e.g., optimized: boolean) and/or originalContentType from optimizeImage so the caller can set headers without relying on a magic content type.
| // 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); | |
| } | |
| // Set appropriate content type, preferring optimized result and falling back to original if needed | |
| const resolvedContentType = | |
| optimizationResult.contentType || | |
| response.headers.get("content-type") || | |
| "image/jpeg"; | |
| res.setHeader("Content-Type", resolvedContentType); |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing parameter validation in optimizeImage function allows Sharp to throw unhandled errors when invalid width, height, quality, or effort values are provided