diff --git a/src/modules/uploader/providers/image-processing.service.ts b/src/modules/uploader/providers/image-processing.service.ts index 936cdb7..479fff8 100644 --- a/src/modules/uploader/providers/image-processing.service.ts +++ b/src/modules/uploader/providers/image-processing.service.ts @@ -17,6 +17,9 @@ const RASTER_IMAGE_MIME_TYPES = new Set([ 'image/bmp', ]); +const MIN_WEBP_QUALITY = 50; +const WEBP_EFFORT = 4; + @Injectable() export class ImageProcessingService { private readonly logger = new Logger(ImageProcessingService.name); @@ -24,12 +27,15 @@ export class ImageProcessingService { private readonly maxHeight: number; private readonly maxOutputBytes: number; private readonly webpQuality: number; + private readonly skipImageProcessing: boolean; constructor(private readonly configService: ConfigService) { this.maxWidth = this.configService.get('IMAGE_MAX_WIDTH', 1920); this.maxHeight = this.configService.get('IMAGE_MAX_HEIGHT', 1920); this.maxOutputBytes = this.configService.get('IMAGE_MAX_SIZE', 2 * 1024 * 1024); this.webpQuality = this.configService.get('IMAGE_WEBP_QUALITY', 80); + this.skipImageProcessing = + this.configService.get('SKIP_IMAGE_PROCESSING', 'false').toLowerCase() === 'true'; } shouldProcess(mimetype: string): boolean { @@ -41,41 +47,39 @@ export class ImageProcessingService { return null; } + if (this.skipImageProcessing) { + this.logger.debug('Image processing skipped (SKIP_IMAGE_PROCESSING=true)'); + return null; + } + try { - const source = sharp(buffer, { animated: mimetype === 'image/gif' }).rotate(); - const metadata = await source.metadata(); + const animated = mimetype === 'image/gif'; + const metadata = await sharp(buffer, { animated }).metadata(); - let targetWidth = metadata.width; - let targetHeight = metadata.height; - - if (targetWidth && targetHeight && (targetWidth > this.maxWidth || targetHeight > this.maxHeight)) { - const scale = Math.min(this.maxWidth / targetWidth, this.maxHeight / targetHeight); - targetWidth = Math.round(targetWidth * scale); - targetHeight = Math.round(targetHeight * scale); + if (await this.shouldSkipAsWebp(buffer, mimetype, metadata)) { + this.logger.debug( + `Image processing skipped: webp within limits (${metadata.width}x${metadata.height}, ${buffer.length} bytes)`, + ); + return null; } + const targetDimensions = this.computeTargetDimensions(buffer.length, metadata); let quality = this.webpQuality; - let output = await this.renderWebp(source, targetWidth, targetHeight, quality); + let output = await this.encodeWebp(buffer, animated, targetDimensions, quality); - while (output.length > this.maxOutputBytes) { - if (quality > 50) { - quality -= 10; - output = await this.renderWebp(source, targetWidth, targetHeight, quality); - continue; - } + if (output.length > this.maxOutputBytes) { + quality = this.estimateQuality(quality, output.length); + output = await this.encodeWebp(buffer, animated, targetDimensions, quality); + } - if (!targetWidth || !targetHeight || (targetWidth <= 800 && targetHeight <= 800)) { - break; - } - - targetWidth = Math.round(targetWidth * 0.85); - targetHeight = Math.round(targetHeight * 0.85); + if (output.length > this.maxOutputBytes && this.canShrinkDimensions(targetDimensions)) { + const shrunk = this.shrinkDimensions(targetDimensions, output.length); quality = this.webpQuality; - output = await this.renderWebp(source, targetWidth, targetHeight, quality); + output = await this.encodeWebp(buffer, animated, shrunk, quality); } this.logger.log( - `Image processed: ${metadata.width}x${metadata.height} -> ${targetWidth}x${targetHeight}, ` + + `Image processed: ${metadata.width}x${metadata.height} -> ${targetDimensions.width}x${targetDimensions.height}, ` + `${buffer.length} -> ${output.length} bytes (webp q${quality})`, ); @@ -90,18 +94,87 @@ export class ImageProcessingService { } } - private renderWebp( - source: sharp.Sharp, - width: number | undefined, - height: number | undefined, - quality: number, - ): Promise { - let pipeline = source.clone().rotate(); - - if (width && height) { - pipeline = pipeline.resize(width, height, { fit: 'inside', withoutEnlargement: true }); + private async shouldSkipAsWebp( + buffer: Buffer, + mimetype: string, + metadata: sharp.Metadata, + ): Promise { + if (mimetype !== 'image/webp' || buffer.length > this.maxOutputBytes) { + return false; } - return pipeline.webp({ quality }).toBuffer(); + const { width, height } = metadata; + if (!width || !height) { + return false; + } + + return width <= this.maxWidth && height <= this.maxHeight; + } + + private computeTargetDimensions( + bufferLength: number, + metadata: sharp.Metadata, + ): { width: number; height: number } { + let width = metadata.width ?? 0; + let height = metadata.height ?? 0; + + if (!width || !height) { + return { width: 0, height: 0 }; + } + + if (width > this.maxWidth || height > this.maxHeight) { + const scale = Math.min(this.maxWidth / width, this.maxHeight / height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + + if (bufferLength > this.maxOutputBytes) { + const byteScale = Math.min(1, Math.sqrt((this.maxOutputBytes * 0.85) / bufferLength)); + if (byteScale < 1) { + width = Math.max(1, Math.round(width * byteScale)); + height = Math.max(1, Math.round(height * byteScale)); + } + } + + return { width, height }; + } + + private canShrinkDimensions(dimensions: { width: number; height: number }): boolean { + const { width, height } = dimensions; + return width > 0 && height > 0 && (width > 800 || height > 800); + } + + private shrinkDimensions( + dimensions: { width: number; height: number }, + outputBytes: number, + ): { width: number; height: number } { + const scale = Math.min(0.85, Math.sqrt((this.maxOutputBytes * 0.9) / outputBytes)); + return { + width: Math.max(1, Math.round(dimensions.width * scale)), + height: Math.max(1, Math.round(dimensions.height * scale)), + }; + } + + private estimateQuality(currentQuality: number, outputBytes: number): number { + const ratio = this.maxOutputBytes / outputBytes; + return Math.max(MIN_WEBP_QUALITY, Math.min(currentQuality - 1, Math.floor(currentQuality * ratio * 0.95))); + } + + private encodeWebp( + buffer: Buffer, + animated: boolean, + dimensions: { width: number; height: number }, + quality: number, + ): Promise { + let pipeline = sharp(buffer, { animated }).rotate(); + + if (dimensions.width > 0 && dimensions.height > 0) { + pipeline = pipeline.resize(dimensions.width, dimensions.height, { + fit: 'inside', + withoutEnlargement: true, + }); + } + + return pipeline.webp({ quality, effort: WEBP_EFFORT }).toBuffer(); } }