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