fix(server): more robust person thumbnail generation ()

* more robust person thumbnail generation

* clamp bounding boxes

* update sql

* no need to process invalid images after decoding

* cursed knowledge

* new line
This commit is contained in:
Mert 2025-05-06 14:18:22 -04:00 committed by GitHub
parent d33ce13561
commit 2a80251dc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 491 additions and 223 deletions

View file

@ -2,6 +2,7 @@ import {
mdiBug,
mdiCalendarToday,
mdiCrosshairsOff,
mdiCrop,
mdiDatabase,
mdiLeadPencil,
mdiLockOff,
@ -22,6 +23,18 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri
type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date };
const items: Item[] = [
{
icon: mdiCrop,
iconColor: 'tomato',
title: 'Image dimensions in EXIF metadata are cursed',
description:
'The dimensions in EXIF metadata can be different from the actual dimensions of the image, causing issues with cropping and resizing.',
link: {
url: 'https://github.com/immich-app/immich/pull/17974',
text: '#17974',
},
date: new Date(2025, 5, 5),
},
{
icon: mdiMicrosoftWindows,
iconColor: '#357EC7',

View file

@ -143,23 +143,20 @@ select
"asset_faces"."boundingBoxY2" as "y2",
"asset_faces"."imageWidth" as "oldWidth",
"asset_faces"."imageHeight" as "oldHeight",
"exif"."exifImageWidth",
"exif"."exifImageHeight",
"assets"."type",
"assets"."originalPath",
"asset_files"."path" as "previewPath"
"asset_files"."path" as "previewPath",
"exif"."orientation" as "exifOrientation"
from
"person"
inner join "asset_faces" on "asset_faces"."id" = "person"."faceAssetId"
inner join "assets" on "asset_faces"."assetId" = "assets"."id"
inner join "exif" on "exif"."assetId" = "assets"."id"
inner join "asset_files" on "asset_files"."assetId" = "assets"."id"
left join "exif" on "exif"."assetId" = "assets"."id"
left join "asset_files" on "asset_files"."assetId" = "assets"."id"
where
"person"."id" = $1
and "asset_faces"."deletedAt" is null
and "asset_files"."type" = $2
and "exif"."exifImageWidth" > $3
and "exif"."exifImageHeight" > $4
-- PersonRepository.reassignFace
update "asset_faces"

View file

@ -264,8 +264,8 @@ export class PersonRepository {
.selectFrom('person')
.innerJoin('asset_faces', 'asset_faces.id', 'person.faceAssetId')
.innerJoin('assets', 'asset_faces.assetId', 'assets.id')
.innerJoin('exif', 'exif.assetId', 'assets.id')
.innerJoin('asset_files', 'asset_files.assetId', 'assets.id')
.leftJoin('exif', 'exif.assetId', 'assets.id')
.leftJoin('asset_files', 'asset_files.assetId', 'assets.id')
.select([
'person.ownerId',
'asset_faces.boundingBoxX1 as x1',
@ -274,17 +274,14 @@ export class PersonRepository {
'asset_faces.boundingBoxY2 as y2',
'asset_faces.imageWidth as oldWidth',
'asset_faces.imageHeight as oldHeight',
'exif.exifImageWidth',
'exif.exifImageHeight',
'assets.type',
'assets.originalPath',
'asset_files.path as previewPath',
'exif.orientation as exifOrientation',
])
.where('person.id', '=', id)
.where('asset_faces.deletedAt', 'is', null)
.where('asset_files.type', '=', AssetFileType.PREVIEW)
.where('exif.exifImageWidth', '>', 0)
.where('exif.exifImageHeight', '>', 0)
.$narrowType<{ exifImageWidth: NotNull; exifImageHeight: NotNull }>()
.executeTakeFirst();
}

View file

@ -7,6 +7,7 @@ import {
AssetType,
AudioCodec,
Colorspace,
ExifOrientation,
ImageFormat,
JobName,
JobStatus,
@ -20,7 +21,8 @@ import { JobCounts, RawImageInfo } from 'src/types';
import { assetStub } from 'test/fixtures/asset.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub';
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
describe(MediaService.name, () => {
@ -872,6 +874,323 @@ describe(MediaService.name, () => {
});
});
describe('handleGeneratePersonThumbnail', () => {
it('should skip if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.asset.getByIds).not.toHaveBeenCalled();
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
it('should skip a person not found', async () => {
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should skip a person without a face asset id', async () => {
mocks.person.getById.mockResolvedValue(personStub.noThumbnail);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should skip a person with face not found', async () => {
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should generate a thumbnail', async () => {
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, {
colorspace: Colorspace.P3,
orientation: undefined,
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
data,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
quality: 80,
crop: {
left: 238,
top: 163,
width: 274,
height: 274,
},
raw: info,
processInvalidImages: false,
size: 250,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
expect(mocks.person.update).toHaveBeenCalledWith({
id: 'person-1',
thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
});
});
it('should generate a thumbnail without going negative', async () => {
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, {
colorspace: Colorspace.P3,
orientation: undefined,
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
data,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
quality: 80,
crop: {
left: 0,
top: 85,
width: 510,
height: 510,
},
raw: info,
processInvalidImages: false,
size: 250,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
});
it('should generate a thumbnail without overflowing', async () => {
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, {
colorspace: Colorspace.P3,
orientation: undefined,
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
data,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
quality: 80,
crop: {
left: 591,
top: 591,
width: 408,
height: 408,
},
raw: info,
processInvalidImages: false,
size: 250,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
});
it('should handle negative coordinates', async () => {
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate);
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 4624, height: 3080 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, {
colorspace: Colorspace.P3,
orientation: undefined,
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
data,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
quality: 80,
crop: {
left: 0,
top: 62,
width: 412,
height: 412,
},
raw: info,
processInvalidImages: false,
size: 250,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
});
it('should handle overflowing coordinate', async () => {
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate);
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 4624, height: 3080 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, {
colorspace: Colorspace.P3,
orientation: undefined,
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
data,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
quality: 80,
crop: {
left: 4485,
top: 94,
width: 138,
height: 138,
},
raw: info,
processInvalidImages: false,
size: 250,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
});
it('should use embedded preview if enabled and raw image', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
mocks.media.generateThumbnail.mockResolvedValue();
const extracted = Buffer.from('');
const data = Buffer.from('');
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.JPEG });
mocks.media.decodeImage.mockResolvedValue({ data, info });
mocks.media.getImageDimensions.mockResolvedValue(info);
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, {
colorspace: Colorspace.P3,
orientation: ExifOrientation.Horizontal,
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
data,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
quality: 80,
crop: {
height: 844,
left: 388,
top: 730,
width: 844,
},
raw: info,
processInvalidImages: false,
size: 250,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
});
it('should not use embedded preview if enabled and not raw image', async () => {
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(mocks.media.extract).not.toHaveBeenCalled();
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
});
it('should not use embedded preview if enabled and raw image if not exists', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.media.generateThumbnail.mockResolvedValue();
const data = Buffer.from('');
const info = { width: 2160, height: 3840 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
colorspace: Colorspace.P3,
orientation: undefined,
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
});
it('should not use embedded preview if enabled and raw image if low resolution', async () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
mocks.media.generateThumbnail.mockResolvedValue();
const extracted = Buffer.from('');
const data = Buffer.from('');
const info = { width: 1000, height: 1000 } as OutputInfo;
mocks.media.decodeImage.mockResolvedValue({ data, info });
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.JPEG });
mocks.media.getImageDimensions.mockResolvedValue(info);
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
colorspace: Colorspace.P3,
orientation: undefined,
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
});
});
describe('handleQueueVideoConversion', () => {
it('should queue all video assets', async () => {
mocks.assetJob.streamForVideoConversion.mockReturnValue(makeStream([assetStub.video]));

View file

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { Exif } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
@ -11,6 +11,7 @@ import {
AssetVisibility,
AudioCodec,
Colorspace,
ImageFormat,
JobName,
JobStatus,
LogLevel,
@ -24,10 +25,13 @@ import {
VideoContainer,
} from 'src/enum';
import { UpsertFileOptions } from 'src/repositories/asset.repository';
import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { BaseService } from 'src/services/base.service';
import {
AudioStreamInfo,
CropOptions,
DecodeToBufferOptions,
ImageDimensions,
JobItem,
JobOf,
VideoFormat,
@ -37,6 +41,7 @@ import {
import { getAssetFiles } from 'src/utils/asset.util';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
@Injectable()
export class MediaService extends BaseService {
@ -308,6 +313,100 @@ export class MediaService extends BaseService {
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer };
}
@OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION })
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.GENERATE_PERSON_THUMBNAIL>): Promise<JobStatus> {
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
return JobStatus.SKIPPED;
}
const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
if (!data) {
this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);
return JobStatus.FAILED;
}
const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data;
let inputImage: string | Buffer;
if (mimeTypes.isVideo(originalPath)) {
if (!previewPath) {
this.logger.error(`Could not generate person thumbnail for video ${id}: missing preview path`);
return JobStatus.FAILED;
}
inputImage = previewPath;
}
if (image.extractEmbedded && mimeTypes.isRaw(originalPath)) {
const extracted = await this.extractImage(originalPath, image.preview.size);
inputImage = extracted ? extracted.buffer : originalPath;
} else {
inputImage = originalPath;
}
const { data: decodedImage, info } = await this.mediaRepository.decodeImage(inputImage, {
colorspace: image.colorspace,
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
// if this is an extracted image, it may not have orientation metadata
orientation: Buffer.isBuffer(inputImage) && exifOrientation ? Number(exifOrientation) : undefined,
});
const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId });
this.storageCore.ensureFolders(thumbnailPath);
const thumbnailOptions = {
colorspace: image.colorspace,
format: ImageFormat.JPEG,
raw: info,
quality: image.thumbnail.quality,
crop: this.getCrop(
{ old: { width: oldWidth, height: oldHeight }, new: { width: info.width, height: info.height } },
{ x1, y1, x2, y2 },
),
processInvalidImages: false,
size: FACE_THUMBNAIL_SIZE,
};
await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath);
await this.personRepository.update({ id, thumbnailPath });
return JobStatus.SUCCESS;
}
private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions {
// face bounding boxes can spill outside the image dimensions
const clampedX1 = clamp(x1, 0, dims.old.width);
const clampedY1 = clamp(y1, 0, dims.old.height);
const clampedX2 = clamp(x2, 0, dims.old.width);
const clampedY2 = clamp(y2, 0, dims.old.height);
const widthScale = dims.new.width / dims.old.width;
const heightScale = dims.new.height / dims.old.height;
const halfWidth = (widthScale * (clampedX2 - clampedX1)) / 2;
const halfHeight = (heightScale * (clampedY2 - clampedY1)) / 2;
const middleX = Math.round(widthScale * clampedX1 + halfWidth);
const middleY = Math.round(heightScale * clampedY1 + halfHeight);
// zoom out 10%
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
// get the longest distance from the center of the image without overflowing
const newHalfSize = Math.min(
middleX - Math.max(0, middleX - targetHalfSize),
middleY - Math.max(0, middleY - targetHalfSize),
Math.min(dims.new.width - 1, middleX + targetHalfSize) - middleX,
Math.min(dims.new.height - 1, middleY + targetHalfSize) - middleY,
);
return {
left: middleX - newHalfSize,
top: middleY - newHalfSize,
width: newHalfSize * 2,
height: newHalfSize * 2,
};
}
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
const { image, ffmpeg } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format);

View file

@ -1,7 +1,7 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
import { CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
import { DetectedFaces } from 'src/repositories/machine-learning.repository';
import { FaceSearchResult } from 'src/repositories/search.repository';
import { PersonService } from 'src/services/person.service';
@ -9,7 +9,7 @@ import { ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
import { personStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@ -1024,114 +1024,6 @@ describe(PersonService.name, () => {
});
});
describe('handleGeneratePersonThumbnail', () => {
it('should skip if machine learning is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled);
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.asset.getByIds).not.toHaveBeenCalled();
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
it('should skip a person not found', async () => {
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should skip a person without a face asset id', async () => {
mocks.person.getById.mockResolvedValue(personStub.noThumbnail);
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should skip a person with face not found', async () => {
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
});
it('should generate a thumbnail', async () => {
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
mocks.media.generateThumbnail.mockResolvedValue();
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 250,
quality: 80,
crop: {
left: 238,
top: 163,
width: 274,
height: 274,
},
processInvalidImages: false,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
expect(mocks.person.update).toHaveBeenCalledWith({
id: 'person-1',
thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
});
});
it('should generate a thumbnail without going negative', async () => {
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
mocks.media.generateThumbnail.mockResolvedValue();
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 250,
quality: 80,
crop: {
left: 0,
top: 85,
width: 510,
height: 510,
},
processInvalidImages: false,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
});
it('should generate a thumbnail without overflowing', async () => {
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
mocks.media.generateThumbnail.mockResolvedValue();
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath,
{
colorspace: Colorspace.P3,
format: ImageFormat.JPEG,
size: 250,
quality: 80,
crop: {
left: 591,
top: 591,
width: 408,
height: 408,
},
processInvalidImages: false,
},
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
);
});
});
describe('mergePerson', () => {
it('should require person.write and person.merge permission', async () => {
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);

View file

@ -1,7 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Insertable, Updateable } from 'kysely';
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { Person } from 'src/database';
import { AssetFaces, FaceSearch } from 'src/db';
import { Chunked, OnJob } from 'src/decorators';
@ -25,10 +24,8 @@ import {
PersonUpdateDto,
} from 'src/dtos/person.dto';
import {
AssetType,
AssetVisibility,
CacheControl,
ImageFormat,
JobName,
JobStatus,
Permission,
@ -40,10 +37,10 @@ import {
import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { UpdateFacesData } from 'src/repositories/person.repository';
import { BaseService } from 'src/services/base.service';
import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 'src/types';
import { JobItem, JobOf } from 'src/types';
import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
import { isFacialRecognitionEnabled } from 'src/utils/misc';
@Injectable()
export class PersonService extends BaseService {
@ -537,41 +534,6 @@ export class PersonService extends BaseService {
return JobStatus.SUCCESS;
}
@OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION })
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.GENERATE_PERSON_THUMBNAIL>): Promise<JobStatus> {
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
return JobStatus.SKIPPED;
}
const data = await this.personRepository.getDataForThumbnailGenerationJob(id);
if (!data) {
this.logger.error(`Could not generate person thumbnail for ${id}: missing data`);
return JobStatus.FAILED;
}
const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight } = data;
const { width, height, inputPath } = await this.getInputDimensions(data);
const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId });
this.storageCore.ensureFolders(thumbnailPath);
const thumbnailOptions = {
colorspace: image.colorspace,
format: ImageFormat.JPEG,
size: FACE_THUMBNAIL_SIZE,
quality: image.thumbnail.quality,
crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }),
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
};
await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath);
await this.personRepository.update({ id, thumbnailPath });
return JobStatus.SUCCESS;
}
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids;
if (mergeIds.includes(id)) {
@ -642,57 +604,6 @@ export class PersonService extends BaseService {
return person;
}
private async getInputDimensions(asset: {
type: AssetType;
exifImageWidth: number;
exifImageHeight: number;
previewPath: string;
originalPath: string;
oldWidth: number;
oldHeight: number;
}): Promise<InputDimensions> {
if (asset.type === AssetType.IMAGE) {
let { exifImageWidth: width, exifImageHeight: height } = asset;
if (asset.oldHeight > asset.oldWidth !== height > width) {
[width, height] = [height, width];
}
return { width, height, inputPath: asset.originalPath };
}
const { width, height } = await this.mediaRepository.getImageDimensions(asset.previewPath);
return { width, height, inputPath: asset.previewPath };
}
private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions {
const widthScale = dims.new.width / dims.old.width;
const heightScale = dims.new.height / dims.old.height;
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%
const targetHalfSize = Math.floor(Math.max(halfWidth, halfHeight) * 1.1);
// get the longest distance from the center of the image without overflowing
const newHalfSize = Math.min(
middleX - Math.max(0, middleX - targetHalfSize),
middleY - Math.max(0, middleY - targetHalfSize),
Math.min(dims.new.width - 1, middleX + targetHalfSize) - middleX,
Math.min(dims.new.height - 1, middleY + targetHalfSize) - middleY,
);
return {
left: middleX - newHalfSize,
top: middleY - newHalfSize,
width: newHalfSize * 2,
height: newHalfSize * 2,
};
}
// TODO return a asset face response
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
await Promise.all([

View file

@ -301,3 +301,7 @@ export const globToSqlPattern = (glob: string) => {
const tokens = picomatch.parse(glob).tokens;
return tokens.map((token) => convertTokenToSqlPattern(token)).join('');
};
export function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}

View file

@ -178,8 +178,7 @@ export const personThumbnailStub = {
oldWidth: 2160,
type: AssetType.IMAGE,
originalPath: '/original/path.jpg',
exifImageHeight: 3840,
exifImageWidth: 2160,
exifOrientation: '1',
previewPath: previewFile.path,
}),
newThumbnailMiddle: Object.freeze({
@ -192,8 +191,7 @@ export const personThumbnailStub = {
oldWidth: 400,
type: AssetType.IMAGE,
originalPath: '/original/path.jpg',
exifImageHeight: 1000,
exifImageWidth: 1000,
exifOrientation: '1',
previewPath: previewFile.path,
}),
newThumbnailEnd: Object.freeze({
@ -206,8 +204,46 @@ export const personThumbnailStub = {
oldWidth: 500,
type: AssetType.IMAGE,
originalPath: '/original/path.jpg',
exifImageHeight: 1000,
exifImageWidth: 1000,
exifOrientation: '1',
previewPath: previewFile.path,
}),
rawEmbeddedThumbnail: Object.freeze({
ownerId: userStub.admin.id,
x1: 100,
y1: 100,
x2: 200,
y2: 200,
oldHeight: 500,
oldWidth: 400,
type: AssetType.IMAGE,
originalPath: '/original/path.dng',
exifOrientation: '1',
previewPath: previewFile.path,
}),
negativeCoordinate: Object.freeze({
ownerId: userStub.admin.id,
x1: -176,
y1: -230,
x2: 193,
y2: 251,
oldHeight: 1440,
oldWidth: 2162,
type: AssetType.IMAGE,
originalPath: '/original/path.jpg',
exifOrientation: '1',
previewPath: previewFile.path,
}),
overflowingCoordinate: Object.freeze({
ownerId: userStub.admin.id,
x1: 2097,
y1: 0,
x2: 2171,
y2: 152,
oldHeight: 1440,
oldWidth: 2162,
type: AssetType.IMAGE,
originalPath: '/original/path.jpg',
exifOrientation: '1',
previewPath: previewFile.path,
}),
};