Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -849,9 +849,12 @@
"create_link_to_share": "Create link to share",
"create_link_to_share_description": "Let anyone with the link see the selected photo(s)",
"create_new": "CREATE NEW",
"create_new_face": "Create new face",
"create_new_person": "Create new person",
"create_new_person_hint": "Assign selected assets to a new person",
"create_new_user": "Create new user",
"create_person": "Create person",
"create_person_subtitle": "Add a name to the selected face to create and tag the new person",
"create_shared_album_page_share_add_assets": "ADD ASSETS",
"create_shared_album_page_share_select_photos": "Select Photos",
"create_shared_link": "Create shared link",
Expand Down Expand Up @@ -2214,6 +2217,7 @@
"tag": "Tag",
"tag_assets": "Tag assets",
"tag_created": "Created tag: {tag}",
"tag_face": "Tag face",
"tag_feature_description": "Browsing photos and videos grouped by logical tag topics",
"tag_not_found_question": "Cannot find a tag? <link>Create a new tag.</link>",
"tag_people": "Tag People",
Expand Down
88 changes: 87 additions & 1 deletion server/src/services/person.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ import { PersonFactory } from 'test/factories/person.factory';
import { UserFactory } from 'test/factories/user.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { getAsDetectedFace, getForAssetFace, getForDetectedFaces, getForFacialRecognitionJob } from 'test/mappers';
import {
getAsDetectedFace,
getForAsset,
getForAssetFace,
getForDetectedFaces,
getForFacialRecognitionJob,
} from 'test/mappers';
import { newDate, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';

Expand Down Expand Up @@ -370,6 +376,86 @@ describe(PersonService.name, () => {
});
});

describe('createFace', () => {
it('should create a manual face and initialize the person feature photo creation', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: null });
const featureFace = AssetFaceFactory.create({
assetId: asset.id,
personId: person.id,
sourceType: SourceType.Manual,
});

mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
mocks.person.getById.mockResolvedValue(person);
mocks.person.getRandomFace.mockResolvedValue(featureFace);
mocks.person.update.mockResolvedValue({ ...person, faceAssetId: featureFace.id });

await expect(
sut.createFace(auth, {
assetId: asset.id,
personId: person.id,
imageHeight: 500,
imageWidth: 400,
x: 10,
y: 20,
width: 100,
height: 110,
}),
).resolves.toBeUndefined();

expect(mocks.asset.getById).toHaveBeenCalledWith(asset.id, { edits: true, exifInfo: true });
expect(mocks.person.createAssetFace).toHaveBeenCalledWith({
assetId: asset.id,
personId: person.id,
imageHeight: 500,
imageWidth: 400,
boundingBoxX1: 10,
boundingBoxX2: 110,
boundingBoxY1: 20,
boundingBoxY2: 130,
sourceType: SourceType.Manual,
});
expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.id);
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: featureFace.id });
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.PersonGenerateThumbnail, data: { id: person.id } },
]);
});

it('should not update the person feature photo if one already exists', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: newUuid() });

mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
mocks.person.getById.mockResolvedValue(person);

await expect(
sut.createFace(auth, {
assetId: asset.id,
personId: person.id,
imageHeight: 500,
imageWidth: 400,
x: 10,
y: 20,
width: 100,
height: 110,
}),
).resolves.toBeUndefined();

expect(mocks.person.createAssetFace).toHaveBeenCalledOnce();
expect(mocks.person.getRandomFace).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
});

describe('createNewFeaturePhoto', () => {
it('should change person feature photo', async () => {
const person = PersonFactory.create();
Expand Down
10 changes: 9 additions & 1 deletion server/src/services/person.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,11 @@ export class PersonService extends BaseService {
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
]);

const asset = await this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true });
const [asset, person] = await Promise.all([
this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }),
this.findOrFail(dto.personId),
]);

if (!asset) {
throw new NotFoundException('Asset not found');
}
Expand Down Expand Up @@ -689,6 +693,10 @@ export class PersonService extends BaseService {
boundingBoxY2: Math.round(bottomRight.y),
sourceType: SourceType.Manual,
});

if (!person.faceAssetId) {
await this.createNewFeaturePhoto([person.id]);
}
}

async deleteFace(auth: AuthDto, id: string, dto: AssetFaceDeleteDto): Promise<void> {
Expand Down
13 changes: 12 additions & 1 deletion server/test/medium/specs/services/person.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AccessRepository } from 'src/repositories/access.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetRepository } from 'src/repositories/asset.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';
Expand All @@ -20,7 +21,7 @@ const setup = (db?: Kysely<DB>) => {
return newMediumService(PersonService, {
database: db || defaultDatabase,
real: [AccessRepository, DatabaseRepository, PersonRepository, AssetRepository, AssetEditRepository],
mock: [LoggingRepository, StorageRepository],
mock: [JobRepository, LoggingRepository, StorageRepository],
});
};

Expand Down Expand Up @@ -89,6 +90,7 @@ describe(PersonService.name, () => {
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 });

Expand Down Expand Up @@ -128,6 +130,7 @@ describe(PersonService.name, () => {
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: [
Expand Down Expand Up @@ -199,6 +202,7 @@ describe(PersonService.name, () => {
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: [
Expand Down Expand Up @@ -263,6 +267,7 @@ describe(PersonService.name, () => {
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: [
Expand Down Expand Up @@ -327,6 +332,7 @@ describe(PersonService.name, () => {
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: [
Expand Down Expand Up @@ -400,6 +406,7 @@ describe(PersonService.name, () => {
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: [
Expand Down Expand Up @@ -473,6 +480,7 @@ describe(PersonService.name, () => {
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: [
Expand Down Expand Up @@ -543,6 +551,7 @@ describe(PersonService.name, () => {
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: [
Expand Down Expand Up @@ -622,6 +631,7 @@ describe(PersonService.name, () => {
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: [
Expand Down Expand Up @@ -692,6 +702,7 @@ describe(PersonService.name, () => {
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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { shortcut } from '$lib/actions/shortcut';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
Expand Down Expand Up @@ -260,6 +261,44 @@
};
};

type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;

const getFacePreviewUrl = (data: FaceCoordinates) => {
if (!htmlElement) {
return;
}

const natural = getNaturalSize(htmlElement);
if (natural.width <= 0 || natural.height <= 0) {
return;
}

const x = clamp(data.x, 0, natural.width - 1);
const y = clamp(data.y, 0, natural.height - 1);
const width = clamp(data.width, 1, natural.width - x);
const height = clamp(data.height, 1, natural.height - y);

if (width <= 0 || height <= 0) {
return;
}

const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;

const context = canvas.getContext('2d');
if (!context) {
return;
}

try {
context.drawImage(htmlElement, x, y, width, height, 0, 0, width, height);
return canvas.toDataURL('image/png');
} catch {
return;
}
};

const tagFace = async (person: PersonResponseDto) => {
try {
const data = getFaceCroppedCoordinates();
Expand Down Expand Up @@ -294,6 +333,28 @@
}
};

const showCreateFaceModal = async () => {
try {
const data = getFaceCroppedCoordinates();
if (!data) {
return;
}

const created = await modalManager.show(FaceCreateTagModal, {
assetId,
...data,
previewUrl: getFacePreviewUrl(data),
});
if (!created) {
return;
}

onClose();
} catch (error) {
handleError(error, 'Error creating and tagging face');
}
};

onDestroy(() => {
onClose();
});
Expand All @@ -303,19 +364,19 @@

<div
id="face-editor-data"
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
class="absolute inset-s-0 top-0 z-5 h-full w-full overflow-hidden"
data-overlay-interactive
data-face-left={faceBoxPosition.left}
data-face-top={faceBoxPosition.top}
data-face-width={faceBoxPosition.width}
data-face-height={faceBoxPosition.height}
>
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 inset-s-0"></canvas>

<div
id="face-selector"
bind:this={faceSelectorEl}
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
class="absolute top-[calc(50%-250px)] inset-s-[calc(50%-125px)] max-w-62.5 w-62.5 bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
>
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>

Expand Down Expand Up @@ -354,6 +415,12 @@
{/if}
</div>

<Button size="small" fullWidth onclick={onClose} color="danger" class="mt-2">{$t('cancel')}</Button>
<Button size="small" fullWidth onclick={showCreateFaceModal} variant="outline" class="mt-2">
{$t('create_person')}
</Button>

<Button size="small" fullWidth onclick={onClose} color="danger" class="mt-2">
{$t('cancel')}
</Button>
</div>
</div>
Loading
Loading