fix(web): preserve stacked asset selection when tagging faces

This commit is contained in:
midzelis
2026-03-07 21:14:45 +00:00
parent 6c531e0a5a
commit e223e0ddf9
9 changed files with 363 additions and 52 deletions

View File

@@ -284,7 +284,11 @@ const createDefaultOwner = (ownerId: string) => {
* Convert a TimelineAssetConfig to a full AssetResponseDto
* This matches the response from GET /api/assets/:id
*/
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
export function toAssetResponseDto(
asset: MockTimelineAsset,
owner?: UserResponseDto,
overrides?: Partial<Pick<AssetResponseDto, 'people' | 'unassignedFaces'>>,
): AssetResponseDto {
const now = new Date().toISOString();
// Default owner if not provided
@@ -338,8 +342,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
exifInfo,
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
people: [],
unassignedFaces: [],
people: overrides?.people ?? [],
unassignedFaces: overrides?.unassignedFaces ?? [],
stack: asset.stack,
isOffline: false,
hasMetadata: true,

View File

@@ -1,3 +1,9 @@
import {
type AssetFaceResponseDto,
type AssetFaceWithoutPersonResponseDto,
type AssetResponseDto,
type PersonWithFacesResponseDto,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { randomThumbnail } from 'src/ui/generators/timeline';
@@ -125,3 +131,117 @@ export const setupFaceEditorMockApiRoutes = async (
});
});
};
export type MockFaceSpec = {
personId: string;
personName: string;
faceId: string;
boundingBoxX1: number;
boundingBoxY1: number;
boundingBoxX2: number;
boundingBoxY2: number;
};
export const createMockFaceData = (
faceSpecs: MockFaceSpec[],
imageWidth: number,
imageHeight: number,
): { people: PersonWithFacesResponseDto[]; unassignedFaces: AssetFaceWithoutPersonResponseDto[] } => {
const people: PersonWithFacesResponseDto[] = faceSpecs.map((spec) => ({
id: spec.personId,
name: spec.personName,
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
updatedAt: new Date().toISOString(),
faces: [
{
id: spec.faceId,
imageWidth,
imageHeight,
boundingBoxX1: spec.boundingBoxX1,
boundingBoxY1: spec.boundingBoxY1,
boundingBoxX2: spec.boundingBoxX2,
boundingBoxY2: spec.boundingBoxY2,
},
],
}));
return { people, unassignedFaces: [] };
};
export const setupFaceOverlayMockApiRoutes = async (
context: BrowserContext,
assetDto: AssetResponseDto,
faceSpecs: MockFaceSpec[],
) => {
const faceResponseMap = new Map<string, AssetFaceResponseDto>();
for (const spec of faceSpecs) {
faceResponseMap.set(spec.faceId, {
id: spec.faceId,
imageWidth: assetDto.width ?? 3000,
imageHeight: assetDto.height ?? 4000,
boundingBoxX1: spec.boundingBoxX1,
boundingBoxY1: spec.boundingBoxY1,
boundingBoxX2: spec.boundingBoxX2,
boundingBoxY2: spec.boundingBoxY2,
person: {
id: spec.personId,
name: spec.personName,
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
updatedAt: new Date().toISOString(),
},
});
}
await context.route(`**/api/assets/${assetDto.id}`, async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: assetDto,
});
});
await context.route(`**/api/faces?id=${assetDto.id}`, async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [...faceResponseMap.values()],
});
});
await context.route('**/api/faces/*', async (route, request) => {
if (request.method() !== 'DELETE') {
return route.fallback();
}
const url = new URL(request.url());
const faceId = url.pathname.split('/').at(-1);
if (faceId) {
faceResponseMap.delete(faceId);
}
return route.fulfill({
status: 200,
contentType: 'text/plain',
body: 'OK',
});
});
await context.route('**/api/people/*/thumbnail', async (route) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail('person-thumb', 1),
});
});
};

View File

@@ -0,0 +1,60 @@
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockFaceData,
type MockFaceSpec,
setupFaceOverlayMockApiRoutes,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('face removal auto-close', () => {
const fixture = setupAssetViewerFixture(903);
const singleFaceSpec: MockFaceSpec[] = [
{
personId: 'person-solo',
personName: 'Solo Person',
faceId: 'face-solo',
boundingBoxX1: 1000,
boundingBoxY1: 500,
boundingBoxX2: 1500,
boundingBoxY2: 1200,
},
];
test.beforeEach(async ({ context }) => {
const faceData = createMockFaceData(
singleFaceSpec,
fixture.primaryAssetDto.width ?? 3000,
fixture.primaryAssetDto.height ?? 4000,
);
const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData);
await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces, singleFaceSpec);
});
test('person side panel closes when last face is removed', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
const editPeopleButton = page.locator('#detail-panel').getByLabel('Edit people');
await expect(editPeopleButton).toBeVisible();
await editPeopleButton.click();
const personName = page.locator('text=Solo Person');
await expect(personName.first()).toBeVisible({ timeout: 5000 });
const deleteButton = page.getByLabel('Delete face');
await expect(deleteButton).toBeVisible();
await deleteButton.click();
const confirmButton = page.getByRole('button', { name: /confirm/i });
await expect(confirmButton).toBeVisible();
await confirmButton.click();
await expect(page.locator('text=Edit faces')).toBeHidden({ timeout: 5000 });
});
});

View File

@@ -0,0 +1,100 @@
import { type AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import {
createMockPeople,
FaceCreateCapture,
MockPerson,
setupFaceEditorMockApiRoutes,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('stack face-tag selection preservation', () => {
const fixture = setupAssetViewerFixture(910);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
let mockPeople: MockPerson[];
let faceCreateCapture: FaceCreateCapture;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.originalFileName = 'second-stacked-asset.jpg';
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
mockPeople = createMockPeople(3);
});
test.beforeEach(async ({ context }) => {
faceCreateCapture = { requests: [] };
await setupBrokenAssetMockApiRoutes(context, mockStack);
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
});
test('selected stacked asset is preserved after tagging a face', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const stackThumbnails = stackSlideshow.locator('[data-asset]');
await expect(stackThumbnails).toHaveCount(2);
await stackThumbnails.nth(1).click();
await ensureDetailPanelVisible(page);
await expect(page.locator('#detail-panel')).toContainText('second-stacked-asset.jpg');
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await page.locator('#face-selector').getByText(mockPeople[0].name).click();
const confirmButton = page.getByRole('button', { name: /confirm/i });
await expect(confirmButton).toBeVisible();
await confirmButton.click();
await expect(page.locator('#face-selector')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(secondAssetDto.id);
await expect(page.locator('#detail-panel')).toContainText('second-stacked-asset.jpg');
const selectedThumbnail = stackSlideshow.locator(`[data-asset="${secondAssetDto.id}"]`);
await expect(selectedThumbnail).toBeVisible();
});
test('primary asset stays selected after tagging a face without switching', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
await expect(page.locator('#detail-panel')).toContainText(primaryAssetDto.originalFileName);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await page.locator('#face-selector').getByText(mockPeople[0].name).click();
const confirmButton = page.getByRole('button', { name: /confirm/i });
await expect(confirmButton).toBeVisible();
await confirmButton.click();
await expect(page.locator('#face-selector')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(primaryAssetDto.id);
await expect(page.locator('#detail-panel')).toContainText(primaryAssetDto.originalFileName);
});
});

View File

@@ -10,6 +10,7 @@
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
@@ -100,10 +101,11 @@
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
let stack: StackResponseDto | undefined = $state();
let selectedStackAsset: AssetResponseDto | undefined = $state();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null);
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();
@@ -116,17 +118,25 @@
playOriginalVideo = value;
};
const selectStackedAsset = async (id: string) => {
selectedStackAsset = await assetCacheManager.getAsset({ 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);
}
};
@@ -184,11 +194,21 @@
onClose?.(asset);
};
const refreshPreservingSelection = async () => {
const id = asset.id;
assetCacheManager.invalidateAsset(id);
if (selectedStackAsset) {
await selectStackedAsset(id);
} else {
const asset = await assetCacheManager.getAsset({ id });
assetViewingStore.setAsset(asset);
}
onAssetChange?.(asset);
};
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
const refreshedAsset = await getAssetInfo({ id: asset.id });
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
await refreshPreservingSelection();
}
assetViewerManager.closeEditor();
};
@@ -288,10 +308,6 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
@@ -304,7 +320,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
stack = action.stack ?? undefined;
if (stack) {
cursor.current = stack.assets[0];
}
@@ -371,7 +387,7 @@
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
cursor.current;
untrack(() => handlePromiseError(refresh()));
});
@@ -535,7 +551,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 +606,7 @@
>
{#if showDetailPanel}
<div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} />
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
</div>
{:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full">
@@ -598,10 +619,14 @@
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}
<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">
<div
role="presentation"
class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
onmouseleave={() => (previewStackedAsset = undefined)}
>
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
class={['inline-block px-1 relative transition-all pb-2']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
>
<Thumbnail
@@ -609,22 +634,25 @@
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
cursor.current = stackedAsset;
onClick={async () => {
await selectStackedAsset(stackedAsset.id);
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
onMouseEvent={async ({ isMouseOver }) => {
if (isMouseOver) {
previewStackedAsset = stackedAsset;
previewStackedAsset = await assetCacheManager.getAsset({ id: stackedAsset.id });
}
}}
readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
disableLinkMouseOver
/>
{#if stackedAsset.id === asset.id}
<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>
{/if}
<div class="w-full flex place-items-center place-content-center">
<div class={['w-2 h-2 rounded-full flex mt-0.5', { 'bg-white': stackedAsset.id === asset.id }]}></div>
</div>
</div>
{/each}
</div>

View File

@@ -20,13 +20,7 @@
import { handleError } from '$lib/utils/handle-error';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
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, modalManager, Text } from '@immich/ui';
import {
mdiCalendar,
@@ -52,9 +46,10 @@
interface Props {
asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null;
onRefreshPeople?: () => Promise<void>;
}
let { asset, currentAlbum = null }: Props = $props();
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
let showAssetPath = $state(false);
let showEditFaces = $state(false);
@@ -120,11 +115,6 @@
return undefined;
};
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
showEditFaces = false;
};
const getAssetFolderHref = (asset: AssetResponseDto) => {
// Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) });
@@ -575,6 +565,6 @@
assetId={asset.id}
assetType={asset.type}
onClose={() => (showEditFaces = false)}
onRefresh={handleRefreshPeople}
onRefresh={() => void onRefreshPeople?.()}
/>
{/if}

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
@@ -17,9 +16,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();
@@ -280,7 +280,7 @@
},
});
await assetViewingStore.setAssetId(assetId);
await onTagFace?.();
} catch (error) {
handleError(error, 'Error tagging face');
} finally {

View File

@@ -32,9 +32,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);
@@ -265,6 +266,12 @@
</AdaptiveImage>
{#if isFaceEditMode.value && 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

@@ -1,8 +1,8 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -25,7 +25,6 @@
import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
interface Props {
assetId: string;
@@ -179,7 +178,10 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewingStore.setAssetId(assetId);
onRefresh();
if (peopleWithFaces.length === 0) {
onClose();
}
} catch (error) {
handleError(error, $t('error_delete_face'));
}