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

feat(server): optimize person thumbnail generation ()

* do crop and resize together

* redundant `pipelineColorspace` call

* formatting

* fix rebase

* handle orientation

* remove unused import

* formatting

* use oriented dimensions for half size calculation

* default case for orientation

* simplify orientation code

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Mert 2024-05-08 09:09:34 -04:00 committed by GitHub
parent 81e4b69caf
commit 1167f0f2b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 137 additions and 105 deletions

View file

@ -3,11 +3,19 @@ import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/entities/system-co
export const IMediaRepository = 'IMediaRepository'; export const IMediaRepository = 'IMediaRepository';
export interface ResizeOptions { export interface CropOptions {
top: number;
left: number;
width: number;
height: number;
}
export interface ThumbnailOptions {
size: number; size: number;
format: ImageFormat; format: ImageFormat;
colorspace: string; colorspace: string;
quality: number; quality: number;
crop?: CropOptions;
} }
export interface VideoStreamInfo { export interface VideoStreamInfo {
@ -45,13 +53,6 @@ export interface VideoInfo {
audioStreams: AudioStreamInfo[]; audioStreams: AudioStreamInfo[];
} }
export interface CropOptions {
top: number;
left: number;
width: number;
height: number;
}
export interface TranscodeOptions { export interface TranscodeOptions {
inputOptions: string[]; inputOptions: string[];
outputOptions: string[]; outputOptions: string[];
@ -76,8 +77,7 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig {
export interface IMediaRepository { export interface IMediaRepository {
// image // image
extract(input: string, output: string): Promise<boolean>; extract(input: string, output: string): Promise<boolean>;
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>; generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise<void>;
crop(input: string, options: CropOptions): Promise<Buffer>;
generateThumbhash(imagePath: string): Promise<Buffer>; generateThumbhash(imagePath: string): Promise<Buffer>;
getImageDimensions(input: string): Promise<ImageDimensions>; getImageDimensions(input: string): Promise<ImageDimensions>;

View file

@ -8,10 +8,9 @@ import sharp from 'sharp';
import { Colorspace } from 'src/entities/system-config.entity'; import { Colorspace } from 'src/entities/system-config.entity';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { import {
CropOptions,
IMediaRepository, IMediaRepository,
ImageDimensions, ImageDimensions,
ResizeOptions, ThumbnailOptions,
TranscodeOptions, TranscodeOptions,
VideoInfo, VideoInfo,
} from 'src/interfaces/media.interface'; } from 'src/interfaces/media.interface';
@ -45,23 +44,17 @@ export class MediaRepository implements IMediaRepository {
return true; return true;
} }
crop(input: string | Buffer, options: CropOptions): Promise<Buffer> { async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise<void> {
return sharp(input, { failOn: 'none' }) const pipeline = sharp(input, { failOn: 'none' })
.pipelineColorspace('rgb16')
.extract({
left: options.left,
top: options.top,
width: options.width,
height: options.height,
})
.toBuffer();
}
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
await sharp(input, { failOn: 'none' })
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
.rotate();
if (options.crop) {
pipeline.extract(options.crop);
}
await pipeline
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }) .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.rotate()
.withIccProfile(options.colorspace) .withIccProfile(options.colorspace)
.toFormat(options.format, { .toFormat(options.format, {
quality: options.quality, quality: options.quality,

View file

@ -213,7 +213,7 @@ describe(MediaService.name, () => {
it('should skip thumbnail generation if asset not found', async () => { it('should skip thumbnail generation if asset not found', async () => {
assetMock.getByIds.mockResolvedValue([]); assetMock.getByIds.mockResolvedValue([]);
await sut.handleGeneratePreview({ id: assetStub.image.id }); await sut.handleGeneratePreview({ id: assetStub.image.id });
expect(mediaMock.resize).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith(); expect(assetMock.update).not.toHaveBeenCalledWith();
}); });
@ -221,7 +221,7 @@ describe(MediaService.name, () => {
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleGeneratePreview({ id: assetStub.image.id }); await sut.handleGeneratePreview({ id: assetStub.image.id });
expect(mediaMock.resize).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith(); expect(assetMock.update).not.toHaveBeenCalledWith();
}); });
@ -230,7 +230,7 @@ describe(MediaService.name, () => {
expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(mediaMock.resize).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith(); expect(assetMock.update).not.toHaveBeenCalledWith();
}); });
@ -242,7 +242,7 @@ describe(MediaService.name, () => {
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('/original/path.jpg', previewPath, { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', previewPath, {
size: 1440, size: 1440,
format, format,
quality: 80, quality: 80,
@ -269,7 +269,7 @@ describe(MediaService.name, () => {
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.generateThumbnail).toHaveBeenCalledWith(
'/original/path.jpg', '/original/path.jpg',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{ {
@ -369,7 +369,7 @@ describe(MediaService.name, () => {
it('should skip thumbnail generation if asset not found', async () => { it('should skip thumbnail generation if asset not found', async () => {
assetMock.getByIds.mockResolvedValue([]); assetMock.getByIds.mockResolvedValue([]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(mediaMock.resize).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith(); expect(assetMock.update).not.toHaveBeenCalledWith();
}); });
@ -378,7 +378,7 @@ describe(MediaService.name, () => {
expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(mediaMock.resize).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith(); expect(assetMock.update).not.toHaveBeenCalledWith();
}); });
@ -392,7 +392,7 @@ describe(MediaService.name, () => {
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ 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('/original/path.jpg', thumbnailPath, { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, {
size: 250, size: 250,
format, format,
quality: 80, quality: 80,
@ -419,7 +419,7 @@ describe(MediaService.name, () => {
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ 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.generateThumbnail).toHaveBeenCalledWith(
assetStub.imageDng.originalPath, assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{ {
@ -444,7 +444,7 @@ describe(MediaService.name, () => {
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString();
expect(mediaMock.resize.mock.calls).toEqual([ expect(mediaMock.generateThumbnail.mock.calls).toEqual([
[ [
extractedPath, extractedPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
@ -468,7 +468,7 @@ describe(MediaService.name, () => {
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(mediaMock.resize.mock.calls).toEqual([ expect(mediaMock.generateThumbnail.mock.calls).toEqual([
[ [
assetStub.imageDng.originalPath, assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
@ -491,7 +491,7 @@ describe(MediaService.name, () => {
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(mediaMock.resize).toHaveBeenCalledWith( expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.imageDng.originalPath, assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{ {
@ -511,7 +511,7 @@ describe(MediaService.name, () => {
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(mediaMock.extract).not.toHaveBeenCalled(); expect(mediaMock.extract).not.toHaveBeenCalled();
expect(mediaMock.resize).toHaveBeenCalledWith( expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.imageDng.originalPath, assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{ {

View file

@ -210,7 +210,8 @@ export class MediaService {
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
const imageOptions = { format, size, colorspace, quality: image.quality }; const imageOptions = { format, size, colorspace, quality: image.quality };
await this.mediaRepository.resize(useExtracted ? extractedPath : asset.originalPath, path, imageOptions); const outputPath = useExtracted ? extractedPath : asset.originalPath;
await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions);
} finally { } finally {
if (didExtract) { if (didExtract) {
await this.storageRepository.unlink(extractedPath); await this.storageRepository.unlink(extractedPath);

View file

@ -45,8 +45,6 @@ const responseDto: PersonResponseDto = {
const statistics = { assets: 3 }; const statistics = { assets: 3 };
const croppedFace = Buffer.from('Cropped Face');
const detectFaceMock = { const detectFaceMock = {
assetId: 'asset-1', assetId: 'asset-1',
personId: 'person-1', personId: 'person-1',
@ -104,8 +102,6 @@ describe(PersonService.name, () => {
cryptoMock, cryptoMock,
loggerMock, loggerMock,
); );
mediaMock.crop.mockResolvedValue(croppedFace);
}); });
it('should be defined', () => { it('should be defined', () => {
@ -862,20 +858,20 @@ describe(PersonService.name, () => {
it('should skip a person not found', async () => { it('should skip a person not found', async () => {
personMock.getById.mockResolvedValue(null); personMock.getById.mockResolvedValue(null);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
}); });
it('should skip a person without a face asset id', async () => { it('should skip a person without a face asset id', async () => {
personMock.getById.mockResolvedValue(personStub.noThumbnail); personMock.getById.mockResolvedValue(personStub.noThumbnail);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
}); });
it('should skip a person with a face asset id not found', async () => { it('should skip a person with a face asset id not found', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id }); personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id });
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
}); });
it('should skip a person with a face asset id without a thumbnail', async () => { it('should skip a person with a face asset id without a thumbnail', async () => {
@ -883,30 +879,34 @@ describe(PersonService.name, () => {
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mediaMock.crop).not.toHaveBeenCalled(); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled();
}); });
it('should generate a thumbnail', async () => { it('should generate a thumbnail', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle);
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); assetMock.getById.mockResolvedValue(assetStub.primaryImage);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([faceStub.middle.assetId]); expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/admin-id/thumbs/path.jpg', { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
left: 95, assetStub.primaryImage.originalPath,
top: 95, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
width: 110, {
height: 110, format: 'jpeg',
}); size: 250,
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { quality: 80,
format: 'jpeg', colorspace: Colorspace.P3,
size: 250, crop: {
quality: 80, left: 238,
colorspace: Colorspace.P3, top: 163,
}); width: 274,
height: 274,
},
},
);
expect(personMock.update).toHaveBeenCalledWith({ expect(personMock.update).toHaveBeenCalledWith({
id: 'person-1', id: 'person-1',
thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
@ -916,43 +916,51 @@ describe(PersonService.name, () => {
it('should generate a thumbnail without going negative', async () => { it('should generate a thumbnail without going negative', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId });
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start);
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getById.mockResolvedValue(assetStub.image);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
left: 0, assetStub.image.originalPath,
top: 0, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
width: 510, {
height: 510, format: 'jpeg',
}); size: 250,
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { quality: 80,
format: 'jpeg', colorspace: Colorspace.P3,
size: 250, crop: {
quality: 80, left: 0,
colorspace: Colorspace.P3, top: 428,
}); width: 1102,
height: 1102,
},
},
);
}); });
it('should generate a thumbnail without overflowing', async () => { it('should generate a thumbnail without overflowing', async () => {
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId });
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end);
assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); assetMock.getById.mockResolvedValue(assetStub.primaryImage);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/admin-id/thumbs/path.jpg', { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
left: 297, assetStub.primaryImage.originalPath,
top: 297, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
width: 202, {
height: 202, format: 'jpeg',
}); size: 250,
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { quality: 80,
format: 'jpeg', colorspace: Colorspace.P3,
size: 250, crop: {
quality: 80, left: 591,
colorspace: Colorspace.P3, top: 591,
}); width: 408,
height: 408,
},
},
);
}); });
}); });

View file

@ -40,12 +40,13 @@ import {
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { CropOptions, IMediaRepository } from 'src/interfaces/media.interface'; import { CropOptions, IMediaRepository, ImageDimensions } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface'; import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface'; import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { Orientation } from 'src/services/metadata.service';
import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@ -489,11 +490,13 @@ export class PersonService {
const person = await this.repository.getById(data.id); const person = await this.repository.getById(data.id);
if (!person?.faceAssetId) { if (!person?.faceAssetId) {
this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`);
return JobStatus.FAILED; return JobStatus.FAILED;
} }
const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId); const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId);
if (face === null) { if (face === null) {
this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`);
return JobStatus.FAILED; return JobStatus.FAILED;
} }
@ -507,19 +510,29 @@ export class PersonService {
imageHeight, imageHeight,
} = face; } = face;
const [asset] = await this.assetRepository.getByIds([assetId]); const asset = await this.assetRepository.getById(assetId, { exifInfo: true });
if (!asset?.previewPath) { if (!asset?.exifInfo?.exifImageHeight || !asset.exifInfo.exifImageWidth) {
this.logger.error(`Could not generate person thumbnail: asset ${assetId} dimensions are unknown`);
return JobStatus.FAILED; return JobStatus.FAILED;
} }
this.logger.verbose(`Cropping face for person: ${person.id}`); this.logger.verbose(`Cropping face for person: ${person.id}`);
const thumbnailPath = StorageCore.getPersonThumbnailPath(person); const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
this.storageCore.ensureFolders(thumbnailPath); this.storageCore.ensureFolders(thumbnailPath);
const halfWidth = (x2 - x1) / 2; const { width: exifWidth, height: exifHeight } = this.withOrientation(asset.exifInfo.orientation as Orientation, {
const halfHeight = (y2 - y1) / 2; width: asset.exifInfo.exifImageWidth,
height: asset.exifInfo.exifImageHeight,
});
const middleX = Math.round(x1 + halfWidth); const widthScale = exifWidth / imageWidth;
const middleY = Math.round(y1 + halfHeight); const heightScale = exifHeight / imageHeight;
const halfWidth = (widthScale * (x2 - x1)) / 2;
const halfHeight = (heightScale * (y2 - y1)) / 2;
const middleX = Math.round(widthScale * x1 + halfWidth);
const middleY = Math.round(heightScale * y1 + halfHeight);
// zoom out 10% // zoom out 10%
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1); const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
@ -528,8 +541,8 @@ export class PersonService {
const newHalfSize = Math.min( const newHalfSize = Math.min(
middleX - Math.max(0, middleX - targetHalfSize), middleX - Math.max(0, middleX - targetHalfSize),
middleY - Math.max(0, middleY - targetHalfSize), middleY - Math.max(0, middleY - targetHalfSize),
Math.min(imageWidth - 1, middleX + targetHalfSize) - middleX, Math.min(exifWidth - 1, middleX + targetHalfSize) - middleX,
Math.min(imageHeight - 1, middleY + targetHalfSize) - middleY, Math.min(exifHeight - 1, middleY + targetHalfSize) - middleY,
); );
const cropOptions: CropOptions = { const cropOptions: CropOptions = {
@ -539,15 +552,15 @@ export class PersonService {
height: newHalfSize * 2, height: newHalfSize * 2,
}; };
const croppedOutput = await this.mediaRepository.crop(asset.previewPath, cropOptions);
const thumbnailOptions = { const thumbnailOptions = {
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.quality,
crop: cropOptions,
} as const; } as const;
await this.mediaRepository.resize(croppedOutput, thumbnailPath, thumbnailOptions); await this.mediaRepository.generateThumbnail(asset.originalPath, thumbnailPath, thumbnailOptions);
await this.repository.update({ id: person.id, thumbnailPath }); await this.repository.update({ id: person.id, thumbnailPath });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
@ -614,4 +627,18 @@ export class PersonService {
} }
return person; return person;
} }
private withOrientation(orientation: Orientation, { width, height }: ImageDimensions): ImageDimensions {
switch (orientation) {
case Orientation.MirrorHorizontalRotate270CW:
case Orientation.Rotate90CW:
case Orientation.MirrorHorizontalRotate90CW:
case Orientation.Rotate270CW: {
return { width: height, height: width };
}
default: {
return { width, height };
}
}
}
} }

View file

@ -163,6 +163,8 @@ export const assetStub = {
sidecarPath: null, sidecarPath: null,
exifInfo: { exifInfo: {
fileSizeInByte: 5000, fileSizeInByte: 5000,
exifImageHeight: 1000,
exifImageWidth: 1000,
} as ExifEntity, } as ExifEntity,
stack: assetStackStub('stack-1', [ stack: assetStackStub('stack-1', [
{ id: 'primary-asset-id' } as AssetEntity, { id: 'primary-asset-id' } as AssetEntity,
@ -207,6 +209,8 @@ export const assetStub = {
sidecarPath: null, sidecarPath: null,
exifInfo: { exifInfo: {
fileSizeInByte: 5000, fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as ExifEntity, } as ExifEntity,
}), }),

View file

@ -3,10 +3,9 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => { export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => {
return { return {
generateThumbnail: vitest.fn(),
generateThumbhash: vitest.fn(), generateThumbhash: vitest.fn(),
extract: vitest.fn().mockResolvedValue(false), extract: vitest.fn().mockResolvedValue(false),
resize: vitest.fn(),
crop: vitest.fn(),
probe: vitest.fn(), probe: vitest.fn(),
transcode: vitest.fn(), transcode: vitest.fn(),
getImageDimensions: vitest.fn(), getImageDimensions: vitest.fn(),