mirror of
https://github.com/immich-app/immich.git
synced 2026-03-26 11:50:53 +03:00
feat(web): face overlay hover UX and face editor zoom preservation
Change-Id: I7164305d7764bec54fa06b8738cd97fd6a6a6964 refactor(web): use asset metadata for face editor image dimensions instead of DOM The face editor previously read naturalWidth/naturalHeight from the DOM element via a $effect + load event listener. This was fragile on slow hardware (ARM CI) because imgRef changes as AdaptiveImage progresses through quality levels, and the DOM element's natural dimensions could be 0 during transitions. Now the face editor receives imageSize as a prop from the parent, derived from the asset's metadata dimensions which are always available immediately. Change-Id: Id4c3a59110feff4c50f429bbd744eac46a6a6964 Change-Id: I7164305d7764bec54fa06b8738cd97fd6a6a6964
This commit is contained in:
@@ -20,7 +20,7 @@ export {
|
|||||||
toColumnarFormat,
|
toColumnarFormat,
|
||||||
} from './timeline/rest-response';
|
} 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';
|
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
AssetVisibility,
|
AssetVisibility,
|
||||||
UserAvatarColor,
|
UserAvatarColor,
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
|
type AssetFaceWithoutPersonResponseDto,
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
type ExifResponseDto,
|
type ExifResponseDto,
|
||||||
|
type PersonWithFacesResponseDto,
|
||||||
type TimeBucketAssetResponseDto,
|
type TimeBucketAssetResponseDto,
|
||||||
type TimeBucketsResponseDto,
|
type TimeBucketsResponseDto,
|
||||||
type UserResponseDto,
|
type UserResponseDto,
|
||||||
@@ -284,7 +286,16 @@ const createDefaultOwner = (ownerId: string) => {
|
|||||||
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
* Convert a TimelineAssetConfig to a full AssetResponseDto
|
||||||
* This matches the response from GET /api/assets/:id
|
* 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();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
// Default owner if not provided
|
// Default owner if not provided
|
||||||
@@ -338,8 +349,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons
|
|||||||
exifInfo,
|
exifInfo,
|
||||||
livePhotoVideoId: asset.livePhotoVideoId,
|
livePhotoVideoId: asset.livePhotoVideoId,
|
||||||
tags: [],
|
tags: [],
|
||||||
people: [],
|
people: faceData?.people ?? [],
|
||||||
unassignedFaces: [],
|
unassignedFaces: faceData?.unassignedFaces ?? [],
|
||||||
stack: asset.stack,
|
stack: asset.stack,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
hasMetadata: true,
|
hasMetadata: true,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import type { AssetFaceResponseDto, AssetResponseDto, PersonWithFacesResponseDto, SourceType } from '@immich/sdk';
|
||||||
import { BrowserContext } from '@playwright/test';
|
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
|
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
|
||||||
const MINIMAL_MP4_BASE64 =
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,16 +10,21 @@ import { assetViewerUtils } from '../timeline/utils';
|
|||||||
import { setupAssetViewerFixture } from './utils';
|
import { setupAssetViewerFixture } from './utils';
|
||||||
|
|
||||||
const waitForSelectorTransition = async (page: Page) => {
|
const waitForSelectorTransition = async (page: Page) => {
|
||||||
await page.waitForFunction(
|
await expect(page.locator('#face-editor-data')).toHaveAttribute('data-face-width', /^[1-9]/, { timeout: 10_000 });
|
||||||
() => {
|
await page.locator('#face-selector').evaluate(
|
||||||
const selector = document.querySelector('#face-selector') as HTMLElement | null;
|
(el) =>
|
||||||
if (!selector) {
|
new Promise<void>((resolve) => {
|
||||||
return false;
|
requestAnimationFrame(() =>
|
||||||
}
|
requestAnimationFrame(() => {
|
||||||
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
|
const animations = el.getAnimations();
|
||||||
},
|
if (animations.length === 0) {
|
||||||
undefined,
|
resolve();
|
||||||
{ timeout: 1000, polling: 50 },
|
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.down();
|
||||||
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
await page.waitForTimeout(300);
|
await waitForSelectorTransition(page);
|
||||||
};
|
};
|
||||||
|
|
||||||
test('Face editor opens with person list', async ({ page }) => {
|
test('Face editor opens with person list', async ({ page }) => {
|
||||||
@@ -149,7 +154,7 @@ test.describe('face-editor', () => {
|
|||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
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);
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
await openFaceEditor(page, asset);
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
@@ -163,8 +168,15 @@ test.describe('face-editor', () => {
|
|||||||
await expect(page.locator('#face-editor')).toBeHidden();
|
await expect(page.locator('#face-editor')).toBeHidden();
|
||||||
|
|
||||||
expect(faceCreateCapture.requests).toHaveLength(1);
|
expect(faceCreateCapture.requests).toHaveLength(1);
|
||||||
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
|
const request = faceCreateCapture.requests[0];
|
||||||
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
|
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 }) => {
|
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.left).toBeGreaterThan(beforeDrag.left + 50);
|
||||||
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
340
e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts
Normal file
340
e2e/src/ui/specs/asset-viewer/face-overlay.e2e-spec.ts
Normal file
@@ -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<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[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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1275,6 +1275,7 @@
|
|||||||
"hide_schema": "Hide schema",
|
"hide_schema": "Hide schema",
|
||||||
"hide_text_recognition": "Hide text recognition",
|
"hide_text_recognition": "Hide text recognition",
|
||||||
"hide_unnamed_people": "Hide unnamed people",
|
"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_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_err_local": "Can not add local assets to albums yet, skipping",
|
||||||
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
|
||||||
|
|||||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
onError?: () => void;
|
onError?: () => void;
|
||||||
ref?: HTMLDivElement;
|
ref?: HTMLDivElement;
|
||||||
imgRef?: HTMLImageElement;
|
imgRef?: HTMLImageElement;
|
||||||
|
imgNaturalSize?: Size;
|
||||||
|
imgScaledSize?: Size;
|
||||||
backdrop?: Snippet;
|
backdrop?: Snippet;
|
||||||
overlays?: Snippet;
|
overlays?: Snippet;
|
||||||
};
|
};
|
||||||
@@ -31,6 +33,10 @@
|
|||||||
ref = $bindable(),
|
ref = $bindable(),
|
||||||
// eslint-disable-next-line no-useless-assignment
|
// eslint-disable-next-line no-useless-assignment
|
||||||
imgRef = $bindable(),
|
imgRef = $bindable(),
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
|
imgNaturalSize = $bindable(),
|
||||||
|
// eslint-disable-next-line no-useless-assignment
|
||||||
|
imgScaledSize = $bindable(),
|
||||||
asset,
|
asset,
|
||||||
sharedLink,
|
sharedLink,
|
||||||
objectFit = 'contain',
|
objectFit = 'contain',
|
||||||
@@ -98,9 +104,21 @@
|
|||||||
return { width: 1, height: 1 };
|
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 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 {
|
return {
|
||||||
width: width + 'px',
|
width: width + 'px',
|
||||||
height: height + 'px',
|
height: height + 'px',
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
previousAsset?: AssetResponseDto;
|
previousAsset?: AssetResponseDto;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
cursor: AssetCursor;
|
cursor: AssetCursor;
|
||||||
showNavigation?: boolean;
|
showNavigation?: boolean;
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
onUndoDelete?: OnUndoDelete;
|
onUndoDelete?: OnUndoDelete;
|
||||||
onClose?: (asset: AssetResponseDto) => void;
|
onClose?: (asset: AssetResponseDto) => void;
|
||||||
onRandom?: () => Promise<{ id: string } | undefined>;
|
onRandom?: () => Promise<{ id: string } | undefined>;
|
||||||
}
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
cursor,
|
cursor,
|
||||||
@@ -176,6 +176,7 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
activityManager.reset();
|
activityManager.reset();
|
||||||
assetViewerManager.closeEditor();
|
assetViewerManager.closeEditor();
|
||||||
|
isFaceEditMode.value = false;
|
||||||
syncAssetViewerOpenClass(false);
|
syncAssetViewerOpenClass(false);
|
||||||
preloadManager.destroy();
|
preloadManager.destroy();
|
||||||
});
|
});
|
||||||
@@ -290,6 +291,9 @@
|
|||||||
|
|
||||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
||||||
|
if (isMouseOver) {
|
||||||
|
isFaceEditMode.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreAction = (action: Action) => {
|
const handlePreAction = (action: Action) => {
|
||||||
@@ -358,15 +362,18 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshOcr = async () => {
|
||||||
|
ocrManager.clear();
|
||||||
|
if (sharedLink) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ocrManager.getAssetOcr(asset.id);
|
||||||
|
};
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
await refreshStack();
|
await refreshStack();
|
||||||
ocrManager.clear();
|
await refreshOcr();
|
||||||
if (!sharedLink) {
|
|
||||||
if (previewStackedAsset) {
|
|
||||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
|
||||||
}
|
|
||||||
await ocrManager.getAssetOcr(asset.id);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -375,6 +382,12 @@
|
|||||||
untrack(() => handlePromiseError(refresh()));
|
untrack(() => handlePromiseError(refresh()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
previewStackedAsset;
|
||||||
|
untrack(() => ocrManager.clear());
|
||||||
|
});
|
||||||
|
|
||||||
let lastCursor = $state<AssetCursor>();
|
let lastCursor = $state<AssetCursor>();
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -460,7 +473,7 @@
|
|||||||
|
|
||||||
<section
|
<section
|
||||||
id="immich-asset-viewer"
|
id="immich-asset-viewer"
|
||||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black touch-none"
|
||||||
use:focusTrap
|
use:focusTrap
|
||||||
bind:this={assetViewerHtmlElement}
|
bind:this={assetViewerHtmlElement}
|
||||||
>
|
>
|
||||||
@@ -612,6 +625,7 @@
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
cursor.current = stackedAsset;
|
cursor.current = stackedAsset;
|
||||||
previewStackedAsset = undefined;
|
previewStackedAsset = undefined;
|
||||||
|
isFaceEditMode.value = false;
|
||||||
}}
|
}}
|
||||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||||
readonly
|
readonly
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
import { timeToLoadTheMap } from '$lib/constants';
|
import { timeToLoadTheMap } from '$lib/constants';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-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 { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||||
import { Route } from '$lib/route';
|
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 { boundingBoxesArray } from '$lib/stores/people.store';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences, user } from '$lib/stores/user.store';
|
||||||
@@ -49,15 +50,15 @@
|
|||||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
currentAlbum?: AlbumResponseDto | null;
|
currentAlbum?: AlbumResponseDto | null;
|
||||||
}
|
};
|
||||||
|
|
||||||
let { asset, currentAlbum = null }: Props = $props();
|
let { asset, currentAlbum = null }: Props = $props();
|
||||||
|
|
||||||
let showAssetPath = $state(false);
|
let showAssetPath = $state(false);
|
||||||
let showEditFaces = $state(false);
|
let showEditFaces = $derived(isEditFacesPanelOpen.value);
|
||||||
let isOwner = $derived($user?.id === asset.ownerId);
|
let isOwner = $derived($user?.id === asset.ownerId);
|
||||||
let people = $derived(asset.people || []);
|
let people = $derived(asset.people || []);
|
||||||
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
||||||
@@ -106,7 +107,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showEditFaces = false;
|
isEditFacesPanelOpen.value = false;
|
||||||
previousId = asset.id;
|
previousId = asset.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,7 +123,8 @@
|
|||||||
|
|
||||||
const handleRefreshPeople = async () => {
|
const handleRefreshPeople = async () => {
|
||||||
asset = await getAssetInfo({ id: asset.id });
|
asset = await getAssetInfo({ id: asset.id });
|
||||||
showEditFaces = false;
|
eventManager.emit('AssetUpdate', asset);
|
||||||
|
isEditFacesPanelOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||||
@@ -219,7 +221,7 @@
|
|||||||
shape="round"
|
shape="round"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onclick={() => (showEditFaces = true)}
|
onclick={() => (isEditFacesPanelOpen.value = true)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -228,13 +230,14 @@
|
|||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
{#each people as person, index (person.id)}
|
{#each people as person, index (person.id)}
|
||||||
{#if showingHiddenPeople || !person.isHidden}
|
{#if showingHiddenPeople || !person.isHidden}
|
||||||
|
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
|
||||||
<a
|
<a
|
||||||
class="w-22"
|
class="group w-22 outline-none"
|
||||||
href={Route.viewPerson(person, { previousRoute })}
|
href={Route.viewPerson(person, { previousRoute })}
|
||||||
onfocus={() => ($boundingBoxesArray = people[index].faces)}
|
onfocus={() => ($boundingBoxesArray = people[index].faces)}
|
||||||
onblur={() => ($boundingBoxesArray = [])}
|
onblur={() => ($boundingBoxesArray = [])}
|
||||||
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
|
onpointerover={() => ($boundingBoxesArray = people[index].faces)}
|
||||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
@@ -246,6 +249,8 @@
|
|||||||
widthStyle="90px"
|
widthStyle="90px"
|
||||||
heightStyle="90px"
|
heightStyle="90px"
|
||||||
hidden={person.isHidden}
|
hidden={person.isHidden}
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class="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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
||||||
@@ -574,7 +579,7 @@
|
|||||||
<PersonSidePanel
|
<PersonSidePanel
|
||||||
assetId={asset.id}
|
assetId={asset.id}
|
||||||
assetType={asset.type}
|
assetType={asset.type}
|
||||||
onClose={() => (showEditFaces = false)}
|
onClose={() => (isEditFacesPanelOpen.value = false)}
|
||||||
onRefresh={handleRefreshPeople}
|
onRefresh={handleRefreshPeople}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { scaleFaceRectOnResize, type ResizeContext } from '$lib/utils/people-utils';
|
||||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||||
@@ -12,17 +14,19 @@
|
|||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
imageSize: Size;
|
||||||
containerWidth: number;
|
containerWidth: number;
|
||||||
containerHeight: number;
|
containerHeight: number;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
let { imageSize, containerWidth, containerHeight, assetId }: Props = $props();
|
||||||
|
|
||||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||||
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
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();
|
||||||
@@ -32,6 +36,9 @@
|
|||||||
|
|
||||||
let searchTerm = $state('');
|
let searchTerm = $state('');
|
||||||
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
let faceBoxPosition = $state({ left: 0, top: 0, width: 0, height: 0 });
|
||||||
|
let userMovedRect = false;
|
||||||
|
let previousMetrics: ResizeContext | null = null;
|
||||||
|
let panModifierHeld = $state(false);
|
||||||
|
|
||||||
let filteredCandidates = $derived(
|
let filteredCandidates = $derived(
|
||||||
searchTerm
|
searchTerm
|
||||||
@@ -53,11 +60,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setupCanvas = () => {
|
const setupCanvas = () => {
|
||||||
if (!canvasEl || !htmlElement) {
|
if (!canvasEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas = new Canvas(canvasEl);
|
canvas = new Canvas(canvasEl, { width: containerWidth, height: containerHeight });
|
||||||
|
canvas.selection = false;
|
||||||
configureControlStyle();
|
configureControlStyle();
|
||||||
|
|
||||||
// eslint-disable-next-line tscompat/tscompat
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
@@ -75,66 +83,103 @@
|
|||||||
|
|
||||||
canvas.add(faceRect);
|
canvas.add(faceRect);
|
||||||
canvas.setActiveObject(faceRect);
|
canvas.setActiveObject(faceRect);
|
||||||
setDefaultFaceRectanglePosition(faceRect);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
setupCanvas();
|
void getPeople();
|
||||||
await getPeople();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageContentMetrics = $derived.by(() => {
|
|
||||||
const natural = getNaturalSize(htmlElement);
|
|
||||||
const container = { width: containerWidth, height: containerHeight };
|
|
||||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
|
||||||
return {
|
|
||||||
contentWidth,
|
|
||||||
contentHeight,
|
|
||||||
offsetX: (containerWidth - contentWidth) / 2,
|
|
||||||
offsetY: (containerHeight - contentHeight) / 2,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
|
||||||
const { offsetX, offsetY } = imageContentMetrics;
|
|
||||||
|
|
||||||
faceRect.set({
|
|
||||||
top: offsetY + 200,
|
|
||||||
left: offsetX + 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
faceRect.setCoords();
|
|
||||||
positionFaceSelector();
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.setDimensions({
|
const upperCanvas = canvas.upperCanvasEl;
|
||||||
width: containerWidth,
|
const controller = new AbortController();
|
||||||
height: containerHeight,
|
const { signal } = controller;
|
||||||
});
|
|
||||||
|
|
||||||
if (!faceRect) {
|
const stopIfOnTarget = (event: PointerEvent) => {
|
||||||
|
if (canvas?.findTarget(event).target) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canvas.findTarget(event).target) {
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (faceRect) {
|
||||||
|
event.stopPropagation();
|
||||||
|
const pointer = canvas.getScenePoint(event);
|
||||||
|
faceRect.set({ left: pointer.x, top: pointer.y });
|
||||||
|
faceRect.setCoords();
|
||||||
|
userMovedRect = true;
|
||||||
|
canvas.renderAll();
|
||||||
|
positionFaceSelector();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
upperCanvas.addEventListener('pointerdown', handlePointerDown, { signal });
|
||||||
|
upperCanvas.addEventListener('pointermove', stopIfOnTarget, { signal });
|
||||||
|
upperCanvas.addEventListener('pointerup', stopIfOnTarget, { signal });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageContentMetrics = $derived.by(() => {
|
||||||
|
if (imageSize.width === 0 || imageSize.height === 0) {
|
||||||
|
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||||
|
}
|
||||||
|
return computeContentMetrics(imageSize, { width: containerWidth, height: containerHeight });
|
||||||
|
});
|
||||||
|
|
||||||
|
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
||||||
|
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||||
|
|
||||||
|
faceRect.set({
|
||||||
|
top: offsetY + contentHeight / 2 - 56,
|
||||||
|
left: offsetX + contentWidth / 2 - 56,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { offsetX, offsetY, contentWidth } = imageContentMetrics;
|
||||||
|
|
||||||
|
if (contentWidth === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isFaceRectIntersectingCanvas(faceRect, canvas)) {
|
const isFirstRun = previousMetrics === null;
|
||||||
|
|
||||||
|
if (isFirstRun && !canvas) {
|
||||||
|
setupCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canvas || !faceRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFirstRun) {
|
||||||
|
canvas.setDimensions({ width: containerWidth, height: containerHeight });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFirstRun && userMovedRect && previousMetrics) {
|
||||||
|
faceRect.set(scaleFaceRectOnResize(faceRect, previousMetrics, { contentWidth, offsetX, offsetY }));
|
||||||
|
} else {
|
||||||
setDefaultFaceRectanglePosition(faceRect);
|
setDefaultFaceRectanglePosition(faceRect);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => {
|
faceRect.setCoords();
|
||||||
const faceBox = faceRect.getBoundingRect();
|
previousMetrics = { contentWidth, offsetX, offsetY };
|
||||||
return !(
|
canvas.renderAll();
|
||||||
0 > faceBox.left + faceBox.width ||
|
positionFaceSelector();
|
||||||
0 > faceBox.top + faceBox.height ||
|
});
|
||||||
canvas.width < faceBox.left ||
|
|
||||||
canvas.height < faceBox.top
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
isFaceEditMode.value = false;
|
isFaceEditMode.value = false;
|
||||||
@@ -164,11 +209,15 @@
|
|||||||
const gap = 15;
|
const gap = 15;
|
||||||
const padding = faceRect.padding ?? 0;
|
const padding = faceRect.padding ?? 0;
|
||||||
const rawBox = faceRect.getBoundingRect();
|
const rawBox = faceRect.getBoundingRect();
|
||||||
|
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||||
const faceBox = {
|
const faceBox = {
|
||||||
left: rawBox.left - padding,
|
left: (rawBox.left - padding) * currentZoom + currentPositionX,
|
||||||
top: rawBox.top - padding,
|
top: (rawBox.top - padding) * currentZoom + currentPositionY,
|
||||||
width: rawBox.width + padding * 2,
|
width: (rawBox.width + padding * 2) * currentZoom,
|
||||||
height: rawBox.height + padding * 2,
|
height: (rawBox.height + padding * 2) * currentZoom,
|
||||||
};
|
};
|
||||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||||
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||||
@@ -178,20 +227,21 @@
|
|||||||
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
||||||
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
||||||
|
|
||||||
const overlapArea = (position: { top: number; left: number }) => {
|
const faceRight = faceBox.left + faceBox.width;
|
||||||
const selectorRight = position.left + selectorWidth;
|
const faceBottom = faceBox.top + faceBox.height;
|
||||||
const selectorBottom = position.top + selectorHeight;
|
|
||||||
const faceRight = faceBox.left + faceBox.width;
|
|
||||||
const faceBottom = faceBox.top + faceBox.height;
|
|
||||||
|
|
||||||
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left));
|
const overlapArea = (position: { top: number; left: number }) => {
|
||||||
const overlapY = Math.max(0, Math.min(selectorBottom, faceBottom) - Math.max(position.top, faceBox.top));
|
const overlapX = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(position.left + selectorWidth, faceRight) - Math.max(position.left, faceBox.left),
|
||||||
|
);
|
||||||
|
const overlapY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(position.top + selectorHeight, faceBottom) - Math.max(position.top, faceBox.top),
|
||||||
|
);
|
||||||
return overlapX * overlapY;
|
return overlapX * overlapY;
|
||||||
};
|
};
|
||||||
|
|
||||||
const faceBottom = faceBox.top + faceBox.height;
|
|
||||||
const faceRight = faceBox.left + faceBox.width;
|
|
||||||
|
|
||||||
const positions = [
|
const positions = [
|
||||||
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
|
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
|
||||||
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
|
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
|
||||||
@@ -213,45 +263,139 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
faceSelectorEl.style.top = `${bestPosition.top}px`;
|
const containerRect = containerEl?.getBoundingClientRect();
|
||||||
faceSelectorEl.style.left = `${bestPosition.left}px`;
|
const offsetTop = containerRect?.top ?? 0;
|
||||||
|
const offsetLeft = containerRect?.left ?? 0;
|
||||||
|
faceSelectorEl.style.top = `${bestPosition.top + offsetTop}px`;
|
||||||
|
faceSelectorEl.style.left = `${bestPosition.left + offsetLeft}px`;
|
||||||
scrollableListEl.style.height = `${listHeight}px`;
|
scrollableListEl.style.height = `${listHeight}px`;
|
||||||
faceBoxPosition = { left: faceBox.left, top: faceBox.top, width: faceBox.width, height: faceBox.height };
|
faceBoxPosition = faceBox;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||||
|
canvas.setViewportTransform([currentZoom, 0, 0, currentZoom, currentPositionX, currentPositionY]);
|
||||||
|
canvas.renderAll();
|
||||||
|
positionFaceSelector();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const rect = faceRect;
|
const rect = faceRect;
|
||||||
if (rect) {
|
if (rect) {
|
||||||
rect.on('moving', positionFaceSelector);
|
const onUserMove = () => {
|
||||||
rect.on('scaling', positionFaceSelector);
|
userMovedRect = true;
|
||||||
|
positionFaceSelector();
|
||||||
|
};
|
||||||
|
rect.on('moving', onUserMove);
|
||||||
|
rect.on('scaling', onUserMove);
|
||||||
return () => {
|
return () => {
|
||||||
rect.off('moving', positionFaceSelector);
|
rect.off('moving', onUserMove);
|
||||||
rect.off('scaling', positionFaceSelector);
|
rect.off('scaling', onUserMove);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||||
|
const panModifierKey = isMac ? 'Meta' : 'Control';
|
||||||
|
const panModifierLabel = isMac ? '⌘' : 'Ctrl';
|
||||||
|
const isZoomed = $derived(assetViewerManager.zoom > 1);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!containerEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = containerEl;
|
||||||
|
const parent = element.parentElement;
|
||||||
|
|
||||||
|
const activate = () => {
|
||||||
|
panModifierHeld = true;
|
||||||
|
element.style.pointerEvents = 'none';
|
||||||
|
if (parent) {
|
||||||
|
parent.style.cursor = 'move';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deactivate = () => {
|
||||||
|
panModifierHeld = false;
|
||||||
|
element.style.pointerEvents = '';
|
||||||
|
if (parent) {
|
||||||
|
parent.style.cursor = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === panModifierKey) {
|
||||||
|
activate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === panModifierKey) {
|
||||||
|
deactivate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
document.addEventListener('keyup', onKeyUp);
|
||||||
|
window.addEventListener('blur', deactivate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
document.removeEventListener('keyup', onKeyUp);
|
||||||
|
window.removeEventListener('blur', deactivate);
|
||||||
|
deactivate();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const trapEvents = (node: HTMLElement) => {
|
||||||
|
const stop = (e: Event) => e.stopPropagation();
|
||||||
|
const eventTypes = ['keydown', 'pointerdown', 'pointermove', 'pointerup'] as const;
|
||||||
|
for (const type of eventTypes) {
|
||||||
|
node.addEventListener(type, stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to body so the selector isn't affected by the zoom transform on the container
|
||||||
|
document.body.append(node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
for (const type of eventTypes) {
|
||||||
|
node.removeEventListener(type, stop);
|
||||||
|
}
|
||||||
|
node.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const getFaceCroppedCoordinates = () => {
|
const getFaceCroppedCoordinates = () => {
|
||||||
if (!faceRect || !htmlElement) {
|
if (!faceRect || imageSize.width === 0 || imageSize.height === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
const scaledWidth = faceRect.getScaledWidth();
|
||||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
const scaledHeight = faceRect.getScaledHeight();
|
||||||
const natural = getNaturalSize(htmlElement);
|
|
||||||
|
|
||||||
const scaleX = natural.width / contentWidth;
|
const imageRect = mapContentRectToNatural(
|
||||||
const scaleY = natural.height / contentHeight;
|
{
|
||||||
const imageX = (left - offsetX) * scaleX;
|
left: faceRect.left - scaledWidth / 2,
|
||||||
const imageY = (top - offsetY) * scaleY;
|
top: faceRect.top - scaledHeight / 2,
|
||||||
|
width: scaledWidth,
|
||||||
|
height: scaledHeight,
|
||||||
|
},
|
||||||
|
imageContentMetrics,
|
||||||
|
imageSize,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
imageWidth: natural.width,
|
imageWidth: imageSize.width,
|
||||||
imageHeight: natural.height,
|
imageHeight: imageSize.height,
|
||||||
x: Math.floor(imageX),
|
x: Math.floor(imageRect.left),
|
||||||
y: Math.floor(imageY),
|
y: Math.floor(imageRect.top),
|
||||||
width: Math.floor(width * scaleX),
|
width: Math.floor(imageRect.width),
|
||||||
height: Math.floor(height * scaleY),
|
height: Math.floor(imageRect.height),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,10 +426,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
await assetViewingStore.setAssetId(assetId);
|
await assetViewingStore.setAssetId(assetId);
|
||||||
|
isFaceEditMode.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, 'Error tagging face');
|
handleError(error, 'Error tagging face');
|
||||||
} finally {
|
|
||||||
isFaceEditMode.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -294,8 +437,8 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="face-editor-data"
|
id="face-editor-data"
|
||||||
|
bind:this={containerEl}
|
||||||
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
|
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"
|
||||||
data-overlay-interactive
|
|
||||||
data-face-left={faceBoxPosition.left}
|
data-face-left={faceBoxPosition.left}
|
||||||
data-face-top={faceBoxPosition.top}
|
data-face-top={faceBoxPosition.top}
|
||||||
data-face-width={faceBoxPosition.width}
|
data-face-width={faceBoxPosition.width}
|
||||||
@@ -306,7 +449,9 @@
|
|||||||
<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 transition-[top,left] duration-200 ease-out"
|
class="fixed z-20 w-[min(200px,45vw)] min-w-48 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"
|
||||||
|
use:trapEvents
|
||||||
|
onwheel={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
||||||
|
|
||||||
@@ -347,4 +492,15 @@
|
|||||||
|
|
||||||
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
|
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if isZoomed && !panModifierHeld}
|
||||||
|
<div
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
class="absolute bottom-4 inset-s-1/2 -translate-x-1/2 pointer-events-none z-10"
|
||||||
|
>
|
||||||
|
<p class="bg-black/60 text-white text-xs px-3 py-1.5 rounded-full whitespace-nowrap">
|
||||||
|
{$t('hold_key_to_pan', { values: { key: panModifierLabel } })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { 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 { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-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 { handleError } from '$lib/utils/handle-error';
|
||||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
@@ -67,13 +67,10 @@
|
|||||||
height: containerHeight,
|
height: containerHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
const overlaySize = $derived.by((): Size => {
|
let imageDimensions = $state<Size>({ width: 0, height: 0 });
|
||||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
|
||||||
return { 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) : []);
|
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlaySize) : []);
|
||||||
|
|
||||||
@@ -97,12 +94,6 @@
|
|||||||
|
|
||||||
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
|
|
||||||
onZoom();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO move to action + command palette
|
// TODO move to action + command palette
|
||||||
const onCopyShortcut = (event: KeyboardEvent) => {
|
const onCopyShortcut = (event: KeyboardEvent) => {
|
||||||
if (globalThis.getSelection()?.type === 'Range') {
|
if (globalThis.getSelection()?.type === 'Range') {
|
||||||
@@ -147,46 +138,22 @@
|
|||||||
|
|
||||||
const faceToNameMap = $derived.by(() => {
|
const faceToNameMap = $derived.by(() => {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
const map = new Map<Faces, string>();
|
const map = new Map<Faces, string | undefined>();
|
||||||
for (const person of asset.people ?? []) {
|
for (const person of asset.people ?? []) {
|
||||||
for (const face of person.faces ?? []) {
|
for (const face of person.faces ?? []) {
|
||||||
map.set(face, person.name);
|
map.set(face, person.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const face of asset.unassignedFaces ?? []) {
|
||||||
|
map.set(face, undefined);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Array needed for indexed access in the template (faces[index])
|
||||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||||
|
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
|
||||||
const handleImageMouseMove = (event: MouseEvent) => {
|
const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
|
||||||
$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 = [];
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AssetViewerEvents {onCopy} {onZoom} />
|
<AssetViewerEvents {onCopy} {onZoom} />
|
||||||
@@ -207,8 +174,6 @@
|
|||||||
bind:clientHeight={containerHeight}
|
bind:clientHeight={containerHeight}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
ondblclick={onZoom}
|
ondblclick={onZoom}
|
||||||
onmousemove={handleImageMouseMove}
|
|
||||||
onmouseleave={handleImageMouseLeave}
|
|
||||||
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
|
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
|
||||||
{...useSwipe((event) => onSwipe?.(event))}
|
{...useSwipe((event) => onSwipe?.(event))}
|
||||||
>
|
>
|
||||||
@@ -227,6 +192,8 @@
|
|||||||
onReady?.();
|
onReady?.();
|
||||||
}}
|
}}
|
||||||
bind:imgRef={assetViewerManager.imgRef}
|
bind:imgRef={assetViewerManager.imgRef}
|
||||||
|
bind:imgNaturalSize={imageDimensions}
|
||||||
|
bind:imgScaledSize={scaledDimensions}
|
||||||
bind:ref={adaptiveImage}
|
bind:ref={adaptiveImage}
|
||||||
>
|
>
|
||||||
{#snippet backdrop()}
|
{#snippet backdrop()}
|
||||||
@@ -238,20 +205,40 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet overlays()}
|
{#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}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute pointer-events-auto outline-none rounded-lg"
|
||||||
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
|
aria-label="{$t('person')}: {name ?? $t('unknown')}"
|
||||||
|
onpointerenter={() => ($boundingBoxesArray = [face])}
|
||||||
|
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||||
|
></div>
|
||||||
|
{/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}
|
||||||
<div
|
<div
|
||||||
class="absolute border-solid border-white border-3 rounded-lg"
|
class="absolute border-solid border-white border-3 rounded-lg pointer-events-none"
|
||||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||||
></div>
|
>
|
||||||
{#if faceToNameMap.get($boundingBoxesArray[index])}
|
{#if name}
|
||||||
<div
|
<div
|
||||||
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
|
aria-hidden="true"
|
||||||
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
|
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
|
||||||
boundingbox.width}px; transform: translateX(-100%);"
|
style="top: {boundingbox.height + 4}px; right: 0;"
|
||||||
>
|
>
|
||||||
{faceToNameMap.get($boundingBoxesArray[index])}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||||
@@ -261,6 +248,6 @@
|
|||||||
</AdaptiveImage>
|
</AdaptiveImage>
|
||||||
|
|
||||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
<FaceEditor imageSize={imageDimensions} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,14 +11,16 @@
|
|||||||
videoViewerVolume,
|
videoViewerVolume,
|
||||||
} from '$lib/stores/preferences.store';
|
} from '$lib/stores/preferences.store';
|
||||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||||
|
import type { Size } from '$lib/utils/container-utils';
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
import { AssetMediaSize } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
imageSize: Size;
|
||||||
loopVideo: boolean;
|
loopVideo: boolean;
|
||||||
cacheKey: string | null;
|
cacheKey: string | null;
|
||||||
playOriginalVideo: boolean;
|
playOriginalVideo: boolean;
|
||||||
@@ -27,10 +29,11 @@
|
|||||||
onVideoEnded?: () => void;
|
onVideoEnded?: () => void;
|
||||||
onVideoStarted?: () => void;
|
onVideoStarted?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
assetId,
|
assetId,
|
||||||
|
imageSize,
|
||||||
loopVideo,
|
loopVideo,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
playOriginalVideo,
|
playOriginalVideo,
|
||||||
@@ -173,7 +176,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if isFaceEditMode.value}
|
{#if isFaceEditMode.value}
|
||||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
<FaceEditor {imageSize} {containerWidth} {containerHeight} {assetId} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { ProjectionType } from '$lib/constants';
|
import { ProjectionType } from '$lib/constants';
|
||||||
import type { AssetResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
assetId?: string;
|
assetId?: string;
|
||||||
projectionType: string | null | undefined;
|
projectionType: string | null | undefined;
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
onNextAsset?: () => void;
|
onNextAsset?: () => void;
|
||||||
onVideoEnded?: () => void;
|
onVideoEnded?: () => void;
|
||||||
onVideoStarted?: () => void;
|
onVideoStarted?: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
asset,
|
asset,
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
{loopVideo}
|
{loopVideo}
|
||||||
{cacheKey}
|
{cacheKey}
|
||||||
assetId={effectiveAssetId}
|
assetId={effectiveAssetId}
|
||||||
|
imageSize={{ width: asset.width ?? 1, height: asset.height ?? 1 }}
|
||||||
{playOriginalVideo}
|
{playOriginalVideo}
|
||||||
{onPreviousAsset}
|
{onPreviousAsset}
|
||||||
{onNextAsset}
|
{onNextAsset}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
circle?: boolean;
|
circle?: boolean;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
border?: boolean;
|
border?: boolean;
|
||||||
|
highlighted?: boolean;
|
||||||
hiddenIconClass?: string;
|
hiddenIconClass?: string;
|
||||||
class?: ClassValue;
|
class?: ClassValue;
|
||||||
brokenAssetClass?: ClassValue;
|
brokenAssetClass?: ClassValue;
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
circle = false,
|
circle = false,
|
||||||
hidden = false,
|
hidden = false,
|
||||||
border = false,
|
border = false,
|
||||||
|
highlighted = false,
|
||||||
hiddenIconClass = 'text-white',
|
hiddenIconClass = 'text-white',
|
||||||
onComplete = undefined,
|
onComplete = undefined,
|
||||||
class: imageClass = '',
|
class: imageClass = '',
|
||||||
@@ -83,6 +85,10 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if highlighted}
|
||||||
|
<span class={['absolute inset-0 pointer-events-none border-2 border-white', sharedClasses]} {style}></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if hidden}
|
{#if hidden}
|
||||||
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
<div class="absolute start-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||||
<!-- TODO fix `title` type -->
|
<!-- TODO fix `title` type -->
|
||||||
|
|||||||
@@ -27,12 +27,12 @@
|
|||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
assetType: AssetTypeEnum;
|
assetType: AssetTypeEnum;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
let { assetId, assetType, onClose, onRefresh }: Props = $props();
|
let { assetId, assetType, onClose, onRefresh }: Props = $props();
|
||||||
|
|
||||||
@@ -58,6 +58,8 @@
|
|||||||
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
const thumbnailWidth = '90px';
|
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() {
|
async function loadPeople() {
|
||||||
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner);
|
||||||
@@ -226,14 +228,16 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each peopleWithFaces as face, index (face.id)}
|
{#each peopleWithFaces as face, index (face.id)}
|
||||||
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
|
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
|
||||||
|
{@const isHighlighted = $boundingBoxesArray.some((f) => f.id === face.id)}
|
||||||
<div class="relative h-29 w-24">
|
<div class="relative h-29 w-24">
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex={index}
|
tabindex={index}
|
||||||
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
|
data-testid="face-thumbnail"
|
||||||
|
class="group absolute inset-s-0 top-0 h-22.5 w-22.5 cursor-default outline-none"
|
||||||
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||||
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
onpointerover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
onpointerleave={() => ($boundingBoxesArray = [])}
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
{#if selectedPersonToCreate[face.id]}
|
{#if selectedPersonToCreate[face.id]}
|
||||||
@@ -245,6 +249,8 @@
|
|||||||
title={$t('new_person')}
|
title={$t('new_person')}
|
||||||
widthStyle={thumbnailWidth}
|
widthStyle={thumbnailWidth}
|
||||||
heightStyle={thumbnailWidth}
|
heightStyle={thumbnailWidth}
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class={focusHighlightClass}
|
||||||
/>
|
/>
|
||||||
{:else if selectedPersonToReassign[face.id]}
|
{:else if selectedPersonToReassign[face.id]}
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
@@ -259,6 +265,8 @@
|
|||||||
widthStyle={thumbnailWidth}
|
widthStyle={thumbnailWidth}
|
||||||
heightStyle={thumbnailWidth}
|
heightStyle={thumbnailWidth}
|
||||||
hidden={selectedPersonToReassign[face.id].isHidden}
|
hidden={selectedPersonToReassign[face.id].isHidden}
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class={focusHighlightClass}
|
||||||
/>
|
/>
|
||||||
{:else if face.person}
|
{:else if face.person}
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
@@ -270,6 +278,8 @@
|
|||||||
widthStyle={thumbnailWidth}
|
widthStyle={thumbnailWidth}
|
||||||
heightStyle={thumbnailWidth}
|
heightStyle={thumbnailWidth}
|
||||||
hidden={face.person.isHidden}
|
hidden={face.person.isHidden}
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class={focusHighlightClass}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
{#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
|
{#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
|
||||||
@@ -281,6 +291,8 @@
|
|||||||
title={$t('face_unassigned')}
|
title={$t('face_unassigned')}
|
||||||
widthStyle="90px"
|
widthStyle="90px"
|
||||||
heightStyle="90px"
|
heightStyle="90px"
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class={focusHighlightClass}
|
||||||
/>
|
/>
|
||||||
{:then data}
|
{:then data}
|
||||||
<ImageThumbnail
|
<ImageThumbnail
|
||||||
@@ -291,6 +303,8 @@
|
|||||||
title={$t('face_unassigned')}
|
title={$t('face_unassigned')}
|
||||||
widthStyle="90px"
|
widthStyle="90px"
|
||||||
heightStyle="90px"
|
heightStyle="90px"
|
||||||
|
highlighted={isHighlighted}
|
||||||
|
class={focusHighlightClass}
|
||||||
/>
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const isFaceEditMode = $state({ value: false });
|
export const isFaceEditMode = $state({ value: false });
|
||||||
|
export const isEditFacesPanelOpen = $state({ value: false });
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
computeContentMetrics,
|
||||||
getContentMetrics,
|
getContentMetrics,
|
||||||
getNaturalSize,
|
getNaturalSize,
|
||||||
|
mapContentRectToNatural,
|
||||||
|
mapContentToNatural,
|
||||||
mapNormalizedRectToContent,
|
mapNormalizedRectToContent,
|
||||||
mapNormalizedToContent,
|
mapNormalizedToContent,
|
||||||
scaleToCover,
|
scaleToCover,
|
||||||
@@ -123,6 +126,53 @@ describe('scaleToCover', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('computeContentMetrics', () => {
|
||||||
|
it('should compute metrics with scaleToFit by default', () => {
|
||||||
|
expect(computeContentMetrics({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({
|
||||||
|
contentWidth: 800,
|
||||||
|
contentHeight: 400,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept scaleToCover as scale function', () => {
|
||||||
|
expect(computeContentMetrics({ width: 2000, height: 1000 }, { width: 800, height: 600 }, scaleToCover)).toEqual({
|
||||||
|
contentWidth: 1200,
|
||||||
|
contentHeight: 600,
|
||||||
|
offsetX: -200,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compute zero offsets when aspect ratios match', () => {
|
||||||
|
expect(computeContentMetrics({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({
|
||||||
|
contentWidth: 800,
|
||||||
|
contentHeight: 450,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Coordinate space glossary:
|
||||||
|
//
|
||||||
|
// "Normalized" coordinates: values in the 0–1 range, where (0,0) is the top-left
|
||||||
|
// of the image and (1,1) is the bottom-right. Resolution-independent.
|
||||||
|
//
|
||||||
|
// "Content" coordinates: pixel positions within the container, after the image
|
||||||
|
// has been scaled (scaleToFit/scaleToCover) and offset (centered). This is what
|
||||||
|
// CSS and DOM layout use for positioning overlays like face boxes and OCR text.
|
||||||
|
//
|
||||||
|
// "Natural" coordinates: pixel positions in the original image file at its full
|
||||||
|
// resolution (e.g. 4000×3000). Used when cropping or drawing on the source image.
|
||||||
|
//
|
||||||
|
// "Metadata pixel space": the coordinate system used by face detection / OCR
|
||||||
|
// models, where positions are in pixels relative to the image dimensions stored
|
||||||
|
// in metadata (face.imageWidth/imageHeight). These may differ from the natural
|
||||||
|
// dimensions if the image was resized. To convert to normalized, divide by
|
||||||
|
// the metadata dimensions (e.g. face.boundingBoxX1 / face.imageWidth).
|
||||||
|
|
||||||
describe('mapNormalizedToContent', () => {
|
describe('mapNormalizedToContent', () => {
|
||||||
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||||
|
|
||||||
@@ -152,6 +202,31 @@ describe('mapNormalizedToContent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mapContentToNatural', () => {
|
||||||
|
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||||
|
const natural = { width: 4000, height: 2000 };
|
||||||
|
|
||||||
|
it('should map content origin to natural origin', () => {
|
||||||
|
expect(mapContentToNatural({ x: 0, y: 100 }, metrics, natural)).toEqual({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map content bottom-right to natural bottom-right', () => {
|
||||||
|
expect(mapContentToNatural({ x: 800, y: 500 }, metrics, natural)).toEqual({ x: 4000, y: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map content center to natural center', () => {
|
||||||
|
expect(mapContentToNatural({ x: 400, y: 300 }, metrics, natural)).toEqual({ x: 2000, y: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be the inverse of mapNormalizedToContent', () => {
|
||||||
|
const normalized = { x: 0.3, y: 0.7 };
|
||||||
|
const contentPoint = mapNormalizedToContent(normalized, metrics);
|
||||||
|
const naturalPoint = mapContentToNatural(contentPoint, metrics, natural);
|
||||||
|
expect(naturalPoint.x).toBeCloseTo(normalized.x * natural.width);
|
||||||
|
expect(naturalPoint.y).toBeCloseTo(normalized.y * natural.height);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('mapNormalizedRectToContent', () => {
|
describe('mapNormalizedRectToContent', () => {
|
||||||
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||||
|
|
||||||
@@ -177,3 +252,28 @@ describe('mapNormalizedRectToContent', () => {
|
|||||||
expect(rect).toEqual({ left: 200, top: 100, width: 400, height: 200 });
|
expect(rect).toEqual({ left: 200, top: 100, width: 400, height: 200 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mapContentRectToNatural', () => {
|
||||||
|
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||||
|
const natural = { width: 4000, height: 2000 };
|
||||||
|
|
||||||
|
it('should map a content rect to natural image coordinates', () => {
|
||||||
|
const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, natural);
|
||||||
|
expect(rect).toEqual({ left: 1000, top: 500, width: 2000, height: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map full content rect to full natural dimensions', () => {
|
||||||
|
const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, natural);
|
||||||
|
expect(rect).toEqual({ left: 0, top: 0, width: 4000, height: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be the inverse of mapNormalizedRectToContent', () => {
|
||||||
|
const normalized = { topLeft: { x: 0.2, y: 0.3 }, bottomRight: { x: 0.8, y: 0.9 } };
|
||||||
|
const contentRect = mapNormalizedRectToContent(normalized.topLeft, normalized.bottomRight, metrics);
|
||||||
|
const naturalRect = mapContentRectToNatural(contentRect, metrics, natural);
|
||||||
|
expect(naturalRect.left).toBeCloseTo(normalized.topLeft.x * natural.width);
|
||||||
|
expect(naturalRect.top).toBeCloseTo(normalized.topLeft.y * natural.height);
|
||||||
|
expect(naturalRect.width).toBeCloseTo((normalized.bottomRight.x - normalized.topLeft.x) * natural.width);
|
||||||
|
expect(naturalRect.height).toBeCloseTo((normalized.bottomRight.y - normalized.topLeft.y) * natural.height);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,16 +63,24 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
|
|||||||
return { width: element.naturalWidth, height: element.naturalHeight };
|
return { width: element.naturalWidth, height: element.naturalHeight };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
export function computeContentMetrics(
|
||||||
const natural = getNaturalSize(element);
|
imageSize: Size,
|
||||||
const client = getElementSize(element);
|
containerSize: Size,
|
||||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
|
scaleFn: (dimensions: Size, container: Size) => Size = scaleToFit,
|
||||||
|
) {
|
||||||
|
const { width: contentWidth, height: contentHeight } = scaleFn(imageSize, containerSize);
|
||||||
return {
|
return {
|
||||||
contentWidth,
|
contentWidth,
|
||||||
contentHeight,
|
contentHeight,
|
||||||
offsetX: (client.width - contentWidth) / 2,
|
offsetX: (containerSize.width - contentWidth) / 2,
|
||||||
offsetY: (client.height - contentHeight) / 2,
|
offsetY: (containerSize.height - contentHeight) / 2,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
||||||
|
const natural = getNaturalSize(element);
|
||||||
|
const client = getElementSize(element);
|
||||||
|
return computeContentMetrics(natural, client);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
|
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
|
||||||
@@ -88,6 +96,13 @@ export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | Conte
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapContentToNatural(point: Point, metrics: ContentMetrics, naturalSize: Size): Point {
|
||||||
|
return {
|
||||||
|
x: ((point.x - metrics.offsetX) / metrics.contentWidth) * naturalSize.width,
|
||||||
|
y: ((point.y - metrics.offsetY) / metrics.contentHeight) * naturalSize.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type Rect = {
|
export type Rect = {
|
||||||
top: number;
|
top: number;
|
||||||
left: number;
|
left: number;
|
||||||
@@ -109,3 +124,18 @@ export function mapNormalizedRectToContent(
|
|||||||
height: br.y - tl.y,
|
height: br.y - tl.y,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapContentRectToNatural(rect: Rect, metrics: ContentMetrics, naturalSize: Size): Rect {
|
||||||
|
const topLeft = mapContentToNatural({ x: rect.left, y: rect.top }, metrics, naturalSize);
|
||||||
|
const bottomRight = mapContentToNatural(
|
||||||
|
{ x: rect.left + rect.width, y: rect.top + rect.height },
|
||||||
|
metrics,
|
||||||
|
naturalSize,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
top: topLeft.y,
|
||||||
|
left: topLeft.x,
|
||||||
|
width: bottomRight.x - topLeft.x,
|
||||||
|
height: bottomRight.y - topLeft.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Faces } from '$lib/stores/people.store';
|
import type { Faces } from '$lib/stores/people.store';
|
||||||
import type { Size } from '$lib/utils/container-utils';
|
import type { Size } from '$lib/utils/container-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox, scaleFaceRectOnResize, type FaceRectState, type ResizeContext } from '$lib/utils/people-utils';
|
||||||
|
|
||||||
const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
|
const makeFace = (overrides: Partial<Faces> = {}): Faces => ({
|
||||||
id: 'face-1',
|
id: 'face-1',
|
||||||
@@ -68,3 +68,96 @@ describe('getBoundingBox', () => {
|
|||||||
expect(boxes[0].left).toBeLessThan(boxes[1].left);
|
expect(boxes[0].left).toBeLessThan(boxes[1].left);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('scaleFaceRectOnResize', () => {
|
||||||
|
const makeRect = (overrides: Partial<FaceRectState> = {}): FaceRectState => ({
|
||||||
|
left: 300,
|
||||||
|
top: 400,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makePrevious = (overrides: Partial<ResizeContext> = {}): ResizeContext => ({
|
||||||
|
offsetX: 100,
|
||||||
|
offsetY: 50,
|
||||||
|
contentWidth: 800,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve relative position when container doubles in size', () => {
|
||||||
|
const rect = makeRect({ left: 300, top: 250 });
|
||||||
|
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||||
|
|
||||||
|
const result = scaleFaceRectOnResize(rect, previous, { offsetX: 200, offsetY: 100, contentWidth: 1600 });
|
||||||
|
|
||||||
|
// imageRelLeft = (300 - 100) * 2 = 400, new left = 200 + 400 = 600
|
||||||
|
// imageRelTop = (250 - 50) * 2 = 400, new top = 100 + 400 = 500
|
||||||
|
expect(result.left).toBe(600);
|
||||||
|
expect(result.top).toBe(500);
|
||||||
|
expect(result.scaleX).toBe(2);
|
||||||
|
expect(result.scaleY).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve relative position when container halves in size', () => {
|
||||||
|
const rect = makeRect({ left: 300, top: 250 });
|
||||||
|
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||||
|
|
||||||
|
const result = scaleFaceRectOnResize(rect, previous, { offsetX: 50, offsetY: 25, contentWidth: 400 });
|
||||||
|
|
||||||
|
// imageRelLeft = (300 - 100) * 0.5 = 100, new left = 50 + 100 = 150
|
||||||
|
// imageRelTop = (250 - 50) * 0.5 = 100, new top = 25 + 100 = 125
|
||||||
|
expect(result.left).toBe(150);
|
||||||
|
expect(result.top).toBe(125);
|
||||||
|
expect(result.scaleX).toBe(0.5);
|
||||||
|
expect(result.scaleY).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no change in dimensions', () => {
|
||||||
|
const rect = makeRect({ left: 300, top: 250, scaleX: 1.5, scaleY: 1.5 });
|
||||||
|
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||||
|
|
||||||
|
const result = scaleFaceRectOnResize(rect, previous, { offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||||
|
|
||||||
|
expect(result.left).toBe(300);
|
||||||
|
expect(result.top).toBe(250);
|
||||||
|
expect(result.scaleX).toBe(1.5);
|
||||||
|
expect(result.scaleY).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle offset changes without content width change', () => {
|
||||||
|
const rect = makeRect({ left: 300, top: 250 });
|
||||||
|
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||||
|
|
||||||
|
const result = scaleFaceRectOnResize(rect, previous, { offsetX: 150, offsetY: 75, contentWidth: 800 });
|
||||||
|
|
||||||
|
// scale = 1, imageRelLeft = 200, imageRelTop = 200
|
||||||
|
// new left = 150 + 200 = 350, new top = 75 + 200 = 275
|
||||||
|
expect(result.left).toBe(350);
|
||||||
|
expect(result.top).toBe(275);
|
||||||
|
expect(result.scaleX).toBe(1);
|
||||||
|
expect(result.scaleY).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compound existing scale factors', () => {
|
||||||
|
const rect = makeRect({ left: 300, top: 250, scaleX: 2, scaleY: 3 });
|
||||||
|
const previous = makePrevious({ contentWidth: 800 });
|
||||||
|
|
||||||
|
const result = scaleFaceRectOnResize(rect, previous, { ...previous, contentWidth: 1600 });
|
||||||
|
|
||||||
|
expect(result.scaleX).toBe(4);
|
||||||
|
expect(result.scaleY).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle rect at image origin (top-left of content area)', () => {
|
||||||
|
const rect = makeRect({ left: 100, top: 50 });
|
||||||
|
const previous = makePrevious({ offsetX: 100, offsetY: 50, contentWidth: 800 });
|
||||||
|
|
||||||
|
const result = scaleFaceRectOnResize(rect, previous, { offsetX: 200, offsetY: 100, contentWidth: 1600 });
|
||||||
|
|
||||||
|
// imageRelLeft = (100 - 100) * 2 = 0, new left = 200
|
||||||
|
// imageRelTop = (50 - 50) * 2 = 0, new top = 100
|
||||||
|
expect(result.left).toBe(200);
|
||||||
|
expect(result.top).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Faces } from '$lib/stores/people.store';
|
import type { Faces } from '$lib/stores/people.store';
|
||||||
import { getAssetMediaUrl } from '$lib/utils';
|
import { getAssetMediaUrl } from '$lib/utils';
|
||||||
import { mapNormalizedRectToContent, type Rect, type Size } from '$lib/utils/container-utils';
|
import { mapNormalizedRectToContent, type ContentMetrics, type Rect, type Size } from '$lib/utils/container-utils';
|
||||||
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
export type BoundingBox = Rect & { id: string };
|
export type BoundingBox = Rect & { id: string };
|
||||||
@@ -21,6 +21,32 @@ export const getBoundingBox = (faces: Faces[], imageSize: Size): BoundingBox[] =
|
|||||||
return boxes;
|
return boxes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FaceRectState = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
scaleX: number;
|
||||||
|
scaleY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResizeContext = Pick<ContentMetrics, 'contentWidth' | 'offsetX' | 'offsetY'>;
|
||||||
|
|
||||||
|
export const scaleFaceRectOnResize = (
|
||||||
|
faceRect: FaceRectState,
|
||||||
|
previous: ResizeContext,
|
||||||
|
current: ResizeContext,
|
||||||
|
): FaceRectState => {
|
||||||
|
const scale = current.contentWidth / previous.contentWidth;
|
||||||
|
const imageRelativeLeft = (faceRect.left - previous.offsetX) * scale;
|
||||||
|
const imageRelativeTop = (faceRect.top - previous.offsetY) * scale;
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: current.offsetX + imageRelativeLeft,
|
||||||
|
top: current.offsetY + imageRelativeTop,
|
||||||
|
scaleX: faceRect.scaleX * scale,
|
||||||
|
scaleY: faceRect.scaleY * scale,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const zoomImageToBase64 = async (
|
export const zoomImageToBase64 = async (
|
||||||
face: AssetFaceResponseDto,
|
face: AssetFaceResponseDto,
|
||||||
assetId: string,
|
assetId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user