feat: smartcrop in editor

Change-Id: If137c44f30670cacc70a62983e45a4566a6a6964
This commit is contained in:
midzelis
2026-03-17 23:37:01 +00:00
parent 4a9510fc2b
commit a23d239abb
3 changed files with 84 additions and 5 deletions

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { Button, HStack, IconButton } from '@immich/ui';
import { mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
import { Button, HStack, Icon, IconButton } from '@immich/ui';
import { mdiAutoFix, mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
import { t } from 'svelte-i18n';
interface AspectRatioOption {
@@ -103,7 +103,7 @@
/>
</HStack>
<div class="flex h-10 w-full items-center justify-between text-sm mt-6">
<div class="flex h-10 w-full items-center text-sm mt-6">
<h2>{$t('crop')}</h2>
</div>
@@ -120,14 +120,12 @@
variant={ratioSelected(ratio) ? 'filled' : 'outline'}
>
{#if ratio.isFree}
<!-- Free crop icon with dashed border -->
<div
class="w-6 h-6 border-2 border-dashed rounded-xs flex-shrink-0 {ratioSelected(ratio)
? 'border-black'
: 'border-white'}"
></div>
{:else}
<!-- Aspect ratio box -->
<div
class="border-2 rounded-xs flex-shrink-0 {ratioSelected(ratio) ? 'border-black' : 'border-white'}"
style="width: {ratio.width}px; height: {ratio.height}px;"
@@ -137,5 +135,19 @@
<span class="text-sm text-white">{ratio.label}</span>
</HStack>
{/each}
<HStack>
<Button
class="w-14 h-14 m-2"
shape="round"
color="secondary"
variant="outline"
loading={transformManager.isApplyingSmartCrop}
aria-label={$t('editor_smart_crop')}
onclick={() => transformManager.applySmartCrop()}
>
<Icon icon={mdiAutoFix} size="1.75em" />
</Button>
<span class="text-sm text-white">{$t('editor_smart_crop')}</span>
</HStack>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { normalizeTransformEdits } from '$lib/utils/editor';
import { handleError } from '$lib/utils/handle-error';
import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk';
import { clamp } from 'lodash-es';
import smartcrop from 'smartcrop';
import { tick } from 'svelte';
export type CropAspectRatio =
@@ -56,10 +57,12 @@ class TransformManager implements EditToolManager {
isInteracting = $state(false);
isDragging = $state(false);
isApplyingSmartCrop = $state(false);
animationFrame = $state<ReturnType<typeof requestAnimationFrame> | null>(null);
dragAnchor = $state({ x: 0, y: 0 });
resizeSide = $state(ResizeBoundary.None);
imgElement = $state<HTMLImageElement | null>(null);
asset = $state<AssetResponseDto | null>(null);
cropAreaEl = $state<HTMLElement | null>(null);
overlayEl = $state<HTMLElement | null>(null);
cropFrame = $state<HTMLElement | null>(null);
@@ -185,6 +188,7 @@ class TransformManager implements EditToolManager {
}
async onActivate(asset: AssetResponseDto, edits: EditActions): Promise<void> {
this.asset = asset;
const originalSize = getDimensions(asset.exifInfo!);
this.originalImageSize = { width: originalSize.width ?? 0, height: originalSize.height ?? 0 };
@@ -243,6 +247,7 @@ class TransformManager implements EditToolManager {
this.cropImageScale = 1;
this.cropAspectRatio = 'free';
this.hasChanges = false;
this.asset = null;
}
mirror(axis: 'horizontal' | 'vertical') {
@@ -819,6 +824,67 @@ class TransformManager implements EditToolManager {
this.draw();
}
async applySmartCrop() {
const img = this.imgElement;
if (!img || !this.cropAreaEl || !this.asset) {
return;
}
this.isApplyingSmartCrop = true;
try {
const allFaces = [
...(this.asset.people?.flatMap((person) => person.faces ?? []) ?? []),
...(this.asset.unassignedFaces ?? []),
];
const boosts =
allFaces.length > 0
? allFaces.map((face) => ({
x: (face.boundingBoxX1 / face.imageWidth) * img.naturalWidth,
y: (face.boundingBoxY1 / face.imageHeight) * img.naturalHeight,
width: ((face.boundingBoxX2 - face.boundingBoxX1) / face.imageWidth) * img.naturalWidth,
height: ((face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight) * img.naturalHeight,
weight: 1,
}))
: undefined;
let requestWidth: number;
let requestHeight: number;
if (this.cropAspectRatio === 'free') {
requestWidth = Math.round(this.region.width / this.cropImageScale);
requestHeight = Math.round(this.region.height / this.cropImageScale);
} else {
const [aspectW, aspectH] = this.cropAspectRatio.split(':').map(Number);
const fitScale = Math.min(img.naturalWidth / aspectW, img.naturalHeight / aspectH);
requestWidth = Math.round(aspectW * fitScale);
requestHeight = Math.round(aspectH * fitScale);
}
const result = await smartcrop.crop(img, {
width: requestWidth,
height: requestHeight,
...(boosts && { boost: boosts }),
});
const { x, y, width, height } = result.topCrop;
const displayRegion = {
x: x * this.cropImageScale,
y: y * this.cropImageScale,
width: width * this.cropImageScale,
height: height * this.cropImageScale,
};
this.hasChanges = true;
this.region.x = displayRegion.x;
this.region.y = displayRegion.y;
this.region.width = displayRegion.width;
this.region.height = displayRegion.height;
this.draw();
} finally {
this.isApplyingSmartCrop = false;
}
}
resetCrop() {
this.cropAspectRatio = 'free';
this.region = {