Skip to content

Commit 5c92e51

Browse files
authored
Merge pull request #338 from lklynet/feature/image-proxy-webp-optimization
Optimize cached image proxy responses with WebP
2 parents 9f18cb5 + 3f7ec4f commit 5c92e51

1 file changed

Lines changed: 149 additions & 4 deletions

File tree

backend/services/imageProxyService.js

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "fs";
33
import path from "path";
44
import { fileURLToPath } from "url";
55
import axios from "axios";
6+
import sharp from "sharp";
67

78
const __filename = fileURLToPath(import.meta.url);
89
const __dirname = path.dirname(__filename);
@@ -11,6 +12,10 @@ const IMAGE_PROXY_ROUTE = "/api/image-proxy";
1112
const IMAGE_PROXY_DIR = path.join(__dirname, "..", "data", "image-proxy");
1213
const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
1314
const FETCH_TIMEOUT_MS = 25000;
15+
const OPTIMIZED_IMAGE_MAX_BYTES = 1024 * 1024;
16+
const DEFAULT_WEBP_QUALITY = 70;
17+
const FALLBACK_WEBP_QUALITIES = [70, 60, 50, 40];
18+
const FALLBACK_MAX_DIMENSIONS = [null, 1600, 1400, 1200, 1000, 800];
1419
const inflightRequests = new Map();
1520
const cacheEntriesByKey = new Map();
1621
const cacheKeysBySourceUrl = new Map();
@@ -35,6 +40,12 @@ const MIME_EXTENSION_MAP = {
3540
"image/avif": "avif",
3641
"image/svg+xml": "svg",
3742
};
43+
const OPTIMIZABLE_CONTENT_TYPES = new Set([
44+
"image/jpeg",
45+
"image/png",
46+
"image/webp",
47+
"image/avif",
48+
]);
3849

3950
const ensureCacheDir = () => {
4051
fs.mkdirSync(IMAGE_PROXY_DIR, { recursive: true });
@@ -109,6 +120,18 @@ const getCachePaths = (cacheKey) => ({
109120
baseImagePath: path.join(IMAGE_PROXY_DIR, `${cacheKey}`),
110121
});
111122

123+
const removeStaleCachedFiles = (cacheKey, keepExtension) => {
124+
ensureCacheDir();
125+
const prefix = `${cacheKey}.`;
126+
for (const file of fs.readdirSync(IMAGE_PROXY_DIR)) {
127+
if (!file.startsWith(prefix) || file.endsWith(".json")) continue;
128+
if (keepExtension && file === `${cacheKey}.${keepExtension}`) continue;
129+
try {
130+
fs.unlinkSync(path.join(IMAGE_PROXY_DIR, file));
131+
} catch {}
132+
}
133+
};
134+
112135
const buildLocalImageUrl = (cacheKey, extension) =>
113136
`${IMAGE_PROXY_ROUTE}/${cacheKey}.${extension}`;
114137

@@ -162,6 +185,7 @@ const writeCacheEntry = (cacheKey, buffer, contentType, sourceUrl) => {
162185
size: buffer.length,
163186
};
164187

188+
removeStaleCachedFiles(cacheKey, extension);
165189
fs.writeFileSync(imagePath, buffer);
166190
fs.writeFileSync(metaPath, JSON.stringify(meta));
167191
const entry = {
@@ -178,6 +202,122 @@ const writeCacheEntry = (cacheKey, buffer, contentType, sourceUrl) => {
178202
return entry;
179203
};
180204

205+
const shouldNormalizeCachedEntry = (entry) => {
206+
if (!entry?.imagePath || !entry?.meta?.contentType) return false;
207+
if (!OPTIMIZABLE_CONTENT_TYPES.has(entry.meta.contentType)) return false;
208+
return (
209+
entry.meta.contentType !== "image/webp" ||
210+
Number(entry.meta.size || 0) > OPTIMIZED_IMAGE_MAX_BYTES
211+
);
212+
};
213+
214+
const optimizeImageBuffer = async (buffer, contentType) => {
215+
if (!OPTIMIZABLE_CONTENT_TYPES.has(contentType)) {
216+
return {
217+
buffer,
218+
contentType,
219+
};
220+
}
221+
222+
let metadata = null;
223+
try {
224+
metadata = await sharp(buffer, { animated: false }).metadata();
225+
} catch {
226+
return {
227+
buffer,
228+
contentType,
229+
};
230+
}
231+
232+
const largestDimension = Math.max(metadata?.width || 0, metadata?.height || 0);
233+
const dimensionSteps = FALLBACK_MAX_DIMENSIONS.filter(
234+
(dimension) => dimension === null || largestDimension > dimension,
235+
);
236+
if (dimensionSteps.length === 0) {
237+
dimensionSteps.push(null);
238+
}
239+
240+
let bestCandidate = null;
241+
242+
for (const maxDimension of dimensionSteps) {
243+
for (const quality of FALLBACK_WEBP_QUALITIES) {
244+
let candidate = sharp(buffer, { animated: false }).rotate();
245+
if (maxDimension) {
246+
candidate = candidate.resize({
247+
width: maxDimension,
248+
height: maxDimension,
249+
fit: "inside",
250+
withoutEnlargement: true,
251+
});
252+
}
253+
254+
const optimizedBuffer = await candidate
255+
.webp({
256+
quality,
257+
effort: 4,
258+
})
259+
.toBuffer();
260+
261+
if (
262+
!bestCandidate ||
263+
optimizedBuffer.length < bestCandidate.buffer.length
264+
) {
265+
bestCandidate = {
266+
buffer: optimizedBuffer,
267+
contentType: "image/webp",
268+
};
269+
}
270+
271+
if (
272+
quality === DEFAULT_WEBP_QUALITY &&
273+
optimizedBuffer.length <= OPTIMIZED_IMAGE_MAX_BYTES
274+
) {
275+
return {
276+
buffer: optimizedBuffer,
277+
contentType: "image/webp",
278+
};
279+
}
280+
281+
if (optimizedBuffer.length <= OPTIMIZED_IMAGE_MAX_BYTES) {
282+
return {
283+
buffer: optimizedBuffer,
284+
contentType: "image/webp",
285+
};
286+
}
287+
}
288+
}
289+
290+
return bestCandidate || { buffer, contentType };
291+
};
292+
293+
const normalizeCachedEntryIfNeeded = async (entry) => {
294+
if (!shouldNormalizeCachedEntry(entry)) {
295+
return entry;
296+
}
297+
298+
let buffer = null;
299+
try {
300+
buffer = fs.readFileSync(entry.imagePath);
301+
} catch {
302+
return entry;
303+
}
304+
305+
const optimized = await optimizeImageBuffer(buffer, entry.meta.contentType);
306+
if (
307+
optimized.contentType === entry.meta.contentType &&
308+
optimized.buffer.length === buffer.length
309+
) {
310+
return entry;
311+
}
312+
313+
return writeCacheEntry(
314+
entry.cacheKey,
315+
optimized.buffer,
316+
optimized.contentType,
317+
normalizeKnownImageUrl(entry.meta.sourceUrl) || entry.meta.sourceUrl || null,
318+
);
319+
};
320+
181321
const fetchAndCacheImage = async (sourceUrl) => {
182322
const normalizedSourceUrl = normalizeKnownImageUrl(sourceUrl);
183323
if (!normalizedSourceUrl) {
@@ -210,10 +350,15 @@ const fetchAndCacheImage = async (sourceUrl) => {
210350
throw new Error("Upstream response is not an image");
211351
}
212352

213-
return writeCacheEntry(
214-
hashValue(normalizedSourceUrl),
353+
const optimized = await optimizeImageBuffer(
215354
Buffer.from(response.data),
216355
contentType,
356+
);
357+
358+
return writeCacheEntry(
359+
hashValue(normalizedSourceUrl),
360+
optimized.buffer,
361+
optimized.contentType,
217362
normalizedSourceUrl,
218363
);
219364
};
@@ -223,7 +368,7 @@ export const warmImageProxy = async (sourceUrl) => {
223368
if (cacheKeyFromLocalUrl) {
224369
const cachedLocal = getCachedEntryFromKey(cacheKeyFromLocalUrl);
225370
if (cachedLocal?.imagePath) {
226-
return cachedLocal;
371+
return normalizeCachedEntryIfNeeded(cachedLocal);
227372
}
228373
throw new Error("Missing local cached image");
229374
}
@@ -235,7 +380,7 @@ export const warmImageProxy = async (sourceUrl) => {
235380

236381
const cached = getCachedEntry(normalizedSourceUrl);
237382
if (cached?.isFresh) {
238-
return cached;
383+
return normalizeCachedEntryIfNeeded(cached);
239384
}
240385

241386
if (inflightRequests.has(normalizedSourceUrl)) {

0 commit comments

Comments
 (0)