From 995f0fda475d40e969190925af455c20abb7a02b Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 28 Sep 2024 02:01:04 -0400 Subject: [PATCH] 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? --- mobile/openapi/README.md | Bin 31966 -> 32040 bytes mobile/openapi/lib/api.dart | Bin 11468 -> 11521 bytes mobile/openapi/lib/api_client.dart | Bin 29499 -> 29609 bytes .../system_config_generated_image_dto.dart | Bin 0 -> 3575 bytes .../lib/model/system_config_image_dto.dart | Bin 4829 -> 3856 bytes open-api/immich-openapi-specs.json | 50 +++--- open-api/typescript-sdk/src/fetch-client.ts | 12 +- server/src/config.ts | 23 +-- server/src/dtos/system-config.dto.ts | 38 ++--- server/src/interfaces/media.interface.ts | 9 +- ...7-SeparateQualityForThumbnailAndPreview.ts | 37 +++++ server/src/services/media.service.spec.ts | 8 +- server/src/services/media.service.ts | 27 ++-- server/src/services/person.service.ts | 2 +- .../services/system-config.service.spec.ts | 15 +- .../settings/image/image-settings.svelte | 150 ++++++++++-------- web/src/lib/i18n/en.json | 16 +- 17 files changed, 232 insertions(+), 155 deletions(-) create mode 100644 mobile/openapi/lib/model/system_config_generated_image_dto.dart create mode 100644 server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index fecbbf482be540d0f0f06f90a23d837f40546ac1..81827a9079e5a064acb0b1c3120531e6ba423473 100644 GIT binary patch delta 47 wcmccjlX1l_#tn1xINek8Qi~ExQd1_+&6DPX^E`7C(^DsZ%#+wWH_wp|0Qwyi9{>OV delta 14 WcmZ4Si}Bu1#tn1xHb2jET*%-B9odRciYF=tlVo7SsOG diff --git a/mobile/openapi/lib/model/system_config_generated_image_dto.dart b/mobile/openapi/lib/model/system_config_generated_image_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..2192a7cb0cbd55cd12a33fffe538b915d0aa4dbe GIT binary patch literal 3575 zcmbVPZExE)5dQ98aVdgY!Bjcjry{An7EMy5ZDOFw1`I|ZFcNLElSP-L>UnAY`|ggS zEH|E#^+OVyyyLw*&pl)YgWg~OZ-2iYzj%LodHVV7_2~(moqsxw;baV#<7>DWpPZll zxrb(C`6d^}4S!8uzUt9eH7m7|=~SC^stS1oRase?r?QX>S-LoUSBs@KZl{MRR$^__ zW#v*MzgEhidnp$9dnpY5b<$Wo+}PdjnJ%pprAt+g0o7DV!|rxxutJs6riCtNXy)dk zOkW+Q*<2Xg>tQ$tdJ1|eOR-c5{v7vuSs|>2tF?7<@lKahHG3~hX@ryc`9jR(iPLb` z0~la8K1)}b(t?0eu3*yXbOVYL&t!t$ou(0;4-mQoZmw*CEnf&1@7VcTi9)&ct}Uzn zk+gn)fRzDo0cU&=`15#~O1V&_T2u@8CW;DSeTmk*EdJhFK0L$?RXV7HcKMSTCBQas z^fze?Vut>=S{KxHY^pXe97f~YFET?c++3Oi7>>YWn~5T#+T*bE{k#7J>M?|#KgT%W zt?Sldpt@+-+N~?0?eKAHpGu(3ouyEXGwEP1?EIb1(Qw3pn^B<&DP<nk3tgM58xNSUT5-fgRh1C&bXyQ29gyPStW|L{7REakeo$>*UKb)DmhY*5n;%8nLQy9>5mzd}fjf>mG0* z@~YHArZaHFVaeKa<*2P^33Z)|B*wM)bg<`zk^E}59Kk{?{~)0oPp{WY`5LEq6p>#H zrXM)gb|eaQdokrUE_ILYIc3Aw1Y_RNu14;YqP}!pHX|s|<}6efAoSVZv6Rk?UWwZR z#RsOMurj_6Sa>9iv{m7F3OgfBZZ^o$`2;K_hiL=qz@FB9>J5kbv88ZEwhZY@bgzB^8*6Jok)jd3pqB*6ExrJZdkesm3TY z@d&~vK7}=+2CJ=lon?%OU^^iTdHaT;6{EtU9e1~86cm$s5zxVmI>BMPp$G53Mi3RS ze%2j9^8!op5rl6&deixvY71ywHR}L%Lf@3Mp)~5VFYkXs&Zj2q)B^Ak=&^+p@A#Ko z)8)bbp2r)+1M*w=qiZvc*aQ4Z0?jSc{z0-*ZjMVdfb>x8#QcT#!6WL60PjQerROsp zjr7~WB4XYn|cw=ka7S3 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 681a8c00c3bc009892234f3368905d370afc6c02..5309f7745c44d23067bf0f651cbbc11969d8b69e 100644 GIT binary patch delta 379 zcmcbsIzev3GselDOtO=+nCt?BD~n4~bDi_^(lXQCQ}a@b5=&B3JaZG%Q(a2(6$*+{ z%Q92Tt+}`q6!5Do$tcZD%1g}5nOwlM;rP$i# zZB?Ll=pYo>svy)Vu^$ywk5vF^&n(u1s+~NMLqZrK3Rk`P2!|DuycK?@P8Q^r2vU#L zRMY`_3IgB;5YVcZR+OLXRh*xvkp(28i`3y7)nhkX@wl+bs6z}zk^l+RT65KMaRC52 C+k!a& delta 1391 zcmaJ>O;6iE5EX7fk?*Jnq;`wKM}+01;^xG?1R+tWs#Fjc1aupgdQluFj-3Khlq1&) ztNk5F{Dz)-=E#*3{0H{iyH4bsc4ywad2eQ&-|rsR?w%HwcB43iN%ntXWx9{Akz)h$ zi_tK}F**ZDfJg2yg*}{H%}XUg8vf`*+~YK*5J^9&w`|g-6ZN8aG=NOLN1AM`K9Ht={!(NI!@R3mAej>rNNg0$ez)ORn*%TbZ^c!=^ z2y`U{n`I5C76qqjvRmt6DjfN~)F}e%>ulap5}?=>erXZNk?d;V#6BoS9*w+eue!E# zN%I=30@>OKWPjiMY4b8SfTN%jouN9|V!}5y&9h0NF59ZC$ahAL6*iUK%8i(cUo#f% z$PrPP%F&A>?fNzOB#XZMnv%{HJ2`^$sr){+$a0=5|AdTa^{{R97;=^uh-+XEpS9MX^P>-oXe0JX`B|HLR9V9rM)aOLXCtL3<|@73jPuA^LX wVLaJM^=*@yE^ni%*x2sxsyj=k!Zo*StXYJlHgQ!PW}yl*YKon-?1^o^0!@(E7XSbN diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6afd0d792f..1077762ac3 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11654,6 +11654,28 @@ ], "type": "object" }, + "SystemConfigGeneratedImageDto": { + "properties": { + "format": { + "$ref": "#/components/schemas/ImageFormat" + }, + "quality": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "size": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "format", + "quality", + "size" + ], + "type": "object" + }, "SystemConfigImageDto": { "properties": { "colorspace": { @@ -11662,34 +11684,18 @@ "extractEmbedded": { "type": "boolean" }, - "previewFormat": { - "$ref": "#/components/schemas/ImageFormat" + "preview": { + "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" }, - "previewSize": { - "minimum": 1, - "type": "integer" - }, - "quality": { - "maximum": 100, - "minimum": 1, - "type": "integer" - }, - "thumbnailFormat": { - "$ref": "#/components/schemas/ImageFormat" - }, - "thumbnailSize": { - "minimum": 1, - "type": "integer" + "thumbnail": { + "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" } }, "required": [ "colorspace", "extractEmbedded", - "previewFormat", - "previewSize", - "quality", - "thumbnailFormat", - "thumbnailSize" + "preview", + "thumbnail" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b1ae5d2876..e88f431e8c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1100,14 +1100,16 @@ export type SystemConfigFFmpegDto = { transcode: TranscodePolicy; twoPass: boolean; }; +export type SystemConfigGeneratedImageDto = { + format: ImageFormat; + quality: number; + size: number; +}; export type SystemConfigImageDto = { colorspace: Colorspace; extractEmbedded: boolean; - previewFormat: ImageFormat; - previewSize: number; - quality: number; - thumbnailFormat: ImageFormat; - thumbnailSize: number; + preview: SystemConfigGeneratedImageDto; + thumbnail: SystemConfigGeneratedImageDto; }; export type JobSettingsDto = { concurrency: number; diff --git a/server/src/config.ts b/server/src/config.ts index 1522371487..3317351f9f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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({ 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, }, diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 4a3ca37691..c12a54cd61 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -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; diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 7193684e7a..64ba6236e8 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -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; } diff --git a/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts new file mode 100644 index 0000000000..e02203997f --- /dev/null +++ b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeparateQualityForThumbnailAndPreview1727471863507 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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'`); + } +} diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index ddda8f64fc..c0903fa101 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -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 }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 720bef6c76..1b69c5acd5 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -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 { - 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 { - 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; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 7cb76d1a71..651c8eebee 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -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; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 8b4fb0bc2f..514d8aa0f8 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -135,11 +135,16 @@ const updatedConfig = Object.freeze({ 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, }, diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index d6fc814b98..b5e381d5f8 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -11,6 +11,7 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; + import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; @@ -24,73 +25,96 @@
- + + - + - + + - + + - + + + +