Files
Immich/server/test/medium/specs/services/person.service.spec.ts
T

980 lines
35 KiB
TypeScript

import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
import { AssetFaceCreateDto } from 'src/dtos/person.dto';
import { JobName } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { DB } from 'src/schema';
import { PersonService } from 'src/services/person.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(PersonService, {
database: db || defaultDatabase,
real: [
AccessRepository,
ConfigRepository,
DatabaseRepository,
PersonRepository,
AssetRepository,
AssetEditRepository,
SystemMetadataRepository,
],
mock: [JobRepository, LoggingRepository, StorageRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(PersonService.name, () => {
describe('delete', () => {
it('should throw an error when there is no access', async () => {
const { sut } = setup();
const auth = factory.auth();
const personId = factory.uuid();
await expect(sut.delete(auth, personId)).rejects.toThrow('Not found or no person.delete access');
});
it('should delete the person', async () => {
const { sut, ctx } = setup();
const personRepo = ctx.get(PersonRepository);
const storageMock = ctx.getMock(StorageRepository);
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const auth = factory.auth({ user });
storageMock.unlink.mockResolvedValue();
await expect(personRepo.getByGroupId(person)).resolves.toEqual(
expect.objectContaining({ personGroupId: person.personGroupId }),
);
await expect(sut.delete(auth, person.personGroupId)).resolves.toBeUndefined();
await expect(personRepo.getByGroupId(person)).resolves.toBeUndefined();
expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath);
});
});
describe('deleteAll', () => {
it('should throw an error when there is no access', async () => {
const { sut } = setup();
const auth = factory.auth();
const personId = factory.uuid();
await expect(sut.deleteAll(auth, { ids: [personId] })).rejects.toThrow('Not found or no person.delete access');
});
it('should delete the person', async () => {
const { sut, ctx } = setup();
const storageMock = ctx.getMock(StorageRepository);
const personRepo = ctx.get(PersonRepository);
const { user } = await ctx.newUser();
const { person: person1 } = await ctx.newPerson({ ownerId: user.id });
const { person: person2 } = await ctx.newPerson({ ownerId: user.id });
const auth = factory.auth({ user });
storageMock.unlink.mockResolvedValue();
await expect(
sut.deleteAll(auth, { ids: [person1.personGroupId, person2.personGroupId] }),
).resolves.toBeUndefined();
await expect(personRepo.getByGroupId(person1)).resolves.toBeUndefined();
await expect(personRepo.getByGroupId(person2)).resolves.toBeUndefined();
expect(storageMock.unlink).toHaveBeenCalledTimes(2);
expect(storageMock.unlink).toHaveBeenCalledWith(person1.thumbnailPath);
expect(storageMock.unlink).toHaveBeenCalledWith(person2.thumbnailPath);
});
});
describe('handleQueueRecognizeFaces', () => {
it('should delete all people and queue faces for recognition', async () => {
const { sut, ctx } = setup();
const jobRepo = ctx.getMock(JobRepository);
ctx.getMock(StorageRepository).unlink.mockResolvedValue();
jobRepo.waitForQueueCompletion.mockResolvedValue();
jobRepo.getJobCounts.mockResolvedValue({ active: 0, waiting: 0, completed: 0, delayed: 0, failed: 0, paused: 0 });
jobRepo.queueAll.mockResolvedValue();
const { user } = await ctx.newUser();
const { user: user1 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { asset: assetUser1 } = await ctx.newAsset({ ownerId: user1.id });
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person: personUser1 } = await ctx.newPerson({ ownerId: user1.id });
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
const { assetFace: assetFaceUser1 } = await ctx.newAssetFace({
assetId: assetUser1.id,
personGroupId: personUser1.personGroupId,
});
await sut.handleQueueRecognizeFaces({ force: true });
await expect(ctx.database.selectFrom('person').selectAll().execute()).resolves.toHaveLength(0);
expect(jobRepo.queueAll).toHaveBeenCalledWith(
expect.objectContaining([
{ name: JobName.FacialRecognition, data: { id: assetFace.id, deferred: false } },
{ name: JobName.FacialRecognition, data: { id: assetFaceUser1.id, deferred: false } },
]),
);
});
it('should only delete all people of a specified cluster group and queue their faces for recognition', async () => {
const { sut, ctx } = setup();
const jobRepo = ctx.getMock(JobRepository);
ctx.getMock(StorageRepository).unlink.mockResolvedValue();
jobRepo.waitForQueueCompletion.mockResolvedValue();
jobRepo.getJobCounts.mockResolvedValue({ active: 0, waiting: 0, completed: 0, delayed: 0, failed: 0, paused: 0 });
jobRepo.queueAll.mockResolvedValue();
const { user } = await ctx.newUser();
const { user: user1 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { asset: assetUser1 } = await ctx.newAsset({ ownerId: user1.id });
const { person } = await ctx.newPerson({ ownerId: user.id });
const { person: personUser1 } = await ctx.newPerson({ ownerId: user1.id });
const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId });
const { assetFace: assetFaceUser1 } = await ctx.newAssetFace({
assetId: assetUser1.id,
personGroupId: personUser1.personGroupId,
});
await sut.handleQueueRecognizeFaces({ force: true, clusterGroupId: user.clusterGroupId });
await expect(ctx.database.selectFrom('person').selectAll().execute()).resolves.toHaveLength(1);
expect(jobRepo.queueAll).toHaveBeenCalledWith(
expect.objectContaining([{ name: JobName.FacialRecognition, data: { id: assetFace.id, deferred: false } }]),
);
expect(jobRepo.queueAll).not.toHaveBeenCalledWith(
expect.objectContaining([
{ name: JobName.FacialRecognition, data: { id: assetFace.id, deferred: false } },
{ name: JobName.FacialRecognition, data: { id: assetFaceUser1.id, deferred: false } },
]),
);
});
});
describe('mergePerson', () => {
it('should merge people of multiple users', async () => {
const { sut, ctx } = setup();
const storageMock = ctx.getMock(StorageRepository);
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId });
const { person: person1 } = await ctx.newPerson({ ownerId: user1.id });
const { person: person2 } = await ctx.newPerson({ ownerId: user1.id });
await ctx.newPerson({
ownerId: user2.id,
personGroupId: person1.personGroupId,
});
await ctx.newPerson({
ownerId: user2.id,
personGroupId: person2.personGroupId,
});
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAssetFace({ assetId: asset.id, personGroupId: person2.personGroupId });
storageMock.unlink.mockResolvedValue();
const auth = factory.auth({ user: user1 });
await sut.mergePerson(auth, person1.personGroupId, { ids: [person2.personGroupId] });
const user1People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user1.id }));
const user2People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user2.id }));
expect(user1People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]);
expect(user2People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]);
await expect(ctx.get(PersonRepository).getFaces(asset.id, { viewingUserId: asset.ownerId })).resolves.toEqual([
expect.objectContaining({ personGroupId: person1.personGroupId }),
]);
});
it('should skip people with a different name', async () => {
const { sut, ctx } = setup();
const storageMock = ctx.getMock(StorageRepository);
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId });
const { person: person1 } = await ctx.newPerson({ ownerId: user1.id });
const { person: person2 } = await ctx.newPerson({ ownerId: user1.id });
await ctx.newPerson({
ownerId: user2.id,
personGroupId: person1.personGroupId,
name: 'Person 1',
});
await ctx.newPerson({
ownerId: user2.id,
personGroupId: person2.personGroupId,
name: 'Person 2',
});
storageMock.unlink.mockResolvedValue();
const auth = factory.auth({ user: user1 });
await sut.mergePerson(auth, person1.personGroupId, { ids: [person2.personGroupId] });
const user1People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user1.id }));
const user2People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user2.id }));
expect(user1People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]);
expect(user2People).toEqual(
expect.arrayContaining([
expect.objectContaining({ personGroupId: person1.personGroupId }),
expect.objectContaining({ personGroupId: person2.personGroupId }),
]),
);
});
it('should skip people with a different birthdate', async () => {
const { sut, ctx } = setup();
const storageMock = ctx.getMock(StorageRepository);
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId });
const { person: person1 } = await ctx.newPerson({ ownerId: user1.id });
const { person: person2 } = await ctx.newPerson({ ownerId: user1.id });
await ctx.newPerson({
ownerId: user2.id,
personGroupId: person1.personGroupId,
birthDate: DateTime.now().minus({ years: 1 }).toJSDate(),
});
await ctx.newPerson({
ownerId: user2.id,
personGroupId: person2.personGroupId,
birthDate: DateTime.now().minus({ years: 2 }).toJSDate(),
});
storageMock.unlink.mockResolvedValue();
const auth = factory.auth({ user: user1 });
await sut.mergePerson(auth, person1.personGroupId, { ids: [person2.personGroupId] });
const user1People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user1.id }));
const user2People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user2.id }));
expect(user1People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]);
expect(user2People).toEqual(
expect.arrayContaining([
expect.objectContaining({ personGroupId: person1.personGroupId }),
expect.objectContaining({ personGroupId: person2.personGroupId }),
]),
);
});
it('should not merge into person another user does not have', async () => {
const { sut, ctx } = setup();
const storageMock = ctx.getMock(StorageRepository);
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId });
const { person: person1 } = await ctx.newPerson({ ownerId: user1.id });
const { person: person2 } = await ctx.newPerson({ ownerId: user1.id });
await ctx.newPerson({
ownerId: user2.id,
personGroupId: person2.personGroupId,
});
const { asset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAssetFace({ assetId: asset.id, personGroupId: person2.personGroupId });
storageMock.unlink.mockResolvedValue();
const auth = factory.auth({ user: user1 });
await sut.mergePerson(auth, person1.personGroupId, { ids: [person2.personGroupId] });
const user1People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user1.id }));
const user2People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user2.id }));
expect(user1People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]);
expect(user2People).toEqual([expect.objectContaining({ personGroupId: person2.personGroupId })]);
await expect(ctx.get(PersonRepository).getFaces(asset.id, { viewingUserId: asset.ownerId })).resolves.toEqual([
expect.objectContaining({ personGroupId: person2.personGroupId }),
]);
});
});
describe('createFace', () => {
it('should store and retrieve the face as-is when there are no edits', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 200,
imageHeight: 200,
x: 50,
y: 50,
width: 150,
height: 150,
personId: person.personGroupId,
assetId: asset.id,
};
await sut.createFace(auth, dto);
// retrieve an asset's faces
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 50,
boundingBoxY1: 50,
boundingBoxX2: 200,
boundingBoxY2: 200,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Crop)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Crop,
parameters: {
x: 50,
y: 50,
width: 150,
height: 200,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 150,
imageHeight: 200,
x: 0,
y: 0,
width: 100,
height: 100,
personId: person.personGroupId,
assetId: asset.id,
};
await sut.createFace(auth, dto);
// retrieve an asset's faces
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 100,
boundingBoxY2: 100,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toHaveLength(1);
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 50,
boundingBoxY1: 50,
boundingBoxX2: 150,
boundingBoxY2: 150,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Rotate 90)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 200 });
await ctx.newExif({ assetId: asset.id, exifImageWidth: 200, exifImageHeight: 100 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Rotate,
parameters: {
angle: 90,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 100,
imageHeight: 200,
x: 25,
y: 50,
width: 10,
height: 10,
personId: person.personGroupId,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: expect.closeTo(25, 1),
boundingBoxY1: expect.closeTo(50, 1),
boundingBoxX2: expect.closeTo(35, 1),
boundingBoxY2: expect.closeTo(60, 1),
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 50,
boundingBoxY1: 65,
boundingBoxX2: 60,
boundingBoxY2: 75,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Mirror Horizontal)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 200,
imageHeight: 100,
x: 50,
y: 25,
width: 100,
height: 50,
personId: person.personGroupId,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 50,
boundingBoxY1: 25,
boundingBoxX2: 150,
boundingBoxY2: 75,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 50,
boundingBoxY1: 25,
boundingBoxX2: 150,
boundingBoxY2: 75,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Crop + Rotate)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Crop,
parameters: {
x: 50,
y: 0,
width: 150,
height: 200,
},
},
{
action: AssetEditAction.Rotate,
parameters: {
angle: 90,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 200,
imageHeight: 150,
x: 50,
y: 25,
width: 10,
height: 20,
personId: person.personGroupId,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: expect.closeTo(50, 1),
boundingBoxY1: expect.closeTo(25, 1),
boundingBoxX2: expect.closeTo(60, 1),
boundingBoxY2: expect.closeTo(45, 1),
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 75,
boundingBoxY1: 140,
boundingBoxX2: 95,
boundingBoxY2: 150,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Crop + Mirror)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Crop,
parameters: {
x: 50,
y: 0,
width: 150,
height: 100,
},
},
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 150,
imageHeight: 100,
x: 25,
y: 25,
width: 75,
height: 50,
personId: person.personGroupId,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 25,
boundingBoxY1: 25,
boundingBoxX2: 100,
boundingBoxY2: 75,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 100,
boundingBoxY1: 25,
boundingBoxX2: 175,
boundingBoxY2: 75,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Rotate + Mirror)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 150 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Rotate,
parameters: {
angle: 90,
},
},
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 200,
imageHeight: 150,
x: 50,
y: 25,
width: 15,
height: 20,
personId: person.personGroupId,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: expect.closeTo(50, 1),
boundingBoxY1: expect.closeTo(25, 1),
boundingBoxX2: expect.closeTo(65, 1),
boundingBoxY2: expect.closeTo(45, 1),
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 25,
boundingBoxY1: 50,
boundingBoxX2: 45,
boundingBoxY2: 65,
}),
]),
);
});
it('should properly transform the coordinates when the asset is edited (Crop + Rotate + Mirror)', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Crop,
parameters: {
x: 50,
y: 25,
width: 100,
height: 150,
},
},
{
action: AssetEditAction.Rotate,
parameters: {
angle: 270,
},
},
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 150,
imageHeight: 150,
x: 25,
y: 50,
width: 75,
height: 50,
personId: person.personGroupId,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 25,
boundingBoxY1: 49,
boundingBoxX2: 99,
boundingBoxY2: 100,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 50,
boundingBoxY1: 75,
boundingBoxX2: 100,
boundingBoxY2: 150,
}),
]),
);
});
it('should properly transform the coordinates with multiple mirrors in sequence', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 100 });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Vertical,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 100,
imageHeight: 100,
x: 10,
y: 10,
width: 80,
height: 80,
personId: person.personGroupId,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 10,
boundingBoxY1: 10,
boundingBoxX2: 90,
boundingBoxY2: 90,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 10,
boundingBoxY1: 10,
boundingBoxX2: 90,
boundingBoxY2: 90,
}),
]),
);
});
it('should properly handle exif orientation when creating a face on an edited asset', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 100, orientation: '6' });
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Vertical,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 100,
imageHeight: 100,
x: 10,
y: 10,
width: 80,
height: 80,
personId: person.personGroupId,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 110,
boundingBoxY1: 10,
boundingBoxX2: 190,
boundingBoxY2: 90,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.personGroupId }),
boundingBoxX1: 10,
boundingBoxY1: 10,
boundingBoxX2: 90,
boundingBoxY2: 90,
}),
]),
);
});
});
});