Skip to content

Commit 8e73421

Browse files
authored
perf: do not use next/dist/server image optimizer (calcom#23207)
* write image utils * refactor * better comment
1 parent 5bab2af commit 8e73421

2 files changed

Lines changed: 124 additions & 8 deletions

File tree

apps/web/app/api/logo/route.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -193,25 +193,27 @@ async function getHandler(request: NextRequest) {
193193
const response = await fetch(filteredLogo);
194194
const arrayBuffer = await response.arrayBuffer();
195195
let buffer: Buffer = Buffer.from(arrayBuffer);
196+
let contentType = response.headers.get("content-type") || "image/png";
196197

197-
// If we need to resize the team logos (via Next.js' built-in image processing)
198+
// Resize the team logos if needed
198199
if (teamLogos[logoDefinition.source] && logoDefinition.w) {
199-
const { detectContentType, optimizeImage } = await import("next/dist/server/image-optimizer");
200-
201-
buffer = await optimizeImage({
200+
const { resizeImage } = await import("@calcom/lib/server/imageUtils");
201+
const { buffer: outBuffer, contentType: outContentType } = await resizeImage({
202202
buffer,
203-
contentType: (await detectContentType(buffer)) ?? "image/jpeg",
204-
quality: 100,
205203
width: logoDefinition.w,
206-
height: logoDefinition.h, // optional
204+
height: logoDefinition.h,
205+
quality: 100,
206+
contentType,
207207
});
208+
buffer = outBuffer;
209+
contentType = outContentType;
208210
}
209211

210212
// Create a new response with the image buffer
211213
const imageResponse = new NextResponse(buffer as BodyInit);
212214

213215
// Set the appropriate headers
214-
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
216+
imageResponse.headers.set("Content-Type", contentType);
215217
imageResponse.headers.set("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
216218

217219
return imageResponse;

packages/lib/server/imageUtils.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import sharp from "sharp";
33
// Maximum allowed size for SVG data (5MB)
44
const MAX_SVG_SIZE = 5 * 1024 * 1024;
55

6+
const JPEG = "image/jpeg";
7+
const PNG = "image/png";
8+
const WEBP = "image/webp";
9+
const AVIF = "image/avif";
10+
const GIF = "image/gif";
11+
const SVG = "image/svg+xml";
12+
613
/**
714
* Converts an SVG image to PNG format
815
* @param data Base64 encoded image data
@@ -29,3 +36,110 @@ export const convertSvgToPng = async (data: string) => {
2936
}
3037
return data;
3138
};
39+
40+
/**
41+
* Detect content type from image buffer.
42+
* Simplified version of Next.js image optimizer's detectContentType function (https://github.com/vercel/next.js/blob/9436dce61f1a3ff9478261dc2eba47e0527acf3d/packages/next/src/server/image-optimizer.ts#L160).
43+
* Supports common web image formats (JPEG, PNG, GIF, WEBP, AVIF, SVG) and drops
44+
* irrelevant formats like PDF, ICO, TIFF, etc. that aren't used for logos.
45+
*/
46+
export async function detectContentType(buffer: Buffer): Promise<string | null> {
47+
if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
48+
return JPEG;
49+
}
50+
if ([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((b, i) => buffer[i] === b)) {
51+
return PNG;
52+
}
53+
if ([0x47, 0x49, 0x46, 0x38].every((b, i) => buffer[i] === b)) {
54+
return GIF;
55+
}
56+
if ([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50].every((b, i) => !b || buffer[i] === b)) {
57+
return WEBP;
58+
}
59+
if ([0x3c, 0x3f, 0x78, 0x6d, 0x6c].every((b, i) => buffer[i] === b)) {
60+
return SVG;
61+
}
62+
if ([0x3c, 0x73, 0x76, 0x67].every((b, i) => buffer[i] === b)) {
63+
return SVG;
64+
}
65+
if ([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66].every((b, i) => !b || buffer[i] === b)) {
66+
return AVIF;
67+
}
68+
69+
// Fallback to sharp metadata detection
70+
try {
71+
const meta = await sharp(buffer).metadata();
72+
switch (meta?.format) {
73+
case "avif":
74+
return AVIF;
75+
case "webp":
76+
return WEBP;
77+
case "png":
78+
return PNG;
79+
case "jpeg":
80+
case "jpg":
81+
return JPEG;
82+
case "gif":
83+
return GIF;
84+
case "svg":
85+
return SVG;
86+
default:
87+
return null;
88+
}
89+
} catch {
90+
return null;
91+
}
92+
}
93+
94+
/**
95+
* Resize an image buffer while preserving the original format.
96+
* Simplified version of Next.js image optimizer's optimizeImage function (https://github.com/vercel/next.js/blob/9436dce61f1a3ff9478261dc2eba47e0527acf3d/packages/next/src/server/image-optimizer.ts#L640).
97+
* Supports common web image formats (JPEG, PNG, WEBP, AVIF) with format-specific
98+
* optimization settings. Drops advanced options like limitInputPixels, sequentialRead,
99+
* and timeout that aren't needed for logo processing. Uses failOnError: false for
100+
* better robustness with potentially malformed images.
101+
*/
102+
export async function resizeImage(params: {
103+
buffer: Buffer;
104+
width: number;
105+
height?: number;
106+
quality?: number;
107+
contentType?: string;
108+
}): Promise<{ buffer: Buffer; contentType: string }> {
109+
const { buffer, width, height, quality = 100 } = params;
110+
let { contentType } = params;
111+
112+
// Auto-detect content type if not provided
113+
if (!contentType) {
114+
contentType = (await detectContentType(buffer)) ?? PNG;
115+
}
116+
117+
const transformer = sharp(buffer).rotate();
118+
119+
if (height) {
120+
transformer.resize(width, height);
121+
} else {
122+
transformer.resize(width, undefined, { withoutEnlargement: true });
123+
}
124+
125+
// Apply format-specific optimization (preserving original format)
126+
if (contentType === AVIF) {
127+
transformer.avif({
128+
quality: Math.max(quality - 20, 1),
129+
effort: 3,
130+
});
131+
} else if (contentType === WEBP) {
132+
transformer.webp({ quality });
133+
} else if (contentType === PNG) {
134+
transformer.png({ quality });
135+
} else if (contentType === JPEG) {
136+
transformer.jpeg({ quality, mozjpeg: true });
137+
} else {
138+
// For unknown formats, default to PNG
139+
transformer.png({ quality });
140+
contentType = PNG;
141+
}
142+
143+
const optimizedBuffer = await transformer.toBuffer();
144+
return { buffer: optimizedBuffer, contentType };
145+
}

0 commit comments

Comments
 (0)