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:
midzelis
2026-03-19 13:02:46 +00:00
parent ed04d87273
commit 590a9df7ec
21 changed files with 1147 additions and 208 deletions

View File

@@ -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';

View File

@@ -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,

View File

@@ -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,
});
});
};

View File

@@ -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);
});
}); });

View 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);
});
});

View File

@@ -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}.",

View File

@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View File

@@ -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',

View File

@@ -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

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 -->

View File

@@ -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}

View File

@@ -1 +1,2 @@
export const isFaceEditMode = $state({ value: false }); export const isFaceEditMode = $state({ value: false });
export const isEditFacesPanelOpen = $state({ value: false });

View File

@@ -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 01 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);
});
});

View File

@@ -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,
};
}

View File

@@ -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);
});
});

View File

@@ -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,