import sharp from "sharp"; import { Canvas, Image, ImageData } from "canvas"; // Use the ESM no-bundle build to avoid the default Node entrypoint // which hardcodes require('@tensorflow/tfjs-node') and crashes in Docker. // This build uses pure @tensorflow/tfjs (JS-only, no native C++ bindings). // @ts-ignore - direct path import has no type declarations import * as faceapi from "@vladmandic/face-api/dist/face-api.esm-nobundle.js"; import * as tf from "@tensorflow/tfjs"; import path from "path"; import { fileURLToPath } from "url"; // Polyfill required by face-api for Node.js faceapi.env.monkeyPatch({ Canvas, Image, ImageData } as any); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MODEL_URL = path.join(__dirname, "../models"); // State flag to ensure we only load weights once let modelsLoaded = false; async function loadModelsOnce() { if (modelsLoaded) return; // Initialize pure JS CPU backend (no native bindings needed) await tf.setBackend("cpu"); await tf.ready(); // Load the microscopic TinyFaceDetector (~190KB) await faceapi.nets.tinyFaceDetector.loadFromDisk(MODEL_URL); modelsLoaded = true; } export interface ProcessImageOptions { width: number; height: number; format?: "webp" | "jpeg" | "png" | "avif"; quality?: number; } /** * Maps a URL based on the IMGPROXY_URL_MAPPING environment variable. * Format: "match1:replace1,match2:replace2" */ export function mapUrl(url: string, mappingString?: string): string { if (!mappingString) return url; const mappings = mappingString.split(",").map((m) => { if (m.includes("|")) { return m.split("|"); } // Legacy support for simple "host:target" or cases where one side might have a protocol // We try to find the split point that isn't part of a protocol "://" const colonIndices = []; for (let i = 0; i < m.length; i++) { if (m[i] === ":") { // Check if this colon is part of "://" if (!(m[i + 1] === "/" && m[i + 2] === "/")) { colonIndices.push(i); } } } if (colonIndices.length === 0) return [m]; // In legacy mode with colons, we take the LAST non-protocol colon as the separator // This handles "http://host:port" or "host:http://target" better const lastColon = colonIndices[colonIndices.length - 1]; return [m.substring(0, lastColon), m.substring(lastColon + 1)]; }); let mappedUrl = url; for (const [match, replace] of mappings) { if (match && replace && url.includes(match)) { mappedUrl = url.replace(match, replace); } } return mappedUrl; } /** * Parses legacy imgproxy options string. * Example: rs:fill:300:400/q:80 */ export function parseImgproxyOptions( optionsStr: string, ): Partial { const parts = optionsStr.split("/"); const options: Partial = {}; for (const part of parts) { if (part.startsWith("rs:")) { const [, , w, h] = part.split(":"); if (w) options.width = parseInt(w, 10); if (h) options.height = parseInt(h, 10); } else if (part.startsWith("q:")) { const q = part.split(":")[1]; if (q) options.quality = parseInt(q, 10); } else if (part.startsWith("ext:")) { const ext = part.split(":")[1] as any; if (["webp", "jpeg", "png", "avif"].includes(ext)) { options.format = ext; } } } return options; } export async function processImageWithSmartCrop( inputBuffer: Buffer, options: ProcessImageOptions, ): Promise { const sharpImage = sharp(inputBuffer); const metadata = await sharpImage.metadata(); if (!metadata.width || !metadata.height) { throw new Error("Could not read image metadata"); } // Load ML models (noop if already loaded) await loadModelsOnce(); // Convert sharp image to a Node-compatible canvas Image for face-api const jpegBuffer = await sharpImage.jpeg().toBuffer(); const img = new Image(); img.src = jpegBuffer; const canvas = new Canvas(img.width, img.height); const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, img.width, img.height); // Detect faces locally using the tiny model // Requires explicit any cast since the types expect HTML elements in browser contexts const detections = await faceapi.detectAllFaces( canvas as any, new faceapi.TinyFaceDetectorOptions(), ); let cropPosition: "center" | "attention" | number = "attention"; // Fallback to sharp's attention if no faces if (detections.length > 0) { // We have faces! Calculate the bounding box that contains all of them let minX = metadata.width; let minY = metadata.height; let maxX = 0; let maxY = 0; for (const det of detections) { const box = det.box; if (box.x < minX) minX = Math.max(0, box.x); if (box.y < minY) minY = Math.max(0, box.y); if (box.x + box.width > maxX) maxX = Math.min(metadata.width, box.x + box.width); if (box.y + box.height > maxY) maxY = Math.min(metadata.height, box.y + box.height); } const centerX = Math.floor(minX + (maxX - minX) / 2); const centerY = Math.floor(minY + (maxY - minY) / 2); const targetRatio = options.width / options.height; const currentRatio = metadata.width / metadata.height; let cropWidth = metadata.width; let cropHeight = metadata.height; // Determine the maximal crop window that maintains aspect ratio if (currentRatio > targetRatio) { cropWidth = Math.floor(metadata.height * targetRatio); } else { cropHeight = Math.floor(metadata.width / targetRatio); } // Center the crop window over the center of the faces let cropX = Math.floor(centerX - cropWidth / 2); let cropY = Math.floor(centerY - cropHeight / 2); // Keep crop window inside image bounds if (cropX < 0) cropX = 0; if (cropY < 0) cropY = 0; if (cropX + cropWidth > metadata.width) cropX = metadata.width - cropWidth; if (cropY + cropHeight > metadata.height) cropY = metadata.height - cropHeight; // Pre-crop the image to isolate the faces before resizing sharpImage.extract({ left: cropX, top: cropY, width: cropWidth, height: cropHeight, }); // As we manually calculated the exact focal box, we can now just center it cropPosition = "center"; } let finalImage = sharpImage.resize(options.width, options.height, { fit: "cover", position: cropPosition, }); const format = options.format || "webp"; const quality = options.quality || 80; if (format === "webp") { finalImage = finalImage.webp({ quality }); } else if (format === "jpeg") { finalImage = finalImage.jpeg({ quality }); } else if (format === "png") { finalImage = finalImage.png({ quality }); } else if (format === "avif") { finalImage = finalImage.avif({ quality }); } return finalImage.toBuffer(); }