/** * 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)}` }