UNPKG

evelodb

Version:

A high-performance native B-tree database for Node.js. Made by Evelocore.

242 lines (241 loc) 10.3 kB
"use strict"; /** * 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 */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.default = processImage; exports.clearImageCache = clearImageCache; exports.imageCacheStats = imageCacheStats; const sharp_1 = __importDefault(require("sharp")); const crypto_1 = require("crypto"); const lru_cache_1 = require("lru-cache"); const p_limit_1 = __importDefault(require("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 lru_cache_1.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 = (0, p_limit_1.default)(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 = (0, crypto_1.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 (0, sharp_1.default)(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 (0, sharp_1.default)(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 = (0, sharp_1.default)(imageBuffer, { sequentialRead: true }); if (resizeWidth && resizeHeight) { img = img.resize(resizeWidth, resizeHeight, { fit: 'inside', withoutEnlargement: true, kernel: sharp_1.default.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 // --------------------------------------------------------------------------- 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 */ function clearImageCache() { processedCache.clear(); } /** Current cache stats for monitoring */ 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), }; }