mirror of
https://github.com/immich-app/immich.git
synced 2025-01-21 03:02:44 +01:00
feat(server,web): configure image format (#8581)
This commit is contained in:
parent
55b9acca78
commit
105a74caca
3 changed files with 63 additions and 35 deletions
|
@ -210,25 +210,21 @@ describe(MediaService.name, () => {
|
||||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail for an image', async () => {
|
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => {
|
||||||
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
|
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
|
||||||
|
|
||||||
await sut.handleGeneratePreview({ id: assetStub.image.id });
|
await sut.handleGeneratePreview({ id: assetStub.image.id });
|
||||||
|
|
||||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', previewPath, {
|
||||||
'/original/path.jpg',
|
size: 1440,
|
||||||
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
format,
|
||||||
{
|
quality: 80,
|
||||||
size: 1440,
|
colorspace: Colorspace.SRGB,
|
||||||
format: ImageFormat.JPEG,
|
|
||||||
quality: 80,
|
|
||||||
colorspace: Colorspace.SRGB,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(assetMock.update).toHaveBeenCalledWith({
|
|
||||||
id: 'asset-id',
|
|
||||||
previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
|
||||||
});
|
});
|
||||||
|
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
||||||
|
@ -342,25 +338,25 @@ describe(MediaService.name, () => {
|
||||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail', async () => {
|
it.each(Object.values(ImageFormat))(
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
'should generate a %s thumbnail for an image when specified',
|
||||||
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
async (format) => {
|
||||||
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: format }]);
|
||||||
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
|
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
|
||||||
|
|
||||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
||||||
'/original/path.jpg',
|
|
||||||
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||||
{
|
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, {
|
||||||
format: ImageFormat.WEBP,
|
|
||||||
size: 250,
|
size: 250,
|
||||||
|
format,
|
||||||
quality: 80,
|
quality: 80,
|
||||||
colorspace: Colorspace.SRGB,
|
colorspace: Colorspace.SRGB,
|
||||||
},
|
});
|
||||||
);
|
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath });
|
||||||
expect(assetMock.update).toHaveBeenCalledWith({
|
},
|
||||||
id: 'asset-id',
|
);
|
||||||
thumbnailPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
||||||
|
|
|
@ -167,12 +167,15 @@ export class MediaService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
|
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
|
||||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
const [{ image }, [asset]] = await Promise.all([
|
||||||
|
this.configCore.getConfig(),
|
||||||
|
this.assetRepository.getByIds([id], { exifInfo: true }),
|
||||||
|
]);
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, ImageFormat.JPEG);
|
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
|
||||||
await this.assetRepository.update({ id: asset.id, previewPath });
|
await this.assetRepository.update({ id: asset.id, previewPath });
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
@ -210,18 +213,21 @@ export class MediaService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`,
|
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${asset.id}`,
|
||||||
);
|
);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
|
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
|
||||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
const [{ image }, [asset]] = await Promise.all([
|
||||||
|
this.configCore.getConfig(),
|
||||||
|
this.assetRepository.getByIds([id], { exifInfo: true }),
|
||||||
|
]);
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, ImageFormat.WEBP);
|
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
|
||||||
await this.assetRepository.update({ id: asset.id, thumbnailPath });
|
await this.assetRepository.update({ id: asset.id, thumbnailPath });
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Colorspace, type SystemConfigDto } from '@immich/sdk';
|
import { Colorspace, ImageFormat, type SystemConfigDto } from '@immich/sdk';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
@ -24,6 +24,19 @@
|
||||||
<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">
|
||||||
|
<SettingSelect
|
||||||
|
label="THUMBNAIL FORMAT"
|
||||||
|
desc="WebP produces smaller files than JPEG, but is slower to encode."
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="THUMBNAIL RESOLUTION"
|
label="THUMBNAIL RESOLUTION"
|
||||||
desc="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."
|
desc="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."
|
||||||
|
@ -41,6 +54,19 @@
|
||||||
{disabled}
|
{disabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingSelect
|
||||||
|
label="PREVIEW FORMAT"
|
||||||
|
desc="WebP produces smaller files than JPEG, but is slower to encode."
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="PREVIEW RESOLUTION"
|
label="PREVIEW RESOLUTION"
|
||||||
desc="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."
|
desc="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."
|
||||||
|
|
Loading…
Reference in a new issue