mirror of
https://git.vectorsigma.ru/public/immich.git
synced 2026-07-28 20:28:12 +00:00
feat: generate progressive JPEGs for thumbnails (#25463)
This commit is contained in:
@@ -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_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_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_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_quality": "Quality",
|
||||||
"image_resolution": "Resolution",
|
"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.",
|
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class SystemConfigGeneratedFullsizeImageDto {
|
|||||||
SystemConfigGeneratedFullsizeImageDto({
|
SystemConfigGeneratedFullsizeImageDto({
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
required this.format,
|
required this.format,
|
||||||
|
required this.progressive,
|
||||||
required this.quality,
|
required this.quality,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ class SystemConfigGeneratedFullsizeImageDto {
|
|||||||
|
|
||||||
ImageFormat format;
|
ImageFormat format;
|
||||||
|
|
||||||
|
bool progressive;
|
||||||
|
|
||||||
/// Minimum value: 1
|
/// Minimum value: 1
|
||||||
/// Maximum value: 100
|
/// Maximum value: 100
|
||||||
int quality;
|
int quality;
|
||||||
@@ -30,6 +33,7 @@ class SystemConfigGeneratedFullsizeImageDto {
|
|||||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedFullsizeImageDto &&
|
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedFullsizeImageDto &&
|
||||||
other.enabled == enabled &&
|
other.enabled == enabled &&
|
||||||
other.format == format &&
|
other.format == format &&
|
||||||
|
other.progressive == progressive &&
|
||||||
other.quality == quality;
|
other.quality == quality;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -37,15 +41,17 @@ class SystemConfigGeneratedFullsizeImageDto {
|
|||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(enabled.hashCode) +
|
(enabled.hashCode) +
|
||||||
(format.hashCode) +
|
(format.hashCode) +
|
||||||
|
(progressive.hashCode) +
|
||||||
(quality.hashCode);
|
(quality.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, quality=$quality]';
|
String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, progressive=$progressive, quality=$quality]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'enabled'] = this.enabled;
|
json[r'enabled'] = this.enabled;
|
||||||
json[r'format'] = this.format;
|
json[r'format'] = this.format;
|
||||||
|
json[r'progressive'] = this.progressive;
|
||||||
json[r'quality'] = this.quality;
|
json[r'quality'] = this.quality;
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
@@ -61,6 +67,7 @@ class SystemConfigGeneratedFullsizeImageDto {
|
|||||||
return SystemConfigGeneratedFullsizeImageDto(
|
return SystemConfigGeneratedFullsizeImageDto(
|
||||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||||
format: ImageFormat.fromJson(json[r'format'])!,
|
format: ImageFormat.fromJson(json[r'format'])!,
|
||||||
|
progressive: mapValueOfType<bool>(json, r'progressive')!,
|
||||||
quality: mapValueOfType<int>(json, r'quality')!,
|
quality: mapValueOfType<int>(json, r'quality')!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -111,6 +118,7 @@ class SystemConfigGeneratedFullsizeImageDto {
|
|||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'enabled',
|
'enabled',
|
||||||
'format',
|
'format',
|
||||||
|
'progressive',
|
||||||
'quality',
|
'quality',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ class SystemConfigGeneratedImageDto {
|
|||||||
/// Returns a new [SystemConfigGeneratedImageDto] instance.
|
/// Returns a new [SystemConfigGeneratedImageDto] instance.
|
||||||
SystemConfigGeneratedImageDto({
|
SystemConfigGeneratedImageDto({
|
||||||
required this.format,
|
required this.format,
|
||||||
|
required this.progressive,
|
||||||
required this.quality,
|
required this.quality,
|
||||||
required this.size,
|
required this.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
ImageFormat format;
|
ImageFormat format;
|
||||||
|
|
||||||
|
bool progressive;
|
||||||
|
|
||||||
/// Minimum value: 1
|
/// Minimum value: 1
|
||||||
/// Maximum value: 100
|
/// Maximum value: 100
|
||||||
int quality;
|
int quality;
|
||||||
@@ -30,6 +33,7 @@ class SystemConfigGeneratedImageDto {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto &&
|
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto &&
|
||||||
other.format == format &&
|
other.format == format &&
|
||||||
|
other.progressive == progressive &&
|
||||||
other.quality == quality &&
|
other.quality == quality &&
|
||||||
other.size == size;
|
other.size == size;
|
||||||
|
|
||||||
@@ -37,15 +41,17 @@ class SystemConfigGeneratedImageDto {
|
|||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(format.hashCode) +
|
(format.hashCode) +
|
||||||
|
(progressive.hashCode) +
|
||||||
(quality.hashCode) +
|
(quality.hashCode) +
|
||||||
(size.hashCode);
|
(size.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]';
|
String toString() => 'SystemConfigGeneratedImageDto[format=$format, progressive=$progressive, quality=$quality, size=$size]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'format'] = this.format;
|
json[r'format'] = this.format;
|
||||||
|
json[r'progressive'] = this.progressive;
|
||||||
json[r'quality'] = this.quality;
|
json[r'quality'] = this.quality;
|
||||||
json[r'size'] = this.size;
|
json[r'size'] = this.size;
|
||||||
return json;
|
return json;
|
||||||
@@ -61,6 +67,7 @@ class SystemConfigGeneratedImageDto {
|
|||||||
|
|
||||||
return SystemConfigGeneratedImageDto(
|
return SystemConfigGeneratedImageDto(
|
||||||
format: ImageFormat.fromJson(json[r'format'])!,
|
format: ImageFormat.fromJson(json[r'format'])!,
|
||||||
|
progressive: mapValueOfType<bool>(json, r'progressive')!,
|
||||||
quality: mapValueOfType<int>(json, r'quality')!,
|
quality: mapValueOfType<int>(json, r'quality')!,
|
||||||
size: mapValueOfType<int>(json, r'size')!,
|
size: mapValueOfType<int>(json, r'size')!,
|
||||||
);
|
);
|
||||||
@@ -111,6 +118,7 @@ class SystemConfigGeneratedImageDto {
|
|||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'format',
|
'format',
|
||||||
|
'progressive',
|
||||||
'quality',
|
'quality',
|
||||||
'size',
|
'size',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22624,6 +22624,9 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"progressive": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"quality": {
|
"quality": {
|
||||||
"maximum": 100,
|
"maximum": 100,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
@@ -22633,6 +22636,7 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"enabled",
|
"enabled",
|
||||||
"format",
|
"format",
|
||||||
|
"progressive",
|
||||||
"quality"
|
"quality"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -22646,6 +22650,9 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"progressive": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"quality": {
|
"quality": {
|
||||||
"maximum": 100,
|
"maximum": 100,
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
@@ -22658,6 +22665,7 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"format",
|
"format",
|
||||||
|
"progressive",
|
||||||
"quality",
|
"quality",
|
||||||
"size"
|
"size"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1538,10 +1538,12 @@ export type SystemConfigFFmpegDto = {
|
|||||||
export type SystemConfigGeneratedFullsizeImageDto = {
|
export type SystemConfigGeneratedFullsizeImageDto = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
format: ImageFormat;
|
format: ImageFormat;
|
||||||
|
progressive: boolean;
|
||||||
quality: number;
|
quality: number;
|
||||||
};
|
};
|
||||||
export type SystemConfigGeneratedImageDto = {
|
export type SystemConfigGeneratedImageDto = {
|
||||||
format: ImageFormat;
|
format: ImageFormat;
|
||||||
|
progressive: boolean;
|
||||||
quality: number;
|
quality: number;
|
||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -319,11 +319,13 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
format: ImageFormat.Webp,
|
format: ImageFormat.Webp,
|
||||||
size: 250,
|
size: 250,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
size: 1440,
|
size: 1440,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
},
|
},
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
extractEmbedded: false,
|
extractEmbedded: false,
|
||||||
@@ -331,6 +333,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
newVersionCheck: {
|
newVersionCheck: {
|
||||||
|
|||||||
@@ -585,6 +585,9 @@ class SystemConfigGeneratedImageDto {
|
|||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer' })
|
||||||
size!: number;
|
size!: number;
|
||||||
|
|
||||||
|
@ValidateBoolean()
|
||||||
|
progressive!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class SystemConfigGeneratedFullsizeImageDto {
|
class SystemConfigGeneratedFullsizeImageDto {
|
||||||
@@ -600,6 +603,9 @@ class SystemConfigGeneratedFullsizeImageDto {
|
|||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer' })
|
||||||
quality!: number;
|
quality!: number;
|
||||||
|
|
||||||
|
@ValidateBoolean()
|
||||||
|
progressive!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SystemConfigImageDto {
|
export class SystemConfigImageDto {
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export class MediaRepository {
|
|||||||
quality: options.quality,
|
quality: options.quality,
|
||||||
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
// 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',
|
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
|
||||||
|
progressive: options.progressive,
|
||||||
});
|
});
|
||||||
|
|
||||||
await decoded.toFile(output);
|
await decoded.toFile(output);
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ describe(MediaService.name, () => {
|
|||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
size: 1440,
|
size: 1440,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -365,6 +366,7 @@ describe(MediaService.name, () => {
|
|||||||
format: ImageFormat.Webp,
|
format: ImageFormat.Webp,
|
||||||
size: 250,
|
size: 250,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -575,6 +577,7 @@ describe(MediaService.name, () => {
|
|||||||
format,
|
format,
|
||||||
size: 1440,
|
size: 1440,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -588,6 +591,7 @@ describe(MediaService.name, () => {
|
|||||||
format: ImageFormat.Webp,
|
format: ImageFormat.Webp,
|
||||||
size: 250,
|
size: 250,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -622,6 +626,7 @@ describe(MediaService.name, () => {
|
|||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
size: 1440,
|
size: 1440,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -635,6 +640,7 @@ describe(MediaService.name, () => {
|
|||||||
format,
|
format,
|
||||||
size: 250,
|
size: 250,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
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 () => {
|
it('should delete previous thumbnail if different path', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } });
|
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } });
|
||||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||||
@@ -776,6 +834,7 @@ describe(MediaService.name, () => {
|
|||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
size: 1440,
|
size: 1440,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -807,6 +866,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Webp,
|
format: ImageFormat.Webp,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -820,6 +880,7 @@ describe(MediaService.name, () => {
|
|||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
size: 1440,
|
size: 1440,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -849,6 +910,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -861,6 +923,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
size: 1440,
|
size: 1440,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
@@ -892,6 +955,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -948,6 +1012,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.Srgb,
|
colorspace: Colorspace.Srgb,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -987,6 +1052,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Webp,
|
format: ImageFormat.Webp,
|
||||||
quality: 90,
|
quality: 90,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
raw: rawInfo,
|
raw: rawInfo,
|
||||||
edits: [],
|
edits: [],
|
||||||
@@ -994,6 +1060,27 @@ describe(MediaService.name, () => {
|
|||||||
expect.any(String),
|
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', () => {
|
describe('handleAssetEditThumbnailGeneration', () => {
|
||||||
@@ -1198,6 +1285,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
action: 'crop',
|
action: 'crop',
|
||||||
@@ -1242,6 +1330,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
action: 'crop',
|
action: 'crop',
|
||||||
@@ -1284,6 +1373,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
action: 'crop',
|
action: 'crop',
|
||||||
@@ -1326,6 +1416,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
action: 'crop',
|
action: 'crop',
|
||||||
@@ -1368,6 +1459,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
action: 'crop',
|
action: 'crop',
|
||||||
@@ -1410,6 +1502,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
action: 'crop',
|
action: 'crop',
|
||||||
@@ -1457,6 +1550,7 @@ describe(MediaService.name, () => {
|
|||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
action: 'crop',
|
action: 'crop',
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ export class MediaService extends BaseService {
|
|||||||
const fullsizeOptions = {
|
const fullsizeOptions = {
|
||||||
format: image.fullsize.format,
|
format: image.fullsize.format,
|
||||||
quality: image.fullsize.quality,
|
quality: image.fullsize.quality,
|
||||||
|
progressive: image.fullsize.progressive,
|
||||||
...thumbnailOptions,
|
...thumbnailOptions,
|
||||||
};
|
};
|
||||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
||||||
@@ -434,6 +435,7 @@ export class MediaService extends BaseService {
|
|||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
raw: info,
|
raw: info,
|
||||||
quality: image.thumbnail.quality,
|
quality: image.thumbnail.quality,
|
||||||
|
progressive: false,
|
||||||
processInvalidImages: false,
|
processInvalidImages: false,
|
||||||
size: FACE_THUMBNAIL_SIZE,
|
size: FACE_THUMBNAIL_SIZE,
|
||||||
edits: [
|
edits: [
|
||||||
|
|||||||
@@ -167,13 +167,15 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||||||
size: 250,
|
size: 250,
|
||||||
format: ImageFormat.Webp,
|
format: ImageFormat.Webp,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
|
progressive: false,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
size: 1440,
|
size: 1440,
|
||||||
format: ImageFormat.Jpeg,
|
format: ImageFormat.Jpeg,
|
||||||
quality: 80,
|
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,
|
colorspace: Colorspace.P3,
|
||||||
extractEmbedded: false,
|
extractEmbedded: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,12 +36,14 @@ export type FullsizeImageOptions = {
|
|||||||
format: ImageFormat;
|
format: ImageFormat;
|
||||||
quality: number;
|
quality: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
progressive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ImageOptions = {
|
export type ImageOptions = {
|
||||||
format: ImageFormat;
|
format: ImageFormat;
|
||||||
quality: number;
|
quality: number;
|
||||||
size: number;
|
size: number;
|
||||||
|
progressive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RawImageInfo = {
|
export type RawImageInfo = {
|
||||||
@@ -62,7 +64,7 @@ export interface DecodeToBufferOptions extends DecodeImageOptions {
|
|||||||
orientation?: ExifOrientation;
|
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 };
|
export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo };
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
name="format"
|
name="format"
|
||||||
isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format}
|
isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (value === ImageFormat.Webp) {
|
||||||
|
configToEdit.image.thumbnail.progressive = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
@@ -64,6 +69,15 @@
|
|||||||
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
|
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
|
||||||
{disabled}
|
{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>
|
||||||
|
|
||||||
<SettingAccordion
|
<SettingAccordion
|
||||||
@@ -82,6 +96,11 @@
|
|||||||
name="format"
|
name="format"
|
||||||
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
|
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (value === ImageFormat.Webp) {
|
||||||
|
configToEdit.image.preview.progressive = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
@@ -108,6 +127,15 @@
|
|||||||
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
|
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
|
||||||
{disabled}
|
{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>
|
||||||
|
|
||||||
<SettingAccordion
|
<SettingAccordion
|
||||||
@@ -137,6 +165,11 @@
|
|||||||
name="format"
|
name="format"
|
||||||
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
|
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
|
||||||
disabled={disabled || !configToEdit.image.fullsize.enabled}
|
disabled={disabled || !configToEdit.image.fullsize.enabled}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (value === ImageFormat.Webp) {
|
||||||
|
configToEdit.image.fullsize.progressive = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingInputField
|
<SettingInputField
|
||||||
@@ -147,6 +180,17 @@
|
|||||||
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
|
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
|
||||||
disabled={disabled || !configToEdit.image.fullsize.enabled}
|
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>
|
</SettingAccordion>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user