mirror of
https://github.com/immich-app/immich.git
synced 2026-02-13 20:37:51 +03:00
feat: generate progressive JPEGs for thumbnails (#25463)
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user