mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 11:58:59 +03:00
feat: smartcrop in editor
Change-Id: If137c44f30670cacc70a62983e45a4566a6a6964
This commit is contained in:
@@ -1013,6 +1013,7 @@
|
||||
"editor_reset_all_changes": "Reset changes",
|
||||
"editor_rotate_left": "Rotate 90° counterclockwise",
|
||||
"editor_rotate_right": "Rotate 90° clockwise",
|
||||
"editor_smart_crop": "Smart crop",
|
||||
"email": "Email",
|
||||
"email_notifications": "Email notifications",
|
||||
"empty_folder": "This folder is empty",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user