mirror of
https://github.com/immich-app/immich.git
synced 2026-03-26 20:00:44 +03:00
feat(web): image-relative overlays with zoom support for faces, OCR, and face editor
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
55
e2e/src/ui/mock-network/ocr-network.ts
Normal file
55
e2e/src/ui/mock-network/ocr-network.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AssetOcrResponseDto } from '@immich/sdk';
|
||||
import { BrowserContext } from '@playwright/test';
|
||||
|
||||
export type MockOcrBox = {
|
||||
text: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
x3: number;
|
||||
y3: number;
|
||||
x4: number;
|
||||
y4: number;
|
||||
};
|
||||
|
||||
export const createMockOcrData = (assetId: string, boxes: MockOcrBox[]): AssetOcrResponseDto[] => {
|
||||
return boxes.map((box) => ({
|
||||
id: faker.string.uuid(),
|
||||
assetId,
|
||||
x1: box.x1,
|
||||
y1: box.y1,
|
||||
x2: box.x2,
|
||||
y2: box.y2,
|
||||
x3: box.x3,
|
||||
y3: box.y3,
|
||||
x4: box.x4,
|
||||
y4: box.y4,
|
||||
boxScore: 0.95,
|
||||
textScore: 0.9,
|
||||
text: box.text,
|
||||
}));
|
||||
};
|
||||
|
||||
export const setupOcrMockApiRoutes = async (
|
||||
context: BrowserContext,
|
||||
ocrDataByAssetId: Map<string, AssetOcrResponseDto[]>,
|
||||
) => {
|
||||
await context.route('**/assets/*/ocr', async (route, request) => {
|
||||
if (request.method() !== 'GET') {
|
||||
return route.fallback();
|
||||
}
|
||||
const url = new URL(request.url());
|
||||
const segments = url.pathname.split('/');
|
||||
const assetIdIndex = segments.indexOf('assets') + 1;
|
||||
const assetId = segments[assetIdIndex];
|
||||
|
||||
const ocrData = ocrDataByAssetId.get(assetId) ?? [];
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
json: ocrData,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -149,7 +149,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 +163,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 +289,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 page.waitForTimeout(300);
|
||||
|
||||
const afterClick = await getFaceBoxRect(page);
|
||||
expect(Math.abs(afterClick.left - beforeClick.left)).toBeLessThan(3);
|
||||
expect(Math.abs(afterClick.top - beforeClick.top)).toBeLessThan(3);
|
||||
});
|
||||
});
|
||||
|
||||
264
e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts
Normal file
264
e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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<typeof setupAssetViewerFixture>,
|
||||
) => {
|
||||
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[draggable="false"]');
|
||||
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.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.locator('section div[role="button"]').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);
|
||||
});
|
||||
});
|
||||
300
e2e/src/ui/specs/asset-viewer/ocr.e2e-spec.ts
Normal file
300
e2e/src/ui/specs/asset-viewer/ocr.e2e-spec.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import type { AssetOcrResponseDto, 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 { createMockOcrData, setupOcrMockApiRoutes } from 'src/ui/mock-network/ocr-network';
|
||||
import { assetViewerUtils } from '../timeline/utils';
|
||||
import { setupAssetViewerFixture } from './utils';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const PRIMARY_OCR_BOXES = [
|
||||
{ text: 'Hello World', x1: 0.1, y1: 0.1, x2: 0.4, y2: 0.1, x3: 0.4, y3: 0.15, x4: 0.1, y4: 0.15 },
|
||||
{ text: 'Immich Photo', x1: 0.2, y1: 0.3, x2: 0.6, y2: 0.3, x3: 0.6, y3: 0.36, x4: 0.2, y4: 0.36 },
|
||||
];
|
||||
|
||||
const SECONDARY_OCR_BOXES = [
|
||||
{ text: 'Second Asset Text', x1: 0.15, y1: 0.2, x2: 0.55, y2: 0.2, x3: 0.55, y3: 0.26, x4: 0.15, y4: 0.26 },
|
||||
];
|
||||
|
||||
test.describe('OCR bounding boxes', () => {
|
||||
const fixture = setupAssetViewerFixture(920);
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||
]);
|
||||
|
||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||
});
|
||||
|
||||
test('OCR bounding boxes appear when clicking OCR button', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const ocrButton = page.getByLabel('Text recognition');
|
||||
await expect(ocrButton).toBeVisible();
|
||||
await ocrButton.click();
|
||||
|
||||
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
|
||||
await expect(ocrBoxes).toHaveCount(2);
|
||||
|
||||
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
|
||||
await expect(ocrBoxes.nth(1)).toContainText('Immich Photo');
|
||||
});
|
||||
|
||||
test('OCR bounding boxes toggle off on second click', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const ocrButton = page.getByLabel('Text recognition');
|
||||
await ocrButton.click();
|
||||
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]').first()).toBeVisible();
|
||||
|
||||
await ocrButton.click();
|
||||
await expect(page.locator('[data-viewer-content] [data-testid="ocr-box"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('OCR with stacked assets', () => {
|
||||
const fixture = setupAssetViewerFixture(921);
|
||||
let mockStack: MockStack;
|
||||
let primaryAssetDto: AssetResponseDto;
|
||||
let secondAssetDto: AssetResponseDto;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
||||
secondAssetDto.originalFileName = 'second-ocr-asset.jpg';
|
||||
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||
|
||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||
[secondAssetDto.id, createMockOcrData(secondAssetDto.id, SECONDARY_OCR_BOXES)],
|
||||
]);
|
||||
|
||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||
});
|
||||
|
||||
test('different OCR boxes shown for different stacked assets', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const ocrButton = page.getByLabel('Text recognition');
|
||||
await expect(ocrButton).toBeVisible();
|
||||
await ocrButton.click();
|
||||
|
||||
const ocrBoxes = page.locator('[data-viewer-content] [data-testid="ocr-box"]');
|
||||
await expect(ocrBoxes).toHaveCount(2);
|
||||
await expect(ocrBoxes.nth(0)).toContainText('Hello World');
|
||||
|
||||
const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
|
||||
await expect(stackThumbnails).toHaveCount(2);
|
||||
await stackThumbnails.nth(1).click();
|
||||
|
||||
// refreshOcr() clears showOverlay when switching assets, so re-enable it
|
||||
await expect(ocrBoxes).toHaveCount(0);
|
||||
await expect(ocrButton).toBeVisible();
|
||||
await ocrButton.click();
|
||||
|
||||
await expect(ocrBoxes).toHaveCount(1);
|
||||
await expect(ocrBoxes.first()).toContainText('Second Asset Text');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('OCR boxes and zoom', () => {
|
||||
const fixture = setupAssetViewerFixture(922);
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||
]);
|
||||
|
||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||
});
|
||||
|
||||
test('OCR boxes scale with zoom', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
const ocrButton = page.getByLabel('Text recognition');
|
||||
await expect(ocrButton).toBeVisible();
|
||||
await ocrButton.click();
|
||||
|
||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||
await expect(ocrBox).toBeVisible();
|
||||
|
||||
const initialBox = await ocrBox.boundingBox();
|
||||
expect(initialBox).toBeTruthy();
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -3);
|
||||
|
||||
await expect(async () => {
|
||||
const zoomedBox = await ocrBox.boundingBox();
|
||||
expect(zoomedBox).toBeTruthy();
|
||||
expect(zoomedBox!.width).toBeGreaterThan(initialBox!.width);
|
||||
expect(zoomedBox!.height).toBeGreaterThan(initialBox!.height);
|
||||
}).toPass({ timeout: 2000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('OCR text interaction', () => {
|
||||
const fixture = setupAssetViewerFixture(923);
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||
const ocrDataByAssetId = new Map<string, AssetOcrResponseDto[]>([
|
||||
[primaryAssetDto.id, createMockOcrData(primaryAssetDto.id, PRIMARY_OCR_BOXES)],
|
||||
]);
|
||||
|
||||
await setupOcrMockApiRoutes(context, ocrDataByAssetId);
|
||||
});
|
||||
|
||||
test('OCR text box has data-overlay-interactive attribute', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await page.getByLabel('Text recognition').click();
|
||||
|
||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||
await expect(ocrBox).toBeVisible();
|
||||
await expect(ocrBox).toHaveAttribute('data-overlay-interactive');
|
||||
});
|
||||
|
||||
test('OCR text box receives focus on click', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await page.getByLabel('Text recognition').click();
|
||||
|
||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||
await expect(ocrBox).toBeVisible();
|
||||
|
||||
await ocrBox.click();
|
||||
await expect(ocrBox).toBeFocused();
|
||||
});
|
||||
|
||||
test('dragging on OCR text box does not trigger image pan', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await page.getByLabel('Text recognition').click();
|
||||
|
||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||
await expect(ocrBox).toBeVisible();
|
||||
|
||||
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
||||
const initialTransform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
|
||||
const box = await ocrBox.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const centerX = box!.x + box!.width / 2;
|
||||
const centerY = box!.y + box!.height / 2;
|
||||
|
||||
await page.mouse.move(centerX, centerY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX + 50, centerY + 30, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
|
||||
const afterTransform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
expect(afterTransform).toBe(initialTransform);
|
||||
});
|
||||
|
||||
test('split touch gesture across zoom container does not trigger zoom', async ({ page }) => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||
|
||||
await page.getByLabel('Text recognition').click();
|
||||
const ocrBox = page.locator('[data-viewer-content] [data-testid="ocr-box"]').first();
|
||||
await expect(ocrBox).toBeVisible();
|
||||
|
||||
const imgLocator = page.locator('[data-viewer-content] img[draggable="false"]');
|
||||
const initialTransform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
|
||||
const viewerContent = page.locator('[data-viewer-content]');
|
||||
const viewerBox = await viewerContent.boundingBox();
|
||||
expect(viewerBox).toBeTruthy();
|
||||
|
||||
// Dispatch a synthetic split gesture: one touch inside the viewer, one outside
|
||||
await page.evaluate(
|
||||
({ viewerCenterX, viewerCenterY, outsideY }) => {
|
||||
const viewer = document.querySelector('[data-viewer-content]');
|
||||
if (!viewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createTouch = (id: number, x: number, y: number) => {
|
||||
return new Touch({
|
||||
identifier: id,
|
||||
target: viewer,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
});
|
||||
};
|
||||
|
||||
const insideTouch = createTouch(0, viewerCenterX, viewerCenterY);
|
||||
const outsideTouch = createTouch(1, viewerCenterX, outsideY);
|
||||
|
||||
const touchStartEvent = new TouchEvent('touchstart', {
|
||||
touches: [insideTouch, outsideTouch],
|
||||
targetTouches: [insideTouch],
|
||||
changedTouches: [insideTouch, outsideTouch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
const touchMoveEvent = new TouchEvent('touchmove', {
|
||||
touches: [createTouch(0, viewerCenterX, viewerCenterY - 30), createTouch(1, viewerCenterX, outsideY + 30)],
|
||||
targetTouches: [createTouch(0, viewerCenterX, viewerCenterY - 30)],
|
||||
changedTouches: [
|
||||
createTouch(0, viewerCenterX, viewerCenterY - 30),
|
||||
createTouch(1, viewerCenterX, outsideY + 30),
|
||||
],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
const touchEndEvent = new TouchEvent('touchend', {
|
||||
touches: [],
|
||||
targetTouches: [],
|
||||
changedTouches: [insideTouch, outsideTouch],
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
viewer.dispatchEvent(touchStartEvent);
|
||||
viewer.dispatchEvent(touchMoveEvent);
|
||||
viewer.dispatchEvent(touchEndEvent);
|
||||
},
|
||||
{
|
||||
viewerCenterX: viewerBox!.x + viewerBox!.width / 2,
|
||||
viewerCenterY: viewerBox!.y + viewerBox!.height / 2,
|
||||
outsideY: 10, // near the top of the page, outside the viewer
|
||||
},
|
||||
);
|
||||
|
||||
const afterTransform = await imgLocator.evaluate((element) => {
|
||||
return getComputedStyle(element.closest('[style*="transform"]') ?? element).transform;
|
||||
});
|
||||
expect(afterTransform).toBe(initialTransform);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user