Compare commits

..

5 Commits

Author SHA1 Message Date
Min Idzelis 9cd5d9e218 refactor(web): decouple FaceEditor from DOM element
Change-Id: I71f07ba8d0bc2d829c0b2af4da5ee5bc6a6a6964
2026-06-03 14:33:41 +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
25 changed files with 197 additions and 398 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`
-2
View File
@@ -2364,8 +2364,6 @@
"trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger",
"trigger_album_asset_added": "Asset Added to Album",
"trigger_album_asset_added_description": "Triggered when an asset is added to an album",
"trigger_asset_uploaded": "Asset Upload",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kicks off the workflow",
-3
View File
@@ -26,14 +26,12 @@ class WorkflowTrigger {
static const assetCreate = WorkflowTrigger._(r'AssetCreate');
static const assetMetadataExtraction = WorkflowTrigger._(r'AssetMetadataExtraction');
static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
static const albumAssetAdded = WorkflowTrigger._(r'AlbumAssetAdded');
/// List of all possible values in this [enum][WorkflowTrigger].
static const values = <WorkflowTrigger>[
assetCreate,
assetMetadataExtraction,
personRecognized,
albumAssetAdded,
];
static WorkflowTrigger? fromJson(dynamic value) => WorkflowTriggerTypeTransformer().decode(value);
@@ -75,7 +73,6 @@ class WorkflowTriggerTypeTransformer {
case r'AssetCreate': return WorkflowTrigger.assetCreate;
case r'AssetMetadataExtraction': return WorkflowTrigger.assetMetadataExtraction;
case r'PersonRecognized': return WorkflowTrigger.personRecognized;
case r'AlbumAssetAdded': return WorkflowTrigger.albumAssetAdded;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
-3
View File
@@ -25,13 +25,11 @@ class WorkflowType {
static const assetV1 = WorkflowType._(r'AssetV1');
static const assetPersonV1 = WorkflowType._(r'AssetPersonV1');
static const assetAlbumV1 = WorkflowType._(r'AssetAlbumV1');
/// List of all possible values in this [enum][WorkflowType].
static const values = <WorkflowType>[
assetV1,
assetPersonV1,
assetAlbumV1,
];
static WorkflowType? fromJson(dynamic value) => WorkflowTypeTypeTransformer().decode(value);
@@ -72,7 +70,6 @@ class WorkflowTypeTypeTransformer {
switch (data) {
case r'AssetV1': return WorkflowType.assetV1;
case r'AssetPersonV1': return WorkflowType.assetPersonV1;
case r'AssetAlbumV1': return WorkflowType.assetAlbumV1;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
+2 -4
View File
@@ -26811,8 +26811,7 @@
"enum": [
"AssetCreate",
"AssetMetadataExtraction",
"PersonRecognized",
"AlbumAssetAdded"
"PersonRecognized"
],
"type": "string"
},
@@ -26840,8 +26839,7 @@
"description": "Workflow type",
"enum": [
"AssetV1",
"AssetPersonV1",
"AssetAlbumV1"
"AssetPersonV1"
],
"type": "string"
},
-11
View File
@@ -11,7 +11,6 @@ type DeepPartial<T> = T extends Date
export type WorkflowEventMap = {
[WorkflowType.AssetV1]: AssetV1;
[WorkflowType.AssetPersonV1]: AssetPersonV1;
[WorkflowType.AssetAlbumV1]: AssetAlbumV1;
};
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
@@ -20,7 +19,6 @@ export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
AssetMetadataExtraction = 'AssetMetadataExtraction',
PersonRecognized = 'PersonRecognized',
AlbumAssetAdded = 'AlbumAssetAdded',
}
export type WorkflowEventPayload<
@@ -130,12 +128,3 @@ export type AssetPersonV1 = AssetV1 & {
name: string;
};
};
export type AssetAlbumV1 = AssetV1 & {
album: {
id: string;
ownerId: string;
albumName: string;
description: string;
};
};
+2 -4
View File
@@ -7179,14 +7179,12 @@ export enum PartnerDirection {
}
export enum WorkflowType {
AssetV1 = "AssetV1",
AssetPersonV1 = "AssetPersonV1",
AssetAlbumV1 = "AssetAlbumV1"
AssetPersonV1 = "AssetPersonV1"
}
export enum WorkflowTrigger {
AssetCreate = "AssetCreate",
AssetMetadataExtraction = "AssetMetadataExtraction",
PersonRecognized = "PersonRecognized",
AlbumAssetAdded = "AlbumAssetAdded"
PersonRecognized = "PersonRecognized"
}
export enum QueueJobStatus {
Active = "active",
-1
View File
@@ -1172,7 +1172,6 @@ export const WorkflowTriggerSchema = z
export enum WorkflowType {
AssetV1 = 'AssetV1',
AssetPersonV1 = 'AssetPersonV1',
AssetAlbumV1 = 'AssetAlbumV1',
}
export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' });
@@ -40,7 +40,6 @@ type EventMap = {
// album events
AlbumUpdate: [{ id: string; recipientId: string }];
AlbumInvite: [{ id: string; userId: string; senderName: string }];
AlbumAssetAdd: [{ albumId: string; assetId: string; userId: string }];
// asset events
AssetCreate: [{ asset: Asset; file: UploadFile }];
@@ -5,7 +5,6 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { WorkflowSearchDto } from 'src/dtos/workflow.dto';
import { AlbumUserRole } from 'src/enum';
import { DB } from 'src/schema';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
@@ -183,15 +182,4 @@ export class WorkflowRepository {
.where('id', '=', assetId)
.executeTakeFirstOrThrow();
}
getAlbumForWorkflow(albumId: string) {
return this.db
.selectFrom('album')
.innerJoin('album_user', 'album_user.albumId', 'album.id')
.where('album_user.role', '=', AlbumUserRole.Owner)
.select(['album.id', 'album_user.userId as ownerId', 'album.albumName', 'album.description'])
.where('album.id', '=', albumId)
.where('album.deletedAt', 'is', null)
.executeTakeFirstOrThrow();
}
}
-13
View File
@@ -742,9 +742,6 @@ describe(AlbumService.name, () => {
owner.id,
);
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]);
for (const assetId of [asset1.id, asset2.id, asset3.id]) {
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumAssetAdd', { albumId: album.id, assetId, userId: owner.id });
}
});
it('should not set the thumbnail if the album has one already', async () => {
@@ -1058,16 +1055,6 @@ describe(AlbumService.name, () => {
id: album2.id,
recipientId: owner2.id,
});
for (const { albumId, assetId } of [
{ albumId: album1.id, assetId: asset1.id },
{ albumId: album1.id, assetId: asset2.id },
{ albumId: album1.id, assetId: asset3.id },
{ albumId: album2.id, assetId: asset1.id },
{ albumId: album2.id, assetId: asset2.id },
{ albumId: album2.id, assetId: asset3.id },
]) {
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumAssetAdd', { albumId, assetId, userId: user.id });
}
});
it('should not allow a shared user with viewer access to add assets', async () => {
-10
View File
@@ -201,12 +201,6 @@ export class AlbumService extends BaseService {
}
}
for (const { id: assetId, success } of results) {
if (success) {
await this.eventRepository.emit('AlbumAssetAdd', { albumId: id, assetId, userId: auth.user.id });
}
}
return results;
}
@@ -267,10 +261,6 @@ export class AlbumService extends BaseService {
}
}
for (const { albumId, assetId } of albumAssetValues) {
await this.eventRepository.emit('AlbumAssetAdd', { albumId, assetId, userId: auth.user.id });
}
return results;
}
@@ -40,7 +40,7 @@ type ExecuteOptions<T extends WorkflowType> = {
write: (auth: AuthDto, changes: WorkflowChanges<T>) => Promise<void>;
};
type AssetTrigger = { userId: string; assetId: string; albumId?: string; trigger: WorkflowTrigger };
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string;
@@ -274,57 +274,21 @@ export class WorkflowExecutionService extends BaseService {
return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction });
}
@OnEvent({ name: 'AlbumAssetAdd' })
onAlbumAssetAdd({ userId, assetId, albumId }: ArgOf<'AlbumAssetAdd'>) {
return this.onAssetTrigger({ userId, assetId, albumId, trigger: WorkflowTrigger.AlbumAssetAdded });
}
private async onAssetTrigger({ userId, assetId, albumId, trigger }: AssetTrigger) {
private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) {
const items = await this.workflowRepository.search({ userId, trigger });
await this.jobRepository.queueAll(
items.map((workflow) => ({
name: JobName.WorkflowAssetTrigger,
data: { workflowId: workflow.id, assetId, albumId, trigger },
data: { workflowId: workflow.id, assetId, trigger },
})),
);
}
@OnJob({ name: JobName.WorkflowAssetTrigger, queue: QueueName.Workflow })
handleAssetTrigger({ workflowId, assetId, albumId }: JobOf<JobName.WorkflowAssetTrigger>) {
handleAssetTrigger({ workflowId, assetId }: JobOf<JobName.WorkflowAssetTrigger>) {
return this.execute(workflowId, (type) => {
const assetService = BaseService.create(AssetService, this);
const writeAsset: ExecuteOptions<WorkflowType.AssetV1>['write'] = async (auth, changes) => {
const asset = changes.asset;
if (!asset) {
return;
}
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
dateTimeOriginal: asset.exifInfo?.dateTimeOriginal ?? undefined,
// TODO allow setting to null
longitude: asset.exifInfo?.longitude ?? undefined,
// TODO allow setting to null
latitude: asset.exifInfo?.latitude ?? undefined,
// TODO allow setting to null
description: asset.exifInfo?.description ?? undefined,
rating: asset.exifInfo?.rating,
// TODO add to update dto
// make: asset.exifInfo?.make,
// model: asset.exifInfo?.model,
// city: asset.exifInfo?.city,
// state: asset.exifInfo?.state,
// country: asset.exifInfo?.country,
// lensModel: asset.exifInfo?.lensModel,
// fNumber: asset.exifInfo?.fNumber,
// fps: asset.exifInfo?.fps,
// iso: asset.exifInfo?.iso,
});
};
switch (type) {
case WorkflowType.AssetV1: {
return {
@@ -335,28 +299,36 @@ export class WorkflowExecutionService extends BaseService {
authUserId: asset.ownerId,
};
},
write: writeAsset,
} satisfies ExecuteOptions<typeof type>;
}
write: async (auth, changes) => {
const asset = changes.asset;
if (!asset) {
return;
}
case WorkflowType.AssetAlbumV1: {
if (!albumId) {
this.logger.error(`Misconfigured workflow ${workflowId}: missing albumId for type ${type}`);
return;
}
await assetService.update(auth, assetId, {
isFavorite: asset.isFavorite,
visibility: asset.visibility,
dateTimeOriginal: asset.exifInfo?.dateTimeOriginal ?? undefined,
// TODO allow setting to null
longitude: asset.exifInfo?.longitude ?? undefined,
// TODO allow setting to null
latitude: asset.exifInfo?.latitude ?? undefined,
// TODO allow setting to null
description: asset.exifInfo?.description ?? undefined,
rating: asset.exifInfo?.rating,
return {
read: async () => {
const [asset, album] = await Promise.all([
this.workflowRepository.getForAssetV1(assetId),
this.workflowRepository.getAlbumForWorkflow(albumId),
]);
return {
data: { asset, album } as any,
authUserId: asset.ownerId,
};
// TODO add to update dto
// make: asset.exifInfo?.make,
// model: asset.exifInfo?.model,
// city: asset.exifInfo?.city,
// state: asset.exifInfo?.state,
// country: asset.exifInfo?.country,
// lensModel: asset.exifInfo?.lensModel,
// fNumber: asset.exifInfo?.fNumber,
// fps: asset.exifInfo?.fps,
// iso: asset.exifInfo?.iso,
});
},
write: writeAsset,
} satisfies ExecuteOptions<typeof type>;
}
}
+1 -1
View File
@@ -420,7 +420,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob }
// Workflow
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string; albumId?: string } }
| { name: JobName.WorkflowAssetTrigger; data: { workflowId: string; assetId: string } }
// Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
-15
View File
@@ -28,21 +28,6 @@ const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected:
types: [WorkflowType.AssetV1, WorkflowType.AssetPersonV1],
expected: true,
},
{
trigger: WorkflowTrigger.AlbumAssetAdded,
types: [WorkflowType.AssetAlbumV1],
expected: true,
},
{
trigger: WorkflowTrigger.AlbumAssetAdded,
types: [WorkflowType.AssetV1],
expected: false,
},
{
trigger: WorkflowTrigger.AssetCreate,
types: [WorkflowType.AssetAlbumV1],
expected: true,
},
];
describe(isMethodCompatible.name, () => {
-2
View File
@@ -6,7 +6,6 @@ export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
[WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
[WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1],
[WorkflowTrigger.AlbumAssetAdded]: [WorkflowType.AssetAlbumV1],
};
export const getWorkflowTriggers = () =>
@@ -16,7 +15,6 @@ export const getWorkflowTriggers = () =>
const inferredMap: Record<WorkflowType, WorkflowType[]> = {
[WorkflowType.AssetV1]: [],
[WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1],
[WorkflowType.AssetAlbumV1]: [WorkflowType.AssetV1],
};
const withImpliedItems = (type: WorkflowType): WorkflowType[] => {
@@ -286,7 +286,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,31 @@
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();
const onImageLoad = () => (assetViewerManager.imgRef = img);
img.addEventListener('load', onImageLoad);
img.src = canvas.toDataURL('image/png');
return () => {
img.removeEventListener('load', onImageLoad);
img.src = '';
assetViewerManager.imgRef = undefined;
};
});
// The time is only refreshed on HLS fragment decode by default,
@@ -454,8 +476,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,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 { 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 { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -14,13 +14,12 @@
import { t } from 'svelte-i18n';
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 canvas: Canvas | undefined = $state();
@@ -54,7 +53,7 @@
};
const setupCanvas = () => {
if (!canvasEl || !htmlElement) {
if (!canvasEl) {
return;
}
@@ -86,17 +85,7 @@
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 imageContentMetrics = $derived(computeContentMetrics(imageSize, containerSize));
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
const { offsetX, offsetY } = imageContentMetrics;
@@ -116,8 +105,8 @@
}
canvas.setDimensions({
width: containerWidth,
height: containerHeight,
width: containerSize.width,
height: containerSize.height,
});
if (!faceRect) {
@@ -175,11 +164,11 @@
};
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 overlapArea = (position: { top: number; left: number }) => {
const selectorRight = position.left + selectorWidth;
@@ -238,61 +227,42 @@
});
const getFaceCroppedCoordinates = () => {
if (!faceRect || !htmlElement) {
if (!faceRect || imageContentMetrics.contentWidth === 0) {
return;
}
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;
const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), 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;
+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,
};
}
-6
View File
@@ -9,9 +9,6 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => {
case WorkflowTrigger.PersonRecognized: {
return $t('trigger_person_recognized');
}
case WorkflowTrigger.AlbumAssetAdded: {
return $t('trigger_album_asset_added');
}
default: {
return type;
}
@@ -26,9 +23,6 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge
case WorkflowTrigger.PersonRecognized: {
return $t('trigger_person_recognized_description');
}
case WorkflowTrigger.AlbumAssetAdded: {
return $t('trigger_album_asset_added_description');
}
default: {
return type;
}