1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-30 09:47:31 +02:00

feat(web/server): webp thumbnail size configurable (#3598)

* feat(server/web): webp thumbnail size configurable

* update api

* add ui and fix test

* lint

* setting for jpeg size

* feat: coerce to number

* api

* jpeg resolution

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Alex 2023-08-08 09:39:51 -05:00 committed by GitHub
parent 1812e8811b
commit ddd4ec2d9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 254 additions and 15 deletions

View File

@ -2425,6 +2425,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto * @memberof SystemConfigDto
*/ */
'storageTemplate': SystemConfigStorageTemplateDto; 'storageTemplate': SystemConfigStorageTemplateDto;
/**
*
* @type {SystemConfigThumbnailDto}
* @memberof SystemConfigDto
*/
'thumbnail': SystemConfigThumbnailDto;
} }
/** /**
* *
@ -2716,6 +2722,25 @@ export interface SystemConfigTemplateStorageOptionDto {
*/ */
'yearOptions': Array<string>; 'yearOptions': Array<string>;
} }
/**
*
* @export
* @interface SystemConfigThumbnailDto
*/
export interface SystemConfigThumbnailDto {
/**
*
* @type {number}
* @memberof SystemConfigThumbnailDto
*/
'jpegSize': number;
/**
*
* @type {number}
* @memberof SystemConfigThumbnailDto
*/
'webpSize': number;
}
/** /**
* *
* @export * @export

View File

@ -104,6 +104,7 @@ doc/SystemConfigOAuthDto.md
doc/SystemConfigPasswordLoginDto.md doc/SystemConfigPasswordLoginDto.md
doc/SystemConfigStorageTemplateDto.md doc/SystemConfigStorageTemplateDto.md
doc/SystemConfigTemplateStorageOptionDto.md doc/SystemConfigTemplateStorageOptionDto.md
doc/SystemConfigThumbnailDto.md
doc/TagApi.md doc/TagApi.md
doc/TagResponseDto.md doc/TagResponseDto.md
doc/TagTypeEnum.md doc/TagTypeEnum.md
@ -236,6 +237,7 @@ lib/model/system_config_o_auth_dto.dart
lib/model/system_config_password_login_dto.dart lib/model/system_config_password_login_dto.dart
lib/model/system_config_storage_template_dto.dart lib/model/system_config_storage_template_dto.dart
lib/model/system_config_template_storage_option_dto.dart lib/model/system_config_template_storage_option_dto.dart
lib/model/system_config_thumbnail_dto.dart
lib/model/tag_response_dto.dart lib/model/tag_response_dto.dart
lib/model/tag_type_enum.dart lib/model/tag_type_enum.dart
lib/model/thumbnail_format.dart lib/model/thumbnail_format.dart
@ -355,6 +357,7 @@ test/system_config_o_auth_dto_test.dart
test/system_config_password_login_dto_test.dart test/system_config_password_login_dto_test.dart
test/system_config_storage_template_dto_test.dart test/system_config_storage_template_dto_test.dart
test/system_config_template_storage_option_dto_test.dart test/system_config_template_storage_option_dto_test.dart
test/system_config_thumbnail_dto_test.dart
test/tag_api_test.dart test/tag_api_test.dart
test/tag_response_dto_test.dart test/tag_response_dto_test.dart
test/tag_type_enum_test.dart test/tag_type_enum_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -6590,6 +6590,9 @@
}, },
"storageTemplate": { "storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto" "$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
},
"thumbnail": {
"$ref": "#/components/schemas/SystemConfigThumbnailDto"
} }
}, },
"required": [ "required": [
@ -6597,7 +6600,8 @@
"oauth", "oauth",
"passwordLogin", "passwordLogin",
"storageTemplate", "storageTemplate",
"job" "job",
"thumbnail"
], ],
"type": "object" "type": "object"
}, },
@ -6828,6 +6832,21 @@
], ],
"type": "object" "type": "object"
}, },
"SystemConfigThumbnailDto": {
"properties": {
"jpegSize": {
"type": "integer"
},
"webpSize": {
"type": "integer"
}
},
"required": [
"webpSize",
"jpegSize"
],
"type": "object"
},
"TagResponseDto": { "TagResponseDto": {
"properties": { "properties": {
"id": { "id": {

View File

@ -1,3 +1 @@
export const JPEG_THUMBNAIL_SIZE = 1440;
export const WEBP_THUMBNAIL_SIZE = 250;
export const FACE_THUMBNAIL_SIZE = 250; export const FACE_THUMBNAIL_SIZE = 250;

View File

@ -7,7 +7,6 @@ import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SI
import { IStorageRepository, StorageCore, StorageFolder } from '../storage'; import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config'; import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core'; import { SystemConfigCore } from '../system-config/system-config.core';
import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant';
import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo } from './media.repository'; import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo } from './media.repository';
import { H264Config, HEVCConfig, NVENCConfig, QSVConfig, ThumbnailConfig, VAAPIConfig, VP9Config } from './media.util'; import { H264Config, HEVCConfig, NVENCConfig, QSVConfig, ThumbnailConfig, VAAPIConfig, VP9Config } from './media.util';
@ -63,11 +62,12 @@ export class MediaService {
const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId); const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
this.storageRepository.mkdirSync(resizePath); this.storageRepository.mkdirSync(resizePath);
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`); const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
const { thumbnail } = await this.configCore.getConfig();
switch (asset.type) { switch (asset.type) {
case AssetType.IMAGE: case AssetType.IMAGE:
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, { await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
size: JPEG_THUMBNAIL_SIZE, size: thumbnail.jpegSize,
format: 'jpeg', format: 'jpeg',
}); });
this.logger.log(`Successfully generated image thumbnail ${asset.id}`); this.logger.log(`Successfully generated image thumbnail ${asset.id}`);
@ -80,7 +80,7 @@ export class MediaService {
return false; return false;
} }
const { ffmpeg } = await this.configCore.getConfig(); const { ffmpeg } = await this.configCore.getConfig();
const config = { ...ffmpeg, targetResolution: JPEG_THUMBNAIL_SIZE.toString(), twoPass: false }; const config = { ...ffmpeg, targetResolution: thumbnail.jpegSize.toString(), twoPass: false };
const options = new ThumbnailConfig(config).getOptions(mainVideoStream); const options = new ThumbnailConfig(config).getOptions(mainVideoStream);
await this.mediaRepository.transcode(asset.originalPath, jpegThumbnailPath, options); await this.mediaRepository.transcode(asset.originalPath, jpegThumbnailPath, options);
this.logger.log(`Successfully generated video thumbnail ${asset.id}`); this.logger.log(`Successfully generated video thumbnail ${asset.id}`);
@ -100,7 +100,8 @@ export class MediaService {
const webpPath = asset.resizePath.replace('jpeg', 'webp').replace('jpg', 'webp'); const webpPath = asset.resizePath.replace('jpeg', 'webp').replace('jpg', 'webp');
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' }); const { thumbnail } = await this.configCore.getConfig();
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: thumbnail.webpSize, format: 'webp' });
await this.assetRepository.save({ id: asset.id, webpPath }); await this.assetRepository.save({ id: asset.id, webpPath });
return true; return true;

View File

@ -2,4 +2,5 @@ export * from './system-config-ffmpeg.dto';
export * from './system-config-oauth.dto'; export * from './system-config-oauth.dto';
export * from './system-config-password-login.dto'; export * from './system-config-password-login.dto';
export * from './system-config-storage-template.dto'; export * from './system-config-storage-template.dto';
export * from './system-config-thumbnail.dto';
export * from './system-config.dto'; export * from './system-config.dto';

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt } from 'class-validator';
export class SystemConfigThumbnailDto {
@IsInt()
@Type(() => Number)
@ApiProperty({ type: 'integer' })
webpSize!: number;
@IsInt()
@Type(() => Number)
@ApiProperty({ type: 'integer' })
jpegSize!: number;
}

View File

@ -1,3 +1,4 @@
import { SystemConfigThumbnailDto } from '@app/domain/system-config';
import { SystemConfig } from '@app/infra/entities'; import { SystemConfig } from '@app/infra/entities';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsObject, ValidateNested } from 'class-validator'; import { IsObject, ValidateNested } from 'class-validator';
@ -32,6 +33,11 @@ export class SystemConfigDto {
@ValidateNested() @ValidateNested()
@IsObject() @IsObject()
job!: SystemConfigJobDto; job!: SystemConfigJobDto;
@Type(() => SystemConfigThumbnailDto)
@ValidateNested()
@IsObject()
thumbnail!: SystemConfigThumbnailDto;
} }
export function mapConfig(config: SystemConfig): SystemConfigDto { export function mapConfig(config: SystemConfig): SystemConfigDto {

View File

@ -64,6 +64,11 @@ export const defaults = Object.freeze<SystemConfig>({
storageTemplate: { storageTemplate: {
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
}, },
thumbnail: {
webpSize: 250,
jpegSize: 1440,
},
}); });
const singleton = new Subject<SystemConfig>(); const singleton = new Subject<SystemConfig>();

View File

@ -65,6 +65,10 @@ const updatedConfig = Object.freeze<SystemConfig>({
storageTemplate: { storageTemplate: {
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
}, },
thumbnail: {
webpSize: 250,
jpegSize: 1440,
},
}); });
describe(SystemConfigService.name, () => { describe(SystemConfigService.name, () => {

View File

@ -52,6 +52,9 @@ export enum SystemConfigKey {
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled', PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
STORAGE_TEMPLATE = 'storageTemplate.template', STORAGE_TEMPLATE = 'storageTemplate.template',
THUMBNAIL_WEBP_SIZE = 'thumbnail.webpSize',
THUMBNAIL_JPEG_SIZE = 'thumbnail.jpegSize',
} }
export enum TranscodePolicy { export enum TranscodePolicy {
@ -121,4 +124,8 @@ export interface SystemConfig {
storageTemplate: { storageTemplate: {
template: string; template: string;
}; };
thumbnail: {
webpSize: number;
jpegSize: number;
};
} }

View File

@ -12,7 +12,7 @@ export class MediaRepository implements IMediaRepository {
private logger = new Logger(MediaRepository.name); private logger = new Logger(MediaRepository.name);
crop(input: string, options: CropOptions): Promise<Buffer> { crop(input: string, options: CropOptions): Promise<Buffer> {
return sharp(input, { failOnError: false }) return sharp(input, { failOn: 'none' })
.extract({ .extract({
left: options.left, left: options.left,
top: options.top, top: options.top,
@ -23,7 +23,7 @@ export class MediaRepository implements IMediaRepository {
} }
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> { async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
await sharp(input, { failOnError: false }) await sharp(input, { failOn: 'none' })
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }) .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.rotate() .rotate()
.toFormat(options.format) .toFormat(options.format)

View File

@ -2425,6 +2425,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto * @memberof SystemConfigDto
*/ */
'storageTemplate': SystemConfigStorageTemplateDto; 'storageTemplate': SystemConfigStorageTemplateDto;
/**
*
* @type {SystemConfigThumbnailDto}
* @memberof SystemConfigDto
*/
'thumbnail': SystemConfigThumbnailDto;
} }
/** /**
* *
@ -2716,6 +2722,25 @@ export interface SystemConfigTemplateStorageOptionDto {
*/ */
'yearOptions': Array<string>; 'yearOptions': Array<string>;
} }
/**
*
* @export
* @interface SystemConfigThumbnailDto
*/
export interface SystemConfigThumbnailDto {
/**
*
* @type {number}
* @memberof SystemConfigThumbnailDto
*/
'jpegSize': number;
/**
*
* @type {number}
* @memberof SystemConfigThumbnailDto
*/
'webpSize': number;
}
/** /**
* *
* @export * @export

View File

@ -27,7 +27,7 @@
}; };
</script> </script>
<div class="w-full"> <div class="mb-4 w-full">
<div class={`flex h-[26px] place-items-center gap-1`}> <div class={`flex h-[26px] place-items-center gap-1`}>
<label class={`immich-form-label text-sm`} for={label}>{label}</label> <label class={`immich-form-label text-sm`} for={label}>{label}</label>
{#if required} {#if required}
@ -45,7 +45,7 @@
</div> </div>
{#if desc} {#if desc}
<p class="immich-form-label pb-2 text-xs" id="{label}-desc"> <p class="immich-form-label pb-2 text-sm" id="{label}-desc">
{desc} {desc}
</p> </p>
{/if} {/if}

View File

@ -2,19 +2,23 @@
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
export let value: string; export let value: string | number;
export let options: { value: string; text: string }[]; export let options: { value: string | number; text: string }[];
export let label = ''; export let label = '';
export let desc = ''; export let desc = '';
export let name = ''; export let name = '';
export let isEdited = false; export let isEdited = false;
export let number = false;
const handleChange = (e: Event) => { const handleChange = (e: Event) => {
value = (e.target as HTMLInputElement).value; value = (e.target as HTMLInputElement).value;
if (number) {
value = parseInt(value);
}
}; };
</script> </script>
<div class="w-full"> <div class="mb-4 w-full">
<div class={`flex h-[26px] place-items-center gap-1`}> <div class={`flex h-[26px] place-items-center gap-1`}>
<label class={`immich-form-label text-sm`} for="{name}-select">{label}</label> <label class={`immich-form-label text-sm`} for="{name}-select">{label}</label>
@ -29,7 +33,7 @@
</div> </div>
{#if desc} {#if desc}
<p class="immich-form-label pb-2 text-xs" id="{name}-desc"> <p class="immich-form-label pb-2 text-sm" id="{name}-desc">
{desc} {desc}
</p> </p>
{/if} {/if}

View File

@ -0,0 +1,121 @@
<script lang="ts">
import SettingSelect from '$lib/components/admin-page/settings/setting-select.svelte';
import { api, SystemConfigThumbnailDto } from '@api';
import { fade } from 'svelte/transition';
import { isEqual } from 'lodash-es';
import SettingButtonsRow from '$lib/components/admin-page/settings/setting-buttons-row.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
export let thumbnailConfig: SystemConfigThumbnailDto; // this is the config that is being edited
let savedConfig: SystemConfigThumbnailDto;
let defaultConfig: SystemConfigThumbnailDto;
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.thumbnail),
api.systemConfigApi.getDefaults().then((res) => res.data.thumbnail),
]);
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
thumbnailConfig = { ...resetConfig.thumbnail };
savedConfig = { ...resetConfig.thumbnail };
notificationController.show({
message: 'Reset thumbnail settings to the recent saved settings',
type: NotificationType.Info,
});
}
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
thumbnailConfig = { ...configs.thumbnail };
defaultConfig = { ...configs.thumbnail };
notificationController.show({
message: 'Reset thumbnail settings to default',
type: NotificationType.Info,
});
}
async function saveSetting() {
try {
const { data: configs } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
systemConfigDto: {
...configs,
thumbnail: thumbnailConfig,
},
});
thumbnailConfig = { ...result.data.thumbnail };
savedConfig = { ...result.data.thumbnail };
notificationController.show({
message: 'Thumbnail settings saved',
type: NotificationType.Info,
});
} catch (e) {
console.error('Error [thumbnail-settings] [saveSetting]', e);
notificationController.show({
message: 'Unable to save settings',
type: NotificationType.Error,
});
}
}
</script>
<div>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSelect
label="WEBP RESOLUTION"
desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
number
bind:value={thumbnailConfig.webpSize}
options={[
{ value: 1080, text: '1080p' },
{ value: 720, text: '720p' },
{ value: 480, text: '480p' },
{ value: 250, text: '250p' },
]}
name="resolution"
isEdited={!(thumbnailConfig.webpSize === savedConfig.webpSize)}
/>
<SettingSelect
label="JPEG RESOLUTION"
desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
number
bind:value={thumbnailConfig.jpegSize}
options={[
{ value: 2160, text: '4K' },
{ value: 1440, text: '1440p' },
]}
name="resolution"
isEdited={!(thumbnailConfig.jpegSize === savedConfig.jpegSize)}
/>
</div>
<div class="ml-4">
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
/>
</div>
</form>
</div>
{/await}
</div>

View File

@ -2,6 +2,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte'; import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte'; import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte';
import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte';
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte'; import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte'; import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte';
import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte'; import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
@ -22,6 +23,10 @@
{#await getConfig()} {#await getConfig()}
<LoadingSpinner /> <LoadingSpinner />
{:then configs} {:then configs}
<SettingAccordion title="Thumbnail Settings" subtitle="Manage the resolution of thumbnail sizes">
<ThumbnailSettings thumbnailConfig={configs.thumbnail} />
</SettingAccordion>
<SettingAccordion <SettingAccordion
title="FFmpeg Settings" title="FFmpeg Settings"
subtitle="Manage the resolution and encoding information of the video files" subtitle="Manage the resolution and encoding information of the video files"