File size: 5,123 Bytes
bc5c9ee
 
 
970f7c4
 
 
 
 
 
 
 
 
bc5c9ee
 
 
970f7c4
 
 
bc5c9ee
970f7c4
 
bc5c9ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
970f7c4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc5c9ee
 
 
970f7c4
 
 
 
bc5c9ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
970f7c4
 
bc5c9ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
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<stream.Readable>} - 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();