diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 028331d0c0..090eebdf85 100644 Binary files a/mobile/openapi/lib/model/asset_response_dto.dart and b/mobile/openapi/lib/model/asset_response_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index eeef262ea0..94feb6cfcf 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7725,6 +7725,12 @@ "type": { "$ref": "#/components/schemas/AssetTypeEnum" }, + "unassignedFaces": { + "items": { + "$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto" + }, + "type": "array" + }, "updatedAt": { "format": "date-time", "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c835ff1902..c604de9f8a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -194,6 +194,7 @@ export type AssetResponseDto = { tags?: TagResponseDto[]; thumbhash: string | null; "type": AssetTypeEnum; + unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; updatedAt: string; }; export type AlbumResponseDto = { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 3d6cd4cad5..755ae8e1d7 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -2,7 +2,12 @@ import { ApiProperty } from '@nestjs/swagger'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto'; -import { PersonWithFacesResponseDto, mapFacesWithoutPerson, mapPerson } from 'src/dtos/person.dto'; +import { + AssetFaceWithoutPersonResponseDto, + PersonWithFacesResponseDto, + mapFacesWithoutPerson, + mapPerson, +} from 'src/dtos/person.dto'; import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; @@ -41,6 +46,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { smartInfo?: SmartInfoResponseDto; tags?: TagResponseDto[]; people?: PersonWithFacesResponseDto[]; + unassignedFaces?: AssetFaceWithoutPersonResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; stackParentId?: string | null; @@ -116,6 +122,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map(mapTag), people: peopleWithFaces(entity.faces), + unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), checksum: entity.checksum.toString('base64'), stackParentId: withStack ? entity.stack?.primaryAssetId : undefined, stack: withStack diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 60ed02859b..89509fd712 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -27,6 +27,7 @@ mdiImageOutline, mdiInformationOutline, mdiPencil, + mdiAccountOff, } from '@mdi/js'; import { DateTime } from 'luxon'; import { createEventDispatcher, onMount } from 'svelte'; @@ -76,6 +77,7 @@ if (newAsset.id && !isSharedLink()) { const data = await getAssetInfo({ id: asset.id }); people = data?.people || []; + unassignedFaces = data?.unassignedFaces || []; } }; @@ -93,6 +95,8 @@ $: people = asset.people || []; $: showingHiddenPeople = false; + $: unassignedFaces = asset.unassignedFaces || []; + onMount(() => { return websocketEvents.on('on_asset_update', (assetUpdate) => { if (assetUpdate.id === asset.id) { @@ -118,6 +122,7 @@ const handleRefreshPeople = async () => { await getAssetInfo({ id: asset.id }).then((data) => { people = data?.people || []; + unassignedFaces = data?.unassignedFaces || []; }); showEditFaces = false; }; @@ -158,11 +163,20 @@ - {#if !isSharedLink() && people.length > 0} + {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0} {$t('people').toUpperCase()} + {#if unassignedFaces.length > 0} + + {/if} {#if people.some((person) => person.isHidden)} import { timeBeforeShowLoadingSpinner } from '$lib/constants'; - import { photoViewer } from '$lib/stores/assets.store'; - import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils'; import { getPersonNameWithHiddenValue } from '$lib/utils/person'; + import { getPeopleThumbnailUrl } from '$lib/utils'; import { AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto } from '@immich/sdk'; import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; import { linear } from 'svelte/easing'; import { fly } from 'svelte/transition'; + import { photoViewer } from '$lib/stores/assets.store'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import SearchPeople from '$lib/components/faces-page/people-search.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; + import { zoomImageToBase64 } from '$lib/utils/people-utils'; import { t } from 'svelte-i18n'; - export let peopleWithFaces: AssetFaceResponseDto[]; export let allPeople: PersonResponseDto[]; - export let editedPerson: PersonResponseDto; - export let assetType: AssetTypeEnum; + export let editedFace: AssetFaceResponseDto; export let assetId: string; + export let assetType: AssetTypeEnum; // loading spinners let isShowLoadingNewPerson = false; @@ -39,71 +39,11 @@ const handleBackButton = () => { dispatch('close'); }; - const zoomImageToBase64 = async (face: AssetFaceResponseDto): Promise => { - let image: HTMLImageElement | null = null; - if (assetType === AssetTypeEnum.Image) { - image = $photoViewer; - } else if (assetType === AssetTypeEnum.Video) { - const data = getAssetThumbnailUrl(assetId); - const img: HTMLImageElement = new Image(); - img.src = data; - - await new Promise((resolve) => { - img.addEventListener('load', () => resolve()); - img.addEventListener('error', () => resolve()); - }); - - image = img; - } - if (image === null) { - return null; - } - const { - boundingBoxX1: x1, - boundingBoxX2: x2, - boundingBoxY1: y1, - boundingBoxY2: y2, - imageWidth, - imageHeight, - } = face; - - const coordinates = { - x1: (image.naturalWidth / imageWidth) * x1, - x2: (image.naturalWidth / imageWidth) * x2, - y1: (image.naturalHeight / imageHeight) * y1, - y2: (image.naturalHeight / imageHeight) * y2, - }; - - const faceWidth = coordinates.x2 - coordinates.x1; - const faceHeight = coordinates.y2 - coordinates.y1; - - const faceImage = new Image(); - faceImage.src = image.src; - - await new Promise((resolve) => { - faceImage.addEventListener('load', resolve); - faceImage.addEventListener('error', () => resolve(null)); - }); - - const canvas = document.createElement('canvas'); - canvas.width = faceWidth; - canvas.height = faceHeight; - - const context = canvas.getContext('2d'); - if (context) { - context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight); - - return canvas.toDataURL(); - } else { - return null; - } - }; const handleCreatePerson = async () => { const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner); - const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id); - const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null; + const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewer); dispatch('createPerson', newFeaturePhoto); @@ -161,7 +101,7 @@ {$t('all_people')} {#each showPeople as person (person.id)} - {#if person.id !== editedPerson.id} + {#if !editedFace.person || person.id !== editedFace.person.id} dispatch('reassign', person)}> diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 1365f70c15..f8f9b2bc3d 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -7,14 +7,16 @@ import { handleError } from '$lib/utils/handle-error'; import { getPersonNameWithHiddenValue } from '$lib/utils/person'; import { - AssetTypeEnum, createPerson, getAllPeople, getFaces, reassignFacesById, + AssetTypeEnum, type AssetFaceResponseDto, type PersonResponseDto, } from '@immich/sdk'; + import { mdiAccountOff } from '@mdi/js'; + import Icon from '$lib/components/elements/icon.svelte'; import { mdiArrowLeftThin, mdiMinus, mdiRestart } from '@mdi/js'; import { createEventDispatcher, onMount } from 'svelte'; import { linear } from 'svelte/easing'; @@ -23,6 +25,8 @@ import { NotificationType, notificationController } from '../shared-components/notification/notification'; import AssignFaceSidePanel from './assign-face-side-panel.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; + import { zoomImageToBase64 } from '$lib/utils/people-utils'; + import { photoViewer } from '$lib/stores/assets.store'; import { t } from 'svelte-i18n'; export let assetId: string; @@ -36,7 +40,6 @@ let peopleWithFaces: AssetFaceResponseDto[] = []; let selectedPersonToReassign: Record = {}; let selectedPersonToCreate: Record = {}; - let editedPerson: PersonResponseDto; let editedFace: AssetFaceResponseDto; // loading spinners @@ -171,11 +174,8 @@ }; const handleFacePicker = (face: AssetFaceResponseDto) => { - if (face.person) { - editedFace = face; - editedPerson = face.person; - showSelectedFaces = true; - } + editedFace = face; + showSelectedFaces = true; }; @@ -209,91 +209,125 @@ {:else} {#each peopleWithFaces as face, index} - {#if face.person} - - ($boundingBoxesArray = [peopleWithFaces[index]])} - on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} - on:mouseleave={() => ($boundingBoxesArray = [])} - > - - {#if selectedPersonToCreate[face.id]} + {@const personName = face.person ? face.person?.name : 'Unassigned'} + + ($boundingBoxesArray = [peopleWithFaces[index]])} + on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} + on:mouseleave={() => ($boundingBoxesArray = [])} + > + + {#if selectedPersonToCreate[face.id]} + + {:else if selectedPersonToReassign[face.id]} + + {:else if face.person} + + {:else} + {#await zoomImageToBase64(face, assetId, assetType, $photoViewer)} - {:else if selectedPersonToReassign[face.id]} + {:then data} - {:else} - - {/if} - - - {#if !selectedPersonToCreate[face.id]} - - {#if selectedPersonToReassign[face.id]?.id} - {selectedPersonToReassign[face.id]?.name} - {:else} - {face.person?.name} - {/if} - + {/await} {/if} + - - {#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]} - handleReset(face.id)} - /> + {#if !selectedPersonToCreate[face.id]} + + {#if selectedPersonToReassign[face.id]?.id} + {selectedPersonToReassign[face.id]?.name} {:else} - handleFacePicker(face)} - /> + {personName} {/if} - + + {/if} + + + {#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]} + handleReset(face.id)} + /> + {:else} + handleFacePicker(face)} + /> + {/if} + + + {#if !selectedPersonToCreate[face.id] && !selectedPersonToReassign[face.id] && !face.person} + + + + {/if} - {/if} + {/each} {/if} @@ -302,11 +336,10 @@ {#if showSelectedFaces} (showSelectedFaces = false)} on:createPerson={(event) => handleCreatePerson(event.detail)} on:reassign={(event) => handleReassignFace(event.detail)} diff --git a/web/src/lib/utils/people-utils.ts b/web/src/lib/utils/people-utils.ts index 1d630c8c32..5fb03842b8 100644 --- a/web/src/lib/utils/people-utils.ts +++ b/web/src/lib/utils/people-utils.ts @@ -1,4 +1,6 @@ import type { Faces } from '$lib/stores/people.store'; +import { getAssetThumbnailUrl } from '$lib/utils'; +import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk'; import type { ZoomImageWheelState } from '@zoom-image/core'; const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => { @@ -69,3 +71,61 @@ export const getBoundingBox = ( } return boxes; }; + +export const zoomImageToBase64 = async ( + face: AssetFaceResponseDto, + assetId: string, + assetType: AssetTypeEnum, + photoViewer: HTMLImageElement | null, +): Promise => { + let image: HTMLImageElement | null = null; + if (assetType === AssetTypeEnum.Image) { + image = photoViewer; + } else if (assetType === AssetTypeEnum.Video) { + const data = getAssetThumbnailUrl(assetId); + const img: HTMLImageElement = new Image(); + img.src = data; + + await new Promise((resolve) => { + img.addEventListener('load', () => resolve()); + img.addEventListener('error', () => resolve()); + }); + + image = img; + } + if (image === null) { + return null; + } + const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face; + + const coordinates = { + x1: (image.naturalWidth / imageWidth) * x1, + x2: (image.naturalWidth / imageWidth) * x2, + y1: (image.naturalHeight / imageHeight) * y1, + y2: (image.naturalHeight / imageHeight) * y2, + }; + + const faceWidth = coordinates.x2 - coordinates.x1; + const faceHeight = coordinates.y2 - coordinates.y1; + + const faceImage = new Image(); + faceImage.src = image.src; + + await new Promise((resolve) => { + faceImage.addEventListener('load', resolve); + faceImage.addEventListener('error', () => resolve(null)); + }); + + const canvas = document.createElement('canvas'); + canvas.width = faceWidth; + canvas.height = faceHeight; + + const context = canvas.getContext('2d'); + if (context) { + context.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight); + + return canvas.toDataURL(); + } else { + return null; + } +};
- {#if selectedPersonToReassign[face.id]?.id} - {selectedPersonToReassign[face.id]?.name} - {:else} - {face.person?.name} - {/if} -
+ {#if selectedPersonToReassign[face.id]?.id} + {selectedPersonToReassign[face.id]?.name} {:else} - handleFacePicker(face)} - /> + {personName} {/if} -