Compare commits

..

7 Commits

Author SHA1 Message Date
Min Idzelis baf522c426 feat(web): zoom-aware face editing
Change-Id: I85a15daf5aff30e010a1d277cc4e2f7f6a6a6964
2026-06-03 04:32:56 +00:00
Min Idzelis ea202b6ed8 refactor(web): decouple FaceEditor from DOM element
Change-Id: I71f07ba8d0bc2d829c0b2af4da5ee5bc6a6a6964
2026-06-03 04:32:56 +00:00
Min Idzelis a29dc703b6 refactor(web): expose scaled image dimensions from AdaptiveImage
Change-Id: Iae105fb749525739ba8df5b944a73ea66a6a6964
2026-06-03 03:56:29 +00:00
immich-tofu[bot] 92841f311f Added Code of conduct 2026-06-02 21:57:50 +00:00
immich-tofu[bot] 9d2e576630 chore: modify .github/FUNDING.yml 2026-06-02 21:57:47 +00:00
immich-tofu[bot] 936418a464 chore: use immich.app email for security reports (#10594)
chore: use  immich.app email for security reports
2026-06-02 21:57:45 +00:00
Daniel Dietzler 84c75d95c7 fix: migration order (#28779) 2026-06-02 21:33:13 +00:00
18 changed files with 519 additions and 438 deletions
-1
View File
@@ -1 +0,0 @@
custom: ['https://buy.immich.app', 'https://immich.store']
-134
View File
@@ -1,134 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation
in our community a harassment-free experience for everyone, regardless
of age, body size, visible or invisible disability, ethnicity, sex
characteristics, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance,
race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open,
welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for
our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our
mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or
political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in
a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our
standards of acceptable behavior and will take appropriate and fair
corrective action in response to any behavior that they deem
inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit,
or reject comments, commits, code, wiki edits, issues, and other
contributions that are not aligned to this Code of Conduct, and will
communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also
applies when an individual is officially representing the community in
public spaces. Examples of representing our community include using an
official e-mail address, posting via an official social media account,
or acting as an appointed representative at an online or offline
event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior
may be reported to the community leaders responsible for enforcement
at our Discord channel. All complaints
will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and
security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in
determining the consequences for any action they deem in violation of
this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior
deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders,
providing clarity around the nature of the violation and an
explanation of why the behavior was inappropriate. A public apology
may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued
behavior. No interaction with the people involved, including
unsolicited interaction with those enforcing the Code of Conduct, for
a specified period of time. This includes avoiding interactions in
community spaces as well as external channels like social
media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards,
including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or
public communication with the community for a specified period of
time. No public or private interaction with the people involved,
including unsolicited interaction with those enforcing the Code of
Conduct, is allowed during this period. Violating these terms may lead
to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of
community standards, including sustained inappropriate behavior,
harassment of an individual, or aggression toward or disparagement of
classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction
within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor
Covenant][homepage], version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of
conduct enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the
FAQ at https://www.contributor-covenant.org/faq. Translations are
available at https://www.contributor-covenant.org/translations.
-5
View File
@@ -1,5 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `security@immich.app`
+1
View File
@@ -1300,6 +1300,7 @@
"hide_schema": "Hide schema",
"hide_text_recognition": "Hide text recognition",
"hide_unnamed_people": "Hide unnamed people",
"hold_key_to_pan": "Hold {key} to pan",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
-46
View File
@@ -55,22 +55,6 @@
}
],
"uiHints": ["SmartAlbum"]
},
{
"name": "asset-webhook",
"title": "Send a webhook",
"description": "Send the information of newly uploaded assets to an external endpoint",
"trigger": "AssetCreate",
"steps": [
{
"method": "immich-plugin-core#webhook",
"config": {
"url": "",
"method": "POST"
}
}
],
"uiHints": ["Webhook"]
}
],
"methods": [
@@ -254,36 +238,6 @@
"required": ["albumIds"]
}
},
{
"name": "webhook",
"title": "Send a webhook",
"description": "Send the asset information to an external endpoint",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "Webhook URL",
"description": "The endpoint that will receive the asset information"
},
"method": {
"type": "string",
"title": "HTTP method",
"enum": ["POST", "PUT", "PATCH"],
"default": "POST",
"description": "HTTP method used to send the request"
},
"secret": {
"type": "string",
"title": "Secret",
"description": "Optional value sent as the X-Immich-Webhook-Secret header so the receiver can verify the request"
}
},
"required": ["url"]
},
"uiHints": ["Webhook"]
},
{
"name": "noop1",
"title": "DEV: Nested properties",
-3
View File
@@ -22,7 +22,4 @@ declare module 'main' {
export function assetTimeline(): I32;
export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
// integrations
export function webhook(): I32;
}
-38
View File
@@ -102,44 +102,6 @@ export const assetTrash = () => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
type WebhookConfig = {
url: string;
method?: 'PATCH' | 'POST' | 'PUT';
secret?: string;
};
export const webhook = () => {
return wrapper<WorkflowType.AssetV1, WebhookConfig>(({ config, data, trigger, workflow }) => {
const { url, method = 'POST', secret } = config;
if (!url) {
console.warn('Webhook step skipped: no URL configured');
return {};
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'Immich',
};
if (secret) {
headers['X-Immich-Webhook-Secret'] = secret;
}
const body = JSON.stringify({
trigger,
workflowId: workflow.id,
asset: data.asset,
});
const response = Http.request({ url, method, headers }, body);
if (response.status >= 400) {
console.error(`Webhook request to ${url} failed with status ${response.status}`);
} else {
console.debug(`Webhook request to ${url} returned status ${response.status}`);
}
return {};
});
};
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
const assetId = data.asset.id;
@@ -213,8 +213,6 @@ export class PluginRepository {
{
useWasi: true,
runInWorker,
// allow plugins (e.g. the webhook workflow step) to make outbound HTTP requests
allowedHosts: ['*'],
functions: {
'extism:host/user': functions ?? {},
},
@@ -1,8 +1,6 @@
import { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk';
import { Kysely } from 'kysely';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { AddressInfo } from 'node:net';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { AssetVisibility, LogLevel } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
@@ -334,47 +332,4 @@ describe('core plugin', () => {
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
});
});
describe('webhook', () => {
it('should send the asset information to the configured endpoint', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const received = new Promise<{ headers: NodeJS.Dict<string | string[]>; body: any }>((resolve, reject) => {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
res.writeHead(200);
res.end();
server.close();
resolve({ headers: req.headers, body: JSON.parse(Buffer.concat(chunks).toString()) });
});
});
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [
{
method: 'immich-plugin-core#webhook',
config: { url: `http://127.0.0.1:${port}/hook`, secret: 'super-secret' },
},
],
})
.then((workflow) => ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id }))
.catch(reject);
});
});
const { headers, body } = await received;
expect(headers['x-immich-webhook-secret']).toBe('super-secret');
expect(body).toMatchObject({
trigger: WorkflowTrigger.AssetCreate,
asset: { id: asset.id, ownerId: user.id },
});
});
});
});
+20 -2
View File
@@ -74,6 +74,8 @@
onError?: () => void;
ref?: HTMLDivElement;
imgRef?: HTMLImageElement;
imgNaturalSize?: Size;
imgScaledSize?: Size;
backdrop?: Snippet;
overlays?: Snippet;
};
@@ -82,6 +84,10 @@
ref = $bindable(),
// eslint-disable-next-line no-useless-assignment
imgRef = $bindable(),
// eslint-disable-next-line no-useless-assignment
imgNaturalSize = $bindable(),
// eslint-disable-next-line no-useless-assignment
imgScaledSize = $bindable(),
asset,
sharedLink,
objectFit = 'contain',
@@ -149,10 +155,22 @@
return { width: 1, height: 1 };
});
$effect(() => {
imgNaturalSize = imageDimensions;
});
const scaledDimensions = $derived.by(() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
return scaleFn(imageDimensions, container);
});
$effect(() => {
imgScaledSize = scaledDimensions;
});
const { insetInlineStart, top, displayWidth, displayHeight, rasterWidth, rasterHeight, rasterScale } = $derived.by(
() => {
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
const { width, height } = scaleFn(imageDimensions, container);
const { width, height } = scaledDimensions;
if (maxRasterPixels === 0) {
return {
insetInlineStart: (container.width - width) / 2 + 'px',
@@ -13,7 +13,7 @@
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { getNaturalSize, scaleToFit, type Size } from '$lib/utils/container-utils';
import type { Size } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox, type BoundingBox } from '$lib/utils/people-utils';
@@ -67,13 +67,9 @@
height: containerHeight,
});
const overlaySize = $derived.by((): Size => {
if (!assetViewerManager.imgRef || !visibleImageReady) {
return { width: 0, height: 0 };
}
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
});
const overlaySize = $derived(visibleImageReady ? scaledDimensions : { width: 0, height: 0 });
const highlightedBoxes = $derived(getBoundingBox(assetViewerManager.highlightedFaces, overlaySize));
const isHighlighting = $derived(highlightedBoxes.length > 0);
@@ -235,6 +231,7 @@
onReady?.();
}}
bind:imgRef={assetViewerManager.imgRef}
bind:imgScaledSize={scaledDimensions}
bind:ref={adaptiveImage}
>
{#snippet backdrop()}
@@ -286,7 +283,11 @@
{/snippet}
</AdaptiveImage>
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef && asset.width && asset.height}
<FaceEditor
imageSize={{ width: asset.width, height: asset.height }}
containerSize={{ width: containerWidth, height: containerHeight }}
assetId={asset.id}
/>
{/if}
</div>
@@ -308,9 +308,30 @@
let containerHeight = $state(0);
$effect(() => {
if (assetViewerManager.isFaceEditMode) {
videoPlayer?.pause();
if (!assetViewerManager.isFaceEditMode || !videoPlayer) {
return;
}
videoPlayer.pause();
const { videoWidth, videoHeight } = videoPlayer;
if (videoWidth === 0 || videoHeight === 0) {
return;
}
const canvas = document.createElement('canvas');
canvas.width = videoWidth;
canvas.height = videoHeight;
canvas.getContext('2d')?.drawImage(videoPlayer, 0, 0);
const img = new Image();
img.onload = () => (assetViewerManager.imgRef = img);
img.src = canvas.toDataURL('image/png');
return () => {
img.onload = null;
img.src = '';
assetViewerManager.imgRef = undefined;
};
});
// The time is only refreshed on HLS fragment decode by default,
@@ -454,8 +475,12 @@
</div>
{/if}
{#if assetViewerManager.isFaceEditMode && videoPlayer}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{#if assetViewerManager.isFaceEditMode}
<FaceEditor
imageSize={{ width: asset.width ?? 0, height: asset.height ?? 0 }}
containerSize={{ width: containerWidth, height: containerHeight }}
{assetId}
/>
{/if}
{/if}
</div>
@@ -4,25 +4,27 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
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 { scaleFaceRectOnResize, type ResizeContext } from '$lib/utils/people-utils';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { clamp } from 'lodash-es';
import { onDestroy, onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
type Props = {
htmlElement: HTMLImageElement | HTMLVideoElement;
containerWidth: number;
containerHeight: number;
imageSize: Size;
containerSize: Size;
assetId: string;
};
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
let { imageSize, containerSize, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let containerEl: HTMLDivElement | undefined = $state();
let canvas: Canvas | undefined = $state();
let faceRect: Rect | undefined = $state();
let faceSelectorEl: HTMLDivElement | undefined = $state();
@@ -33,6 +35,9 @@
let searchTerm = $state('');
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(
searchTerm
@@ -54,11 +59,12 @@
};
const setupCanvas = () => {
if (!canvasEl || !htmlElement) {
if (!canvasEl) {
return;
}
canvas = new Canvas(canvasEl);
canvas = new Canvas(canvasEl, { width: containerSize.width, height: containerSize.height });
canvas.selection = false;
configureControlStyle();
// eslint-disable-next-line tscompat/tscompat
@@ -76,57 +82,100 @@
canvas.add(faceRect);
canvas.setActiveObject(faceRect);
setDefaultFaceRectanglePosition(faceRect);
};
onMount(async () => {
setupCanvas();
await getPeople();
void getPeople();
await tick();
searchInputEl?.focus();
});
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(() => {
if (!canvas) {
return;
}
canvas.setDimensions({
width: containerWidth,
height: containerHeight,
});
const upperCanvas = canvas.upperCanvasEl;
const controller = new AbortController();
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(computeContentMetrics(imageSize, containerSize));
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;
}
if (!isFaceRectIntersectingCanvas(faceRect, canvas)) {
const isFirstRun = previousMetrics === null;
if (isFirstRun && !canvas) {
setupCanvas();
}
if (!canvas || !faceRect) {
return;
}
if (!isFirstRun) {
canvas.setDimensions({ width: containerSize.width, height: containerSize.height });
}
if (!isFirstRun && userMovedRect && previousMetrics) {
faceRect.set(scaleFaceRectOnResize(faceRect, previousMetrics, { contentWidth, offsetX, offsetY }));
} else {
setDefaultFaceRectanglePosition(faceRect);
}
faceRect.setCoords();
previousMetrics = { contentWidth, offsetX, offsetY };
canvas.renderAll();
positionFaceSelector();
});
const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => {
@@ -167,34 +216,39 @@
const gap = 15;
const padding = faceRect.padding ?? 0;
const rawBox = faceRect.getBoundingRect();
if (Number.isNaN(rawBox.left) || Number.isNaN(rawBox.width)) {
return;
}
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
const faceBox = {
left: rawBox.left - padding,
top: rawBox.top - padding,
width: rawBox.width + padding * 2,
height: rawBox.height + padding * 2,
left: (rawBox.left - padding) * currentZoom + currentPositionX,
top: (rawBox.top - padding) * currentZoom + currentPositionY,
width: (rawBox.width + padding * 2) * currentZoom,
height: (rawBox.height + padding * 2) * currentZoom,
};
const selectorWidth = faceSelectorEl.offsetWidth;
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
const listHeight = Math.min(MAX_LIST_HEIGHT, containerSize.height - gap * 2 - chromeHeight);
const selectorHeight = listHeight + chromeHeight;
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap);
const faceRight = faceBox.left + faceBox.width;
const faceBottom = faceBox.top + faceBox.height;
const overlapArea = (position: { top: number; left: number }) => {
const selectorRight = position.left + selectorWidth;
const selectorBottom = position.top + selectorHeight;
const faceRight = faceBox.left + faceBox.width;
const faceBottom = faceBox.top + faceBox.height;
const overlapX = Math.max(0, Math.min(selectorRight, faceRight) - Math.max(position.left, faceBox.left));
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;
};
const faceBottom = faceBox.top + faceBox.height;
const faceRight = faceBox.left + faceBox.width;
const positions = [
{ top: clampTop(faceBottom + gap), left: clampLeft(faceBox.left) },
{ top: clampTop(faceBox.top - selectorHeight - gap), left: clampLeft(faceBox.left) },
@@ -216,83 +270,164 @@
}
}
faceSelectorEl.style.top = `${bestPosition.top}px`;
faceSelectorEl.style.left = `${bestPosition.left}px`;
const containerRect = containerEl?.getBoundingClientRect();
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`;
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(() => {
const rect = faceRect;
const cvs = canvas;
if (rect && cvs) {
rect.on('moving', positionFaceSelector);
rect.on('scaling', positionFaceSelector);
const onUserMove = () => {
userMovedRect = true;
positionFaceSelector();
};
rect.on('moving', onUserMove);
rect.on('scaling', onUserMove);
cvs.on('object:modified', () => searchInputEl?.focus());
return () => {
rect.off('moving', positionFaceSelector);
rect.off('scaling', positionFaceSelector);
rect.off('moving', onUserMove);
rect.off('scaling', onUserMove);
cvs.off('object:modified', () => searchInputEl?.focus());
};
}
});
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);
}
document.body.append(node);
return {
destroy() {
for (const type of eventTypes) {
node.removeEventListener(type, stop);
}
node.remove();
},
};
};
const getFaceCroppedCoordinates = () => {
if (!faceRect || !htmlElement) {
if (!faceRect || imageSize.width === 0 || imageSize.height === 0) {
return;
}
const { left, top, width, height } = faceRect.getBoundingRect();
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
const natural = getNaturalSize(htmlElement);
const scaledWidth = faceRect.getScaledWidth();
const scaledHeight = faceRect.getScaledHeight();
const scaleX = natural.width / contentWidth;
const scaleY = natural.height / contentHeight;
const imageX = (left - offsetX) * scaleX;
const imageY = (top - offsetY) * scaleY;
const imageRect = mapContentRectToNatural(
{
left: faceRect.left - scaledWidth / 2,
top: faceRect.top - scaledHeight / 2,
width: scaledWidth,
height: scaledHeight,
},
imageContentMetrics,
imageSize,
);
return {
imageWidth: natural.width,
imageHeight: natural.height,
x: Math.floor(imageX),
y: Math.floor(imageY),
width: Math.floor(width * scaleX),
height: Math.floor(height * scaleY),
imageWidth: imageSize.width,
imageHeight: imageSize.height,
x: Math.floor(imageRect.left),
y: Math.floor(imageRect.top),
width: Math.floor(imageRect.width),
height: Math.floor(imageRect.height),
};
};
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
const getFacePreviewUrl = (data: FaceCoordinates) => {
if (!htmlElement) {
const imgRef = assetViewerManager.imgRef;
if (!imgRef || imageContentMetrics.contentWidth === 0) {
return;
}
const natural = getNaturalSize(htmlElement);
if (natural.width <= 0 || natural.height <= 0) {
return;
}
const x = clamp(data.x, 0, natural.width - 1);
const y = clamp(data.y, 0, natural.height - 1);
const width = clamp(data.width, 1, natural.width - x);
const height = clamp(data.height, 1, natural.height - y);
if (width <= 0 || height <= 0) {
return;
}
const scaleX = imgRef.naturalWidth / imageSize.width;
const scaleY = imgRef.naturalHeight / imageSize.height;
const x = clamp(Math.floor(data.x * scaleX), 0, imgRef.naturalWidth - 1);
const y = clamp(Math.floor(data.y * scaleY), 0, imgRef.naturalHeight - 1);
const width = clamp(Math.floor(data.width * scaleX), 1, imgRef.naturalWidth - x);
const height = clamp(Math.floor(data.height * scaleY), 1, imgRef.naturalHeight - y);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return;
}
try {
context.drawImage(htmlElement, x, y, width, height, 0, 0, width, height);
canvas.getContext('2d')?.drawImage(imgRef, x, y, width, height, 0, 0, width, height);
return canvas.toDataURL('image/png');
} catch {
return;
@@ -364,6 +499,7 @@
<div
id="face-editor-data"
bind:this={containerEl}
class="absolute inset-s-0 top-0 z-5 size-full overflow-hidden"
data-overlay-interactive
data-face-left={faceBoxPosition.left}
@@ -371,12 +507,14 @@
data-face-width={faceBoxPosition.width}
data-face-height={faceBoxPosition.height}
>
<canvas bind:this={canvasEl} id="face-editor" class="absolute inset-s-0 top-0"></canvas>
<canvas bind:this={canvasEl} id="face-editor" class="absolute top-0 start-0"></canvas>
<div
id="face-selector"
bind:this={faceSelectorEl}
class="absolute inset-s-[calc(50%-125px)] top-[calc(50%-250px)] w-62.5 max-w-62.5 rounded-xl border border-gray-200 bg-white px-2 py-4 backdrop-blur-sm transition-[top,left] duration-200 ease-out dark:border-gray-800 dark:bg-immich-dark-gray dark:text-immich-dark-fg"
class="fixed z-20 w-[min(200px,45vw)] min-w-48 rounded-xl border border-gray-200 bg-white px-2 py-4 backdrop-blur-sm transition-[top,left] duration-200 ease-out dark:border-gray-800 dark:bg-immich-dark-gray dark:text-immich-dark-fg"
use:trapEvents
onwheel={(e) => e.stopPropagation()}
>
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
@@ -423,4 +561,15 @@
{$t('cancel')}
</Button>
</div>
{#if isZoomed && !panModifierHeld}
<div
transition:fade={{ duration: 200 }}
class="pointer-events-none absolute inset-s-1/2 bottom-4 z-10 -translate-x-1/2"
>
<p class="whitespace-nowrap rounded-full bg-black/60 px-3 py-1.5 text-xs text-white">
{$t('hold_key_to_pan', { values: { key: panModifierLabel } })}
</p>
</div>
{/if}
</div>
+67 -33
View File
@@ -1,18 +1,15 @@
import {
getContentMetrics,
computeContentMetrics,
getNaturalSize,
mapContentRectToNatural,
mapNormalizedRectToContent,
mapNormalizedToContent,
scaleToCover,
scaleToFit,
} from '$lib/utils/container-utils';
const mockImage = (props: {
naturalWidth: number;
naturalHeight: number;
width: number;
height: number;
}): HTMLImageElement => props as unknown as HTMLImageElement;
const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement =>
props as unknown as HTMLImageElement;
const mockVideo = (props: {
videoWidth: number;
@@ -49,48 +46,85 @@ describe('scaleToFit', () => {
});
});
describe('getContentMetrics', () => {
it('should compute zero offsets when aspect ratios match', () => {
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 });
expect(getContentMetrics(img)).toEqual({
describe('computeContentMetrics', () => {
it('should return zero metrics for zero-width content', () => {
expect(computeContentMetrics({ width: 0, height: 1080 }, { width: 800, height: 600 })).toEqual({
contentWidth: 0,
contentHeight: 0,
offsetX: 0,
offsetY: 0,
});
});
it('should return zero metrics for zero-height content', () => {
expect(computeContentMetrics({ width: 1920, height: 0 }, { width: 800, height: 600 })).toEqual({
contentWidth: 0,
contentHeight: 0,
offsetX: 0,
offsetY: 0,
});
});
it('should center wide content vertically', () => {
expect(computeContentMetrics({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({
contentWidth: 800,
contentHeight: 400,
offsetX: 0,
offsetY: 100,
});
});
it('should center tall content horizontally', () => {
expect(computeContentMetrics({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({
contentWidth: 300,
contentHeight: 600,
offsetX: 250,
offsetY: 0,
});
});
it('should produce 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,
});
});
});
it('should compute horizontal letterbox offsets for tall image', () => {
const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 });
const metrics = getContentMetrics(img);
expect(metrics.contentWidth).toBe(300);
expect(metrics.contentHeight).toBe(600);
expect(metrics.offsetX).toBe(250);
expect(metrics.offsetY).toBe(0);
describe('mapContentRectToNatural', () => {
it('should map a full-content rect back to natural size', () => {
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, {
width: 2000,
height: 1000,
});
expect(rect).toEqual({ left: 0, top: 0, width: 2000, height: 1000 });
});
it('should compute vertical letterbox offsets for wide image', () => {
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 });
const metrics = getContentMetrics(img);
expect(metrics.contentWidth).toBe(800);
expect(metrics.contentHeight).toBe(400);
expect(metrics.offsetX).toBe(0);
expect(metrics.offsetY).toBe(100);
it('should map a centered sub-rect to natural coordinates', () => {
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, {
width: 2000,
height: 1000,
});
expect(rect).toEqual({ left: 500, top: 250, width: 1000, height: 500 });
});
it('should use clientWidth/clientHeight for video elements', () => {
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
const metrics = getContentMetrics(video);
expect(metrics.contentWidth).toBe(800);
expect(metrics.contentHeight).toBe(450);
expect(metrics.offsetX).toBe(0);
expect(metrics.offsetY).toBe(75);
it('should handle letterboxed content with horizontal offset', () => {
const metrics = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 };
const rect = mapContentRectToNatural({ left: 250, top: 0, width: 300, height: 600 }, metrics, {
width: 1000,
height: 2000,
});
expect(rect).toEqual({ left: 0, top: 0, width: 1000, height: 2000 });
});
});
describe('getNaturalSize', () => {
it('should return naturalWidth/naturalHeight for images', () => {
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 });
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000 });
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
});
+30 -14
View File
@@ -49,13 +49,6 @@ export const scaleToFit = (dimensions: Size, container: Size): Size => {
};
};
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.clientWidth, height: element.clientHeight };
}
return { width: element.width, height: element.height };
};
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
if (element instanceof HTMLVideoElement) {
return { width: element.videoWidth, height: element.videoHeight };
@@ -63,17 +56,18 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
return { width: element.naturalWidth, height: element.naturalHeight };
};
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
const natural = getNaturalSize(element);
const client = getElementSize(element);
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
export function computeContentMetrics(imageSize: Size, containerSize: Size): ContentMetrics {
if (imageSize.width === 0 || imageSize.height === 0) {
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
}
const { width: contentWidth, height: contentHeight } = scaleToFit(imageSize, containerSize);
return {
contentWidth,
contentHeight,
offsetX: (client.width - contentWidth) / 2,
offsetY: (client.height - contentHeight) / 2,
offsetX: (containerSize.width - contentWidth) / 2,
offsetY: (containerSize.height - contentHeight) / 2,
};
};
}
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
if ('contentWidth' in sizeOrMetrics) {
@@ -109,3 +103,25 @@ export function mapNormalizedRectToContent(
height: br.y - tl.y,
};
}
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 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,
};
}
+86 -1
View File
@@ -1,6 +1,6 @@
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
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 => ({
id: 'face-1',
@@ -68,3 +68,88 @@ describe('getBoundingBox', () => {
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 });
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 });
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 });
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', () => {
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 });
expect(result.left).toBe(200);
expect(result.top).toBe(100);
});
});
+27 -1
View File
@@ -1,7 +1,7 @@
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
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';
export type BoundingBox = Rect & { id: string };
@@ -21,6 +21,32 @@ export const getBoundingBox = (faces: Faces[], imageSize: Size): BoundingBox[] =
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 (
face: AssetFaceResponseDto,
assetId: string,