Compare commits

..

10 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
shenlong 9287fa08c6 fix!: unauthorized face creation (#28561)
* fix: unauthorized face creation

* review changes

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-06-02 22:44:11 +05:30
renovate[bot] 408e1180ca chore(deps): update machine-learning (#28239)
* chore(deps): update machine-learning

* fix typing

* fix deprecation log

* no control socket

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2026-06-02 16:44:50 +00:00
renovate[bot] 07f19d2caa chore(deps): update base-image to v202606021219 (#28771) 2026-06-02 18:31:52 +02:00
25 changed files with 1081 additions and 767 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}.",
+4 -4
View File
@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
FROM builder-cpu AS builder-cuda
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
FROM python:3.11-slim-bookworm@sha256:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
+1
View File
@@ -49,6 +49,7 @@ try:
str(settings.http_keepalive_timeout_s),
"--graceful-timeout",
"10",
"--no-control-socket",
],
) as cmd:
cmd.wait()
+2 -1
View File
@@ -12,7 +12,7 @@ from zipfile import BadZipFile
import orjson
from fastapi import Depends, FastAPI, File, Form, HTTPException
from fastapi.responses import ORJSONResponse, PlainTextResponse
from fastapi.responses import PlainTextResponse
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
from PIL.Image import Image
from pydantic import ValidationError
@@ -32,6 +32,7 @@ from .schemas import (
ModelIdentity,
ModelTask,
ModelType,
ORJSONResponse,
PipelineRequest,
T,
)
@@ -89,7 +89,9 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder):
tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
pad_id: int = tokenizer.token_to_id(pad_token)
pad_id = tokenizer.token_to_id(pad_token)
if pad_id is None:
raise ValueError(f"Pad token '{pad_token}' not found in tokenizer vocab")
tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
tokenizer.enable_truncation(max_length=context_length)
+7
View File
@@ -3,9 +3,16 @@ from typing import Any, Literal, Protocol, TypeGuard, TypeVar
import numpy as np
import numpy.typing as npt
import orjson
from fastapi.responses import JSONResponse
from typing_extensions import TypedDict
class ORJSONResponse(JSONResponse):
def render(self, content: Any) -> bytes:
return orjson.dumps(content, option=orjson.OPT_SERIALIZE_NUMPY)
class StrEnum(str, Enum):
value: str
+498 -450
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -31,7 +31,7 @@
"@types/node": "^24.12.4",
"esbuild": "^0.28.0",
"tsc-alias": "^1.8.16",
"typescript": "^6.0.0"
"typescript": "^5.9.3"
},
"peerDependencies": {
"@extism/js-pdk": "^1.1.1"
+2 -2
View File
@@ -348,8 +348,8 @@ importers:
specifier: ^1.8.16
version: 1.8.17
typescript:
specifier: ^6.0.0
version: 6.0.3
specifier: ^5.9.3
version: 5.9.3
packages/sdk:
dependencies:
+2 -2
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/immich-app/base-server-dev:202605121138@sha256:127cc323590e1d64d765016492e1de1e9355da8f658f078aef7be6bede0fdd0f AS builder
FROM ghcr.io/immich-app/base-server-dev:202606021219@sha256:63fa91aa011f6f2921dd32fe6d1be8d637e9bd7f3e3dd0c8e446afb31b282af4 AS builder
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
CI=1 \
COREPACK_HOME=/tmp \
@@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise //:plugins
FROM ghcr.io/immich-app/base-server-prod:202605121138@sha256:b346d42d86f42799fede6b6c9f6feed0018c5ee46e4ac31d1165f50737cee842
FROM ghcr.io/immich-app/base-server-prod:202606021219@sha256:6ef9ef5859492149af770a6c884b5e2ddbaeef99f8885ea5f2d9f73625a3d9ec
WORKDIR /usr/src/app
ENV NODE_ENV=production \
+1 -1
View File
@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:202605121138@sha256:127cc323590e1d64d765016492e1de1e9355da8f658f078aef7be6bede0fdd0f AS dev
FROM ghcr.io/immich-app/base-server-dev:202606021219@sha256:63fa91aa011f6f2921dd32fe6d1be8d637e9bd7f3e3dd0c8e446afb31b282af4 AS dev
COPY --from=ghcr.io/jdx/mise:2026.5.18@sha256:5bb3311994fa78cef307ca3077cdb18f9551da0886371fc26ea91ab56220ffc5 /usr/local/bin/mise /usr/local/bin/mise
@@ -0,0 +1,16 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Delete unauthorized cross-owner asset faces
await sql`
DELETE FROM asset_face
USING person, asset
WHERE asset_face."personId" = person.id
AND asset_face."assetId" = asset.id
AND person."ownerId" != asset."ownerId"
`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented: the deleted rows were unauthorized cross-owner entries
}
@@ -452,6 +452,30 @@ describe(PersonService.name, () => {
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
});
it('should reject creating a face on an asset the user does not own', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const person = PersonFactory.create({ faceAssetId: null });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
await expect(
sut.createFace(auth, {
assetId: asset.id,
personId: person.id,
imageHeight: 500,
imageWidth: 400,
x: 10,
y: 20,
width: 100,
height: 110,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.person.createAssetFace).not.toHaveBeenCalled();
});
});
describe('createNewFeaturePhoto', () => {
+1 -1
View File
@@ -625,7 +625,7 @@ export class PersonService extends BaseService {
// TODO return a asset face response
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
await Promise.all([
this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }),
this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.assetId] }),
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
]);
+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,