mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 14:29:26 +03:00
feat: enhance face-editor positioning (#26303)
feat: enhance face-editor positioning - less overlap test: timeline with actual video
This commit is contained in:
127
e2e/src/ui/mock-network/face-editor-network.ts
Normal file
127
e2e/src/ui/mock-network/face-editor-network.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { BrowserContext } from '@playwright/test';
|
||||||
|
import { 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 =
|
||||||
|
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
|
||||||
|
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
|
||||||
|
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
|
||||||
|
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
|
||||||
|
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
|
||||||
|
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
|
||||||
|
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
|
||||||
|
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
|
||||||
|
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
|
||||||
|
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
|
||||||
|
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
|
||||||
|
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
|
||||||
|
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
|
||||||
|
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
|
||||||
|
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
|
||||||
|
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
|
||||||
|
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
|
||||||
|
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
|
||||||
|
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
|
||||||
|
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
|
||||||
|
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
|
||||||
|
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
|
||||||
|
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
|
||||||
|
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
|
||||||
|
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
|
||||||
|
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
|
||||||
|
|
||||||
|
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
|
||||||
|
|
||||||
|
export type MockPerson = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
birthDate: string | null;
|
||||||
|
isHidden: boolean;
|
||||||
|
thumbnailPath: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMockPeople = (count: number): MockPerson[] => {
|
||||||
|
const names = [
|
||||||
|
'Alice Johnson',
|
||||||
|
'Bob Smith',
|
||||||
|
'Charlie Brown',
|
||||||
|
'Diana Prince',
|
||||||
|
'Eve Adams',
|
||||||
|
'Frank Castle',
|
||||||
|
'Grace Lee',
|
||||||
|
'Hank Pym',
|
||||||
|
'Iris West',
|
||||||
|
'Jack Ryan',
|
||||||
|
];
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
id: `person-${index}`,
|
||||||
|
name: names[index % names.length],
|
||||||
|
birthDate: null,
|
||||||
|
isHidden: false,
|
||||||
|
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
|
||||||
|
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FaceCreateCapture = {
|
||||||
|
requests: Array<{
|
||||||
|
assetId: string;
|
||||||
|
personId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
imageWidth: number;
|
||||||
|
imageHeight: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupFaceEditorMockApiRoutes = async (
|
||||||
|
context: BrowserContext,
|
||||||
|
mockPeople: MockPerson[],
|
||||||
|
faceCreateCapture: FaceCreateCapture,
|
||||||
|
) => {
|
||||||
|
await context.route('**/api/people?*', async (route, request) => {
|
||||||
|
if (request.method() !== 'GET') {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: {
|
||||||
|
hasNextPage: false,
|
||||||
|
hidden: 0,
|
||||||
|
people: mockPeople,
|
||||||
|
total: mockPeople.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/faces', async (route, request) => {
|
||||||
|
if (request.method() !== 'POST') {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.postDataJSON();
|
||||||
|
faceCreateCapture.requests.push(body);
|
||||||
|
|
||||||
|
return route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TimelineData,
|
TimelineData,
|
||||||
} from 'src/ui/generators/timeline';
|
} from 'src/ui/generators/timeline';
|
||||||
import { sleep } from 'src/ui/specs/timeline/utils';
|
import { sleep } from 'src/ui/specs/timeline/utils';
|
||||||
|
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
|
||||||
|
|
||||||
export class TimelineTestContext {
|
export class TimelineTestContext {
|
||||||
slowBucket = false;
|
slowBucket = false;
|
||||||
@@ -135,6 +136,14 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
return route.continue();
|
return route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets/*/video/playback*', async (route) => {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'video/mp4' },
|
||||||
|
body: MINIMAL_MP4_BUFFER,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await context.route('**/api/albums/**', async (route, request) => {
|
await context.route('**/api/albums/**', async (route, request) => {
|
||||||
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||||
if (albumsMatch) {
|
if (albumsMatch) {
|
||||||
|
|||||||
285
e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts
Normal file
285
e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { expect, Page, test } from '@playwright/test';
|
||||||
|
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline';
|
||||||
|
import {
|
||||||
|
createMockPeople,
|
||||||
|
FaceCreateCapture,
|
||||||
|
MockPerson,
|
||||||
|
setupFaceEditorMockApiRoutes,
|
||||||
|
} from 'src/ui/mock-network/face-editor-network';
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.keyboard.press('i');
|
||||||
|
await page.locator('#detail-panel').waitFor({ state: 'visible' });
|
||||||
|
await page.getByLabel('Tag people').click();
|
||||||
|
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||||
|
await waitForSelectorTransition(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
test.describe('face-editor', () => {
|
||||||
|
const fixture = setupAssetViewerFixture(777);
|
||||||
|
const rng = new SeededRandom(777);
|
||||||
|
let mockPeople: MockPerson[];
|
||||||
|
let faceCreateCapture: FaceCreateCapture;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
mockPeople = createMockPeople(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
faceCreateCapture = { requests: [] };
|
||||||
|
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
|
||||||
|
});
|
||||||
|
|
||||||
|
type ScreenRect = { top: number; left: number; width: number; height: number };
|
||||||
|
|
||||||
|
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
|
||||||
|
const dataEl = page.locator('#face-editor-data');
|
||||||
|
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
|
||||||
|
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
|
||||||
|
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
|
||||||
|
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
|
||||||
|
const canvasBox = await page.locator('#face-editor').boundingBox();
|
||||||
|
if (!canvasBox) {
|
||||||
|
throw new Error('Canvas element not found');
|
||||||
|
}
|
||||||
|
const left = Number(await dataEl.getAttribute('data-face-left'));
|
||||||
|
const top = Number(await dataEl.getAttribute('data-face-top'));
|
||||||
|
const width = Number(await dataEl.getAttribute('data-face-width'));
|
||||||
|
const height = Number(await dataEl.getAttribute('data-face-height'));
|
||||||
|
return {
|
||||||
|
top: canvasBox.y + top,
|
||||||
|
left: canvasBox.x + left,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
|
||||||
|
const box = await page.locator('#face-selector').boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
throw new Error('Face selector element not found');
|
||||||
|
}
|
||||||
|
return { top: box.y, left: box.x, width: box.width, height: box.height };
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
|
||||||
|
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
|
||||||
|
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
|
||||||
|
return overlapX * overlapY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const centerX = faceBox.left + faceBox.width / 2;
|
||||||
|
const centerY = faceBox.top + faceBox.height / 2;
|
||||||
|
await page.mouse.move(centerX, centerY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('Face editor opens with person list', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await expect(page.locator('#face-selector')).toBeVisible();
|
||||||
|
await expect(page.locator('#face-editor')).toBeVisible();
|
||||||
|
|
||||||
|
for (const person of mockPeople) {
|
||||||
|
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Search filters people by name', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const searchInput = page.locator('#face-selector input');
|
||||||
|
await searchInput.fill('Alice');
|
||||||
|
|
||||||
|
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
|
||||||
|
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
|
||||||
|
|
||||||
|
await searchInput.clear();
|
||||||
|
|
||||||
|
for (const person of mockPeople) {
|
||||||
|
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Search with no results shows empty message', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const searchInput = page.locator('#face-selector input');
|
||||||
|
await searchInput.fill('Nonexistent Person XYZ');
|
||||||
|
|
||||||
|
for (const person of mockPeople) {
|
||||||
|
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selecting a person shows confirmation dialog', 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Confirming tag calls createFace API and closes editor', 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('button', { name: /confirm/i }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('#face-selector')).toBeHidden();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cancel button closes face editor', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await expect(page.locator('#face-selector')).toBeVisible();
|
||||||
|
await expect(page.locator('#face-editor')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /cancel/i }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('#face-selector')).toBeHidden();
|
||||||
|
await expect(page.locator('#face-editor')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector does not overlap face box on initial open', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||||
|
|
||||||
|
expect(overlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await dragFaceBox(page, 0, 150);
|
||||||
|
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||||
|
|
||||||
|
expect(overlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await dragFaceBox(page, 200, 0);
|
||||||
|
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||||
|
|
||||||
|
expect(overlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await dragFaceBox(page, -300, -300);
|
||||||
|
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||||
|
|
||||||
|
expect(overlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await dragFaceBox(page, 300, 300);
|
||||||
|
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||||
|
|
||||||
|
expect(overlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector stays within viewport bounds', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const viewportSize = page.viewportSize()!;
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
|
||||||
|
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||||
|
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await dragFaceBox(page, -400, -400);
|
||||||
|
|
||||||
|
const viewportSize = page.viewportSize()!;
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
|
||||||
|
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||||
|
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Face box is draggable on the canvas', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const beforeDrag = await getFaceBoxRect(page);
|
||||||
|
await dragFaceBox(page, 100, 50);
|
||||||
|
const afterDrag = await getFaceBoxRect(page);
|
||||||
|
|
||||||
|
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
|
||||||
|
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||||
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||||
|
import { clamp } from 'lodash-es';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
@@ -23,10 +24,12 @@
|
|||||||
let canvas: Canvas | undefined = $state();
|
let canvas: Canvas | undefined = $state();
|
||||||
let faceRect: Rect | undefined = $state();
|
let faceRect: Rect | undefined = $state();
|
||||||
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
let faceSelectorEl: HTMLDivElement | undefined = $state();
|
||||||
|
let scrollableListEl: HTMLDivElement | undefined = $state();
|
||||||
let page = $state(1);
|
let page = $state(1);
|
||||||
let candidates = $state<PersonResponseDto[]>([]);
|
let candidates = $state<PersonResponseDto[]>([]);
|
||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
|
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
||||||
|
|
||||||
let filteredCandidates = $derived(
|
let filteredCandidates = $derived(
|
||||||
searchTerm
|
searchTerm
|
||||||
@@ -113,30 +116,33 @@
|
|||||||
positionFaceSelector();
|
positionFaceSelector();
|
||||||
});
|
});
|
||||||
|
|
||||||
const getContainedSize = (
|
const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement) => {
|
||||||
img: HTMLImageElement | HTMLVideoElement,
|
if (element instanceof HTMLImageElement) {
|
||||||
): { actualWidth: number; actualHeight: number } => {
|
return {
|
||||||
if (img instanceof HTMLImageElement) {
|
naturalWidth: element.naturalWidth,
|
||||||
const ratio = img.naturalWidth / img.naturalHeight;
|
naturalHeight: element.naturalHeight,
|
||||||
let actualWidth = img.height * ratio;
|
displayWidth: element.width,
|
||||||
let actualHeight = img.height;
|
displayHeight: element.height,
|
||||||
if (actualWidth > img.width) {
|
};
|
||||||
actualWidth = img.width;
|
|
||||||
actualHeight = img.width / ratio;
|
|
||||||
}
|
|
||||||
return { actualWidth, actualHeight };
|
|
||||||
} else if (img instanceof HTMLVideoElement) {
|
|
||||||
const ratio = img.videoWidth / img.videoHeight;
|
|
||||||
let actualWidth = img.clientHeight * ratio;
|
|
||||||
let actualHeight = img.clientHeight;
|
|
||||||
if (actualWidth > img.clientWidth) {
|
|
||||||
actualWidth = img.clientWidth;
|
|
||||||
actualHeight = img.clientWidth / ratio;
|
|
||||||
}
|
|
||||||
return { actualWidth, actualHeight };
|
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
naturalWidth: element.videoWidth,
|
||||||
|
naturalHeight: element.videoHeight,
|
||||||
|
displayWidth: element.clientWidth,
|
||||||
|
displayHeight: element.clientHeight,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return { actualWidth: 0, actualHeight: 0 };
|
const getContainedSize = (element: HTMLImageElement | HTMLVideoElement) => {
|
||||||
|
const { naturalWidth, naturalHeight, displayWidth, displayHeight } = getNaturalSize(element);
|
||||||
|
const ratio = naturalWidth / naturalHeight;
|
||||||
|
let actualWidth = displayHeight * ratio;
|
||||||
|
let actualHeight = displayHeight;
|
||||||
|
if (actualWidth > displayWidth) {
|
||||||
|
actualWidth = displayWidth;
|
||||||
|
actualHeight = displayWidth / ratio;
|
||||||
|
}
|
||||||
|
return { actualWidth, actualHeight };
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
@@ -157,69 +163,80 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_LIST_HEIGHT = 250;
|
||||||
|
|
||||||
const positionFaceSelector = () => {
|
const positionFaceSelector = () => {
|
||||||
if (!faceRect || !faceSelectorEl) {
|
if (!faceRect || !faceSelectorEl || !scrollableListEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = faceRect.getBoundingRect();
|
const gap = 15;
|
||||||
|
const padding = faceRect.padding ?? 0;
|
||||||
|
const rawBox = faceRect.getBoundingRect();
|
||||||
|
const faceBox = {
|
||||||
|
left: rawBox.left - padding,
|
||||||
|
top: rawBox.top - padding,
|
||||||
|
width: rawBox.width + padding * 2,
|
||||||
|
height: rawBox.height + padding * 2,
|
||||||
|
};
|
||||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||||
const selectorHeight = faceSelectorEl.offsetHeight;
|
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||||
|
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
|
||||||
|
const selectorHeight = listHeight + chromeHeight;
|
||||||
|
|
||||||
const spaceAbove = rect.top;
|
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
||||||
const spaceBelow = containerHeight - (rect.top + rect.height);
|
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
||||||
const spaceLeft = rect.left;
|
|
||||||
const spaceRight = containerWidth - (rect.left + rect.width);
|
|
||||||
|
|
||||||
let top, left;
|
const overlapArea = (position: { top: number; left: number }) => {
|
||||||
|
const selectorRight = position.left + selectorWidth;
|
||||||
|
const selectorBottom = position.top + selectorHeight;
|
||||||
|
const faceRight = faceBox.left + faceBox.width;
|
||||||
|
const faceBottom = faceBox.top + faceBox.height;
|
||||||
|
|
||||||
if (
|
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left));
|
||||||
spaceBelow >= selectorHeight ||
|
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top));
|
||||||
(spaceBelow >= spaceAbove && spaceBelow >= spaceLeft && spaceBelow >= spaceRight)
|
return overlapX * overlapY;
|
||||||
) {
|
};
|
||||||
top = rect.top + rect.height + 15;
|
|
||||||
left = rect.left;
|
const faceBottom = faceBox.top + faceBox.height;
|
||||||
} else if (
|
const faceRight = faceBox.left + faceBox.width;
|
||||||
spaceAbove >= selectorHeight ||
|
|
||||||
(spaceAbove >= spaceBelow && spaceAbove >= spaceLeft && spaceAbove >= spaceRight)
|
const positions = [
|
||||||
) {
|
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
|
||||||
top = rect.top - selectorHeight - 15;
|
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
|
||||||
left = rect.left;
|
{ top: clampTop(faceBox.top), left: clampLeft(faceRight + gap) },
|
||||||
} else if (
|
{ top: clampTop(faceBox.top), left: clampLeft(faceBox.left - selectorWidth - gap) },
|
||||||
spaceRight >= selectorWidth ||
|
];
|
||||||
(spaceRight >= spaceLeft && spaceRight >= spaceAbove && spaceRight >= spaceBelow)
|
|
||||||
) {
|
let bestPosition = positions[0];
|
||||||
top = rect.top;
|
let leastOverlap = Infinity;
|
||||||
left = rect.left + rect.width + 15;
|
|
||||||
} else {
|
for (const position of positions) {
|
||||||
top = rect.top;
|
const overlap = overlapArea(position);
|
||||||
left = rect.left - selectorWidth - 15;
|
if (overlap < leastOverlap) {
|
||||||
|
leastOverlap = overlap;
|
||||||
|
bestPosition = position;
|
||||||
|
if (overlap === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (left + selectorWidth > containerWidth) {
|
faceSelectorEl.style.top = `${bestPosition.top}px`;
|
||||||
left = containerWidth - selectorWidth - 15;
|
faceSelectorEl.style.left = `${bestPosition.left}px`;
|
||||||
}
|
scrollableListEl.style.height = `${listHeight}px`;
|
||||||
|
faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height };
|
||||||
if (left < 0) {
|
|
||||||
left = 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (top + selectorHeight > containerHeight) {
|
|
||||||
top = containerHeight - selectorHeight - 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (top < 0) {
|
|
||||||
top = 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
faceSelectorEl.style.top = `${top}px`;
|
|
||||||
faceSelectorEl.style.left = `${left}px`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (faceRect) {
|
const rect = faceRect;
|
||||||
faceRect.on('moving', positionFaceSelector);
|
if (rect) {
|
||||||
faceRect.on('scaling', positionFaceSelector);
|
rect.on('moving', positionFaceSelector);
|
||||||
|
rect.on('scaling', positionFaceSelector);
|
||||||
|
return () => {
|
||||||
|
rect.off('moving', positionFaceSelector);
|
||||||
|
rect.off('scaling', positionFaceSelector);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,49 +245,22 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
const faceBox = faceRect.getBoundingRect();
|
||||||
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
||||||
|
const { naturalWidth, naturalHeight } = getNaturalSize(htmlElement);
|
||||||
|
|
||||||
const offsetArea = {
|
const offsetX = (containerWidth - actualWidth) / 2;
|
||||||
width: (containerWidth - actualWidth) / 2,
|
const offsetY = (containerHeight - actualHeight) / 2;
|
||||||
height: (containerHeight - actualHeight) / 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const x1Coeff = (left - offsetArea.width) / actualWidth;
|
const scaleX = naturalWidth / actualWidth;
|
||||||
const y1Coeff = (top - offsetArea.height) / actualHeight;
|
const scaleY = naturalHeight / actualHeight;
|
||||||
const x2Coeff = (left + width - offsetArea.width) / actualWidth;
|
|
||||||
const y2Coeff = (top + height - offsetArea.height) / actualHeight;
|
|
||||||
|
|
||||||
// transpose to the natural image location
|
const x = Math.floor((faceBox.left - offsetX) * scaleX);
|
||||||
if (htmlElement instanceof HTMLImageElement) {
|
const y = Math.floor((faceBox.top - offsetY) * scaleY);
|
||||||
const x1 = x1Coeff * htmlElement.naturalWidth;
|
const width = Math.floor(faceBox.width * scaleX);
|
||||||
const y1 = y1Coeff * htmlElement.naturalHeight;
|
const height = Math.floor(faceBox.height * scaleY);
|
||||||
const x2 = x2Coeff * htmlElement.naturalWidth;
|
|
||||||
const y2 = y2Coeff * htmlElement.naturalHeight;
|
|
||||||
|
|
||||||
return {
|
return { imageWidth: naturalWidth, imageHeight: naturalHeight, x, y, width, height };
|
||||||
imageWidth: htmlElement.naturalWidth,
|
|
||||||
imageHeight: htmlElement.naturalHeight,
|
|
||||||
x: Math.floor(x1),
|
|
||||||
y: Math.floor(y1),
|
|
||||||
width: Math.floor(x2 - x1),
|
|
||||||
height: Math.floor(y2 - y1),
|
|
||||||
};
|
|
||||||
} else if (htmlElement instanceof HTMLVideoElement) {
|
|
||||||
const x1 = x1Coeff * htmlElement.videoWidth;
|
|
||||||
const y1 = y1Coeff * htmlElement.videoHeight;
|
|
||||||
const x2 = x2Coeff * htmlElement.videoWidth;
|
|
||||||
const y2 = y2Coeff * htmlElement.videoHeight;
|
|
||||||
|
|
||||||
return {
|
|
||||||
imageWidth: htmlElement.videoWidth,
|
|
||||||
imageHeight: htmlElement.videoHeight,
|
|
||||||
x: Math.floor(x1),
|
|
||||||
y: Math.floor(y1),
|
|
||||||
width: Math.floor(x2 - x1),
|
|
||||||
height: Math.floor(y2 - y1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const tagFace = async (person: PersonResponseDto) => {
|
const tagFace = async (person: PersonResponseDto) => {
|
||||||
@@ -308,13 +298,20 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute start-0 top-0">
|
<div
|
||||||
|
id="face-editor-data"
|
||||||
|
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
|
||||||
|
data-face-left={faceBoxPosition.left}
|
||||||
|
data-face-top={faceBoxPosition.top}
|
||||||
|
data-face-width={faceBoxPosition.width}
|
||||||
|
data-face-height={faceBoxPosition.height}
|
||||||
|
>
|
||||||
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
|
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="face-selector"
|
id="face-selector"
|
||||||
bind:this={faceSelectorEl}
|
bind:this={faceSelectorEl}
|
||||||
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800"
|
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800 transition-[top,left] duration-200 ease-out"
|
||||||
>
|
>
|
||||||
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
||||||
|
|
||||||
@@ -322,7 +319,7 @@
|
|||||||
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
|
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-62.5 overflow-y-auto mt-2">
|
<div bind:this={scrollableListEl} class="h-62.5 overflow-y-auto mt-2">
|
||||||
{#if filteredCandidates.length > 0}
|
{#if filteredCandidates.length > 0}
|
||||||
<div class="mt-2 rounded-lg">
|
<div class="mt-2 rounded-lg">
|
||||||
{#each filteredCandidates as person (person.id)}
|
{#each filteredCandidates as person (person.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user