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.

490 lines (425 loc) 17 kB
/** * Image validation utilities for product images * Provides comprehensive validation beyond basic file checks */ /** * Validate image dimensions for specific use cases * @param {Object} dimensions - Image dimensions {width, height} * @param {string} useCase - Use case ('product', 'thumbnail', 'banner', 'avatar') * @returns {Object} Validation result */ export const validateImageDimensions = (dimensions, useCase = 'product') => { const { width, height } = dimensions; const requirements = { product: { minWidth: 300, minHeight: 300, maxWidth: 4000, maxHeight: 4000, preferredAspectRatio: 1, // 1:1 aspectRatioTolerance: 0.3 }, thumbnail: { minWidth: 150, minHeight: 150, maxWidth: 500, maxHeight: 500, preferredAspectRatio: 1, aspectRatioTolerance: 0.1 }, banner: { minWidth: 800, minHeight: 300, maxWidth: 2000, maxHeight: 800, preferredAspectRatio: 2.5, // 5:2 aspectRatioTolerance: 0.5 }, avatar: { minWidth: 100, minHeight: 100, maxWidth: 1000, maxHeight: 1000, preferredAspectRatio: 1, aspectRatioTolerance: 0.05 } }; const req = requirements[useCase] || requirements.product; const errors = []; const warnings = []; // Check minimum dimensions if (width < req.minWidth || height < req.minHeight) { errors.push(`Image too small: ${width}x${height}. Minimum required: ${req.minWidth}x${req.minHeight}`); } // Check maximum dimensions if (width > req.maxWidth || height > req.maxHeight) { warnings.push(`Image very large: ${width}x${height}. Consider resizing to improve performance. Maximum recommended: ${req.maxWidth}x${req.maxHeight}`); } // Check aspect ratio const aspectRatio = width / height; const aspectRatioDiff = Math.abs(aspectRatio - req.preferredAspectRatio); if (aspectRatioDiff > req.aspectRatioTolerance) { const preferredRatio = req.preferredAspectRatio === 1 ? '1:1 (square)' : `${req.preferredAspectRatio}:1`; warnings.push(`Aspect ratio ${aspectRatio.toFixed(2)}:1 differs from preferred ${preferredRatio} for ${useCase} images`); } // Check for unusual dimensions if (width % 2 !== 0 || height % 2 !== 0) { warnings.push('Odd pixel dimensions may cause issues with some image processing. Consider even dimensions.'); } // Check for extremely wide or tall images if (aspectRatio > 5 || aspectRatio < 0.2) { warnings.push(`Extreme aspect ratio (${aspectRatio.toFixed(2)}:1) may not display well in all contexts`); } return { isValid: errors.length === 0, errors, warnings, aspectRatio, useCase, recommendations: generateDimensionRecommendations(dimensions, req) }; }; /** * Generate dimension recommendations * @param {Object} dimensions - Current dimensions * @param {Object} requirements - Dimension requirements * @returns {Array} Array of recommendations */ const generateDimensionRecommendations = (dimensions, requirements) => { const { width, height } = dimensions; const { preferredAspectRatio, minWidth, minHeight } = requirements; const recommendations = []; // Recommend optimal dimensions if (width < minWidth * 2 || height < minHeight * 2) { const optimalWidth = Math.max(minWidth * 2, 600); const optimalHeight = Math.max(minHeight * 2, 600); recommendations.push(`For best quality, use images at least ${optimalWidth}x${optimalHeight}`); } // Recommend aspect ratio adjustment const currentRatio = width / height; if (Math.abs(currentRatio - preferredAspectRatio) > 0.1) { if (preferredAspectRatio === 1) { const newDimension = Math.min(width, height); recommendations.push(`For square format, crop to ${newDimension}x${newDimension}`); } else { const newHeight = Math.round(width / preferredAspectRatio); recommendations.push(`For optimal aspect ratio, resize to ${width}x${newHeight}`); } } return recommendations; }; /** * Validate image file format and quality * @param {Object} file - File object with metadata * @param {Object} options - Validation options * @returns {Object} Validation result */ export const validateImageFormat = (file, options = {}) => { const { allowedFormats = ['image/jpeg', 'image/png', 'image/webp'], preferredFormat = 'image/jpeg', maxQuality = 95, minQuality = 60 } = options; const errors = []; const warnings = []; const recommendations = []; // Check if format is allowed if (!allowedFormats.includes(file.mimetype)) { errors.push(`Format ${file.mimetype} not allowed. Allowed formats: ${allowedFormats.join(', ')}`); } // Format-specific recommendations if (file.mimetype === 'image/png' && file.size > 2 * 1024 * 1024) { recommendations.push('PNG files are large. Consider JPEG for photographs or WebP for better compression'); } if (file.mimetype === 'image/jpeg') { // Try to estimate JPEG quality (this is approximate) const estimatedQuality = estimateJpegQuality(file); if (estimatedQuality && estimatedQuality < minQuality) { warnings.push(`JPEG quality appears low (estimated ${estimatedQuality}%). Consider using higher quality images.`); } if (estimatedQuality && estimatedQuality > maxQuality) { recommendations.push(`JPEG quality is very high (estimated ${estimatedQuality}%). You can reduce file size by using 85-90% quality.`); } } // Format recommendations based on content if (file.mimetype === 'image/png' && !hasTransparency(file)) { recommendations.push('PNG without transparency detected. JPEG would be more efficient for this image.'); } return { isValid: errors.length === 0, errors, warnings, recommendations, detectedFormat: file.mimetype, preferredFormat }; }; /** * Estimate JPEG quality (approximate) * @param {Object} file - File object * @returns {number|null} Estimated quality percentage */ const estimateJpegQuality = (file) => { // This is a rough estimation based on file size vs dimensions // More accurate methods would require reading JPEG headers if (!file.width || !file.height || !file.size) { return null; } const pixels = file.width * file.height; const bytesPerPixel = file.size / pixels; // Very rough estimation if (bytesPerPixel < 0.5) return 60; if (bytesPerPixel < 1) return 75; if (bytesPerPixel < 2) return 85; if (bytesPerPixel < 3) return 95; return 100; }; /** * Check if PNG has transparency (placeholder - would need actual image analysis) * @param {Object} file - File object * @returns {boolean} Whether image has transparency */ const hasTransparency = (file) => { // Placeholder - in real implementation, you'd analyze the PNG file // For now, assume all PNGs might have transparency return file.mimetype === 'image/png'; }; /** * Validate image content for appropriateness * @param {Object} file - File object * @param {Object} options - Validation options * @returns {Promise<Object>} Validation result */ export const validateImageContent = async (file, options = {}) => { const { checkForFaces = false, checkForText = false, checkBrightness = true, checkContrast = true, checkBlur = true } = options; // This is a placeholder for content validation // In a real implementation, you might use: // - Computer vision APIs (Google Vision, AWS Rekognition, Azure Computer Vision) // - Machine learning models for content analysis // - Image analysis libraries const warnings = []; const recommendations = []; try { // Placeholder validations if (checkBrightness) { // Simulate brightness check const brightness = Math.random() * 100; if (brightness < 20) { warnings.push('Image appears very dark. Consider brightening for better visibility.'); } else if (brightness > 90) { warnings.push('Image appears very bright. Consider reducing brightness to avoid overexposure.'); } } if (checkContrast) { // Simulate contrast check const contrast = Math.random() * 100; if (contrast < 30) { recommendations.push('Low contrast detected. Increasing contrast may improve image clarity.'); } } if (checkBlur) { // Simulate blur detection const sharpness = Math.random() * 100; if (sharpness < 40) { warnings.push('Image appears blurry. Sharp, clear images work best for product display.'); } } return { isValid: true, // Content issues are typically warnings, not errors errors: [], warnings, recommendations, analysis: { brightness: checkBrightness ? Math.random() * 100 : null, contrast: checkContrast ? Math.random() * 100 : null, sharpness: checkBlur ? Math.random() * 100 : null, hasFaces: checkForFaces ? Math.random() > 0.7 : null, hasText: checkForText ? Math.random() > 0.5 : null } }; } catch (error) { return { isValid: true, // Don't fail upload for content analysis errors errors: [], warnings: [`Content analysis failed: ${error.message}`], recommendations: [], analysis: null }; } }; /** * Comprehensive image validation combining all checks * @param {Object} file - File object with metadata * @param {Object} options - Validation options * @returns {Promise<Object>} Complete validation result */ export const validateImageComprehensive = async (file, options = {}) => { const { useCase = 'product', checkContent = false, maxFileSize = 5 * 1024 * 1024, // 5MB ...otherOptions } = options; const results = { isValid: true, errors: [], warnings: [], recommendations: [], checks: {} }; try { // Basic file validation if (!file) { results.errors.push('No file provided'); results.isValid = false; return results; } // File size check if (file.size > maxFileSize) { results.errors.push(`File size (${(file.size / 1024 / 1024).toFixed(2)}MB) exceeds maximum (${maxFileSize / 1024 / 1024}MB)`); results.isValid = false; } // Dimension validation if (file.width && file.height) { const dimensionCheck = validateImageDimensions( { width: file.width, height: file.height }, useCase ); results.checks.dimensions = dimensionCheck; if (!dimensionCheck.isValid) { results.isValid = false; } results.errors.push(...dimensionCheck.errors); results.warnings.push(...dimensionCheck.warnings); results.recommendations.push(...dimensionCheck.recommendations); } // Format validation const formatCheck = validateImageFormat(file, otherOptions); results.checks.format = formatCheck; if (!formatCheck.isValid) { results.isValid = false; } results.errors.push(...formatCheck.errors); results.warnings.push(...formatCheck.warnings); results.recommendations.push(...formatCheck.recommendations); // Content validation (optional) if (checkContent) { const contentCheck = await validateImageContent(file, otherOptions); results.checks.content = contentCheck; // Content issues are typically warnings, not blocking errors results.warnings.push(...contentCheck.warnings); results.recommendations.push(...contentCheck.recommendations); } // Additional business logic validations if (file.originalname) { const filename = file.originalname.toLowerCase(); // Check for inappropriate filenames const inappropriateKeywords = ['temp', 'tmp', 'test', 'untitled', 'screenshot']; if (inappropriateKeywords.some(keyword => filename.includes(keyword))) { results.warnings.push('Filename suggests this might be a temporary or test image. Consider using a descriptive filename.'); } // Check filename length if (filename.length > 100) { results.warnings.push('Very long filename detected. Consider shortening for better compatibility.'); } } // Performance recommendations if (file.size > 1024 * 1024) { // 1MB results.recommendations.push('Large file size detected. Consider optimizing the image to improve loading times.'); } return results; } catch (error) { console.error('Error in comprehensive image validation:', error); return { isValid: false, errors: [`Validation failed: ${error.message}`], warnings: [], recommendations: [], checks: {} }; } }; /** * Batch validate multiple images * @param {Array} files - Array of file objects * @param {Object} options - Validation options * @returns {Promise<Object>} Batch validation results */ export const validateImagesBatch = async (files, options = {}) => { if (!Array.isArray(files) || files.length === 0) { return { isValid: false, errors: ['No files provided for validation'], results: [], summary: { total: 0, valid: 0, invalid: 0, warnings: 0 } }; } const results = []; const batchErrors = []; let validCount = 0; let warningCount = 0; // Validate each file for (let i = 0; i < files.length; i++) { try { const file = files[i]; const validation = await validateImageComprehensive(file, { ...options, fileIndex: i }); validation.fileIndex = i; validation.fileName = file.originalname || file.name || `file_${i}`; results.push(validation); if (validation.isValid) { validCount++; } if (validation.warnings.length > 0) { warningCount++; } } catch (error) { console.error(`Error validating file ${i}:`, error); results.push({ fileIndex: i, fileName: files[i]?.originalname || files[i]?.name || `file_${i}`, isValid: false, errors: [`Validation error: ${error.message}`], warnings: [], recommendations: [], checks: {} }); } } // Check batch-level constraints const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0); const maxBatchSize = options.maxBatchSize || 15 * 1024 * 1024; // 15MB total if (totalSize > maxBatchSize) { batchErrors.push(`Total batch size (${(totalSize / 1024 / 1024).toFixed(2)}MB) exceeds maximum (${maxBatchSize / 1024 / 1024}MB)`); } const isValid = validCount === files.length && batchErrors.length === 0; return { isValid, errors: batchErrors, results, summary: { total: files.length, valid: validCount, invalid: files.length - validCount, warnings: warningCount, totalSize, averageSize: Math.round(totalSize / files.length) } }; }; export default { validateImageDimensions, validateImageFormat, validateImageContent, validateImageComprehensive, validateImagesBatch };