mirror of
https://github.com/immich-app/immich.git
synced 2026-06-03 04:35:24 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfb2ec0f59 |
@@ -0,0 +1 @@
|
||||
custom: ['https://buy.immich.app', 'https://immich.store']
|
||||
@@ -0,0 +1,134 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues to `security@immich.app`
|
||||
@@ -55,6 +55,22 @@
|
||||
}
|
||||
],
|
||||
"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": [
|
||||
@@ -238,6 +254,36 @@
|
||||
"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,4 +22,7 @@ declare module 'main' {
|
||||
export function assetTimeline(): I32;
|
||||
export function assetTrash(): I32;
|
||||
export function assetAddToAlbums(): I32;
|
||||
|
||||
// integrations
|
||||
export function webhook(): I32;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,44 @@ 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,6 +213,8 @@ 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,6 +1,8 @@
|
||||
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';
|
||||
@@ -332,4 +334,47 @@ 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,11 +286,7 @@
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
{#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 assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -308,30 +308,9 @@
|
||||
let containerHeight = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (!assetViewerManager.isFaceEditMode || !videoPlayer) {
|
||||
return;
|
||||
if (assetViewerManager.isFaceEditMode) {
|
||||
videoPlayer?.pause();
|
||||
}
|
||||
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,
|
||||
@@ -475,12 +454,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if assetViewerManager.isFaceEditMode}
|
||||
<FaceEditor
|
||||
imageSize={{ width: asset.width ?? 0, height: asset.height ?? 0 }}
|
||||
containerSize={{ width: containerWidth, height: containerHeight }}
|
||||
{assetId}
|
||||
/>
|
||||
{#if assetViewerManager.isFaceEditMode && videoPlayer}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
|
||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
@@ -14,12 +14,13 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
imageSize: Size;
|
||||
containerSize: Size;
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
};
|
||||
|
||||
let { imageSize, containerSize, assetId }: Props = $props();
|
||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let canvas: Canvas | undefined = $state();
|
||||
@@ -53,7 +54,7 @@
|
||||
};
|
||||
|
||||
const setupCanvas = () => {
|
||||
if (!canvasEl) {
|
||||
if (!canvasEl || !htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,7 +86,17 @@
|
||||
searchInputEl?.focus();
|
||||
});
|
||||
|
||||
const imageContentMetrics = $derived(computeContentMetrics(imageSize, containerSize));
|
||||
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;
|
||||
@@ -105,8 +116,8 @@
|
||||
}
|
||||
|
||||
canvas.setDimensions({
|
||||
width: containerSize.width,
|
||||
height: containerSize.height,
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
if (!faceRect) {
|
||||
@@ -164,11 +175,11 @@
|
||||
};
|
||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||
const listHeight = Math.min(MAX_LIST_HEIGHT, containerSize.height - gap * 2 - chromeHeight);
|
||||
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
|
||||
const selectorHeight = listHeight + chromeHeight;
|
||||
|
||||
const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
|
||||
const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap);
|
||||
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
||||
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
||||
|
||||
const overlapArea = (position: { top: number; left: number }) => {
|
||||
const selectorRight = position.left + selectorWidth;
|
||||
@@ -227,42 +238,61 @@
|
||||
});
|
||||
|
||||
const getFaceCroppedCoordinates = () => {
|
||||
if (!faceRect || imageContentMetrics.contentWidth === 0) {
|
||||
if (!faceRect || !htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, imageSize);
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
|
||||
const scaleX = natural.width / contentWidth;
|
||||
const scaleY = natural.height / contentHeight;
|
||||
const imageX = (left - offsetX) * scaleX;
|
||||
const imageY = (top - offsetY) * scaleY;
|
||||
|
||||
return {
|
||||
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),
|
||||
imageWidth: natural.width,
|
||||
imageHeight: natural.height,
|
||||
x: Math.floor(imageX),
|
||||
y: Math.floor(imageY),
|
||||
width: Math.floor(width * scaleX),
|
||||
height: Math.floor(height * scaleY),
|
||||
};
|
||||
};
|
||||
|
||||
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
|
||||
|
||||
const getFacePreviewUrl = (data: FaceCoordinates) => {
|
||||
const imgRef = assetViewerManager.imgRef;
|
||||
if (!imgRef || imageContentMetrics.contentWidth === 0) {
|
||||
if (!htmlElement) {
|
||||
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 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 canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.getContext('2d')?.drawImage(imgRef, x, y, width, height, 0, 0, width, height);
|
||||
context.drawImage(htmlElement, x, y, width, height, 0, 0, width, height);
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
return;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
computeContentMetrics,
|
||||
getContentMetrics,
|
||||
getNaturalSize,
|
||||
mapContentRectToNatural,
|
||||
mapNormalizedRectToContent,
|
||||
mapNormalizedToContent,
|
||||
scaleToCover,
|
||||
scaleToFit,
|
||||
} from '$lib/utils/container-utils';
|
||||
|
||||
const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement =>
|
||||
props as unknown as HTMLImageElement;
|
||||
const mockImage = (props: {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): HTMLImageElement => props as unknown as HTMLImageElement;
|
||||
|
||||
const mockVideo = (props: {
|
||||
videoWidth: number;
|
||||
@@ -46,85 +49,48 @@ describe('scaleToFit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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({
|
||||
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({
|
||||
contentWidth: 800,
|
||||
contentHeight: 450,
|
||||
offsetX: 0,
|
||||
offsetY: 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 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);
|
||||
});
|
||||
|
||||
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 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 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 });
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNaturalSize', () => {
|
||||
it('should return naturalWidth/naturalHeight for images', () => {
|
||||
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000 });
|
||||
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 });
|
||||
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ 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 };
|
||||
@@ -56,18 +63,17 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
|
||||
return { width: element.naturalWidth, height: element.naturalHeight };
|
||||
};
|
||||
|
||||
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);
|
||||
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
||||
const natural = getNaturalSize(element);
|
||||
const client = getElementSize(element);
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (containerSize.width - contentWidth) / 2,
|
||||
offsetY: (containerSize.height - contentHeight) / 2,
|
||||
offsetX: (client.width - contentWidth) / 2,
|
||||
offsetY: (client.height - contentHeight) / 2,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
|
||||
if ('contentWidth' in sizeOrMetrics) {
|
||||
@@ -103,25 +109,3 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user