import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { runExec } from "../process/exec.js"; type Sharp = typeof import("sharp"); export type ImageMetadata = { width: number; height: number; }; function isBun(): boolean { return typeof (process.versions as { bun?: unknown }).bun === "string"; } function prefersSips(): boolean { return ( process.env.CLAWDBOT_IMAGE_BACKEND === "sips" || (process.env.CLAWDBOT_IMAGE_BACKEND !== "sharp" && isBun() && process.platform === "darwin") ); } async function loadSharp(): Promise<(buffer: Buffer) => ReturnType> { const mod = (await import("sharp")) as unknown as { default?: Sharp }; const sharp = mod.default ?? (mod as unknown as Sharp); return (buffer) => sharp(buffer, { failOnError: false }); } /** * Reads EXIF orientation from JPEG buffer. * Returns orientation value 1-8, or null if not found/not JPEG. * * EXIF orientation values: * 1 = Normal, 2 = Flip H, 3 = Rotate 180, 4 = Flip V, * 5 = Rotate 270 CW + Flip H, 6 = Rotate 90 CW, 7 = Rotate 90 CW + Flip H, 8 = Rotate 270 CW */ function readJpegExifOrientation(buffer: Buffer): number | null { // Check JPEG magic bytes if (buffer.length < 2 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { return null; } let offset = 2; while (offset < buffer.length - 4) { // Look for marker if (buffer[offset] !== 0xff) { offset++; continue; } const marker = buffer[offset + 1]; // Skip padding FF bytes if (marker === 0xff) { offset++; continue; } // APP1 marker (EXIF) if (marker === 0xe1) { const exifStart = offset + 4; // Check for "Exif\0\0" header if ( buffer.length > exifStart + 6 && buffer.toString("ascii", exifStart, exifStart + 4) === "Exif" && buffer[exifStart + 4] === 0 && buffer[exifStart + 5] === 0 ) { const tiffStart = exifStart + 6; if (buffer.length < tiffStart + 8) return null; // Check byte order (II = little-endian, MM = big-endian) const byteOrder = buffer.toString("ascii", tiffStart, tiffStart + 2); const isLittleEndian = byteOrder === "II"; const readU16 = (pos: number) => isLittleEndian ? buffer.readUInt16LE(pos) : buffer.readUInt16BE(pos); const readU32 = (pos: number) => isLittleEndian ? buffer.readUInt32LE(pos) : buffer.readUInt32BE(pos); // Read IFD0 offset const ifd0Offset = readU32(tiffStart + 4); const ifd0Start = tiffStart + ifd0Offset; if (buffer.length < ifd0Start + 2) return null; const numEntries = readU16(ifd0Start); for (let i = 0; i < numEntries; i++) { const entryOffset = ifd0Start + 2 + i * 12; if (buffer.length < entryOffset + 12) break; const tag = readU16(entryOffset); // Orientation tag = 0x0112 if (tag === 0x0112) { const value = readU16(entryOffset + 8); return value >= 1 && value <= 8 ? value : null; } } } return null; } // Skip other segments if (marker >= 0xe0 && marker <= 0xef) { const segmentLength = buffer.readUInt16BE(offset + 2); offset += 2 + segmentLength; continue; } // SOF, SOS, or other marker - stop searching if (marker === 0xc0 || marker === 0xda) { break; } offset++; } return null; } async function withTempDir(fn: (dir: string) => Promise): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-img-")); try { return await fn(dir); } finally { await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); } } async function sipsMetadataFromBuffer(buffer: Buffer): Promise { return await withTempDir(async (dir) => { const input = path.join(dir, "in.img"); await fs.writeFile(input, buffer); const { stdout } = await runExec( "/usr/bin/sips", ["-g", "pixelWidth", "-g", "pixelHeight", input], { timeoutMs: 10_000, maxBuffer: 512 * 1024, }, ); const w = stdout.match(/pixelWidth:\s*([0-9]+)/); const h = stdout.match(/pixelHeight:\s*([0-9]+)/); if (!w?.[1] || !h?.[1]) return null; const width = Number.parseInt(w[1], 10); const height = Number.parseInt(h[1], 10); if (!Number.isFinite(width) || !Number.isFinite(height)) return null; if (width <= 0 || height <= 0) return null; return { width, height }; }); } async function sipsResizeToJpeg(params: { buffer: Buffer; maxSide: number; quality: number; }): Promise { return await withTempDir(async (dir) => { const input = path.join(dir, "in.img"); const output = path.join(dir, "out.jpg"); await fs.writeFile(input, params.buffer); await runExec( "/usr/bin/sips", [ "-Z", String(Math.max(1, Math.round(params.maxSide))), "-s", "format", "jpeg", "-s", "formatOptions", String(Math.max(1, Math.min(100, Math.round(params.quality)))), input, "--out", output, ], { timeoutMs: 20_000, maxBuffer: 1024 * 1024 }, ); return await fs.readFile(output); }); } async function sipsConvertToJpeg(buffer: Buffer): Promise { return await withTempDir(async (dir) => { const input = path.join(dir, "in.heic"); const output = path.join(dir, "out.jpg"); await fs.writeFile(input, buffer); await runExec("/usr/bin/sips", ["-s", "format", "jpeg", input, "--out", output], { timeoutMs: 20_000, maxBuffer: 1024 * 1024, }); return await fs.readFile(output); }); } export async function getImageMetadata(buffer: Buffer): Promise { if (prefersSips()) { return await sipsMetadataFromBuffer(buffer).catch(() => null); } try { const sharp = await loadSharp(); const meta = await sharp(buffer).metadata(); const width = Number(meta.width ?? 0); const height = Number(meta.height ?? 0); if (!Number.isFinite(width) || !Number.isFinite(height)) return null; if (width <= 0 || height <= 0) return null; return { width, height }; } catch { return null; } } /** * Applies rotation/flip to image buffer using sips based on EXIF orientation. */ async function sipsApplyOrientation(buffer: Buffer, orientation: number): Promise { // Map EXIF orientation to sips operations // sips -r rotates clockwise, -f flips (horizontal/vertical) const ops: string[] = []; switch (orientation) { case 2: // Flip horizontal ops.push("-f", "horizontal"); break; case 3: // Rotate 180 ops.push("-r", "180"); break; case 4: // Flip vertical ops.push("-f", "vertical"); break; case 5: // Rotate 270 CW + flip horizontal ops.push("-r", "270", "-f", "horizontal"); break; case 6: // Rotate 90 CW ops.push("-r", "90"); break; case 7: // Rotate 90 CW + flip horizontal ops.push("-r", "90", "-f", "horizontal"); break; case 8: // Rotate 270 CW ops.push("-r", "270"); break; default: // Orientation 1 or unknown - no change needed return buffer; } return await withTempDir(async (dir) => { const input = path.join(dir, "in.jpg"); const output = path.join(dir, "out.jpg"); await fs.writeFile(input, buffer); await runExec("/usr/bin/sips", [...ops, input, "--out", output], { timeoutMs: 20_000, maxBuffer: 1024 * 1024, }); return await fs.readFile(output); }); } /** * Normalizes EXIF orientation in an image buffer. * Returns the buffer with correct pixel orientation (rotated if needed). * Falls back to original buffer if normalization fails. */ export async function normalizeExifOrientation(buffer: Buffer): Promise { if (prefersSips()) { try { const orientation = readJpegExifOrientation(buffer); if (!orientation || orientation === 1) { return buffer; // No rotation needed } return await sipsApplyOrientation(buffer, orientation); } catch { return buffer; } } try { const sharp = await loadSharp(); // .rotate() with no args auto-rotates based on EXIF orientation return await sharp(buffer).rotate().toBuffer(); } catch { // Sharp not available or failed - return original buffer return buffer; } } export async function resizeToJpeg(params: { buffer: Buffer; maxSide: number; quality: number; withoutEnlargement?: boolean; }): Promise { if (prefersSips()) { // Normalize EXIF orientation BEFORE resizing (sips resize doesn't auto-rotate) const normalized = await normalizeExifOrientationSips(params.buffer); // Avoid enlarging by checking dimensions first (sips has no withoutEnlargement flag). if (params.withoutEnlargement !== false) { const meta = await getImageMetadata(normalized); if (meta) { const maxDim = Math.max(meta.width, meta.height); if (maxDim > 0 && maxDim <= params.maxSide) { return await sipsResizeToJpeg({ buffer: normalized, maxSide: maxDim, quality: params.quality, }); } } } return await sipsResizeToJpeg({ buffer: normalized, maxSide: params.maxSide, quality: params.quality, }); } const sharp = await loadSharp(); // Use .rotate() BEFORE .resize() to auto-rotate based on EXIF orientation return await sharp(params.buffer) .rotate() // Auto-rotate based on EXIF before resizing .resize({ width: params.maxSide, height: params.maxSide, fit: "inside", withoutEnlargement: params.withoutEnlargement !== false, }) .jpeg({ quality: params.quality, mozjpeg: true }) .toBuffer(); } export async function convertHeicToJpeg(buffer: Buffer): Promise { if (prefersSips()) { return await sipsConvertToJpeg(buffer); } const sharp = await loadSharp(); return await sharp(buffer).jpeg({ quality: 90, mozjpeg: true }).toBuffer(); } /** * Checks if an image has an alpha channel (transparency). * Returns true if the image has alpha, false otherwise. */ export async function hasAlphaChannel(buffer: Buffer): Promise { try { const sharp = await loadSharp(); const meta = await sharp(buffer).metadata(); // Check if the image has an alpha channel // PNG color types with alpha: 4 (grayscale+alpha), 6 (RGBA) // Sharp reports this via 'channels' (4 = RGBA) or 'hasAlpha' return meta.hasAlpha === true || meta.channels === 4; } catch { return false; } } /** * Resizes an image to PNG format, preserving alpha channel (transparency). * Falls back to sharp only (no sips fallback for PNG with alpha). */ export async function resizeToPng(params: { buffer: Buffer; maxSide: number; compressionLevel?: number; withoutEnlargement?: boolean; }): Promise { const sharp = await loadSharp(); // Compression level 6 is a good balance (0=fastest, 9=smallest) const compressionLevel = params.compressionLevel ?? 6; return await sharp(params.buffer) .rotate() // Auto-rotate based on EXIF if present .resize({ width: params.maxSide, height: params.maxSide, fit: "inside", withoutEnlargement: params.withoutEnlargement !== false, }) .png({ compressionLevel }) .toBuffer(); } export async function optimizeImageToPng( buffer: Buffer, maxBytes: number, ): Promise<{ buffer: Buffer; optimizedSize: number; resizeSide: number; compressionLevel: number; }> { // Try a grid of sizes/compression levels until under the limit. // PNG uses compression levels 0-9 (higher = smaller but slower). const sides = [2048, 1536, 1280, 1024, 800]; const compressionLevels = [6, 7, 8, 9]; let smallest: { buffer: Buffer; size: number; resizeSide: number; compressionLevel: number; } | null = null; for (const side of sides) { for (const compressionLevel of compressionLevels) { try { const out = await resizeToPng({ buffer, maxSide: side, compressionLevel, withoutEnlargement: true, }); const size = out.length; if (!smallest || size < smallest.size) { smallest = { buffer: out, size, resizeSide: side, compressionLevel }; } if (size <= maxBytes) { return { buffer: out, optimizedSize: size, resizeSide: side, compressionLevel, }; } } catch { // Continue trying other size/compression combinations. } } } if (smallest) { return { buffer: smallest.buffer, optimizedSize: smallest.size, resizeSide: smallest.resizeSide, compressionLevel: smallest.compressionLevel, }; } throw new Error("Failed to optimize PNG image"); } /** * Internal sips-only EXIF normalization (no sharp fallback). * Used by resizeToJpeg to normalize before sips resize. */ async function normalizeExifOrientationSips(buffer: Buffer): Promise { try { const orientation = readJpegExifOrientation(buffer); if (!orientation || orientation === 1) { return buffer; } return await sipsApplyOrientation(buffer, orientation); } catch { return buffer; } }