otimize image processing
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-06-04 17:32:42 +03:30
parent 9a6eebf385
commit 02ebdf83b6
@@ -17,6 +17,9 @@ const RASTER_IMAGE_MIME_TYPES = new Set([
'image/bmp', 'image/bmp',
]); ]);
const MIN_WEBP_QUALITY = 50;
const WEBP_EFFORT = 4;
@Injectable() @Injectable()
export class ImageProcessingService { export class ImageProcessingService {
private readonly logger = new Logger(ImageProcessingService.name); private readonly logger = new Logger(ImageProcessingService.name);
@@ -24,12 +27,15 @@ export class ImageProcessingService {
private readonly maxHeight: number; private readonly maxHeight: number;
private readonly maxOutputBytes: number; private readonly maxOutputBytes: number;
private readonly webpQuality: number; private readonly webpQuality: number;
private readonly skipImageProcessing: boolean;
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
this.maxWidth = this.configService.get<number>('IMAGE_MAX_WIDTH', 1920); this.maxWidth = this.configService.get<number>('IMAGE_MAX_WIDTH', 1920);
this.maxHeight = this.configService.get<number>('IMAGE_MAX_HEIGHT', 1920); this.maxHeight = this.configService.get<number>('IMAGE_MAX_HEIGHT', 1920);
this.maxOutputBytes = this.configService.get<number>('IMAGE_MAX_SIZE', 2 * 1024 * 1024); this.maxOutputBytes = this.configService.get<number>('IMAGE_MAX_SIZE', 2 * 1024 * 1024);
this.webpQuality = this.configService.get<number>('IMAGE_WEBP_QUALITY', 80); this.webpQuality = this.configService.get<number>('IMAGE_WEBP_QUALITY', 80);
this.skipImageProcessing =
this.configService.get<string>('SKIP_IMAGE_PROCESSING', 'false').toLowerCase() === 'true';
} }
shouldProcess(mimetype: string): boolean { shouldProcess(mimetype: string): boolean {
@@ -41,41 +47,39 @@ export class ImageProcessingService {
return null; return null;
} }
if (this.skipImageProcessing) {
this.logger.debug('Image processing skipped (SKIP_IMAGE_PROCESSING=true)');
return null;
}
try { try {
const source = sharp(buffer, { animated: mimetype === 'image/gif' }).rotate(); const animated = mimetype === 'image/gif';
const metadata = await source.metadata(); const metadata = await sharp(buffer, { animated }).metadata();
let targetWidth = metadata.width; if (await this.shouldSkipAsWebp(buffer, mimetype, metadata)) {
let targetHeight = metadata.height; this.logger.debug(
`Image processing skipped: webp within limits (${metadata.width}x${metadata.height}, ${buffer.length} bytes)`,
if (targetWidth && targetHeight && (targetWidth > this.maxWidth || targetHeight > this.maxHeight)) { );
const scale = Math.min(this.maxWidth / targetWidth, this.maxHeight / targetHeight); return null;
targetWidth = Math.round(targetWidth * scale);
targetHeight = Math.round(targetHeight * scale);
} }
const targetDimensions = this.computeTargetDimensions(buffer.length, metadata);
let quality = this.webpQuality; 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 (output.length > this.maxOutputBytes) {
if (quality > 50) { quality = this.estimateQuality(quality, output.length);
quality -= 10; output = await this.encodeWebp(buffer, animated, targetDimensions, quality);
output = await this.renderWebp(source, targetWidth, targetHeight, quality);
continue;
} }
if (!targetWidth || !targetHeight || (targetWidth <= 800 && targetHeight <= 800)) { if (output.length > this.maxOutputBytes && this.canShrinkDimensions(targetDimensions)) {
break; const shrunk = this.shrinkDimensions(targetDimensions, output.length);
}
targetWidth = Math.round(targetWidth * 0.85);
targetHeight = Math.round(targetHeight * 0.85);
quality = this.webpQuality; quality = this.webpQuality;
output = await this.renderWebp(source, targetWidth, targetHeight, quality); output = await this.encodeWebp(buffer, animated, shrunk, quality);
} }
this.logger.log( 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})`, `${buffer.length} -> ${output.length} bytes (webp q${quality})`,
); );
@@ -90,18 +94,87 @@ export class ImageProcessingService {
} }
} }
private renderWebp( private async shouldSkipAsWebp(
source: sharp.Sharp, buffer: Buffer,
width: number | undefined, mimetype: string,
height: number | undefined, metadata: sharp.Metadata,
): Promise<boolean> {
if (mimetype !== 'image/webp' || buffer.length > this.maxOutputBytes) {
return false;
}
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, quality: number,
): Promise<Buffer> { ): Promise<Buffer> {
let pipeline = source.clone().rotate(); let pipeline = sharp(buffer, { animated }).rotate();
if (width && height) { if (dimensions.width > 0 && dimensions.height > 0) {
pipeline = pipeline.resize(width, height, { fit: 'inside', withoutEnlargement: true }); pipeline = pipeline.resize(dimensions.width, dimensions.height, {
fit: 'inside',
withoutEnlargement: true,
});
} }
return pipeline.webp({ quality }).toBuffer(); return pipeline.webp({ quality, effort: WEBP_EFFORT }).toBuffer();
} }
} }