evelodb
Version:
A high-performance native B-tree database for Node.js. Made by Evelocore.
234 lines (233 loc) • 9.86 kB
JavaScript
/**
* imageProcess.ts
*
* Performance architecture:
*
* 1. LRU cache (hash + config key → processed Buffer)
* → cache hit = zero Sharp involvement, zero decode
*
* 2. Metadata fast path: read dimensions from a tiny
* `sharp(buf).metadata()` call only when pixel-budget
* resize is needed. For all other paths, metadata is skipped.
*
* 3. AVIF concurrency limiter (default: 2 parallel encodes)
* → prevents CPU saturation under burst traffic
*
* 4. AVIF effort = 2 (was implicit 4-5)
* → 2–4× faster AVIF encode, ~5% larger file — fair trade
*
* 5. mozjpeg disabled
* → standard libjpeg is 3–5× faster
*/
import sharp from 'sharp';
import { createHash } from 'crypto';
import { LRUCache } from 'lru-cache';
import pLimit from 'p-limit';
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_CONFIG = {
returnBase64: true,
quality: 1,
pixels: 0,
blackAndWhite: false,
mirror: false,
upToDown: false,
invert: false,
brightness: 1,
contrast: 1,
maxWidth: null,
maxHeight: null,
};
// ---------------------------------------------------------------------------
// LRU cache
// Keyed by: sha1(imageBuffer) + fileExtension + JSON(config)
// Stores processed Buffer (not base64 — base64 is applied at return time)
// Max 200 MB total across all entries
// ---------------------------------------------------------------------------
const processedCache = new LRUCache({
maxSize: 200 * 1024 * 1024, // 200 MB
sizeCalculation: (buf) => buf.byteLength,
});
// ---------------------------------------------------------------------------
// AVIF concurrency limiter
// AVIF is CPU-bound; cap parallel encodes to avoid saturating all cores.
// Tune AVIF_CONCURRENCY via env var for your server's core count.
// ---------------------------------------------------------------------------
const AVIF_CONCURRENCY = parseInt(process.env.AVIF_CONCURRENCY ?? '2', 10);
const avifLimit = pLimit(AVIF_CONCURRENCY);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getMimeType(ext) {
switch (ext) {
case '.jpg':
case '.jpeg':
case '.jfif': return 'image/jpeg';
case '.png': return 'image/png';
case '.gif': return 'image/gif';
case '.webp': return 'image/webp';
case '.bmp': return 'image/bmp';
case '.tiff': return 'image/tiff';
case '.svg': return 'image/svg+xml';
case '.ico': return 'image/x-icon';
case '.heic': return 'image/heic';
case '.avif': return 'image/avif';
default: return 'application/octet-stream';
}
}
function toBase64(buf, ext) {
return `data:${getMimeType(ext)};base64,${buf.toString('base64')}`;
}
/** Deterministic cache key: sha1 of raw bytes + ext + config snapshot */
function cacheKey(imageBuffer, ext, cfg) {
const hash = createHash('sha1').update(imageBuffer).digest('hex');
return `${hash}|${ext}|${JSON.stringify(cfg)}`;
}
/** True when any pixel-level transform (beyond resize/quality) is requested */
function hasVisualTransforms(cfg) {
return (cfg.blackAndWhite ||
cfg.mirror ||
cfg.upToDown ||
cfg.invert ||
cfg.brightness !== 1 ||
cfg.contrast !== 1);
}
function buildFormatSpec(ext, quality) {
const q = Math.round(quality * 100);
switch (ext) {
case '.jpg':
case '.jpeg':
case '.jfif':
// mozjpeg: false → standard libjpeg, 3–5× faster
return { format: 'jpeg', options: { quality: q, mozjpeg: false } };
case '.png':
// Lower compressionLevel = faster; still honours quality for palette
return { format: 'png', options: { quality: q, compressionLevel: Math.round(9 * (1 - quality)) } };
case '.webp':
return { format: 'webp', options: { quality: q } };
case '.avif':
// effort 2 = ~2–4× faster than default (4); ~5% larger file
return { format: 'avif', options: { quality: q, effort: 2 } };
case '.tiff':
return { format: 'tiff', options: { quality: q } };
case '.gif':
return { format: 'gif', options: {} };
default:
return { format: 'jpeg', options: { quality: q } };
}
}
// ---------------------------------------------------------------------------
// Core encode — builds and executes the Sharp pipeline.
// Returns a Buffer (not base64); caller handles the cache + base64 layer.
// ---------------------------------------------------------------------------
async function encode(imageBuffer, ext, cfg) {
// SVG: transforms require rasterising first
if (ext === '.svg') {
if (!hasVisualTransforms(cfg))
return imageBuffer; // pass-through
return sharp(imageBuffer).png().toBuffer(); // rasterise
}
// -----------------------------------------------------------------------
// Pixel-budget resize: we need actual dimensions → one metadata() call.
// We isolate this on a throwaway instance so the cost is contained and
// the main pipeline starts building immediately after.
// -----------------------------------------------------------------------
let resizeWidth;
let resizeHeight;
if (cfg.pixels > 0) {
const { width = 0, height = 0 } = await sharp(imageBuffer).metadata();
const currentPixels = width * height;
if (currentPixels > cfg.pixels) {
const scale = Math.sqrt(cfg.pixels / currentPixels);
resizeWidth = Math.round(width * scale);
resizeHeight = Math.round(height * scale);
}
}
// -----------------------------------------------------------------------
// Build pipeline
// sequentialRead: true → stream the buffer; avoids random-access seeks
// -----------------------------------------------------------------------
let img = sharp(imageBuffer, { sequentialRead: true });
if (resizeWidth && resizeHeight) {
img = img.resize(resizeWidth, resizeHeight, {
fit: 'inside',
withoutEnlargement: true,
kernel: sharp.kernel.lanczos3,
});
}
if (cfg.maxWidth || cfg.maxHeight) {
img = img.resize(cfg.maxWidth ?? undefined, cfg.maxHeight ?? undefined, {
fit: 'inside',
withoutEnlargement: true,
});
}
// Apply visual transforms only when actually set
if (cfg.mirror)
img = img.flop();
if (cfg.upToDown)
img = img.flip();
if (cfg.blackAndWhite)
img = img.greyscale();
if (cfg.invert)
img = img.negate({ alpha: false });
if (cfg.brightness !== 1)
img = img.modulate({ brightness: cfg.brightness });
if (cfg.contrast !== 1)
img = img.linear(cfg.contrast, -(128 * (cfg.contrast - 1)));
const { format, options } = buildFormatSpec(ext, cfg.quality);
// AVIF encode is wrapped in a concurrency limiter to prevent CPU saturation
if (format === 'avif') {
return avifLimit(() => img.toFormat(format, options).toBuffer());
}
return img
.toFormat(format, options)
.toBuffer();
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export default async function processImage(imageBuffer, fileExtension, config = {}) {
const cfg = { ...DEFAULT_CONFIG, ...config };
const ext = fileExtension.toLowerCase();
// ── 1. Cache lookup ──────────────────────────────────────────────────────
// A hit means zero Sharp involvement — no decode, no pipeline, nothing.
const key = cacheKey(imageBuffer, ext, cfg);
const cached = processedCache.get(key);
if (cached) {
return cfg.returnBase64 ? toBase64(cached, ext) : cached;
}
// ── 2. Process ───────────────────────────────────────────────────────────
let result;
try {
result = await encode(imageBuffer, ext, cfg);
}
catch (err) {
console.warn('[imageProcess] Sharp failed, returning original buffer:', err instanceof Error ? err.message : err);
result = imageBuffer; // fallback: pass through untouched
}
// ── 3. Cache store ───────────────────────────────────────────────────────
// Skip caching absurdly large results (>50 MB) to protect the LRU budget
if (result.byteLength < 50 * 1024 * 1024) {
processedCache.set(key, result);
}
return cfg.returnBase64 ? toBase64(result, ext) : result;
}
// ---------------------------------------------------------------------------
// Cache management utilities — export for tests / health endpoints
// ---------------------------------------------------------------------------
/** Wipe the entire processed image cache */
export function clearImageCache() {
processedCache.clear();
}
/** Current cache stats for monitoring */
export function imageCacheStats() {
const used = processedCache.calculatedSize;
const max = 200 * 1024 * 1024;
return {
entryCount: processedCache.size,
usedBytes: used,
maxBytes: max,
utilizationPct: Math.round((used / max) * 100),
};
}