mirror of
https://github.com/immich-app/immich.git
synced 2025-01-21 00:52:43 -05:00
feat(server): separate quality for thumbnail and preview images (#13006)
* allow different thumbnail and preview quality, better config structure * update web and api * wording * remove empty line?
This commit is contained in:
parent
4248594ac5
commit
995f0fda47
17 changed files with 369 additions and 198 deletions
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
|
@ -416,6 +416,7 @@ Class | Method | HTTP request | Description
|
|||
- [SystemConfigDto](doc//SystemConfigDto.md)
|
||||
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
|
||||
- [SystemConfigFacesDto](doc//SystemConfigFacesDto.md)
|
||||
- [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md)
|
||||
- [SystemConfigImageDto](doc//SystemConfigImageDto.md)
|
||||
- [SystemConfigJobDto](doc//SystemConfigJobDto.md)
|
||||
- [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md)
|
||||
|
|
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
|
@ -229,6 +229,7 @@ part 'model/stack_update_dto.dart';
|
|||
part 'model/system_config_dto.dart';
|
||||
part 'model/system_config_f_fmpeg_dto.dart';
|
||||
part 'model/system_config_faces_dto.dart';
|
||||
part 'model/system_config_generated_image_dto.dart';
|
||||
part 'model/system_config_image_dto.dart';
|
||||
part 'model/system_config_job_dto.dart';
|
||||
part 'model/system_config_library_dto.dart';
|
||||
|
|
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
|
@ -512,6 +512,8 @@ class ApiClient {
|
|||
return SystemConfigFFmpegDto.fromJson(value);
|
||||
case 'SystemConfigFacesDto':
|
||||
return SystemConfigFacesDto.fromJson(value);
|
||||
case 'SystemConfigGeneratedImageDto':
|
||||
return SystemConfigGeneratedImageDto.fromJson(value);
|
||||
case 'SystemConfigImageDto':
|
||||
return SystemConfigImageDto.fromJson(value);
|
||||
case 'SystemConfigJobDto':
|
||||
|
|
118
mobile/openapi/lib/model/system_config_generated_image_dto.dart
generated
Normal file
118
mobile/openapi/lib/model/system_config_generated_image_dto.dart
generated
Normal file
|
@ -0,0 +1,118 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SystemConfigGeneratedImageDto {
|
||||
/// Returns a new [SystemConfigGeneratedImageDto] instance.
|
||||
SystemConfigGeneratedImageDto({
|
||||
required this.format,
|
||||
required this.quality,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
ImageFormat format;
|
||||
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 100
|
||||
int quality;
|
||||
|
||||
/// Minimum value: 1
|
||||
int size;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto &&
|
||||
other.format == format &&
|
||||
other.quality == quality &&
|
||||
other.size == size;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(format.hashCode) +
|
||||
(quality.hashCode) +
|
||||
(size.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'format'] = this.format;
|
||||
json[r'quality'] = this.quality;
|
||||
json[r'size'] = this.size;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigGeneratedImageDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigGeneratedImageDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SystemConfigGeneratedImageDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigGeneratedImageDto(
|
||||
format: ImageFormat.fromJson(json[r'format'])!,
|
||||
quality: mapValueOfType<int>(json, r'quality')!,
|
||||
size: mapValueOfType<int>(json, r'size')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigGeneratedImageDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigGeneratedImageDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigGeneratedImageDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigGeneratedImageDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigGeneratedImageDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigGeneratedImageDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigGeneratedImageDto-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigGeneratedImageDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigGeneratedImageDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SystemConfigGeneratedImageDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'format',
|
||||
'quality',
|
||||
'size',
|
||||
};
|
||||
}
|
||||
|
|
@ -15,64 +15,42 @@ class SystemConfigImageDto {
|
|||
SystemConfigImageDto({
|
||||
required this.colorspace,
|
||||
required this.extractEmbedded,
|
||||
required this.previewFormat,
|
||||
required this.previewSize,
|
||||
required this.quality,
|
||||
required this.thumbnailFormat,
|
||||
required this.thumbnailSize,
|
||||
required this.preview,
|
||||
required this.thumbnail,
|
||||
});
|
||||
|
||||
Colorspace colorspace;
|
||||
|
||||
bool extractEmbedded;
|
||||
|
||||
ImageFormat previewFormat;
|
||||
SystemConfigGeneratedImageDto preview;
|
||||
|
||||
/// Minimum value: 1
|
||||
int previewSize;
|
||||
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 100
|
||||
int quality;
|
||||
|
||||
ImageFormat thumbnailFormat;
|
||||
|
||||
/// Minimum value: 1
|
||||
int thumbnailSize;
|
||||
SystemConfigGeneratedImageDto thumbnail;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigImageDto &&
|
||||
other.colorspace == colorspace &&
|
||||
other.extractEmbedded == extractEmbedded &&
|
||||
other.previewFormat == previewFormat &&
|
||||
other.previewSize == previewSize &&
|
||||
other.quality == quality &&
|
||||
other.thumbnailFormat == thumbnailFormat &&
|
||||
other.thumbnailSize == thumbnailSize;
|
||||
other.preview == preview &&
|
||||
other.thumbnail == thumbnail;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(colorspace.hashCode) +
|
||||
(extractEmbedded.hashCode) +
|
||||
(previewFormat.hashCode) +
|
||||
(previewSize.hashCode) +
|
||||
(quality.hashCode) +
|
||||
(thumbnailFormat.hashCode) +
|
||||
(thumbnailSize.hashCode);
|
||||
(preview.hashCode) +
|
||||
(thumbnail.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, previewFormat=$previewFormat, previewSize=$previewSize, quality=$quality, thumbnailFormat=$thumbnailFormat, thumbnailSize=$thumbnailSize]';
|
||||
String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, preview=$preview, thumbnail=$thumbnail]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'colorspace'] = this.colorspace;
|
||||
json[r'extractEmbedded'] = this.extractEmbedded;
|
||||
json[r'previewFormat'] = this.previewFormat;
|
||||
json[r'previewSize'] = this.previewSize;
|
||||
json[r'quality'] = this.quality;
|
||||
json[r'thumbnailFormat'] = this.thumbnailFormat;
|
||||
json[r'thumbnailSize'] = this.thumbnailSize;
|
||||
json[r'preview'] = this.preview;
|
||||
json[r'thumbnail'] = this.thumbnail;
|
||||
return json;
|
||||
}
|
||||
|
||||
|
@ -87,11 +65,8 @@ class SystemConfigImageDto {
|
|||
return SystemConfigImageDto(
|
||||
colorspace: Colorspace.fromJson(json[r'colorspace'])!,
|
||||
extractEmbedded: mapValueOfType<bool>(json, r'extractEmbedded')!,
|
||||
previewFormat: ImageFormat.fromJson(json[r'previewFormat'])!,
|
||||
previewSize: mapValueOfType<int>(json, r'previewSize')!,
|
||||
quality: mapValueOfType<int>(json, r'quality')!,
|
||||
thumbnailFormat: ImageFormat.fromJson(json[r'thumbnailFormat'])!,
|
||||
thumbnailSize: mapValueOfType<int>(json, r'thumbnailSize')!,
|
||||
preview: SystemConfigGeneratedImageDto.fromJson(json[r'preview'])!,
|
||||
thumbnail: SystemConfigGeneratedImageDto.fromJson(json[r'thumbnail'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
@ -141,11 +116,8 @@ class SystemConfigImageDto {
|
|||
static const requiredKeys = <String>{
|
||||
'colorspace',
|
||||
'extractEmbedded',
|
||||
'previewFormat',
|
||||
'previewSize',
|
||||
'quality',
|
||||
'thumbnailFormat',
|
||||
'thumbnailSize',
|
||||
'preview',
|
||||
'thumbnail',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -11654,6 +11654,28 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigGeneratedImageDto": {
|
||||
"properties": {
|
||||
"format": {
|
||||
"$ref": "#/components/schemas/ImageFormat"
|
||||
},
|
||||
"quality": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"size": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"format",
|
||||
"quality",
|
||||
"size"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigImageDto": {
|
||||
"properties": {
|
||||
"colorspace": {
|
||||
|
@ -11662,34 +11684,18 @@
|
|||
"extractEmbedded": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"previewFormat": {
|
||||
"$ref": "#/components/schemas/ImageFormat"
|
||||
"preview": {
|
||||
"$ref": "#/components/schemas/SystemConfigGeneratedImageDto"
|
||||
},
|
||||
"previewSize": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"quality": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"thumbnailFormat": {
|
||||
"$ref": "#/components/schemas/ImageFormat"
|
||||
},
|
||||
"thumbnailSize": {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
"thumbnail": {
|
||||
"$ref": "#/components/schemas/SystemConfigGeneratedImageDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"colorspace",
|
||||
"extractEmbedded",
|
||||
"previewFormat",
|
||||
"previewSize",
|
||||
"quality",
|
||||
"thumbnailFormat",
|
||||
"thumbnailSize"
|
||||
"preview",
|
||||
"thumbnail"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
|
|
@ -1100,14 +1100,16 @@ export type SystemConfigFFmpegDto = {
|
|||
transcode: TranscodePolicy;
|
||||
twoPass: boolean;
|
||||
};
|
||||
export type SystemConfigGeneratedImageDto = {
|
||||
format: ImageFormat;
|
||||
quality: number;
|
||||
size: number;
|
||||
};
|
||||
export type SystemConfigImageDto = {
|
||||
colorspace: Colorspace;
|
||||
extractEmbedded: boolean;
|
||||
previewFormat: ImageFormat;
|
||||
previewSize: number;
|
||||
quality: number;
|
||||
thumbnailFormat: ImageFormat;
|
||||
thumbnailSize: number;
|
||||
preview: SystemConfigGeneratedImageDto;
|
||||
thumbnail: SystemConfigGeneratedImageDto;
|
||||
};
|
||||
export type JobSettingsDto = {
|
||||
concurrency: number;
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
VideoContainer,
|
||||
} from 'src/enum';
|
||||
import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface';
|
||||
import { ImageOutputConfig } from 'src/interfaces/media.interface';
|
||||
|
||||
export interface SystemConfig {
|
||||
ffmpeg: {
|
||||
|
@ -109,11 +110,8 @@ export interface SystemConfig {
|
|||
template: string;
|
||||
};
|
||||
image: {
|
||||
thumbnailFormat: ImageFormat;
|
||||
thumbnailSize: number;
|
||||
previewFormat: ImageFormat;
|
||||
previewSize: number;
|
||||
quality: number;
|
||||
thumbnail: ImageOutputConfig;
|
||||
preview: ImageOutputConfig;
|
||||
colorspace: Colorspace;
|
||||
extractEmbedded: boolean;
|
||||
};
|
||||
|
@ -259,11 +257,16 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
image: {
|
||||
thumbnailFormat: ImageFormat.WEBP,
|
||||
thumbnailSize: 250,
|
||||
previewFormat: ImageFormat.JPEG,
|
||||
previewSize: 1440,
|
||||
quality: 80,
|
||||
thumbnail: {
|
||||
format: ImageFormat.WEBP,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
},
|
||||
preview: {
|
||||
format: ImageFormat.JPEG,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
},
|
||||
colorspace: Colorspace.P3,
|
||||
extractEmbedded: false,
|
||||
},
|
||||
|
|
|
@ -473,26 +473,10 @@ export class SystemConfigThemeDto {
|
|||
customCss!: string;
|
||||
}
|
||||
|
||||
class SystemConfigImageDto {
|
||||
class SystemConfigGeneratedImageDto {
|
||||
@IsEnum(ImageFormat)
|
||||
@ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat })
|
||||
thumbnailFormat!: ImageFormat;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
thumbnailSize!: number;
|
||||
|
||||
@IsEnum(ImageFormat)
|
||||
@ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat })
|
||||
previewFormat!: ImageFormat;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
previewSize!: number;
|
||||
format!: ImageFormat;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
|
@ -501,6 +485,24 @@ class SystemConfigImageDto {
|
|||
@ApiProperty({ type: 'integer' })
|
||||
quality!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
size!: number;
|
||||
}
|
||||
|
||||
class SystemConfigImageDto {
|
||||
@Type(() => SystemConfigGeneratedImageDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
thumbnail!: SystemConfigGeneratedImageDto;
|
||||
|
||||
@Type(() => SystemConfigGeneratedImageDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
preview!: SystemConfigGeneratedImageDto;
|
||||
|
||||
@IsEnum(Colorspace)
|
||||
@ApiProperty({ enumName: 'Colorspace', enum: Colorspace })
|
||||
colorspace!: Colorspace;
|
||||
|
|
|
@ -10,11 +10,14 @@ export interface CropOptions {
|
|||
height: number;
|
||||
}
|
||||
|
||||
export interface ThumbnailOptions {
|
||||
size: number;
|
||||
export interface ImageOutputConfig {
|
||||
format: ImageFormat;
|
||||
colorspace: string;
|
||||
quality: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ThumbnailOptions extends ImageOutputConfig {
|
||||
colorspace: string;
|
||||
crop?: CropOptions;
|
||||
processInvalidImages: boolean;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SeparateQualityForThumbnailAndPreview1727471863507 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
update system_metadata
|
||||
set value = jsonb_set(value, '{image}', jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'preview', jsonb_build_object(
|
||||
'format', value->'image'->'previewFormat',
|
||||
'quality', value->'image'->'quality',
|
||||
'size', value->'image'->'previewSize'),
|
||||
'thumbnail', jsonb_build_object(
|
||||
'format', value->'image'->'thumbnailFormat',
|
||||
'quality', value->'image'->'quality',
|
||||
'size', value->'image'->'thumbnailSize'),
|
||||
'extractEmbedded', value->'extractEmbedded',
|
||||
'colorspace', value->'colorspace'
|
||||
)))
|
||||
where key = 'system-config'`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
update system_metadata
|
||||
set value = jsonb_set(value, '{image}', jsonb_strip_nulls(jsonb_build_object(
|
||||
'previewFormat', value->'image'->'preview'->'format',
|
||||
'previewSize', value->'image'->'preview'->'size',
|
||||
'thumbnailFormat', value->'image'->'thumbnail'->'format',
|
||||
'thumbnailSize', value->'image'->'thumbnail'->'size',
|
||||
'extractEmbedded', value->'extractEmbedded',
|
||||
'colorspace', value->'colorspace',
|
||||
'quality', value->'image'->'preview'->'quality'
|
||||
)))
|
||||
where key = 'system-config'`);
|
||||
}
|
||||
}
|
|
@ -285,7 +285,7 @@ describe(MediaService.name, () => {
|
|||
});
|
||||
|
||||
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => {
|
||||
systemMock.get.mockResolvedValue({ image: { previewFormat: format } });
|
||||
systemMock.get.mockResolvedValue({ image: { preview: { format } } });
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
|
||||
|
||||
|
@ -307,7 +307,7 @@ describe(MediaService.name, () => {
|
|||
});
|
||||
|
||||
it('should delete previous preview if different path', async () => {
|
||||
systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } });
|
||||
systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } });
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
|
||||
await sut.handleGeneratePreview({ id: assetStub.image.id });
|
||||
|
@ -464,7 +464,7 @@ describe(MediaService.name, () => {
|
|||
it.each(Object.values(ImageFormat))(
|
||||
'should generate a %s thumbnail for an image when specified',
|
||||
async (format) => {
|
||||
systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } });
|
||||
systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } });
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
|
||||
|
||||
|
@ -487,7 +487,7 @@ describe(MediaService.name, () => {
|
|||
);
|
||||
|
||||
it('should delete previous thumbnail if different path', async () => {
|
||||
systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } });
|
||||
systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } });
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
|
||||
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
||||
|
|
|
@ -10,7 +10,6 @@ import {
|
|||
AssetType,
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
ImageFormat,
|
||||
LogLevel,
|
||||
StorageFolder,
|
||||
TranscodeHWAccel,
|
||||
|
@ -175,18 +174,15 @@ export class MediaService {
|
|||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.previewFormat);
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
||||
await this.storageCore.moveAssetVideo(asset);
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const [{ image }, [asset]] = await Promise.all([
|
||||
this.configCore.getConfig({ withCache: true }),
|
||||
this.assetRepository.getByIds([id], { exifInfo: true, files: true }),
|
||||
]);
|
||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true });
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
@ -195,7 +191,7 @@ export class MediaService {
|
|||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
|
||||
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW);
|
||||
if (!previewPath) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
@ -213,9 +209,9 @@ export class MediaService {
|
|||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) {
|
||||
private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) {
|
||||
const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true });
|
||||
const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize;
|
||||
const { size, format, quality } = image[type];
|
||||
const path = StorageCore.getImagePath(asset, type, format);
|
||||
this.storageCore.ensureFolders(path);
|
||||
|
||||
|
@ -226,13 +222,13 @@ export class MediaService {
|
|||
const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath));
|
||||
|
||||
try {
|
||||
const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize));
|
||||
const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size));
|
||||
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
|
||||
const imageOptions = {
|
||||
format,
|
||||
size,
|
||||
colorspace,
|
||||
quality: image.quality,
|
||||
quality,
|
||||
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
||||
};
|
||||
|
||||
|
@ -274,10 +270,7 @@ export class MediaService {
|
|||
}
|
||||
|
||||
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const [{ image }, [asset]] = await Promise.all([
|
||||
this.configCore.getConfig({ withCache: true }),
|
||||
this.assetRepository.getByIds([id], { exifInfo: true, files: true }),
|
||||
]);
|
||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true });
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
@ -286,7 +279,7 @@ export class MediaService {
|
|||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
|
||||
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL);
|
||||
if (!thumbnailPath) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
|
|
@ -574,7 +574,7 @@ export class PersonService {
|
|||
format: ImageFormat.JPEG,
|
||||
size: FACE_THUMBNAIL_SIZE,
|
||||
colorspace: image.colorspace,
|
||||
quality: image.quality,
|
||||
quality: image.thumbnail.quality,
|
||||
crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }),
|
||||
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
||||
} as const;
|
||||
|
|
|
@ -135,11 +135,16 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
image: {
|
||||
thumbnailFormat: ImageFormat.WEBP,
|
||||
thumbnailSize: 250,
|
||||
previewFormat: ImageFormat.JPEG,
|
||||
previewSize: 1440,
|
||||
quality: 80,
|
||||
thumbnail: {
|
||||
size: 250,
|
||||
format: ImageFormat.WEBP,
|
||||
quality: 80,
|
||||
},
|
||||
preview: {
|
||||
size: 1440,
|
||||
format: ImageFormat.JPEG,
|
||||
quality: 80,
|
||||
},
|
||||
colorspace: Colorspace.P3,
|
||||
extractEmbedded: false,
|
||||
},
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
SettingInputFieldType,
|
||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
|
||||
export let savedConfig: SystemConfigDto;
|
||||
export let defaultConfig: SystemConfigDto;
|
||||
|
@ -24,73 +25,96 @@
|
|||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" on:submit|preventDefault>
|
||||
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSelect
|
||||
label={$t('admin.image_thumbnail_format')}
|
||||
desc={$t('admin.image_format_description')}
|
||||
bind:value={config.image.thumbnailFormat}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={config.image.thumbnailFormat !== savedConfig.image.thumbnailFormat}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingAccordion
|
||||
key="thumbnail-settings"
|
||||
title={$t('admin.image_thumbnail_title')}
|
||||
subtitle={$t('admin.image_thumbnail_description')}
|
||||
isOpen={true}
|
||||
>
|
||||
<SettingSelect
|
||||
label={$t('admin.image_format')}
|
||||
desc={$t('admin.image_format_description')}
|
||||
bind:value={config.image.thumbnail.format}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={config.image.thumbnail.format !== savedConfig.image.thumbnail.format}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.image_thumbnail_resolution')}
|
||||
desc={$t('admin.image_thumbnail_resolution_description')}
|
||||
number
|
||||
bind:value={config.image.thumbnailSize}
|
||||
options={[
|
||||
{ value: 1080, text: '1080p' },
|
||||
{ value: 720, text: '720p' },
|
||||
{ value: 480, text: '480p' },
|
||||
{ value: 250, text: '250p' },
|
||||
{ value: 200, text: '200p' },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={config.image.thumbnailSize !== savedConfig.image.thumbnailSize}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingSelect
|
||||
label={$t('admin.image_resolution')}
|
||||
desc={$t('admin.image_resolution_description')}
|
||||
number
|
||||
bind:value={config.image.thumbnail.size}
|
||||
options={[
|
||||
{ value: 1080, text: '1080p' },
|
||||
{ value: 720, text: '720p' },
|
||||
{ value: 480, text: '480p' },
|
||||
{ value: 250, text: '250p' },
|
||||
{ value: 200, text: '200p' },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={config.image.thumbnail.size !== savedConfig.image.thumbnail.size}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.image_preview_format')}
|
||||
desc={$t('admin.image_format_description')}
|
||||
bind:value={config.image.previewFormat}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={config.image.previewFormat !== savedConfig.image.previewFormat}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.image_quality')}
|
||||
desc={$t('admin.image_thumbnail_quality_description')}
|
||||
bind:value={config.image.thumbnail.quality}
|
||||
isEdited={config.image.thumbnail.quality !== savedConfig.image.thumbnail.quality}
|
||||
{disabled}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('admin.image_preview_resolution')}
|
||||
desc={$t('admin.image_preview_resolution_description')}
|
||||
number
|
||||
bind:value={config.image.previewSize}
|
||||
options={[
|
||||
{ value: 2160, text: '4K' },
|
||||
{ value: 1440, text: '1440p' },
|
||||
{ value: 1080, text: '1080p' },
|
||||
{ value: 720, text: '720p' },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={config.image.previewSize !== savedConfig.image.previewSize}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingAccordion
|
||||
key="preview-settings"
|
||||
title={$t('admin.image_preview_title')}
|
||||
subtitle={$t('admin.image_preview_description')}
|
||||
isOpen={true}
|
||||
>
|
||||
<SettingSelect
|
||||
label={$t('admin.image_format')}
|
||||
desc={$t('admin.image_format_description')}
|
||||
bind:value={config.image.preview.format}
|
||||
options={[
|
||||
{ value: ImageFormat.Jpeg, text: 'JPEG' },
|
||||
{ value: ImageFormat.Webp, text: 'WebP' },
|
||||
]}
|
||||
name="format"
|
||||
isEdited={config.image.preview.format !== savedConfig.image.preview.format}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.image_quality')}
|
||||
desc={$t('admin.image_quality_description')}
|
||||
bind:value={config.image.quality}
|
||||
isEdited={config.image.quality !== savedConfig.image.quality}
|
||||
{disabled}
|
||||
/>
|
||||
<SettingSelect
|
||||
label={$t('admin.image_resolution')}
|
||||
desc={$t('admin.image_resolution_description')}
|
||||
number
|
||||
bind:value={config.image.preview.size}
|
||||
options={[
|
||||
{ value: 2160, text: '4K' },
|
||||
{ value: 1440, text: '1440p' },
|
||||
{ value: 1080, text: '1080p' },
|
||||
{ value: 720, text: '720p' },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={config.image.preview.size !== savedConfig.image.preview.size}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.image_quality')}
|
||||
desc={$t('admin.image_preview_quality_description')}
|
||||
bind:value={config.image.preview.quality}
|
||||
isEdited={config.image.preview.quality !== savedConfig.image.preview.quality}
|
||||
{disabled}
|
||||
/>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('admin.image_prefer_wide_gamut')}
|
||||
|
|
|
@ -54,21 +54,23 @@
|
|||
"failed_job_command": "Command {command} failed for job: {job}",
|
||||
"force_delete_user_warning": "WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be recovered.",
|
||||
"forcing_refresh_library_files": "Forcing refresh of all library files",
|
||||
"image_format": "Format",
|
||||
"image_format_description": "WebP produces smaller files than JPEG, but is slower to encode.",
|
||||
"image_prefer_embedded_preview": "Prefer embedded preview",
|
||||
"image_prefer_embedded_preview_setting_description": "Use embedded previews in RAW photos as the input to image processing when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts.",
|
||||
"image_prefer_wide_gamut": "Prefer wide gamut",
|
||||
"image_prefer_wide_gamut_setting_description": "Use Display P3 for thumbnails. This better preserves the vibrance of images with wide colorspaces, but images may appear differently on old devices with an old browser version. sRGB images are kept as sRGB to avoid color shifts.",
|
||||
"image_preview_format": "Preview format",
|
||||
"image_preview_resolution": "Preview resolution",
|
||||
"image_preview_resolution_description": "Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.",
|
||||
"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_quality": "Quality",
|
||||
"image_quality_description": "Image quality from 1-100. Higher is better for quality but produces larger files, this option affects the Preview and Thumbnail images.",
|
||||
"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_settings": "Image Settings",
|
||||
"image_settings_description": "Manage the quality and resolution of generated images",
|
||||
"image_thumbnail_format": "Thumbnail format",
|
||||
"image_thumbnail_resolution": "Thumbnail resolution",
|
||||
"image_thumbnail_resolution_description": "Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness.",
|
||||
"image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
|
||||
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
|
||||
"image_thumbnail_title": "Thumbnail Settings",
|
||||
"job_concurrency": "{job} concurrency",
|
||||
"job_created": "Job created",
|
||||
"job_not_concurrency_safe": "This job is not concurrency-safe.",
|
||||
|
|
Loading…
Add table
Reference in a new issue