import sharp from 'sharp'; const FETCH_TIMEOUT_MS = 15000; const MAX_BYTES = 50 * 1024 * 1024; // 50MB const MAX_EDGE = 1000; const TRIM_THRESHOLD = 10; const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed) const AVIF_QUALITY = 80; const AVIF_EFFORT = 4; /** * Validates and normalizes an image URL. * @param {string} url * @returns {URL} */ export function validateImageUrl(url) { if (typeof url !== 'string' || !url.trim()) { throw new Error('url is required'); } let parsed; try { parsed = new URL(url.trim()); } catch { throw new Error('Invalid URL'); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`Unsupported URL protocol: ${parsed.protocol}. Only http: and https: are allowed.`); } return parsed; } /** * Crawls/downloads an image from a URL. * * @param {string} url * @param {object} [options] * @param {AbortSignal} [options.signal] * @returns {Promise} */ export async function crawlImage(url, { signal } = {}) { const parsedUrl = validateImageUrl(url); const fetchSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) : AbortSignal.timeout(FETCH_TIMEOUT_MS); const response = await fetch(parsedUrl.href, { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9,de;q=0.8', 'Referer': parsedUrl.origin + '/', }, signal: fetchSignal, redirect: 'follow', }); if (!response.ok) { throw new Error(`Failed to crawl image: HTTP ${response.status} ${response.statusText}`); } const contentLength = Number(response.headers.get('content-length')); if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) { throw new Error(`Image size exceeds limit of ${MAX_BYTES} bytes`); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); if (buffer.length === 0) { throw new Error('Crawled image is empty (0 bytes)'); } if (buffer.length > MAX_BYTES) { throw new Error(`Image size (${buffer.length} bytes) exceeds limit of ${MAX_BYTES} bytes`); } return buffer; } /** * Trims transparent and white borders from an image buffer, restores up to * RESTORE_MARGIN px of the original image on each edge (never more than was * actually trimmed, never enlarging beyond the original), resizes to max 1000px, * and converts to AVIF. * * @param {Buffer} inputBuffer - Raw image buffer * @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>} */ export async function cropAndConvertToAvif(inputBuffer) { // 1. Initial metadata and orientation correction const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate(); const initialMeta = await baseSharp.metadata(); const hasAlpha = Boolean(initialMeta.hasAlpha); // Use PNG intermediate for trimming passes to preserve alpha const originalBuffer = await baseSharp.png().toBuffer(); const origMeta = await sharp(originalBuffer).metadata(); // Cumulative crop box in original (oriented) pixel coordinates let box = { left: 0, top: 0, width: origMeta.width, height: origMeta.height }; let currentBuffer = originalBuffer; const trimPass = async (background) => { try { const trimmed = await sharp(currentBuffer) .trim({ background, threshold: TRIM_THRESHOLD }) .png() .toBuffer({ resolveWithObject: true }); if (trimmed.info.width > 0 && trimmed.info.height > 0) { currentBuffer = trimmed.data; box = { left: box.left - (trimmed.info.trimOffsetLeft ?? 0), top: box.top - (trimmed.info.trimOffsetTop ?? 0), width: trimmed.info.width, height: trimmed.info.height, }; } } catch { // Keep currentBuffer } }; // 2. Step A: Trim transparent borders if alpha is present if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 }); // 3. Step B: Trim white borders await trimPass('#ffffff'); // 4. Step C: Trim transparent borders again (handles white border containing transparent padding) if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 }); // 4b. Restore up to RESTORE_MARGIN px per edge from the original, clamped to original bounds const left = Math.max(0, box.left - RESTORE_MARGIN); const top = Math.max(0, box.top - RESTORE_MARGIN); const right = Math.min(origMeta.width, box.left + box.width + RESTORE_MARGIN); const bottom = Math.min(origMeta.height, box.top + box.height + RESTORE_MARGIN); if (left !== box.left || top !== box.top || right - left !== box.width || bottom - top !== box.height) { try { const restored = await sharp(originalBuffer) .extract({ left, top, width: right - left, height: bottom - top }) .png() .toBuffer({ resolveWithObject: true }); currentBuffer = restored.data; } catch { // Keep currentBuffer } } const currentMeta = await sharp(currentBuffer).metadata(); // 5. Resize to max 1000px without enlargement let finalPipeline = sharp(currentBuffer); if (currentMeta.width > MAX_EDGE || currentMeta.height > MAX_EDGE) { finalPipeline = finalPipeline.resize(MAX_EDGE, MAX_EDGE, { fit: 'inside', withoutEnlargement: true, }); } // 6. Convert to AVIF const avifResult = await finalPipeline .avif({ quality: AVIF_QUALITY, effort: AVIF_EFFORT }) .toBuffer({ resolveWithObject: true }); return { buffer: avifResult.data, width: avifResult.info.width, height: avifResult.info.height, size: avifResult.info.size, }; } /** * Crawls a URL, crops white & transparency borders, resizes to max 1000px, and returns AVIF. * * @param {string} url - Image URL to crawl * @param {object} [options] * @param {AbortSignal} [options.signal] - Abort signal * @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>} */ export async function crawlAndProcessImage(url, { signal } = {}) { const rawBuffer = await crawlImage(url, { signal }); return cropAndConvertToAvif(rawBuffer); }