UNPKG

@ideal-photography/shared

Version:

Shared MongoDB and utility logic for Ideal Photography PWAs: users, products, services, bookings, orders/cart, galleries, reviews, notifications, campaigns, settings, audit logs, minimart items/orders, and push notification subscriptions.

546 lines (474 loc) 18.1 kB
/** * Image optimization utilities for preparing images for upload * Handles compression, resizing, format conversion, and metadata stripping */ /** * Image optimization configuration presets */ export const OPTIMIZATION_PRESETS = { product: { maxWidth: 1200, maxHeight: 1200, quality: 85, format: 'auto', stripMetadata: true, progressive: true }, thumbnail: { maxWidth: 300, maxHeight: 300, quality: 80, format: 'auto', stripMetadata: true, progressive: false }, banner: { maxWidth: 1920, maxHeight: 800, quality: 90, format: 'auto', stripMetadata: true, progressive: true }, highQuality: { maxWidth: 2000, maxHeight: 2000, quality: 95, format: 'auto', stripMetadata: false, progressive: true }, webOptimized: { maxWidth: 800, maxHeight: 800, quality: 75, format: 'webp', stripMetadata: true, progressive: true } }; /** * Calculate optimal dimensions while maintaining aspect ratio * @param {Object} currentDimensions - Current width and height * @param {Object} maxDimensions - Maximum allowed width and height * @returns {Object} Optimized dimensions */ export const calculateOptimalDimensions = (currentDimensions, maxDimensions) => { const { width: currentWidth, height: currentHeight } = currentDimensions; const { maxWidth, maxHeight } = maxDimensions; // If image is already within limits, return current dimensions if (currentWidth <= maxWidth && currentHeight <= maxHeight) { return { width: currentWidth, height: currentHeight, needsResize: false }; } // Calculate aspect ratio const aspectRatio = currentWidth / currentHeight; let newWidth, newHeight; // Determine which dimension is the limiting factor if (currentWidth / maxWidth > currentHeight / maxHeight) { // Width is the limiting factor newWidth = maxWidth; newHeight = Math.round(maxWidth / aspectRatio); } else { // Height is the limiting factor newHeight = maxHeight; newWidth = Math.round(maxHeight * aspectRatio); } // Ensure dimensions are even numbers (better for video encoding if needed) newWidth = newWidth % 2 === 0 ? newWidth : newWidth - 1; newHeight = newHeight % 2 === 0 ? newHeight : newHeight - 1; return { width: newWidth, height: newHeight, needsResize: true, originalWidth: currentWidth, originalHeight: currentHeight, aspectRatio, reductionRatio: (newWidth * newHeight) / (currentWidth * currentHeight) }; }; /** * Estimate file size after optimization * @param {Object} dimensions - Image dimensions * @param {Object} options - Optimization options * @returns {Object} Size estimation */ export const estimateOptimizedSize = (dimensions, options = {}) => { const { width, height } = dimensions; const { quality = 85, format = 'jpeg', hasTransparency = false } = options; const pixels = width * height; let bytesPerPixel; switch (format.toLowerCase()) { case 'jpeg': case 'jpg': // JPEG compression estimates if (quality >= 95) bytesPerPixel = 3; else if (quality >= 85) bytesPerPixel = 1.5; else if (quality >= 75) bytesPerPixel = 1; else if (quality >= 60) bytesPerPixel = 0.7; else bytesPerPixel = 0.5; break; case 'png': // PNG is lossless, size depends on complexity and transparency bytesPerPixel = hasTransparency ? 4 : 3; break; case 'webp': // WebP is generally 25-35% smaller than JPEG if (quality >= 95) bytesPerPixel = 2; else if (quality >= 85) bytesPerPixel = 1; else if (quality >= 75) bytesPerPixel = 0.7; else bytesPerPixel = 0.4; break; default: bytesPerPixel = 2; // Conservative estimate } const estimatedSize = Math.round(pixels * bytesPerPixel); return { estimatedBytes: estimatedSize, estimatedMB: estimatedSize / (1024 * 1024), bytesPerPixel, compressionRatio: format === 'png' ? 1 : (100 - quality) / 100 }; }; /** * Generate optimization recommendations based on image analysis * @param {Object} imageInfo - Image information (dimensions, size, format) * @param {string} useCase - Intended use case * @returns {Object} Optimization recommendations */ export const generateOptimizationRecommendations = (imageInfo, useCase = 'product') => { const { width, height, size, format } = imageInfo; const recommendations = []; const warnings = []; const currentSizeMB = size / (1024 * 1024); const pixels = width * height; // Get preset for use case const preset = OPTIMIZATION_PRESETS[useCase] || OPTIMIZATION_PRESETS.product; // Check if resizing is needed const dimensionCalc = calculateOptimalDimensions( { width, height }, { maxWidth: preset.maxWidth, maxHeight: preset.maxHeight } ); if (dimensionCalc.needsResize) { const sizeReduction = ((1 - dimensionCalc.reductionRatio) * 100).toFixed(1); recommendations.push({ type: 'resize', description: `Resize from ${width}x${height} to ${dimensionCalc.width}x${dimensionCalc.height}`, benefit: `Reduce file size by approximately ${sizeReduction}%`, newDimensions: { width: dimensionCalc.width, height: dimensionCalc.height } }); } // Format recommendations if (format === 'png' && currentSizeMB > 2) { recommendations.push({ type: 'format', description: 'Convert PNG to JPEG for better compression', benefit: 'Reduce file size by 50-70% for photographic content', suggestedFormat: 'jpeg' }); } if (format === 'jpeg' && currentSizeMB > 5) { recommendations.push({ type: 'quality', description: 'Reduce JPEG quality to 85%', benefit: 'Reduce file size while maintaining good visual quality', suggestedQuality: 85 }); } // WebP recommendations if (!['webp'].includes(format) && currentSizeMB > 1) { recommendations.push({ type: 'format', description: 'Consider WebP format for modern browsers', benefit: 'Reduce file size by 25-35% compared to JPEG', suggestedFormat: 'webp', note: 'Ensure browser compatibility or provide fallback' }); } // Progressive JPEG for large images if (format === 'jpeg' && pixels > 500000) { // ~700x700 recommendations.push({ type: 'progressive', description: 'Use progressive JPEG encoding', benefit: 'Improve perceived loading performance', technical: 'Image loads in progressive passes' }); } // Size warnings if (currentSizeMB > 10) { warnings.push({ type: 'size', message: `Very large file size (${currentSizeMB.toFixed(2)}MB)`, impact: 'May cause slow loading and poor user experience' }); } if (pixels > 4000 * 4000) { warnings.push({ type: 'dimensions', message: `Very high resolution (${width}x${height})`, impact: 'May be unnecessary for web display and cause performance issues' }); } // Calculate potential savings let estimatedSavings = 0; if (dimensionCalc.needsResize) { estimatedSavings += (1 - dimensionCalc.reductionRatio) * 100; } if (format === 'png' && currentSizeMB > 2) { estimatedSavings += 60; // Rough estimate for PNG to JPEG conversion } return { recommendations, warnings, currentSize: { bytes: size, mb: currentSizeMB, dimensions: { width, height }, format }, estimatedSavings: Math.min(estimatedSavings, 90), // Cap at 90% preset: preset, priority: currentSizeMB > 5 ? 'high' : currentSizeMB > 2 ? 'medium' : 'low' }; }; /** * Client-side image compression using Canvas API * @param {File} file - Image file * @param {Object} options - Compression options * @returns {Promise<Blob>} Compressed image blob */ export const compressImageClientSide = async (file, options = {}) => { const { maxWidth = 1200, maxHeight = 1200, quality = 0.85, format = 'image/jpeg', stripMetadata = true } = options; return new Promise((resolve, reject) => { try { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const img = new Image(); img.onload = () => { try { // Calculate optimal dimensions const dimensions = calculateOptimalDimensions( { width: img.width, height: img.height }, { maxWidth, maxHeight } ); // Set canvas dimensions canvas.width = dimensions.width; canvas.height = dimensions.height; // Enable image smoothing for better quality ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; // Draw and compress ctx.drawImage(img, 0, 0, dimensions.width, dimensions.height); // Convert to blob canvas.toBlob( (blob) => { // Clean up object URL to prevent memory leak URL.revokeObjectURL(img.src); if (blob) { resolve(blob); } else { reject(new Error('Failed to compress image')); } }, format, quality ); } catch (error) { // Clean up object URL on error URL.revokeObjectURL(img.src); reject(new Error(`Canvas processing error: ${error.message}`)); } }; img.onerror = () => { // Clean up object URL on error URL.revokeObjectURL(img.src); reject(new Error('Failed to load image for compression')); }; // Load image const objectUrl = URL.createObjectURL(file); img.src = objectUrl; } catch (error) { reject(new Error(`Client-side compression error: ${error.message}`)); } }); }; /** * Prepare image for upload with optimal settings * @param {File} file - Image file * @param {string} useCase - Use case for optimization * @param {Object} customOptions - Custom optimization options * @returns {Promise<Object>} Optimization result */ export const prepareImageForUpload = async (file, useCase = 'product', customOptions = {}) => { try { // Get preset configuration const preset = OPTIMIZATION_PRESETS[useCase] || OPTIMIZATION_PRESETS.product; const options = { ...preset, ...customOptions }; // Get image dimensions (if available) const imageInfo = await getImageInfo(file); // Generate recommendations const recommendations = generateOptimizationRecommendations( { ...imageInfo, size: file.size, format: file.type }, useCase ); let optimizedFile = file; let wasOptimized = false; const optimizations = []; // Apply client-side optimization if needed and supported if (typeof document !== 'undefined' && recommendations.priority !== 'low') { try { const compressed = await compressImageClientSide(file, { maxWidth: options.maxWidth, maxHeight: options.maxHeight, quality: options.quality / 100, format: options.format === 'auto' ? file.type : `image/${options.format}`, stripMetadata: options.stripMetadata }); // Only use compressed version if it's actually smaller if (compressed.size < file.size) { optimizedFile = new File([compressed], file.name, { type: compressed.type, lastModified: Date.now() }); wasOptimized = true; optimizations.push('client-side-compression'); } } catch (compressionError) { console.warn('Client-side compression failed:', compressionError.message); // Continue with original file } } return { originalFile: file, optimizedFile, wasOptimized, optimizations, sizeBefore: file.size, sizeAfter: optimizedFile.size, sizeReduction: file.size > 0 ? ((file.size - optimizedFile.size) / file.size * 100) : 0, recommendations, preset: options, imageInfo }; } catch (error) { console.error('Error preparing image for upload:', error); return { originalFile: file, optimizedFile: file, wasOptimized: false, optimizations: [], sizeBefore: file.size, sizeAfter: file.size, sizeReduction: 0, recommendations: { recommendations: [], warnings: [], priority: 'low' }, error: error.message }; } }; /** * Get image information from file * @param {File} file - Image file * @returns {Promise<Object>} Image information */ export const getImageInfo = (file) => { return new Promise((resolve) => { if (typeof document === 'undefined') { // Server-side or no DOM available resolve({ width: null, height: null, aspectRatio: null }); return; } const img = new Image(); let objectUrl; img.onload = () => { URL.revokeObjectURL(objectUrl); resolve({ width: img.width, height: img.height, aspectRatio: img.width / img.height }); }; img.onerror = () => { URL.revokeObjectURL(objectUrl); resolve({ width: null, height: null, aspectRatio: null }); }; objectUrl = URL.createObjectURL(file); img.src = objectUrl; }); }; /** * Batch optimize multiple images * @param {Array} files - Array of image files * @param {string} useCase - Use case for optimization * @param {Object} options - Optimization options * @returns {Promise<Array>} Array of optimization results */ export const batchOptimizeImages = async (files, useCase = 'product', options = {}) => { const { maxConcurrent = 3, onProgress } = options; const results = []; const batches = []; // Split files into batches for (let i = 0; i < files.length; i += maxConcurrent) { batches.push(files.slice(i, i + maxConcurrent)); } let completed = 0; for (const batch of batches) { const batchPromises = batch.map(async (file) => { try { const result = await prepareImageForUpload(file, useCase, options); completed++; if (onProgress) { onProgress({ completed, total: files.length, progress: (completed / files.length) * 100, currentFile: file.name }); } return result; } catch (error) { completed++; if (onProgress) { onProgress({ completed, total: files.length, progress: (completed / files.length) * 100, currentFile: file.name, error: error.message }); } return { originalFile: file, optimizedFile: file, wasOptimized: false, error: error.message }; } }); const batchResults = await Promise.all(batchPromises); results.push(...batchResults); // Small delay between batches to prevent overwhelming the browser if (batches.indexOf(batch) < batches.length - 1) { await new Promise(resolve => setTimeout(resolve, 50)); } } return results; }; export default { OPTIMIZATION_PRESETS, calculateOptimalDimensions, estimateOptimizedSize, generateOptimizationRecommendations, compressImageClientSide, prepareImageForUpload, getImageInfo, batchOptimizeImages };