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}