mirror of https://github.com/immich-app/immich.git
feat(web): manual face tagging and deletion (#16062)
parent
94c0e8253a
commit
007eaaceb9
@ -0,0 +1,155 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AssetFaceCreateDto {
|
||||||
|
/// Returns a new [AssetFaceCreateDto] instance.
|
||||||
|
AssetFaceCreateDto({
|
||||||
|
required this.assetId,
|
||||||
|
required this.height,
|
||||||
|
required this.imageHeight,
|
||||||
|
required this.imageWidth,
|
||||||
|
required this.personId,
|
||||||
|
required this.width,
|
||||||
|
required this.x,
|
||||||
|
required this.y,
|
||||||
|
});
|
||||||
|
|
||||||
|
String assetId;
|
||||||
|
|
||||||
|
int height;
|
||||||
|
|
||||||
|
int imageHeight;
|
||||||
|
|
||||||
|
int imageWidth;
|
||||||
|
|
||||||
|
String personId;
|
||||||
|
|
||||||
|
int width;
|
||||||
|
|
||||||
|
int x;
|
||||||
|
|
||||||
|
int y;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AssetFaceCreateDto &&
|
||||||
|
other.assetId == assetId &&
|
||||||
|
other.height == height &&
|
||||||
|
other.imageHeight == imageHeight &&
|
||||||
|
other.imageWidth == imageWidth &&
|
||||||
|
other.personId == personId &&
|
||||||
|
other.width == width &&
|
||||||
|
other.x == x &&
|
||||||
|
other.y == y;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(assetId.hashCode) +
|
||||||
|
(height.hashCode) +
|
||||||
|
(imageHeight.hashCode) +
|
||||||
|
(imageWidth.hashCode) +
|
||||||
|
(personId.hashCode) +
|
||||||
|
(width.hashCode) +
|
||||||
|
(x.hashCode) +
|
||||||
|
(y.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AssetFaceCreateDto[assetId=$assetId, height=$height, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, width=$width, x=$x, y=$y]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'assetId'] = this.assetId;
|
||||||
|
json[r'height'] = this.height;
|
||||||
|
json[r'imageHeight'] = this.imageHeight;
|
||||||
|
json[r'imageWidth'] = this.imageWidth;
|
||||||
|
json[r'personId'] = this.personId;
|
||||||
|
json[r'width'] = this.width;
|
||||||
|
json[r'x'] = this.x;
|
||||||
|
json[r'y'] = this.y;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AssetFaceCreateDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AssetFaceCreateDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AssetFaceCreateDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AssetFaceCreateDto(
|
||||||
|
assetId: mapValueOfType<String>(json, r'assetId')!,
|
||||||
|
height: mapValueOfType<int>(json, r'height')!,
|
||||||
|
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
|
||||||
|
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
|
||||||
|
personId: mapValueOfType<String>(json, r'personId')!,
|
||||||
|
width: mapValueOfType<int>(json, r'width')!,
|
||||||
|
x: mapValueOfType<int>(json, r'x')!,
|
||||||
|
y: mapValueOfType<int>(json, r'y')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AssetFaceCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AssetFaceCreateDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AssetFaceCreateDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AssetFaceCreateDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AssetFaceCreateDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AssetFaceCreateDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AssetFaceCreateDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AssetFaceCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AssetFaceCreateDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AssetFaceCreateDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'assetId',
|
||||||
|
'height',
|
||||||
|
'imageHeight',
|
||||||
|
'imageWidth',
|
||||||
|
'personId',
|
||||||
|
'width',
|
||||||
|
'x',
|
||||||
|
'y',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class AssetFaceDeleteDto {
|
||||||
|
/// Returns a new [AssetFaceDeleteDto] instance.
|
||||||
|
AssetFaceDeleteDto({
|
||||||
|
required this.force,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool force;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is AssetFaceDeleteDto &&
|
||||||
|
other.force == force;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(force.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'AssetFaceDeleteDto[force=$force]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'force'] = this.force;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [AssetFaceDeleteDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static AssetFaceDeleteDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "AssetFaceDeleteDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return AssetFaceDeleteDto(
|
||||||
|
force: mapValueOfType<bool>(json, r'force')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<AssetFaceDeleteDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <AssetFaceDeleteDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = AssetFaceDeleteDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, AssetFaceDeleteDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, AssetFaceDeleteDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = AssetFaceDeleteDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of AssetFaceDeleteDto-objects as value to a dart map
|
||||||
|
static Map<String, List<AssetFaceDeleteDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<AssetFaceDeleteDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = AssetFaceDeleteDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'force',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddDeletedAtColumnToAssetFacesTable1739466714036 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE asset_faces
|
||||||
|
ADD COLUMN "deletedAt" TIMESTAMP WITH TIME ZONE DEFAULT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE asset_faces
|
||||||
|
DROP COLUMN "deletedAt"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,310 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||||
|
import { notificationController } from '$lib/components/shared-components/notification/notification';
|
||||||
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
|
import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk';
|
||||||
|
import { Button } from '@immich/ui';
|
||||||
|
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
imgElement: HTMLImageElement;
|
||||||
|
containerWidth: number;
|
||||||
|
containerHeight: number;
|
||||||
|
assetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { imgElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||||
|
|
||||||
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||||
|
let canvas: Canvas | undefined = $state();
|
||||||
|
let faceRect: Rect | undefined = $state();
|
||||||
|
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
const configureControlStyle = () => {
|
||||||
|
InteractiveFabricObject.ownDefaults = {
|
||||||
|
...InteractiveFabricObject.ownDefaults,
|
||||||
|
cornerStyle: 'circle',
|
||||||
|
cornerColor: 'rgb(153,166,251)',
|
||||||
|
cornerSize: 10,
|
||||||
|
padding: 8,
|
||||||
|
transparentCorners: false,
|
||||||
|
lockRotation: true,
|
||||||
|
hasBorders: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupCanvas = () => {
|
||||||
|
if (!canvasEl || !imgElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas = new Canvas(canvasEl);
|
||||||
|
configureControlStyle();
|
||||||
|
|
||||||
|
faceRect = new Rect({
|
||||||
|
fill: 'rgba(66,80,175,0.25)',
|
||||||
|
stroke: 'rgb(66,80,175)',
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeUniform: true,
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
objectCaching: true,
|
||||||
|
rx: 8,
|
||||||
|
ry: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.add(faceRect);
|
||||||
|
canvas.setActiveObject(faceRect);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
setupCanvas();
|
||||||
|
await getPeople();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { actualWidth, actualHeight } = getContainedSize(imgElement);
|
||||||
|
const offsetArea = {
|
||||||
|
width: (containerWidth - actualWidth) / 2,
|
||||||
|
height: (containerHeight - actualHeight) / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageBoundingBox = {
|
||||||
|
top: offsetArea.height,
|
||||||
|
left: offsetArea.width,
|
||||||
|
width: containerWidth - offsetArea.width * 2,
|
||||||
|
height: containerHeight - offsetArea.height * 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.setDimensions({
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!faceRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
faceRect.set({
|
||||||
|
top: imageBoundingBox.top + 200,
|
||||||
|
left: imageBoundingBox.left + 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
faceRect.setCoords();
|
||||||
|
positionFaceSelector();
|
||||||
|
});
|
||||||
|
|
||||||
|
const getContainedSize = (img: HTMLImageElement): { actualWidth: number; actualHeight: number } => {
|
||||||
|
const ratio = img.naturalWidth / img.naturalHeight;
|
||||||
|
let actualWidth = img.height * ratio;
|
||||||
|
let actualHeight = img.height;
|
||||||
|
if (actualWidth > img.width) {
|
||||||
|
actualWidth = img.width;
|
||||||
|
actualHeight = img.width / ratio;
|
||||||
|
}
|
||||||
|
return { actualWidth, actualHeight };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
isFaceEditMode.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let page = $state(1);
|
||||||
|
let candidates = $state<PersonResponseDto[]>([]);
|
||||||
|
|
||||||
|
const getPeople = async () => {
|
||||||
|
const { hasNextPage, people, total } = await getAllPeople({ page, size: 250, withHidden: false });
|
||||||
|
|
||||||
|
if (candidates.length === total) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates = [...candidates, ...people];
|
||||||
|
|
||||||
|
if (hasNextPage) {
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionFaceSelector = () => {
|
||||||
|
if (!faceRect || !faceSelectorEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = faceRect.getBoundingRect();
|
||||||
|
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||||
|
const selectorHeight = faceSelectorEl.offsetHeight;
|
||||||
|
|
||||||
|
const spaceAbove = rect.top;
|
||||||
|
const spaceBelow = containerHeight - (rect.top + rect.height);
|
||||||
|
const spaceLeft = rect.left;
|
||||||
|
const spaceRight = containerWidth - (rect.left + rect.width);
|
||||||
|
|
||||||
|
let top, left;
|
||||||
|
|
||||||
|
if (
|
||||||
|
spaceBelow >= selectorHeight ||
|
||||||
|
(spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight)
|
||||||
|
) {
|
||||||
|
top = rect.top + rect.height + 15;
|
||||||
|
left = rect.left;
|
||||||
|
} else if (
|
||||||
|
spaceAbove >= selectorHeight ||
|
||||||
|
(spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight)
|
||||||
|
) {
|
||||||
|
top = rect.top - selectorHeight - 15;
|
||||||
|
left = rect.left;
|
||||||
|
} else if (
|
||||||
|
spaceRight >= selectorWidth ||
|
||||||
|
(spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow)
|
||||||
|
) {
|
||||||
|
top = rect.top;
|
||||||
|
left = rect.left + rect.width + 15;
|
||||||
|
} else {
|
||||||
|
top = rect.top;
|
||||||
|
left = rect.left - selectorWidth - 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left + selectorWidth > containerWidth) {
|
||||||
|
left = containerWidth - selectorWidth - 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left < 0) {
|
||||||
|
left = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top + selectorHeight > containerHeight) {
|
||||||
|
top = containerHeight - selectorHeight - 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top < 0) {
|
||||||
|
top = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
faceSelectorEl.style.top = `${top}px`;
|
||||||
|
faceSelectorEl.style.left = `${left}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (faceRect) {
|
||||||
|
faceRect.on('moving', positionFaceSelector);
|
||||||
|
faceRect.on('scaling', positionFaceSelector);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getFaceCroppedCoordinates = () => {
|
||||||
|
if (!faceRect || !imgElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||||
|
const { actualWidth, actualHeight } = getContainedSize(imgElement);
|
||||||
|
|
||||||
|
const offsetArea = {
|
||||||
|
width: (containerWidth - actualWidth) / 2,
|
||||||
|
height: (containerHeight - actualHeight) / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const x1Coeff = (left - offsetArea.width) / actualWidth;
|
||||||
|
const y1Coeff = (top - offsetArea.height) / actualHeight;
|
||||||
|
const x2Coeff = (left + width - offsetArea.width) / actualWidth;
|
||||||
|
const y2Coeff = (top + height - offsetArea.height) / actualHeight;
|
||||||
|
|
||||||
|
// transpose to the natural image location
|
||||||
|
const x1 = x1Coeff * imgElement.naturalWidth;
|
||||||
|
const y1 = y1Coeff * imgElement.naturalHeight;
|
||||||
|
const x2 = x2Coeff * imgElement.naturalWidth;
|
||||||
|
const y2 = y2Coeff * imgElement.naturalHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageWidth: imgElement.naturalWidth,
|
||||||
|
imageHeight: imgElement.naturalHeight,
|
||||||
|
x: Math.floor(x1),
|
||||||
|
y: Math.floor(y1),
|
||||||
|
width: Math.floor(x2 - x1),
|
||||||
|
height: Math.floor(y2 - y1),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const tagFace = async (person: PersonResponseDto) => {
|
||||||
|
try {
|
||||||
|
const data = getFaceCroppedCoordinates();
|
||||||
|
if (!data) {
|
||||||
|
notificationController.show({
|
||||||
|
message: 'Error tagging face - cannot get bounding box coordinates',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isConfirmed = await dialogController.show({
|
||||||
|
prompt: `Do you want to tag this face as ${person.name}?`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isConfirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createFace({
|
||||||
|
assetFaceCreateDto: {
|
||||||
|
assetId,
|
||||||
|
personId: person.id,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assetViewingStore.setAssetId(assetId);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Error tagging face');
|
||||||
|
} finally {
|
||||||
|
isFaceEditMode.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="absolute left-0 top-0">
|
||||||
|
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 left-0"></canvas>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="face-selector"
|
||||||
|
bind:this={faceSelectorEl}
|
||||||
|
class="absolute top-[calc(50%-250px)] left-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200"
|
||||||
|
>
|
||||||
|
<p class="text-center text-sm">Select a person to tag</p>
|
||||||
|
|
||||||
|
<div class="max-h-[250px] overflow-y-auto mt-2">
|
||||||
|
<div class="mt-2 rounded-lg">
|
||||||
|
{#each candidates as person}
|
||||||
|
<button
|
||||||
|
onclick={() => tagFace(person)}
|
||||||
|
type="button"
|
||||||
|
class="w-full flex place-items-center gap-2 rounded-lg pl-1 pr-4 py-2 hover:bg-immich-primary/25"
|
||||||
|
>
|
||||||
|
<ImageThumbnail
|
||||||
|
curve
|
||||||
|
shadow
|
||||||
|
url={getPeopleThumbnailUrl(person)}
|
||||||
|
altText={person.name}
|
||||||
|
title={person.name}
|
||||||
|
widthStyle="30px"
|
||||||
|
heightStyle="30px"
|
||||||
|
/>
|
||||||
|
<p class="text-sm">
|
||||||
|
{person.name}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export const isFaceEditMode = $state({ value: false });
|
||||||
Loading…
Reference in New Issue