mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00: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:
parent
1812e8811b
commit
ddd4ec2d9e
26 changed files with 254 additions and 15 deletions
25
cli/src/api/open-api/api.ts
generated
25
cli/src/api/open-api/api.ts
generated
|
@ -2425,6 +2425,12 @@ export interface SystemConfigDto {
|
|||
* @memberof SystemConfigDto
|
||||
*/
|
||||
'storageTemplate': SystemConfigStorageTemplateDto;
|
||||
/**
|
||||
*
|
||||
* @type {SystemConfigThumbnailDto}
|
||||
* @memberof SystemConfigDto
|
||||
*/
|
||||
'thumbnail': SystemConfigThumbnailDto;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
@ -2716,6 +2722,25 @@ export interface SystemConfigTemplateStorageOptionDto {
|
|||
*/
|
||||
'yearOptions': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface SystemConfigThumbnailDto
|
||||
*/
|
||||
export interface SystemConfigThumbnailDto {
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'jpegSize': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'webpSize': number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
|
|
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
|
@ -104,6 +104,7 @@ doc/SystemConfigOAuthDto.md
|
|||
doc/SystemConfigPasswordLoginDto.md
|
||||
doc/SystemConfigStorageTemplateDto.md
|
||||
doc/SystemConfigTemplateStorageOptionDto.md
|
||||
doc/SystemConfigThumbnailDto.md
|
||||
doc/TagApi.md
|
||||
doc/TagResponseDto.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_storage_template_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_type_enum.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_storage_template_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_response_dto_test.dart
|
||||
test/tag_type_enum_test.dart
|
||||
|
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigDto.md
generated
BIN
mobile/openapi/doc/SystemConfigDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigThumbnailDto.md
generated
Normal file
BIN
mobile/openapi/doc/SystemConfigThumbnailDto.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_thumbnail_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/system_config_thumbnail_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/test/system_config_dto_test.dart
generated
BIN
mobile/openapi/test/system_config_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_thumbnail_dto_test.dart
generated
Normal file
BIN
mobile/openapi/test/system_config_thumbnail_dto_test.dart
generated
Normal file
Binary file not shown.
|
@ -6590,6 +6590,9 @@
|
|||
},
|
||||
"storageTemplate": {
|
||||
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
|
||||
},
|
||||
"thumbnail": {
|
||||
"$ref": "#/components/schemas/SystemConfigThumbnailDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
@ -6597,7 +6600,8 @@
|
|||
"oauth",
|
||||
"passwordLogin",
|
||||
"storageTemplate",
|
||||
"job"
|
||||
"job",
|
||||
"thumbnail"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
@ -6828,6 +6832,21 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigThumbnailDto": {
|
||||
"properties": {
|
||||
"jpegSize": {
|
||||
"type": "integer"
|
||||
},
|
||||
"webpSize": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"webpSize",
|
||||
"jpegSize"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TagResponseDto": {
|
||||
"properties": {
|
||||
"id": {
|
||||
|
|
|
@ -1,3 +1 @@
|
|||
export const JPEG_THUMBNAIL_SIZE = 1440;
|
||||
export const WEBP_THUMBNAIL_SIZE = 250;
|
||||
export const FACE_THUMBNAIL_SIZE = 250;
|
||||
|
|
|
@ -7,7 +7,6 @@ import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SI
|
|||
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
|
||||
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 { 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);
|
||||
this.storageRepository.mkdirSync(resizePath);
|
||||
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
||||
const { thumbnail } = await this.configCore.getConfig();
|
||||
|
||||
switch (asset.type) {
|
||||
case AssetType.IMAGE:
|
||||
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
||||
size: JPEG_THUMBNAIL_SIZE,
|
||||
size: thumbnail.jpegSize,
|
||||
format: 'jpeg',
|
||||
});
|
||||
this.logger.log(`Successfully generated image thumbnail ${asset.id}`);
|
||||
|
@ -80,7 +80,7 @@ export class MediaService {
|
|||
return false;
|
||||
}
|
||||
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);
|
||||
await this.mediaRepository.transcode(asset.originalPath, jpegThumbnailPath, options);
|
||||
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');
|
||||
|
||||
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 });
|
||||
|
||||
return true;
|
||||
|
|
|
@ -2,4 +2,5 @@ export * from './system-config-ffmpeg.dto';
|
|||
export * from './system-config-oauth.dto';
|
||||
export * from './system-config-password-login.dto';
|
||||
export * from './system-config-storage-template.dto';
|
||||
export * from './system-config-thumbnail.dto';
|
||||
export * from './system-config.dto';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
import { SystemConfigThumbnailDto } from '@app/domain/system-config';
|
||||
import { SystemConfig } from '@app/infra/entities';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsObject, ValidateNested } from 'class-validator';
|
||||
|
@ -32,6 +33,11 @@ export class SystemConfigDto {
|
|||
@ValidateNested()
|
||||
@IsObject()
|
||||
job!: SystemConfigJobDto;
|
||||
|
||||
@Type(() => SystemConfigThumbnailDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
thumbnail!: SystemConfigThumbnailDto;
|
||||
}
|
||||
|
||||
export function mapConfig(config: SystemConfig): SystemConfigDto {
|
||||
|
|
|
@ -64,6 +64,11 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||
storageTemplate: {
|
||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
|
||||
thumbnail: {
|
||||
webpSize: 250,
|
||||
jpegSize: 1440,
|
||||
},
|
||||
});
|
||||
|
||||
const singleton = new Subject<SystemConfig>();
|
||||
|
|
|
@ -65,6 +65,10 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||
storageTemplate: {
|
||||
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
|
||||
},
|
||||
thumbnail: {
|
||||
webpSize: 250,
|
||||
jpegSize: 1440,
|
||||
},
|
||||
});
|
||||
|
||||
describe(SystemConfigService.name, () => {
|
||||
|
|
|
@ -52,6 +52,9 @@ export enum SystemConfigKey {
|
|||
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
|
||||
|
||||
STORAGE_TEMPLATE = 'storageTemplate.template',
|
||||
|
||||
THUMBNAIL_WEBP_SIZE = 'thumbnail.webpSize',
|
||||
THUMBNAIL_JPEG_SIZE = 'thumbnail.jpegSize',
|
||||
}
|
||||
|
||||
export enum TranscodePolicy {
|
||||
|
@ -121,4 +124,8 @@ export interface SystemConfig {
|
|||
storageTemplate: {
|
||||
template: string;
|
||||
};
|
||||
thumbnail: {
|
||||
webpSize: number;
|
||||
jpegSize: number;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ export class MediaRepository implements IMediaRepository {
|
|||
private logger = new Logger(MediaRepository.name);
|
||||
|
||||
crop(input: string, options: CropOptions): Promise<Buffer> {
|
||||
return sharp(input, { failOnError: false })
|
||||
return sharp(input, { failOn: 'none' })
|
||||
.extract({
|
||||
left: options.left,
|
||||
top: options.top,
|
||||
|
@ -23,7 +23,7 @@ export class MediaRepository implements IMediaRepository {
|
|||
}
|
||||
|
||||
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 })
|
||||
.rotate()
|
||||
.toFormat(options.format)
|
||||
|
|
25
web/src/api/open-api/api.ts
generated
25
web/src/api/open-api/api.ts
generated
|
@ -2425,6 +2425,12 @@ export interface SystemConfigDto {
|
|||
* @memberof SystemConfigDto
|
||||
*/
|
||||
'storageTemplate': SystemConfigStorageTemplateDto;
|
||||
/**
|
||||
*
|
||||
* @type {SystemConfigThumbnailDto}
|
||||
* @memberof SystemConfigDto
|
||||
*/
|
||||
'thumbnail': SystemConfigThumbnailDto;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
@ -2716,6 +2722,25 @@ export interface SystemConfigTemplateStorageOptionDto {
|
|||
*/
|
||||
'yearOptions': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface SystemConfigThumbnailDto
|
||||
*/
|
||||
export interface SystemConfigThumbnailDto {
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'jpegSize': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'webpSize': number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
};
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="mb-4 w-full">
|
||||
<div class={`flex h-[26px] place-items-center gap-1`}>
|
||||
<label class={`immich-form-label text-sm`} for={label}>{label}</label>
|
||||
{#if required}
|
||||
|
@ -45,7 +45,7 @@
|
|||
</div>
|
||||
|
||||
{#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}
|
||||
</p>
|
||||
{/if}
|
||||
|
|
|
@ -2,19 +2,23 @@
|
|||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
export let value: string;
|
||||
export let options: { value: string; text: string }[];
|
||||
export let value: string | number;
|
||||
export let options: { value: string | number; text: string }[];
|
||||
export let label = '';
|
||||
export let desc = '';
|
||||
export let name = '';
|
||||
export let isEdited = false;
|
||||
export let number = false;
|
||||
|
||||
const handleChange = (e: Event) => {
|
||||
value = (e.target as HTMLInputElement).value;
|
||||
if (number) {
|
||||
value = parseInt(value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<div class="mb-4 w-full">
|
||||
<div class={`flex h-[26px] place-items-center gap-1`}>
|
||||
<label class={`immich-form-label text-sm`} for="{name}-select">{label}</label>
|
||||
|
||||
|
@ -29,7 +33,7 @@
|
|||
</div>
|
||||
|
||||
{#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}
|
||||
</p>
|
||||
{/if}
|
||||
|
|
|
@ -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>
|
|
@ -2,6 +2,7 @@
|
|||
import { page } from '$app/stores';
|
||||
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 ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-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 SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
|
||||
|
@ -22,6 +23,10 @@
|
|||
{#await getConfig()}
|
||||
<LoadingSpinner />
|
||||
{:then configs}
|
||||
<SettingAccordion title="Thumbnail Settings" subtitle="Manage the resolution of thumbnail sizes">
|
||||
<ThumbnailSettings thumbnailConfig={configs.thumbnail} />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
title="FFmpeg Settings"
|
||||
subtitle="Manage the resolution and encoding information of the video files"
|
||||
|
|
Loading…
Reference in a new issue