const sharp = require('sharp'); const stream = require('stream'); /** * PRODUCTION OPTIMIZATION: * Force sharp to use 1 thread per image operation. * This is the recommended setting for web servers handling many concurrent requests. * It prevents thread starvation and memory fragmentation by letting Node's libuv * thread pool (UV_THREADPOOL_SIZE) handle concurrency at the request level instead. */ sharp.concurrency(1); class ImageProcessor { /** * Processes an image to CMYK TIFF format from a readable stream. * Accepts PNG, JPEG, WebP, AVIF, and TIFF inputs. * Outputs the highest-quality CMYK TIFF suitable for professional printing. * * @param {stream.Readable} input - The incoming stream of image data. * @param {Object} options - Processing options including quality. * @returns {Promise} - A readable stream of the processed CMYK TIFF image. */ processImage(input, options = {}) { return new Promise((resolve, reject) => { try { const { quality = 95 } = options; // Validate input stream if (!input || typeof input.pipe !== 'function') { throw new Error('Invalid input stream provided'); } let sharpInstance = sharp(); // Handle Sharp initialization errors sharpInstance.on('error', (error) => { reject(new Error(`Image processing failed: ${error.message}`)); }); const compressionLevel = Math.max(1, Math.min(9, Math.round(parseInt(quality) / 100 * 9))); /** * CMYK conversion pipeline: * - toColorspace('cmyk'): Converts from any input colorspace to CMYK. * - compression: 'deflate' provides lossless PNG-style compression. * - quality: 100 ensures no lossy degradation. * - predictor: 'horizontal' improves deflate compression ratios on photographic data. * * NOTE: The input can be WebP (lossy or lossless). Sharp's libvips will decode * WebP to full uncompressed pixel data before conversion, so there is ZERO quality * loss from the transport format — the TIFF output quality depends only on the * original render resolution (600 DPI), not the transport encoding. */ sharpInstance = sharpInstance .toColorspace('cmyk') .tiff({ compression: 'deflate', quality: 100, level: compressionLevel, predictor: 'horizontal' }); // Create transform stream for error handling const transformStream = new stream.PassThrough(); // Handle stream errors input.on('error', (error) => { reject(new Error(`Input stream error: ${error.message}`)); }); transformStream.on('error', (error) => { reject(new Error(`Processing stream error: ${error.message}`)); }); // Pipe through Sharp with error handling const processingStream = input.pipe(sharpInstance).pipe(transformStream); processingStream.on('error', (error) => { reject(new Error(`Pipeline error: ${error.message}`)); }); resolve(processingStream); } catch (error) { reject(new Error(`Setup error: ${error.message}`)); } }); } /** * Validates processing options * @param {Object} options - Processing options * @returns {Object} - Validated options */ validateOptions(options) { const { quality } = options; if (quality && isNaN(quality)) { throw new Error('Quality must be a number'); } const qualityValue = quality ? parseInt(quality) : 95; if (qualityValue < 1 || qualityValue > 100) { throw new Error('Quality must be between 1 and 100'); } return { quality: qualityValue }; } /** * Validates the input content type. * Now accepts image/webp for optimized frontend transfers. * @param {string} contentType - Request content type */ validateContentType(contentType) { if (!contentType) { throw new Error('Content-Type header is required'); } const supportedTypes = [ 'image/jpeg', 'image/jpg', 'image/png', 'image/tiff', 'image/webp', 'image/avif' ]; if (!supportedTypes.some(type => contentType.startsWith(type))) { throw new Error(`Unsupported image format. Supported: ${supportedTypes.join(', ')}`); } } } module.exports = new ImageProcessor();