summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authortamaina <tamaina@hotmail.co.jp>2023-03-11 14:11:40 +0900
committerGitHub <noreply@github.com>2023-03-11 14:11:40 +0900
commit88e3d3e8cbe26c0280308e819965a64a91491f90 (patch)
tree25c9039f6fa6725af4fa5a7de5decb71f2a3150b /packages
parentUpdate CHANGELOG.md (diff)
downloadmisskey-88e3d3e8cbe26c0280308e819965a64a91491f90.tar.gz
misskey-88e3d3e8cbe26c0280308e819965a64a91491f90.tar.bz2
misskey-88e3d3e8cbe26c0280308e819965a64a91491f90.zip
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>
Diffstat (limited to 'packages')
-rw-r--r--packages/backend/src/core/DriveService.ts37
-rw-r--r--packages/backend/src/core/ImageProcessingService.ts91
-rw-r--r--packages/backend/src/core/entities/DriveFileEntityService.ts4
-rw-r--r--packages/backend/src/misc/is-mime-image.ts8
-rw-r--r--packages/backend/src/server/FileServerService.ts10
-rw-r--r--packages/backend/src/server/web/UrlPreviewService.ts2
-rw-r--r--packages/frontend/src/components/MkMediaImage.vue2
-rw-r--r--packages/frontend/src/pages/user/index.photos.vue2
-rw-r--r--packages/frontend/src/scripts/media-proxy.ts7
-rw-r--r--packages/frontend/src/widgets/WidgetPhotos.vue2
10 files changed, 91 insertions, 74 deletions
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts
index dfaacffc1d..7eccf4b3b1 100644
--- a/packages/backend/src/core/DriveService.ts
+++ b/packages/backend/src/core/DriveService.ts
@@ -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);
diff --git a/packages/backend/src/core/ImageProcessingService.ts b/packages/backend/src/core/ImageProcessingService.ts
index 7c88f5e9a0..3246475d12 100644
--- a/packages/backend/src/core/ImageProcessingService.ts
+++ b/packages/backend/src/core/ImageProcessingService.ts
@@ -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';
@@ -38,90 +51,96 @@ export class ImageProcessingService {
}
/**
- * Convert to JPEG
+ * Convert to WebP
* 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);
+ public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
+ return this.convertSharpToWebp(sharp(path), width, height, options);
}
@bindThis
- public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
- const data = await sharp
+ public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
+ const result = this.convertSharpToWebpStream(sharp, width, height, options);
+
+ return {
+ data: await result.data.toBuffer(),
+ ext: result.ext,
+ type: result.type,
+ };
+ }
+
+ @bindThis
+ 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): IImageSharp {
+ const data = sharp
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.rotate()
- .jpeg({
- quality: 85,
- progressive: true,
- })
- .toBuffer();
+ .webp(options);
return {
data,
- ext: 'jpg',
- type: 'image/jpeg',
+ ext: 'webp',
+ type: 'image/webp',
};
}
/**
- * Convert to WebP
+ * Convert to Avif
* with resize, remove metadata, resolve orientation, stop animation
*/
@bindThis
- public async convertToWebp(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise<IImage> {
- return this.convertSharpToWebp(sharp(path), width, height, options);
+ 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 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();
+ 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,
- 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 {
- return this.convertSharpToWebpStream(sharp(path), width, height, options);
+ public convertToAvifStream(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp {
+ return this.convertSharpToAvifStream(sharp(path), width, height, options);
}
@bindThis
- public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream {
+ 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()
- .webp(options);
+ .avif(options);
return {
data,
- ext: 'webp',
- type: 'image/webp',
+ 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
diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts
index 74a0689d89..1a6913b800 100644
--- a/packages/backend/src/core/entities/DriveFileEntityService.ts
+++ b/packages/backend/src/core/entities/DriveFileEntityService.ts
@@ -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
diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts
index 0b6d147dc1..46a66efc0f 100644
--- a/packages/backend/src/misc/is-mime-image.ts
+++ b/packages/backend/src/misc/is-mime-image.ts
@@ -2,10 +2,10 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
const dictionary = {
'safe-file': FILE_TYPE_BROWSERSAFE,
- 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'],
- 'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'],
- 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
- 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
+ 'sharp-convertible-image': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'],
+ 'sharp-animation-convertible-image': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'],
+ 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
+ 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'],
};
export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime);
diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts
index 6db9a9672c..fb1c67f20d 100644
--- a/packages/backend/src/server/FileServerService.ts
+++ b/packages/backend/src/server/FileServerService.ts
@@ -130,7 +130,7 @@ export class FileServerService {
if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) {
reply.header('Cache-Control', 'max-age=31536000, immutable');
- const url = new URL(`${this.config.mediaProxy}/static.webp`);
+ const url = new URL(`${this.config.mediaProxy}/static.avif`);
url.searchParams.set('url', file.url);
url.searchParams.set('static', '1');
@@ -151,7 +151,7 @@ export class FileServerService {
if (['image/svg+xml'].includes(file.mime)) {
reply.header('Cache-Control', 'max-age=31536000, immutable');
- const url = new URL(`${this.config.mediaProxy}/svg.webp`);
+ const url = new URL(`${this.config.mediaProxy}/svg.avif`);
url.searchParams.set('url', file.url);
file.cleanup();
@@ -291,9 +291,9 @@ export class FileServerService {
};
}
} else if ('static' in request.query) {
- image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 280);
+ image = this.imageProcessingService.convertSharpToAvifStream(await sharpBmp(file.path, file.mime), 498, 422);
} else if ('preview' in request.query) {
- image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200);
+ image = this.imageProcessingService.convertSharpToAvifStream(await sharpBmp(file.path, file.mime), 200, 200);
} else if ('badge' in request.query) {
const mask = (await sharpBmp(file.path, file.mime))
.resize(96, 96, {
@@ -325,7 +325,7 @@ export class FileServerService {
type: 'image/png',
};
} else if (file.mime === 'image/svg+xml') {
- image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048);
+ image = this.imageProcessingService.convertToAvifStream(file.path, 2048, 2048);
} else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) {
throw new StatusError('Rejected type', 403, 'Rejected type');
}
diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts
index 2ce7293a52..5f4d53d0ec 100644
--- a/packages/backend/src/server/web/UrlPreviewService.ts
+++ b/packages/backend/src/server/web/UrlPreviewService.ts
@@ -33,7 +33,7 @@ export class UrlPreviewService {
private wrap(url?: string | null): string | null {
return url != null
? url.match(/^https?:\/\//)
- ? `${this.config.mediaProxy}/preview.webp?${query({
+ ? `${this.config.mediaProxy}/preview.avif?${query({
url,
preview: '1',
})}`
diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue
index 6091b40016..a4065dcd07 100644
--- a/packages/frontend/src/components/MkMediaImage.vue
+++ b/packages/frontend/src/components/MkMediaImage.vue
@@ -43,7 +43,7 @@ let darkMode = $ref(defaultStore.state.darkMode);
const url = (props.raw || defaultStore.state.loadRawImages)
? props.image.url
: defaultStore.state.disableShowingAnimatedImages
- ? getStaticImageUrl(props.image.thumbnailUrl)
+ ? getStaticImageUrl(props.image.url)
: props.image.thumbnailUrl;
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
diff --git a/packages/frontend/src/pages/user/index.photos.vue b/packages/frontend/src/pages/user/index.photos.vue
index 607082c1e4..85f6591eee 100644
--- a/packages/frontend/src/pages/user/index.photos.vue
+++ b/packages/frontend/src/pages/user/index.photos.vue
@@ -41,7 +41,7 @@ let images = $ref<{
function thumbnail(image: misskey.entities.DriveFile): string {
return defaultStore.state.disableShowingAnimatedImages
- ? getStaticImageUrl(image.thumbnailUrl)
+ ? getStaticImageUrl(image.url)
: image.thumbnailUrl;
}
diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts
index 2fe5bdcf8f..d0c95e0b75 100644
--- a/packages/frontend/src/scripts/media-proxy.ts
+++ b/packages/frontend/src/scripts/media-proxy.ts
@@ -10,7 +10,10 @@ export function getProxiedImageUrl(imageUrl: string, type?: 'preview', mustOrigi
imageUrl = (new URL(imageUrl)).searchParams.get('url') ?? imageUrl;
}
- return `${mustOrigin ? localProxy : instance.mediaProxy}/image.webp?${query({
+ return `${mustOrigin ? localProxy : instance.mediaProxy}/${
+ type === 'preview' ? 'preview.avif'
+ : 'image.webp'
+ }?${query({
url: imageUrl,
fallback: '1',
...(type ? { [type]: '1' } : {}),
@@ -38,7 +41,7 @@ export function getStaticImageUrl(baseUrl: string): string {
return u.href;
}
- return `${instance.mediaProxy}/static.webp?${query({
+ return `${instance.mediaProxy}/static.avif?${query({
url: u.href,
static: '1',
})}`;
diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue
index 562249f094..716bbb4274 100644
--- a/packages/frontend/src/widgets/WidgetPhotos.vue
+++ b/packages/frontend/src/widgets/WidgetPhotos.vue
@@ -67,7 +67,7 @@ const onDriveFileCreated = (file) => {
const thumbnail = (image: any): string => {
return defaultStore.state.disableShowingAnimatedImages
- ? getStaticImageUrl(image.thumbnailUrl)
+ ? getStaticImageUrl(image.url)
: image.thumbnailUrl;
};