From a34909ad192e64a745b70f4f8ec991acb0609f59 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Sun, 4 Aug 2024 14:54:14 +0000 Subject: [PATCH 01/25] feat(server): handle orientation from sidecar in thumbnail generation --- server/src/interfaces/media.interface.ts | 2 ++ server/src/repositories/media.repository.ts | 11 ++++++++- server/src/services/media.service.ts | 27 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index f7389d3d068cd..0bf8140078522 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -16,6 +16,8 @@ export interface ThumbnailOptions { colorspace: string; quality: number; crop?: CropOptions; + angle?: number; + mirror?: boolean; processInvalidImages: boolean; } diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index a84ef6f596f4e..feaec6cf5b139 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -48,7 +48,16 @@ export class MediaRepository implements IMediaRepository { // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false }) .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') - .rotate(); + + if (options.mirror) { + pipeline.flop(); + } + + if (options.angle) { + pipeline.rotate(options.angle); + } else { + pipeline.rotate(); // auto-rotate based on EXIF orientation + } if (options.crop) { pipeline.extract(options.crop); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 9d5b4ed8589d3..4fb9248b30d52 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -206,8 +206,35 @@ export class MediaService { colorspace, quality: image.quality, processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', + angle: 0, + mirror: false }; + if (asset.exifInfo?.orientation) { + switch (asset.exifInfo.orientation) { + case '1': + case '2': + imageOptions.angle = 0; + break; + case '3': + case '4': + imageOptions.angle = 180; + break; + case '6': + case '7': + imageOptions.angle = 90; + break; + case '5': + case '8': + imageOptions.angle = 270; + break; + } + + if (['2', '4', '5', '7'].includes(asset.exifInfo.orientation)) { + imageOptions.mirror = true; + } + } + const outputPath = useExtracted ? extractedPath : asset.originalPath; await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions); } finally { From e65bed87e22aed6c1b59ee60476429468324db87 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Sun, 4 Aug 2024 15:44:17 +0000 Subject: [PATCH 02/25] style: reuse existing exif Orientation enum --- e2e/test-assets | 2 +- server/src/interfaces/metadata.interface.ts | 11 +++++++++ server/src/services/media.service.ts | 24 ++++++++++++-------- server/src/services/metadata.service.spec.ts | 6 ++--- server/src/services/metadata.service.ts | 21 ++++------------- 5 files changed, 35 insertions(+), 29 deletions(-) diff --git a/e2e/test-assets b/e2e/test-assets index 39f25a96f13f7..898069e47f8e3 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 39f25a96f13f743c96cdb7c6d93b031fcb61b83c +Subproject commit 898069e47f8e3283bf3bbd40b58b56d8fd57dc65 diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 386f69a9e740c..fe60a56581de4 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -7,6 +7,17 @@ export interface ExifDuration { Scale?: number; } +export enum ExifOrientation { + Horizontal = '1', + MirrorHorizontal = '2', + Rotate180 = '3', + MirrorVertical = '4', + MirrorHorizontalRotate270CW = '5', + Rotate90CW = '6', + MirrorHorizontalRotate90CW = '7', + Rotate270CW = '8', +} + export interface ImmichTags extends Omit { ContentIdentifier?: string; MotionPhoto?: number; diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 4fb9248b30d52..e3b057814d47b 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -29,6 +29,7 @@ import { } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AudioStreamInfo, IMediaRepository, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { ExifOrientation } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -212,25 +213,30 @@ export class MediaService { if (asset.exifInfo?.orientation) { switch (asset.exifInfo.orientation) { - case '1': - case '2': + case ExifOrientation.Horizontal: + case ExifOrientation.MirrorHorizontal: imageOptions.angle = 0; break; - case '3': - case '4': + case ExifOrientation.Rotate180: + case ExifOrientation.MirrorVertical: imageOptions.angle = 180; break; - case '6': - case '7': + case ExifOrientation.Rotate90CW: + case ExifOrientation.MirrorHorizontalRotate90CW: imageOptions.angle = 90; break; - case '5': - case '8': + case ExifOrientation.MirrorHorizontalRotate270CW: + case ExifOrientation.Rotate270CW: imageOptions.angle = 270; break; } - if (['2', '4', '5', '7'].includes(asset.exifInfo.orientation)) { + if ([ + ExifOrientation.MirrorHorizontal, + ExifOrientation.MirrorVertical, + ExifOrientation.MirrorHorizontalRotate90CW, + ExifOrientation.MirrorHorizontalRotate270CW, + ].includes(asset.exifInfo.orientation as ExifOrientation)) { imageOptions.mirror = true; } } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 3adae863775de..f954a2ddfd640 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -13,13 +13,13 @@ import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; -import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; +import { ExifOrientation, IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { MetadataService, Orientation } from 'src/services/metadata.service'; +import { MetadataService } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; @@ -356,7 +356,7 @@ describe(MetadataService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ orientation: Orientation.Rotate270CW }), + expect.objectContaining({ orientation: ExifOrientation.Rotate270CW }), ); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 7e940744e7a37..965f40bc4ed52 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -28,7 +28,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; -import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; +import { ExifOrientation, IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -48,17 +48,6 @@ const EXIF_DATE_TAGS: Array = [ 'DateTimeCreated', ]; -export enum Orientation { - Horizontal = '1', - MirrorHorizontal = '2', - Rotate180 = '3', - MirrorVertical = '4', - MirrorHorizontalRotate270CW = '5', - Rotate90CW = '6', - MirrorHorizontalRotate90CW = '7', - Rotate270CW = '8', -} - type ExifEntityWithoutGeocodeAndTypeOrm = Omit & { dateTimeOriginal: Date; }; @@ -554,19 +543,19 @@ export class MetadataService implements OnEvents { if (videoStreams[0]) { switch (videoStreams[0].rotation) { case -90: { - exifData.orientation = Orientation.Rotate90CW; + exifData.orientation = ExifOrientation.Rotate90CW; break; } case 0: { - exifData.orientation = Orientation.Horizontal; + exifData.orientation = ExifOrientation.Horizontal; break; } case 90: { - exifData.orientation = Orientation.Rotate270CW; + exifData.orientation = ExifOrientation.Rotate270CW; break; } case 180: { - exifData.orientation = Orientation.Rotate180; + exifData.orientation = ExifOrientation.Rotate180; break; } } From 476fd6d81613429856f2fb9e3a6d74bd10842594 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Mon, 5 Aug 2024 09:00:26 +0000 Subject: [PATCH 03/25] feat(web): add rotate actions (wip) --- open-api/immich-openapi-specs.json | 13 +++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/asset.dto.ts | 5 ++ server/src/interfaces/job.interface.ts | 1 + server/src/interfaces/metadata.interface.ts | 4 +- .../src/repositories/metadata.repository.ts | 4 +- server/src/services/asset.service.ts | 8 +- server/src/services/metadata.service.ts | 7 +- .../components/asset-viewer/actions/action.ts | 1 + .../asset-viewer/actions/rotate-action.svelte | 82 +++++++++++++++++++ .../asset-viewer/asset-viewer-nav-bar.svelte | 6 ++ .../asset-viewer/asset-viewer.svelte | 12 ++- .../asset-viewer/photo-viewer.svelte | 14 +++- web/src/lib/constants.ts | 1 + web/src/lib/i18n/en.json | 4 + web/src/lib/i18n/fr.json | 4 + 16 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/actions/rotate-action.svelte diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 750af46883e46..77b775da811ea 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11596,6 +11596,19 @@ "longitude": { "type": "number" }, + "orientation": { + "enum": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8" + ], + "type": "string" + }, "rating": { "maximum": 5, "minimum": 0, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 68f37100caa9e..75ff6238b028f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -391,6 +391,7 @@ export type UpdateAssetDto = { isFavorite?: boolean; latitude?: number; longitude?: number; + orientation?: string; rating?: number; }; export type AssetMediaReplaceDto = { diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 8b438992d380e..c432a27feecc7 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -3,6 +3,7 @@ import { Type } from 'class-transformer'; import { IsDateString, IsEnum, + IsIn, IsInt, IsLatitude, IsLongitude, @@ -74,6 +75,10 @@ export class UpdateAssetDto extends UpdateAssetBase { @Optional() @IsString() description?: string; + + @Optional() + @IsIn(['1', '2', '3', '4', '5', '6', '7', '8']) + orientation?: string; } export class RandomAssetsDto { diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 7776d2bd370b5..1603205b2a942 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -147,6 +147,7 @@ export interface ISidecarWriteJob extends IEntityJob { dateTimeOriginal?: string; latitude?: number; longitude?: number; + orientation?: string; rating?: number; } diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index fe60a56581de4..73b1047fa0579 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -1,4 +1,4 @@ -import { BinaryField, Tags } from 'exiftool-vendored'; +import { BinaryField, Tags, WriteTags } from 'exiftool-vendored'; export const IMetadataRepository = 'IMetadataRepository'; @@ -39,7 +39,7 @@ export interface ImmichTags extends Omit; readTags(path: string): Promise; - writeTags(path: string, tags: Partial): Promise; + writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; getCountries(userId: string): Promise>; getStates(userId: string, country?: string): Promise>; diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 832cffbee6ae1..9e66f4a96b5bd 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; +import { DefaultReadTaskOptions, ExifTool, Tags, WriteTags } from 'exiftool-vendored'; import geotz from 'geo-tz'; import { DummyValue, GenerateSql } from 'src/decorators'; import { ExifEntity } from 'src/entities/exif.entity'; @@ -47,7 +47,7 @@ export class MetadataRepository implements IMetadataRepository { return this.exiftool.extractBinaryTagToBuffer(tagName, path); } - async writeTags(path: string, tags: Partial): Promise { + async writeTags(path: string, tags: Partial): Promise { try { await this.exiftool.write(path, tags); } catch (error) { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index a34349498b42f..d202850870830 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -158,8 +158,8 @@ export class AssetService { async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); - const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; - await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); + const { description, dateTimeOriginal, latitude, longitude, orientation, rating, ...rest } = dto; + await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, orientation, rating }); await this.assetRepository.update({ id, ...rest }); const asset = await this.assetRepository.getById(id, { @@ -405,8 +405,8 @@ export class AssetService { } private async updateMetadata(dto: ISidecarWriteJob) { - const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; - const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); + const { id, description, dateTimeOriginal, latitude, longitude, orientation, rating } = dto; + const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, orientation, rating }, _.isUndefined); if (Object.keys(writes).length > 0) { await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 965f40bc4ed52..397cc7fe6bcbf 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored'; +import { ContainerDirectoryItem, ExifDateTime, Tags, WriteTags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import _ from 'lodash'; import { Duration } from 'luxon'; @@ -262,20 +262,21 @@ export class MetadataService implements OnEvents { } async handleSidecarWrite(job: ISidecarWriteJob): Promise { - const { id, description, dateTimeOriginal, latitude, longitude, rating } = job; + const { id, description, dateTimeOriginal, latitude, longitude, orientation, rating } = job; const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { return JobStatus.FAILED; } const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; - const exif = _.omitBy( + const exif = _.omitBy( { Description: description, ImageDescription: description, DateTimeOriginal: dateTimeOriginal, GPSLatitude: latitude, GPSLongitude: longitude, + 'Orientation#': Number.parseInt(orientation ?? '1', 10), Rating: rating, }, _.isUndefined, diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index d6136f2d1867e..d9194f4c23dbe 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -12,6 +12,7 @@ type ActionMap = { [AssetAction.ADD]: { asset: AssetResponseDto }; [AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto }; [AssetAction.UNSTACK]: { assets: AssetResponseDto[] }; + [AssetAction.ROTATE]: { asset: AssetResponseDto }; }; export type Action = { diff --git a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte new file mode 100644 index 0000000000000..4459cd526b683 --- /dev/null +++ b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte @@ -0,0 +1,82 @@ + + + diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index a5534f79d8e6d..0fa25afb16424 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -7,6 +7,7 @@ import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte'; import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte'; + import RotateAction from '$lib/components/asset-viewer/actions/rotate-action.svelte'; import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte'; import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte'; import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte'; @@ -49,6 +50,7 @@ export let onShowDetail: () => void; // export let showEditorHandler: () => void; export let onClose: () => void; + export let onSetRotation: (rotation: number) => void; const sharedLink = getSharedLink(); @@ -136,6 +138,10 @@ {/if} {#if isOwner} + {#if !asset.isTrashed} + + + {/if} {#if hasStackChildren} {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 0c8481805ae75..eb5020dbc54fb 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -89,6 +89,7 @@ let unsubscribes: (() => void)[] = []; let zoomToggle = () => void 0; let copyImage: () => Promise; + let setRotation = (_: number) => void 0; $: isFullScreen = fullscreenElement !== null; @@ -431,6 +432,7 @@ onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} onShowDetail={toggleDetailPanel} onClose={closeViewer} + onSetRotation={setRotation} > {:else} - + {/if} {:else} Promise) | null = null; export let zoomToggle: (() => void) | null = null; + export let setRotation: ((rotation: number) => void) | null = null; const { slideshowState, slideshowLook } = slideshowStore; @@ -101,6 +102,13 @@ $zoomed = $zoomed ? false : true; }; + setRotation = (rotation: number) => { + photoZoomState.update((state) => { + state.currentRotation = rotation; + return state; + }); + }; + const onCopyShortcut = (event: KeyboardEvent) => { if (window.getSelection()?.type === 'Range') { return; @@ -132,7 +140,11 @@ {:else if !imageError} -
+
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} Date: Sun, 11 Aug 2024 13:24:00 +0000 Subject: [PATCH 04/25] feat(web): refresh thumbnail after rotate --- .../asset-viewer/actions/rotate-action.svelte | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte index 4459cd526b683..ad2e700f99433 100644 --- a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte @@ -10,20 +10,19 @@ import { mdiRotateLeft, mdiRotateRight } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { OnAction } from './action'; + import { photoViewer } from '$lib/stores/assets.store'; export let asset: AssetResponseDto; export let onAction: OnAction; export let onSetRotation: (rotation: number) => void; export let to: 'left' | 'right'; - $: orientation = asset.exifInfo?.orientation || '1'; - let angle = 0; const handleRotateAsset = async () => { let newOrientation = '1'; try { - switch (orientation) { + switch (asset.exifInfo?.orientation) { case '0': case '1': { newOrientation = to === 'left' ? '8' : '6'; @@ -59,6 +58,7 @@ } } await updateAsset({ id: asset.id, updateAssetDto: { orientation: newOrientation } }); + asset.exifInfo = { ...asset.exifInfo, orientation: newOrientation }; await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name: AssetJobName.RegenerateThumbnail } }); notificationController.show({ type: NotificationType.Info, @@ -66,12 +66,16 @@ }); onAction({ type: AssetAction.ROTATE, asset }); - angle += 90; - onSetRotation(angle); + angle += to === 'left' ? -90 : 90; + setTimeout(() => { + // force the image to refresh the thumbnail + const oldSrc = new URL($photoViewer!.src); + oldSrc.searchParams.set('t', Date.now().toString()); + $photoViewer!.src = oldSrc.toString(); + }, 500); } catch (error) { handleError(error, $t('errors.unable_to_edit_asset')); } - orientation = newOrientation; }; From d471e581222a5b3f7dd5ad5be40454c049ecbb45 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Sun, 11 Aug 2024 15:18:30 +0000 Subject: [PATCH 05/25] feat(web): remove attempt to refresh view --- .../asset-viewer/actions/rotate-action.svelte | 1 - .../asset-viewer/asset-viewer-nav-bar.svelte | 5 ++--- .../lib/components/asset-viewer/asset-viewer.svelte | 12 +----------- .../lib/components/asset-viewer/photo-viewer.svelte | 8 -------- 4 files changed, 3 insertions(+), 23 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte index ad2e700f99433..461f1fc480f73 100644 --- a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte @@ -14,7 +14,6 @@ export let asset: AssetResponseDto; export let onAction: OnAction; - export let onSetRotation: (rotation: number) => void; export let to: 'left' | 'right'; let angle = 0; diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 0fa25afb16424..e6c8d0afcf3a2 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -50,7 +50,6 @@ export let onShowDetail: () => void; // export let showEditorHandler: () => void; export let onClose: () => void; - export let onSetRotation: (rotation: number) => void; const sharedLink = getSharedLink(); @@ -139,8 +138,8 @@ {#if isOwner} {#if !asset.isTrashed} - - + + {/if} {#if hasStackChildren} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index eb5020dbc54fb..0c8481805ae75 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -89,7 +89,6 @@ let unsubscribes: (() => void)[] = []; let zoomToggle = () => void 0; let copyImage: () => Promise; - let setRotation = (_: number) => void 0; $: isFullScreen = fullscreenElement !== null; @@ -432,7 +431,6 @@ onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} onShowDetail={toggleDetailPanel} onClose={closeViewer} - onSetRotation={setRotation} > {:else} - + {/if} {:else} Promise) | null = null; export let zoomToggle: (() => void) | null = null; - export let setRotation: ((rotation: number) => void) | null = null; const { slideshowState, slideshowLook } = slideshowStore; @@ -102,13 +101,6 @@ $zoomed = $zoomed ? false : true; }; - setRotation = (rotation: number) => { - photoZoomState.update((state) => { - state.currentRotation = rotation; - return state; - }); - }; - const onCopyShortcut = (event: KeyboardEvent) => { if (window.getSelection()?.type === 'Range') { return; From c1c118a6c50961e3b5e010ee6e4e9f212ef80fd2 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Wed, 14 Aug 2024 11:48:42 +0000 Subject: [PATCH 06/25] chore: use static key and lookup formula --- .../asset-viewer/actions/rotate-action.svelte | 52 ++++--------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte index 461f1fc480f73..f3e29e7a60d02 100644 --- a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte @@ -6,7 +6,7 @@ } from '$lib/components/shared-components/notification/notification'; import { AssetAction } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; - import { AssetJobName, runAssetJobs, updateAsset, type AssetResponseDto } from '@immich/sdk'; + import { AssetJobName, ExifOrientation, runAssetJobs, updateAsset, type AssetResponseDto } from '@immich/sdk'; import { mdiRotateLeft, mdiRotateRight } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { OnAction } from './action'; @@ -16,46 +16,17 @@ export let onAction: OnAction; export let to: 'left' | 'right'; - let angle = 0; - const handleRotateAsset = async () => { - let newOrientation = '1'; + // every rotation, in order + // interleaved normal / mirrored + const orientations = ['1', '2', '6', '7', '3', '4', '8', '5']; + let index = orientations.indexOf(asset.exifInfo?.orientation ?? '1'); + if (index === -1) { + index = 0; + } + index = (to === 'right' ? index + 2 : index - 2 + orientations.length) % orientations.length; + const newOrientation = orientations[index] as ExifOrientation; try { - switch (asset.exifInfo?.orientation) { - case '0': - case '1': { - newOrientation = to === 'left' ? '8' : '6'; - break; - } - case '2': { - newOrientation = to === 'left' ? '5' : '7'; - break; - } - case '3': { - newOrientation = to === 'left' ? '6' : '8'; - break; - } - case '4': { - newOrientation = to === 'left' ? '7' : '5'; - break; - } - case '5': { - newOrientation = to === 'left' ? '4' : '2'; - break; - } - case '6': { - newOrientation = to === 'left' ? '1' : '3'; - break; - } - case '7': { - newOrientation = to === 'left' ? '2' : '4'; - break; - } - case '8': { - newOrientation = to === 'left' ? '3' : '1'; - break; - } - } await updateAsset({ id: asset.id, updateAssetDto: { orientation: newOrientation } }); asset.exifInfo = { ...asset.exifInfo, orientation: newOrientation }; await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name: AssetJobName.RegenerateThumbnail } }); @@ -65,7 +36,6 @@ }); onAction({ type: AssetAction.ROTATE, asset }); - angle += to === 'left' ? -90 : 90; setTimeout(() => { // force the image to refresh the thumbnail const oldSrc = new URL($photoViewer!.src); @@ -81,5 +51,5 @@ From df17f250fc0ba440050b9f60ca107b3c0e8a96f7 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Wed, 14 Aug 2024 11:49:45 +0000 Subject: [PATCH 07/25] style: fix formatting --- server/src/repositories/media.repository.ts | 6 ++++-- server/src/services/media.service.ts | 16 +++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index feaec6cf5b139..0afeea02d2f10 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -46,8 +46,10 @@ export class MediaRepository implements IMediaRepository { async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise { // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes - const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false }) - .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') + const pipeline = sharp(input, { + failOn: options.processInvalidImages ? 'none' : 'error', + limitInputPixels: false, + }).pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16'); if (options.mirror) { pipeline.flop(); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index e3b057814d47b..c80463986a1bc 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -208,7 +208,7 @@ export class MediaService { quality: image.quality, processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', angle: 0, - mirror: false + mirror: false, }; if (asset.exifInfo?.orientation) { @@ -231,12 +231,14 @@ export class MediaService { break; } - if ([ - ExifOrientation.MirrorHorizontal, - ExifOrientation.MirrorVertical, - ExifOrientation.MirrorHorizontalRotate90CW, - ExifOrientation.MirrorHorizontalRotate270CW, - ].includes(asset.exifInfo.orientation as ExifOrientation)) { + if ( + [ + ExifOrientation.MirrorHorizontal, + ExifOrientation.MirrorVertical, + ExifOrientation.MirrorHorizontalRotate90CW, + ExifOrientation.MirrorHorizontalRotate270CW, + ].includes(asset.exifInfo.orientation as ExifOrientation) + ) { imageOptions.mirror = true; } } From 73795b28941d00f51051c9bdc2a404650af794f8 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Wed, 14 Aug 2024 12:44:00 +0000 Subject: [PATCH 08/25] chore: fix tests --- server/src/services/media.service.spec.ts | 18 ++++++++++++++++++ server/src/services/metadata.service.spec.ts | 3 +++ server/src/services/metadata.service.ts | 2 +- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 7bb201f78f0cf..d32717b64e9a6 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -297,6 +297,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.SRGB, processInvalidImages: false, + angle: 0, + mirror: false, }); expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath }); }); @@ -328,6 +330,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ); expect(assetMock.update).toHaveBeenCalledWith({ @@ -471,6 +475,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.SRGB, processInvalidImages: false, + angle: 0, + mirror: false, }); expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath }); }, @@ -502,6 +508,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ); expect(assetMock.update).toHaveBeenCalledWith({ @@ -529,6 +537,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ], ]); @@ -554,6 +564,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ], ]); @@ -577,6 +589,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ); expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); @@ -598,6 +612,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ); expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); @@ -619,6 +635,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: true, + angle: 0, + mirror: false, }, ); expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index f954a2ddfd640..a37eeae603959 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -934,6 +934,7 @@ describe(MetadataService.name, () => { const description = 'this is a description'; const gps = 12; const date = '2023-11-22T04:56:12.196Z'; + const orientation = 6; assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); await expect( @@ -943,6 +944,7 @@ describe(MetadataService.name, () => { latitude: gps, longitude: gps, dateTimeOriginal: date, + orientation: orientation.toString(), }), ).resolves.toBe(JobStatus.SUCCESS); expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { @@ -951,6 +953,7 @@ describe(MetadataService.name, () => { DateTimeOriginal: date, GPSLatitude: gps, GPSLongitude: gps, + 'Orientation#': orientation, }); }); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 397cc7fe6bcbf..34b31ccfb5197 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -276,7 +276,7 @@ export class MetadataService implements OnEvents { DateTimeOriginal: dateTimeOriginal, GPSLatitude: latitude, GPSLongitude: longitude, - 'Orientation#': Number.parseInt(orientation ?? '1', 10), + 'Orientation#': _.isUndefined(orientation) ? undefined : Number.parseInt(orientation ?? '1', 10), Rating: rating, }, _.isUndefined, From a635e67657c36fda5bf0c8c5dc9b1371711c0c1d Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Wed, 14 Aug 2024 16:18:47 +0000 Subject: [PATCH 09/25] chore: fix stuff --- .../openapi/lib/model/update_asset_dto.dart | 105 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 12 +- .../src/repositories/metadata.repository.ts | 2 +- server/src/services/media.service.ts | 12 +- 4 files changed, 124 insertions(+), 7 deletions(-) diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 391836c444bb3..ab454a31e416a 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -19,6 +19,7 @@ class UpdateAssetDto { this.isFavorite, this.latitude, this.longitude, + this.orientation, this.rating, }); @@ -70,6 +71,8 @@ class UpdateAssetDto { /// num? longitude; + UpdateAssetDtoOrientationEnum? orientation; + /// Minimum value: 0 /// Maximum value: 5 /// @@ -88,6 +91,7 @@ class UpdateAssetDto { other.isFavorite == isFavorite && other.latitude == latitude && other.longitude == longitude && + other.orientation == orientation && other.rating == rating; @override @@ -99,10 +103,11 @@ class UpdateAssetDto { (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + + (orientation == null ? 0 : orientation!.hashCode) + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; + String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, orientation=$orientation, rating=$rating]'; Map toJson() { final json = {}; @@ -136,6 +141,11 @@ class UpdateAssetDto { } else { // json[r'longitude'] = null; } + if (this.orientation != null) { + json[r'orientation'] = this.orientation; + } else { + // json[r'orientation'] = null; + } if (this.rating != null) { json[r'rating'] = this.rating; } else { @@ -158,6 +168,7 @@ class UpdateAssetDto { isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), + orientation: UpdateAssetDtoOrientationEnum.fromJson(json[r'orientation']), rating: num.parse('${json[r'rating']}'), ); } @@ -209,3 +220,95 @@ class UpdateAssetDto { }; } + +class UpdateAssetDtoOrientationEnum { + /// Instantiate a new enum with the provided [value]. + const UpdateAssetDtoOrientationEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const n1 = UpdateAssetDtoOrientationEnum._(r'1'); + static const n2 = UpdateAssetDtoOrientationEnum._(r'2'); + static const n3 = UpdateAssetDtoOrientationEnum._(r'3'); + static const n4 = UpdateAssetDtoOrientationEnum._(r'4'); + static const n5 = UpdateAssetDtoOrientationEnum._(r'5'); + static const n6 = UpdateAssetDtoOrientationEnum._(r'6'); + static const n7 = UpdateAssetDtoOrientationEnum._(r'7'); + static const n8 = UpdateAssetDtoOrientationEnum._(r'8'); + + /// List of all possible values in this [enum][UpdateAssetDtoOrientationEnum]. + static const values = [ + n1, + n2, + n3, + n4, + n5, + n6, + n7, + n8, + ]; + + static UpdateAssetDtoOrientationEnum? fromJson(dynamic value) => UpdateAssetDtoOrientationEnumTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = UpdateAssetDtoOrientationEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [UpdateAssetDtoOrientationEnum] to String, +/// and [decode] dynamic data back to [UpdateAssetDtoOrientationEnum]. +class UpdateAssetDtoOrientationEnumTypeTransformer { + factory UpdateAssetDtoOrientationEnumTypeTransformer() => _instance ??= const UpdateAssetDtoOrientationEnumTypeTransformer._(); + + const UpdateAssetDtoOrientationEnumTypeTransformer._(); + + String encode(UpdateAssetDtoOrientationEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a UpdateAssetDtoOrientationEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + UpdateAssetDtoOrientationEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'1': return UpdateAssetDtoOrientationEnum.n1; + case r'2': return UpdateAssetDtoOrientationEnum.n2; + case r'3': return UpdateAssetDtoOrientationEnum.n3; + case r'4': return UpdateAssetDtoOrientationEnum.n4; + case r'5': return UpdateAssetDtoOrientationEnum.n5; + case r'6': return UpdateAssetDtoOrientationEnum.n6; + case r'7': return UpdateAssetDtoOrientationEnum.n7; + case r'8': return UpdateAssetDtoOrientationEnum.n8; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [UpdateAssetDtoOrientationEnumTypeTransformer] instance. + static UpdateAssetDtoOrientationEnumTypeTransformer? _instance; +} + + diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 75ff6238b028f..7bc9e238fa189 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -391,7 +391,7 @@ export type UpdateAssetDto = { isFavorite?: boolean; latitude?: number; longitude?: number; - orientation?: string; + orientation?: Orientation; rating?: number; }; export type AssetMediaReplaceDto = { @@ -3142,6 +3142,16 @@ export enum AssetJobName { RefreshMetadata = "refresh-metadata", TranscodeVideo = "transcode-video" } +export enum Orientation { + $1 = "1", + $2 = "2", + $3 = "3", + $4 = "4", + $5 = "5", + $6 = "6", + $7 = "7", + $8 = "8" +} export enum AssetMediaSize { Preview = "preview", Thumbnail = "thumbnail" diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 9e66f4a96b5bd..cfc1565eab8cc 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DefaultReadTaskOptions, ExifTool, Tags, WriteTags } from 'exiftool-vendored'; +import { DefaultReadTaskOptions, ExifTool, WriteTags } from 'exiftool-vendored'; import geotz from 'geo-tz'; import { DummyValue, GenerateSql } from 'src/decorators'; import { ExifEntity } from 'src/entities/exif.entity'; diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index c80463986a1bc..c494e6287d0c0 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -214,21 +214,25 @@ export class MediaService { if (asset.exifInfo?.orientation) { switch (asset.exifInfo.orientation) { case ExifOrientation.Horizontal: - case ExifOrientation.MirrorHorizontal: + case ExifOrientation.MirrorHorizontal: { imageOptions.angle = 0; break; + } case ExifOrientation.Rotate180: - case ExifOrientation.MirrorVertical: + case ExifOrientation.MirrorVertical: { imageOptions.angle = 180; break; + } case ExifOrientation.Rotate90CW: - case ExifOrientation.MirrorHorizontalRotate90CW: + case ExifOrientation.MirrorHorizontalRotate90CW: { imageOptions.angle = 90; break; + } case ExifOrientation.MirrorHorizontalRotate270CW: - case ExifOrientation.Rotate270CW: + case ExifOrientation.Rotate270CW: { imageOptions.angle = 270; break; + } } if ( From 5b33ff1f59cd33b6b1c32da30eb7567d283427c5 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Wed, 14 Aug 2024 16:35:50 +0000 Subject: [PATCH 10/25] chore: restore commit --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + mobile/openapi/lib/api_helper.dart | 3 + .../openapi/lib/model/exif_orientation.dart | 103 ++++++++++++++++++ .../openapi/lib/model/update_asset_dto.dart | 102 ++--------------- open-api/immich-openapi-specs.json | 25 +++-- open-api/typescript-sdk/src/fetch-client.ts | 4 +- server/src/dtos/asset.dto.ts | 7 +- server/src/services/media.service.ts | 76 ++++++------- 10 files changed, 176 insertions(+), 148 deletions(-) create mode 100644 mobile/openapi/lib/model/exif_orientation.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 247fe9e8c4a4e..5d3bdff3c1ca8 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -317,6 +317,7 @@ Class | Method | HTTP request | Description - [EmailNotificationsResponse](doc//EmailNotificationsResponse.md) - [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md) - [EntityType](doc//EntityType.md) + - [ExifOrientation](doc//ExifOrientation.md) - [ExifResponseDto](doc//ExifResponseDto.md) - [FaceDto](doc//FaceDto.md) - [FacialRecognitionConfig](doc//FacialRecognitionConfig.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index bbe680731e2db..afda98abb15aa 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -129,6 +129,7 @@ part 'model/duplicate_response_dto.dart'; part 'model/email_notifications_response.dart'; part 'model/email_notifications_update.dart'; part 'model/entity_type.dart'; +part 'model/exif_orientation.dart'; part 'model/exif_response_dto.dart'; part 'model/face_dto.dart'; part 'model/facial_recognition_config.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 01c646d393cfc..f3a781f2936cb 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -315,6 +315,8 @@ class ApiClient { return EmailNotificationsUpdate.fromJson(value); case 'EntityType': return EntityTypeTypeTransformer().decode(value); + case 'ExifOrientation': + return ExifOrientationTypeTransformer().decode(value); case 'ExifResponseDto': return ExifResponseDto.fromJson(value); case 'FaceDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 04fcaa3463e48..bd5ad0a57a1c9 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -85,6 +85,9 @@ String parameterToString(dynamic value) { if (value is EntityType) { return EntityTypeTypeTransformer().encode(value).toString(); } + if (value is ExifOrientation) { + return ExifOrientationTypeTransformer().encode(value).toString(); + } if (value is ImageFormat) { return ImageFormatTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/exif_orientation.dart b/mobile/openapi/lib/model/exif_orientation.dart new file mode 100644 index 0000000000000..ae76b09c0a3cb --- /dev/null +++ b/mobile/openapi/lib/model/exif_orientation.dart @@ -0,0 +1,103 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ExifOrientation { + /// Instantiate a new enum with the provided [value]. + const ExifOrientation._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const n1 = ExifOrientation._(r'1'); + static const n2 = ExifOrientation._(r'2'); + static const n3 = ExifOrientation._(r'3'); + static const n4 = ExifOrientation._(r'4'); + static const n5 = ExifOrientation._(r'5'); + static const n6 = ExifOrientation._(r'6'); + static const n7 = ExifOrientation._(r'7'); + static const n8 = ExifOrientation._(r'8'); + + /// List of all possible values in this [enum][ExifOrientation]. + static const values = [ + n1, + n2, + n3, + n4, + n5, + n6, + n7, + n8, + ]; + + static ExifOrientation? fromJson(dynamic value) => ExifOrientationTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ExifOrientation.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ExifOrientation] to String, +/// and [decode] dynamic data back to [ExifOrientation]. +class ExifOrientationTypeTransformer { + factory ExifOrientationTypeTransformer() => _instance ??= const ExifOrientationTypeTransformer._(); + + const ExifOrientationTypeTransformer._(); + + String encode(ExifOrientation data) => data.value; + + /// Decodes a [dynamic value][data] to a ExifOrientation. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ExifOrientation? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'1': return ExifOrientation.n1; + case r'2': return ExifOrientation.n2; + case r'3': return ExifOrientation.n3; + case r'4': return ExifOrientation.n4; + case r'5': return ExifOrientation.n5; + case r'6': return ExifOrientation.n6; + case r'7': return ExifOrientation.n7; + case r'8': return ExifOrientation.n8; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ExifOrientationTypeTransformer] instance. + static ExifOrientationTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index ab454a31e416a..f59f61ab7227e 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -71,7 +71,13 @@ class UpdateAssetDto { /// num? longitude; - UpdateAssetDtoOrientationEnum? orientation; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + ExifOrientation? orientation; /// Minimum value: 0 /// Maximum value: 5 @@ -168,7 +174,7 @@ class UpdateAssetDto { isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), - orientation: UpdateAssetDtoOrientationEnum.fromJson(json[r'orientation']), + orientation: ExifOrientation.fromJson(json[r'orientation']), rating: num.parse('${json[r'rating']}'), ); } @@ -220,95 +226,3 @@ class UpdateAssetDto { }; } - -class UpdateAssetDtoOrientationEnum { - /// Instantiate a new enum with the provided [value]. - const UpdateAssetDtoOrientationEnum._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const n1 = UpdateAssetDtoOrientationEnum._(r'1'); - static const n2 = UpdateAssetDtoOrientationEnum._(r'2'); - static const n3 = UpdateAssetDtoOrientationEnum._(r'3'); - static const n4 = UpdateAssetDtoOrientationEnum._(r'4'); - static const n5 = UpdateAssetDtoOrientationEnum._(r'5'); - static const n6 = UpdateAssetDtoOrientationEnum._(r'6'); - static const n7 = UpdateAssetDtoOrientationEnum._(r'7'); - static const n8 = UpdateAssetDtoOrientationEnum._(r'8'); - - /// List of all possible values in this [enum][UpdateAssetDtoOrientationEnum]. - static const values = [ - n1, - n2, - n3, - n4, - n5, - n6, - n7, - n8, - ]; - - static UpdateAssetDtoOrientationEnum? fromJson(dynamic value) => UpdateAssetDtoOrientationEnumTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = UpdateAssetDtoOrientationEnum.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [UpdateAssetDtoOrientationEnum] to String, -/// and [decode] dynamic data back to [UpdateAssetDtoOrientationEnum]. -class UpdateAssetDtoOrientationEnumTypeTransformer { - factory UpdateAssetDtoOrientationEnumTypeTransformer() => _instance ??= const UpdateAssetDtoOrientationEnumTypeTransformer._(); - - const UpdateAssetDtoOrientationEnumTypeTransformer._(); - - String encode(UpdateAssetDtoOrientationEnum data) => data.value; - - /// Decodes a [dynamic value][data] to a UpdateAssetDtoOrientationEnum. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - UpdateAssetDtoOrientationEnum? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'1': return UpdateAssetDtoOrientationEnum.n1; - case r'2': return UpdateAssetDtoOrientationEnum.n2; - case r'3': return UpdateAssetDtoOrientationEnum.n3; - case r'4': return UpdateAssetDtoOrientationEnum.n4; - case r'5': return UpdateAssetDtoOrientationEnum.n5; - case r'6': return UpdateAssetDtoOrientationEnum.n6; - case r'7': return UpdateAssetDtoOrientationEnum.n7; - case r'8': return UpdateAssetDtoOrientationEnum.n8; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [UpdateAssetDtoOrientationEnumTypeTransformer] instance. - static UpdateAssetDtoOrientationEnumTypeTransformer? _instance; -} - - diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 77b775da811ea..cd25064dd9bf0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8607,6 +8607,19 @@ ], "type": "string" }, + "ExifOrientation": { + "enum": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8" + ], + "type": "string" + }, "ExifResponseDto": { "properties": { "city": { @@ -11597,17 +11610,7 @@ "type": "number" }, "orientation": { - "enum": [ - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8" - ], - "type": "string" + "$ref": "#/components/schemas/ExifOrientation" }, "rating": { "maximum": 5, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7bc9e238fa189..02d36828894cb 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -391,7 +391,7 @@ export type UpdateAssetDto = { isFavorite?: boolean; latitude?: number; longitude?: number; - orientation?: Orientation; + orientation?: ExifOrientation; rating?: number; }; export type AssetMediaReplaceDto = { @@ -3142,7 +3142,7 @@ export enum AssetJobName { RefreshMetadata = "refresh-metadata", TranscodeVideo = "transcode-video" } -export enum Orientation { +export enum ExifOrientation { $1 = "1", $2 = "2", $3 = "3", diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index c432a27feecc7..e256b39b91cf4 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -3,7 +3,6 @@ import { Type } from 'class-transformer'; import { IsDateString, IsEnum, - IsIn, IsInt, IsLatitude, IsLongitude, @@ -17,6 +16,7 @@ import { import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AssetType } from 'src/entities/asset.entity'; import { AssetStats } from 'src/interfaces/asset.interface'; +import { ExifOrientation } from 'src/interfaces/metadata.interface'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @@ -77,8 +77,9 @@ export class UpdateAssetDto extends UpdateAssetBase { description?: string; @Optional() - @IsIn(['1', '2', '3', '4', '5', '6', '7', '8']) - orientation?: string; + @ApiProperty({ enumName: 'ExifOrientation', enum: ExifOrientation }) + @IsEnum(ExifOrientation) + orientation?: ExifOrientation; } export class RandomAssetsDto { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index c494e6287d0c0..0a45343506d41 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -207,46 +207,11 @@ export class MediaService { colorspace, quality: image.quality, processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - angle: 0, - mirror: false, + ...this.decodeExifOrientation( + (asset.exifInfo?.orientation as ExifOrientation) ?? ExifOrientation.Horizontal, + ), }; - if (asset.exifInfo?.orientation) { - switch (asset.exifInfo.orientation) { - case ExifOrientation.Horizontal: - case ExifOrientation.MirrorHorizontal: { - imageOptions.angle = 0; - break; - } - case ExifOrientation.Rotate180: - case ExifOrientation.MirrorVertical: { - imageOptions.angle = 180; - break; - } - case ExifOrientation.Rotate90CW: - case ExifOrientation.MirrorHorizontalRotate90CW: { - imageOptions.angle = 90; - break; - } - case ExifOrientation.MirrorHorizontalRotate270CW: - case ExifOrientation.Rotate270CW: { - imageOptions.angle = 270; - break; - } - } - - if ( - [ - ExifOrientation.MirrorHorizontal, - ExifOrientation.MirrorVertical, - ExifOrientation.MirrorHorizontalRotate90CW, - ExifOrientation.MirrorHorizontalRotate270CW, - ].includes(asset.exifInfo.orientation as ExifOrientation) - ) { - imageOptions.mirror = true; - } - } - const outputPath = useExtracted ? extractedPath : asset.originalPath; await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions); } finally { @@ -517,6 +482,41 @@ export class MediaService { } } + private decodeExifOrientation(orientation: ExifOrientation) { + let angle; + switch (orientation) { + case ExifOrientation.Rotate180: + case ExifOrientation.MirrorVertical: { + angle = 180; + break; + } + case ExifOrientation.Rotate90CW: + case ExifOrientation.MirrorHorizontalRotate90CW: { + angle = 90; + break; + } + case ExifOrientation.MirrorHorizontalRotate270CW: + case ExifOrientation.Rotate270CW: { + angle = 270; + break; + } + case ExifOrientation.Horizontal: + case ExifOrientation.MirrorHorizontal: + default: { + angle = 0; + break; + } + } + const mirror = [ + ExifOrientation.MirrorHorizontal, + ExifOrientation.MirrorVertical, + ExifOrientation.MirrorHorizontalRotate90CW, + ExifOrientation.MirrorHorizontalRotate270CW, + ].includes(orientation); + + return { angle, mirror }; + } + private parseBitrateToBps(bitrateString: string) { const bitrateValue = Number.parseInt(bitrateString); From d066d624d004a925a013b345a24216fe73232894 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Wed, 14 Aug 2024 16:39:13 +0000 Subject: [PATCH 11/25] chore: lint --- server/src/services/media.service.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 0a45343506d41..4579bfdbc4c04 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -483,7 +483,7 @@ export class MediaService { } private decodeExifOrientation(orientation: ExifOrientation) { - let angle; + let angle = 0; switch (orientation) { case ExifOrientation.Rotate180: case ExifOrientation.MirrorVertical: { @@ -501,8 +501,7 @@ export class MediaService { break; } case ExifOrientation.Horizontal: - case ExifOrientation.MirrorHorizontal: - default: { + case ExifOrientation.MirrorHorizontal: { angle = 0; break; } From e5dbed7f589bad69ea5dc022a59d416307cc89da Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Wed, 14 Aug 2024 17:32:57 +0000 Subject: [PATCH 12/25] feat: add support for xmp crop data in api and thumbnails --- e2e/test-assets | 2 +- .../openapi/lib/model/update_asset_dto.dart | 19 ++++++++++++++- open-api/immich-openapi-specs.json | 23 +++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 1 + server/src/dtos/asset.dto.ts | 5 +++- server/src/dtos/exif.dto.ts | 8 +++++++ server/src/entities/exif.entity.ts | 13 +++++++++++ server/src/interfaces/job.interface.ts | 2 ++ server/src/interfaces/media.interface.ts | 8 +------ server/src/interfaces/metadata.interface.ts | 7 ++++++ .../1723654247229-AddXmpCropData.ts | 20 ++++++++++++++++ server/src/repositories/media.repository.ts | 12 ++++++---- server/src/services/asset.service.ts | 4 ++-- server/src/services/media.service.ts | 6 +++++ server/src/services/metadata.service.ts | 10 +++++++- server/src/services/person.service.ts | 3 ++- 16 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 server/src/migrations/1723654247229-AddXmpCropData.ts diff --git a/e2e/test-assets b/e2e/test-assets index 898069e47f8e3..4e9731d3fc270 160000 --- a/e2e/test-assets +++ b/e2e/test-assets @@ -1 +1 @@ -Subproject commit 898069e47f8e3283bf3bbd40b58b56d8fd57dc65 +Subproject commit 4e9731d3fc270fe25901f72a6b6f57277cdb8a30 diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index f59f61ab7227e..bb10bcea1124c 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class UpdateAssetDto { /// Returns a new [UpdateAssetDto] instance. UpdateAssetDto({ + this.crop, this.dateTimeOriginal, this.description, this.isArchived, @@ -23,6 +24,14 @@ class UpdateAssetDto { this.rating, }); + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Object? crop; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -91,6 +100,7 @@ class UpdateAssetDto { @override bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto && + other.crop == crop && other.dateTimeOriginal == dateTimeOriginal && other.description == description && other.isArchived == isArchived && @@ -103,6 +113,7 @@ class UpdateAssetDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (crop == null ? 0 : crop!.hashCode) + (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + (description == null ? 0 : description!.hashCode) + (isArchived == null ? 0 : isArchived!.hashCode) + @@ -113,10 +124,15 @@ class UpdateAssetDto { (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, orientation=$orientation, rating=$rating]'; + String toString() => 'UpdateAssetDto[crop=$crop, dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, orientation=$orientation, rating=$rating]'; Map toJson() { final json = {}; + if (this.crop != null) { + json[r'crop'] = this.crop; + } else { + // json[r'crop'] = null; + } if (this.dateTimeOriginal != null) { json[r'dateTimeOriginal'] = this.dateTimeOriginal; } else { @@ -168,6 +184,7 @@ class UpdateAssetDto { final json = value.cast(); return UpdateAssetDto( + crop: mapValueOfType(json, r'crop'), dateTimeOriginal: mapValueOfType(json, r'dateTimeOriginal'), description: mapValueOfType(json, r'description'), isArchived: mapValueOfType(json, r'isArchived'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index cd25064dd9bf0..2263ad7bf91fa 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8632,6 +8632,26 @@ "nullable": true, "type": "string" }, + "cropHeight": { + "default": null, + "nullable": true, + "type": "number" + }, + "cropLeft": { + "default": null, + "nullable": true, + "type": "number" + }, + "cropTop": { + "default": null, + "nullable": true, + "type": "number" + }, + "cropWidth": { + "default": null, + "nullable": true, + "type": "number" + }, "dateTimeOriginal": { "default": null, "format": "date-time", @@ -11591,6 +11611,9 @@ }, "UpdateAssetDto": { "properties": { + "crop": { + "type": "object" + }, "dateTimeOriginal": { "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 02d36828894cb..2e80f963faeb3 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -385,6 +385,7 @@ export type AssetStatsResponseDto = { videos: number; }; export type UpdateAssetDto = { + crop?: object; dateTimeOriginal?: string; description?: string; isArchived?: boolean; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index e256b39b91cf4..25e0096cf245a 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -16,7 +16,7 @@ import { import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AssetType } from 'src/entities/asset.entity'; import { AssetStats } from 'src/interfaces/asset.interface'; -import { ExifOrientation } from 'src/interfaces/metadata.interface'; +import { CropOptions, ExifOrientation } from 'src/interfaces/metadata.interface'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @@ -80,6 +80,9 @@ export class UpdateAssetDto extends UpdateAssetBase { @ApiProperty({ enumName: 'ExifOrientation', enum: ExifOrientation }) @IsEnum(ExifOrientation) orientation?: ExifOrientation; + + @Optional() + crop?: CropOptions; } export class RandomAssetsDto { diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 079891ae56cb7..0864b2f2568c7 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -26,6 +26,10 @@ export class ExifResponseDto { description?: string | null = null; projectionType?: string | null = null; rating?: number | null = null; + cropLeft?: number | null = null; + cropTop?: number | null = null; + cropWidth?: number | null = null; + cropHeight?: number | null = null; } export function mapExif(entity: ExifEntity): ExifResponseDto { @@ -52,6 +56,10 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { description: entity.description, projectionType: entity.projectionType, rating: entity.rating, + cropLeft: entity.cropLeft, + cropTop: entity.cropTop, + cropWidth: entity.cropWidth, + cropHeight: entity.cropHeight, }; } diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts index c9c29d732a3d9..60d4516a93c43 100644 --- a/server/src/entities/exif.entity.ts +++ b/server/src/entities/exif.entity.ts @@ -101,4 +101,17 @@ export class ExifEntity { /* Video info */ @Column({ type: 'float8', nullable: true }) fps?: number | null; + + /* User crop options */ + @Column({ type: 'integer', nullable: true }) + cropLeft!: number | null; + + @Column({ type: 'integer', nullable: true }) + cropTop!: number | null; + + @Column({ type: 'integer', nullable: true }) + cropWidth!: number | null; + + @Column({ type: 'integer', nullable: true }) + cropHeight!: number | null; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 1603205b2a942..87747703fe31c 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -1,3 +1,4 @@ +import { CropOptions } from 'src/interfaces/metadata.interface'; import { EmailImageAttachment } from 'src/interfaces/notification.interface'; export enum QueueName { @@ -149,6 +150,7 @@ export interface ISidecarWriteJob extends IEntityJob { longitude?: number; orientation?: string; rating?: number; + crop?: CropOptions; } export interface IDeferrableJob extends IEntityJob { diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index 0bf8140078522..451df398989da 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -1,15 +1,9 @@ import { Writable } from 'node:stream'; import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/config'; +import { CropOptions } from 'src/interfaces/metadata.interface'; export const IMediaRepository = 'IMediaRepository'; -export interface CropOptions { - top: number; - left: number; - width: number; - height: number; -} - export interface ThumbnailOptions { size: number; format: ImageFormat; diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 73b1047fa0579..bdd854ba30ad0 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -18,6 +18,13 @@ export enum ExifOrientation { Rotate270CW = '8', } +export interface CropOptions { + top: number; + left: number; + width: number; + height: number; +} + export interface ImmichTags extends Omit { ContentIdentifier?: string; MotionPhoto?: number; diff --git a/server/src/migrations/1723654247229-AddXmpCropData.ts b/server/src/migrations/1723654247229-AddXmpCropData.ts new file mode 100644 index 0000000000000..97b4cf0e54de9 --- /dev/null +++ b/server/src/migrations/1723654247229-AddXmpCropData.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddXmpCropData1723654247229 implements MigrationInterface { + name = 'AddXmpCropData1723654247229' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" ADD "cropLeft" integer`); + await queryRunner.query(`ALTER TABLE "exif" ADD "cropTop" integer`); + await queryRunner.query(`ALTER TABLE "exif" ADD "cropWidth" integer`); + await queryRunner.query(`ALTER TABLE "exif" ADD "cropHeight" integer`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "cropHeight"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "cropWidth"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "cropTop"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "cropLeft"`); + } + +} diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 0afeea02d2f10..c0777f39e8f8f 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -46,11 +46,17 @@ export class MediaRepository implements IMediaRepository { async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise { // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes - const pipeline = sharp(input, { + let pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false, }).pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16'); + if (options.crop) { + pipeline.extract(options.crop); + } + + pipeline = sharp(await pipeline.toBuffer()); + if (options.mirror) { pipeline.flop(); } @@ -61,10 +67,6 @@ export class MediaRepository implements IMediaRepository { pipeline.rotate(); // auto-rotate based on EXIF orientation } - if (options.crop) { - pipeline.extract(options.crop); - } - await pipeline .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }) .withIccProfile(options.colorspace) diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index d202850870830..e64986d7c1ad8 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -158,8 +158,8 @@ export class AssetService { async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); - const { description, dateTimeOriginal, latitude, longitude, orientation, rating, ...rest } = dto; - await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, orientation, rating }); + const { description, dateTimeOriginal, latitude, longitude, orientation, rating, crop, ...rest } = dto; + await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, orientation, rating, crop }); await this.assetRepository.update({ id, ...rest }); const asset = await this.assetRepository.getById(id, { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 4579bfdbc4c04..0e7da690600aa 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; +import _ from 'lodash'; import { dirname } from 'node:path'; import { AudioCodec, @@ -201,6 +202,10 @@ export class MediaService { try { const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize)); const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const { cropLeft, cropTop, cropWidth, cropHeight } = asset.exifInfo ?? {}; + const crop = _.every([cropLeft, cropTop, cropWidth, cropHeight], _.isNumber) + ? { left: cropLeft!, top: cropTop!, width: cropWidth!, height: cropHeight! } + : undefined; const imageOptions = { format, size, @@ -210,6 +215,7 @@ export class MediaService { ...this.decodeExifOrientation( (asset.exifInfo?.orientation as ExifOrientation) ?? ExifOrientation.Horizontal, ), + crop, }; const outputPath = useExtracted ? extractedPath : asset.originalPath; diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 34b31ccfb5197..b5d4f42058879 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -262,7 +262,7 @@ export class MetadataService implements OnEvents { } async handleSidecarWrite(job: ISidecarWriteJob): Promise { - const { id, description, dateTimeOriginal, latitude, longitude, orientation, rating } = job; + const { id, description, dateTimeOriginal, latitude, longitude, orientation, rating, crop } = job; const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { return JobStatus.FAILED; @@ -278,6 +278,10 @@ export class MetadataService implements OnEvents { GPSLongitude: longitude, 'Orientation#': _.isUndefined(orientation) ? undefined : Number.parseInt(orientation ?? '1', 10), Rating: rating, + CropLeft: crop?.left?.toString(), + CropTop: crop?.top.toString(), + CropWidth: crop?.width, + CropHeight: crop?.height, }, _.isUndefined, ); @@ -472,6 +476,10 @@ export class MetadataService implements OnEvents { assetId: asset.id, bitsPerSample: this.getBitsPerSample(tags), colorspace: tags.ColorSpace ?? null, + cropLeft: _.isUndefined(tags.CropLeft) ? null : Number.parseInt(tags.CropLeft), + cropTop: _.isUndefined(tags.CropTop) ? null : Number.parseInt(tags.CropTop), + cropWidth: validate(tags.CropWidth), + cropHeight: validate(tags.CropHeight), dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt, description: String(tags.ImageDescription || tags.Description || '').trim(), exifImageHeight: validate(tags.ImageHeight), diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 261c771b0d118..b9fe663f5dced 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -44,7 +44,8 @@ import { } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { BoundingBox, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { CropOptions, IMediaRepository, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; +import { IMediaRepository, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; +import { CropOptions } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; From 9b4da697a34fd51af0fcc5b7d3ab0c351d2cf7da Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Wed, 14 Aug 2024 17:41:03 +0000 Subject: [PATCH 13/25] chore: update api and fix stuff --- .../openapi/lib/model/exif_response_dto.dart | 54 ++++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 4 ++ server/src/services/metadata.service.spec.ts | 4 ++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 0185f300fac5b..38a7df2df09cc 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -15,6 +15,10 @@ class ExifResponseDto { ExifResponseDto({ this.city, this.country, + this.cropHeight, + this.cropLeft, + this.cropTop, + this.cropWidth, this.dateTimeOriginal, this.description, this.exifImageHeight, @@ -41,6 +45,14 @@ class ExifResponseDto { String? country; + num? cropHeight; + + num? cropLeft; + + num? cropTop; + + num? cropWidth; + DateTime? dateTimeOriginal; String? description; @@ -85,6 +97,10 @@ class ExifResponseDto { bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto && other.city == city && other.country == country && + other.cropHeight == cropHeight && + other.cropLeft == cropLeft && + other.cropTop == cropTop && + other.cropWidth == cropWidth && other.dateTimeOriginal == dateTimeOriginal && other.description == description && other.exifImageHeight == exifImageHeight && @@ -111,6 +127,10 @@ class ExifResponseDto { // ignore: unnecessary_parenthesis (city == null ? 0 : city!.hashCode) + (country == null ? 0 : country!.hashCode) + + (cropHeight == null ? 0 : cropHeight!.hashCode) + + (cropLeft == null ? 0 : cropLeft!.hashCode) + + (cropTop == null ? 0 : cropTop!.hashCode) + + (cropWidth == null ? 0 : cropWidth!.hashCode) + (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + (description == null ? 0 : description!.hashCode) + (exifImageHeight == null ? 0 : exifImageHeight!.hashCode) + @@ -133,7 +153,7 @@ class ExifResponseDto { (timeZone == null ? 0 : timeZone!.hashCode); @override - String toString() => 'ExifResponseDto[city=$city, country=$country, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, projectionType=$projectionType, rating=$rating, state=$state, timeZone=$timeZone]'; + String toString() => 'ExifResponseDto[city=$city, country=$country, cropHeight=$cropHeight, cropLeft=$cropLeft, cropTop=$cropTop, cropWidth=$cropWidth, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, projectionType=$projectionType, rating=$rating, state=$state, timeZone=$timeZone]'; Map toJson() { final json = {}; @@ -147,6 +167,26 @@ class ExifResponseDto { } else { // json[r'country'] = null; } + if (this.cropHeight != null) { + json[r'cropHeight'] = this.cropHeight; + } else { + // json[r'cropHeight'] = null; + } + if (this.cropLeft != null) { + json[r'cropLeft'] = this.cropLeft; + } else { + // json[r'cropLeft'] = null; + } + if (this.cropTop != null) { + json[r'cropTop'] = this.cropTop; + } else { + // json[r'cropTop'] = null; + } + if (this.cropWidth != null) { + json[r'cropWidth'] = this.cropWidth; + } else { + // json[r'cropWidth'] = null; + } if (this.dateTimeOriginal != null) { json[r'dateTimeOriginal'] = this.dateTimeOriginal!.toUtc().toIso8601String(); } else { @@ -260,6 +300,18 @@ class ExifResponseDto { return ExifResponseDto( city: mapValueOfType(json, r'city'), country: mapValueOfType(json, r'country'), + cropHeight: json[r'cropHeight'] == null + ? null + : num.parse('${json[r'cropHeight']}'), + cropLeft: json[r'cropLeft'] == null + ? null + : num.parse('${json[r'cropLeft']}'), + cropTop: json[r'cropTop'] == null + ? null + : num.parse('${json[r'cropTop']}'), + cropWidth: json[r'cropWidth'] == null + ? null + : num.parse('${json[r'cropWidth']}'), dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''), description: mapValueOfType(json, r'description'), exifImageHeight: json[r'exifImageHeight'] == null diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2e80f963faeb3..1d045e6bd7966 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -146,6 +146,10 @@ export type AlbumUserResponseDto = { export type ExifResponseDto = { city?: string | null; country?: string | null; + cropHeight?: number | null; + cropLeft?: number | null; + cropTop?: number | null; + cropWidth?: number | null; dateTimeOriginal?: string | null; description?: string | null; exifImageHeight?: number | null; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index a37eeae603959..1321f6a6badf6 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -618,6 +618,10 @@ describe(MetadataService.name, () => { bitsPerSample: expect.any(Number), autoStackId: null, colorspace: tags.ColorSpace, + cropHeight: null, + cropLeft: null, + cropTop: null, + cropWidth: null, dateTimeOriginal: new Date('1970-01-01'), description: tags.ImageDescription, exifImageHeight: null, From faa904d243a14d30cc398b9af5b6b797a5d62dbe Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Wed, 14 Aug 2024 17:55:00 +0000 Subject: [PATCH 14/25] chore: update queries and tests --- server/src/queries/asset.repository.sql | 30 ++++++++++++++++++- server/src/queries/person.repository.sql | 6 +++- server/src/queries/search.repository.sql | 6 +++- server/src/queries/shared.link.repository.sql | 8 +++++ server/test/fixtures/shared-link.stub.ts | 4 +++ 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 98fb1d6999d8f..53d5ce6e70945 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -59,7 +59,11 @@ SELECT "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", - "exifInfo"."fps" AS "exifInfo_fps" + "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight" FROM "assets" "entity" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id" @@ -180,6 +184,10 @@ SELECT "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", "AssetEntity__AssetEntity_exifInfo"."rating" AS "AssetEntity__AssetEntity_exifInfo_rating", "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", + "AssetEntity__AssetEntity_exifInfo"."cropLeft" AS "AssetEntity__AssetEntity_exifInfo_cropLeft", + "AssetEntity__AssetEntity_exifInfo"."cropTop" AS "AssetEntity__AssetEntity_exifInfo_cropTop", + "AssetEntity__AssetEntity_exifInfo"."cropWidth" AS "AssetEntity__AssetEntity_exifInfo_cropWidth", + "AssetEntity__AssetEntity_exifInfo"."cropHeight" AS "AssetEntity__AssetEntity_exifInfo_cropHeight", "AssetEntity__AssetEntity_smartInfo"."assetId" AS "AssetEntity__AssetEntity_smartInfo_assetId", "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags", "AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects", @@ -632,6 +640,10 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", "stack"."primaryAssetId" AS "stack_primaryAssetId", @@ -774,6 +786,10 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", "stack"."primaryAssetId" AS "stack_primaryAssetId", @@ -892,6 +908,10 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", "stack"."primaryAssetId" AS "stack_primaryAssetId", @@ -1060,6 +1080,10 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", "stack"."primaryAssetId" AS "stack_primaryAssetId" @@ -1137,6 +1161,10 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", "stack"."primaryAssetId" AS "stack_primaryAssetId" diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 9b20b964d8eb3..151ff2cc9ab5b 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -323,7 +323,11 @@ FROM "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", "AssetEntity__AssetEntity_exifInfo"."rating" AS "AssetEntity__AssetEntity_exifInfo_rating", - "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps" + "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", + "AssetEntity__AssetEntity_exifInfo"."cropLeft" AS "AssetEntity__AssetEntity_exifInfo_cropLeft", + "AssetEntity__AssetEntity_exifInfo"."cropTop" AS "AssetEntity__AssetEntity_exifInfo_cropTop", + "AssetEntity__AssetEntity_exifInfo"."cropWidth" AS "AssetEntity__AssetEntity_exifInfo_cropWidth", + "AssetEntity__AssetEntity_exifInfo"."cropHeight" AS "AssetEntity__AssetEntity_exifInfo_cropHeight" FROM "assets" "AssetEntity" LEFT JOIN "asset_faces" "AssetEntity__AssetEntity_faces" ON "AssetEntity__AssetEntity_faces"."assetId" = "AssetEntity"."id" diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 390aedaf35017..c061bda6db4b4 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -403,7 +403,11 @@ SELECT "exif"."colorspace" AS "exif_colorspace", "exif"."bitsPerSample" AS "exif_bitsPerSample", "exif"."rating" AS "exif_rating", - "exif"."fps" AS "exif_fps" + "exif"."fps" AS "exif_fps", + "exif"."cropLeft" AS "exif_cropLeft", + "exif"."cropTop" AS "exif_cropTop", + "exif"."cropWidth" AS "exif_cropWidth", + "exif"."cropHeight" AS "exif_cropHeight" FROM "assets" "asset" INNER JOIN "exif" "exif" ON "exif"."assetId" = "asset"."id" diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 2880e6896f506..9c29bc778f803 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -79,6 +79,10 @@ FROM "9b1d35b344d838023994a3233afd6ffe098be6d8"."bitsPerSample" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_bitsPerSample", "9b1d35b344d838023994a3233afd6ffe098be6d8"."rating" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_rating", "9b1d35b344d838023994a3233afd6ffe098be6d8"."fps" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_fps", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."cropLeft" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_cropLeft", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."cropTop" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_cropTop", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."cropWidth" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_cropWidth", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."cropHeight" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_cropHeight", "SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id", "SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId", "SharedLinkEntity__SharedLinkEntity_album"."albumName" AS "SharedLinkEntity__SharedLinkEntity_album_albumName", @@ -147,6 +151,10 @@ FROM "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."bitsPerSample" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_bitsPerSample", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."rating" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_rating", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."fps" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_fps", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."cropLeft" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_cropLeft", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."cropTop" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_cropTop", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."cropWidth" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_cropWidth", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."cropHeight" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_cropHeight", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."isAdmin" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_isAdmin", diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 1120e15e94521..6cfb8fdba03f3 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -254,6 +254,10 @@ export const sharedLinkStub = { colorspace: 'sRGB', autoStackId: null, rating: 3, + cropLeft: 100, + cropTop: 100, + cropWidth: 600, + cropHeight: 400, }, tags: [], sharedLinks: [], From 83b391f52d4a07ef49debfa240d32d5f9accd8d4 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Thu, 22 Aug 2024 18:22:39 +0000 Subject: [PATCH 15/25] wip: merge CropSettings and CropOptions to a DTO --- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/crop_options_dto.dart | 126 ++++++++++++++++++ .../openapi/lib/model/update_asset_dto.dart | 4 +- open-api/immich-openapi-specs.json | 29 +++- open-api/typescript-sdk/src/fetch-client.ts | 8 +- server/src/dtos/asset.dto.ts | 24 +++- server/src/interfaces/metadata.interface.ts | 4 +- server/src/services/metadata.service.ts | 4 +- server/src/services/person.service.ts | 4 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 26 ++-- .../asset-viewer/asset-viewer.svelte | 13 +- .../editor/crop-tool/crop-settings.ts | 9 +- .../asset-viewer/editor/crop-tool/drawing.ts | 6 +- .../editor/crop-tool/image-loading.ts | 4 +- .../editor/crop-tool/mouse-handlers.ts | 6 +- web/src/lib/stores/asset-editor.store.ts | 9 +- 18 files changed, 231 insertions(+), 49 deletions(-) create mode 100644 mobile/openapi/lib/model/crop_options_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7d27ff8d50bef..f42065d248eef 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -306,6 +306,7 @@ Class | Method | HTTP request | Description - [CreateLibraryDto](doc//CreateLibraryDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) - [CreateTagDto](doc//CreateTagDto.md) + - [CropOptionsDto](doc//CropOptionsDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponse](doc//DownloadResponse.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2b7695945305d..27b546e5caf49 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -121,6 +121,7 @@ part 'model/create_album_dto.dart'; part 'model/create_library_dto.dart'; part 'model/create_profile_image_response_dto.dart'; part 'model/create_tag_dto.dart'; +part 'model/crop_options_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index e1091bad9d85e..01463608243bc 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -297,6 +297,8 @@ class ApiClient { return CreateProfileImageResponseDto.fromJson(value); case 'CreateTagDto': return CreateTagDto.fromJson(value); + case 'CropOptionsDto': + return CropOptionsDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': diff --git a/mobile/openapi/lib/model/crop_options_dto.dart b/mobile/openapi/lib/model/crop_options_dto.dart new file mode 100644 index 0000000000000..3e39dd8cba022 --- /dev/null +++ b/mobile/openapi/lib/model/crop_options_dto.dart @@ -0,0 +1,126 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class CropOptionsDto { + /// Returns a new [CropOptionsDto] instance. + CropOptionsDto({ + required this.height, + required this.width, + required this.x, + required this.y, + }); + + /// Minimum value: 1 + num height; + + /// Minimum value: 1 + num width; + + /// Minimum value: 1 + num x; + + /// Minimum value: 1 + num y; + + @override + bool operator ==(Object other) => identical(this, other) || other is CropOptionsDto && + other.height == height && + other.width == width && + other.x == x && + other.y == y; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (height.hashCode) + + (width.hashCode) + + (x.hashCode) + + (y.hashCode); + + @override + String toString() => 'CropOptionsDto[height=$height, width=$width, x=$x, y=$y]'; + + Map toJson() { + final json = {}; + json[r'height'] = this.height; + json[r'width'] = this.width; + json[r'x'] = this.x; + json[r'y'] = this.y; + return json; + } + + /// Returns a new [CropOptionsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static CropOptionsDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return CropOptionsDto( + height: num.parse('${json[r'height']}'), + width: num.parse('${json[r'width']}'), + x: num.parse('${json[r'x']}'), + y: num.parse('${json[r'y']}'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = CropOptionsDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = CropOptionsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of CropOptionsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = CropOptionsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'height', + 'width', + 'x', + 'y', + }; +} + diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index bb10bcea1124c..294f60460c456 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -30,7 +30,7 @@ class UpdateAssetDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - Object? crop; + CropOptionsDto? crop; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -184,7 +184,7 @@ class UpdateAssetDto { final json = value.cast(); return UpdateAssetDto( - crop: mapValueOfType(json, r'crop'), + crop: CropOptionsDto.fromJson(json[r'crop']), dateTimeOriginal: mapValueOfType(json, r'dateTimeOriginal'), description: mapValueOfType(json, r'description'), isArchived: mapValueOfType(json, r'isArchived'), diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b651e7b5c6cb6..735f6cdd34a1b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8704,6 +8704,33 @@ ], "type": "object" }, + "CropOptionsDto": { + "properties": { + "height": { + "minimum": 1, + "type": "number" + }, + "width": { + "minimum": 1, + "type": "number" + }, + "x": { + "minimum": 1, + "type": "number" + }, + "y": { + "minimum": 1, + "type": "number" + } + }, + "required": [ + "height", + "width", + "x", + "y" + ], + "type": "object" + }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -12002,7 +12029,7 @@ "UpdateAssetDto": { "properties": { "crop": { - "type": "object" + "$ref": "#/components/schemas/CropOptionsDto" }, "dateTimeOriginal": { "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index b9524dcc4563e..e597d506a77c9 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -389,8 +389,14 @@ export type AssetStatsResponseDto = { total: number; videos: number; }; +export type CropOptionsDto = { + height: number; + width: number; + x: number; + y: number; +}; export type UpdateAssetDto = { - crop?: object; + crop?: CropOptionsDto; dateTimeOriginal?: string; description?: string; isArchived?: boolean; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index db5308ccf9453..6014ea6bb9636 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -65,6 +65,28 @@ export class AssetBulkUpdateDto extends UpdateAssetBase { duplicateId?: string | null; } +export class CropOptionsDto implements CropOptions { + @IsInt() + @IsPositive() + @Type(() => Number) + x!: number; + + @IsInt() + @IsPositive() + @Type(() => Number) + y!: number; + + @IsInt() + @IsPositive() + @Type(() => Number) + width!: number; + + @IsInt() + @IsPositive() + @Type(() => Number) + height!: number; +} + export class UpdateAssetDto extends UpdateAssetBase { @Optional() @IsString() @@ -76,7 +98,7 @@ export class UpdateAssetDto extends UpdateAssetBase { orientation?: ExifOrientation; @Optional() - crop?: CropOptions; + crop?: CropOptionsDto; } export class RandomAssetsDto { diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index bdd854ba30ad0..03b240d79ed62 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -19,8 +19,8 @@ export enum ExifOrientation { } export interface CropOptions { - top: number; - left: number; + x: number; + y: number; width: number; height: number; } diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4ff1a069b0c07..a7a5ef655a8f0 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -284,8 +284,8 @@ export class MetadataService { GPSLongitude: longitude, 'Orientation#': _.isUndefined(orientation) ? undefined : Number.parseInt(orientation ?? '1', 10), Rating: rating, - CropLeft: crop?.left?.toString(), - CropTop: crop?.top.toString(), + CropLeft: crop?.x?.toString(), + CropTop: crop?.y.toString(), CropWidth: crop?.width, CropHeight: crop?.height, }, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 846ca7fd1fe22..debcb63202ad7 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -694,8 +694,8 @@ export class PersonService { ); return { - left: middleX - newHalfSize, - top: middleY - newHalfSize, + x: middleX - newHalfSize, + y: middleY - newHalfSize, width: newHalfSize * 2, height: newHalfSize * 2, }; diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index d54cb1e4d5a9b..fdcfd2d65fa8b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -16,6 +16,7 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; + import { ProjectionType } from '$lib/constants'; import { user } from '$lib/stores/user.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; import { getAssetJobName, getSharedLink } from '$lib/utils'; @@ -33,6 +34,7 @@ mdiContentCopy, mdiDatabaseRefreshOutline, mdiDotsVertical, + mdiImageEditOutline, mdiImageRefreshOutline, mdiMagnifyMinusOutline, mdiMagnifyPlusOutline, @@ -53,22 +55,22 @@ export let onRunJob: (name: AssetJobName) => void; export let onPlaySlideshow: () => void; export let onShowDetail: () => void; - // export let showEditorHandler: () => void; + export let showEditorHandler: () => void; export let onClose: () => void; const sharedLink = getSharedLink(); $: isOwner = $user && asset.ownerId === $user?.id; $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; - // $: showEditorButton = - // isOwner && - // asset.type === AssetTypeEnum.Image && - // !( - // asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || - // (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) - // ) && - // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && - // !asset.livePhotoVideoId; + $: showEditorButton = + isOwner && + asset.type === AssetTypeEnum.Image && + !( + asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + ) && + !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && + !asset.livePhotoVideoId;
{/if} - + {/if} {#if isOwner} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 3ed955848b347..cae7b06c7e250 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -320,12 +320,12 @@ dispatch(order); }; - // const showEditorHandler = () => { - // if (isShowActivity) { - // isShowActivity = false; - // } - // isShowEditor = !isShowEditor; - // }; + const showEditorHandler = () => { + if (isShowActivity) { + isShowActivity = false; + } + isShowEditor = !isShowEditor; + }; const handleRunJob = async (name: AssetJobName) => { try { @@ -417,6 +417,7 @@ {album} {stack} showDetailButton={enableDetailPanel} + {showEditorHandler} showSlideshow={!!assetStore} onZoomImage={zoomToggle} onCopyImage={copyImage} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts index a0390d2d4d47e..329ff7e9708ec 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts @@ -1,14 +1,15 @@ -import type { CropAspectRatio, CropSettings } from '$lib/stores/asset-editor.store'; +import type { CropAspectRatio } from '$lib/stores/asset-editor.store'; +import type { CropOptionsDto } from '@immich/sdk'; import { get } from 'svelte/store'; import { cropAreaEl } from './crop-store'; import { checkEdits } from './mouse-handlers'; export function recalculateCrop( - crop: CropSettings, + crop: CropOptionsDto, canvas: HTMLElement, aspectRatio: CropAspectRatio, returnNewCrop = false, -): CropSettings | null { +): CropOptionsDto | null { const canvasW = canvas.clientWidth; const canvasH = canvas.clientHeight; @@ -52,7 +53,7 @@ export function recalculateCrop( } } -export function animateCropChange(crop: CropSettings, newCrop: CropSettings, draw: () => void, duration = 100) { +export function animateCropChange(crop: CropOptionsDto, newCrop: CropOptionsDto, draw: () => void, duration = 100) { const cropArea = get(cropAreaEl); if (!cropArea) { return; diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts index 85e7f4b1c408d..cbb825f4dd960 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts @@ -1,8 +1,8 @@ -import type { CropSettings } from '$lib/stores/asset-editor.store'; +import type { CropOptionsDto } from '@immich/sdk'; import { get } from 'svelte/store'; import { cropFrame, overlayEl } from './crop-store'; -export function draw(crop: CropSettings) { +export function draw(crop: CropOptionsDto) { const mCropFrame = get(cropFrame); if (!mCropFrame) { @@ -17,7 +17,7 @@ export function draw(crop: CropSettings) { drawOverlay(crop); } -export function drawOverlay(crop: CropSettings) { +export function drawOverlay(crop: CropOptionsDto) { const overlay = get(overlayEl); if (!overlay) { return; diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts index bce90efd9e1f7..501e5f8aa7076 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts @@ -1,4 +1,4 @@ -import { cropImageScale, cropImageSize, cropSettings, type CropSettings } from '$lib/stores/asset-editor.store'; +import { cropImageScale, cropImageSize, cropSettings, type CropOptionsDto } from '$lib/stores/asset-editor.store'; import { get } from 'svelte/store'; import { cropAreaEl, cropFrame, imgElement } from './crop-store'; import { draw } from './drawing'; @@ -62,7 +62,7 @@ export function calculateScale(img: HTMLImageElement, containerWidth: number, co return scale; } -export function normalizeCropArea(crop: CropSettings, img: HTMLImageElement, scale: number) { +export function normalizeCropArea(crop: CropOptionsDto, img: HTMLImageElement, scale: number) { const prevScale = get(cropImageScale); const scaleRatio = scale / prevScale; diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts index 656fd09294abb..57c6818381793 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts @@ -7,8 +7,8 @@ import { normaizedRorateDegrees, rotateDegrees, showCancelConfirmDialog, - type CropSettings, } from '$lib/stores/asset-editor.store'; +import type { CropOptionsDto } from '@immich/sdk'; import { get } from 'svelte/store'; import { adjustDimensions, keepAspectRatio } from './crop-settings'; import { @@ -125,7 +125,7 @@ function getBoundingClientRectCached(el: HTMLElement | null) { return getBoundingClientRectCache.data; } -function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) { +function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropOptionsDto) { const { x, y, width, height } = crop; const sensitivity = 10; const cornerSensitivity = 15; @@ -184,7 +184,7 @@ function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) { }; } -function isInCropArea(mouseX: number, mouseY: number, crop: CropSettings) { +function isInCropArea(mouseX: number, mouseY: number, crop: CropOptionsDto) { const { x, y, width, height } = crop; return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; } diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index 4d2f8977ee592..6502d9f573b30 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -3,7 +3,7 @@ import { mdiCropRotate } from '@mdi/js'; import { derived, get, writable } from 'svelte/store'; //---------crop -export const cropSettings = writable({ x: 0, y: 0, width: 100, height: 100 }); +export const cropSettings = writable({ x: 0, y: 0, width: 100, height: 100 }); export const cropImageSize = writable([1000, 1000]); export const cropImageScale = writable(1); export const cropAspectRatio = writable('free'); @@ -64,10 +64,3 @@ export type CropAspectRatio = | '5:7' | 'free' | 'reset'; - -export type CropSettings = { - x: number; - y: number; - width: number; - height: number; -}; From a5de09811999904f4b05ec0394195faee87cdbde Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Fri, 23 Aug 2024 00:29:19 +0000 Subject: [PATCH 16/25] wip: load crop settings in crop editor --- server/src/repositories/media.repository.ts | 3 ++- server/src/services/media.service.ts | 2 +- .../asset-viewer/editor/crop-tool/crop-area.svelte | 11 +++++++++-- .../asset-viewer/editor/crop-tool/image-loading.ts | 14 ++++++++------ web/src/lib/stores/asset-editor.store.ts | 1 + 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index c0777f39e8f8f..92252f118f0b3 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -52,7 +52,8 @@ export class MediaRepository implements IMediaRepository { }).pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16'); if (options.crop) { - pipeline.extract(options.crop); + const { x, y, width, height } = options.crop; + pipeline.extract({ left: x, top: y, width, height }); } pipeline = sharp(await pipeline.toBuffer()); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 4287edd3a6a9e..a66dc443b0dbc 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -223,7 +223,7 @@ export class MediaService { const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; const { cropLeft, cropTop, cropWidth, cropHeight } = asset.exifInfo ?? {}; const crop = _.every([cropLeft, cropTop, cropWidth, cropHeight], _.isNumber) - ? { left: cropLeft!, top: cropTop!, width: cropWidth!, height: cropHeight! } + ? { x: cropLeft!, y: cropTop!, width: cropWidth!, height: cropHeight! } : undefined; const imageOptions = { format, diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte index c35fd915197aa..33ba1df993bff 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte @@ -17,8 +17,10 @@ resetGlobalCropStore, rotateDegrees, } from '$lib/stores/asset-editor.store'; + import type { AssetResponseDto } from '@immich/sdk'; + import _ from 'lodash'; - export let asset; + export let asset: AssetResponseDto; let img: HTMLImageElement; $: imgElement.set(img); @@ -40,7 +42,12 @@ img.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum }); - img.addEventListener('load', () => onImageLoad(true)); + const { cropLeft, cropTop, cropWidth, cropHeight } = asset.exifInfo ?? {}; + const crop = _.every([cropLeft, cropTop, cropWidth, cropHeight], _.isNumber) + ? { x: cropLeft!, y: cropTop!, width: cropWidth!, height: cropHeight! } + : null; + + img.addEventListener('load', () => onImageLoad({ crop })); img.addEventListener('error', (error) => { handleError(error, $t('error_loading_image')); }); diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts index 501e5f8aa7076..22ffdc52b792b 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts @@ -1,9 +1,10 @@ -import { cropImageScale, cropImageSize, cropSettings, type CropOptionsDto } from '$lib/stores/asset-editor.store'; +import { cropImageScale, cropImageSize, cropSettings } from '$lib/stores/asset-editor.store'; +import type { CropOptionsDto } from '@immich/sdk'; import { get } from 'svelte/store'; import { cropAreaEl, cropFrame, imgElement } from './crop-store'; import { draw } from './drawing'; -export function onImageLoad(resetSize: boolean = false) { +export function onImageLoad(resetSize: { crop: CropOptionsDto | null } | null = null) { const img = get(imgElement); const cropArea = get(cropAreaEl); @@ -19,11 +20,12 @@ export function onImageLoad(resetSize: boolean = false) { cropImageSize.set([img.width, img.height]); if (resetSize) { + const rect = resetSize.crop ?? { x: 0, y: 0, width: img.width, height: img.height }; cropSettings.update((crop) => { - crop.x = 0; - crop.y = 0; - crop.width = img.width * scale; - crop.height = img.height * scale; + crop.x = rect.x * scale; + crop.y = rect.y * scale; + crop.width = rect.width * scale; + crop.height = rect.height * scale; return crop; }); } else { diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index 6502d9f573b30..fea944b8b2c10 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -1,4 +1,5 @@ import CropTool from '$lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte'; +import type { CropOptionsDto } from '@immich/sdk'; import { mdiCropRotate } from '@mdi/js'; import { derived, get, writable } from 'svelte/store'; From ae3a10d342bc5e12e004ff70e4ea5080da12d1a0 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Fri, 23 Aug 2024 00:29:35 +0000 Subject: [PATCH 17/25] style: fix typo --- .../asset-viewer/editor/crop-tool/crop-tool.svelte | 4 ++-- .../asset-viewer/editor/crop-tool/mouse-handlers.ts | 6 +++--- web/src/lib/stores/asset-editor.store.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte index dba3be5d671ff..f0a02261dcca0 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte @@ -6,7 +6,7 @@ cropImageSize, cropSettings, cropSettingsChanged, - normaizedRorateDegrees, + normalizedRorateDegrees, rotateDegrees, type CropAspectRatio, } from '$lib/stores/asset-editor.store'; @@ -16,7 +16,7 @@ import { tick } from 'svelte'; import CropPreset from './crop-preset.svelte'; - $: rotateHorizontal = [90, 270].includes($normaizedRorateDegrees); + $: rotateHorizontal = [90, 270].includes($normalizedRorateDegrees); const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`; const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`; const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`; diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts index 57c6818381793..93fd2a326af3b 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts @@ -4,7 +4,7 @@ import { cropImageSize, cropSettings, cropSettingsChanged, - normaizedRorateDegrees, + normalizedRorateDegrees, rotateDegrees, showCancelConfirmDialog, } from '$lib/stores/asset-editor.store'; @@ -89,7 +89,7 @@ function getMousePosition(e: MouseEvent) { let offsetX = e.clientX; let offsetY = e.clientY; const clienRect = getBoundingClientRectCached(get(cropAreaEl)); - const rotateDeg = get(normaizedRorateDegrees); + const rotateDeg = get(normalizedRorateDegrees); if (rotateDeg == 90) { offsetX = e.clientY - (clienRect?.top ?? 0); @@ -429,7 +429,7 @@ function updateCursor(mouseX: number, mouseY: number) { } const crop = get(cropSettings); - const rotateDeg = get(normaizedRorateDegrees); + const rotateDeg = get(normalizedRorateDegrees); let { onLeftBoundary, diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index fea944b8b2c10..3f85e2e9c908a 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -11,11 +11,11 @@ export const cropAspectRatio = writable('free'); export const cropSettingsChanged = writable(false); //---------rotate export const rotateDegrees = writable(0); -export const normaizedRorateDegrees = derived(rotateDegrees, (v) => { +export const normalizedRorateDegrees = derived(rotateDegrees, (v) => { const newAngle = v % 360; return newAngle < 0 ? newAngle + 360 : newAngle; }); -export const changedOriention = derived(normaizedRorateDegrees, () => get(normaizedRorateDegrees) % 180 > 0); +export const changedOriention = derived(normalizedRorateDegrees, () => get(normalizedRorateDegrees) % 180 > 0); //-----other export const showCancelConfirmDialog = writable(false); From bb1b719ed885c66996887e1b0dcc349de4e483c4 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Fri, 23 Aug 2024 00:29:59 +0000 Subject: [PATCH 18/25] style: fix typo --- .../asset-viewer/editor/crop-tool/crop-tool.svelte | 4 ++-- .../asset-viewer/editor/crop-tool/mouse-handlers.ts | 6 +++--- web/src/lib/stores/asset-editor.store.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte index f0a02261dcca0..17a20fd098436 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte @@ -6,7 +6,7 @@ cropImageSize, cropSettings, cropSettingsChanged, - normalizedRorateDegrees, + normalizedRotateDegrees, rotateDegrees, type CropAspectRatio, } from '$lib/stores/asset-editor.store'; @@ -16,7 +16,7 @@ import { tick } from 'svelte'; import CropPreset from './crop-preset.svelte'; - $: rotateHorizontal = [90, 270].includes($normalizedRorateDegrees); + $: rotateHorizontal = [90, 270].includes($normalizedRotateDegrees); const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`; const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`; const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`; diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts index 93fd2a326af3b..2d92f42ee9698 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts @@ -4,7 +4,7 @@ import { cropImageSize, cropSettings, cropSettingsChanged, - normalizedRorateDegrees, + normalizedRotateDegrees, rotateDegrees, showCancelConfirmDialog, } from '$lib/stores/asset-editor.store'; @@ -89,7 +89,7 @@ function getMousePosition(e: MouseEvent) { let offsetX = e.clientX; let offsetY = e.clientY; const clienRect = getBoundingClientRectCached(get(cropAreaEl)); - const rotateDeg = get(normalizedRorateDegrees); + const rotateDeg = get(normalizedRotateDegrees); if (rotateDeg == 90) { offsetX = e.clientY - (clienRect?.top ?? 0); @@ -429,7 +429,7 @@ function updateCursor(mouseX: number, mouseY: number) { } const crop = get(cropSettings); - const rotateDeg = get(normalizedRorateDegrees); + const rotateDeg = get(normalizedRotateDegrees); let { onLeftBoundary, diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index 3f85e2e9c908a..214ac81db1ae0 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -11,11 +11,11 @@ export const cropAspectRatio = writable('free'); export const cropSettingsChanged = writable(false); //---------rotate export const rotateDegrees = writable(0); -export const normalizedRorateDegrees = derived(rotateDegrees, (v) => { +export const normalizedRotateDegrees = derived(rotateDegrees, (v) => { const newAngle = v % 360; return newAngle < 0 ? newAngle + 360 : newAngle; }); -export const changedOriention = derived(normalizedRorateDegrees, () => get(normalizedRorateDegrees) % 180 > 0); +export const changedOriention = derived(normalizedRotateDegrees, () => get(normalizedRotateDegrees) % 180 > 0); //-----other export const showCancelConfirmDialog = writable(false); From 1a0aa62c925a4e1dda05089e65b35da2a413b788 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Fri, 23 Aug 2024 00:49:49 +0000 Subject: [PATCH 19/25] style: fix typo --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index cae7b06c7e250..264482609d4dc 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -48,7 +48,7 @@ import VideoViewer from './video-wrapper-viewer.svelte'; import EditorPanel from './editor/editor-panel.svelte'; import CropArea from './editor/crop-tool/crop-area.svelte'; - import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; + import { closeEditorCofirm as closeEditorConfirm } from '$lib/stores/asset-editor.store'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] = []; @@ -272,7 +272,7 @@ }; const closeEditor = () => { - closeEditorCofirm(() => { + closeEditorConfirm(() => { isShowEditor = false; }); }; From a289b91d6b472b0791d806a40798b37bbda077e5 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Fri, 23 Aug 2024 15:32:31 +0000 Subject: [PATCH 20/25] i18n: fix diacritic --- web/src/lib/i18n/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index 7e7ac0d1bd628..9d6163bc92210 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -550,7 +550,7 @@ "edit_user": "Modifier l'utilisateur", "edited": "Modifié", "edited_asset": "Média modifié", - "editor": "Editeur", + "editor": "Éditeur", "email": "Courriel", "empty": "", "empty_album": "Album vide", From 2bca0a9abe747fadd1a36ddb05ebc646706c5209 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Fri, 23 Aug 2024 15:34:09 +0000 Subject: [PATCH 21/25] wip: add save changes button --- .../asset-viewer/editor/editor-panel.svelte | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte index 1adef3273502d..5f0918ac9a998 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -21,6 +21,7 @@ export let onUpdateSelectedType: (type: string) => void; export let onClose: () => void; + export let onSave: () => void; let selectedType: string = editTypes[0].name; $: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0]; @@ -37,9 +38,18 @@
-
- -

{$t('editor')}

+
+
+ +

{$t('editor')}

+
+
    From d034a959c2a94d04006ba33cab82ab0bc6d2e02c Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Fri, 23 Aug 2024 15:34:25 +0000 Subject: [PATCH 22/25] style: fix typo --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 2 +- web/src/lib/stores/asset-editor.store.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 264482609d4dc..a6d038e1219de 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -48,7 +48,7 @@ import VideoViewer from './video-wrapper-viewer.svelte'; import EditorPanel from './editor/editor-panel.svelte'; import CropArea from './editor/crop-tool/crop-area.svelte'; - import { closeEditorCofirm as closeEditorConfirm } from '$lib/stores/asset-editor.store'; + import { closeEditorConfirm as closeEditorConfirm } from '$lib/stores/asset-editor.store'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] = []; diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index 214ac81db1ae0..e83482a0ca96f 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -28,7 +28,7 @@ export const editTypes = [ }, ]; -export function closeEditorCofirm(closeCallback: CallableFunction) { +export function closeEditorConfirm(closeCallback: CallableFunction) { if (get(hasChanges)) { showCancelConfirmDialog.set(closeCallback); } else { From 167152764a82c6424fb7ca4ae3b5daa1f770d75d Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Fri, 23 Aug 2024 21:27:58 +0000 Subject: [PATCH 23/25] wip: cropping is saved --- server/src/services/asset.service.ts | 15 +++++++-- server/src/services/metadata.service.ts | 1 + .../asset-viewer/actions/rotate-action.svelte | 10 ++---- .../asset-viewer/asset-viewer-nav-bar.svelte | 5 +-- .../asset-viewer/asset-viewer.svelte | 30 ++++++++++++++++-- .../asset-viewer/editor/editor-panel.svelte | 9 +++--- web/src/lib/i18n/en.json | 1 + web/src/lib/i18n/fr.json | 3 +- web/src/lib/stores/asset-editor.store.ts | 31 ++++++++++++++++++- 9 files changed, 85 insertions(+), 20 deletions(-) diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 48e6d4c69d18d..8ab47120666ef 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -319,8 +319,19 @@ export class AssetService { } private async updateMetadata(dto: ISidecarWriteJob) { - const { id, description, dateTimeOriginal, latitude, longitude, orientation, rating } = dto; - const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, orientation, rating }, _.isUndefined); + const { id, description, dateTimeOriginal, latitude, longitude, orientation, rating, crop } = dto; + const writes = _.omitBy( + { + description, + dateTimeOriginal, + latitude, + longitude, + orientation, + rating, + crop, + }, + _.isUndefined, + ); if (Object.keys(writes).length > 0) { await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index a7a5ef655a8f0..5483e89b52de2 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -275,6 +275,7 @@ export class MetadataService { } const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; + this.logger.warn(`Writing sidecar for asset ${asset.id} with ${JSON.stringify(job)}`); const exif = _.omitBy( { Description: description, diff --git a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte index f3e29e7a60d02..f83a797cc0fa2 100644 --- a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte @@ -14,6 +14,7 @@ export let asset: AssetResponseDto; export let onAction: OnAction; + export let refreshEditedAsset: () => Promise; export let to: 'left' | 'right'; const handleRotateAsset = async () => { @@ -29,19 +30,14 @@ try { await updateAsset({ id: asset.id, updateAssetDto: { orientation: newOrientation } }); asset.exifInfo = { ...asset.exifInfo, orientation: newOrientation }; - await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name: AssetJobName.RegenerateThumbnail } }); notificationController.show({ type: NotificationType.Info, message: $t('edited_asset'), }); onAction({ type: AssetAction.ROTATE, asset }); - setTimeout(() => { - // force the image to refresh the thumbnail - const oldSrc = new URL($photoViewer!.src); - oldSrc.searchParams.set('t', Date.now().toString()); - $photoViewer!.src = oldSrc.toString(); - }, 500); + + await refreshEditedAsset(); } catch (error) { handleError(error, $t('errors.unable_to_edit_asset')); } diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index fdcfd2d65fa8b..3f2f58b4d64d1 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -57,6 +57,7 @@ export let onShowDetail: () => void; export let showEditorHandler: () => void; export let onClose: () => void; + export let refreshEditedAsset: () => Promise; const sharedLink = getSharedLink(); @@ -145,8 +146,8 @@ {#if isOwner} {#if !asset.isTrashed} - - + + {/if} {#if stack} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index a6d038e1219de..cabfd00c981f8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -8,7 +8,7 @@ import { AssetAction, ProjectionType } from '$lib/constants'; import { updateNumberOfComments } from '$lib/stores/activity.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { AssetStore } from '$lib/stores/assets.store'; + import { type AssetStore, photoViewer } from '$lib/stores/assets.store'; import { isShowDetail } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; @@ -48,7 +48,7 @@ import VideoViewer from './video-wrapper-viewer.svelte'; import EditorPanel from './editor/editor-panel.svelte'; import CropArea from './editor/crop-tool/crop-area.svelte'; - import { closeEditorConfirm as closeEditorConfirm } from '$lib/stores/asset-editor.store'; + import { applyChanges, closeEditorConfirm as closeEditorConfirm } from '$lib/stores/asset-editor.store'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] = []; @@ -277,6 +277,13 @@ }); }; + const closeEditorAfterSave = async () => { + await applyChanges(asset, () => {}); + await new Promise((resolve) => setTimeout(resolve, 500)); // TODO: otherwise it doesn't work + isShowEditor = false; + await refreshEditedAsset(); + }; + const navigateAssetRandom = async () => { if (!assetStore) { return; @@ -395,6 +402,17 @@ onAction?.(action); }; + const refreshEditedAsset = async () => { + await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name: AssetJobName.RegenerateThumbnail } }); + setTimeout(() => { + // force the image to refresh the thumbnail + // TODO: this is not reliable. Ultimately the new files table will be used. + const oldSrc = new URL($photoViewer!.src); + oldSrc.searchParams.set('t', Date.now().toString()); + $photoViewer!.src = oldSrc.toString(); + }, 500); + }; + let selectedEditType: string = ''; function handleUpdateSelectedEditType(type: string) { @@ -418,6 +436,7 @@ {stack} showDetailButton={enableDetailPanel} {showEditorHandler} + {refreshEditedAsset} showSlideshow={!!assetStore} onZoomImage={zoomToggle} onCopyImage={copyImage} @@ -559,7 +578,12 @@ class="z-[1002] row-start-1 row-span-4 w-[400px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg" translate="yes" > - +
{/if} diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte index 5f0918ac9a998..76045eceb7cb5 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -5,9 +5,10 @@ import { onMount } from 'svelte'; import CircleIconButton from '../../elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; - import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store'; + import { editTypes, showCancelConfirmDialog, hasLossyChanges, applyChanges } from '$lib/stores/asset-editor.store'; import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import { shortcut } from '$lib/actions/shortcut'; + import { get } from 'svelte/store'; export let asset: AssetResponseDto; @@ -21,7 +22,7 @@ export let onUpdateSelectedType: (type: string) => void; export let onClose: () => void; - export let onSave: () => void; + export let onSave: () => Promise; let selectedType: string = editTypes[0].name; $: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0]; @@ -46,9 +47,9 @@
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 6f58b9595b082..c7be28458ddd3 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1032,6 +1032,7 @@ "rotate_left": "Rotate left", "rotate_right": "Rotate right", "save": "Save", + "save_copy": "Save copy", "saved_api_key": "Saved API Key", "saved_profile": "Saved profile", "saved_settings": "Saved settings", diff --git a/web/src/lib/i18n/fr.json b/web/src/lib/i18n/fr.json index 9d6163bc92210..f148869655dfa 100644 --- a/web/src/lib/i18n/fr.json +++ b/web/src/lib/i18n/fr.json @@ -1084,7 +1084,8 @@ "role_viewer": "Visionneuse", "rotate_left": "Faire pivoter vers la gauche", "rotate_right": "Faire pivoter vers la droite", - "save": "Sauvegarder", + "save": "Enregistrer", + "save_copy": "Enregistrer une copie", "saved_api_key": "Clé API sauvegardée", "saved_profile": "Profil enregistré", "saved_settings": "Paramètres enregistrés", diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index e83482a0ca96f..728a671b14df6 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -1,5 +1,5 @@ import CropTool from '$lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte'; -import type { CropOptionsDto } from '@immich/sdk'; +import { updateAsset, type AssetResponseDto, type CropOptionsDto } from '@immich/sdk'; import { mdiCropRotate } from '@mdi/js'; import { derived, get, writable } from 'svelte/store'; @@ -25,6 +25,13 @@ export const editTypes = [ icon: mdiCropRotate, component: CropTool, changesFlag: cropSettingsChanged, + lossless: true, + async apply(asset: AssetResponseDto) { + const crop = get(cropSettings); + await updateAsset({ id: asset.id, updateAssetDto: { crop } }); + const { x, y, width, height } = crop; + asset.exifInfo = { ...asset.exifInfo, cropLeft: x, cropTop: y, cropWidth: width, cropHeight: height }; + }, }, ]; @@ -43,6 +50,28 @@ export const hasChanges = derived( }, ); +export const hasLossyChanges = derived( + editTypes.filter((t) => !t.lossless).map((t) => t.changesFlag), + ($flags) => { + return $flags.some(Boolean); + }, +); + +export async function applyChanges(asset: AssetResponseDto, closeCallback: CallableFunction) { + if (get(hasLossyChanges)) { + closeCallback(); + return; // not supported yet + } + + for (const type of editTypes) { + if (get(type.changesFlag)) { + await type.apply(asset); + } + } + + setTimeout(closeCallback, 1000); +} + export function resetGlobalCropStore() { cropSettings.set({ x: 0, y: 0, width: 100, height: 100 }); cropImageSize.set([1000, 1000]); From a03d9f1255245ec96585c3631b13406405869af6 Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Fri, 23 Aug 2024 21:55:51 +0000 Subject: [PATCH 24/25] wip: round crop data --- web/src/lib/stores/asset-editor.store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index 728a671b14df6..f0373eb7b610b 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -28,8 +28,8 @@ export const editTypes = [ lossless: true, async apply(asset: AssetResponseDto) { const crop = get(cropSettings); - await updateAsset({ id: asset.id, updateAssetDto: { crop } }); - const { x, y, width, height } = crop; + const [x, y, width, height] = [crop.x, crop.y, crop.width, crop.height].map((v) => Math.round(v)); + await updateAsset({ id: asset.id, updateAssetDto: { crop: { x, y, width, height } } }); asset.exifInfo = { ...asset.exifInfo, cropLeft: x, cropTop: y, cropWidth: width, cropHeight: height }; }, }, From 121c07fac40b5a49848fda7b6b85b32d26578f5f Mon Sep 17 00:00:00 2001 From: Tom Niget Date: Wed, 4 Sep 2024 11:40:24 +0000 Subject: [PATCH 25/25] fix: try handle resize --- .../editor/crop-tool/crop-area.svelte | 8 ++++++++ .../editor/crop-tool/image-loading.ts | 16 ++++++++++++++++ web/src/lib/stores/asset-editor.store.ts | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte index 33ba1df993bff..9c26786172c7c 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte @@ -22,6 +22,7 @@ export let asset: AssetResponseDto; let img: HTMLImageElement; + let resizeObserver: ResizeObserver; $: imgElement.set(img); @@ -53,9 +54,16 @@ }); window.addEventListener('mousemove', handleMouseMove); + + resizeObserver = new ResizeObserver(() => { + resizeCanvas(); + }); + + resizeObserver.observe(document.body); }); onDestroy(() => { + resizeObserver.unobserve(document.body); window.removeEventListener('mousemove', handleMouseMove); resetCropStore(); resetGlobalCropStore(); diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts index 22ffdc52b792b..0f8c778d5368a 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts @@ -93,6 +93,8 @@ export function resizeCanvas() { const containerHeight = cropArea?.clientHeight ?? 0; const imageAspectRatio = img.width / img.height; + console.log('resizeCanvas', containerWidth, containerHeight, imageAspectRatio, img.width, img.height); + let scale; if (imageAspectRatio > 1) { scale = containerWidth / img.width; @@ -115,5 +117,19 @@ export function resizeCanvas() { cropFrame.style.height = `${img.height * scale}px`; } + const oldScale = get(cropImageScale); + + const factor = scale / oldScale; + + cropSettings.update((crop) => { + crop.x *= factor; + crop.y *= factor; + crop.width *= factor; + crop.height *= factor; + return crop; + }); + + cropImageScale.set(scale); + draw(get(cropSettings)); } diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index f0373eb7b610b..d886886359fca 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -28,7 +28,8 @@ export const editTypes = [ lossless: true, async apply(asset: AssetResponseDto) { const crop = get(cropSettings); - const [x, y, width, height] = [crop.x, crop.y, crop.width, crop.height].map((v) => Math.round(v)); + const scale = get(cropImageScale); + const [x, y, width, height] = [crop.x, crop.y, crop.width, crop.height].map((v) => Math.round(v / scale)); await updateAsset({ id: asset.id, updateAssetDto: { crop: { x, y, width, height } } }); asset.exifInfo = { ...asset.exifInfo, cropLeft: x, cropTop: y, cropWidth: width, cropHeight: height }; },