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',
]);
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<number>('IMAGE_MAX_WIDTH', 1920);
this.maxHeight = this.configService.get<number>('IMAGE_MAX_HEIGHT', 1920);
this.maxOutputBytes = this.configService.get<number>('IMAGE_MAX_SIZE', 2 * 1024 * 1024);
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 {
@@ -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<Buffer> {
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<boolean> {
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<Buffer> {
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();
}
}