diff --git a/i18n/en.json b/i18n/en.json index c1c06f47f..4215f1bc0 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 fbeb704b2..f36105f59 100644 Binary files a/mobile/openapi/lib/model/system_config_generated_fullsize_image_dto.dart and b/mobile/openapi/lib/model/system_config_generated_fullsize_image_dto.dart differ 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 2192a7cb0..7dd5b2be7 100644 Binary files a/mobile/openapi/lib/model/system_config_generated_image_dto.dart and b/mobile/openapi/lib/model/system_config_generated_image_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fb329d265..1129dd486 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 41d4f2689..a6bbf5cdd 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 62f7841b4..2a43b5118 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 31b814503..4301d7193 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 699c31ba5..33025e73c 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 4310f678a..8e6440eb7 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 6d282004a..8684b78c2 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 fdeabd3a9..1c93c9d7d 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 afcaa6509..9f8c8011e 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 afed6b373..e1f4ec500 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} + />