diff --git a/e2e/src/ui/generators/timeline.ts b/e2e/src/ui/generators/timeline.ts index d4c91d667f..9f926f6b0c 100644 --- a/e2e/src/ui/generators/timeline.ts +++ b/e2e/src/ui/generators/timeline.ts @@ -20,7 +20,7 @@ export { toColumnarFormat, } from './timeline/rest-response'; -export type { Changes } from './timeline/rest-response'; +export type { Changes, FaceData } from './timeline/rest-response'; export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images'; diff --git a/e2e/src/ui/generators/timeline/rest-response.ts b/e2e/src/ui/generators/timeline/rest-response.ts index 0c4bd06dc3..9baadda095 100644 --- a/e2e/src/ui/generators/timeline/rest-response.ts +++ b/e2e/src/ui/generators/timeline/rest-response.ts @@ -7,8 +7,10 @@ import { AssetVisibility, UserAvatarColor, type AlbumResponseDto, + type AssetFaceWithoutPersonResponseDto, type AssetResponseDto, type ExifResponseDto, + type PersonWithFacesResponseDto, type TimeBucketAssetResponseDto, type TimeBucketsResponseDto, type UserResponseDto, @@ -284,7 +286,16 @@ 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 type FaceData = { + people: PersonWithFacesResponseDto[]; + unassignedFaces: AssetFaceWithoutPersonResponseDto[]; +}; + +export function toAssetResponseDto( + asset: MockTimelineAsset, + owner?: UserResponseDto, + faceData?: FaceData, +): AssetResponseDto { const now = new Date().toISOString(); // Default owner if not provided @@ -338,8 +349,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons exifInfo, livePhotoVideoId: asset.livePhotoVideoId, tags: [], - people: [], - unassignedFaces: [], + people: faceData?.people ?? [], + unassignedFaces: faceData?.unassignedFaces ?? [], stack: asset.stack, isOffline: false, hasMetadata: true, diff --git a/e2e/src/ui/mock-network/face-editor-network.ts b/e2e/src/ui/mock-network/face-editor-network.ts index 778f04baf9..df384478d2 100644 --- a/e2e/src/ui/mock-network/face-editor-network.ts +++ b/e2e/src/ui/mock-network/face-editor-network.ts @@ -1,5 +1,6 @@ +import type { AssetFaceResponseDto, AssetResponseDto, PersonWithFacesResponseDto, SourceType } from '@immich/sdk'; import { BrowserContext } from '@playwright/test'; -import { randomThumbnail } from 'src/ui/generators/timeline'; +import { type FaceData, randomThumbnail } from 'src/ui/generators/timeline'; // Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight const MINIMAL_MP4_BASE64 = @@ -125,3 +126,84 @@ export const setupFaceEditorMockApiRoutes = async ( }); }); }; + +export type MockFaceSpec = { + personId: string; + personName: string; + faceId: string; + boundingBoxX1: number; + boundingBoxY1: number; + boundingBoxX2: number; + boundingBoxY2: number; +}; + +const toPersonResponseDto = (spec: MockFaceSpec) => ({ + id: spec.personId, + name: spec.personName, + birthDate: null, + isHidden: false, + thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`, + updatedAt: '2025-01-01T00:00:00.000Z', +}); + +const toBoundingBox = (spec: MockFaceSpec, imageWidth: number, imageHeight: number) => ({ + id: spec.faceId, + imageWidth, + imageHeight, + boundingBoxX1: spec.boundingBoxX1, + boundingBoxY1: spec.boundingBoxY1, + boundingBoxX2: spec.boundingBoxX2, + boundingBoxY2: spec.boundingBoxY2, +}); + +export const createMockFaceData = (specs: MockFaceSpec[], imageWidth: number, imageHeight: number): FaceData => { + const people: PersonWithFacesResponseDto[] = specs.map((spec) => ({ + ...toPersonResponseDto(spec), + faces: [toBoundingBox(spec, imageWidth, imageHeight)], + })); + + return { people, unassignedFaces: [] }; +}; + +export const createMockAssetFaces = ( + specs: MockFaceSpec[], + imageWidth: number, + imageHeight: number, +): AssetFaceResponseDto[] => { + return specs.map((spec) => ({ + ...toBoundingBox(spec, imageWidth, imageHeight), + person: toPersonResponseDto(spec), + sourceType: 'machine-learning' as SourceType, + })); +}; + +export const setupGetFacesMockApiRoute = async (context: BrowserContext, faces: AssetFaceResponseDto[]) => { + await context.route('**/api/faces?*', async (route, request) => { + if (request.method() !== 'GET') { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: faces, + }); + }); +}; + +export const setupFaceOverlayMockApiRoutes = async (context: BrowserContext, assetDto: AssetResponseDto) => { + await context.route('**/api/assets/*', async (route, request) => { + if (request.method() !== 'GET') { + return route.fallback(); + } + const url = new URL(request.url()); + const assetId = url.pathname.split('/').at(-1); + if (assetId !== assetDto.id) { + return route.fallback(); + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + json: assetDto, + }); + }); +}; diff --git a/e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts index b1058f646e..8496bbd91c 100644 --- a/e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts +++ b/e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts @@ -10,16 +10,21 @@ import { assetViewerUtils } from '../timeline/utils'; import { setupAssetViewerFixture } from './utils'; const waitForSelectorTransition = async (page: Page) => { - await page.waitForFunction( - () => { - const selector = document.querySelector('#face-selector') as HTMLElement | null; - if (!selector) { - return false; - } - return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished'); - }, - undefined, - { timeout: 1000, polling: 50 }, + await expect(page.locator('#face-editor-data')).toHaveAttribute('data-face-width', /^[1-9]/, { timeout: 10_000 }); + await page.locator('#face-selector').evaluate( + (el) => + new Promise((resolve) => { + requestAnimationFrame(() => + requestAnimationFrame(() => { + const animations = el.getAnimations(); + if (animations.length === 0) { + resolve(); + return; + } + void Promise.all(animations.map((a) => a.finished)).then(() => resolve()); + }), + ); + }), ); }; @@ -95,7 +100,7 @@ test.describe('face-editor', () => { await page.mouse.down(); await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 }); await page.mouse.up(); - await page.waitForTimeout(300); + await waitForSelectorTransition(page); }; test('Face editor opens with person list', async ({ page }) => { @@ -149,7 +154,7 @@ test.describe('face-editor', () => { await expect(page.getByRole('dialog')).toBeVisible(); }); - test('Confirming tag calls createFace API and closes editor', async ({ page }) => { + test('Confirming tag calls createFace API with valid coordinates and closes editor', async ({ page }) => { const asset = selectRandom(fixture.assets, rng); await openFaceEditor(page, asset); @@ -163,8 +168,15 @@ test.describe('face-editor', () => { await expect(page.locator('#face-editor')).toBeHidden(); expect(faceCreateCapture.requests).toHaveLength(1); - expect(faceCreateCapture.requests[0].assetId).toBe(asset.id); - expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id); + const request = faceCreateCapture.requests[0]; + expect(request.assetId).toBe(asset.id); + expect(request.personId).toBe(personToTag.id); + expect(request.x).toBeGreaterThanOrEqual(0); + expect(request.y).toBeGreaterThanOrEqual(0); + expect(request.width).toBeGreaterThan(0); + expect(request.height).toBeGreaterThan(0); + expect(request.x + request.width).toBeLessThanOrEqual(request.imageWidth); + expect(request.y + request.height).toBeLessThanOrEqual(request.imageHeight); }); test('Cancel button closes face editor', async ({ page }) => { @@ -282,4 +294,39 @@ test.describe('face-editor', () => { expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50); expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20); }); + + test('Cancel on confirmation dialog keeps face editor open', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + const personToTag = mockPeople[0]; + await page.locator('#face-selector').getByText(personToTag.name).click(); + + await expect(page.getByRole('dialog')).toBeVisible(); + await page + .getByRole('dialog') + .getByRole('button', { name: /cancel/i }) + .click(); + + await expect(page.getByRole('dialog')).toBeHidden(); + await expect(page.locator('#face-selector')).toBeVisible(); + await expect(page.locator('#face-editor')).toBeVisible(); + expect(faceCreateCapture.requests).toHaveLength(0); + }); + + test('Clicking on face rect center does not reposition it', async ({ page }) => { + const asset = selectRandom(fixture.assets, rng); + await openFaceEditor(page, asset); + + const beforeClick = await getFaceBoxRect(page); + const centerX = beforeClick.left + beforeClick.width / 2; + const centerY = beforeClick.top + beforeClick.height / 2; + + await page.mouse.click(centerX, centerY); + await waitForSelectorTransition(page); + + const afterClick = await getFaceBoxRect(page); + expect(Math.abs(afterClick.left - beforeClick.left)).toBeLessThan(3); + expect(Math.abs(afterClick.top - beforeClick.top)).toBeLessThan(3); + }); }); diff --git a/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts new file mode 100644 index 0000000000..e452ee3b29 --- /dev/null +++ b/e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts @@ -0,0 +1,340 @@ +import { expect, test } from '@playwright/test'; +import { toAssetResponseDto } from 'src/ui/generators/timeline'; +import { + createMockAssetFaces, + createMockFaceData, + createMockPeople, + type MockFaceSpec, + setupFaceEditorMockApiRoutes, + setupFaceOverlayMockApiRoutes, + setupGetFacesMockApiRoute, +} from 'src/ui/mock-network/face-editor-network'; +import { assetViewerUtils } from '../timeline/utils'; +import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils'; + +test.describe.configure({ mode: 'parallel' }); + +const FACE_SPECS: MockFaceSpec[] = [ + { + personId: 'person-alice', + personName: 'Alice Johnson', + faceId: 'face-alice', + boundingBoxX1: 1000, + boundingBoxY1: 500, + boundingBoxX2: 1500, + boundingBoxY2: 1200, + }, + { + personId: 'person-bob', + personName: 'Bob Smith', + faceId: 'face-bob', + boundingBoxX1: 2000, + boundingBoxY1: 800, + boundingBoxX2: 2400, + boundingBoxY2: 1600, + }, +]; + +const setupFaceMocks = async ( + context: import('@playwright/test').BrowserContext, + fixture: ReturnType, +) => { + const mockPeople = createMockPeople(4); + const faceData = createMockFaceData( + FACE_SPECS, + fixture.primaryAssetDto.width ?? 3000, + fixture.primaryAssetDto.height ?? 4000, + ); + const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData); + await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces); + await setupFaceEditorMockApiRoutes(context, mockPeople, { requests: [] }); +}; + +test.describe('face overlay bounding boxes', () => { + const fixture = setupAssetViewerFixture(901); + + test.beforeEach(async ({ context }) => { + await setupFaceMocks(context, fixture); + }); + + test('face overlay divs render with correct aria labels', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const aliceOverlay = page.getByLabel('Person: Alice Johnson'); + const bobOverlay = page.getByLabel('Person: Bob Smith'); + + await expect(aliceOverlay).toBeVisible(); + await expect(bobOverlay).toBeVisible(); + }); + + test('face overlay shows border on hover', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const aliceOverlay = page.getByLabel('Person: Alice Johnson'); + await expect(aliceOverlay).toBeVisible(); + + const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3'); + await expect(activeBorder).toHaveCount(0); + + await aliceOverlay.hover(); + await expect(activeBorder).toHaveCount(1); + }); + + test('face name tooltip appears on hover', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const aliceOverlay = page.getByLabel('Person: Alice Johnson'); + await expect(aliceOverlay).toBeVisible(); + + await aliceOverlay.hover(); + + const nameTooltip = page.locator('[data-viewer-content]').getByText('Alice Johnson'); + await expect(nameTooltip).toBeVisible(); + }); + + test('face overlays hidden in face edit mode', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const aliceOverlay = page.getByLabel('Person: Alice Johnson'); + await expect(aliceOverlay).toBeVisible(); + + await ensureDetailPanelVisible(page); + await page.getByLabel('Tag people').click(); + await page.locator('#face-selector').waitFor({ state: 'visible' }); + + await expect(aliceOverlay).toBeHidden(); + }); + + test('face overlay hover works after exiting face edit mode', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const aliceOverlay = page.getByLabel('Person: Alice Johnson'); + await expect(aliceOverlay).toBeVisible(); + + await ensureDetailPanelVisible(page); + await page.getByLabel('Tag people').click(); + await page.locator('#face-selector').waitFor({ state: 'visible' }); + await expect(aliceOverlay).toBeHidden(); + + await page.getByRole('button', { name: /cancel/i }).click(); + await expect(page.locator('#face-selector')).toBeHidden(); + + await expect(aliceOverlay).toBeVisible(); + + const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3'); + await expect(activeBorder).toHaveCount(0); + await aliceOverlay.hover(); + await expect(activeBorder).toHaveCount(1); + }); +}); + +test.describe('zoom and face editor interaction', () => { + const fixture = setupAssetViewerFixture(902); + + test.beforeEach(async ({ context }) => { + await setupFaceMocks(context, fixture); + }); + + test('zoom is preserved when entering face edit mode', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); + await page.mouse.wheel(0, -1); + + const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]'); + await expect(async () => { + const transform = await imgLocator.evaluate((element) => { + return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform; + }); + expect(transform).not.toBe('none'); + expect(transform).not.toBe(''); + }).toPass({ timeout: 2000 }); + + await ensureDetailPanelVisible(page); + await page.getByLabel('Tag people').click(); + await page.locator('#face-selector').waitFor({ state: 'visible' }); + + await expect(page.locator('#face-editor')).toBeVisible(); + + const afterTransform = await imgLocator.evaluate((element) => { + return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform; + }); + expect(afterTransform).not.toBe('none'); + }); + + test('modifier+drag pans zoomed image without repositioning face rect', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + const { width, height } = page.viewportSize()!; + await page.mouse.move(width / 2, height / 2); + for (let i = 0; i < 10; i++) { + await page.mouse.wheel(0, -3); + } + + const imgLocator = page.locator('[data-viewer-content] img[data-testid="preview"]'); + await expect(async () => { + const transform = await imgLocator.evaluate((element) => { + return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform; + }); + expect(transform).not.toBe('none'); + }).toPass({ timeout: 2000 }); + + await ensureDetailPanelVisible(page); + await page.getByLabel('Tag people').click(); + await page.locator('#face-selector').waitFor({ state: 'visible' }); + + const dataEl = page.locator('#face-editor-data'); + await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/); + const beforeLeft = Number(await dataEl.getAttribute('data-face-left')); + const beforeTop = Number(await dataEl.getAttribute('data-face-top')); + const transformBefore = await imgLocator.evaluate((element) => { + return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform; + }); + + const panModifier = await page.evaluate(() => + /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? 'Meta' : 'Control', + ); + await page.keyboard.down(panModifier); + + // Verify face editor becomes transparent to pointer events + await expect(async () => { + const pe = await dataEl.evaluate((el) => getComputedStyle(el).pointerEvents); + expect(pe).toBe('none'); + }).toPass({ timeout: 2000 }); + + await page.mouse.move(width / 2, height / 2); + await page.mouse.down(); + await page.mouse.move(width / 2 + 100, height / 2 + 50, { steps: 5 }); + await page.mouse.up(); + await page.keyboard.up(panModifier); + + const transformAfter = await imgLocator.evaluate((element) => { + return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform; + }); + expect(transformAfter).not.toBe(transformBefore); + + // Extract translate values from matrix(a, b, c, d, tx, ty) + const parseTranslate = (matrix: string) => { + const values = + matrix + .match(/matrix\((.+)\)/)?.[1] + .split(',') + .map(Number) ?? []; + return { tx: values[4], ty: values[5] }; + }; + const panBefore = parseTranslate(transformBefore); + const panAfter = parseTranslate(transformAfter); + const panDeltaX = panAfter.tx - panBefore.tx; + const panDeltaY = panAfter.ty - panBefore.ty; + + // Face rect screen position should have moved by the same amount as the pan + // (it follows the image), NOT been repositioned by a click + const afterLeft = Number(await dataEl.getAttribute('data-face-left')); + const afterTop = Number(await dataEl.getAttribute('data-face-top')); + const faceDeltaX = afterLeft - beforeLeft; + const faceDeltaY = afterTop - beforeTop; + expect(Math.abs(faceDeltaX - panDeltaX)).toBeLessThan(3); + expect(Math.abs(faceDeltaY - panDeltaY)).toBeLessThan(3); + }); +}); + +test.describe('face overlay via detail panel interaction', () => { + const fixture = setupAssetViewerFixture(903); + + test.beforeEach(async ({ context }) => { + await setupFaceMocks(context, fixture); + }); + + test('hovering person in detail panel shows face overlay border', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + await ensureDetailPanelVisible(page); + + const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' }); + await expect(personLink).toBeVisible(); + + const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3'); + await expect(activeBorder).toHaveCount(0); + + await personLink.hover(); + await expect(activeBorder).toHaveCount(1); + }); + + test('touch pointer on person in detail panel shows face overlay border', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + await ensureDetailPanelVisible(page); + + const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' }); + await expect(personLink).toBeVisible(); + + const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3'); + await expect(activeBorder).toHaveCount(0); + + // Simulate a touch-type pointerover (the fix changed from onmouseover to onpointerover, + // which fires for touch pointers unlike mouseover) + await personLink.dispatchEvent('pointerover', { pointerType: 'touch' }); + await expect(activeBorder).toHaveCount(1); + }); + + test('hovering person in detail panel works after exiting face edit mode', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + await ensureDetailPanelVisible(page); + await page.getByLabel('Tag people').click(); + await page.locator('#face-selector').waitFor({ state: 'visible' }); + + await page.getByRole('button', { name: /cancel/i }).click(); + await expect(page.locator('#face-selector')).toBeHidden(); + + const personLink = page.locator('#detail-panel a').filter({ hasText: 'Alice Johnson' }); + await expect(personLink).toBeVisible(); + + const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3'); + await personLink.hover(); + await expect(activeBorder).toHaveCount(1); + }); +}); + +test.describe('face overlay via edit faces side panel', () => { + const fixture = setupAssetViewerFixture(904); + + test.beforeEach(async ({ context }) => { + await setupFaceMocks(context, fixture); + + const assetFaces = createMockAssetFaces( + FACE_SPECS, + fixture.primaryAssetDto.width ?? 3000, + fixture.primaryAssetDto.height ?? 4000, + ); + await setupGetFacesMockApiRoute(context, assetFaces); + }); + + test('hovering person in edit faces panel shows face overlay border', async ({ page }) => { + await page.goto(`/photos/${fixture.primaryAsset.id}`); + await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset); + + await ensureDetailPanelVisible(page); + await page.getByLabel('Edit people').click(); + + const faceThumbnail = page.getByTestId('face-thumbnail').first(); + await expect(faceThumbnail).toBeVisible(); + + const activeBorder = page.locator('[data-viewer-content] .border-solid.border-white.border-3'); + await expect(activeBorder).toHaveCount(0); + + await faceThumbnail.hover(); + await expect(activeBorder).toHaveCount(1); + }); +}); diff --git a/i18n/en.json b/i18n/en.json index 956ed03989..f427d51abb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1275,6 +1275,7 @@ "hide_schema": "Hide schema", "hide_text_recognition": "Hide text recognition", "hide_unnamed_people": "Hide unnamed people", + "hold_key_to_pan": "Hold {key} to pan", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_success": "Added {added} assets to album {album}.", diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000000..5fca3f84bc --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 90c9328cf8..b651bff98f 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -23,6 +23,8 @@ onError?: () => void; ref?: HTMLDivElement; imgRef?: HTMLImageElement; + imgNaturalSize?: Size; + imgScaledSize?: Size; backdrop?: Snippet; overlays?: Snippet; }; @@ -31,6 +33,10 @@ ref = $bindable(), // eslint-disable-next-line no-useless-assignment imgRef = $bindable(), + // eslint-disable-next-line no-useless-assignment + imgNaturalSize = $bindable(), + // eslint-disable-next-line no-useless-assignment + imgScaledSize = $bindable(), asset, sharedLink, objectFit = 'contain', @@ -98,9 +104,21 @@ return { width: 1, height: 1 }; }); - const { width, height, left, top } = $derived.by(() => { + $effect(() => { + imgNaturalSize = imageDimensions; + }); + + const scaledDimensions = $derived.by(() => { const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit; - const { width, height } = scaleFn(imageDimensions, container); + return scaleFn(imageDimensions, container); + }); + + $effect(() => { + imgScaledSize = scaledDimensions; + }); + + const { width, height, left, top } = $derived.by(() => { + const { width, height } = scaledDimensions; return { width: width + 'px', height: height + 'px', diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 3f7b048c8f..702eeaa61b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -59,7 +59,7 @@ previousAsset?: AssetResponseDto; }; - interface Props { + type Props = { cursor: AssetCursor; showNavigation?: boolean; withStacked?: boolean; @@ -72,7 +72,7 @@ onUndoDelete?: OnUndoDelete; onClose?: (asset: AssetResponseDto) => void; onRandom?: () => Promise<{ id: string } | undefined>; - } + }; let { cursor, @@ -176,6 +176,7 @@ onDestroy(() => { activityManager.reset(); assetViewerManager.closeEditor(); + isFaceEditMode.value = false; syncAssetViewerOpenClass(false); preloadManager.destroy(); }); @@ -290,6 +291,9 @@ const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => { previewStackedAsset = isMouseOver ? stackedAsset : undefined; + if (isMouseOver) { + isFaceEditMode.value = false; + } }; const handlePreAction = (action: Action) => { @@ -358,15 +362,18 @@ } }; + const refreshOcr = async () => { + ocrManager.clear(); + if (sharedLink) { + return; + } + + await ocrManager.getAssetOcr(asset.id); + }; + const refresh = async () => { await refreshStack(); - ocrManager.clear(); - if (!sharedLink) { - if (previewStackedAsset) { - await ocrManager.getAssetOcr(previewStackedAsset.id); - } - await ocrManager.getAssetOcr(asset.id); - } + await refreshOcr(); }; $effect(() => { @@ -375,6 +382,12 @@ untrack(() => handlePromiseError(refresh())); }); + $effect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + previewStackedAsset; + untrack(() => ocrManager.clear()); + }); + let lastCursor = $state(); $effect(() => { @@ -460,7 +473,7 @@
@@ -612,6 +625,7 @@ onClick={() => { cursor.current = stackedAsset; previewStackedAsset = undefined; + isFaceEditMode.value = false; }} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} readonly diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index e80d376f57..d469f3c4eb 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -7,10 +7,11 @@ import { timeToLoadTheMap } from '$lib/constants'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import { eventManager } from '$lib/managers/event-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte'; import { Route } from '$lib/route'; - import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; + import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { locale } from '$lib/stores/preferences.store'; import { preferences, user } from '$lib/stores/user.store'; @@ -49,15 +50,15 @@ import UserAvatar from '../shared-components/user-avatar.svelte'; import AlbumListItemDetails from './album-list-item-details.svelte'; - interface Props { + type Props = { asset: AssetResponseDto; currentAlbum?: AlbumResponseDto | null; - } + }; let { asset, currentAlbum = null }: Props = $props(); let showAssetPath = $state(false); - let showEditFaces = $state(false); + let showEditFaces = $derived(isEditFacesPanelOpen.value); let isOwner = $derived($user?.id === asset.ownerId); let people = $derived(asset.people || []); let unassignedFaces = $derived(asset.unassignedFaces || []); @@ -106,7 +107,7 @@ return; } - showEditFaces = false; + isEditFacesPanelOpen.value = false; previousId = asset.id; }); @@ -122,7 +123,8 @@ const handleRefreshPeople = async () => { asset = await getAssetInfo({ id: asset.id }); - showEditFaces = false; + eventManager.emit('AssetUpdate', asset); + isEditFacesPanelOpen.value = false; }; const getAssetFolderHref = (asset: AssetResponseDto) => { @@ -219,7 +221,7 @@ shape="round" color="secondary" variant="ghost" - onclick={() => (showEditFaces = true)} + onclick={() => (isEditFacesPanelOpen.value = true)} /> {/if} @@ -228,13 +230,14 @@
{#each people as person, index (person.id)} {#if showingHiddenPeople || !person.isHidden} + {@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))} ($boundingBoxesArray = people[index].faces)} onblur={() => ($boundingBoxesArray = [])} - onmouseover={() => ($boundingBoxesArray = people[index].faces)} - onmouseleave={() => ($boundingBoxesArray = [])} + onpointerover={() => ($boundingBoxesArray = people[index].faces)} + onpointerleave={() => ($boundingBoxesArray = [])} >

{person.name}

@@ -574,7 +579,7 @@ (showEditFaces = false)} + onClose={() => (isEditFacesPanelOpen.value = false)} onRefresh={handleRefreshPeople} /> {/if} diff --git a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte index ddd8f393c4..c7b9a35303 100644 --- a/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte +++ b/web/src/lib/components/asset-viewer/face-editor/face-editor.svelte @@ -1,10 +1,12 @@ @@ -294,8 +437,8 @@
e.stopPropagation()} >

{$t('select_person_to_tag')}

@@ -347,4 +492,15 @@
+ + {#if isZoomed && !panModifierHeld} +
+

+ {$t('hold_key_to_pan', { values: { key: panModifierLabel } })} +

+
+ {/if}
diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 581e715367..7743077339 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -8,13 +8,13 @@ import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte'; - import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; + import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.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'; - import { getNaturalSize, scaleToFit, type Size } from '$lib/utils/container-utils'; + import type { Size } from '$lib/utils/container-utils'; import { handleError } from '$lib/utils/handle-error'; import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; @@ -67,13 +67,10 @@ height: containerHeight, }); - const overlaySize = $derived.by((): Size => { - if (!assetViewerManager.imgRef || !visibleImageReady) { - return { width: 0, height: 0 }; - } + let imageDimensions = $state({ width: 0, height: 0 }); + let scaledDimensions = $state({ width: 0, height: 0 }); - return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight }); - }); + const overlaySize = $derived(visibleImageReady ? scaledDimensions : { width: 0, height: 0 }); const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []); @@ -97,12 +94,6 @@ const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow); - $effect(() => { - if (isFaceEditMode.value && assetViewerManager.zoom > 1) { - onZoom(); - } - }); - // TODO move to action + command palette const onCopyShortcut = (event: KeyboardEvent) => { if (globalThis.getSelection()?.type === 'Range') { @@ -147,46 +138,22 @@ const faceToNameMap = $derived.by(() => { // eslint-disable-next-line svelte/prefer-svelte-reactivity - const map = new Map(); + const map = new Map(); for (const person of asset.people ?? []) { for (const face of person.faces ?? []) { map.set(face, person.name); } } + for (const face of asset.unassignedFaces ?? []) { + map.set(face, undefined); + } return map; }); + // Array needed for indexed access in the template (faces[index]) const faces = $derived(Array.from(faceToNameMap.keys())); - - const handleImageMouseMove = (event: MouseEvent) => { - $boundingBoxesArray = []; - if (!assetViewerManager.imgRef || !element || isFaceEditMode.value || ocrManager.showOverlay) { - return; - } - - const natural = getNaturalSize(assetViewerManager.imgRef); - const scaled = scaleToFit(natural, container); - const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState; - - 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 handleImageMouseLeave = () => { - $boundingBoxesArray = []; - }; + const boundingBoxes = $derived(getBoundingBox(faces, overlaySize)); + const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id))); @@ -207,8 +174,6 @@ bind:clientHeight={containerHeight} role="presentation" ondblclick={onZoom} - onmousemove={handleImageMouseMove} - onmouseleave={handleImageMouseLeave} use:zoomImageAction={{ zoomTarget: adaptiveImage }} {...useSwipe((event) => onSwipe?.(event))} > @@ -227,6 +192,8 @@ onReady?.(); }} bind:imgRef={assetViewerManager.imgRef} + bind:imgNaturalSize={imageDimensions} + bind:imgScaledSize={scaledDimensions} bind:ref={adaptiveImage} > {#snippet backdrop()} @@ -238,20 +205,40 @@ {/if} {/snippet} {#snippet overlays()} - {#each getBoundingBox($boundingBoxesArray, overlaySize) as boundingbox, index (boundingbox.id)} + {#if !isFaceEditMode.value} + {#each boundingBoxes as boundingbox, index (boundingbox.id)} + {@const face = faces[index]} + {@const name = faceToNameMap.get(face)} + {#if name !== undefined || isEditFacesPanelOpen.value} + +
($boundingBoxesArray = [face])} + onpointerleave={() => ($boundingBoxesArray = [])} + >
+ {/if} + {/each} + {/if} + + {#each activeBoundingBoxes as boundingbox (boundingbox.id)} + {@const face = faces.find((f) => f.id === boundingbox.id)} + {@const name = face ? faceToNameMap.get(face) : undefined}
- {#if faceToNameMap.get($boundingBoxesArray[index])} -
- {faceToNameMap.get($boundingBoxesArray[index])} -
- {/if} + > + {#if name} + + {/if} + {/each} {#each ocrBoxes as ocrBox (ocrBox.id)} @@ -261,6 +248,6 @@ {#if isFaceEditMode.value && assetViewerManager.imgRef} - + {/if} diff --git a/web/src/lib/components/asset-viewer/video-native-viewer.svelte b/web/src/lib/components/asset-viewer/video-native-viewer.svelte index e53414be07..60867985a0 100644 --- a/web/src/lib/components/asset-viewer/video-native-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-native-viewer.svelte @@ -11,14 +11,16 @@ videoViewerVolume, } from '$lib/stores/preferences.store'; import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils'; + import type { Size } from '$lib/utils/container-utils'; import { AssetMediaSize } from '@immich/sdk'; import { LoadingSpinner } from '@immich/ui'; import { onDestroy, onMount } from 'svelte'; import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures'; import { fade } from 'svelte/transition'; - interface Props { + type Props = { assetId: string; + imageSize: Size; loopVideo: boolean; cacheKey: string | null; playOriginalVideo: boolean; @@ -27,10 +29,11 @@ onVideoEnded?: () => void; onVideoStarted?: () => void; onClose?: () => void; - } + }; let { assetId, + imageSize, loopVideo, cacheKey, playOriginalVideo, @@ -173,7 +176,7 @@ {/if} {#if isFaceEditMode.value} - + {/if} {/if} diff --git a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte index 57d8acd78a..6ab53f9b11 100644 --- a/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-wrapper-viewer.svelte @@ -4,7 +4,7 @@ import { ProjectionType } from '$lib/constants'; import type { AssetResponseDto } from '@immich/sdk'; - interface Props { + type Props = { asset: AssetResponseDto; assetId?: string; projectionType: string | null | undefined; @@ -16,7 +16,7 @@ onNextAsset?: () => void; onVideoEnded?: () => void; onVideoStarted?: () => void; - } + }; let { asset, @@ -42,6 +42,7 @@ {loopVideo} {cacheKey} assetId={effectiveAssetId} + imageSize={{ width: asset.width ?? 1, height: asset.height ?? 1 }} {playOriginalVideo} {onPreviousAsset} {onNextAsset} diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index a54ad911fd..d580a4826b 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -16,6 +16,7 @@ circle?: boolean; hidden?: boolean; border?: boolean; + highlighted?: boolean; hiddenIconClass?: string; class?: ClassValue; brokenAssetClass?: ClassValue; @@ -34,6 +35,7 @@ circle = false, hidden = false, border = false, + highlighted = false, hiddenIconClass = 'text-white', onComplete = undefined, class: imageClass = '', @@ -83,6 +85,10 @@ /> {/if} +{#if highlighted} + +{/if} + {#if hidden}
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 cad29706a4..314837cb77 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -27,12 +27,12 @@ import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import AssignFaceSidePanel from './assign-face-side-panel.svelte'; - interface Props { + type Props = { assetId: string; assetType: AssetTypeEnum; onClose: () => void; onRefresh: () => void; - } + }; let { assetId, assetType, onClose, onRefresh }: Props = $props(); @@ -58,6 +58,8 @@ let automaticRefreshTimeout: ReturnType; const thumbnailWidth = '90px'; + const focusHighlightClass = + 'group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary'; async function loadPeople() { const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner); @@ -226,14 +228,16 @@ {:else} {#each peopleWithFaces as face, index (face.id)} {@const personName = face.person ? face.person?.name : $t('face_unassigned')} + {@const isHighlighted = $boundingBoxesArray.some((f) => f.id === face.id)}
($boundingBoxesArray = [peopleWithFaces[index]])} - onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} - onmouseleave={() => ($boundingBoxesArray = [])} + onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} + onpointerleave={() => ($boundingBoxesArray = [])} >
{#if selectedPersonToCreate[face.id]} @@ -245,6 +249,8 @@ title={$t('new_person')} widthStyle={thumbnailWidth} heightStyle={thumbnailWidth} + highlighted={isHighlighted} + class={focusHighlightClass} /> {:else if selectedPersonToReassign[face.id]}