feat: generate progressive JPEGs for thumbnails (#25463)

This commit is contained in:
Min Idzelis 2026-01-23 19:35:07 -05:00 committed by GitHub
parent 4fedae4150
commit 357ec1394a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 168 additions and 2 deletions

View file

@ -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.",

View file

@ -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"
],

View file

@ -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;
};

View file

@ -319,11 +319,13 @@ export const defaults = Object.freeze<SystemConfig>({
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<SystemConfig>({
enabled: false,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
},
},
newVersionCheck: {

View file

@ -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 {

View file

@ -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);

View file

@ -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',

View file

@ -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: [

View file

@ -167,13 +167,15 @@ const updatedConfig = Object.freeze<SystemConfig>({
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,
},

View file

@ -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<ImageOptions, 'format' | 'quality'> & DecodeToBufferOptions;
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality' | 'progressive'> & DecodeToBufferOptions;
export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo };

View file

@ -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;
}
}}
/>
<SettingSelect
@ -64,6 +69,15 @@
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
{disabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.thumbnail.progressive}
onToggle={(isChecked) => (configToEdit.image.thumbnail.progressive = isChecked)}
isEdited={configToEdit.image.thumbnail.progressive !== config.image.thumbnail.progressive}
disabled={disabled || configToEdit.image.thumbnail.format === ImageFormat.Webp}
/>
</SettingAccordion>
<SettingAccordion
@ -82,6 +96,11 @@
name="format"
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
{disabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.preview.progressive = false;
}
}}
/>
<SettingSelect
@ -108,6 +127,15 @@
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
{disabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.preview.progressive}
onToggle={(isChecked) => (configToEdit.image.preview.progressive = isChecked)}
isEdited={configToEdit.image.preview.progressive !== config.image.preview.progressive}
disabled={disabled || configToEdit.image.preview.format === ImageFormat.Webp}
/>
</SettingAccordion>
<SettingAccordion
@ -137,6 +165,11 @@
name="format"
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
disabled={disabled || !configToEdit.image.fullsize.enabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.fullsize.progressive = false;
}
}}
/>
<SettingInputField
@ -147,6 +180,17 @@
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
disabled={disabled || !configToEdit.image.fullsize.enabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.fullsize.progressive}
onToggle={(isChecked) => (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}
/>
</SettingAccordion>
<div class="mt-4">