/** * image-utils.ts — Browser-side image validation, reading, resizing, and processing. * * Pure utilities shared by chat mode (drag/paste → base64 inline) and terminal mode * (drag/paste → upload). All functions are side-effect-free except for Canvas usage * in resizeImageInBrowser. * * Observability: * - console.warn on validation failure (wrong MIME type, oversized file) * - Errors thrown with descriptive messages for upstream catch handlers */ // ─── Constants ──────────────────────────────────────────────────────────────── const ALLOWED_MIME_TYPES = new Set([ "image/jpeg", "image/png", "image/gif", "image/webp", ]); /** Raw file size limit before base64 encoding (20 MB) */ const MAX_RAW_FILE_SIZE = 20 * 1024 * 1024; /** Maximum base64 payload size after encoding/resize (4.5 MB) */ const MAX_BASE64_PAYLOAD_SIZE = 4.5 * 1024 * 1024; /** Maximum image dimension (width or height) before resize triggers */ const MAX_DIMENSION = 2000; /** Maximum number of pending images per message */ export const MAX_PENDING_IMAGES = 5; // ─── Types ──────────────────────────────────────────────────────────────────── export interface PendingImage { /** Unique identifier for removal */ id: string; /** Base64-encoded image data (no data URI prefix) */ data: string; /** MIME type of the image */ mimeType: string; /** Blob URL for efficient thumbnail rendering — must be revoked on cleanup */ previewUrl: string; } // ─── Validation ─────────────────────────────────────────────────────────────── export function validateImageFile(file: File): { valid: boolean; error?: string; } { if (!ALLOWED_MIME_TYPES.has(file.type)) { const error = `Unsupported image type: ${file.type || "unknown"}. Accepted: JPEG, PNG, GIF, WebP.`; console.warn("[image-utils] validation failed:", error); return { valid: false, error }; } if (file.size > MAX_RAW_FILE_SIZE) { const sizeMB = (file.size / (1024 * 1024)).toFixed(1); const error = `Image too large (${sizeMB} MB). Maximum: 20 MB.`; console.warn("[image-utils] validation failed:", error); return { valid: false, error }; } return { valid: true }; } // ─── File Reading ───────────────────────────────────────────────────────────── export function readFileAsBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const arrayBuffer = reader.result as ArrayBuffer; const bytes = new Uint8Array(arrayBuffer); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } resolve(btoa(binary)); }; reader.onerror = () => reject(new Error("Failed to read image file")); reader.readAsArrayBuffer(file); }); } // ─── Resize ─────────────────────────────────────────────────────────────────── /** * Resize an image if its dimensions exceed MAX_DIMENSION or its payload exceeds * MAX_BASE64_PAYLOAD_SIZE. * * For GIF/WebP: skip resize if the base64 payload is already under the byte limit * (canvas strips animation frames). If over limit, convert to JPEG. * * Re-checks final payload size after resize; rejects if still over limit. */ export async function resizeImageInBrowser( base64: string, mimeType: string, ): Promise<{ data: string; mimeType: string }> { const payloadBytes = base64.length * 0.75; // approximate decoded size // For animated formats (GIF/WebP), preserve if under limit if ( (mimeType === "image/gif" || mimeType === "image/webp") && payloadBytes <= MAX_BASE64_PAYLOAD_SIZE ) { return { data: base64, mimeType }; } // Load image to check dimensions const img = await loadImage(base64, mimeType); const needsDimensionResize = img.width > MAX_DIMENSION || img.height > MAX_DIMENSION; const needsPayloadResize = payloadBytes > MAX_BASE64_PAYLOAD_SIZE; if (!needsDimensionResize && !needsPayloadResize) { return { data: base64, mimeType }; } // Determine output format — animated formats convert to JPEG when resized const outputMime = mimeType === "image/gif" || mimeType === "image/webp" ? "image/jpeg" : mimeType; // Calculate target dimensions let targetWidth = img.width; let targetHeight = img.height; if (needsDimensionResize) { const scale = Math.min( MAX_DIMENSION / img.width, MAX_DIMENSION / img.height, ); targetWidth = Math.round(img.width * scale); targetHeight = Math.round(img.height * scale); } // Canvas resize const canvas = document.createElement("canvas"); canvas.width = targetWidth; canvas.height = targetHeight; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas 2D context not available"); ctx.drawImage(img, 0, 0, targetWidth, targetHeight); // Encode — JPEG gets quality 0.85, PNG is lossless const quality = outputMime === "image/jpeg" ? 0.85 : undefined; const dataUrl = canvas.toDataURL(outputMime, quality); const resizedBase64 = dataUrl.split(",")[1]; // Re-check payload size const finalBytes = resizedBase64.length * 0.75; if (finalBytes > MAX_BASE64_PAYLOAD_SIZE) { throw new Error( `Image still exceeds 4.5 MB after resize (${(finalBytes / (1024 * 1024)).toFixed(1)} MB). Try a smaller image.`, ); } return { data: resizedBase64, mimeType: outputMime }; } // ─── Orchestrator ───────────────────────────────────────────────────────────── /** * Single entry point: validate → read → resize. * Used by both chat mode and terminal mode. */ export async function processImageFile( file: File, ): Promise<{ data: string; mimeType: string }> { const validation = validateImageFile(file); if (!validation.valid) { throw new Error(validation.error); } const base64 = await readFileAsBase64(file); return resizeImageInBrowser(base64, file.type); } // ─── Helpers ────────────────────────────────────────────────────────────────── function loadImage( base64: string, mimeType: string, ): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error("Failed to decode image")); img.src = `data:${mimeType};base64,${base64}`; }); } /** Generate a short unique ID for pending image tracking */ export function generateImageId(): string { return `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; }