mirror of
https://github.com/immich-app/immich.git
synced 2025-01-01 08:31:59 +00:00
refactor(server): person thumbnail job (#4233)
* refactor(server): person thumbnail job * fix(server): set feature photo
This commit is contained in:
parent
ea797c1723
commit
7bc6e9ef64
12 changed files with 153 additions and 186 deletions
|
@ -26,45 +26,19 @@ import { FacialRecognitionService } from './facial-recognition.services';
|
||||||
|
|
||||||
const croppedFace = Buffer.from('Cropped Face');
|
const croppedFace = Buffer.from('Cropped Face');
|
||||||
|
|
||||||
const face = {
|
const detectFaceMock = {
|
||||||
start: {
|
assetId: 'asset-1',
|
||||||
assetId: 'asset-1',
|
personId: 'person-1',
|
||||||
personId: 'person-1',
|
boundingBox: {
|
||||||
boundingBox: {
|
x1: 100,
|
||||||
x1: 5,
|
y1: 100,
|
||||||
y1: 5,
|
x2: 200,
|
||||||
x2: 505,
|
y2: 200,
|
||||||
y2: 505,
|
|
||||||
},
|
|
||||||
imageHeight: 1000,
|
|
||||||
imageWidth: 1000,
|
|
||||||
},
|
|
||||||
middle: {
|
|
||||||
assetId: 'asset-1',
|
|
||||||
personId: 'person-1',
|
|
||||||
boundingBox: {
|
|
||||||
x1: 100,
|
|
||||||
y1: 100,
|
|
||||||
x2: 200,
|
|
||||||
y2: 200,
|
|
||||||
},
|
|
||||||
imageHeight: 500,
|
|
||||||
imageWidth: 400,
|
|
||||||
embedding: [1, 2, 3, 4],
|
|
||||||
score: 0.2,
|
|
||||||
},
|
|
||||||
end: {
|
|
||||||
assetId: 'asset-1',
|
|
||||||
personId: 'person-1',
|
|
||||||
boundingBox: {
|
|
||||||
x1: 300,
|
|
||||||
y1: 300,
|
|
||||||
x2: 495,
|
|
||||||
y2: 495,
|
|
||||||
},
|
|
||||||
imageHeight: 500,
|
|
||||||
imageWidth: 500,
|
|
||||||
},
|
},
|
||||||
|
imageHeight: 500,
|
||||||
|
imageWidth: 400,
|
||||||
|
embedding: [1, 2, 3, 4],
|
||||||
|
score: 0.2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const faceSearch = {
|
const faceSearch = {
|
||||||
|
@ -214,7 +188,7 @@ describe(FacialRecognitionService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match existing people', async () => {
|
it('should match existing people', async () => {
|
||||||
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
machineLearningMock.detectFaces.mockResolvedValue([detectFaceMock]);
|
||||||
searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch);
|
searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
await sut.handleRecognizeFaces({ id: assetStub.image.id });
|
await sut.handleRecognizeFaces({ id: assetStub.image.id });
|
||||||
|
@ -233,7 +207,7 @@ describe(FacialRecognitionService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a new person', async () => {
|
it('should create a new person', async () => {
|
||||||
machineLearningMock.detectFaces.mockResolvedValue([face.middle]);
|
machineLearningMock.detectFaces.mockResolvedValue([detectFaceMock]);
|
||||||
searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
|
searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch);
|
||||||
personMock.create.mockResolvedValue(personStub.noName);
|
personMock.create.mockResolvedValue(personStub.noName);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
|
@ -253,60 +227,56 @@ describe(FacialRecognitionService.name, () => {
|
||||||
imageWidth: 400,
|
imageWidth: 400,
|
||||||
});
|
});
|
||||||
expect(jobMock.queue.mock.calls).toEqual([
|
expect(jobMock.queue.mock.calls).toEqual([
|
||||||
[
|
|
||||||
{
|
|
||||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
|
||||||
data: {
|
|
||||||
assetId: 'asset-1',
|
|
||||||
personId: 'person-1',
|
|
||||||
boundingBox: {
|
|
||||||
x1: 100,
|
|
||||||
y1: 100,
|
|
||||||
x2: 200,
|
|
||||||
y2: 200,
|
|
||||||
},
|
|
||||||
imageHeight: 500,
|
|
||||||
imageWidth: 400,
|
|
||||||
score: 0.2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }],
|
[{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }],
|
||||||
|
[{ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'person-1' } }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleGenerateFaceThumbnail', () => {
|
describe('handleGeneratePersonThumbnail', () => {
|
||||||
it('should return if machine learning is disabled', async () => {
|
it('should return if machine learning is disabled', async () => {
|
||||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
|
||||||
|
|
||||||
await expect(sut.handleGenerateFaceThumbnail(face.middle)).resolves.toBe(true);
|
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(true);
|
||||||
expect(assetMock.getByIds).not.toHaveBeenCalled();
|
expect(assetMock.getByIds).not.toHaveBeenCalled();
|
||||||
expect(configMock.load).toHaveBeenCalled();
|
expect(configMock.load).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip an asset not found', async () => {
|
it('should skip a person not found', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([]);
|
personMock.getById.mockResolvedValue(null);
|
||||||
|
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
|
||||||
|
|
||||||
expect(mediaMock.crop).not.toHaveBeenCalled();
|
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip an asset without a thumbnail', async () => {
|
it('should skip a person without a face asset id', async () => {
|
||||||
|
personMock.getById.mockResolvedValue(personStub.noThumbnail);
|
||||||
|
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||||
|
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip an person with a face asset id not found', async () => {
|
||||||
|
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
|
||||||
|
faceMock.getByIds.mockResolvedValue([faceStub.face1]);
|
||||||
|
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||||
|
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip a person with a face asset id without a thumbnail', async () => {
|
||||||
|
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
|
||||||
|
faceMock.getByIds.mockResolvedValue([faceStub.face1]);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
|
assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
|
||||||
|
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
|
||||||
|
|
||||||
expect(mediaMock.crop).not.toHaveBeenCalled();
|
expect(mediaMock.crop).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a thumbnail', async () => {
|
it('should generate a thumbnail', async () => {
|
||||||
|
personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId });
|
||||||
|
faceMock.getByIds.mockResolvedValue([faceStub.middle]);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
|
|
||||||
await sut.handleGenerateFaceThumbnail(face.middle);
|
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||||
|
|
||||||
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
|
expect(assetMock.getByIds).toHaveBeenCalledWith([faceStub.middle.assetId]);
|
||||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/pe/rs');
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/pe/rs');
|
||||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
|
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
|
||||||
left: 95,
|
left: 95,
|
||||||
|
@ -321,16 +291,17 @@ describe(FacialRecognitionService.name, () => {
|
||||||
colorspace: Colorspace.P3,
|
colorspace: Colorspace.P3,
|
||||||
});
|
});
|
||||||
expect(personMock.update).toHaveBeenCalledWith({
|
expect(personMock.update).toHaveBeenCalledWith({
|
||||||
faceAssetId: 'asset-1',
|
|
||||||
id: 'person-1',
|
id: 'person-1',
|
||||||
thumbnailPath: 'upload/thumbs/user-id/pe/rs/person-1.jpeg',
|
thumbnailPath: 'upload/thumbs/user-id/pe/rs/person-1.jpeg',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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 });
|
||||||
|
faceMock.getByIds.mockResolvedValue([faceStub.start]);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
|
|
||||||
await sut.handleGenerateFaceThumbnail(face.start);
|
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||||
|
|
||||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
|
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
|
||||||
left: 0,
|
left: 0,
|
||||||
|
@ -347,9 +318,11 @@ describe(FacialRecognitionService.name, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
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 });
|
||||||
|
faceMock.getByIds.mockResolvedValue([faceStub.end]);
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||||
|
|
||||||
await sut.handleGenerateFaceThumbnail(face.end);
|
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||||
|
|
||||||
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
|
expect(mediaMock.crop).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg', {
|
||||||
left: 297,
|
left: 297,
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
|
import { PersonEntity } from '@app/infra/entities';
|
||||||
import { Inject, Logger } from '@nestjs/common';
|
import { Inject, Logger } from '@nestjs/common';
|
||||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||||
import { usePagination } from '../domain.util';
|
import { usePagination } from '../domain.util';
|
||||||
import { IBaseJob, IEntityJob, IFaceThumbnailJob, IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
|
import { IBaseJob, IEntityJob, IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
|
||||||
import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media';
|
import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media';
|
||||||
import { IPersonRepository } from '../person/person.repository';
|
import { IPersonRepository } from '../person/person.repository';
|
||||||
import { ISearchRepository } from '../search/search.repository';
|
import { ISearchRepository } from '../search/search.repository';
|
||||||
|
@ -89,18 +90,14 @@ export class FacialRecognitionService {
|
||||||
personId = faceSearchResult.items[0].personId;
|
personId = faceSearchResult.items[0].personId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let newPerson: PersonEntity | null = null;
|
||||||
if (!personId) {
|
if (!personId) {
|
||||||
this.logger.debug('No matches, creating a new person.');
|
this.logger.debug('No matches, creating a new person.');
|
||||||
const person = await this.personRepository.create({ ownerId: asset.ownerId });
|
newPerson = await this.personRepository.create({ ownerId: asset.ownerId });
|
||||||
personId = person.id;
|
personId = newPerson.id;
|
||||||
await this.jobRepository.queue({
|
|
||||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
|
||||||
data: { assetId: asset.id, personId, ...rest },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const faceId: AssetFaceId = { assetId: asset.id, personId };
|
const faceId: AssetFaceId = { assetId: asset.id, personId };
|
||||||
|
|
||||||
await this.faceRepository.create({
|
await this.faceRepository.create({
|
||||||
...faceId,
|
...faceId,
|
||||||
embedding,
|
embedding,
|
||||||
|
@ -112,6 +109,11 @@ export class FacialRecognitionService {
|
||||||
boundingBoxY2: rest.boundingBox.y2,
|
boundingBoxY2: rest.boundingBox.y2,
|
||||||
});
|
});
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
|
||||||
|
|
||||||
|
if (newPerson) {
|
||||||
|
await this.personRepository.update({ id: personId, faceAssetId: asset.id });
|
||||||
|
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -132,24 +134,41 @@ export class FacialRecognitionService {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) {
|
async handleGeneratePersonThumbnail(data: IEntityJob) {
|
||||||
const { machineLearning } = await this.configCore.getConfig();
|
const { machineLearning, thumbnail } = await this.configCore.getConfig();
|
||||||
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
|
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { assetId, personId, boundingBox, imageWidth, imageHeight } = data;
|
const person = await this.personRepository.getById(data.id);
|
||||||
|
if (!person?.faceAssetId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [face] = await this.faceRepository.getByIds([{ personId: person.id, assetId: person.faceAssetId }]);
|
||||||
|
if (!face) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
assetId,
|
||||||
|
personId,
|
||||||
|
boundingBoxX1: x1,
|
||||||
|
boundingBoxX2: x2,
|
||||||
|
boundingBoxY1: y1,
|
||||||
|
boundingBoxY2: y2,
|
||||||
|
imageWidth,
|
||||||
|
imageHeight,
|
||||||
|
} = face;
|
||||||
|
|
||||||
const [asset] = await this.assetRepository.getByIds([assetId]);
|
const [asset] = await this.assetRepository.getByIds([assetId]);
|
||||||
if (!asset || !asset.resizePath) {
|
if (!asset?.resizePath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.verbose(`Cropping face for person: ${personId}`);
|
this.logger.verbose(`Cropping face for person: ${personId}`);
|
||||||
|
|
||||||
const output = this.storageCore.ensurePath(StorageFolder.THUMBNAILS, asset.ownerId, `${personId}.jpeg`);
|
const thumbnailPath = this.storageCore.ensurePath(StorageFolder.THUMBNAILS, asset.ownerId, `${personId}.jpeg`);
|
||||||
|
|
||||||
const { x1, y1, x2, y2 } = boundingBox;
|
|
||||||
|
|
||||||
const halfWidth = (x2 - x1) / 2;
|
const halfWidth = (x2 - x1) / 2;
|
||||||
const halfHeight = (y2 - y1) / 2;
|
const halfHeight = (y2 - y1) / 2;
|
||||||
|
@ -175,7 +194,6 @@ export class FacialRecognitionService {
|
||||||
height: newHalfSize * 2,
|
height: newHalfSize * 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { thumbnail } = await this.configCore.getConfig();
|
|
||||||
const croppedOutput = await this.mediaRepository.crop(asset.resizePath, cropOptions);
|
const croppedOutput = await this.mediaRepository.crop(asset.resizePath, cropOptions);
|
||||||
const thumbnailOptions = {
|
const thumbnailOptions = {
|
||||||
format: 'jpeg',
|
format: 'jpeg',
|
||||||
|
@ -183,8 +201,9 @@ export class FacialRecognitionService {
|
||||||
colorspace: thumbnail.colorspace,
|
colorspace: thumbnail.colorspace,
|
||||||
quality: thumbnail.quality,
|
quality: thumbnail.quality,
|
||||||
} as const;
|
} as const;
|
||||||
await this.mediaRepository.resize(croppedOutput, output, thumbnailOptions);
|
|
||||||
await this.personRepository.update({ id: personId, thumbnailPath: output, faceAssetId: data.assetId });
|
await this.mediaRepository.resize(croppedOutput, thumbnailPath, thumbnailOptions);
|
||||||
|
await this.personRepository.update({ id: personId, thumbnailPath });
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ export enum JobName {
|
||||||
GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
|
GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
|
||||||
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
|
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
|
||||||
GENERATE_THUMBHASH_THUMBNAIL = 'generate-thumbhash-thumbnail',
|
GENERATE_THUMBHASH_THUMBNAIL = 'generate-thumbhash-thumbnail',
|
||||||
GENERATE_FACE_THUMBNAIL = 'generate-face-thumbnail',
|
GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail',
|
||||||
|
|
||||||
// metadata
|
// metadata
|
||||||
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
|
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
|
||||||
|
@ -113,7 +113,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||||
[JobName.GENERATE_JPEG_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
[JobName.GENERATE_JPEG_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
||||||
[JobName.GENERATE_WEBP_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
[JobName.GENERATE_WEBP_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
||||||
[JobName.GENERATE_THUMBHASH_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
[JobName.GENERATE_THUMBHASH_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
||||||
[JobName.GENERATE_FACE_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
[JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
||||||
|
|
||||||
// metadata
|
// metadata
|
||||||
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { BoundingBox } from '../smart-info';
|
|
||||||
|
|
||||||
export interface IBaseJob {
|
export interface IBaseJob {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -9,14 +7,6 @@ export interface IAssetFaceJob extends IBaseJob {
|
||||||
personId: string;
|
personId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFaceThumbnailJob extends IAssetFaceJob {
|
|
||||||
imageWidth: number;
|
|
||||||
imageHeight: number;
|
|
||||||
boundingBox: BoundingBox;
|
|
||||||
assetId: string;
|
|
||||||
personId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IEntityJob extends IBaseJob {
|
export interface IEntityJob extends IBaseJob {
|
||||||
id: string;
|
id: string;
|
||||||
source?: 'upload';
|
source?: 'upload';
|
||||||
|
|
|
@ -6,7 +6,6 @@ import {
|
||||||
IBulkEntityJob,
|
IBulkEntityJob,
|
||||||
IDeleteFilesJob,
|
IDeleteFilesJob,
|
||||||
IEntityJob,
|
IEntityJob,
|
||||||
IFaceThumbnailJob,
|
|
||||||
ILibraryFileJob,
|
ILibraryFileJob,
|
||||||
ILibraryRefreshJob,
|
ILibraryRefreshJob,
|
||||||
IOfflineLibraryFileJob,
|
IOfflineLibraryFileJob,
|
||||||
|
@ -68,7 +67,7 @@ export type JobItem =
|
||||||
// Recognize Faces
|
// Recognize Faces
|
||||||
| { name: JobName.QUEUE_RECOGNIZE_FACES; data: IBaseJob }
|
| { name: JobName.QUEUE_RECOGNIZE_FACES; data: IBaseJob }
|
||||||
| { name: JobName.RECOGNIZE_FACES; data: IEntityJob }
|
| { name: JobName.RECOGNIZE_FACES; data: IEntityJob }
|
||||||
| { name: JobName.GENERATE_FACE_THUMBNAIL; data: IFaceThumbnailJob }
|
| { name: JobName.GENERATE_PERSON_THUMBNAIL; data: IEntityJob }
|
||||||
|
|
||||||
// Clip Embedding
|
// Clip Embedding
|
||||||
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
|
| { name: JobName.QUEUE_ENCODE_CLIP; data: IBaseJob }
|
||||||
|
|
|
@ -73,19 +73,8 @@ describe(MediaService.name, () => {
|
||||||
expect(personMock.getAll).toHaveBeenCalled();
|
expect(personMock.getAll).toHaveBeenCalled();
|
||||||
expect(personMock.getAllWithoutThumbnail).not.toHaveBeenCalled();
|
expect(personMock.getAllWithoutThumbnail).not.toHaveBeenCalled();
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||||
data: {
|
data: { id: personStub.newThumbnail.id },
|
||||||
imageWidth: faceStub.face1.imageWidth,
|
|
||||||
imageHeight: faceStub.face1.imageHeight,
|
|
||||||
boundingBox: {
|
|
||||||
x1: faceStub.face1.boundingBoxX1,
|
|
||||||
x2: faceStub.face1.boundingBoxX2,
|
|
||||||
y1: faceStub.face1.boundingBoxY1,
|
|
||||||
y2: faceStub.face1.boundingBoxY2,
|
|
||||||
},
|
|
||||||
assetId: faceStub.face1.assetId,
|
|
||||||
personId: personStub.newThumbnail.id,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -106,18 +95,9 @@ describe(MediaService.name, () => {
|
||||||
expect(personMock.getAllWithoutThumbnail).toHaveBeenCalled();
|
expect(personMock.getAllWithoutThumbnail).toHaveBeenCalled();
|
||||||
expect(personMock.getRandomFace).toHaveBeenCalled();
|
expect(personMock.getRandomFace).toHaveBeenCalled();
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
name: JobName.GENERATE_PERSON_THUMBNAIL,
|
||||||
data: {
|
data: {
|
||||||
imageWidth: faceStub.face1.imageWidth,
|
id: personStub.newThumbnail.id,
|
||||||
imageHeight: faceStub.face1.imageHeight,
|
|
||||||
boundingBox: {
|
|
||||||
x1: faceStub.face1.boundingBoxX1,
|
|
||||||
x2: faceStub.face1.boundingBoxX2,
|
|
||||||
y1: faceStub.face1.boundingBoxY1,
|
|
||||||
y2: faceStub.face1.boundingBoxY2,
|
|
||||||
},
|
|
||||||
assetId: faceStub.face1.assetId,
|
|
||||||
personId: personStub.newThumbnail.id,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -53,27 +53,16 @@ export class MediaService {
|
||||||
const people = force ? await this.personRepository.getAll() : await this.personRepository.getAllWithoutThumbnail();
|
const people = force ? await this.personRepository.getAll() : await this.personRepository.getAllWithoutThumbnail();
|
||||||
|
|
||||||
for (const person of people) {
|
for (const person of people) {
|
||||||
// use stored asset for generating thumbnail or pick a random one if not present
|
if (!person.faceAssetId) {
|
||||||
const face = person.faceAssetId
|
const face = await this.personRepository.getRandomFace(person.id);
|
||||||
? await this.personRepository.getFaceById({ personId: person.id, assetId: person.faceAssetId })
|
if (!face) {
|
||||||
: await this.personRepository.getRandomFace(person.id);
|
continue;
|
||||||
if (face) {
|
}
|
||||||
await this.jobRepository.queue({
|
|
||||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
await this.personRepository.update({ id: person.id, faceAssetId: face.assetId });
|
||||||
data: {
|
|
||||||
imageWidth: face.imageWidth,
|
|
||||||
imageHeight: face.imageHeight,
|
|
||||||
boundingBox: {
|
|
||||||
x1: face.boundingBoxX1,
|
|
||||||
x2: face.boundingBoxX2,
|
|
||||||
y1: face.boundingBoxY1,
|
|
||||||
y2: face.boundingBoxY2,
|
|
||||||
},
|
|
||||||
assetId: face.assetId,
|
|
||||||
personId: person.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -249,7 +249,9 @@ describe(PersonService.name, () => {
|
||||||
|
|
||||||
it("should update a person's thumbnailPath", async () => {
|
it("should update a person's thumbnailPath", async () => {
|
||||||
personMock.getById.mockResolvedValue(personStub.withName);
|
personMock.getById.mockResolvedValue(personStub.withName);
|
||||||
|
personMock.update.mockResolvedValue(personStub.withName);
|
||||||
personMock.getFaceById.mockResolvedValue(faceStub.face1);
|
personMock.getFaceById.mockResolvedValue(faceStub.face1);
|
||||||
|
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||||
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
@ -257,25 +259,12 @@ describe(PersonService.name, () => {
|
||||||
).resolves.toEqual(responseDto);
|
).resolves.toEqual(responseDto);
|
||||||
|
|
||||||
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||||
|
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.assetId });
|
||||||
expect(personMock.getFaceById).toHaveBeenCalledWith({
|
expect(personMock.getFaceById).toHaveBeenCalledWith({
|
||||||
assetId: faceStub.face1.assetId,
|
assetId: faceStub.face1.assetId,
|
||||||
personId: 'person-1',
|
personId: 'person-1',
|
||||||
});
|
});
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'person-1' } });
|
||||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
|
||||||
data: {
|
|
||||||
assetId: faceStub.face1.assetId,
|
|
||||||
personId: 'person-1',
|
|
||||||
boundingBox: {
|
|
||||||
x1: faceStub.face1.boundingBoxX1,
|
|
||||||
x2: faceStub.face1.boundingBoxX2,
|
|
||||||
y1: faceStub.face1.boundingBoxY1,
|
|
||||||
y2: faceStub.face1.boundingBoxY2,
|
|
||||||
},
|
|
||||||
imageHeight: faceStub.face1.imageHeight,
|
|
||||||
imageWidth: faceStub.face1.imageWidth,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -77,8 +77,10 @@ export class PersonService {
|
||||||
await this.access.requirePermission(authUser, Permission.PERSON_WRITE, id);
|
await this.access.requirePermission(authUser, Permission.PERSON_WRITE, id);
|
||||||
let person = await this.findOrFail(id);
|
let person = await this.findOrFail(id);
|
||||||
|
|
||||||
if (dto.name !== undefined || dto.birthDate !== undefined || dto.isHidden !== undefined) {
|
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
|
||||||
person = await this.repository.update({ id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden });
|
|
||||||
|
if (name !== undefined || birthDate !== undefined || isHidden !== undefined) {
|
||||||
|
person = await this.repository.update({ id, name, birthDate, isHidden });
|
||||||
if (this.needsSearchIndexUpdate(dto)) {
|
if (this.needsSearchIndexUpdate(dto)) {
|
||||||
const assets = await this.repository.getAssets(id);
|
const assets = await this.repository.getAssets(id);
|
||||||
const ids = assets.map((asset) => asset.id);
|
const ids = assets.map((asset) => asset.id);
|
||||||
|
@ -86,28 +88,15 @@ export class PersonService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.featureFaceAssetId) {
|
if (assetId) {
|
||||||
const assetId = dto.featureFaceAssetId;
|
await this.access.requirePermission(authUser, Permission.ASSET_READ, assetId);
|
||||||
const face = await this.repository.getFaceById({ personId: id, assetId });
|
const face = await this.repository.getFaceById({ personId: id, assetId });
|
||||||
if (!face) {
|
if (!face) {
|
||||||
throw new BadRequestException('Invalid assetId for feature face');
|
throw new BadRequestException('Invalid assetId for feature face');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.jobRepository.queue({
|
person = await this.repository.update({ id, faceAssetId: assetId });
|
||||||
name: JobName.GENERATE_FACE_THUMBNAIL,
|
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
|
||||||
data: {
|
|
||||||
personId: id,
|
|
||||||
assetId,
|
|
||||||
boundingBox: {
|
|
||||||
x1: face.boundingBoxX1,
|
|
||||||
x2: face.boundingBoxX2,
|
|
||||||
y1: face.boundingBoxY1,
|
|
||||||
y2: face.boundingBoxY2,
|
|
||||||
},
|
|
||||||
imageHeight: face.imageHeight,
|
|
||||||
imageWidth: face.imageWidth,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapPerson(person);
|
return mapPerson(person);
|
||||||
|
|
|
@ -70,7 +70,7 @@ export class JobRepository implements IJobRepository {
|
||||||
|
|
||||||
private getJobOptions(item: JobItem): JobsOptions | null {
|
private getJobOptions(item: JobItem): JobsOptions | null {
|
||||||
switch (item.name) {
|
switch (item.name) {
|
||||||
case JobName.GENERATE_FACE_THUMBNAIL:
|
case JobName.GENERATE_PERSON_THUMBNAIL:
|
||||||
return { priority: 1 };
|
return { priority: 1 };
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -78,7 +78,7 @@ export class AppService {
|
||||||
[JobName.LINK_LIVE_PHOTOS]: (data) => this.metadataProcessor.handleLivePhotoLinking(data),
|
[JobName.LINK_LIVE_PHOTOS]: (data) => this.metadataProcessor.handleLivePhotoLinking(data),
|
||||||
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
|
[JobName.QUEUE_RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleQueueRecognizeFaces(data),
|
||||||
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
|
[JobName.RECOGNIZE_FACES]: (data) => this.facialRecognitionService.handleRecognizeFaces(data),
|
||||||
[JobName.GENERATE_FACE_THUMBNAIL]: (data) => this.facialRecognitionService.handleGenerateFaceThumbnail(data),
|
[JobName.GENERATE_PERSON_THUMBNAIL]: (data) => this.facialRecognitionService.handleGeneratePersonThumbnail(data),
|
||||||
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
|
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
|
||||||
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
|
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
|
||||||
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
|
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
|
||||||
|
|
39
server/test/fixtures/face.stub.ts
vendored
39
server/test/fixtures/face.stub.ts
vendored
|
@ -55,4 +55,43 @@ export const faceStub = {
|
||||||
imageHeight: 1024,
|
imageHeight: 1024,
|
||||||
imageWidth: 1024,
|
imageWidth: 1024,
|
||||||
}),
|
}),
|
||||||
|
start: Object.freeze<AssetFaceEntity>({
|
||||||
|
assetId: assetStub.image.id,
|
||||||
|
asset: assetStub.image,
|
||||||
|
personId: personStub.newThumbnail.id,
|
||||||
|
person: personStub.newThumbnail,
|
||||||
|
embedding: [1, 2, 3, 4],
|
||||||
|
boundingBoxX1: 5,
|
||||||
|
boundingBoxY1: 5,
|
||||||
|
boundingBoxX2: 505,
|
||||||
|
boundingBoxY2: 505,
|
||||||
|
imageHeight: 1000,
|
||||||
|
imageWidth: 1000,
|
||||||
|
}),
|
||||||
|
middle: Object.freeze<AssetFaceEntity>({
|
||||||
|
assetId: assetStub.image.id,
|
||||||
|
asset: assetStub.image,
|
||||||
|
personId: personStub.newThumbnail.id,
|
||||||
|
person: personStub.newThumbnail,
|
||||||
|
embedding: [1, 2, 3, 4],
|
||||||
|
boundingBoxX1: 100,
|
||||||
|
boundingBoxY1: 100,
|
||||||
|
boundingBoxX2: 200,
|
||||||
|
boundingBoxY2: 200,
|
||||||
|
imageHeight: 500,
|
||||||
|
imageWidth: 400,
|
||||||
|
}),
|
||||||
|
end: Object.freeze<AssetFaceEntity>({
|
||||||
|
assetId: assetStub.image.id,
|
||||||
|
asset: assetStub.image,
|
||||||
|
personId: personStub.newThumbnail.id,
|
||||||
|
person: personStub.newThumbnail,
|
||||||
|
embedding: [1, 2, 3, 4],
|
||||||
|
boundingBoxX1: 300,
|
||||||
|
boundingBoxY1: 300,
|
||||||
|
boundingBoxX2: 495,
|
||||||
|
boundingBoxY2: 495,
|
||||||
|
imageHeight: 500,
|
||||||
|
imageWidth: 500,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue