From 357ec1394a7c525c96fffad362b1cc5a2e44ff0d Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 23 Jan 2026 19:35:07 -0500 Subject: [PATCH] feat: generate progressive JPEGs for thumbnails (#25463) --- i18n/en.json | 2 + ...m_config_generated_fullsize_image_dto.dart | 10 +- .../system_config_generated_image_dto.dart | 10 +- open-api/immich-openapi-specs.json | 8 ++ open-api/typescript-sdk/src/fetch-client.ts | 2 + server/src/config.ts | 3 + server/src/dtos/system-config.dto.ts | 6 ++ server/src/repositories/media.repository.ts | 1 + server/src/services/media.service.spec.ts | 94 +++++++++++++++++++ server/src/services/media.service.ts | 2 + .../services/system-config.service.spec.ts | 4 +- server/src/types.ts | 4 +- .../admin-settings/ImageSettings.svelte | 44 +++++++++ 13 files changed, 186 insertions(+), 4 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index c1c06f47fc..4215f1bc0e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -104,6 +104,8 @@ "image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning", "image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.", "image_preview_title": "Preview Settings", + "image_progressive": "Progressive", + "image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.", "image_quality": "Quality", "image_resolution": "Resolution", "image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.", diff --git a/mobile/openapi/lib/model/system_config_generated_fullsize_image_dto.dart b/mobile/openapi/lib/model/system_config_generated_fullsize_image_dto.dart index fbeb704b27..f36105f590 100644 --- a/mobile/openapi/lib/model/system_config_generated_fullsize_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_generated_fullsize_image_dto.dart @@ -15,6 +15,7 @@ class SystemConfigGeneratedFullsizeImageDto { SystemConfigGeneratedFullsizeImageDto({ required this.enabled, required this.format, + required this.progressive, required this.quality, }); @@ -22,6 +23,8 @@ class SystemConfigGeneratedFullsizeImageDto { ImageFormat format; + bool progressive; + /// Minimum value: 1 /// Maximum value: 100 int quality; @@ -30,6 +33,7 @@ class SystemConfigGeneratedFullsizeImageDto { bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedFullsizeImageDto && other.enabled == enabled && other.format == format && + other.progressive == progressive && other.quality == quality; @override @@ -37,15 +41,17 @@ class SystemConfigGeneratedFullsizeImageDto { // ignore: unnecessary_parenthesis (enabled.hashCode) + (format.hashCode) + + (progressive.hashCode) + (quality.hashCode); @override - String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, quality=$quality]'; + String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, progressive=$progressive, quality=$quality]'; Map toJson() { final json = {}; json[r'enabled'] = this.enabled; json[r'format'] = this.format; + json[r'progressive'] = this.progressive; json[r'quality'] = this.quality; return json; } @@ -61,6 +67,7 @@ class SystemConfigGeneratedFullsizeImageDto { return SystemConfigGeneratedFullsizeImageDto( enabled: mapValueOfType(json, r'enabled')!, format: ImageFormat.fromJson(json[r'format'])!, + progressive: mapValueOfType(json, r'progressive')!, quality: mapValueOfType(json, r'quality')!, ); } @@ -111,6 +118,7 @@ class SystemConfigGeneratedFullsizeImageDto { static const requiredKeys = { 'enabled', 'format', + 'progressive', 'quality', }; } diff --git a/mobile/openapi/lib/model/system_config_generated_image_dto.dart b/mobile/openapi/lib/model/system_config_generated_image_dto.dart index 2192a7cb0c..7dd5b2be7e 100644 --- a/mobile/openapi/lib/model/system_config_generated_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_generated_image_dto.dart @@ -14,12 +14,15 @@ class SystemConfigGeneratedImageDto { /// Returns a new [SystemConfigGeneratedImageDto] instance. SystemConfigGeneratedImageDto({ required this.format, + required this.progressive, required this.quality, required this.size, }); ImageFormat format; + bool progressive; + /// Minimum value: 1 /// Maximum value: 100 int quality; @@ -30,6 +33,7 @@ class SystemConfigGeneratedImageDto { @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto && other.format == format && + other.progressive == progressive && other.quality == quality && other.size == size; @@ -37,15 +41,17 @@ class SystemConfigGeneratedImageDto { int get hashCode => // ignore: unnecessary_parenthesis (format.hashCode) + + (progressive.hashCode) + (quality.hashCode) + (size.hashCode); @override - String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]'; + String toString() => 'SystemConfigGeneratedImageDto[format=$format, progressive=$progressive, quality=$quality, size=$size]'; Map toJson() { final json = {}; json[r'format'] = this.format; + json[r'progressive'] = this.progressive; json[r'quality'] = this.quality; json[r'size'] = this.size; return json; @@ -61,6 +67,7 @@ class SystemConfigGeneratedImageDto { return SystemConfigGeneratedImageDto( format: ImageFormat.fromJson(json[r'format'])!, + progressive: mapValueOfType(json, r'progressive')!, quality: mapValueOfType(json, r'quality')!, size: mapValueOfType(json, r'size')!, ); @@ -111,6 +118,7 @@ class SystemConfigGeneratedImageDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'format', + 'progressive', 'quality', 'size', }; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fb329d2653..1129dd4862 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -22624,6 +22624,9 @@ } ] }, + "progressive": { + "type": "boolean" + }, "quality": { "maximum": 100, "minimum": 1, @@ -22633,6 +22636,7 @@ "required": [ "enabled", "format", + "progressive", "quality" ], "type": "object" @@ -22646,6 +22650,9 @@ } ] }, + "progressive": { + "type": "boolean" + }, "quality": { "maximum": 100, "minimum": 1, @@ -22658,6 +22665,7 @@ }, "required": [ "format", + "progressive", "quality", "size" ], diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 41d4f2689d..a6bbf5cdde 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1538,10 +1538,12 @@ export type SystemConfigFFmpegDto = { export type SystemConfigGeneratedFullsizeImageDto = { enabled: boolean; format: ImageFormat; + progressive: boolean; quality: number; }; export type SystemConfigGeneratedImageDto = { format: ImageFormat; + progressive: boolean; quality: number; size: number; }; diff --git a/server/src/config.ts b/server/src/config.ts index 62f7841b4a..2a43b51187 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -319,11 +319,13 @@ export const defaults = Object.freeze({ format: ImageFormat.Webp, size: 250, quality: 80, + progressive: false, }, preview: { format: ImageFormat.Jpeg, size: 1440, quality: 80, + progressive: false, }, colorspace: Colorspace.P3, extractEmbedded: false, @@ -331,6 +333,7 @@ export const defaults = Object.freeze({ enabled: false, format: ImageFormat.Jpeg, quality: 80, + progressive: false, }, }, newVersionCheck: { diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 31b8145034..4301d7193f 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -585,6 +585,9 @@ class SystemConfigGeneratedImageDto { @Type(() => Number) @ApiProperty({ type: 'integer' }) size!: number; + + @ValidateBoolean() + progressive!: boolean; } class SystemConfigGeneratedFullsizeImageDto { @@ -600,6 +603,9 @@ class SystemConfigGeneratedFullsizeImageDto { @Type(() => Number) @ApiProperty({ type: 'integer' }) quality!: number; + + @ValidateBoolean() + progressive!: boolean; } export class SystemConfigImageDto { diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 699c31ba5b..33025e73cf 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -176,6 +176,7 @@ export class MediaRepository { quality: options.quality, // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', + progressive: options.progressive, }); await decoded.toFile(output); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 4310f678ab..8e6440eb7a 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -352,6 +352,7 @@ describe(MediaService.name, () => { format: ImageFormat.Jpeg, size: 1440, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -365,6 +366,7 @@ describe(MediaService.name, () => { format: ImageFormat.Webp, size: 250, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -575,6 +577,7 @@ describe(MediaService.name, () => { format, size: 1440, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -588,6 +591,7 @@ describe(MediaService.name, () => { format: ImageFormat.Webp, size: 250, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -622,6 +626,7 @@ describe(MediaService.name, () => { format: ImageFormat.Jpeg, size: 1440, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -635,6 +640,7 @@ describe(MediaService.name, () => { format, size: 250, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -643,6 +649,58 @@ describe(MediaService.name, () => { ); }); + it('should generate progressive JPEG for preview when enabled', async () => { + mocks.systemMetadata.get.mockResolvedValue({ + image: { preview: { progressive: true }, thumbnail: { progressive: false } }, + }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ + format: ImageFormat.Jpeg, + progressive: true, + }), + expect.stringContaining('preview.jpeg'), + ); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ + format: ImageFormat.Webp, + progressive: false, + }), + expect.stringContaining('thumbnail.webp'), + ); + }); + + it('should generate progressive JPEG for thumbnail when enabled', async () => { + mocks.systemMetadata.get.mockResolvedValue({ + image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } }, + }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ + format: ImageFormat.Jpeg, + progressive: false, + }), + expect.stringContaining('preview.jpeg'), + ); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ + format: ImageFormat.Jpeg, + progressive: true, + }), + expect.stringContaining('thumbnail.jpeg'), + ); + }); + it('should delete previous thumbnail if different path', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); @@ -776,6 +834,7 @@ describe(MediaService.name, () => { format: ImageFormat.Jpeg, size: 1440, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -807,6 +866,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Webp, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -820,6 +880,7 @@ describe(MediaService.name, () => { format: ImageFormat.Jpeg, size: 1440, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -849,6 +910,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -861,6 +923,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, + progressive: false, size: 1440, processInvalidImages: false, raw: rawInfo, @@ -892,6 +955,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -948,6 +1012,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.Srgb, format: ImageFormat.Jpeg, quality: 80, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -987,6 +1052,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Webp, quality: 90, + progressive: false, processInvalidImages: false, raw: rawInfo, edits: [], @@ -994,6 +1060,27 @@ describe(MediaService.name, () => { expect.any(String), ); }); + + it('should generate progressive JPEG for fullsize when enabled', async () => { + mocks.systemMetadata.get.mockResolvedValue({ + image: { fullsize: { enabled: true, format: ImageFormat.Jpeg, progressive: true } }, + }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); + mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ + format: ImageFormat.Jpeg, + progressive: true, + }), + expect.stringContaining('fullsize.jpeg'), + ); + }); }); describe('handleAssetEditThumbnailGeneration', () => { @@ -1198,6 +1285,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, + progressive: false, edits: [ { action: 'crop', @@ -1242,6 +1330,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, + progressive: false, edits: [ { action: 'crop', @@ -1284,6 +1373,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, + progressive: false, edits: [ { action: 'crop', @@ -1326,6 +1416,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, + progressive: false, edits: [ { action: 'crop', @@ -1368,6 +1459,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, + progressive: false, edits: [ { action: 'crop', @@ -1410,6 +1502,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, + progressive: false, edits: [ { action: 'crop', @@ -1457,6 +1550,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, + progressive: false, edits: [ { action: 'crop', diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 6d282004a3..8684b78c2f 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -351,6 +351,7 @@ export class MediaService extends BaseService { const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, + progressive: image.fullsize.progressive, ...thumbnailOptions, }; promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); @@ -434,6 +435,7 @@ export class MediaService extends BaseService { format: ImageFormat.Jpeg, raw: info, quality: image.thumbnail.quality, + progressive: false, processInvalidImages: false, size: FACE_THUMBNAIL_SIZE, edits: [ diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index fdeabd3a90..1c93c9d7d3 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -167,13 +167,15 @@ const updatedConfig = Object.freeze({ size: 250, format: ImageFormat.Webp, quality: 80, + progressive: false, }, preview: { size: 1440, format: ImageFormat.Jpeg, quality: 80, + progressive: false, }, - fullsize: { enabled: false, format: ImageFormat.Jpeg, quality: 80 }, + fullsize: { enabled: false, format: ImageFormat.Jpeg, quality: 80, progressive: false }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/server/src/types.ts b/server/src/types.ts index afcaa6509b..9f8c8011e5 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -36,12 +36,14 @@ export type FullsizeImageOptions = { format: ImageFormat; quality: number; enabled: boolean; + progressive: boolean; }; export type ImageOptions = { format: ImageFormat; quality: number; size: number; + progressive: boolean; }; export type RawImageInfo = { @@ -62,7 +64,7 @@ export interface DecodeToBufferOptions extends DecodeImageOptions { orientation?: ExifOrientation; } -export type GenerateThumbnailOptions = Pick & DecodeToBufferOptions; +export type GenerateThumbnailOptions = Pick & DecodeToBufferOptions; export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo }; diff --git a/web/src/lib/components/admin-settings/ImageSettings.svelte b/web/src/lib/components/admin-settings/ImageSettings.svelte index afed6b3738..e1f4ec5008 100644 --- a/web/src/lib/components/admin-settings/ImageSettings.svelte +++ b/web/src/lib/components/admin-settings/ImageSettings.svelte @@ -37,6 +37,11 @@ name="format" isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format} {disabled} + onSelect={(value) => { + if (value === ImageFormat.Webp) { + configToEdit.image.thumbnail.progressive = false; + } + }} /> + + (configToEdit.image.thumbnail.progressive = isChecked)} + isEdited={configToEdit.image.thumbnail.progressive !== config.image.thumbnail.progressive} + disabled={disabled || configToEdit.image.thumbnail.format === ImageFormat.Webp} + /> { + if (value === ImageFormat.Webp) { + configToEdit.image.preview.progressive = false; + } + }} /> + + (configToEdit.image.preview.progressive = isChecked)} + isEdited={configToEdit.image.preview.progressive !== config.image.preview.progressive} + disabled={disabled || configToEdit.image.preview.format === ImageFormat.Webp} + /> { + if (value === ImageFormat.Webp) { + configToEdit.image.fullsize.progressive = false; + } + }} /> + + (configToEdit.image.fullsize.progressive = isChecked)} + isEdited={configToEdit.image.fullsize.progressive !== config.image.fullsize.progressive} + disabled={disabled || + !configToEdit.image.fullsize.enabled || + configToEdit.image.fullsize.format === ImageFormat.Webp} + />