This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user