mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 13:39:26 +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_reset_all_changes": "Reset changes",
|
||||||
"editor_rotate_left": "Rotate 90° counterclockwise",
|
"editor_rotate_left": "Rotate 90° counterclockwise",
|
||||||
"editor_rotate_right": "Rotate 90° clockwise",
|
"editor_rotate_right": "Rotate 90° clockwise",
|
||||||
|
"editor_smart_crop": "Smart crop",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"email_notifications": "Email notifications",
|
"email_notifications": "Email notifications",
|
||||||
"empty_folder": "This folder is empty",
|
"empty_folder": "This folder is empty",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||||
import { Button, HStack, IconButton } from '@immich/ui';
|
import { Button, HStack, Icon, IconButton } from '@immich/ui';
|
||||||
import { mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
|
import { mdiAutoFix, mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface AspectRatioOption {
|
interface AspectRatioOption {
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
/>
|
/>
|
||||||
</HStack>
|
</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>
|
<h2>{$t('crop')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,14 +120,12 @@
|
|||||||
variant={ratioSelected(ratio) ? 'filled' : 'outline'}
|
variant={ratioSelected(ratio) ? 'filled' : 'outline'}
|
||||||
>
|
>
|
||||||
{#if ratio.isFree}
|
{#if ratio.isFree}
|
||||||
<!-- Free crop icon with dashed border -->
|
|
||||||
<div
|
<div
|
||||||
class="w-6 h-6 border-2 border-dashed rounded-xs flex-shrink-0 {ratioSelected(ratio)
|
class="w-6 h-6 border-2 border-dashed rounded-xs flex-shrink-0 {ratioSelected(ratio)
|
||||||
? 'border-black'
|
? 'border-black'
|
||||||
: 'border-white'}"
|
: 'border-white'}"
|
||||||
></div>
|
></div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Aspect ratio box -->
|
|
||||||
<div
|
<div
|
||||||
class="border-2 rounded-xs flex-shrink-0 {ratioSelected(ratio) ? 'border-black' : 'border-white'}"
|
class="border-2 rounded-xs flex-shrink-0 {ratioSelected(ratio) ? 'border-black' : 'border-white'}"
|
||||||
style="width: {ratio.width}px; height: {ratio.height}px;"
|
style="width: {ratio.width}px; height: {ratio.height}px;"
|
||||||
@@ -137,5 +135,19 @@
|
|||||||
<span class="text-sm text-white">{ratio.label}</span>
|
<span class="text-sm text-white">{ratio.label}</span>
|
||||||
</HStack>
|
</HStack>
|
||||||
{/each}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { normalizeTransformEdits } from '$lib/utils/editor';
|
|||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk';
|
import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk';
|
||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
|
import smartcrop from 'smartcrop';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
export type CropAspectRatio =
|
export type CropAspectRatio =
|
||||||
@@ -56,10 +57,12 @@ class TransformManager implements EditToolManager {
|
|||||||
|
|
||||||
isInteracting = $state(false);
|
isInteracting = $state(false);
|
||||||
isDragging = $state(false);
|
isDragging = $state(false);
|
||||||
|
isApplyingSmartCrop = $state(false);
|
||||||
animationFrame = $state<ReturnType<typeof requestAnimationFrame> | null>(null);
|
animationFrame = $state<ReturnType<typeof requestAnimationFrame> | null>(null);
|
||||||
dragAnchor = $state({ x: 0, y: 0 });
|
dragAnchor = $state({ x: 0, y: 0 });
|
||||||
resizeSide = $state(ResizeBoundary.None);
|
resizeSide = $state(ResizeBoundary.None);
|
||||||
imgElement = $state<HTMLImageElement | null>(null);
|
imgElement = $state<HTMLImageElement | null>(null);
|
||||||
|
asset = $state<AssetResponseDto | null>(null);
|
||||||
cropAreaEl = $state<HTMLElement | null>(null);
|
cropAreaEl = $state<HTMLElement | null>(null);
|
||||||
overlayEl = $state<HTMLElement | null>(null);
|
overlayEl = $state<HTMLElement | null>(null);
|
||||||
cropFrame = $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> {
|
async onActivate(asset: AssetResponseDto, edits: EditActions): Promise<void> {
|
||||||
|
this.asset = asset;
|
||||||
const originalSize = getDimensions(asset.exifInfo!);
|
const originalSize = getDimensions(asset.exifInfo!);
|
||||||
this.originalImageSize = { width: originalSize.width ?? 0, height: originalSize.height ?? 0 };
|
this.originalImageSize = { width: originalSize.width ?? 0, height: originalSize.height ?? 0 };
|
||||||
|
|
||||||
@@ -243,6 +247,7 @@ class TransformManager implements EditToolManager {
|
|||||||
this.cropImageScale = 1;
|
this.cropImageScale = 1;
|
||||||
this.cropAspectRatio = 'free';
|
this.cropAspectRatio = 'free';
|
||||||
this.hasChanges = false;
|
this.hasChanges = false;
|
||||||
|
this.asset = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
mirror(axis: 'horizontal' | 'vertical') {
|
mirror(axis: 'horizontal' | 'vertical') {
|
||||||
@@ -819,6 +824,67 @@ class TransformManager implements EditToolManager {
|
|||||||
this.draw();
|
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() {
|
resetCrop() {
|
||||||
this.cropAspectRatio = 'free';
|
this.cropAspectRatio = 'free';
|
||||||
this.region = {
|
this.region = {
|
||||||
|
|||||||
Reference in New Issue
Block a user