Compare commits

..

1 Commits

Author SHA1 Message Date
midzelis
6a38b2690e fix(web): preserve stacked asset selection when tagging faces
Change-Id: Iec1507560f99f2e9433bd5cf6b460b176a6a6964
2026-04-25 16:11:34 +00:00
20 changed files with 190 additions and 379 deletions

View File

@@ -183,10 +183,7 @@ async def predict(
text: str | None = Form(default=None),
) -> Any:
if image is not None:
decoded = await run(lambda: decode_pil(image))
if decoded.width == 0 or decoded.height == 0:
raise HTTPException(400, "Image has zero width or height")
inputs: Image | str = decoded
inputs: Image | str = await run(lambda: decode_pil(image))
elif text is not None:
inputs = text
else:

View File

@@ -1198,19 +1198,6 @@ class TestLoad:
mock_model.model_format = ModelFormat.ONNX
@pytest.mark.parametrize("size", [(0, 100), (100, 0), (0, 0)])
def test_predict_rejects_empty_image(size: tuple[int, int], deployed_app: TestClient) -> None:
with mock.patch("immich_ml.main.decode_pil", return_value=Image.new("RGB", size)):
response = deployed_app.post(
"http://localhost:3003/predict",
data={"entries": json.dumps({"clip": {"visual": {"modelName": "ViT-B-32__openai"}}})},
files={"image": b"fake image bytes"},
)
assert response.status_code == 400
assert "zero" in response.json()["detail"].lower()
def test_root_endpoint(deployed_app: TestClient) -> None:
response = deployed_app.get("http://localhost:3003")

View File

@@ -3,7 +3,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -192,22 +191,17 @@ class SyncStreamService {
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
await _runWithManageMediaPermission(
logContext: "Trashed Assets",
action: () async {
await _handleRemoteDeleted(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id));
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
await _applyRemoteRestoreToLocal();
},
);
} else {
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
}
}
return;
case SyncEntityType.assetDeleteV1:
await _runWithManageMediaPermission(
logContext: "Deleted Assets",
action: () async {
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
await _handleRemoteDeleted(remoteSyncAssets.map((e) => e.assetId));
},
);
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
@@ -388,32 +382,28 @@ class SyncStreamService {
}
}
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
if (localAssetsToTrash.isNotEmpty) {
await _trashLocalAssets(localAssetsToTrash);
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else {
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
}
}
}
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
}
Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
@@ -423,21 +413,4 @@ class SyncStreamService {
_logger.info("No remote assets found for restoration");
}
}
Future<void> _runWithManageMediaPermission({
required String logContext,
required Future<void> Function() action,
}) async {
if (!CurrentPlatform.isAndroid || !Store.get(StoreKey.manageLocalMediaAndroid, false)) {
return;
}
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (!hasPermission) {
_logger.warning("sync $logContext cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
await action();
}
}

View File

@@ -109,40 +109,31 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get();
}
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
if (checksums.isEmpty) {
return {};
}
final result = <String, List<LocalAsset>>{};
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
useColumns: false,
),
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
useColumns: false,
),
])..where(
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
_db.remoteAssetEntity.id.isIn(slice),
_db.localAssetEntity.checksum.isIn(slice),
))
.get();
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto();
final assetData = row.readTable(_db.localAssetEntity);
final asset = assetData.toDto();
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
}
return result;
}

View File

@@ -164,14 +164,6 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> emptyTrash() async {
await _db.remoteAssetEntity.deleteWhere((t) => t.deletedAt.isNotNull());
}
Future<void> restoreAllTrash() async {
await _db.remoteAssetEntity.update().write(const RemoteAssetEntityCompanion(deletedAt: Value(null)));
}
Future<void> delete(List<String> ids) {
return _db.batch((batch) {
for (final id in ids) {

View File

@@ -1,18 +1,13 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class DriftTrashPage extends StatelessWidget {
@@ -41,7 +36,6 @@ class DriftTrashPage extends StatelessWidget {
pinned: true,
centerTitle: true,
elevation: 0,
actions: [const _TrashKebabMenu()],
),
topSliverWidgetHeight: 24,
topSliverWidget: Consumer(
@@ -59,83 +53,3 @@ class DriftTrashPage extends StatelessWidget {
);
}
}
class _TrashKebabMenu extends ConsumerWidget {
const _TrashKebabMenu();
Future<void> _onEmptyTrash(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) =>
ConfirmDialog(title: context.t.empty_trash, content: context.t.empty_trash_confirmation, onOk: () {}),
);
if (confirmed == true && context.mounted) {
final result = await ref.read(actionProvider.notifier).emptyTrash();
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success
? context.t.assets_permanently_deleted_count(count: result.count)
: context.t.scaffold_body_error_occurred,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
}
Future<void> _onRestoreAll(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) =>
ConfirmDialog(title: context.t.restore_all, content: context.t.assets_restore_confirmation, onOk: () {}),
);
if (confirmed == true && context.mounted) {
final result = await ref.read(actionProvider.notifier).restoreAllTrash();
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success
? context.t.assets_restored_count(count: result.count)
: context.t.scaffold_body_error_occurred,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return MenuAnchor(
consumeOutsideTap: true,
style: MenuStyle(
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
elevation: const WidgetStatePropertyAll(4),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
),
menuChildren: [
BaseActionButton(
label: context.t.empty_trash,
iconData: Icons.delete_forever_outlined,
onPressed: () => _onEmptyTrash(context, ref),
menuItem: true,
),
BaseActionButton(
label: context.t.restore_all,
iconData: Icons.restore_outlined,
onPressed: () => _onRestoreAll(context, ref),
menuItem: true,
),
],
builder: (context, controller, child) {
return IconButton(
icon: const Icon(Icons.more_vert_rounded),
onPressed: () => controller.isOpen ? controller.close() : controller.open(),
);
},
);
}
}

View File

@@ -239,26 +239,6 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> emptyTrash() async {
try {
final count = await _service.emptyTrash();
return ActionResult(count: count, success: true);
} catch (error, stack) {
_logger.severe('Failed to empty trash', error, stack);
return ActionResult(count: 0, success: false, error: error.toString());
}
}
Future<ActionResult> restoreAllTrash() async {
try {
final count = await _service.restoreAllTrash();
return ActionResult(count: count, success: true);
} catch (error, stack) {
_logger.severe('Failed to restore all trash assets', error, stack);
return ActionResult(count: 0, success: false, error: error.toString());
}
}
Future<ActionResult> trashRemoteAndDeleteLocal(ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
final localIds = _getLocalIdsForSource(source);

View File

@@ -31,16 +31,6 @@ class AssetApiRepository extends ApiRepository {
await _trashApi.restoreAssets(BulkIdsDto(ids: ids));
}
Future<int> emptyTrash() async {
final response = await _trashApi.emptyTrash();
return response?.count ?? 0;
}
Future<int> restoreAllTrash() async {
final response = await _trashApi.restoreTrash();
return response?.count ?? 0;
}
Future<void> updateVisibility(List<String> ids, AssetVisibilityEnum visibility) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: _mapVisibility(visibility)));
}

View File

@@ -108,18 +108,6 @@ class ActionService {
await _remoteAssetRepository.restoreTrash(ids);
}
Future<int> emptyTrash() async {
final count = await _assetApiRepository.emptyTrash();
await _remoteAssetRepository.emptyTrash();
return count;
}
Future<int> restoreAllTrash() async {
final count = await _assetApiRepository.restoreAllTrash();
await _remoteAssetRepository.restoreAllTrash();
return count;
}
Future<void> trashRemoteAndDeleteLocal(List<String> remoteIds, List<String> localIds) async {
await _assetApiRepository.delete(remoteIds, false);
await _remoteAssetRepository.trash(remoteIds);

View File

@@ -419,8 +419,8 @@ void main() {
'album-b': [mergedAsset],
};
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
return assetsByAlbum;
});
@@ -482,18 +482,12 @@ void main() {
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
});
test("requests local deletions lookup by remote ids for permanent remote delete events", () async {
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
expect(requestedRemoteIds.toSet(), equals({'remote-asset'}));
return {};
});
test("does not request local deletions for permanent remote delete events", () async {
final events = [SyncStreamStub.assetDeleteV1];
await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
});

View File

@@ -9,6 +9,7 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
@@ -99,9 +100,10 @@
const stackSelectedThumbnailSize = 65;
let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null);
let stack: StackResponseDto | undefined = $state();
let selectedStackAsset: AssetResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? cursor.current);
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink();
@@ -114,17 +116,29 @@
playOriginalVideo = value;
};
const selectStackedAsset = async (id: string) => {
ocrManager.clear();
selectedStackAsset = await assetCacheManager.getAsset({ id });
if (!sharedLink) {
await ocrManager.getAssetOcr(id);
}
};
const refreshStack = async () => {
if (authManager.isSharedLink || !withStacked) {
return;
}
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
if (!cursor.current.stack) {
stack = undefined;
selectedStackAsset = undefined;
return;
}
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
stack = await getStack({ id: cursor.current.stack.id });
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
if (primaryAsset) {
await selectStackedAsset(primaryAsset.id);
}
};
@@ -182,11 +196,21 @@
onClose?.(asset);
};
const refreshPreservingSelection = async () => {
const id = asset.id;
assetCacheManager.invalidateAsset(id);
if (selectedStackAsset) {
await selectStackedAsset(id);
} else {
const refreshedAsset = await assetCacheManager.getAsset({ id });
assetViewerManager.setAsset(refreshedAsset);
}
onAssetChange?.(asset);
};
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
const refreshedAsset = await getAssetInfo({ id: asset.id });
onAssetChange?.(refreshedAsset);
assetViewerManager.setAsset(refreshedAsset);
await refreshPreservingSelection();
}
assetViewerManager.closeEditor();
};
@@ -285,10 +309,6 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
@@ -301,7 +321,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
stack = action.stack ?? undefined;
if (stack) {
cursor.current = stack.assets[0];
}
@@ -309,7 +329,7 @@
}
case AssetAction.STACK:
case AssetAction.SET_STACK_PRIMARY_ASSET: {
stack = action.stack;
stack = action.stack ?? undefined;
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
@@ -368,7 +388,7 @@
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
cursor.current;
untrack(() => handlePromiseError(refresh()));
});
@@ -533,7 +553,12 @@
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
<PhotoViewer
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
onTagFace={refreshPreservingSelection}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{asset}
@@ -585,7 +610,7 @@
translate="yes"
>
{#if showDetailPanel}
<DetailPanel {asset} currentAlbum={album} />
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
{:else if assetViewerManager.isShowEditor}
<EditorPanel {asset} onClose={closeEditor} />
{/if}
@@ -597,27 +622,24 @@
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
{@const isSelected = stackedAsset.id === (selectedStackAsset?.id ?? cursor.current.id)}
<div
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
style:bottom={isSelected ? '0' : '-10px'}
>
<Thumbnail
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
imageClass={{ 'border-2 border-white': isSelected }}
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id}
dimmed={!isSelected}
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
cursor.current = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
onClick={() => selectStackedAsset(stackedAsset.id)}
readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
thumbnailSize={isSelected ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
disableLinkMouseOver
/>
{#if stackedAsset.id === asset.id}
{#if isSelected}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
</div>

View File

@@ -10,19 +10,14 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { getParentPath } from '$lib/utils/tree-utils';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import {
mdiCamera,
@@ -48,13 +43,15 @@
interface Props {
asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null;
onRefreshPeople?: () => Promise<void>;
}
let { asset, currentAlbum = null }: Props = $props();
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
let people = $derived(asset.people || []);
let unassignedFaces = $derived(asset.unassignedFaces || []);
let showingHiddenPeople = $state(false);
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
@@ -107,11 +104,6 @@
return undefined;
};
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
assetViewerManager.closeEditFacesPanel();
};
const getAssetFolderHref = (asset: AssetResponseDto) => {
// Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) });
@@ -171,12 +163,12 @@
{#if people.some((person) => person.isHidden)}
<IconButton
aria-label={$t('show_hidden_people')}
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
size="medium"
shape="round"
color="secondary"
variant="ghost"
onclick={() => assetViewerManager.toggleHiddenPeople()}
onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
/>
{/if}
<IconButton
@@ -205,17 +197,15 @@
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person, index (person.id)}
{#if assetViewerManager.isShowingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) =>
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
)}
{#if showingHiddenPeople || !person.isHidden}
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
<a
class="group w-22 outline-none"
href={Route.viewPerson(person, { previousRoute })}
onfocus={() => assetViewerManager.setHighlightedFaces(people[index].faces)}
onblur={() => assetViewerManager.clearHighlightedFaces()}
onpointerenter={() => assetViewerManager.setHighlightedFaces(people[index].faces)}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
onmouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
<ImageThumbnail
@@ -501,6 +491,6 @@
assetId={asset.id}
assetType={asset.type}
onClose={() => assetViewerManager.closeEditFacesPanel()}
onRefresh={handleRefreshPeople}
onRefresh={() => void onRefreshPeople?.()}
/>
{/if}

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
import {
@@ -54,9 +55,14 @@
let viewer: Viewer;
let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = [];
$effect(() => {
const faces: Faces[] = assetViewerManager.highlightedFaces;
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
// Debounce; don't do anything when the data didn't actually change.
if (faces === previousFaces) {
return;
}
previousFaces = faces;
if (animationInProgress) {
animationInProgress.cancel();
@@ -99,7 +105,7 @@
textureX: x,
textureY: y,
zoom: Math.min(viewer.getZoomLevel(), 75),
speed: 500,
speed: 500, // duration in ms
});
}
});
@@ -241,8 +247,7 @@
if (viewer) {
viewer.destroy();
}
assetViewerManager.clearHighlightedFaces();
assetViewerManager.hideHiddenPeople();
boundingBoxesUnsubscribe();
assetViewerManager.zoom = 1;
});
</script>

View File

@@ -6,9 +6,10 @@
import Thumbhash from '$lib/components/Thumbhash.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/OcrBoundingBox.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
@@ -30,9 +31,10 @@
onReady?: () => void;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
onTagFace?: () => Promise<void>;
};
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
@@ -49,13 +51,12 @@
untrack(() => {
assetViewerManager.resetZoomState();
visibleImageReady = false;
assetViewerManager.clearHighlightedFaces();
$boundingBoxesArray = [];
});
});
onDestroy(() => {
assetViewerManager.clearHighlightedFaces();
assetViewerManager.hideHiddenPeople();
$boundingBoxesArray = [];
});
let containerWidth = $state(0);
@@ -74,13 +75,15 @@
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
});
const highlightedBoxes = $derived(getBoundingBox(assetViewerManager.highlightedFaces, overlaySize));
const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlaySize));
const isHighlighting = $derived(highlightedBoxes.length > 0);
let visibleBoxes = $state<BoundingBox[]>([]);
let visibleBoundingBoxes = $state<Faces[]>([]);
$effect(() => {
if (isHighlighting) {
visibleBoxes = highlightedBoxes;
visibleBoundingBoxes = $boundingBoxesArray;
}
});
@@ -158,9 +161,6 @@
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<Faces, string>();
for (const person of asset.people ?? []) {
if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
continue;
}
for (const face of person.faces ?? []) {
map.set(face, person.name);
}
@@ -170,31 +170,35 @@
const faces = $derived(Array.from(faceToNameMap.keys()));
const boundingBoxes = $derived.by(() => {
if (assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
return [];
const handleImageMouseMove = (event: MouseEvent) => {
$boundingBoxesArray = [];
if (!assetViewerManager.imgRef || !element || assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
return;
}
const knownBoxes = getBoundingBox(faces, overlaySize);
const result = knownBoxes.map((box, index) => ({
...box,
face: faces[index],
name: faceToNameMap.get(faces[index]),
}));
const natural = getNaturalSize(assetViewerManager.imgRef);
const scaled = scaleToFit(natural, container);
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
if (assetViewerManager.highlightedFaces.length === 0) {
return result;
const contentOffsetX = (container.width - scaled.width) / 2;
const contentOffsetY = (container.height - scaled.height) / 2;
const containerRect = element.getBoundingClientRect();
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
const faceBoxes = getBoundingBox(faces, overlaySize);
for (const [index, box] of faceBoxes.entries()) {
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
$boundingBoxesArray.push(faces[index]);
}
}
};
const knownIds = new Set(faces.map((f) => f.id));
const unassignedFaces = assetViewerManager.highlightedFaces.filter((f) => !knownIds.has(f.id));
const unassignedBoxes = getBoundingBox(unassignedFaces, overlaySize);
for (let i = 0; i < unassignedBoxes.length; i++) {
result.push({ ...unassignedBoxes[i], face: unassignedFaces[i], name: undefined });
}
return result;
});
const handleImageMouseLeave = () => {
$boundingBoxesArray = [];
};
</script>
<AssetViewerEvents {onCopy} {onZoom} {onFaceEditModeChange} />
@@ -215,6 +219,8 @@
bind:clientHeight={containerHeight}
role="presentation"
ondblclick={onZoom}
onmousemove={handleImageMouseMove}
onmouseleave={handleImageMouseLeave}
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
{...useSwipe((event) => onSwipe?.(event))}
>
@@ -256,27 +262,22 @@
</defs>
<rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" />
</svg>
</div>
{#each boundingBoxes as boundingbox (boundingbox.id)}
{@const isActive = assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute pointer-events-auto rounded-lg {isActive && 'border-solid border-white border-3'}"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
onpointerenter={() => assetViewerManager.setHighlightedFaces([boundingbox.face])}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
>
{#if isActive && boundingbox.name}
{#each visibleBoxes as boundingbox, index (boundingbox.id)}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{#if faceToNameMap.get(visibleBoundingBoxes[index])}
<div
aria-hidden="true"
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
style="top: {boundingbox.height + 4}px; right: 0;"
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
boundingbox.width}px; transform: translateX(-100%);"
>
{boundingbox.name}
{faceToNameMap.get(visibleBoundingBoxes[index])}
</div>
{/if}
</div>
{/each}
{/each}
</div>
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
@@ -285,6 +286,12 @@
</AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
<FaceEditor
htmlElement={assetViewerManager.imgRef}
{containerWidth}
{containerHeight}
assetId={asset.id}
{onTagFace}
/>
{/if}
</div>

View File

@@ -18,9 +18,10 @@
containerWidth: number;
containerHeight: number;
assetId: string;
onTagFace?: () => Promise<void>;
};
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state();
@@ -325,7 +326,7 @@
},
});
await assetViewerManager.setAssetId(assetId);
await onTagFace?.();
} catch (error) {
handleError(error, 'Error tagging face');
} finally {

View File

@@ -4,6 +4,7 @@
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
@@ -178,7 +179,10 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewerManager.setAssetId(assetId);
onRefresh();
if (peopleWithFaces.length === 0) {
onClose();
}
} catch (error) {
handleError(error, $t('error_delete_face'));
}
@@ -238,15 +242,15 @@
{:else}
{#each peopleWithFaces as face, index (face.id)}
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
{@const isHighlighted = assetViewerManager.highlightedFaces.some((b) => b.id === face.id)}
{@const isHighlighted = $boundingBoxesArray.some((b) => b.id === face.id)}
<div class="relative h-29 w-24">
<div
role="button"
tabindex={index}
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
onfocus={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
onpointerenter={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
onmouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
{#if selectedPersonToCreate[face.id]}

View File

@@ -8,16 +8,6 @@ import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { PersistedLocalStorage } from '$lib/utils/persisted';
export interface Faces {
id: string;
imageHeight: number;
imageWidth: number;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
}
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
const isShowAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false);
@@ -58,8 +48,6 @@ class AssetViewerManager extends BaseEventManager<Events> {
#isEditFacesPanelOpen = $state(false);
#viewingAssetStoreState = $state<AssetResponseDto>();
#viewState = $state<boolean>(false);
#highlightedFaces = $state<Faces[]>([]);
#showingHiddenPeople = $state(false);
gridScrollTarget = $state<AssetGridRouteSearchParams | null | undefined>();
get asset() {
@@ -221,31 +209,6 @@ class AssetViewerManager extends BaseEventManager<Events> {
this.closeFaceEditMode();
this.closeEditFacesPanel();
}
get highlightedFaces() {
return this.#highlightedFaces;
}
setHighlightedFaces(faces: Faces[]) {
this.#highlightedFaces = faces;
}
clearHighlightedFaces() {
this.#highlightedFaces = [];
}
get isShowingHiddenPeople() {
return this.#showingHiddenPeople;
}
toggleHiddenPeople() {
this.#showingHiddenPeople = !this.#showingHiddenPeople;
}
hideHiddenPeople() {
this.#showingHiddenPeople = false;
}
setAsset(asset: AssetResponseDto) {
this.#viewingAssetStoreState = asset;
this.#viewState = true;

View File

@@ -0,0 +1,13 @@
import { writable } from 'svelte/store';
export interface Faces {
id: string;
imageHeight: number;
imageWidth: number;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
}
export const boundingBoxesArray = writable<Faces[]>([]);

View File

@@ -1,4 +1,4 @@
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
import type { Faces } from '$lib/stores/people.store';
import type { Size } from '$lib/utils/container-utils';
import { getBoundingBox } from '$lib/utils/people-utils';

View File

@@ -1,5 +1,5 @@
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
import type { Faces } from '$lib/stores/people.store';
import { getAssetMediaUrl } from '$lib/utils';
import { mapNormalizedRectToContent, type Rect, type Size } from '$lib/utils/container-utils';