@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
JavaScript
/**
* 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
};