feat(server): separate quality for thumbnail and preview images (#13006)

* allow different thumbnail and preview quality, better config structure

* update web and api

* wording

* remove empty line?
This commit is contained in:
Mert
2024-09-28 02:01:04 -04:00
committed by GitHub
parent 4248594ac5
commit 995f0fda47
17 changed files with 369 additions and 198 deletions

View File

@@ -20,6 +20,7 @@ import {
VideoContainer,
} from 'src/enum';
import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface';
import { ImageOutputConfig } from 'src/interfaces/media.interface';
export interface SystemConfig {
ffmpeg: {
@@ -109,11 +110,8 @@ export interface SystemConfig {
template: string;
};
image: {
thumbnailFormat: ImageFormat;
thumbnailSize: number;
previewFormat: ImageFormat;
previewSize: number;
quality: number;
thumbnail: ImageOutputConfig;
preview: ImageOutputConfig;
colorspace: Colorspace;
extractEmbedded: boolean;
};
@@ -259,11 +257,16 @@ export const defaults = Object.freeze<SystemConfig>({
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
image: {
thumbnailFormat: ImageFormat.WEBP,
thumbnailSize: 250,
previewFormat: ImageFormat.JPEG,
previewSize: 1440,
quality: 80,
thumbnail: {
format: ImageFormat.WEBP,
size: 250,
quality: 80,
},
preview: {
format: ImageFormat.JPEG,
size: 1440,
quality: 80,
},
colorspace: Colorspace.P3,
extractEmbedded: false,
},

View File

@@ -473,26 +473,10 @@ export class SystemConfigThemeDto {
customCss!: string;
}
class SystemConfigImageDto {
class SystemConfigGeneratedImageDto {
@IsEnum(ImageFormat)
@ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat })
thumbnailFormat!: ImageFormat;
@IsInt()
@Min(1)
@Type(() => Number)
@ApiProperty({ type: 'integer' })
thumbnailSize!: number;
@IsEnum(ImageFormat)
@ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat })
previewFormat!: ImageFormat;
@IsInt()
@Min(1)
@Type(() => Number)
@ApiProperty({ type: 'integer' })
previewSize!: number;
format!: ImageFormat;
@IsInt()
@Min(1)
@@ -501,6 +485,24 @@ class SystemConfigImageDto {
@ApiProperty({ type: 'integer' })
quality!: number;
@IsInt()
@Min(1)
@Type(() => Number)
@ApiProperty({ type: 'integer' })
size!: number;
}
class SystemConfigImageDto {
@Type(() => SystemConfigGeneratedImageDto)
@ValidateNested()
@IsObject()
thumbnail!: SystemConfigGeneratedImageDto;
@Type(() => SystemConfigGeneratedImageDto)
@ValidateNested()
@IsObject()
preview!: SystemConfigGeneratedImageDto;
@IsEnum(Colorspace)
@ApiProperty({ enumName: 'Colorspace', enum: Colorspace })
colorspace!: Colorspace;

View File

@@ -10,11 +10,14 @@ export interface CropOptions {
height: number;
}
export interface ThumbnailOptions {
size: number;
export interface ImageOutputConfig {
format: ImageFormat;
colorspace: string;
quality: number;
size: number;
}
export interface ThumbnailOptions extends ImageOutputConfig {
colorspace: string;
crop?: CropOptions;
processInvalidImages: boolean;
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SeparateQualityForThumbnailAndPreview1727471863507 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
update system_metadata
set value = jsonb_set(value, '{image}', jsonb_strip_nulls(
jsonb_build_object(
'preview', jsonb_build_object(
'format', value->'image'->'previewFormat',
'quality', value->'image'->'quality',
'size', value->'image'->'previewSize'),
'thumbnail', jsonb_build_object(
'format', value->'image'->'thumbnailFormat',
'quality', value->'image'->'quality',
'size', value->'image'->'thumbnailSize'),
'extractEmbedded', value->'extractEmbedded',
'colorspace', value->'colorspace'
)))
where key = 'system-config'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
update system_metadata
set value = jsonb_set(value, '{image}', jsonb_strip_nulls(jsonb_build_object(
'previewFormat', value->'image'->'preview'->'format',
'previewSize', value->'image'->'preview'->'size',
'thumbnailFormat', value->'image'->'thumbnail'->'format',
'thumbnailSize', value->'image'->'thumbnail'->'size',
'extractEmbedded', value->'extractEmbedded',
'colorspace', value->'colorspace',
'quality', value->'image'->'preview'->'quality'
)))
where key = 'system-config'`);
}
}

View File

@@ -285,7 +285,7 @@ describe(MediaService.name, () => {
});
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => {
systemMock.get.mockResolvedValue({ image: { previewFormat: format } });
systemMock.get.mockResolvedValue({ image: { preview: { format } } });
assetMock.getByIds.mockResolvedValue([assetStub.image]);
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
@@ -307,7 +307,7 @@ describe(MediaService.name, () => {
});
it('should delete previous preview if different path', async () => {
systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } });
systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } });
assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGeneratePreview({ id: assetStub.image.id });
@@ -464,7 +464,7 @@ describe(MediaService.name, () => {
it.each(Object.values(ImageFormat))(
'should generate a %s thumbnail for an image when specified',
async (format) => {
systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } });
systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } });
assetMock.getByIds.mockResolvedValue([assetStub.image]);
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
@@ -487,7 +487,7 @@ describe(MediaService.name, () => {
);
it('should delete previous thumbnail if different path', async () => {
systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } });
systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } });
assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });

View File

@@ -10,7 +10,6 @@ import {
AssetType,
AudioCodec,
Colorspace,
ImageFormat,
LogLevel,
StorageFolder,
TranscodeHWAccel,
@@ -175,18 +174,15 @@ export class MediaService {
return JobStatus.FAILED;
}
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.previewFormat);
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format);
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
await this.storageCore.moveAssetVideo(asset);
return JobStatus.SUCCESS;
}
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
const [{ image }, [asset]] = await Promise.all([
this.configCore.getConfig({ withCache: true }),
this.assetRepository.getByIds([id], { exifInfo: true, files: true }),
]);
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true });
if (!asset) {
return JobStatus.FAILED;
}
@@ -195,7 +191,7 @@ export class MediaService {
return JobStatus.SKIPPED;
}
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW);
if (!previewPath) {
return JobStatus.SKIPPED;
}
@@ -213,9 +209,9 @@ export class MediaService {
return JobStatus.SUCCESS;
}
private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) {
private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) {
const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true });
const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize;
const { size, format, quality } = image[type];
const path = StorageCore.getImagePath(asset, type, format);
this.storageCore.ensureFolders(path);
@@ -226,13 +222,13 @@ export class MediaService {
const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath));
try {
const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize));
const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size));
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
const imageOptions = {
format,
size,
colorspace,
quality: image.quality,
quality,
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
};
@@ -274,10 +270,7 @@ export class MediaService {
}
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
const [{ image }, [asset]] = await Promise.all([
this.configCore.getConfig({ withCache: true }),
this.assetRepository.getByIds([id], { exifInfo: true, files: true }),
]);
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true });
if (!asset) {
return JobStatus.FAILED;
}
@@ -286,7 +279,7 @@ export class MediaService {
return JobStatus.SKIPPED;
}
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL);
if (!thumbnailPath) {
return JobStatus.SKIPPED;
}

View File

@@ -574,7 +574,7 @@ export class PersonService {
format: ImageFormat.JPEG,
size: FACE_THUMBNAIL_SIZE,
colorspace: image.colorspace,
quality: image.quality,
quality: image.thumbnail.quality,
crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }),
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
} as const;

View File

@@ -135,11 +135,16 @@ const updatedConfig = Object.freeze<SystemConfig>({
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
image: {
thumbnailFormat: ImageFormat.WEBP,
thumbnailSize: 250,
previewFormat: ImageFormat.JPEG,
previewSize: 1440,
quality: 80,
thumbnail: {
size: 250,
format: ImageFormat.WEBP,
quality: 80,
},
preview: {
size: 1440,
format: ImageFormat.JPEG,
quality: 80,
},
colorspace: Colorspace.P3,
extractEmbedded: false,
},