1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-27 22:22:45 +01:00

feat(server): separate quality for thumbnail and preview images ()

* allow different thumbnail and preview quality, better config structure

* update web and api

* wording

* remove empty line?
This commit is contained in:
Mert 2024-09-28 02:01:04 -04:00 committed by GitHub
parent 4248594ac5
commit 995f0fda47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 232 additions and 155 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -11654,6 +11654,28 @@
], ],
"type": "object" "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": { "SystemConfigImageDto": {
"properties": { "properties": {
"colorspace": { "colorspace": {
@ -11662,34 +11684,18 @@
"extractEmbedded": { "extractEmbedded": {
"type": "boolean" "type": "boolean"
}, },
"previewFormat": { "preview": {
"$ref": "#/components/schemas/ImageFormat" "$ref": "#/components/schemas/SystemConfigGeneratedImageDto"
}, },
"previewSize": { "thumbnail": {
"minimum": 1, "$ref": "#/components/schemas/SystemConfigGeneratedImageDto"
"type": "integer"
},
"quality": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"thumbnailFormat": {
"$ref": "#/components/schemas/ImageFormat"
},
"thumbnailSize": {
"minimum": 1,
"type": "integer"
} }
}, },
"required": [ "required": [
"colorspace", "colorspace",
"extractEmbedded", "extractEmbedded",
"previewFormat", "preview",
"previewSize", "thumbnail"
"quality",
"thumbnailFormat",
"thumbnailSize"
], ],
"type": "object" "type": "object"
}, },

View file

@ -1100,14 +1100,16 @@ export type SystemConfigFFmpegDto = {
transcode: TranscodePolicy; transcode: TranscodePolicy;
twoPass: boolean; twoPass: boolean;
}; };
export type SystemConfigGeneratedImageDto = {
format: ImageFormat;
quality: number;
size: number;
};
export type SystemConfigImageDto = { export type SystemConfigImageDto = {
colorspace: Colorspace; colorspace: Colorspace;
extractEmbedded: boolean; extractEmbedded: boolean;
previewFormat: ImageFormat; preview: SystemConfigGeneratedImageDto;
previewSize: number; thumbnail: SystemConfigGeneratedImageDto;
quality: number;
thumbnailFormat: ImageFormat;
thumbnailSize: number;
}; };
export type JobSettingsDto = { export type JobSettingsDto = {
concurrency: number; concurrency: number;

View file

@ -20,6 +20,7 @@ import {
VideoContainer, VideoContainer,
} from 'src/enum'; } from 'src/enum';
import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface';
import { ImageOutputConfig } from 'src/interfaces/media.interface';
export interface SystemConfig { export interface SystemConfig {
ffmpeg: { ffmpeg: {
@ -109,11 +110,8 @@ export interface SystemConfig {
template: string; template: string;
}; };
image: { image: {
thumbnailFormat: ImageFormat; thumbnail: ImageOutputConfig;
thumbnailSize: number; preview: ImageOutputConfig;
previewFormat: ImageFormat;
previewSize: number;
quality: number;
colorspace: Colorspace; colorspace: Colorspace;
extractEmbedded: boolean; extractEmbedded: boolean;
}; };
@ -259,11 +257,16 @@ export const defaults = Object.freeze<SystemConfig>({
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
}, },
image: { image: {
thumbnailFormat: ImageFormat.WEBP, thumbnail: {
thumbnailSize: 250, format: ImageFormat.WEBP,
previewFormat: ImageFormat.JPEG, size: 250,
previewSize: 1440,
quality: 80, quality: 80,
},
preview: {
format: ImageFormat.JPEG,
size: 1440,
quality: 80,
},
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
extractEmbedded: false, extractEmbedded: false,
}, },

View file

@ -473,26 +473,10 @@ export class SystemConfigThemeDto {
customCss!: string; customCss!: string;
} }
class SystemConfigImageDto { class SystemConfigGeneratedImageDto {
@IsEnum(ImageFormat) @IsEnum(ImageFormat)
@ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat })
thumbnailFormat!: ImageFormat; format!: 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;
@IsInt() @IsInt()
@Min(1) @Min(1)
@ -501,6 +485,24 @@ class SystemConfigImageDto {
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
quality!: number; 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) @IsEnum(Colorspace)
@ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace })
colorspace!: Colorspace; colorspace!: Colorspace;

View file

@ -10,11 +10,14 @@ export interface CropOptions {
height: number; height: number;
} }
export interface ThumbnailOptions { export interface ImageOutputConfig {
size: number;
format: ImageFormat; format: ImageFormat;
colorspace: string;
quality: number; quality: number;
size: number;
}
export interface ThumbnailOptions extends ImageOutputConfig {
colorspace: string;
crop?: CropOptions; crop?: CropOptions;
processInvalidImages: boolean; processInvalidImages: boolean;
} }

View file

@ -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'`);
}
}

View file

@ -285,7 +285,7 @@ describe(MediaService.name, () => {
}); });
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { 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]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; 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 () => { 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]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGeneratePreview({ id: assetStub.image.id }); await sut.handleGeneratePreview({ id: assetStub.image.id });
@ -464,7 +464,7 @@ describe(MediaService.name, () => {
it.each(Object.values(ImageFormat))( it.each(Object.values(ImageFormat))(
'should generate a %s thumbnail for an image when specified', 'should generate a %s thumbnail for an image when specified',
async (format) => { async (format) => {
systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } }); systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } });
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; 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 () => { 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]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });

View file

@ -10,7 +10,6 @@ import {
AssetType, AssetType,
AudioCodec, AudioCodec,
Colorspace, Colorspace,
ImageFormat,
LogLevel, LogLevel,
StorageFolder, StorageFolder,
TranscodeHWAccel, TranscodeHWAccel,
@ -175,18 +174,15 @@ export class MediaService {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.previewFormat); await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format);
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
await this.storageCore.moveAssetVideo(asset); await this.storageCore.moveAssetVideo(asset);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> { async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
const [{ image }, [asset]] = await Promise.all([ const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true });
this.configCore.getConfig({ withCache: true }),
this.assetRepository.getByIds([id], { exifInfo: true, files: true }),
]);
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
@ -195,7 +191,7 @@ export class MediaService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW);
if (!previewPath) { if (!previewPath) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
@ -213,9 +209,9 @@ export class MediaService {
return JobStatus.SUCCESS; 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 { 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); const path = StorageCore.getImagePath(asset, type, format);
this.storageCore.ensureFolders(path); this.storageCore.ensureFolders(path);
@ -226,13 +222,13 @@ export class MediaService {
const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath));
try { 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 colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
const imageOptions = { const imageOptions = {
format, format,
size, size,
colorspace, colorspace,
quality: image.quality, quality,
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
}; };
@ -274,10 +270,7 @@ export class MediaService {
} }
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> { async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
const [{ image }, [asset]] = await Promise.all([ const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true });
this.configCore.getConfig({ withCache: true }),
this.assetRepository.getByIds([id], { exifInfo: true, files: true }),
]);
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
@ -286,7 +279,7 @@ export class MediaService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL);
if (!thumbnailPath) { if (!thumbnailPath) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }

View file

@ -574,7 +574,7 @@ export class PersonService {
format: ImageFormat.JPEG, format: ImageFormat.JPEG,
size: FACE_THUMBNAIL_SIZE, size: FACE_THUMBNAIL_SIZE,
colorspace: image.colorspace, 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 }), crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }),
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
} as const; } as const;

View file

@ -135,11 +135,16 @@ const updatedConfig = Object.freeze<SystemConfig>({
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
}, },
image: { image: {
thumbnailFormat: ImageFormat.WEBP, thumbnail: {
thumbnailSize: 250, size: 250,
previewFormat: ImageFormat.JPEG, format: ImageFormat.WEBP,
previewSize: 1440,
quality: 80, quality: 80,
},
preview: {
size: 1440,
format: ImageFormat.JPEG,
quality: 80,
},
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
extractEmbedded: false, extractEmbedded: false,
}, },

View file

@ -11,6 +11,7 @@
SettingInputFieldType, SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte'; } from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
export let savedConfig: SystemConfigDto; export let savedConfig: SystemConfigDto;
export let defaultConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto;
@ -24,24 +25,30 @@
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault> <form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingAccordion
key="thumbnail-settings"
title={$t('admin.image_thumbnail_title')}
subtitle={$t('admin.image_thumbnail_description')}
isOpen={true}
>
<SettingSelect <SettingSelect
label={$t('admin.image_thumbnail_format')} label={$t('admin.image_format')}
desc={$t('admin.image_format_description')} desc={$t('admin.image_format_description')}
bind:value={config.image.thumbnailFormat} bind:value={config.image.thumbnail.format}
options={[ options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' }, { value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' }, { value: ImageFormat.Webp, text: 'WebP' },
]} ]}
name="format" name="format"
isEdited={config.image.thumbnailFormat !== savedConfig.image.thumbnailFormat} isEdited={config.image.thumbnail.format !== savedConfig.image.thumbnail.format}
{disabled} {disabled}
/> />
<SettingSelect <SettingSelect
label={$t('admin.image_thumbnail_resolution')} label={$t('admin.image_resolution')}
desc={$t('admin.image_thumbnail_resolution_description')} desc={$t('admin.image_resolution_description')}
number number
bind:value={config.image.thumbnailSize} bind:value={config.image.thumbnail.size}
options={[ options={[
{ value: 1080, text: '1080p' }, { value: 1080, text: '1080p' },
{ value: 720, text: '720p' }, { value: 720, text: '720p' },
@ -50,28 +57,44 @@
{ value: 200, text: '200p' }, { value: 200, text: '200p' },
]} ]}
name="resolution" name="resolution"
isEdited={config.image.thumbnailSize !== savedConfig.image.thumbnailSize} isEdited={config.image.thumbnail.size !== savedConfig.image.thumbnail.size}
{disabled} {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>
<SettingAccordion
key="preview-settings"
title={$t('admin.image_preview_title')}
subtitle={$t('admin.image_preview_description')}
isOpen={true}
>
<SettingSelect <SettingSelect
label={$t('admin.image_preview_format')} label={$t('admin.image_format')}
desc={$t('admin.image_format_description')} desc={$t('admin.image_format_description')}
bind:value={config.image.previewFormat} bind:value={config.image.preview.format}
options={[ options={[
{ value: ImageFormat.Jpeg, text: 'JPEG' }, { value: ImageFormat.Jpeg, text: 'JPEG' },
{ value: ImageFormat.Webp, text: 'WebP' }, { value: ImageFormat.Webp, text: 'WebP' },
]} ]}
name="format" name="format"
isEdited={config.image.previewFormat !== savedConfig.image.previewFormat} isEdited={config.image.preview.format !== savedConfig.image.preview.format}
{disabled} {disabled}
/> />
<SettingSelect <SettingSelect
label={$t('admin.image_preview_resolution')} label={$t('admin.image_resolution')}
desc={$t('admin.image_preview_resolution_description')} desc={$t('admin.image_resolution_description')}
number number
bind:value={config.image.previewSize} bind:value={config.image.preview.size}
options={[ options={[
{ value: 2160, text: '4K' }, { value: 2160, text: '4K' },
{ value: 1440, text: '1440p' }, { value: 1440, text: '1440p' },
@ -79,18 +102,19 @@
{ value: 720, text: '720p' }, { value: 720, text: '720p' },
]} ]}
name="resolution" name="resolution"
isEdited={config.image.previewSize !== savedConfig.image.previewSize} isEdited={config.image.preview.size !== savedConfig.image.preview.size}
{disabled} {disabled}
/> />
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.image_quality')} label={$t('admin.image_quality')}
desc={$t('admin.image_quality_description')} desc={$t('admin.image_preview_quality_description')}
bind:value={config.image.quality} bind:value={config.image.preview.quality}
isEdited={config.image.quality !== savedConfig.image.quality} isEdited={config.image.preview.quality !== savedConfig.image.preview.quality}
{disabled} {disabled}
/> />
</SettingAccordion>
<SettingSwitch <SettingSwitch
title={$t('admin.image_prefer_wide_gamut')} title={$t('admin.image_prefer_wide_gamut')}

View file

@ -54,21 +54,23 @@
"failed_job_command": "Command {command} failed for job: {job}", "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.", "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", "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_format_description": "WebP produces smaller files than JPEG, but is slower to encode.",
"image_prefer_embedded_preview": "Prefer embedded preview", "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_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": "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_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_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning",
"image_preview_resolution": "Preview resolution", "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_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_title": "Preview Settings",
"image_quality": "Quality", "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": "Image Settings",
"image_settings_description": "Manage the quality and resolution of generated images", "image_settings_description": "Manage the quality and resolution of generated images",
"image_thumbnail_format": "Thumbnail format", "image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
"image_thumbnail_resolution": "Thumbnail resolution", "image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
"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_title": "Thumbnail Settings",
"job_concurrency": "{job} concurrency", "job_concurrency": "{job} concurrency",
"job_created": "Job created", "job_created": "Job created",
"job_not_concurrency_safe": "This job is not concurrency-safe.", "job_not_concurrency_safe": "This job is not concurrency-safe.",