enhance(server): 画像圧縮周り(主にサムネイルの仕様)の変更 (#10287)
* DriveService, is-mime-image * static, previewをavifに, アニメーション画像でもthumbnailを生成 * fallback * animated: true * fix * avatarはwebp * revert ?? file.url --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
		| @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import sharp from 'sharp'; | ||||
| import { sharpBmp } from 'sharp-read-bmp'; | ||||
| import { IsNull } from 'typeorm'; | ||||
| import { DI } from '@/di-symbols.js'; | ||||
| import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js'; | ||||
| @@ -34,6 +35,7 @@ import { FileInfoService } from '@/core/FileInfoService.js'; | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| import { RoleService } from '@/core/RoleService.js'; | ||||
| import { correctFilename } from '@/misc/correct-filename.js'; | ||||
| import { isMimeImage } from '@/misc/is-mime-image.js'; | ||||
| import type S3 from 'aws-sdk/clients/s3.js'; | ||||
|  | ||||
| type AddFileArgs = { | ||||
| @@ -274,8 +276,8 @@ export class DriveService { | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (!['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/svg+xml'].includes(type)) { | ||||
| 			this.registerLogger.debug('web image and thumbnail not created (not an required file)'); | ||||
| 		if (!isMimeImage(type, 'sharp-convertible-image-with-bmp')) { | ||||
| 			this.registerLogger.debug('web image and thumbnail not created (cannot convert by sharp)'); | ||||
| 			return { | ||||
| 				webpublic: null, | ||||
| 				thumbnail: null, | ||||
| @@ -284,22 +286,16 @@ export class DriveService { | ||||
|  | ||||
| 		let img: sharp.Sharp | null = null; | ||||
| 		let satisfyWebpublic: boolean; | ||||
| 		let isAnimated: boolean; | ||||
|  | ||||
| 		try { | ||||
| 			img = sharp(path); | ||||
| 			img = await sharpBmp(path, type); | ||||
| 			const metadata = await img.metadata(); | ||||
| 			const isAnimated = metadata.pages && metadata.pages > 1; | ||||
|  | ||||
| 			// skip animated | ||||
| 			if (isAnimated) { | ||||
| 				return { | ||||
| 					webpublic: null, | ||||
| 					thumbnail: null, | ||||
| 				}; | ||||
| 			} | ||||
| 			isAnimated = !!(metadata.pages && metadata.pages > 1); | ||||
|  | ||||
| 			satisfyWebpublic = !!( | ||||
| 				type !== 'image/svg+xml' && type !== 'image/avif' && | ||||
| 				type !== 'image/svg+xml' && // security reason | ||||
| 				type !== 'image/avif' && // not supported by Mastodon | ||||
| 			!(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) && | ||||
| 			metadata.width && metadata.width <= 2048 && | ||||
| 			metadata.height && metadata.height <= 2048 | ||||
| @@ -315,15 +311,13 @@ export class DriveService { | ||||
| 		// #region webpublic | ||||
| 		let webpublic: IImage | null = null; | ||||
|  | ||||
| 		if (generateWeb && !satisfyWebpublic) { | ||||
| 		if (generateWeb && !satisfyWebpublic && !isAnimated) { | ||||
| 			this.registerLogger.info('creating web image'); | ||||
|  | ||||
| 			try { | ||||
| 				if (type === 'image/jpeg') { | ||||
| 					webpublic = await this.imageProcessingService.convertSharpToJpeg(img, 2048, 2048); | ||||
| 				} else if (['image/webp', 'image/avif'].includes(type)) { | ||||
| 				if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) { | ||||
| 					webpublic = await this.imageProcessingService.convertSharpToWebp(img, 2048, 2048); | ||||
| 				} else if (['image/png', 'image/svg+xml'].includes(type)) { | ||||
| 				} else if (['image/png', 'image/bmp', 'image/svg+xml'].includes(type)) { | ||||
| 					webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); | ||||
| 				} else { | ||||
| 					this.registerLogger.debug('web image not created (not an required image)'); | ||||
| @@ -333,6 +327,7 @@ export class DriveService { | ||||
| 			} | ||||
| 		} else { | ||||
| 			if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)'); | ||||
| 			else if (isAnimated) this.registerLogger.info('web image not created (animated image)'); | ||||
| 			else this.registerLogger.info('web image not created (from remote)'); | ||||
| 		} | ||||
| 		// #endregion webpublic | ||||
| @@ -341,10 +336,10 @@ export class DriveService { | ||||
| 		let thumbnail: IImage | null = null; | ||||
|  | ||||
| 		try { | ||||
| 			if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(type)) { | ||||
| 				thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 280); | ||||
| 			if (isAnimated) { | ||||
| 				thumbnail = await this.imageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 }); | ||||
| 			} else { | ||||
| 				this.registerLogger.debug('thumbnail not created (not an required file)'); | ||||
| 				thumbnail = await this.imageProcessingService.convertSharpToAvif(img, 498, 422); | ||||
| 			} | ||||
| 		} catch (err) { | ||||
| 			this.registerLogger.warn('thumbnail not created (an error occured)', err as Error); | ||||
|   | ||||
| @@ -15,15 +15,28 @@ export type IImageStream = { | ||||
| 	type: string; | ||||
| }; | ||||
|  | ||||
| export type IImageStreamable = IImage | IImageStream; | ||||
| export type IImageSharp = { | ||||
| 	data: sharp.Sharp; | ||||
| 	ext: string | null; | ||||
| 	type: string; | ||||
| }; | ||||
|  | ||||
| export type IImageStreamable = IImage | IImageStream | IImageSharp; | ||||
|  | ||||
| export const webpDefault: sharp.WebpOptions = { | ||||
| 	quality: 85, | ||||
| 	quality: 77, | ||||
| 	alphaQuality: 95, | ||||
| 	lossless: false, | ||||
| 	nearLossless: false, | ||||
| 	smartSubsample: true, | ||||
| 	mixed: true, | ||||
| 	effort: 2, | ||||
| }; | ||||
|  | ||||
| export const avifDefault: sharp.AvifOptions = { | ||||
| 	quality: 60, | ||||
| 	lossless: false, | ||||
| 	effort: 2, | ||||
| }; | ||||
|  | ||||
| import { bindThis } from '@/decorators.js'; | ||||
| @@ -37,36 +50,6 @@ export class ImageProcessingService { | ||||
| 	) { | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Convert to JPEG | ||||
| 	 *   with resize, remove metadata, resolve orientation, stop animation | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async convertToJpeg(path: string, width: number, height: number): Promise<IImage> { | ||||
| 		return this.convertSharpToJpeg(await sharp(path), width, height); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> { | ||||
| 		const data = await sharp | ||||
| 			.resize(width, height, { | ||||
| 				fit: 'inside', | ||||
| 				withoutEnlargement: true, | ||||
| 			}) | ||||
| 			.rotate() | ||||
| 			.jpeg({ | ||||
| 				quality: 85, | ||||
| 				progressive: true, | ||||
| 			}) | ||||
| 			.toBuffer(); | ||||
|  | ||||
| 		return { | ||||
| 			data, | ||||
| 			ext: 'jpg', | ||||
| 			type: 'image/jpeg', | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Convert to WebP | ||||
| 	 *   with resize, remove metadata, resolve orientation, stop animation | ||||
| @@ -78,29 +61,22 @@ export class ImageProcessingService { | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> { | ||||
| 		const data = await sharp | ||||
| 			.resize(width, height, { | ||||
| 				fit: 'inside', | ||||
| 				withoutEnlargement: true, | ||||
| 			}) | ||||
| 			.rotate() | ||||
| 			.webp(options) | ||||
| 			.toBuffer(); | ||||
| 		const result = this.convertSharpToWebpStream(sharp, width, height, options); | ||||
|  | ||||
| 		return { | ||||
| 			data, | ||||
| 			ext: 'webp', | ||||
| 			type: 'image/webp', | ||||
| 			data: await result.data.toBuffer(), | ||||
| 			ext: result.ext, | ||||
| 			type: result.type, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { | ||||
| 	public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { | ||||
| 		return this.convertSharpToWebpStream(sharp(path), width, height, options); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { | ||||
| 	public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { | ||||
| 		const data = sharp | ||||
| 			.resize(width, height, { | ||||
| 				fit: 'inside', | ||||
| @@ -115,13 +91,56 @@ export class ImageProcessingService { | ||||
| 			type: 'image/webp', | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Convert to Avif | ||||
| 	 *   with resize, remove metadata, resolve orientation, stop animation | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> { | ||||
| 		return this.convertSharpToAvif(sharp(path), width, height, options); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public async convertSharpToAvif(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise<IImage> { | ||||
| 		const result = this.convertSharpToAvifStream(sharp, width, height, options); | ||||
|  | ||||
| 		return { | ||||
| 			data: await result.data.toBuffer(), | ||||
| 			ext: result.ext, | ||||
| 			type: result.type, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public convertToAvifStream(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { | ||||
| 		return this.convertSharpToAvifStream(sharp(path), width, height, options); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
| 	public convertSharpToAvifStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { | ||||
| 		const data = sharp | ||||
| 			.resize(width, height, { | ||||
| 				fit: 'inside', | ||||
| 				withoutEnlargement: true, | ||||
| 			}) | ||||
| 			.rotate() | ||||
| 			.avif(options); | ||||
|  | ||||
| 		return { | ||||
| 			data, | ||||
| 			ext: 'avif', | ||||
| 			type: 'image/avif', | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * Convert to PNG | ||||
| 	 *   with resize, remove metadata, resolve orientation, stop animation | ||||
| 	 */ | ||||
| 	@bindThis | ||||
| 	public async convertToPng(path: string, width: number, height: number): Promise<IImage> { | ||||
| 		return this.convertSharpToPng(await sharp(path), width, height); | ||||
| 		return this.convertSharpToPng(sharp(path), width, height); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
| @@ -76,7 +76,7 @@ export class DriveFileEntityService { | ||||
| 	@bindThis | ||||
| 	private getProxiedUrl(url: string, mode?: 'static' | 'avatar'): string { | ||||
| 		return appendQuery( | ||||
| 			`${this.config.mediaProxy}/${mode ?? 'image'}.webp`, | ||||
| 			`${this.config.mediaProxy}/${mode ?? 'image'}.${mode === 'avatar' ? 'webp' : 'avif'}`, | ||||
| 			query({ | ||||
| 				url, | ||||
| 				...(mode ? { [mode]: '1' } : {}), | ||||
| @@ -104,7 +104,7 @@ export class DriveFileEntityService { | ||||
|  | ||||
| 		const url = file.webpublicUrl ?? file.url; | ||||
|  | ||||
| 		return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? this.getProxiedUrl(url, 'static') : null); | ||||
| 		return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? url : null); | ||||
| 	} | ||||
|  | ||||
| 	@bindThis | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 tamaina
					tamaina