mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 09:15:35 +03:00
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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'`);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user