/** * Image processing utility functions */ // Image compression configuration const COMPRESSION_CONFIG = { DEFAULT_QUALITY: 0.8, // Default JPEG quality (0-1) HIGH_QUALITY: 0.9, // High quality setting MEDIUM_QUALITY: 0.7, // Medium quality setting LOW_QUALITY: 0.5, // Low quality setting DEFAULT_FORMAT: 'image/jpeg' as const, // Default compression format SUPPORTED_FORMATS: ['image/jpeg', 'image/webp', 'image/png'] as const, MAX_FILE_SIZE_KB: 2048, // Default maximum file size in KB (2MB) COMPRESSION_RETRY_STEPS: [0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3], // Quality steps for size optimization BASE64_OVERHEAD_FACTOR: 1.37, // Base64 encoding increases size by ~37% } as const; // Compression options interface export interface CompressionOptions { enabled?: boolean; // Enable compression (default: true) quality?: number; // Compression quality 0-1 (default: 0.8) format?: 'image/jpeg' | 'image/webp' | 'image/png'; // Output format (default: 'image/jpeg') maxFileSizeKB?: number; // Maximum file size in KB (default: 2048) } // Compression result interface export interface CompressionResult { dataUrl: string; // Compressed image data URL originalSizeKB: number; // Original size in KB compressedSizeKB: number; // Compressed size in KB compressionRatio: number; // Compression ratio (0-1, lower is better compression) format: string; // Final format used quality: number; // Final quality used } /** * Create ImageBitmap from data URL (for OffscreenCanvas) * @param dataUrl Image data URL * @returns Created ImageBitmap object */ export async function createImageBitmapFromUrl(dataUrl: string): Promise { const response = await fetch(dataUrl); const blob = await response.blob(); return await createImageBitmap(blob); } /** * Stitch multiple image parts (dataURL) onto a single canvas * @param parts Array of image parts, each containing dataUrl and y coordinate * @param totalWidthPx Total width (pixels) * @param totalHeightPx Total height (pixels) * @returns Stitched canvas */ export async function stitchImages( parts: { dataUrl: string; y: number }[], totalWidthPx: number, totalHeightPx: number, ): Promise { const canvas = new OffscreenCanvas(totalWidthPx, totalHeightPx); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Unable to get canvas context'); } ctx.fillStyle = '#FFFFFF'; ctx.fillRect(0, 0, canvas.width, canvas.height); for (const part of parts) { try { const img = await createImageBitmapFromUrl(part.dataUrl); const sx = 0; const sy = 0; const sWidth = img.width; let sHeight = img.height; const dy = part.y; if (dy + sHeight > totalHeightPx) { sHeight = totalHeightPx - dy; } if (sHeight <= 0) continue; ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, dy, sWidth, sHeight); } catch (error) { console.error('Error stitching image part:', error, part); } } return canvas; } /** * Crop image (from dataURL) to specified rectangle and resize * @param originalDataUrl Original image data URL * @param cropRectPx Crop rectangle (physical pixels) * @param dpr Device pixel ratio * @param targetWidthOpt Optional target output width (CSS pixels) * @param targetHeightOpt Optional target output height (CSS pixels) * @returns Cropped canvas */ export async function cropAndResizeImage( originalDataUrl: string, cropRectPx: { x: number; y: number; width: number; height: number }, dpr: number = 1, targetWidthOpt?: number, targetHeightOpt?: number, ): Promise { const img = await createImageBitmapFromUrl(originalDataUrl); let sx = cropRectPx.x; let sy = cropRectPx.y; let sWidth = cropRectPx.width; let sHeight = cropRectPx.height; // Ensure crop area is within image boundaries if (sx < 0) { sWidth += sx; sx = 0; } if (sy < 0) { sHeight += sy; sy = 0; } if (sx + sWidth > img.width) { sWidth = img.width - sx; } if (sy + sHeight > img.height) { sHeight = img.height - sy; } if (sWidth <= 0 || sHeight <= 0) { throw new Error( 'Invalid calculated crop size (<=0). Element may not be visible or fully captured.', ); } const finalCanvasWidthPx = targetWidthOpt ? targetWidthOpt * dpr : sWidth; const finalCanvasHeightPx = targetHeightOpt ? targetHeightOpt * dpr : sHeight; const canvas = new OffscreenCanvas(finalCanvasWidthPx, finalCanvasHeightPx); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Unable to get canvas context'); } ctx.drawImage(img, sx, sy, sWidth, sHeight, 0, 0, finalCanvasWidthPx, finalCanvasHeightPx); return canvas; } /** * Convert canvas to data URL * @param canvas Canvas * @param format Image format * @param quality JPEG quality (0-1) * @returns Data URL */ export async function canvasToDataURL( canvas: OffscreenCanvas, format: string = 'image/png', quality?: number, ): Promise { const blob = await canvas.convertToBlob({ type: format, quality: format === 'image/jpeg' ? quality : undefined, }); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); } /** * Estimate base64 data size in KB * @param dataUrl Base64 data URL * @returns Size in KB */ function estimateBase64SizeKB(dataUrl: string): number { // Remove data URL prefix and calculate size const base64Data = dataUrl.split(',')[1] || dataUrl; const sizeBytes = (base64Data.length * 3) / 4; // Base64 to bytes conversion return sizeBytes / 1024; // Convert to KB } /** * Compress image with automatic quality adjustment to meet size target * @param canvas Source canvas * @param options Compression options * @returns Compression result with statistics */ export async function compressImage( canvas: OffscreenCanvas, options: CompressionOptions = {}, ): Promise { const { enabled = true, quality = COMPRESSION_CONFIG.DEFAULT_QUALITY, format = COMPRESSION_CONFIG.DEFAULT_FORMAT, maxFileSizeKB = COMPRESSION_CONFIG.MAX_FILE_SIZE_KB, } = options; // If compression is disabled, return original as PNG if (!enabled) { const originalDataUrl = await canvasToDataURL(canvas, 'image/png'); const sizeKB = estimateBase64SizeKB(originalDataUrl); return { dataUrl: originalDataUrl, originalSizeKB: sizeKB, compressedSizeKB: sizeKB, compressionRatio: 1.0, format: 'image/png', quality: 1.0, }; } // Get original size for comparison const originalDataUrl = await canvasToDataURL(canvas, 'image/png'); const originalSizeKB = estimateBase64SizeKB(originalDataUrl); let bestDataUrl = originalDataUrl; let bestSizeKB = originalSizeKB; let bestQuality = 1.0; let finalFormat = format; // Try compression with specified format and quality if (format !== 'image/png') { const compressedDataUrl = await canvasToDataURL(canvas, format, quality); const compressedSizeKB = estimateBase64SizeKB(compressedDataUrl); if (compressedSizeKB <= maxFileSizeKB) { // Target size achieved with specified quality bestDataUrl = compressedDataUrl; bestSizeKB = compressedSizeKB; bestQuality = quality; } else { // Need to reduce quality to meet size target for (const testQuality of COMPRESSION_CONFIG.COMPRESSION_RETRY_STEPS) { const testDataUrl = await canvasToDataURL(canvas, format, testQuality); const testSizeKB = estimateBase64SizeKB(testDataUrl); if (testSizeKB <= maxFileSizeKB) { bestDataUrl = testDataUrl; bestSizeKB = testSizeKB; bestQuality = testQuality; break; } } } } // If still too large, try WebP format (if not already using it) if (bestSizeKB > maxFileSizeKB && format !== 'image/webp') { for (const testQuality of COMPRESSION_CONFIG.COMPRESSION_RETRY_STEPS) { const testDataUrl = await canvasToDataURL(canvas, 'image/webp', testQuality); const testSizeKB = estimateBase64SizeKB(testDataUrl); if (testSizeKB <= maxFileSizeKB) { bestDataUrl = testDataUrl; bestSizeKB = testSizeKB; bestQuality = testQuality; finalFormat = 'image/webp'; break; } } } return { dataUrl: bestDataUrl, originalSizeKB, compressedSizeKB: bestSizeKB, compressionRatio: bestSizeKB / originalSizeKB, format: finalFormat, quality: bestQuality, }; } /** * Resize image canvas by a scale factor * @param canvas Source canvas * @param scaleFactor Scale factor (e.g., 0.7 for 70% size) * @returns Resized canvas */ export function resizeCanvas(canvas: OffscreenCanvas, scaleFactor: number): OffscreenCanvas { const newWidth = Math.round(canvas.width * scaleFactor); const newHeight = Math.round(canvas.height * scaleFactor); const resizedCanvas = new OffscreenCanvas(newWidth, newHeight); const ctx = resizedCanvas.getContext('2d'); if (!ctx) { throw new Error('Unable to get canvas context for resizing'); } // Use high-quality scaling ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(canvas, 0, 0, newWidth, newHeight); return resizedCanvas; } /** * Compress image from data URL with optional scaling * @param dataUrl Source image data URL * @param options Compression options * @param scaleFactor Optional scale factor to apply before compression (e.g., 0.7 for 70% size) * @returns Compression result with statistics */ export async function compressImageFromDataUrl( dataUrl: string, options: CompressionOptions = {}, scaleFactor?: number, ): Promise { // Create canvas from data URL const img = await createImageBitmapFromUrl(dataUrl); let canvas = new OffscreenCanvas(img.width, img.height); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Unable to get canvas context for compression'); } ctx.drawImage(img, 0, 0); // Apply scaling if specified if (scaleFactor && scaleFactor !== 1.0) { canvas = resizeCanvas(canvas, scaleFactor); } // Compress the canvas return compressImage(canvas, options); }