mirror of
https://github.com/immich-app/immich.git
synced 2026-06-03 04:35:24 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| baf522c426 | |||
| ea202b6ed8 | |||
| a29dc703b6 | |||
| 92841f311f | |||
| 9d2e576630 | |||
| 936418a464 | |||
| 84c75d95c7 |
@@ -1 +0,0 @@
|
||||
custom: ['https://buy.immich.app', 'https://immich.store']
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues to `security@immich.app`
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
-3
@@ -22,7 +22,4 @@ declare module 'main' {
|
||||
export function assetTimeline(): I32;
|
||||
export function assetTrash(): I32;
|
||||
export function assetAddToAlbums(): I32;
|
||||
|
||||
// integrations
|
||||
export function webhook(): I32;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user