From e8c80d88a50c8db161b1710540a7f118eeda196d Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Fri, 9 Jan 2026 17:59:52 -0500 Subject: [PATCH] feat: image editing (#24155) --- e2e/src/generators/timeline/rest-response.ts | 2 + e2e/src/web/specs/timeline/utils.ts | 8 +- i18n/en.json | 15 +- mobile/lib/domain/services/asset.service.dart | 5 +- .../repositories/sync_stream.repository.dart | 18 +- mobile/lib/pages/search/map/map.page.dart | 1 + .../sheet_location_details.widget.dart | 9 +- .../widgets/images/remote_image_provider.dart | 3 +- mobile/lib/utils/image_url_builder.dart | 40 +- .../detail_panel/asset_location.dart | 2 +- .../asset_viewer/detail_panel/exif_map.dart | 13 +- mobile/lib/widgets/map/map_thumbnail.dart | 11 +- .../map/positioned_asset_marker_icon.dart | 9 +- mobile/openapi/README.md | Bin 49853 -> 50828 bytes mobile/openapi/lib/api.dart | Bin 16092 -> 16538 bytes mobile/openapi/lib/api/assets_api.dart | Bin 49714 -> 55347 bytes mobile/openapi/lib/api_client.dart | Bin 39339 -> 40339 bytes mobile/openapi/lib/api_helper.dart | Bin 7632 -> 7838 bytes .../openapi/lib/model/asset_edit_action.dart | Bin 0 -> 2784 bytes .../lib/model/asset_edit_action_crop.dart | Bin 0 -> 3180 bytes .../lib/model/asset_edit_action_list_dto.dart | Bin 0 -> 3030 bytes ...sset_edit_action_list_dto_edits_inner.dart | Bin 0 -> 3431 bytes .../lib/model/asset_edit_action_mirror.dart | Bin 0 -> 3222 bytes .../lib/model/asset_edit_action_rotate.dart | Bin 0 -> 3222 bytes mobile/openapi/lib/model/asset_edits_dto.dart | Bin 0 -> 3094 bytes .../openapi/lib/model/asset_response_dto.dart | Bin 14644 -> 15374 bytes mobile/openapi/lib/model/crop_parameters.dart | Bin 0 -> 3520 bytes mobile/openapi/lib/model/job_name.dart | Bin 11111 -> 11326 bytes mobile/openapi/lib/model/mirror_axis.dart | Bin 0 -> 2607 bytes .../openapi/lib/model/mirror_parameters.dart | Bin 0 -> 2836 bytes mobile/openapi/lib/model/permission.dart | Bin 26781 -> 27510 bytes mobile/openapi/lib/model/queue_name.dart | Bin 4737 -> 4846 bytes .../lib/model/queues_response_legacy_dto.dart | Bin 8335 -> 8583 bytes .../openapi/lib/model/rotate_parameters.dart | Bin 0 -> 2840 bytes mobile/openapi/lib/model/sync_asset_v1.dart | Bin 7964 -> 8591 bytes .../lib/model/system_config_job_dto.dart | Bin 6452 -> 6684 bytes .../sync_stream_repository_test.dart | 185 +++ .../domain/services/asset.service_test.dart | 22 +- mobile/test/fixtures/sync_stream.stub.dart | 22 +- .../modules/utils/openapi_patching_test.dart | 12 + open-api/immich-openapi-specs.json | 414 +++++- open-api/typescript-sdk/src/fetch-client.ts | 108 +- pnpm-lock.yaml | 8 + server/package.json | 3 +- server/src/config.ts | 1 + .../src/controllers/asset-media.controller.ts | 4 +- .../src/controllers/asset.controller.spec.ts | 58 + server/src/controllers/asset.controller.ts | 39 + server/src/cores/storage.core.ts | 8 +- server/src/database.ts | 5 + server/src/dtos/asset-media.dto.ts | 3 + server/src/dtos/asset-response.dto.ts | 24 +- server/src/dtos/asset.dto.ts | 5 + server/src/dtos/editing.dto.ts | 125 ++ server/src/dtos/person.dto.ts | 43 +- server/src/dtos/queue-legacy.dto.ts | 3 + server/src/dtos/sync.dto.ts | 4 + server/src/dtos/system-config.dto.ts | 6 + server/src/enum.ts | 13 + server/src/queries/asset.edit.repository.sql | 17 + server/src/queries/asset.job.repository.sql | 38 +- server/src/queries/asset.repository.sql | 11 +- server/src/queries/ocr.repository.sql | 10 + server/src/queries/person.repository.sql | 8 +- server/src/queries/sync.repository.sql | 15 +- .../src/repositories/asset-edit.repository.ts | 41 + .../src/repositories/asset-job.repository.ts | 5 +- server/src/repositories/asset.repository.ts | 17 +- server/src/repositories/index.ts | 2 + .../src/repositories/media.repository.spec.ts | 667 ++++++++++ server/src/repositories/media.repository.ts | 71 +- server/src/repositories/ocr.repository.ts | 48 +- server/src/repositories/person.repository.ts | 45 +- server/src/repositories/sync.repository.ts | 1 + .../src/repositories/websocket.repository.ts | 1 + server/src/schema/index.ts | 3 + .../1763785815996-AddAssetWidthHeight.ts | 28 + .../1764041175465-CreateAssetEditTable.ts | 22 + .../1764458955216-CreateIsVisibleColumns.ts | 11 + server/src/schema/tables/asset-edit.table.ts | 17 + server/src/schema/tables/asset-face.table.ts | 3 + server/src/schema/tables/asset-ocr.table.ts | 3 + server/src/schema/tables/asset.table.ts | 6 + .../src/services/asset-media.service.spec.ts | 108 +- server/src/services/asset-media.service.ts | 32 +- server/src/services/asset.service.spec.ts | 3 +- server/src/services/asset.service.ts | 123 +- server/src/services/base.service.ts | 3 + server/src/services/job.service.ts | 12 + server/src/services/media.service.spec.ts | 497 +++++++- server/src/services/media.service.ts | 309 +++-- server/src/services/metadata.service.spec.ts | 53 + server/src/services/metadata.service.ts | 35 +- server/src/services/person.service.spec.ts | 1 + server/src/services/person.service.ts | 6 +- server/src/services/queue.service.spec.ts | 3 +- .../services/system-config.service.spec.ts | 1 + server/src/types.ts | 18 +- server/src/utils/access.ts | 12 + server/src/utils/asset.util.ts | 30 +- server/src/utils/database.ts | 28 +- server/src/utils/editor.spec.ts | 505 ++++++++ server/src/utils/editor.ts | 107 ++ server/src/utils/transform.spec.ts | 293 +++++ server/src/utils/transform.ts | 227 ++++ server/src/validation.ts | 43 + server/test/fixtures/asset.stub.ts | 231 +++- server/test/fixtures/face.stub.ts | 8 + server/test/fixtures/shared-link.stub.ts | 7 + server/test/medium.factory.ts | 1 + .../medium/specs/services/ocr.service.spec.ts | 6 + .../specs/sync/sync-album-asset.spec.ts | 4 + .../test/medium/specs/sync/sync-asset.spec.ts | 4 + .../specs/sync/sync-partner-asset.spec.ts | 2 + server/test/small.factory.ts | 13 + server/test/utils.ts | 4 + .../asset-viewer/actions/edit-action.svelte | 20 + .../asset-viewer/asset-viewer-nav-bar.svelte | 26 +- .../asset-viewer/asset-viewer.svelte | 35 +- .../editor/crop-tool/crop-settings.ts | 159 --- .../editor/crop-tool/crop-store.ts | 27 - .../editor/crop-tool/crop-tool.svelte | 172 --- .../asset-viewer/editor/crop-tool/drawing.ts | 40 - .../editor/crop-tool/image-loading.ts | 117 -- .../editor/crop-tool/mouse-handlers.ts | 536 -------- .../asset-viewer/editor/editor-panel.svelte | 108 +- .../crop-area.svelte | 113 +- .../crop-preset.svelte | 2 +- .../transform-tool/transform-tool.svelte | 150 +++ .../gallery-viewer/gallery-viewer.svelte | 5 + .../timeline/TimelineAssetViewer.svelte | 3 + .../lib/managers/edit/edit-manager.svelte.ts | 145 +++ .../managers/edit/transform-manager.svelte.ts | 1116 +++++++++++++++++ web/src/lib/services/queue.service.ts | 5 + web/src/lib/stores/asset-editor.store.ts | 72 +- web/src/lib/stores/websocket.ts | 23 + web/src/lib/utils.ts | 11 +- web/src/lib/utils/asset-utils.ts | 13 +- web/src/lib/utils/layout-utils.ts | 5 +- web/src/lib/utils/timeline-util.ts | 3 +- web/src/test-data/factories/asset-factory.ts | 2 + 141 files changed, 6358 insertions(+), 1620 deletions(-) create mode 100644 mobile/openapi/lib/model/asset_edit_action.dart create mode 100644 mobile/openapi/lib/model/asset_edit_action_crop.dart create mode 100644 mobile/openapi/lib/model/asset_edit_action_list_dto.dart create mode 100644 mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart create mode 100644 mobile/openapi/lib/model/asset_edit_action_mirror.dart create mode 100644 mobile/openapi/lib/model/asset_edit_action_rotate.dart create mode 100644 mobile/openapi/lib/model/asset_edits_dto.dart create mode 100644 mobile/openapi/lib/model/crop_parameters.dart create mode 100644 mobile/openapi/lib/model/mirror_axis.dart create mode 100644 mobile/openapi/lib/model/mirror_parameters.dart create mode 100644 mobile/openapi/lib/model/rotate_parameters.dart create mode 100644 mobile/test/domain/repositories/sync_stream_repository_test.dart create mode 100644 server/src/dtos/editing.dto.ts create mode 100644 server/src/queries/asset.edit.repository.sql create mode 100644 server/src/repositories/asset-edit.repository.ts create mode 100644 server/src/repositories/media.repository.spec.ts create mode 100644 server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts create mode 100644 server/src/schema/migrations/1764041175465-CreateAssetEditTable.ts create mode 100644 server/src/schema/migrations/1764458955216-CreateIsVisibleColumns.ts create mode 100644 server/src/schema/tables/asset-edit.table.ts create mode 100644 server/src/utils/editor.spec.ts create mode 100644 server/src/utils/editor.ts create mode 100644 server/src/utils/transform.spec.ts create mode 100644 server/src/utils/transform.ts create mode 100644 web/src/lib/components/asset-viewer/actions/edit-action.svelte delete mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts delete mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts delete mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte delete mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts delete mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts delete mode 100644 web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts rename web/src/lib/components/asset-viewer/editor/{crop-tool => transform-tool}/crop-area.svelte (56%) rename web/src/lib/components/asset-viewer/editor/{crop-tool => transform-tool}/crop-preset.svelte (93%) create mode 100644 web/src/lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte create mode 100644 web/src/lib/managers/edit/edit-manager.svelte.ts create mode 100644 web/src/lib/managers/edit/transform-manager.svelte.ts diff --git a/e2e/src/generators/timeline/rest-response.ts b/e2e/src/generators/timeline/rest-response.ts index 6fcfe52fc..21cf59e79 100644 --- a/e2e/src/generators/timeline/rest-response.ts +++ b/e2e/src/generators/timeline/rest-response.ts @@ -346,6 +346,8 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons duplicateId: null, resized: true, checksum: asset.checksum, + width: exifInfo.exifImageWidth ?? 1, + height: exifInfo.exifImageHeight ?? 1, }; } diff --git a/e2e/src/web/specs/timeline/utils.ts b/e2e/src/web/specs/timeline/utils.ts index 0b49f0294..397a1656e 100644 --- a/e2e/src/web/specs/timeline/utils.ts +++ b/e2e/src/web/specs/timeline/utils.ts @@ -181,8 +181,12 @@ export const assetViewerUtils = { }, async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) { await page - .locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`) - .or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)) + .locator( + `img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`, + ) + .or( + page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`), + ) .waitFor(); }, async expectActiveAssetToBe(page: Page, assetId: string) { diff --git a/i18n/en.json b/i18n/en.json index 473bd6f37..c66d1d344 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -833,6 +833,9 @@ "created_at": "Created", "creating_linked_albums": "Creating linked albums...", "crop": "Crop", + "crop_aspect_ratio_fixed": "Fixed", + "crop_aspect_ratio_free": "Free", + "crop_aspect_ratio_original": "Original", "curated_object_page_title": "Things", "current_device": "Current device", "current_pin_code": "Current PIN code", @@ -966,9 +969,13 @@ "editor": "Editor", "editor_close_without_save_prompt": "The changes will not be saved", "editor_close_without_save_title": "Close editor?", - "editor_crop_tool_h2_aspect_ratios": "Aspect ratios", - "editor_crop_tool_h2_rotation": "Rotation", - "editor_mode": "Editor mode", + "editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?", + "editor_flip_horizontal": "Flip horizontal", + "editor_flip_vertical": "Flip vertical", + "editor_orientation": "Orientation", + "editor_reset_all_changes": "Reset changes", + "editor_rotate_left": "Rotate 90° counterclockwise", + "editor_rotate_right": "Rotate 90° clockwise", "email": "Email", "email_notifications": "Email notifications", "empty_folder": "This folder is empty", @@ -1459,6 +1466,8 @@ "minimize": "Minimize", "minute": "Minute", "minutes": "Minutes", + "mirror_horizontal": "Horizontal", + "mirror_vertical": "Vertical", "missing": "Missing", "mobile_app": "Mobile App", "mobile_app_download_onboarding_note": "Download the companion mobile app using the following options", diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index eb78ea0c8..198733b3c 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -4,7 +4,6 @@ import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; -import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped}); @@ -99,9 +98,7 @@ class AssetService { height = fetched?.height?.toDouble(); } - final exif = await getExif(asset); - final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation); - return (width: width, height: height, isFlipped: isFlipped); + return (width: width, height: height, isFlipped: false); } Future> getPlaces(String userId) { diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 5ab184457..b6dc7a286 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -22,6 +22,7 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey; import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey; @@ -194,6 +195,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { livePhotoVideoId: Value(asset.livePhotoVideoId), stackId: Value(asset.stackId), libraryId: Value(asset.libraryId), + width: Value(asset.width), + height: Value(asset.height), ); batch.insert( @@ -245,10 +248,21 @@ class SyncStreamRepository extends DriftDatabaseRepository { await _db.batch((batch) { for (final exif in data) { + int? width; + int? height; + + if (ExifDtoConverter.isOrientationFlipped(exif.orientation)) { + width = exif.exifImageHeight; + height = exif.exifImageWidth; + } else { + width = exif.exifImageWidth; + height = exif.exifImageHeight; + } + batch.update( _db.remoteAssetEntity, - RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)), - where: (row) => row.id.equals(exif.assetId), + RemoteAssetEntityCompanion(width: Value(width), height: Value(height)), + where: (row) => row.id.equals(exif.assetId) & row.width.isNull() & row.height.isNull(), ); } }); diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index a93b826f0..e366cf70f 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -370,6 +370,7 @@ class _MapWithMarker extends StatelessWidget { ? PositionedAssetMarkerIcon( point: value.point, assetRemoteId: value.marker.assetRemoteId, + assetThumbhash: '', durationInMilliseconds: value.shouldAnimate ? 100 : 0, onTap: onMarkerTapped, ) diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart index 4edd6855a..664cdb358 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart @@ -68,7 +68,7 @@ class _SheetLocationDetailsState extends ConsumerState { return const SizedBox.shrink(); } - final remoteId = asset is LocalAsset ? asset.remoteId : (asset as RemoteAsset).id; + final remoteAsset = asset as RemoteAsset; final locationName = _getLocationName(exifInfo); final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}"; @@ -92,7 +92,12 @@ class _SheetLocationDetailsState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated), + ExifMap( + exifInfo: exifInfo!, + markerId: remoteAsset.id, + markerAssetThumbhash: remoteAsset.thumbHash, + onMapCreated: _onMapCreated, + ), const SizedBox(height: 16), if (locationName != null) Padding( diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 7a063a867..d9a736861 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_ import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:openapi/api.dart'; class RemoteThumbProvider extends CancellableImageProvider with CancellableImageProviderMixin { @@ -93,7 +94,7 @@ class RemoteFullImageProvider extends CancellableImageProvider - '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}'; - String getPlaybackUrlForRemoteId(final String id) { return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?'; } diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart b/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart index 7ad290c15..6edf226e8 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/asset_location.dart @@ -74,7 +74,7 @@ class AssetLocation extends HookConsumerWidget { ], ), asset.isRemote ? const SizedBox.shrink() : const SizedBox(height: 16), - ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId), + ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId, markerAssetThumbhash: asset.thumbhash), const SizedBox(height: 16), getLocationName(), Text( diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart index 893e53408..f48ee06fd 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/exif_map.dart @@ -10,10 +10,20 @@ import 'package:url_launcher/url_launcher.dart'; class ExifMap extends StatelessWidget { final ExifInfo exifInfo; + // TODO: Pass in a BaseAsset instead of the ID and thumbhash when removing old timeline + // This is currently structured this way because of the old timeline implementation + // reusing this component final String? markerId; + final String? markerAssetThumbhash; final MapCreatedCallback? onMapCreated; - const ExifMap({super.key, required this.exifInfo, this.markerId = 'marker', this.onMapCreated}); + const ExifMap({ + super.key, + required this.exifInfo, + this.markerAssetThumbhash, + this.markerId = 'marker', + this.onMapCreated, + }); @override Widget build(BuildContext context) { @@ -61,6 +71,7 @@ class ExifMap extends StatelessWidget { width: constraints.maxWidth, zoom: 12.0, assetMarkerRemoteId: markerId, + assetThumbhash: markerAssetThumbhash, onTap: (tapPosition, latLong) async { Uri? uri = await createCoordinatesUri(); diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index 55f5ff77c..32d90a28d 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -19,6 +19,7 @@ class MapThumbnail extends HookConsumerWidget { final Function(Point, LatLng)? onTap; final LatLng centre; final String? assetMarkerRemoteId; + final String? assetThumbhash; final bool showMarkerPin; final double zoom; final double height; @@ -35,6 +36,7 @@ class MapThumbnail extends HookConsumerWidget { this.onTap, this.zoom = 8, this.assetMarkerRemoteId, + this.assetThumbhash, this.showMarkerPin = false, this.themeMode, this.showAttribution = true, @@ -109,8 +111,13 @@ class MapThumbnail extends HookConsumerWidget { ), ValueListenableBuilder( valueListenable: position, - builder: (_, value, __) => value != null && assetMarkerRemoteId != null - ? PositionedAssetMarkerIcon(size: height / 2, point: value, assetRemoteId: assetMarkerRemoteId!) + builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null + ? PositionedAssetMarkerIcon( + size: height / 2, + point: value, + assetRemoteId: assetMarkerRemoteId!, + assetThumbhash: assetThumbhash!, + ) : const SizedBox.shrink(), ), ], diff --git a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart index 0944f7ce3..becef728d 100644 --- a/mobile/lib/widgets/map/positioned_asset_marker_icon.dart +++ b/mobile/lib/widgets/map/positioned_asset_marker_icon.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/utils/image_url_builder.dart'; class PositionedAssetMarkerIcon extends StatelessWidget { final Point point; final String assetRemoteId; + final String assetThumbhash; final double size; final int durationInMilliseconds; @@ -18,6 +19,7 @@ class PositionedAssetMarkerIcon extends StatelessWidget { const PositionedAssetMarkerIcon({ required this.point, required this.assetRemoteId, + required this.assetThumbhash, this.size = 100, this.durationInMilliseconds = 100, this.onTap, @@ -35,7 +37,7 @@ class PositionedAssetMarkerIcon extends StatelessWidget { onTap: () => onTap?.call(), child: SizedBox.square( dimension: size, - child: _AssetMarkerIcon(id: assetRemoteId, key: Key(assetRemoteId)), + child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)), ), ), ); @@ -43,14 +45,15 @@ class PositionedAssetMarkerIcon extends StatelessWidget { } class _AssetMarkerIcon extends StatelessWidget { - const _AssetMarkerIcon({required this.id, super.key}); + const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key}); final String id; + final String thumbhash; @override Widget build(BuildContext context) { final imageUrl = getThumbnailUrlForRemoteId(id); - final cacheKey = getThumbnailCacheKeyForRemoteId(id); + final cacheKey = getThumbnailCacheKeyForRemoteId(id, thumbhash); return LayoutBuilder( builder: (context, constraints) { return Stack( diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 657e62bf6d9bc02451c9e9d5313f474a6ce7bcdf..4b62ee7877a1d48f66d761d10206cdc38839425f 100644 GIT binary patch delta 813 zcmdnn%G}e-ykWi=OKM7H$>c_9X$T`mL~`;2DOM$rXt6?#f@48JPNf2bSCX%gn5U3h zky%`lnU_AfK}2Hme6ba*t{|n84@ydMB^DQ_mVnhxekd-f1~VflwWKIBwJa5COj>>s zx;dNe#S@wNa`Vel9YIFGOcV<&N(Bib%+&-sNlVMc)yFl&RZB}jA1qU>U!9p!tBI3|~5=I6y~q~s^->%*;vi0b90XmTm&Dnz4+I~U~_VAlbX z!KKRw7-%jf`3Q~m5N|^4g31!m3=S60yu8#RgfUo5K`6szo^NJRQGGrRQ$UjWMYyyE z<(DLuq++)pB8RE77{%kz=-vFJX%D9|D98d5ixP8z>87X{YAmYY=IK3KIlLjRas;M5 is3L>_+(L*DhH>b^o1LdVM5%L delta 47 zcmV+~0MP%8jRU=f1F)YKlNuMOvq>0P1G6z4RcNymhN}s)>yNAmv+J1H3zILU2ebO5 Fk|EK{6wLqt diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 59f31d1392e05565b9970d9161f429f66b5cbdbd..50120d96a6a8f9e99bd42378f4f07fb0176debd7 100644 GIT binary patch delta 269 zcmcapJFAg#gDVerYD#8Fd}4A*W`5q}i#$>SNc{NZqWl7oAV0ETPG<4sL@mk51-xtu zXyPUDDJA*wAi?7J%)GqRB9LYwWJS4|MMe2wVH7iq@=GS4mlX$@$qZCJSu6EG-QHPZ4Gf delta 40 wcmbQ$$atrAgDcPE1-z`AYj|teH%p7|WScA?%ez@sMvrsydNuaV^{Ud+04XmG5dZ)H diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index ac50d015ed9ca57f70e1e0057501868ea87f33a2..03d91c9daeabaebdebbcea27e5dbc145a7e1406b 100644 GIT binary patch delta 1485 zcmcJP-)mA~7{^hkHlfXJIHybY+7CRk6WSnVGg7GrMN2UfidCNGQ4XlDoO6nVtr_%# zh_U7qF`~v6)$QEQMOb6fKTx~qALw=$tGlj>-g9({KcWrRd-?qOKF{ZU-)Hr{Mk;6u zV-$Rj=yYt16WJK7m=C~rF9kX3*#4UsCw-{wprry8RaK($Bg1`VvxBbqoldU-kuwFx zwHwl}6Qe3fIbvX>1FfnLB#rQl?ypyX0yPMkk{N!`1D)maBirh*F=>{HC!K1z=sIr= zWHQOD;u4UE5tByj9ZnFrbOM-ckEiTrkihaBD0|ayTs@YJ$fZ8u3_UM3zP= zj^{D&B!wVXT!4*N+9PKPi+~b+fs{QX;%kTz1XirGgKFk*o7^VMJ+MJ&rbSwdED`LQ z%dqIzYUNY`><`Dm|6JQqS6YO?y`M1sX#PJ=dQiEW-Aro@2DTvxgo*Ezf>p6mmqeb> zM_i0X)u1|pEm5rKoMnQ+$GttxgbAzOc`5Y%g~}r1kwqP}lT@l{2=M6%4GW_k(mU5J zy-8t;;+SuE6DDx4>s`Bi5%9%21il1@X}77pI(fd&3_*XSdVN>&mT!QEUhlbf9A>-q zwkpjE@&qf^Ot2jGXcaWlW_U{li`PT{BxQgxZ-k6hgs1K{$>rHM+@Hd?*~)K<^ElPW z?x^e+5R**q_A}$cag5C1A@%mY}^dLftU7n zxSEX;G?V$aof69YKG(duy2yUe!`JoWWI-Ar_u){(3UieL5RIOeKR672+}gpul#FXj T>c0z(Hwg%hyHQ#y&8hzY+%7Ah delta 155 zcmdnIfq7F4^9Emy$p^}9Cok7fpBzx5HMzmVeX_6SyUF)EPE4NQbY*g`ddlSb@{-AK zt_V!F(v_a<>Ek$giLU(S6+ZS%lRwn?OfHDy+1zbW%D!2_UB+Va%)YqE_s`XA_G{(! z0-7r`dBYW($rsM+0?p=~tasHIZ2qJR?}6rrZ8p8+!~|883^W*|Aa(QNs}q<2gS1Eg diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 6f68e44cefb77b32d2c8f1d92f124a8c989b22de..9d13b3e0341971a77136ff90c8c5b2caeb389c62 100644 GIT binary patch delta 466 zcmZ3znQ8KFrVV;3tgb1UC61E?yd)=oP-bNZ^O8#@U-U7Wyg{9n56aKX&vP!yFPNOD zE{QI*Q9+d#p~@$-c=Ez(36NQ6qJjP@AQL4~)RwrEr0}_i8b5lz|uILoyLlFUb+Ej5fkEshkC&W#T6`3GaTyS3T&QRW+Je@#Mf*rOkeku?_&>p|6<$ delta 37 vcmV+=0NVePy8^4T0nMCuTb diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 1a5f703c78671ef952eea25890df5b161c51fb08..18fa3d5e31528b6ec824842a8e05ef7caeae394b 100644 GIT binary patch delta 86 zcmca$J-sFqyBCKHUEgNqxuzFEVcHy2lz|GXhF zmi(A27Z$%HKfjysqrNxRVOm(17P`bUXiVcVPqD-bO_+7hx~i=UqZ+E*tAkJLCZrDk zYqTSEqbmBWm80)~&XaNH$JH}yd{8E&Iwyy^(CDJNA9Yr0gFY>-xhJu(Qzm`)Q=09R z^OFg=3t$VdH5ygxg#O=8CX=jG-ow&+469s+WfrtG@O1((rvhAwcdY_~0)u-%(^Rn6 zVFzJ{P&@lTk>#*?P-TPLg!E#_LyJi|4Lj|VD0WJY#r~RXG$uz^9<;fKc*{YuthlPM zx|o$5(XZazv}r&RO@0mRzz{TdhypM+j9}LT$a*cO^=C9<-67Z5W{K zW&mge=0&`*)Sf*5IuQM^A#a2tr8$cy30}?;5}&s*s@CMd#5oksRX?P|~lLLwElYz%%%IXKe}h&h8agY5|2R zJqnQmd`B$ND_(4^S*GR2k;nk=)UF2VQ8G`w1VZV zrd^@V`jSQekCR6x!#VUY(V$QR zfL182R4O}F>Dd~Td8A}%>}Utl;5)2n9pzm`Se{-O97#B&eW8iC8_)ZT4BL{7)%DXd zzI#bg zl52L&_R!Q4@8>z^UU4!RO(t;l=iTD@Z}Z#v`>VV8HQc;@JCEUd0k?}gc(=HIee>5D znvvzpoGCN=Ir-_uh#tkJl8UEmsnWG5`4pO}(L7Ii$+x^Rv40lZS}GItVC9bOby_zj zRs2&U6uMVzi+^jT@PEUV#^6ea-7{Hf!zz=C921JQ;L5t|V6swFT&Jb1HfUxFR;4d~ zOtXS1JsM#;19A4Bls!#S3D5*D1hJ)&~|=)i>D-JZUb zpSe;99|pOXvLpZ$Gg8597A@{S@(f`y1y|=Vy95Vw#>$9_jQz=%FYXtJFj&u?**zB( zy!$!);NJHs`?HheAB+;!28?t+A`8wq!0pndnLTa!9CS2$+NWBLZHjlS{>4R%A>ZR1 zi0l%RywS446iy~3E z>BWIm_g@D9Se1j@1{#FX@R^kj*RWP{+lE`(S2#0kG#JxdEfJMf3{_L2I7x{+q3tLQ zn64FH<4oIipqdQjt5Xtoirc}6EX74q$efsWFG6GZt1ZJ~-D2;`^T0RtMzNe<8yVRe zV|WrE{0Yt@O*7W-%xEUC10Mz8h(H@D$(S}LR7P&H-D;pE6Dm5g$;ieLJ^k7!A#80^ zB$W?sDIBkh6ZhV9yy`==O&@GEtx|}vPAM(yZv{3QK5=UYqR%Z zkty7QQ5#+EaE)8j{@inENyN;7#65mkev1+z-pk``W~DuhO5Xx%CSiH*L^SYp7}Ee! zoFl|rFC(e5$OAS7|I0?PgWJ+E+&6*ZFJa5-ASu3D2M$^h?tXKHBKf^UeS7>d`@h0( z?PiXiZ%qiUp!OOv?NyvIt?_O`cM!MikEq1AYVXNR=t*$x8zM|yQ;#Cm#!9T|fTHh^ zi*PNSohIPR1dSOJ)AAdm^%6};0zcfmy2X}k?E^K0aSV0ry{An28%QFEe6``!(bQ!BhfZHnN&%thLPsK@9rqt z@+zm<0wl5IdwrhgjvNjL!y#P$c{_da$K+=6@$zte9 z&B*d~DXbs;n*H)}K#yvvjg|Sr*nFWXc@B+koGf!$$+grzjn8Ub8|!;LM70rHm)DKY zt^Cp`i|$&i@oz0G{`b;43~pV&dttQmLi=2mm{2X0wCwJBlT}JfmsdtF(af)e&R_nV z7b{`iV1Vg7m<5jE8$kZn^Iym9B?N; zQk|BCbWYeUsRo6xl1@1`OBkRmdVe4PlebDQ!JF_Q;dW2^k(_a`(NFwue#$t05cR9z znnJln>8fIk(cU_vF;*aSp|q&rGrmGXzq7u7xZqNOEoSzV!=KL4cCUnBVp(ZyM=u_t z8vdgKLFf{~P&y!)eHB$B9W1O_V_4Gkj9;N>7ie%w+MZ#j^AvPbp;ky38|7Hb9VTik z7dTq1S=$t-CYO`WavV~xiX7*8WlCZuyf`ukzi}lDVGC)fFyh(NODjr=GnjChDLn5d z>^bsH>ccLAB?%S#h=BkOsNrB)A#jxbO=%L8L%~`S`ZFe+kIb0G^CKNjb~BmLBwr)& z+%9QkuYfZedirVGc;Lr2KM}DLK?~<2TVMG0848&;;zCGg9VPKOdldn z0VOM4Q+bkbfA8bjd=EDTCbX#}I5}a>X3E_dKUvp)8zUnFxN?#c^(NCR|<6>iNG2j<$>)+fv005fUBWB^v_{; zKIg(Yya~{WA9n5mr})-xBdZxbDd}QQgly>RsqAvNjIBFd{eWczZ5AB=mRs7Z>>sqc zCpVIOKsCDVh76m>N7{vql?jLBM)?)KqUoXoxu202!5DqpWs64yI?;8CVxq))ltSt{ zHfpRFGU(OJQzsfzch&T0!vCYGC(7k1(rY(iXIuPGS=ikn?fu*wZs3l<(%nGsM?2lA zu4gY`!j4#maJ!N_UBufhpadNr>;j2zP%?KC@YHmemWD*Y+fUOQx*@iD2-m;p{EmEg T<3GMXP7< literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart b/mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart new file mode 100644 index 0000000000000000000000000000000000000000..c4c0496631666e8eac1e96ab62a42d42db1631cd GIT binary patch literal 3431 zcmbtWU2oeq6n*!vxG9R-!BlzMQ{mKJgT)!rHSy494})O{j6~b)WKt!m8b+G`zI#bg zl4Ec3wuhz`dB1qixrZDMd&41I{(e7u{mb-j`tkC9dIi@vAEpsp&ERf!54W?co9jQ$ z(2OkKWlY=gkMR$0dh{t)QfZznl}?r-=OZX(X?T|KoNu_aQTP^{LTP){gXLScGf7d} zMDs7D(C9AN2LBdJDY=Hn9>5SA z@{!w8O9Kof-@?4hZvn!UpYj+3bXgB53qWWS*tIY*a}`BLcAEat?$bG*<{$@nceiyb zv{w4Jy6eycaQpAnSFSbo8N)m%l~boQvr5BwJeWOv<|+2iuDL#k@dP{`Da!{`W)yb5 zee=ISoY2)*uiUX*(CVO=tEw6HcAHwsqWLUZHj_c_NeE38|`8^3Jw zF8UruR=oA$Bgl3*N+O+L#`i`^%;zbz6q4oe8JA!T2iA{I7w$~pEatlJ^Y#~L+~V9b zqo$C?uvzoQ+Kq<`OAAlL$p1Aqz+?tqT@2tf!dI4;+`v++O%;3YmT=ORXwaw8oZqry z5rHgo(Hcpwb2Z#^6ICym-1?xP{ zz-1-}Pma-ZkC-5E$0eGzNOXSY#fp3zevII`{2T;mjQfiwNY#TUS5L(+Lbm2tg%4@= z+pdmIAV;(7P6XpHItxeNOd-LqtZ@6 zmk=E@W?AN`*YfmG&~#IrJzy#?tLT`6(8>{nUoMJMg$gxfb4}K+lT2X4ilf+t(tX>| zir@(v*%>y-A$26cgmwKRO3rTVQT)4!L*=U;P@`rL5@rm(s_6CPFPuHZaaHdE<}rN< zZo=uP+x~?A9(xx!c781+J2-%ioO{PV`Hn6J=kGb)Kp!ZuM+9`O2Nc_YpCrt#Ys$YQ zw!*G)iRO`BWnCz}_HG!Zx&!daLw9r`F9BHFAwG4+4p z-ifq2g`$afdgLk2Gi15F?j!Jc1LgViY@N`uV381l5Z^?lSkK{@H+ChTEBHmxo7u9 H5B2OX{YY_> literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_edit_action_mirror.dart b/mobile/openapi/lib/model/asset_edit_action_mirror.dart new file mode 100644 index 0000000000000000000000000000000000000000..782d317b7b9ac819c8e346f1a241d97880f486c0 GIT binary patch literal 3222 zcmbVO-*4MC5PtVxaVd&g!BlzMQ{l{6gT)!zHF43V4})O{j6~b)WKkul8b<2>eRrfN z$uZMrduVEr$NTYp-yJy`4Mrol_;fpa`RDXz`r+bsdI?u=-c4h;oWafP7OrQPZ?676 zK{K*^oik-7zb3!D8qlX$l~VC^DOI`@1s_9Q)|%%jFZhO+CidTAQ%PllCs?s%JDpaw zNfrN63x&r^w!yy@Q~1B(N~3e7!^bmOYQsvCiW~!qrQpgQ*TG+kOzFV@!x@lEkQFakB@+C1HW*|D(;6nL4O8dz}yYo^zK$ed$>Hb|>b5@yLWt~FCT5*BJ@&9%_h zEOIC%{`e{UcX4N8wvMx}fq{pq$ zwyAn-SBfvO!|iZTb%r9=Dakv|W*Ye42`n~<-8mGvcjeRd zJW$#(+}k;|QgX}g3ha7VvO@EC?}2r&Dz59oP=ACY%oiO!lP1#=qR+aJQ!2zUS1EiLryBI~1{m1}j3o6UxcN3cc@_ZU`zUVR`Q4)H~^r zriLUx2e>z~FYCoNazhF#v=v}5{d3r`DoBmb*8zl9gub6;VPAe$QSF`u z%;38uSi6ZM>9KynvD7?M#+{;5rZv7@=#Ap8fCEBtRqZ^b34IB!eMy9?E9;5WT5pLp z?h*9^iV?E8JNOUZ(YUtfolZMuJ;Mh)sJEC4Dd6|IyPErScTnH50fuCVr?Y zEbfr=ATbHi)`Hj*SKM}T*VA#UxWGWbcs-$v;C9VhO|*|tl%K}80z=kol*=6lyjwMf urT$>>_T%h^9<+@Z?1kIDVE>}CZR4NcTTJ{L#N9K)-o0U;v1bDuLH`0PxFHMx literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_edit_action_rotate.dart b/mobile/openapi/lib/model/asset_edit_action_rotate.dart new file mode 100644 index 0000000000000000000000000000000000000000..1104c06307e14a870ad6c6df8449ab20b1ce25df GIT binary patch literal 3222 zcmbVO-*4MC5PtVxaVd&g!BlzMQ{l{BgT)!zwQ-TA4})O{j6_@PWYHz58b+G`eRrfN z$uZMrduVEr$NTYp-yJy`4Mrn4|8z5d`RD9vc71*`yMW6#?`APv%;9Q&1MlY-Z!Z5n zK{K*^oik-7zb3!D8qlX$S5onGB~`i-B_Bgw)tcuiFZqU7CidTAvz5vOPq1>w_B!3x zCRO}PEfgNF*arV@nZo}KR~nrw9X_7PN*h+0ROA>?tOQr~xDEy@Ma6Ym%4&^freIb2 z>gO~on9_p*hBF{nAh*0?TannL4O8CY`z1=GbJGUu3}4bm!5BN6)WL0F2Mwjtncp6Y$0GR7Ha6?4wq=$Q`ZBdE|Jc#TXV+B zO5VDqg?rN8e{Tg~RSs@8Xb?ujS60?s!%E3b<8Wb@uzOZ$Fr*$^ATG-os=7ppk{)+L z+otNVy;XdL9d3t%sxuU^PD$P=&I%*46vs*-b7J2ugd6gAn~24`!QUn5!LPS##d3aO zWMs39;aQOOXV{z6*;s=!r0&2fydQuA8m+G+W7-@M8o4faV}gcGIPu7aBP&Pv_@P-s z2;0I)Iz3^Wp14s%XFJZv@Njq>Ip~as%g#wt_`S`i(F_sSYNmnzoxoy)*iE6ty-T02 z=Yi6W;m*#fwURq_TVmJ4ij|tj_a0aWtKzyY4fRJj!eZIcLw<=JN(hIM6?h`_l{B-> z*6h7wWDMtG)cBV>9PZY%+xMJWGBI{AbcZ6A&|pQ#cS1RtSfTg*(hWfcB`nXKoO&l6 z($tXT=K%NSl#x_h1MeLu^>zWl7B+C2-H z!FNfpb`wX^WBr0-sd=W1J4L5VYka%V8^v7#2ZZ9P+Ivb9`Vw6Gk_cB<))T3<-V$rv zBkBhfBV-GA@E^XXac$2#op#K6h7WkgqiuU(?f6Mk(?TWPExr?`z$I!ddhi7KcjmoeJ|es(bE&9@)X%M6Sy}feyA!e z?vV2!F$vMug4h#R+;(!;({Zb~z(BxwJ)w-?rr@n6+D9nLPvcvGA?r2D<(329ts28p se=vCaaehS)+C~ia!fju$f6>{t@uznd6aNNr_sp<&Z`fz-*#Jk-zt|xl5C8xG literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_edits_dto.dart b/mobile/openapi/lib/model/asset_edits_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..26757dce3bb3f55f9a1daf90bfccfb45278eb6e1 GIT binary patch literal 3094 zcmbVOZExE)5dQ98aVd&M!BlzOry{An28%PKYh$3z1`LKFFcNLElSP%JY8Yw$`|ggU zY^ClJ>_bzFy4UAReB&CB9aECCP)2sUvED7L~Gpb%f@Zlq> zr&5+XU{5rAkI!$q)aQj$x~4PifqS{CYiY*7<{ad${wj?@B+>a+>x!^2!f6AeQ8K;# zEDMb9R??inXbc|lLR1OWo5nZazWHCE9^&@m1;z#cHa3irV;>OS=cSa*4v>BJ0`ieai`z5Q|!BRR{3A_4DmlBKQgq=`HpS3KcwZd#kUnrTCw92ws67bBT z{|Wr$UvS#p@po7SKAiSH5{BovL!Lx43&BUv!;jF#=s5?UA9K(TqIu(Eq)={g3{)`& zr{7s!V^FV~g{nmbpYaIA@tyVE{TbH`Y%w#(A>HvDt@oAoz>KTd8S$exF|2Y9M+f&L zz^AJpL|uX(e+wk&ucB(Dg@w`U5NO6nI4}z|IHqx$ArA8t>b61&lHNAT@*M3k*%-ON zA?E2YZGj@SImtK2b>LKy>(TiP z)kj*!n!N$39jlK106m&GMpg)%$A|PLUdI$HAl*xpa2k3Wh&+Y0X#OG)r;D3yBSXM( zLW)RXNBGH+UnbS4L zS$f^dQ(*uH5gmP4CKSo?K z)N4c=GyW&oY&Y^`{AdtxuY@bbDRf`|Tv&_u47yMFJ>4S?Pt7(`m(i1wHnu`I`gR_n zkfUU5v`5GzR3bz(@A!}0(t2foufrXKk;xvP=-Q17_Kc6T_821VKeHRfbpqMJ7`IWbM%$|S&Ao|#gTvH2a#LKg14(p-B5h?>n^ zoC+LVU;&7NrGnoW1t4+?wze=;y~1(KG74&HTna#tUy_kpqz6_GQUcNUT5LX}3_@RC zX-s%sWOt9f(!7 zDi9l&$w@NvXQnA=lw@QU>p>Jwo~I-!24SklD%dI@OHELbg9VI(y4>ayis5X0aP43t zLApi2EQDr=1W+#vh&9<>U2L$?#}u*x z3gzk*Bo-B?YN)GJqnT2xuDMxXXANt-0>sO3Yv7Kf#1^O_AWJSA*0I3?#na-cu!4d* PL{S}raCd&#`o delta 94 zcmV-k0HObmc(ih`uL85l0z(3`B?X-Xv(gD02($bV@d2|76i@@RcNd=lvqTvJ1G91* zE&;Q!9qa+Ksvj8xvpOSS2D1$+8U(YEE}{hiDU$&z6SJ-~Vg{2QJKY9-I|_XY3R$Ed Aa{vGU diff --git a/mobile/openapi/lib/model/crop_parameters.dart b/mobile/openapi/lib/model/crop_parameters.dart new file mode 100644 index 0000000000000000000000000000000000000000..8c5b884596526f1fcaaa1f380559df366d5c5c94 GIT binary patch literal 3520 zcmbVOZExE)5dQ98aV>({!4$dOry`xQ7EQ9YOX8u;uoXriFcNLElSPfBYYVCW`|ggC zEXCTA^+RHbcf7afxzmlugYg*N{C+(-`sM8E?Bkp3vr{;Ke{mMT=>)DO*Kj#GeSiM> z396Ci>r@yQ{TRM@IiOcH&$W^9Oq+P7GI=_ycFRtK4%T;NiBjr+&&_8r!8#>=~^-DV2&qC!6=F-7J*u`6&N{pLB zXnK`eG?$6AR+y4n47+9_t+K2(@~DUP^+Wi><4?}0d=5_ghY^=` z*lmre4}p}dFu3t z!wcYPmk&>IkWqawYOg_khH=*h06OThkuVX~?cqG~Be7NNuBQ?EA+aaGG0njcTg1xI z@ZgT<(;~-qc<6^OU10S4^(&rq)HnUA$d`^<*{_D&X{lA^*>dm|i3re!L=`L-3QN*W z_yjUMJV%Cf@S#KlxZ{C1H+n5@Gn^wZ6PcC4wywLcx{-F3IqIr=rl&VM*vKHEMj_x7 zV~&T)UTJr>EL>SzL?m(Yjw-`?jo(jJ-N|dL8DwuaM&qFA(nUJ+wm^^?c7HR4){v}JA;C$b6aR*`zW(QKd+B6L&jjuE#xklRW z0&9gkkl?PNy`_u5kypYy)$qly5GvEcOtz3hJxu89O6CIzdO7jf&P11Ow)8ss9?m?H z4v(Q$HE&y8{~&WPt1i4<48p8kwibbcrZIOd8Z@lx>2Gw@BEuN2adjr+gnP5LBgHJP z2m%3Kq3Biuel0D!CGrdS@M&^IyMKi{e?ptfztY{Nolow#81?Gmzbr%l?WkTh!|I{r K2V7cky!-=5HB~hL literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index 038a17a8e6599d2f2305f969f99321624cb6098f..b027c921143a7039301ae87ae5ca578b97d6b6c0 100644 GIT binary patch delta 144 zcmaDJwl8AC6K1iL%#x6d(%huH#LOJ`)V$Q9#FEVXyvdC$q5|krllgfiCmXN`Y<|al jlNX1^&ASCBb7ShfC?_lEiecVdHSx*va%`LZWF`3k_e3@| delta 38 ucmdlN@jPt96XwYiShyzVvv6z4a?R}Fmt diff --git a/mobile/openapi/lib/model/mirror_axis.dart b/mobile/openapi/lib/model/mirror_axis.dart new file mode 100644 index 0000000000000000000000000000000000000000..4deeeb047c2ee3903c0b4febf71dcf024c64ceb9 GIT binary patch literal 2607 zcmai0ZExE)5dQ98aRG+L0X${fr^20^28gp|Xp>=eJ`9E-FcKAWl}L}I;(A5?`<^59 zX2;6{#1_SSeeUi#pP!G;&*}1hargZ%^V|6!my7uoUEh41Pw487ZtoWK>F(<0`p*-H zvE+wbSU>wI`SIc# z{JT~b>RMFz+z5+rFRg=d>yE2uMmsOGPgM?ws!-Afb$8TRskC%yY4jt+{95Sr?GI_T z7S@eMaOcSsWHwTZjY{zUj*|pDUzH-BSt*>OPvKP%_%6;jk5?+!Z zEw?0fUC~zgHTkuqjWtgQHmBv2C~LV&pck?j7)#QzUn`dci8F)|O)sEc>s;D$r}QI* zTMn6JqnAon57M&9LTORbXKy>nVCI8)lGegMM4C%);=}liX78iXAlhC3=8QhP1n6F6 zP2g)|)jvjiIGpWgVNd(JQzSyPr-)c!<(OJ~!%9u|AC(IQ5@ksn@$+iqcda$pL;H_oXTc|<&Z7N& z>oIobUN3YoK`}G}JCE+8^vR5LU6w;*L^1S4@g;XI4zSv7(!#ZV}V!H zwnZ+ypa+qCVdtar#q10vc)1)e6?iZ6~iG0X)$?$N8=~m0BH=o+1{czR@|iC z)*x0O%w&4pBI>4%rw2)~2sybr%cOG!jO=)tnW&+!L(8$%*f}y!=vT9q82kxO#)0cF zU9bCYZ2wOL(KmP3y~b{g+fwFp-Y+r9HO55mL&5BS5D?(~($vk(i?3l?x#oY^|w4!I!{PSW0bnKnfn1e5fP9@&n` zqtR^1{q&e#d)aH{vJha=DUxmfIOvlurD1R^;kkr#X&-C;DHKXOKE2mpGzuG+b*u;4 zKX&PqzGDdDnrwZ%!g9=(93N;bHuPsys+1laHlUY-QAT$)B*tZXvCa;pFM2+C$8`BG Dk=bAP literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/mirror_parameters.dart b/mobile/openapi/lib/model/mirror_parameters.dart new file mode 100644 index 0000000000000000000000000000000000000000..e8b8db685b20b846f65aa26a6f3e5d0f0c8947a7 GIT binary patch literal 2836 zcmbVOU2oeq6n*!vxG9EO0aSV0Q<2nOgT)!zHF42q4})O{j7(eXWKkojdPeI1zI!i4 z*_PcUSb!uFc|XrNm(*a;8w}v;kNe51-^O?2PgnQjYq)v$VI0Ht1nwsHa67qvck|~N znvvz(xiD_{bMn*c9=)n%sf|n*+N29r$P1{;+RAw<3%Qb|i^IEGRob|d9->%_jZLfC zrAGc;D}(N(SmEDF82s;~u^8Og)9#rrtrMk7)f^M5g_4Hd?a5??Dy2;eT`tkg<)Tbq z|CnaEFt*pjbPn_abR|nssRVy7d%dg>*21kaMw^dqLGaiE7$5+jq^nJ7K|m?jFzwpS zKyi7QOwggb8&MR1&^EYS*+l%KY>eyMbIk6OT;UQp4J)n!1l8Y$<4tm6^SAn|GzPK2 z{RgcJ>c$AC4Gf3TL%S zxTAiz-YG>H{y=|ceTL{(RByaX4D$_2O=TmD^4{taV|YpysuTr$!6g{Nk@e&Dk}CwZ zn3_IC@6XV*WzZWKRtHd}ZVUM}Vit)ENrIrX-ydhsmid#ktG%oQUuXM>_b0YhchC z5PfX{!Ma)+F_+g)M_gbGFS^vcKu$?YSh=@hy@@J>063t5!z7uo?ud}ctAm0ykau%M zoQVvGtBaOoGDI@_%W#Evq=n`e`v>UG~w+S&3 zR6Go#CbVAT0mY`f{Qx`TjcL1CcYtBx4LF3;j>fYgi*G+95kqYxVm|j|?|FJ?X+9{< z0nm+?)6`ZJpt^wY#8G&zf=>6QyIc7PR-!t|MJUz7f>s1~I3$k3g!!kA1ME0+|A{)= z4LvL0(=g6+bE95Bd-f6m+71x5jjwccF-qEBQ)}gNT%!4*Yq`t9D{qEg&8bK? zqyYLpp=c3?N<^?4ZntRfi%}BjRy&MnORIrV<6!D j=gA#?Y&Goj4cC5R{Z78^XpbHl4u5^~H`iqk&ys%uXYZOU literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 3b9a3964b6d7ece9c764307f3ab2f357086cc74d..d762946c3f59c8fddf8f935ee9b6cc4b97f684e8 100644 GIT binary patch delta 332 zcmbPxk@4F##tmuglLc70SY1+!GRr0>vPv_9X=YK7D6eZuW=Q~0CO^eJwPdm(lkg-q zR*qDlpk6wVA1Ek1nV*%L1*B?nB9lB&k_99=nUPBxq#YtLQ4=9y$SplNi-T)(0lSu} zQ*m)B)NrunsXAN=K#+(KMzI~6>|{X$VSY3XlOO8HZeF5OCO-L}BRkkL3!N3A-ZQip z<^>tA2lv87JF&^~PUs$WbQXtrq|iYYB+U$T$z*vyxycSrY|KEtlNp^fz+we%%A4g} G>Qw-v)oWA$ delta 24 gcmex%jdAWp#tmugo2PP^sBU)9YY^Z3*{xXx0E%Y{P5=M^ diff --git a/mobile/openapi/lib/model/queue_name.dart b/mobile/openapi/lib/model/queue_name.dart index bcc4159fce5fde2f8295e35cc45e9224e81e535b..d94304d0d3d97803b28d091b4d0671264cbda0b1 100644 GIT binary patch delta 105 zcmZoveW$v?h;4EyGrw$VN@huZk%Fy4U}ik+Pq zs%djRR|FFuMBG*dDu0xxpGgkFR*zM%RVc~GEY^eYt+^DyVDdc?vCYZ+KCCdy1w}Ps QMlKY&$OU6($*VE~0PAcv^#A|> delta 47 zcmV+~0MP%3LytkQ5dyO-0#gFBpadiXv*HK&0<-f9Mgp@*4UGb``wv?MvzZmc3bSe; F9RZmE5Z(X) diff --git a/mobile/openapi/lib/model/rotate_parameters.dart b/mobile/openapi/lib/model/rotate_parameters.dart new file mode 100644 index 0000000000000000000000000000000000000000..33609e83e531c243c752cb7ae9f924ce4abf0352 GIT binary patch literal 2840 zcmbVOU2oeq6n*!vxGjoU0aSV0Q<2nOi^Un*wegUq4}%d1j6_@PWKkoj8iwotzI!hv zS(cq8SOCWsc|XrNm(*x97>(fl*Sp#4-={az+xK_VE4cpfX&S=S3~pw3@OgIi;rg!^ zXhxRrQeoWqm+0p=1A0`;LK_(`w22ogmlseKm6d5MbGed*3!7)PDz$MZJw(118ylCE zi;euJQU=`%vBLkQF!=4Hu^8Og)9#5btP_QcRf-AKLP^8!_GGeL71GAJE|zHKGEu~D zeu|S!7&{nXItO|Ix|D?|RfM0*!63G|?j~rD%l&_U#8FtVwja=X)@DQ0QL7mu`6cpo3IhDjXewj{*4Zm(1x-(cva>Q6zSan%W8)s4k%S#Zh>sY8JY)-LHHCD^Z^0qAAtGf>s1~b3`14X{Mh# zZeYi}|A{)=jXf)m=@)0YzEm#&b?NWF7_T6_Y3WAw2jc;QxN0^HhY>v~X`5~cP2bVe zTymg@?K;dn!6^bY_l|$d4IO^$?=`unvZ3^VSafX%2;0U-I<^=k?Qg2Jav3hs{LszZ zXW_LsL$CTo#1kCN=@o^dp_EQ2f>d-oMyw0kI;oB4&M7ACs^*bY|3^E|l*Mx-R-b8| zGvG-DVPA(N_nC-@q87T2kivFM_X8WY>N+v)n5rX+5!_|6<-dJVLaAB2Wd+h)p)?*O s;6-LJEscSIk6>^uATI&$nCqmh}huwxd1SG92~g&97aT13XOr1yZY-p8x;= literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart index f0d5097ea4ffc31aec3982d8808c4f95ef7e21bc..a2c89eb5c1573eda059354c4c68b975b8daf8f6f 100644 GIT binary patch delta 485 zcmbPZ*YCVRnu#qVH8VY3sv-nbt yLBwrUpz`*dhZ)5nZ1q?LTZNL0%wj#L+-EKs7O39I3H*vMV;K4UIbp&c5^n&+Ei~x> delta 50 zcmV-20L}lLG_*3Xx&gD)0h$4`hywxwv#13>0<-@ItO2vT3BduA7Z4A#unYbJv*`~( I36nS( tbl.id.equals(assetId)); + final result = await query.getSingle(); + + expect(result.width, equals(1080)); + expect(result.height, equals(1920)); + } + }); + + test('does not swap dimensions for asset with normal orientation', () async { + final nonFlippedOrientations = ['1', '2', '3', '4']; + for (final orientation in nonFlippedOrientations) { + final assetId = 'asset-$orientation-degrees'; + + await sut.updateUsersV1([_createUser()]); + + final asset = _createAsset(id: assetId, checksum: 'checksum-$orientation', fileName: 'normal_$orientation.jpg'); + await sut.updateAssetsV1([asset]); + + final exif = _createExif( + assetId: assetId, + width: 1920, + height: 1080, + orientation: orientation, // EXIF orientation value for normal + ); + await sut.updateAssetsExifV1([exif]); + + final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId)); + final result = await query.getSingle(); + + expect(result.width, equals(1920)); + expect(result.height, equals(1080)); + } + }); + + test('does not update dimensions if asset already has width and height', () async { + const assetId = 'asset-with-dimensions'; + const existingWidth = 1920; + const existingHeight = 1080; + const exifWidth = 3840; + const exifHeight = 2160; + + await sut.updateUsersV1([_createUser()]); + + final asset = _createAsset( + id: assetId, + checksum: 'checksum-with-dims', + fileName: 'with_dimensions.jpg', + width: existingWidth, + height: existingHeight, + ); + await sut.updateAssetsV1([asset]); + + final exif = _createExif(assetId: assetId, width: exifWidth, height: exifHeight, orientation: '6'); + await sut.updateAssetsExifV1([exif]); + + // Verify the asset still has original dimensions (not updated from EXIF) + final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId)); + final result = await query.getSingle(); + + expect(result.width, equals(existingWidth), reason: 'Width should remain as originally set'); + expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set'); + }); + }); +} diff --git a/mobile/test/domain/services/asset.service_test.dart b/mobile/test/domain/services/asset.service_test.dart index ca9defc33..04e49f89f 100644 --- a/mobile/test/domain/services/asset.service_test.dart +++ b/mobile/test/domain/services/asset.service_test.dart @@ -166,8 +166,8 @@ void main() { expect(result, 1080 / 1920); }); - test('handles various flipped EXIF orientations correctly', () async { - final flippedOrientations = ['5', '6', '7', '8', '90', '-90']; + test('should not flip remote asset dimensions', () async { + final flippedOrientations = ['1', '2', '3', '4', '5', '6', '7', '8', '90', '-90']; for (final orientation in flippedOrientations) { final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080); @@ -178,23 +178,7 @@ void main() { final result = await sut.getAspectRatio(remoteAsset); - expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip dimensions'); - } - }); - - test('handles various non-flipped EXIF orientations correctly', () async { - final nonFlippedOrientations = ['1', '2', '3', '4']; - - for (final orientation in nonFlippedOrientations) { - final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080); - - final exif = ExifInfo(orientation: orientation); - - when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif); - - final result = await sut.getAspectRatio(remoteAsset); - - expect(result, 1920 / 1080, reason: 'Orientation $orientation should NOT flip dimensions'); + expect(result, 1920 / 1080, reason: 'Should not flipped remote asset dimensions for orientation $orientation'); } }); }); diff --git a/mobile/test/fixtures/sync_stream.stub.dart b/mobile/test/fixtures/sync_stream.stub.dart index 523984f96..69f6c1753 100644 --- a/mobile/test/fixtures/sync_stream.stub.dart +++ b/mobile/test/fixtures/sync_stream.stub.dart @@ -94,25 +94,11 @@ abstract final class SyncStreamStub { required String ack, DateTime? trashedAt, }) { - return _assetV1( - id: id, - checksum: checksum, - deletedAt: trashedAt ?? DateTime(2025, 1, 1), - ack: ack, - ); + return _assetV1(id: id, checksum: checksum, deletedAt: trashedAt ?? DateTime(2025, 1, 1), ack: ack); } - static SyncEvent assetModified({ - required String id, - required String checksum, - required String ack, - }) { - return _assetV1( - id: id, - checksum: checksum, - deletedAt: null, - ack: ack, - ); + static SyncEvent assetModified({required String id, required String checksum, required String ack}) { + return _assetV1(id: id, checksum: checksum, deletedAt: null, ack: ack); } static SyncEvent _assetV1({ @@ -140,6 +126,8 @@ abstract final class SyncStreamStub { thumbhash: null, type: AssetTypeEnum.IMAGE, visibility: AssetVisibility.timeline, + width: null, + height: null, ), ack: ack, ); diff --git a/mobile/test/modules/utils/openapi_patching_test.dart b/mobile/test/modules/utils/openapi_patching_test.dart index b956c4bfb..a577b0544 100644 --- a/mobile/test/modules/utils/openapi_patching_test.dart +++ b/mobile/test/modules/utils/openapi_patching_test.dart @@ -45,5 +45,17 @@ void main() { addDefault(value, keys, defaultValue); expect(value['alpha']['beta'], 'gamma'); }); + + test('addDefault with null', () { + dynamic value = jsonDecode(""" +{ + "download": { + "archiveSize": 4294967296, + "includeEmbeddedVideos": false + } +} +"""); + expect(value['download']['unknownKey'], isNull); + }); }); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 70f482c5b..2f160e6be 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3303,6 +3303,173 @@ "x-immich-state": "Stable" } }, + "/assets/{id}/edits": { + "delete": { + "description": "Removes all edit actions (crop, rotate, mirror) associated with the specified asset.", + "operationId": "removeAssetEdits", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Remove edits from an existing asset", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v2.5.0", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.edit.delete", + "x-immich-state": "Beta" + }, + "get": { + "description": "Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.", + "operationId": "getAssetEdits", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetEditsDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve edits for an existing asset", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v2.5.0", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.edit.get", + "x-immich-state": "Beta" + }, + "put": { + "description": "Apply a series of edit actions (crop, rotate, mirror) to the specified asset.", + "operationId": "editAsset", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetEditActionListDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetEditsDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Apply edits to an existing asset", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v2.5.0", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.edit.create", + "x-immich-state": "Beta" + } + }, "/assets/{id}/metadata": { "get": { "description": "Retrieve all metadata key-value pairs associated with the specified asset.", @@ -3632,6 +3799,15 @@ "description": "Downloads the original file of the specified asset.", "operationId": "downloadAsset", "parameters": [ + { + "name": "edited", + "required": false, + "in": "query", + "schema": { + "default": false, + "type": "boolean" + } + }, { "name": "id", "required": true, @@ -3792,6 +3968,15 @@ "description": "Retrieve the thumbnail image for the specified asset.", "operationId": "viewAsset", "parameters": [ + { + "name": "edited", + "required": false, + "in": "query", + "schema": { + "default": false, + "type": "boolean" + } + }, { "name": "id", "required": true, @@ -15286,6 +15471,128 @@ ], "type": "object" }, + "AssetEditAction": { + "enum": [ + "crop", + "rotate", + "mirror" + ], + "type": "string" + }, + "AssetEditActionCrop": { + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetEditAction" + } + ] + }, + "parameters": { + "$ref": "#/components/schemas/CropParameters" + } + }, + "required": [ + "action", + "parameters" + ], + "type": "object" + }, + "AssetEditActionListDto": { + "properties": { + "edits": { + "description": "list of edits", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/AssetEditActionCrop" + }, + { + "$ref": "#/components/schemas/AssetEditActionRotate" + }, + { + "$ref": "#/components/schemas/AssetEditActionMirror" + } + ] + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "edits" + ], + "type": "object" + }, + "AssetEditActionMirror": { + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetEditAction" + } + ] + }, + "parameters": { + "$ref": "#/components/schemas/MirrorParameters" + } + }, + "required": [ + "action", + "parameters" + ], + "type": "object" + }, + "AssetEditActionRotate": { + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetEditAction" + } + ] + }, + "parameters": { + "$ref": "#/components/schemas/RotateParameters" + } + }, + "required": [ + "action", + "parameters" + ], + "type": "object" + }, + "AssetEditsDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "edits": { + "description": "list of edits", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/AssetEditActionCrop" + }, + { + "$ref": "#/components/schemas/AssetEditActionRotate" + }, + { + "$ref": "#/components/schemas/AssetEditActionMirror" + } + ] + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "assetId", + "edits" + ], + "type": "object" + }, "AssetFaceCreateDto": { "properties": { "assetId": { @@ -15959,6 +16266,10 @@ "hasMetadata": { "type": "boolean" }, + "height": { + "nullable": true, + "type": "number" + }, "id": { "type": "string" }, @@ -16079,6 +16390,10 @@ "$ref": "#/components/schemas/AssetVisibility" } ] + }, + "width": { + "nullable": true, + "type": "number" } }, "required": [ @@ -16090,6 +16405,7 @@ "fileCreatedAt", "fileModifiedAt", "hasMetadata", + "height", "id", "isArchived", "isFavorite", @@ -16102,7 +16418,8 @@ "thumbhash", "type", "updatedAt", - "visibility" + "visibility", + "width" ], "type": "object" }, @@ -16466,6 +16783,37 @@ ], "type": "object" }, + "CropParameters": { + "properties": { + "height": { + "description": "Height of the crop", + "minimum": 1, + "type": "number" + }, + "width": { + "description": "Width of the crop", + "minimum": 1, + "type": "number" + }, + "x": { + "description": "Top-Left X coordinate of crop", + "minimum": 0, + "type": "number" + }, + "y": { + "description": "Top-Left Y coordinate of crop", + "minimum": 0, + "type": "number" + } + }, + "required": [ + "height", + "width", + "x", + "y" + ], + "type": "object" + }, "DatabaseBackupConfig": { "properties": { "cronExpression": { @@ -16865,6 +17213,7 @@ "AssetDetectFaces", "AssetDetectDuplicatesQueueAll", "AssetDetectDuplicates", + "AssetEditThumbnailGeneration", "AssetEncodeVideoQueueAll", "AssetEncodeVideo", "AssetEmptyTrash", @@ -17620,6 +17969,30 @@ }, "type": "object" }, + "MirrorAxis": { + "description": "Axis to mirror along", + "enum": [ + "horizontal", + "vertical" + ], + "type": "string" + }, + "MirrorParameters": { + "properties": { + "axis": { + "allOf": [ + { + "$ref": "#/components/schemas/MirrorAxis" + } + ], + "description": "Axis to mirror along" + } + }, + "required": [ + "axis" + ], + "type": "object" + }, "NotificationCreateDto": { "properties": { "data": { @@ -18100,6 +18473,10 @@ "asset.upload", "asset.replace", "asset.copy", + "asset.derive", + "asset.edit.get", + "asset.edit.create", + "asset.edit.delete", "album.create", "album.read", "album.update", @@ -18813,7 +19190,8 @@ "notifications", "backupDatabase", "ocr", - "workflow" + "workflow", + "editor" ], "type": "string" }, @@ -18920,6 +19298,9 @@ "duplicateDetection": { "$ref": "#/components/schemas/QueueResponseLegacyDto" }, + "editor": { + "$ref": "#/components/schemas/QueueResponseLegacyDto" + }, "faceDetection": { "$ref": "#/components/schemas/QueueResponseLegacyDto" }, @@ -18967,6 +19348,7 @@ "backgroundTask", "backupDatabase", "duplicateDetection", + "editor", "faceDetection", "facialRecognition", "library", @@ -19179,6 +19561,18 @@ ], "type": "object" }, + "RotateParameters": { + "properties": { + "angle": { + "description": "Rotation angle in degrees", + "type": "number" + } + }, + "required": [ + "angle" + ], + "type": "object" + }, "SearchAlbumResponseDto": { "properties": { "count": { @@ -20892,6 +21286,10 @@ "nullable": true, "type": "string" }, + "height": { + "nullable": true, + "type": "integer" + }, "id": { "type": "string" }, @@ -20938,6 +21336,10 @@ "$ref": "#/components/schemas/AssetVisibility" } ] + }, + "width": { + "nullable": true, + "type": "integer" } }, "required": [ @@ -20946,6 +21348,7 @@ "duration", "fileCreatedAt", "fileModifiedAt", + "height", "id", "isFavorite", "libraryId", @@ -20956,7 +21359,8 @@ "stackId", "thumbhash", "type", - "visibility" + "visibility", + "width" ], "type": "object" }, @@ -21809,6 +22213,9 @@ "backgroundTask": { "$ref": "#/components/schemas/JobSettingsDto" }, + "editor": { + "$ref": "#/components/schemas/JobSettingsDto" + }, "faceDetection": { "$ref": "#/components/schemas/JobSettingsDto" }, @@ -21848,6 +22255,7 @@ }, "required": [ "backgroundTask", + "editor", "faceDetection", "library", "metadataExtraction", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index afc583f91..496e6906a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -349,6 +349,7 @@ export type AssetResponseDto = { /** The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken. */ fileModifiedAt: string; hasMetadata: boolean; + height: number | null; id: string; isArchived: boolean; isFavorite: boolean; @@ -373,6 +374,7 @@ export type AssetResponseDto = { /** The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. */ updatedAt: string; visibility: AssetVisibility; + width: number | null; }; export type ContributorCountResponseDto = { assetCount: number; @@ -574,6 +576,45 @@ export type UpdateAssetDto = { rating?: number; visibility?: AssetVisibility; }; +export type CropParameters = { + /** Height of the crop */ + height: number; + /** Width of the crop */ + width: number; + /** Top-Left X coordinate of crop */ + x: number; + /** Top-Left Y coordinate of crop */ + y: number; +}; +export type AssetEditActionCrop = { + action: AssetEditAction; + parameters: CropParameters; +}; +export type RotateParameters = { + /** Rotation angle in degrees */ + angle: number; +}; +export type AssetEditActionRotate = { + action: AssetEditAction; + parameters: RotateParameters; +}; +export type MirrorParameters = { + /** Axis to mirror along */ + axis: MirrorAxis; +}; +export type AssetEditActionMirror = { + action: AssetEditAction; + parameters: MirrorParameters; +}; +export type AssetEditsDto = { + assetId: string; + /** list of edits */ + edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[]; +}; +export type AssetEditActionListDto = { + /** list of edits */ + edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[]; +}; export type AssetMetadataResponseDto = { key: string; updatedAt: string; @@ -749,6 +790,7 @@ export type QueuesResponseLegacyDto = { backgroundTask: QueueResponseLegacyDto; backupDatabase: QueueResponseLegacyDto; duplicateDetection: QueueResponseLegacyDto; + editor: QueueResponseLegacyDto; faceDetection: QueueResponseLegacyDto; facialRecognition: QueueResponseLegacyDto; library: QueueResponseLegacyDto; @@ -1484,6 +1526,7 @@ export type JobSettingsDto = { }; export type SystemConfigJobDto = { backgroundTask: JobSettingsDto; + editor: JobSettingsDto; faceDetection: JobSettingsDto; library: JobSettingsDto; metadataExtraction: JobSettingsDto; @@ -2581,6 +2624,46 @@ export function updateAsset({ id, updateAssetDto }: { body: updateAssetDto }))); } +/** + * Remove edits from an existing asset + */ +export function removeAssetEdits({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/edits`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve edits for an existing asset + */ +export function getAssetEdits({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetEditsDto; + }>(`/assets/${encodeURIComponent(id)}/edits`, { + ...opts + })); +} +/** + * Apply edits to an existing asset + */ +export function editAsset({ id, assetEditActionListDto }: { + id: string; + assetEditActionListDto: AssetEditActionListDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetEditsDto; + }>(`/assets/${encodeURIComponent(id)}/edits`, oazapfts.json({ + ...opts, + method: "PUT", + body: assetEditActionListDto + }))); +} /** * Get asset metadata */ @@ -2652,7 +2735,8 @@ export function getAssetOcr({ id }: { /** * Download original asset */ -export function downloadAsset({ id, key, slug }: { +export function downloadAsset({ edited, id, key, slug }: { + edited?: boolean; id: string; key?: string; slug?: string; @@ -2661,6 +2745,7 @@ export function downloadAsset({ id, key, slug }: { status: 200; data: Blob; }>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({ + edited, key, slug }))}`, { @@ -2691,7 +2776,8 @@ export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: { /** * View asset thumbnail */ -export function viewAsset({ id, key, size, slug }: { +export function viewAsset({ edited, id, key, size, slug }: { + edited?: boolean; id: string; key?: string; size?: AssetMediaSize; @@ -2701,6 +2787,7 @@ export function viewAsset({ id, key, size, slug }: { status: 200; data: Blob; }>(`/assets/${encodeURIComponent(id)}/thumbnail${QS.query(QS.explode({ + edited, key, size, slug @@ -5288,6 +5375,10 @@ export enum Permission { AssetUpload = "asset.upload", AssetReplace = "asset.replace", AssetCopy = "asset.copy", + AssetDerive = "asset.derive", + AssetEditGet = "asset.edit.get", + AssetEditCreate = "asset.edit.create", + AssetEditDelete = "asset.edit.delete", AlbumCreate = "album.create", AlbumRead = "album.read", AlbumUpdate = "album.update", @@ -5433,6 +5524,15 @@ export enum AssetJobName { RegenerateThumbnail = "regenerate-thumbnail", TranscodeVideo = "transcode-video" } +export enum AssetEditAction { + Crop = "crop", + Rotate = "rotate", + Mirror = "mirror" +} +export enum MirrorAxis { + Horizontal = "horizontal", + Vertical = "vertical" +} export enum AssetMediaSize { Fullsize = "fullsize", Preview = "preview", @@ -5463,7 +5563,8 @@ export enum QueueName { Notifications = "notifications", BackupDatabase = "backupDatabase", Ocr = "ocr", - Workflow = "workflow" + Workflow = "workflow", + Editor = "editor" } export enum QueueCommand { Start = "start", @@ -5508,6 +5609,7 @@ export enum JobName { AssetDetectFaces = "AssetDetectFaces", AssetDetectDuplicatesQueueAll = "AssetDetectDuplicatesQueueAll", AssetDetectDuplicates = "AssetDetectDuplicates", + AssetEditThumbnailGeneration = "AssetEditThumbnailGeneration", AssetEncodeVideoQueueAll = "AssetEncodeVideoQueueAll", AssetEncodeVideo = "AssetEncodeVideo", AssetEmptyTrash = "AssetEmptyTrash", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3eb433e6..287faee4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -565,6 +565,9 @@ importers: thumbhash: specifier: ^0.1.1 version: 0.1.1 + transformation-matrix: + specifier: ^3.1.0 + version: 3.1.0 ua-parser-js: specifier: ^2.0.0 version: 2.0.7 @@ -11332,6 +11335,9 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + transformation-matrix@3.1.0: + resolution: {integrity: sha512-oYubRWTi2tYFHAL2J8DLvPIqIYcYZ0fSOi2vmSy042Ho4jBW2ce6VP7QfD44t65WQz6bw5w1Pk22J7lcUpaTKA==} + tree-dump@1.1.0: resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} engines: {node: '>=10.0'} @@ -24876,6 +24882,8 @@ snapshots: punycode: 2.3.1 optional: true + transformation-matrix@3.1.0: {} + tree-dump@1.1.0(tslib@2.8.1): dependencies: tslib: 2.8.1 diff --git a/server/package.json b/server/package.json index 81f1181e6..2e54b11de 100644 --- a/server/package.json +++ b/server/package.json @@ -110,6 +110,7 @@ "socket.io": "^4.8.1", "tailwindcss-preset-email": "^1.4.0", "thumbhash": "^0.1.1", + "transformation-matrix": "^3.1.0", "ua-parser-js": "^2.0.0", "uuid": "^11.1.0", "validator": "^13.12.0" @@ -128,8 +129,8 @@ "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/fluent-ffmpeg": "^2.1.21", - "@types/jsonwebtoken": "^9.0.10", "@types/js-yaml": "^4.0.9", + "@types/jsonwebtoken": "^9.0.10", "@types/lodash": "^4.14.197", "@types/luxon": "^3.6.2", "@types/mock-fs": "^4.13.1", diff --git a/server/src/config.ts b/server/src/config.ts index c18acd79f..9b5fafd60 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -236,6 +236,7 @@ export const defaults = Object.freeze({ [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, [QueueName.Workflow]: { concurrency: 5 }, + [QueueName.Editor]: { concurrency: 2 }, }, logging: { enabled: true, diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index d52a40d7d..788ee0c0e 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -33,6 +33,7 @@ import { CheckExistingAssetsDto, UploadFieldName, } from 'src/dtos/asset-media.dto'; +import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; @@ -104,10 +105,11 @@ export class AssetMediaController { async downloadAsset( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, + @Query() dto: AssetDownloadOriginalDto, @Res() res: Response, @Next() next: NextFunction, ) { - await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger); + await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger); } @Put(':id/original') diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 56c9d1804..cf8b80be3 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -292,6 +292,64 @@ describe(AssetController.name, () => { }); }); + describe('PUT /assets/:id/edits', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/edits`).send({ edits: [] }); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should accept valid edits and pass to service correctly', async () => { + const edits = [ + { + action: 'crop', + parameters: { + x: 0, + y: 0, + width: 100, + height: 100, + }, + }, + ]; + + const assetId = factory.uuid(); + const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}/edits`).send({ + edits, + }); + + expect(service.editAsset).toHaveBeenCalledWith(undefined, assetId, { edits }); + expect(status).toBe(200); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets/123/edits`) + .send({ + edits: [ + { + action: 'crop', + parameters: { + x: 0, + y: 0, + width: 100, + height: 100, + }, + }, + ], + }); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); + }); + + it('should require at least one edit', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/assets/${factory.uuid()}/edits`) + .send({ edits: [] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(['edits must contain at least 1 elements'])); + }); + }); + describe('DELETE /assets/:id/metadata/:key', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`); diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index ba9ec865f..988623360 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -20,6 +20,7 @@ import { UpdateAssetDto, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { ApiTag, Permission, RouteKey } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; @@ -226,4 +227,42 @@ export class AssetController { deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise { return this.service.deleteMetadataByKey(auth, id, key); } + + @Get(':id/edits') + @Authenticated({ permission: Permission.AssetEditGet }) + @Endpoint({ + summary: 'Retrieve edits for an existing asset', + description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.', + history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'), + }) + getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getAssetEdits(auth, id); + } + + @Put(':id/edits') + @Authenticated({ permission: Permission.AssetEditCreate }) + @Endpoint({ + summary: 'Apply edits to an existing asset', + description: 'Apply a series of edit actions (crop, rotate, mirror) to the specified asset.', + history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'), + }) + editAsset( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: AssetEditActionListDto, + ): Promise { + return this.service.editAsset(auth, id, dto); + } + + @Delete(':id/edits') + @Authenticated({ permission: Permission.AssetEditDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Remove edits from an existing asset', + description: 'Removes all edit actions (crop, rotate, mirror) associated with the specified asset.', + history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'), + }) + removeAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.removeAssetEdits(auth, id); + } } diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 96623092f..d688857de 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -24,7 +24,13 @@ export interface MoveRequest { }; } -export type GeneratedImageType = AssetPathType.Preview | AssetPathType.Thumbnail | AssetPathType.FullSize; +export type GeneratedImageType = + | AssetPathType.Preview + | AssetPathType.Thumbnail + | AssetPathType.FullSize + | AssetPathType.EditedPreview + | AssetPathType.EditedThumbnail + | AssetPathType.EditedFullSize; export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo; export type ThumbnailPathEntity = { id: string; ownerId: string }; diff --git a/server/src/database.ts b/server/src/database.ts index 9f4494b72..95bc98bae 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -272,6 +272,7 @@ export type AssetFace = { person?: Person | null; updatedAt: Date; updateId: string; + isVisible: boolean; }; export type Plugin = Selectable; @@ -340,6 +341,8 @@ export const columns = { 'asset.originalPath', 'asset.ownerId', 'asset.type', + 'asset.width', + 'asset.height', ], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], @@ -390,6 +393,8 @@ export const columns = { 'asset.livePhotoVideoId', 'asset.stackId', 'asset.libraryId', + 'asset.width', + 'asset.height', ], syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index 262e2f963..f5207d304 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -19,6 +19,9 @@ export enum AssetMediaSize { export class AssetMediaOptionsDto { @ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', optional: true }) size?: AssetMediaSize; + + @ValidateBoolean({ optional: true, default: false }) + edited?: boolean; } export enum UploadFieldName { diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index e228cd8f9..1607c1508 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -3,6 +3,7 @@ import { Selectable } from 'kysely'; import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database'; import { HistoryBuilder, Property } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto'; import { AssetFaceWithoutPersonResponseDto, @@ -13,6 +14,8 @@ import { import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { ImageDimensions } from 'src/types'; +import { getDimensions } from 'src/utils/asset.util'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; import { ValidateEnum } from 'src/validation'; @@ -34,6 +37,8 @@ export class SanitizedAssetResponseDto { duration!: string; livePhotoVideoId?: string | null; hasMetadata!: boolean; + width!: number | null; + height!: number | null; } export class AssetResponseDto extends SanitizedAssetResponseDto { @@ -107,6 +112,7 @@ export type MapAsset = { deviceId: string; duplicateId: string | null; duration: string | null; + edits?: AssetEditActionItem[]; encodedVideoPath: string | null; exifInfo?: Selectable | null; faces?: AssetFace[]; @@ -129,6 +135,8 @@ export type MapAsset = { tags?: Tag[]; thumbhash: Buffer | null; type: AssetType; + width: number | null; + height: number | null; }; export class AssetStackResponseDto { @@ -147,7 +155,11 @@ export type AssetMapOptions = { }; // TODO: this is inefficient -const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => { +const peopleWithFaces = ( + faces?: AssetFace[], + edits?: AssetEditActionItem[], + assetDimensions?: ImageDimensions, +): PersonWithFacesResponseDto[] => { const result: PersonWithFacesResponseDto[] = []; if (faces) { for (const face of faces) { @@ -156,7 +168,7 @@ const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => { if (existingPersonEntry) { existingPersonEntry.faces.push(face); } else { - result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face)] }); + result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face, edits, assetDimensions)] }); } } } @@ -190,10 +202,14 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset duration: entity.duration ?? '0:00:00.00000', livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, + width: entity.width, + height: entity.height, }; return sanitizedAssetResponse as AssetResponseDto; } + const assetDimensions = entity.exifInfo ? getDimensions(entity.exifInfo) : undefined; + return { id: entity.id, createdAt: entity.createdAt, @@ -219,7 +235,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map((tag) => mapTag(tag)), - people: peopleWithFaces(entity.faces), + people: peopleWithFaces(entity.faces, entity.edits, assetDimensions), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), checksum: hexOrBufferToBase64(entity.checksum)!, stack: withStack ? mapStack(entity) : undefined, @@ -227,5 +243,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset hasMetadata: true, duplicateId: entity.duplicateId, resized: true, + width: entity.width, + height: entity.height, }; } diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 854c244ba..5ac79a989 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -228,6 +228,11 @@ export class AssetCopyDto { favorite?: boolean; } +export class AssetDownloadOriginalDto { + @ValidateBoolean({ optional: true, default: false }) + edited?: boolean; +} + export const mapStats = (stats: AssetStats): AssetStatsResponseDto => { return { images: stats[AssetType.Image], diff --git a/server/src/dtos/editing.dto.ts b/server/src/dtos/editing.dto.ts new file mode 100644 index 000000000..56bd09f3e --- /dev/null +++ b/server/src/dtos/editing.dto.ts @@ -0,0 +1,125 @@ +import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; +import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer'; +import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator'; +import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation'; + +export enum AssetEditAction { + Crop = 'crop', + Rotate = 'rotate', + Mirror = 'mirror', +} + +export enum MirrorAxis { + Horizontal = 'horizontal', + Vertical = 'vertical', +} + +export class CropParameters { + @IsInt() + @Min(0) + @ApiProperty({ description: 'Top-Left X coordinate of crop' }) + x!: number; + + @IsInt() + @Min(0) + @ApiProperty({ description: 'Top-Left Y coordinate of crop' }) + y!: number; + + @IsInt() + @Min(1) + @ApiProperty({ description: 'Width of the crop' }) + width!: number; + + @IsInt() + @Min(1) + @ApiProperty({ description: 'Height of the crop' }) + height!: number; +} + +export class RotateParameters { + @IsAxisAlignedRotation() + @ApiProperty({ description: 'Rotation angle in degrees' }) + angle!: number; +} + +export class MirrorParameters { + @IsEnum(MirrorAxis) + @ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' }) + axis!: MirrorAxis; +} + +class AssetEditActionBase { + @IsEnum(AssetEditAction) + @ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction' }) + action!: AssetEditAction; +} + +export class AssetEditActionCrop extends AssetEditActionBase { + @ValidateNested() + @Type(() => CropParameters) + @ApiProperty({ type: CropParameters }) + parameters!: CropParameters; +} + +export class AssetEditActionRotate extends AssetEditActionBase { + @ValidateNested() + @Type(() => RotateParameters) + @ApiProperty({ type: RotateParameters }) + parameters!: RotateParameters; +} + +export class AssetEditActionMirror extends AssetEditActionBase { + @ValidateNested() + @Type(() => MirrorParameters) + @ApiProperty({ type: MirrorParameters }) + parameters!: MirrorParameters; +} + +export type AssetEditActionItem = + | { + action: AssetEditAction.Crop; + parameters: CropParameters; + } + | { + action: AssetEditAction.Rotate; + parameters: RotateParameters; + } + | { + action: AssetEditAction.Mirror; + parameters: MirrorParameters; + }; + +export type AssetEditActionParameter = { + [AssetEditAction.Crop]: CropParameters; + [AssetEditAction.Rotate]: RotateParameters; + [AssetEditAction.Mirror]: MirrorParameters; +}; + +type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror; +const actionToClass: Record> = { + [AssetEditAction.Crop]: AssetEditActionCrop, + [AssetEditAction.Rotate]: AssetEditActionRotate, + [AssetEditAction.Mirror]: AssetEditActionMirror, +} as const; + +const getActionClass = (item: { action: AssetEditAction }): ClassConstructor => + actionToClass[item.action]; + +@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop) +export class AssetEditActionListDto { + /** list of edits */ + @ArrayMinSize(1) + @IsUniqueEditActions() + @ValidateNested({ each: true }) + @Transform(({ value: edits }) => + Array.isArray(edits) ? edits.map((item) => plainToInstance(getActionClass(item), item)) : edits, + ) + @ApiProperty({ anyOf: Object.values(actionToClass).map((target) => ({ $ref: getSchemaPath(target) })) }) + edits!: AssetEditActionItem[]; +} + +export class AssetEditsDto extends AssetEditActionListDto { + @ValidateUUID() + @ApiProperty() + assetId!: string; +} diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 3c90cfdc5..5bf6854d3 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -6,9 +6,12 @@ import { DateTime } from 'luxon'; import { AssetFace, Person } from 'src/database'; import { HistoryBuilder, Property } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SourceType } from 'src/enum'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { ImageDimensions } from 'src/types'; import { asDateString } from 'src/utils/date'; +import { transformFaceBoundingBox } from 'src/utils/transform'; import { IsDateStringFormat, MaxDateString, @@ -233,29 +236,37 @@ export function mapPerson(person: Person): PersonResponseDto { }; } -export function mapFacesWithoutPerson(face: Selectable): AssetFaceWithoutPersonResponseDto { +export function mapFacesWithoutPerson( + face: Selectable, + edits?: AssetEditActionItem[], + assetDimensions?: ImageDimensions, +): AssetFaceWithoutPersonResponseDto { return { id: face.id, - imageHeight: face.imageHeight, - imageWidth: face.imageWidth, - boundingBoxX1: face.boundingBoxX1, - boundingBoxX2: face.boundingBoxX2, - boundingBoxY1: face.boundingBoxY1, - boundingBoxY2: face.boundingBoxY2, + ...transformFaceBoundingBox( + { + boundingBoxX1: face.boundingBoxX1, + boundingBoxY1: face.boundingBoxY1, + boundingBoxX2: face.boundingBoxX2, + boundingBoxY2: face.boundingBoxY2, + imageWidth: face.imageWidth, + imageHeight: face.imageHeight, + }, + edits ?? [], + assetDimensions ?? { width: face.imageWidth, height: face.imageHeight }, + ), sourceType: face.sourceType, }; } -export function mapFaces(face: AssetFace, auth: AuthDto): AssetFaceResponseDto { +export function mapFaces( + face: AssetFace, + auth: AuthDto, + edits?: AssetEditActionItem[], + assetDimensions?: ImageDimensions, +): AssetFaceResponseDto { return { - id: face.id, - imageHeight: face.imageHeight, - imageWidth: face.imageWidth, - boundingBoxX1: face.boundingBoxX1, - boundingBoxX2: face.boundingBoxX2, - boundingBoxY1: face.boundingBoxY1, - boundingBoxY2: face.boundingBoxY2, - sourceType: face.sourceType, + ...mapFacesWithoutPerson(face, edits, assetDimensions), person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null, }; } diff --git a/server/src/dtos/queue-legacy.dto.ts b/server/src/dtos/queue-legacy.dto.ts index 79155e3f7..e3b48fa86 100644 --- a/server/src/dtos/queue-legacy.dto.ts +++ b/server/src/dtos/queue-legacy.dto.ts @@ -66,6 +66,9 @@ export class QueuesResponseLegacyDto implements Record { diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 7f811af37..6baf3c8ac 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -117,6 +117,10 @@ export class SyncAssetV1 { livePhotoVideoId!: string | null; stackId!: string | null; libraryId!: string | null; + @ApiProperty({ type: 'integer' }) + width!: number | null; + @ApiProperty({ type: 'integer' }) + height!: number | null; } @ExtraModel() diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index c835073c3..31b814503 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -230,6 +230,12 @@ class SystemConfigJobDto implements Record @IsObject() @Type(() => JobSettingsDto) [QueueName.Workflow]!: JobSettingsDto; + + @ApiProperty({ type: JobSettingsDto }) + @ValidateNested() + @IsObject() + @Type(() => JobSettingsDto) + [QueueName.Editor]!: JobSettingsDto; } class SystemConfigLibraryScanDto { diff --git a/server/src/enum.ts b/server/src/enum.ts index b150cdbfb..8f0526d0e 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -45,6 +45,9 @@ export enum AssetFileType { Preview = 'preview', Thumbnail = 'thumbnail', Sidecar = 'sidecar', + FullSizeEdited = 'fullsize_edited', + PreviewEdited = 'preview_edited', + ThumbnailEdited = 'thumbnail_edited', } export enum AlbumUserRole { @@ -106,6 +109,11 @@ export enum Permission { AssetUpload = 'asset.upload', AssetReplace = 'asset.replace', AssetCopy = 'asset.copy', + AssetDerive = 'asset.derive', + + AssetEditGet = 'asset.edit.get', + AssetEditCreate = 'asset.edit.create', + AssetEditDelete = 'asset.edit.delete', AlbumCreate = 'album.create', AlbumRead = 'album.read', @@ -358,6 +366,9 @@ export enum AssetPathType { Original = 'original', FullSize = 'fullsize', Preview = 'preview', + EditedFullSize = 'edited_fullsize', + EditedPreview = 'edited_preview', + EditedThumbnail = 'edited_thumbnail', Thumbnail = 'thumbnail', EncodedVideo = 'encoded_video', Sidecar = 'sidecar', @@ -555,6 +566,7 @@ export enum QueueName { BackupDatabase = 'backupDatabase', Ocr = 'ocr', Workflow = 'workflow', + Editor = 'editor', } export enum QueueJobStatus { @@ -573,6 +585,7 @@ export enum JobName { AssetDetectFaces = 'AssetDetectFaces', AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll', AssetDetectDuplicates = 'AssetDetectDuplicates', + AssetEditThumbnailGeneration = 'AssetEditThumbnailGeneration', AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll', AssetEncodeVideo = 'AssetEncodeVideo', AssetEmptyTrash = 'AssetEmptyTrash', diff --git a/server/src/queries/asset.edit.repository.sql b/server/src/queries/asset.edit.repository.sql new file mode 100644 index 000000000..d11bc7fe7 --- /dev/null +++ b/server/src/queries/asset.edit.repository.sql @@ -0,0 +1,17 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- AssetEditRepository.replaceAll +begin +delete from "asset_edit" +where + "assetId" = $1 +rollback + +-- AssetEditRepository.getAll +select + "action", + "parameters" +from + "asset_edit" +where + "assetId" = $1 diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index ae2b5110c..ccd90680b 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -105,7 +105,21 @@ select where "asset_file"."assetId" = "asset"."id" ) as agg - ) as "files" + ) as "files", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_edit"."action", + "asset_edit"."parameters" + from + "asset_edit" + where + "asset_edit"."assetId" = "asset"."id" + ) as agg + ) as "edits" from "asset" inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id" @@ -167,6 +181,20 @@ select "asset_file"."assetId" = "asset"."id" ) as agg ) as "files", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "asset_edit"."action", + "asset_edit"."parameters" + from + "asset_edit" + where + "asset_edit"."assetId" = "asset"."id" + ) as agg + ) as "edits", to_json("asset_exif") as "exifInfo" from "asset" @@ -191,6 +219,8 @@ select "asset"."originalPath", "asset"."ownerId", "asset"."type", + "asset"."width", + "asset"."height", ( select coalesce(json_agg(agg), '[]') @@ -203,6 +233,7 @@ select where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $1 ) as agg ) as "faces", ( @@ -218,13 +249,13 @@ select "asset_file" where "asset_file"."assetId" = "asset"."id" - and "asset_file"."type" = $1 + and "asset_file"."type" = $2 ) as agg ) as "files" from "asset" where - "asset"."id" = $2 + "asset"."id" = $3 -- AssetJobRepository.getLockedPropertiesForMetadataExtraction select @@ -402,6 +433,7 @@ select where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true ) as agg ) as "faces", ( diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 27e40139e..aaa7dd46f 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -182,6 +182,7 @@ select where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true ) as agg ) as "faces", ( @@ -383,14 +384,10 @@ with "asset_exif"."projectionType", coalesce( case - when asset_exif."exifImageHeight" = 0 - or asset_exif."exifImageWidth" = 0 then 1 - when "asset_exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round( - asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric, - 3 - ) + when asset."height" = 0 + or asset."width" = 0 then 1 else round( - asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric, + asset."width"::numeric / asset."height"::numeric, 3 ) end, diff --git a/server/src/queries/ocr.repository.sql b/server/src/queries/ocr.repository.sql index d9fe04903..fc8991dea 100644 --- a/server/src/queries/ocr.repository.sql +++ b/server/src/queries/ocr.repository.sql @@ -15,6 +15,7 @@ from "asset_ocr" where "asset_ocr"."assetId" = $1 + and "asset_ocr"."isVisible" = $2 -- OcrRepository.upsert with @@ -66,3 +67,12 @@ with ) select 1 as "dummy" + +-- OcrRepository.updateOcrVisibilities +begin +update "ocr_search" +set + "text" = $1 +where + "assetId" = $2 +commit diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 8ad5b96bb..356f5af8f 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -35,6 +35,7 @@ from where "person"."ownerId" = $1 and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true and "person"."isHidden" = $2 group by "person"."id" @@ -63,6 +64,7 @@ from left join "asset_face" on "asset_face"."personId" = "person"."id" where "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true group by "person"."id" having @@ -89,6 +91,7 @@ from where "asset_face"."assetId" = $1 and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $2 order by "asset_face"."boundingBoxX1" asc @@ -229,6 +232,7 @@ from and "asset"."deletedAt" is null where "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true -- PersonRepository.getNumberOfPeople select @@ -250,6 +254,7 @@ where where "asset_face"."personId" = "person"."id" and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $2 and exists ( select from @@ -260,7 +265,7 @@ where and "asset"."deletedAt" is null ) ) - and "person"."ownerId" = $2 + and "person"."ownerId" = $3 -- PersonRepository.refreshFaces with @@ -321,6 +326,7 @@ from where "asset_face"."personId" = $1 and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" is true -- PersonRepository.getLatestFaceDate select diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 7c1dc3b6b..e7595b3d1 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -69,6 +69,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "album_asset"."updateId" from "album_asset" as "album_asset" @@ -99,6 +101,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" @@ -134,7 +138,9 @@ select "asset"."duration", "asset"."livePhotoVideoId", "asset"."stackId", - "asset"."libraryId" + "asset"."libraryId", + "asset"."width", + "asset"."height" from "album_asset" as "album_asset" inner join "asset" on "asset"."id" = "album_asset"."assetId" @@ -448,6 +454,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" @@ -536,6 +544,7 @@ where "asset_face"."updateId" < $1 and "asset_face"."updateId" > $2 and "asset"."ownerId" = $3 + and "asset_face"."isVisible" = $4 order by "asset_face"."updateId" asc @@ -740,6 +749,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" @@ -789,6 +800,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" diff --git a/server/src/repositories/asset-edit.repository.ts b/server/src/repositories/asset-edit.repository.ts new file mode 100644 index 000000000..fdfbc4e1d --- /dev/null +++ b/server/src/repositories/asset-edit.repository.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; +import { DB } from 'src/schema'; + +@Injectable() +export class AssetEditRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ + params: [DummyValue.UUID], + }) + async replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise { + return await this.db.transaction().execute(async (trx) => { + await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute(); + + if (edits.length > 0) { + return trx + .insertInto('asset_edit') + .values(edits.map((edit) => ({ assetId, ...edit }))) + .returning(['action', 'parameters']) + .execute() as Promise; + } + + return []; + }); + } + + @GenerateSql({ + params: [DummyValue.UUID], + }) + async getAll(assetId: string): Promise { + return this.db + .selectFrom('asset_edit') + .select(['action', 'parameters']) + .where('assetId', '=', assetId) + .execute() as Promise; + } +} diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 8beb053aa..39e658a5a 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -11,6 +11,7 @@ import { asUuid, toJson, withDefaultVisibility, + withEdits, withExif, withExifInner, withFaces, @@ -72,6 +73,7 @@ export class AssetJobRepository { .selectFrom('asset') .select(['asset.id', 'asset.thumbhash']) .select(withFiles) + .select(withEdits) .where('asset.deletedAt', 'is', null) .where('asset.visibility', '!=', AssetVisibility.Hidden) .$if(!force, (qb) => @@ -113,6 +115,7 @@ export class AssetJobRepository { 'asset.type', ]) .select(withFiles) + .select(withEdits) .$call(withExifInner) .where('asset.id', '=', id) .executeTakeFirst(); @@ -200,7 +203,7 @@ export class AssetJobRepository { .selectFrom('asset') .select(['asset.id', 'asset.visibility']) .$call(withExifInner) - .select((eb) => withFaces(eb, true)) + .select((eb) => withFaces(eb, true, true)) .select((eb) => withFiles(eb, AssetFileType.Preview)) .where('asset.id', '=', id) .executeTakeFirst(); diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index e1d16b8a6..7ae6a277b 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -20,6 +20,7 @@ import { truncatedDate, unnest, withDefaultVisibility, + withEdits, withExif, withFaces, withFacesAndPeople, @@ -112,6 +113,7 @@ interface GetByIdsRelations { smartSearch?: boolean; stack?: { assets?: boolean }; tags?: boolean; + edits?: boolean; } const distinctLocked = (eb: ExpressionBuilder, columns: T) => @@ -472,7 +474,10 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) { + getById( + id: string, + { exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {}, + ) { return this.db .selectFrom('asset') .selectAll('asset') @@ -509,6 +514,7 @@ export class AssetRepository { ) .$if(!!files, (qb) => qb.select(withFiles)) .$if(!!tags, (qb) => qb.select(withTags)) + .$if(!!edits, (qb) => qb.select(withEdits)) .limit(1) .executeTakeFirst(); } @@ -536,10 +542,11 @@ export class AssetRepository { .selectAll('asset') .$call(withExif) .$call((qb) => qb.select(withFacesAndPeople)) + .$call((qb) => qb.select(withEdits)) .executeTakeFirst(); } - return this.getById(asset.id, { exifInfo: true, faces: { person: true } }); + return this.getById(asset.id, { exifInfo: true, faces: { person: true }, edits: true }); } async remove(asset: { id: string }): Promise { @@ -696,11 +703,9 @@ export class AssetRepository { .coalesce( eb .case() - .when(sql`asset_exif."exifImageHeight" = 0 or asset_exif."exifImageWidth" = 0`) + .when(sql`asset."height" = 0 or asset."width" = 0`) .then(eb.lit(1)) - .when('asset_exif.orientation', 'in', sql`('5', '6', '7', '8', '-90', '90')`) - .then(sql`round(asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric, 3)`) - .else(sql`round(asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric, 3)`) + .else(sql`round(asset."width"::numeric / asset."height"::numeric, 3)`) .end(), eb.lit(1), ) diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index c59110d67..361a2e717 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -4,6 +4,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; @@ -59,6 +60,7 @@ export const repositories = [ ApiKeyRepository, AppRepository, AssetRepository, + AssetEditRepository, AssetJobRepository, ConfigRepository, CronRepository, diff --git a/server/src/repositories/media.repository.spec.ts b/server/src/repositories/media.repository.spec.ts new file mode 100644 index 000000000..a5380852e --- /dev/null +++ b/server/src/repositories/media.repository.spec.ts @@ -0,0 +1,667 @@ +import sharp from 'sharp'; +import { AssetFace } from 'src/database'; +import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; +import { SourceType } from 'src/enum'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { BoundingBox } from 'src/repositories/machine-learning.repository'; +import { MediaRepository } from 'src/repositories/media.repository'; +import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; +import { automock } from 'test/utils'; + +const getPixelColor = async (buffer: Buffer, x: number, y: number) => { + const metadata = await sharp(buffer).metadata(); + const width = metadata.width!; + const { data } = await sharp(buffer).raw().toBuffer({ resolveWithObject: true }); + const idx = (y * width + x) * 4; + return { + r: data[idx], + g: data[idx + 1], + b: data[idx + 2], + }; +}; + +const buildTestQuadImage = async () => { + // build a 4 quadrant image for testing mirroring + const base = sharp({ + create: { width: 1000, height: 1000, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }).png(); + + const tl = await sharp({ + create: { width: 500, height: 500, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .png() + .toBuffer(); + + const tr = await sharp({ + create: { width: 500, height: 500, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }) + .png() + .toBuffer(); + + const bl = await sharp({ + create: { width: 500, height: 500, channels: 3, background: { r: 0, g: 0, b: 255 } }, + }) + .png() + .toBuffer(); + + const br = await sharp({ + create: { width: 500, height: 500, channels: 3, background: { r: 255, g: 255, b: 0 } }, + }) + .png() + .toBuffer(); + + const image = base.composite([ + { input: tl, left: 0, top: 0 }, // top-left + { input: tr, left: 500, top: 0 }, // top-right + { input: bl, left: 0, top: 500 }, // bottom-left + { input: br, left: 500, top: 500 }, // bottom-right + ]); + + return image.png().toBuffer(); +}; + +describe(MediaRepository.name, () => { + let sut: MediaRepository; + + beforeEach(() => { + // eslint-disable-next-line no-sparse-arrays + sut = new MediaRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false })); + }); + + describe('applyEdits (single actions)', () => { + it('should apply crop edit correctly', async () => { + const result = await sut['applyEdits']( + sharp({ + create: { + width: 1000, + height: 1000, + channels: 4, + background: { r: 255, g: 0, b: 0, alpha: 0.5 }, + }, + }).png(), + [ + { + action: AssetEditAction.Crop, + parameters: { + x: 100, + y: 200, + width: 700, + height: 300, + }, + }, + ], + ); + + const metadata = await result.toBuffer().then((buf) => sharp(buf).metadata()); + expect(metadata.width).toBe(700); + expect(metadata.height).toBe(300); + }); + it('should apply rotate edit correctly', async () => { + const result = await sut['applyEdits']( + sharp({ + create: { + width: 500, + height: 1000, + channels: 4, + background: { r: 255, g: 0, b: 0, alpha: 0.5 }, + }, + }).png(), + [ + { + action: AssetEditAction.Rotate, + parameters: { + angle: 90, + }, + }, + ], + ); + + const metadata = await result.toBuffer().then((buf) => sharp(buf).metadata()); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(500); + }); + + it('should apply mirror edit correctly', async () => { + const resultHorizontal = await sut['applyEdits'](sharp(await buildTestQuadImage()), [ + { + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Horizontal, + }, + }, + ]); + + const bufferHorizontal = await resultHorizontal.toBuffer(); + const metadataHorizontal = await resultHorizontal.metadata(); + expect(metadataHorizontal.width).toBe(1000); + expect(metadataHorizontal.height).toBe(1000); + + expect(await getPixelColor(bufferHorizontal, 10, 10)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(bufferHorizontal, 990, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(bufferHorizontal, 10, 990)).toEqual({ r: 255, g: 255, b: 0 }); + expect(await getPixelColor(bufferHorizontal, 990, 990)).toEqual({ r: 0, g: 0, b: 255 }); + + const resultVertical = await sut['applyEdits'](sharp(await buildTestQuadImage()), [ + { + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Vertical, + }, + }, + ]); + + const bufferVertical = await resultVertical.toBuffer(); + const metadataVertical = await resultVertical.metadata(); + expect(metadataVertical.width).toBe(1000); + expect(metadataVertical.height).toBe(1000); + + // top-left should now be bottom-left (blue) + expect(await getPixelColor(bufferVertical, 10, 10)).toEqual({ r: 0, g: 0, b: 255 }); + // top-right should now be bottom-right (yellow) + expect(await getPixelColor(bufferVertical, 990, 10)).toEqual({ r: 255, g: 255, b: 0 }); + // bottom-left should now be top-left (red) + expect(await getPixelColor(bufferVertical, 10, 990)).toEqual({ r: 255, g: 0, b: 0 }); + // bottom-right should now be top-right (blue) + expect(await getPixelColor(bufferVertical, 990, 990)).toEqual({ r: 0, g: 255, b: 0 }); + }); + }); + + describe('applyEdits (multiple sequential edits)', () => { + it('should apply horizontal mirror then vertical mirror (equivalent to 180° rotation)', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('should apply rotate 90° then horizontal mirror', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 255, g: 255, b: 0 }); + }); + + it('should apply 180° rotation', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Rotate, parameters: { angle: 180 } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('should apply 270° rotations', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Rotate, parameters: { angle: 270 } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 255, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 0, g: 0, b: 255 }); + }); + + it('should apply crop then rotate 90°', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 1000, height: 500 } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(500); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 0, g: 255, b: 0 }); + }); + + it('should apply rotate 90° then crop', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(500); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 0, g: 0, b: 255 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('should apply vertical mirror then horizontal mirror then rotate 90°', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(1000); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 0, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 255, g: 255, b: 0 }); + expect(await getPixelColor(buffer, 10, 990)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 990, 990)).toEqual({ r: 0, g: 0, b: 255 }); + }); + + it('should apply crop to single quadrant then mirror', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 500 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(500); + expect(metadata.height).toBe(500); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 490, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 10, 490)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 490, 490)).toEqual({ r: 255, g: 0, b: 0 }); + }); + + it('should apply all operations: crop, rotate, mirror', async () => { + const imageBuffer = await buildTestQuadImage(); + const result = await sut['applyEdits'](sharp(imageBuffer), [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + ]); + + const buffer = await result.png().toBuffer(); + const metadata = await sharp(buffer).metadata(); + expect(metadata.width).toBe(1000); + expect(metadata.height).toBe(500); + + expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 }); + expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 }); + }); + }); + + describe('checkFaceVisibility', () => { + const baseFace: AssetFace = { + id: 'face-1', + assetId: 'asset-1', + personId: 'person-1', + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 800, + sourceType: SourceType.MachineLearning, + isVisible: true, + updatedAt: new Date(), + deletedAt: null, + updateId: '', + }; + + const assetDimensions = { width: 1000, height: 800 }; + + describe('with no crop edit', () => { + it('should return only currently invisible faces when no crop is provided', () => { + const visibleFace = { ...baseFace, id: 'face-visible', isVisible: true }; + const invisibleFace = { ...baseFace, id: 'face-invisible', isVisible: false }; + const faces = [visibleFace, invisibleFace]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toEqual([invisibleFace]); + expect(result.hidden).toEqual([]); + }); + + it('should return empty arrays when all faces are already visible and no crop is provided', () => { + const faces = [baseFace]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual([]); + }); + + it('should return all faces when all are invisible and no crop is provided', () => { + const face1 = { ...baseFace, id: 'face-1', isVisible: false }; + const face2 = { ...baseFace, id: 'face-2', isVisible: false }; + const faces = [face1, face2]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toEqual([face1, face2]); + expect(result.hidden).toEqual([]); + }); + }); + + describe('with crop edit', () => { + it('should mark face as visible when fully inside crop area', () => { + const crop: BoundingBox = { x1: 0, y1: 0, x2: 500, y2: 400 }; + const faces = [baseFace]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual(faces); + expect(result.hidden).toEqual([]); + }); + + it('should mark face as visible when more than 50% inside crop area', () => { + const crop: BoundingBox = { x1: 150, y1: 150, x2: 650, y2: 550 }; + // Face at (100,100)-(200,200), crop starts at (150,150) + // Overlap: (150,150)-(200,200) = 50x50 = 2500 + // Face area: 100x100 = 10000 + // Overlap percentage: 25% - should be hidden + const faces = [baseFace]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual(faces); + }); + + it('should mark face as hidden when less than 50% inside crop area', () => { + const crop: BoundingBox = { x1: 250, y1: 250, x2: 750, y2: 650 }; + // Face completely outside crop area + const faces = [baseFace]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual(faces); + }); + + it('should mark face as hidden when completely outside crop area', () => { + const crop: BoundingBox = { x1: 500, y1: 500, x2: 700, y2: 700 }; + const faces = [baseFace]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual(faces); + }); + + it('should handle multiple faces with mixed visibility', () => { + const crop: BoundingBox = { x1: 0, y1: 0, x2: 300, y2: 300 }; + const faceInside: AssetFace = { + ...baseFace, + id: 'face-inside', + boundingBoxX1: 50, + boundingBoxY1: 50, + boundingBoxX2: 150, + boundingBoxY2: 150, + }; + const faceOutside: AssetFace = { + ...baseFace, + id: 'face-outside', + boundingBoxX1: 400, + boundingBoxY1: 400, + boundingBoxX2: 500, + boundingBoxY2: 500, + }; + const faces = [faceInside, faceOutside]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual([faceInside]); + expect(result.hidden).toEqual([faceOutside]); + }); + + it('should handle face at exactly 50% overlap threshold', () => { + // Face at (0,0)-(100,100), crop at (50,0)-(150,100) + // Overlap: (50,0)-(100,100) = 50x100 = 5000 + // Face area: 100x100 = 10000 + // Overlap percentage: 50% - exactly at threshold, should be visible + const faceAtEdge: AssetFace = { + ...baseFace, + id: 'face-edge', + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 100, + boundingBoxY2: 100, + }; + const crop: BoundingBox = { x1: 50, y1: 0, x2: 150, y2: 100 }; + const faces = [faceAtEdge]; + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toEqual([faceAtEdge]); + expect(result.hidden).toEqual([]); + }); + }); + + describe('with scaled dimensions', () => { + it('should handle faces when asset dimensions differ from face image dimensions', () => { + // Face stored at 1000x800 resolution, but displaying at 500x400 + const scaledDimensions = { width: 500, height: 400 }; + const crop: BoundingBox = { x1: 0, y1: 0, x2: 250, y2: 200 }; + // Face at (100,100)-(200,200) on 1000x800 + // Scaled to 500x400: (50,50)-(100,100) + // Crop at (0,0)-(250,200) - face is fully inside + const faces = [baseFace]; + const result = checkFaceVisibility(faces, scaledDimensions, crop); + + expect(result.visible).toEqual(faces); + expect(result.hidden).toEqual([]); + }); + }); + }); + + describe('checkOcrVisibility', () => { + const baseOcr: AssetOcrResponseDto & { isVisible: boolean } = { + id: 'ocr-1', + assetId: 'asset-1', + x1: 0.1, + y1: 0.1, + x2: 0.2, + y2: 0.1, + x3: 0.2, + y3: 0.2, + x4: 0.1, + y4: 0.2, + boxScore: 0.9, + textScore: 0.85, + text: 'Test OCR', + isVisible: false, + }; + + const assetDimensions = { width: 1000, height: 800 }; + + describe('with no crop edit', () => { + it('should return only currently invisible OCR items when no crop is provided', () => { + const visibleOcr = { ...baseOcr, id: 'ocr-visible', isVisible: true }; + const invisibleOcr = { ...baseOcr, id: 'ocr-invisible', isVisible: false }; + const ocrs = [visibleOcr, invisibleOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toEqual([invisibleOcr]); + expect(result.hidden).toEqual([]); + }); + + it('should return empty arrays when all OCR items are already visible and no crop is provided', () => { + const visibleOcr = { ...baseOcr, isVisible: true }; + const ocrs = [visibleOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual([]); + }); + + it('should return all OCR items when all are invisible and no crop is provided', () => { + const ocr1 = { ...baseOcr, id: 'ocr-1', isVisible: false }; + const ocr2 = { ...baseOcr, id: 'ocr-2', isVisible: false }; + const ocrs = [ocr1, ocr2]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toEqual([ocr1, ocr2]); + expect(result.hidden).toEqual([]); + }); + }); + + describe('with crop edit', () => { + it('should mark OCR as visible when fully inside crop area', () => { + const crop: BoundingBox = { x1: 0, y1: 0, x2: 500, y2: 400 }; + // OCR box: (0.1,0.1)-(0.2,0.2) on 1000x800 = (100,80)-(200,160) + // Crop: (0,0)-(500,400) - OCR fully inside + const ocrs = [baseOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toEqual(ocrs); + expect(result.hidden).toEqual([]); + }); + + it('should mark OCR as hidden when completely outside crop area', () => { + const crop: BoundingBox = { x1: 500, y1: 500, x2: 700, y2: 700 }; + // OCR box: (100,80)-(200,160) - completely outside crop + const ocrs = [baseOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual(ocrs); + }); + + it('should mark OCR as hidden when less than 50% inside crop area', () => { + const crop: BoundingBox = { x1: 150, y1: 120, x2: 650, y2: 520 }; + // OCR box: (100,80)-(200,160) + // Crop: (150,120)-(650,520) + // Overlap: (150,120)-(200,160) = 50x40 = 2000 + // OCR area: 100x80 = 8000 + // Overlap percentage: 25% - should be hidden + const ocrs = [baseOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toEqual([]); + expect(result.hidden).toEqual(ocrs); + }); + + it('should handle multiple OCR items with mixed visibility', () => { + const crop: BoundingBox = { x1: 0, y1: 0, x2: 300, y2: 300 }; + const ocrInside = { + ...baseOcr, + id: 'ocr-inside', + }; + const ocrOutside = { + ...baseOcr, + id: 'ocr-outside', + x1: 0.5, + y1: 0.5, + x2: 0.6, + y2: 0.5, + x3: 0.6, + y3: 0.6, + x4: 0.5, + y4: 0.6, + }; + const ocrs = [ocrInside, ocrOutside]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toEqual([ocrInside]); + expect(result.hidden).toEqual([ocrOutside]); + }); + + it('should handle OCR boxes with rotated/skewed polygons', () => { + // OCR with a rotated bounding box (not axis-aligned) + const rotatedOcr = { + ...baseOcr, + id: 'ocr-rotated', + x1: 0.15, + y1: 0.1, + x2: 0.25, + y2: 0.15, + x3: 0.2, + y3: 0.25, + x4: 0.1, + y4: 0.2, + }; + const crop: BoundingBox = { x1: 0, y1: 0, x2: 300, y2: 300 }; + const ocrs = [rotatedOcr]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toEqual([rotatedOcr]); + expect(result.hidden).toEqual([]); + }); + }); + + describe('visibility is only affected by crop (not rotate or mirror)', () => { + it('should keep all OCR items visible when there is no crop regardless of other transforms', () => { + // Rotate and mirror edits don't affect visibility - only crop does + // The visibility functions only take an optional crop parameter + const ocrs = [baseOcr]; + + // Without any crop, all OCR items remain visible + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toEqual(ocrs); + expect(result.hidden).toEqual([]); + }); + + it('should only consider crop for visibility calculation', () => { + // Even if the image will be rotated/mirrored, visibility is determined + // solely by whether the OCR box overlaps with the crop area + const crop: BoundingBox = { x1: 0, y1: 0, x2: 300, y2: 300 }; + + const ocrInsideCrop = { + ...baseOcr, + id: 'ocr-inside', + // OCR at (0.1,0.1)-(0.2,0.2) = (100,80)-(200,160) on 1000x800, inside crop + }; + + const ocrOutsideCrop = { + ...baseOcr, + id: 'ocr-outside', + x1: 0.5, + y1: 0.5, + x2: 0.6, + y2: 0.5, + x3: 0.6, + y3: 0.6, + x4: 0.5, + y4: 0.6, + // OCR at (500,400)-(600,480) on 1000x800, outside crop + }; + + const ocrs = [ocrInsideCrop, ocrOutsideCrop]; + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + // OCR inside crop area is visible, OCR outside is hidden + // This is true regardless of any subsequent rotate/mirror operations + expect(result.visible).toEqual([ocrInsideCrop]); + expect(result.hidden).toEqual([ocrOutsideCrop]); + }); + }); + }); +}); diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index a8e96709f..699c31ba5 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -7,6 +7,7 @@ import { Writable } from 'node:stream'; import sharp from 'sharp'; import { ORIENTATION_TO_SHARP_ROTATION } from 'src/constants'; import { Exif } from 'src/database'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { @@ -19,6 +20,7 @@ import { VideoInfo, } from 'src/types'; import { handlePromiseError } from 'src/utils/misc'; +import { createAffineMatrix } from 'src/utils/transform'; const probe = (input: string, options: string[]): Promise => new Promise((resolve, reject) => @@ -138,21 +140,48 @@ export class MediaRepository { } } - decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { - return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); + async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) { + const pipeline = await this.getImageDecodingPipeline(input, options); + return pipeline.raw().toBuffer({ resolveWithObject: true }); + } + + private async applyEdits(pipeline: sharp.Sharp, edits: AssetEditActionItem[]): Promise { + const affineEditOperations = edits.filter((edit) => edit.action !== 'crop'); + const matrix = createAffineMatrix(affineEditOperations); + + const crop = edits.find((edit) => edit.action === 'crop'); + const dimensions = await pipeline.metadata(); + + if (crop) { + pipeline = pipeline.extract({ + left: crop ? Math.round(crop.parameters.x) : 0, + top: crop ? Math.round(crop.parameters.y) : 0, + width: crop ? Math.round(crop.parameters.width) : dimensions.width || 0, + height: crop ? Math.round(crop.parameters.height) : dimensions.height || 0, + }); + } + + const { a, b, c, d } = matrix; + pipeline = pipeline.affine([ + [a, b], + [c, d], + ]); + + return pipeline; } async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise { - await this.getImageDecodingPipeline(input, options) - .toFormat(options.format, { - quality: options.quality, - // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp - chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', - }) - .toFile(output); + const pipeline = await this.getImageDecodingPipeline(input, options); + const decoded = pipeline.toFormat(options.format, { + quality: options.quality, + // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp + chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', + }); + + await decoded.toFile(output); } - private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { + private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { let pipeline = sharp(input, { // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes failOn: options.processInvalidImages ? 'none' : 'error', @@ -175,8 +204,8 @@ export class MediaRepository { } } - if (options.crop) { - pipeline = pipeline.extract(options.crop); + if (options.edits && options.edits.length > 0) { + pipeline = await this.applyEdits(pipeline, options.edits); } if (options.size !== undefined) { @@ -186,14 +215,20 @@ export class MediaRepository { } async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise { - const [{ rgbaToThumbHash }, { data, info }] = await Promise.all([ + const [{ rgbaToThumbHash }, decodingPipeline] = await Promise.all([ import('thumbhash'), - sharp(input, options) - .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }), + this.getImageDecodingPipeline(input, { + colorspace: options.colorspace, + processInvalidImages: options.processInvalidImages, + raw: options.raw, + edits: options.edits, + }), ]); + + const pipeline = decodingPipeline.resize(100, 100, { fit: 'inside', withoutEnlargement: true }).raw().ensureAlpha(); + + const { data, info } = await pipeline.toBuffer({ resolveWithObject: true }); + return Buffer.from(rgbaToThumbHash(info.width, info.height, data)); } diff --git a/server/src/repositories/ocr.repository.ts b/server/src/repositories/ocr.repository.ts index a39f0d368..63375cf57 100644 --- a/server/src/repositories/ocr.repository.ts +++ b/server/src/repositories/ocr.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DummyValue, GenerateSql } from 'src/decorators'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { DB } from 'src/schema'; import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table'; @@ -15,8 +16,15 @@ export class OcrRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getByAssetId(id: string) { - return this.db.selectFrom('asset_ocr').selectAll('asset_ocr').where('asset_ocr.assetId', '=', id).execute(); + getByAssetId(id: string, options?: { isVisible?: boolean }) { + const isVisible = options === undefined ? true : options.isVisible; + + return this.db + .selectFrom('asset_ocr') + .selectAll('asset_ocr') + .where('asset_ocr.assetId', '=', id) + .$if(isVisible !== undefined, (qb) => qb.where('asset_ocr.isVisible', '=', isVisible!)) + .execute(); } deleteAll() { @@ -65,4 +73,40 @@ export class OcrRepository { return query.selectNoFrom(sql`1`.as('dummy')).execute(); } + + @GenerateSql({ params: [DummyValue.UUID, [], []] }) + async updateOcrVisibilities( + assetId: string, + visible: AssetOcrResponseDto[], + hidden: AssetOcrResponseDto[], + ): Promise { + await this.db.transaction().execute(async (trx) => { + if (visible.length > 0) { + await trx + .updateTable('asset_ocr') + .set({ isVisible: true }) + .where( + 'asset_ocr.id', + 'in', + visible.map((i) => i.id), + ) + .execute(); + } + + if (hidden.length > 0) { + await trx + .updateTable('asset_ocr') + .set({ isVisible: false }) + .where( + 'asset_ocr.id', + 'in', + hidden.map((i) => i.id), + ) + .execute(); + } + + const searchText = visible.map((item) => item.text.trim()).join(' '); + await trx.updateTable('ocr_search').set({ text: searchText }).where('assetId', '=', assetId).execute(); + }); + } } diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 725304938..b03112821 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { AssetFace } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFileType, AssetVisibility, SourceType } from 'src/enum'; import { DB } from 'src/schema'; @@ -121,6 +122,7 @@ export class PersonRepository { .$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!)) .$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!)) .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true) .stream(); } @@ -160,6 +162,7 @@ export class PersonRepository { ) .where('person.ownerId', '=', userId) .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true) .orderBy('person.isHidden', 'asc') .orderBy('person.isFavorite', 'desc') .having((eb) => @@ -208,19 +211,23 @@ export class PersonRepository { .selectAll('person') .leftJoin('asset_face', 'asset_face.personId', 'person.id') .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true) .having((eb) => eb.fn.count('asset_face.assetId'), '=', 0) .groupBy('person.id') .execute(); } @GenerateSql({ params: [DummyValue.UUID] }) - getFaces(assetId: string) { + getFaces(assetId: string, options?: { isVisible?: boolean }) { + const isVisible = options === undefined ? true : options.isVisible; + return this.db .selectFrom('asset_face') .selectAll('asset_face') .select(withPerson) .where('asset_face.assetId', '=', assetId) .where('asset_face.deletedAt', 'is', null) + .$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!)) .orderBy('asset_face.boundingBoxX1', 'asc') .execute(); } @@ -350,6 +357,7 @@ export class PersonRepository { ) .select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count')) .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true) .executeTakeFirst(); return { @@ -368,6 +376,7 @@ export class PersonRepository { .selectFrom('asset_face') .whereRef('asset_face.personId', '=', 'person.id') .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true) .where((eb) => eb.exists((eb) => eb @@ -495,6 +504,7 @@ export class PersonRepository { .selectAll('asset_face') .where('asset_face.personId', '=', personId) .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', 'is', true) .executeTakeFirst(); } @@ -539,4 +549,37 @@ export class PersonRepository { } return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute(); } + + @GenerateSql({ params: [[], []] }) + async updateVisibility(visible: AssetFace[], hidden: AssetFace[]): Promise { + if (visible.length === 0 && hidden.length === 0) { + return; + } + + await this.db.transaction().execute(async (trx) => { + if (visible.length > 0) { + await trx + .updateTable('asset_face') + .set({ isVisible: true }) + .where( + 'asset_face.id', + 'in', + visible.map(({ id }) => id), + ) + .execute(); + } + + if (hidden.length > 0) { + await trx + .updateTable('asset_face') + .set({ isVisible: false }) + .where( + 'asset_face.id', + 'in', + hidden.map(({ id }) => id), + ) + .execute(); + } + }); + } } diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 437e32da1..511d7b589 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -483,6 +483,7 @@ class AssetFaceSync extends BaseSync { ]) .leftJoin('asset', 'asset.id', 'asset_face.assetId') .where('asset.ownerId', '=', options.userId) + .where('asset_face.isVisible', '=', true) .stream(); } } diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index d87bf7635..c2da06786 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -37,6 +37,7 @@ export interface ClientEventMap { AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }]; AppRestartV1: [AppRestartEvent]; + AssetEditReadyV1: [{ assetId: string }]; } export type AuthFn = (client: Socket) => Promise; diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 9e206826e..59c9f53d1 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -28,6 +28,7 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; +import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; @@ -86,6 +87,7 @@ export class ImmichDatabase { AlbumTable, ApiKeyTable, AssetAuditTable, + AssetEditTable, AssetFaceTable, AssetFaceAuditTable, AssetMetadataTable, @@ -179,6 +181,7 @@ export interface DB { asset: AssetTable; asset_audit: AssetAuditTable; + asset_edit: AssetEditTable; asset_exif: AssetExifTable; asset_face: AssetFaceTable; asset_face_audit: AssetFaceAuditTable; diff --git a/server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts b/server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts new file mode 100644 index 000000000..90ae32beb --- /dev/null +++ b/server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts @@ -0,0 +1,28 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD COLUMN "width" integer;`.execute(db); + await sql`ALTER TABLE "asset" ADD COLUMN "height" integer;`.execute(db); + + // Populate width and height from exif data with orientation-aware swapping + await sql` + UPDATE "asset" + SET + "width" = CASE + WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageHeight" + ELSE "asset_exif"."exifImageWidth" + END, + "height" = CASE + WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageWidth" + ELSE "asset_exif"."exifImageHeight" + END + FROM "asset_exif" + WHERE "asset"."id" = "asset_exif"."assetId" + AND ("asset_exif"."exifImageWidth" IS NOT NULL OR "asset_exif"."exifImageHeight" IS NOT NULL) + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" DROP COLUMN "width";`.execute(db); + await sql`ALTER TABLE "asset" DROP COLUMN "height";`.execute(db); +} diff --git a/server/src/schema/migrations/1764041175465-CreateAssetEditTable.ts b/server/src/schema/migrations/1764041175465-CreateAssetEditTable.ts new file mode 100644 index 000000000..ef2ef7472 --- /dev/null +++ b/server/src/schema/migrations/1764041175465-CreateAssetEditTable.ts @@ -0,0 +1,22 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql` + CREATE TABLE "asset_edit" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "assetId" uuid NOT NULL, + "action" varchar NOT NULL, + "parameters" jsonb NOT NULL + ); + `.execute(db); + + await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_pkey" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`CREATE INDEX "asset_edit_assetId_idx" ON "asset_edit" ("assetId")`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE IF EXISTS "asset_edit";`.execute(db); +} diff --git a/server/src/schema/migrations/1764458955216-CreateIsVisibleColumns.ts b/server/src/schema/migrations/1764458955216-CreateIsVisibleColumns.ts new file mode 100644 index 000000000..74e4d3bf1 --- /dev/null +++ b/server/src/schema/migrations/1764458955216-CreateIsVisibleColumns.ts @@ -0,0 +1,11 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset_ocr" ADD COLUMN "isVisible" boolean NOT NULL DEFAULT TRUE`.execute(db); + await sql`ALTER TABLE "asset_face" ADD COLUMN "isVisible" boolean NOT NULL DEFAULT TRUE`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset_ocr" DROP COLUMN "isVisible";`.execute(db); + await sql`ALTER TABLE "asset_face" DROP COLUMN "isVisible";`.execute(db); +} diff --git a/server/src/schema/tables/asset-edit.table.ts b/server/src/schema/tables/asset-edit.table.ts new file mode 100644 index 000000000..84d95ca3c --- /dev/null +++ b/server/src/schema/tables/asset-edit.table.ts @@ -0,0 +1,17 @@ +import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn } from 'src/sql-tools'; + +export class AssetEditTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + assetId!: string; + + @Column() + action!: T; + + @Column({ type: 'jsonb' }) + parameters!: AssetEditActionParameter[T]; +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 5041d945e..8b156f2a1 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -78,4 +78,7 @@ export class AssetFaceTable { @UpdateIdColumn() updateId!: Generated; + + @Column({ type: 'boolean', default: true }) + isVisible!: Generated; } diff --git a/server/src/schema/tables/asset-ocr.table.ts b/server/src/schema/tables/asset-ocr.table.ts index 6ab159b53..b9b0838cb 100644 --- a/server/src/schema/tables/asset-ocr.table.ts +++ b/server/src/schema/tables/asset-ocr.table.ts @@ -42,4 +42,7 @@ export class AssetOcrTable { @Column({ type: 'text' }) text!: string; + + @Column({ type: 'boolean', default: true }) + isVisible!: Generated; } diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index b28fc99e4..96ea0a98d 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -137,4 +137,10 @@ export class AssetTable { @Column({ enum: asset_visibility_enum, default: AssetVisibility.Timeline }) visibility!: Generated; + + @Column({ type: 'integer', nullable: true }) + width!: number | null; + + @Column({ type: 'integer', nullable: true }) + height!: number | null; } diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 95eb8b3c9..c19a1ad92 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -489,7 +489,7 @@ describe(AssetMediaService.name, () => { describe('downloadOriginal', () => { it('should require the asset.download permission', async () => { - await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).rejects.toBeInstanceOf(BadRequestException); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, @@ -503,16 +503,16 @@ describe(AssetMediaService.name, () => { it('should throw an error if the asset is not found', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException); + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).rejects.toBeInstanceOf(NotFoundException); - expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true }); + expect(mocks.asset.getById).toHaveBeenCalledWith('asset-1', { files: true, edits: true }); }); it('should download a file', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.asset.getById.mockResolvedValue(assetStub.image); - await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual( + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).resolves.toEqual( new ImmichFileResponse({ path: '/original/path.jpg', fileName: 'asset-id.jpg', @@ -521,6 +521,104 @@ describe(AssetMediaService.name, () => { }), ); }); + + it('should download edited file by default when edits exist', async () => { + const editedAsset = { + ...assetStub.withCropEdit, + files: [ + ...assetStub.withCropEdit.files, + { + id: 'edited-file', + type: AssetFileType.FullSizeEdited, + path: '/uploads/user-id/fullsize/edited.jpg', + } as AssetFile, + ], + }; + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(editedAsset); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual( + new ImmichFileResponse({ + path: '/uploads/user-id/fullsize/edited.jpg', + fileName: 'asset-id.jpg', + contentType: 'image/jpeg', + cacheControl: CacheControl.PrivateWithCache, + }), + ); + }); + + it('should download edited file when edited=true', async () => { + const editedAsset = { + ...assetStub.withCropEdit, + files: [ + ...assetStub.withCropEdit.files, + { + id: 'edited-file', + type: AssetFileType.FullSizeEdited, + path: '/uploads/user-id/fullsize/edited.jpg', + } as AssetFile, + ], + }; + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(editedAsset); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual( + new ImmichFileResponse({ + path: '/uploads/user-id/fullsize/edited.jpg', + fileName: 'asset-id.jpg', + contentType: 'image/jpeg', + cacheControl: CacheControl.PrivateWithCache, + }), + ); + }); + + it('should download original file when edited=false', async () => { + const editedAsset = { + ...assetStub.withCropEdit, + files: [ + ...assetStub.withCropEdit.files, + { + id: 'edited-file', + type: AssetFileType.FullSizeEdited, + path: '/uploads/user-id/fullsize/edited.jpg', + } as AssetFile, + ], + }; + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(editedAsset); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: false })).resolves.toEqual( + new ImmichFileResponse({ + path: '/original/path.jpg', + fileName: 'asset-id.jpg', + contentType: 'image/jpeg', + cacheControl: CacheControl.PrivateWithCache, + }), + ); + }); + + it('should download original file when no edits exist', async () => { + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.image); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual( + new ImmichFileResponse({ + path: '/original/path.jpg', + fileName: 'asset-id.jpg', + contentType: 'image/jpeg', + cacheControl: CacheControl.PrivateWithCache, + }), + ); + }); + + it('should throw a not found when edits exist but no edited file available', async () => { + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + mocks.asset.getById.mockResolvedValue(assetStub.withCropEdit); + + await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).rejects.toBeInstanceOf( + NotFoundException, + ); + }); }); describe('viewThumbnail', () => { @@ -620,6 +718,8 @@ describe(AssetMediaService.name, () => { }), ); }); + + // TODO: Edited asset tests }); describe('playbackVideo', () => { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 5683c6ae1..c2df6397b 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -20,6 +20,7 @@ import { CheckExistingAssetsDto, UploadFieldName, } from 'src/dtos/asset-media.dto'; +import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileType, @@ -193,11 +194,26 @@ export class AssetMediaService extends BaseService { } } - async downloadOriginal(auth: AuthDto, id: string): Promise { + async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] }); const asset = await this.findOrFail(id); + if (asset.edits!.length > 0 && (dto.edited ?? false)) { + const { editedFullsizeFile } = getAssetFiles(asset.files ?? []); + + if (!editedFullsizeFile) { + throw new NotFoundException('Edited asset media not found'); + } + + return new ImmichFileResponse({ + path: editedFullsizeFile.path, + fileName: getFileNameWithoutExtension(asset.originalFileName) + getFilenameExtension(editedFullsizeFile.path), + contentType: mimeTypes.lookup(editedFullsizeFile.path), + cacheControl: CacheControl.PrivateWithCache, + }); + } + return new ImmichFileResponse({ path: asset.originalPath, fileName: asset.originalFileName, @@ -216,12 +232,20 @@ export class AssetMediaService extends BaseService { const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; - const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files ?? []); + const files = getAssetFiles(asset.files ?? []); + + const requestingEdited = (dto.edited ?? false) && asset.edits!.length > 0; + const { fullsizeFile, previewFile, thumbnailFile } = { + fullsizeFile: requestingEdited ? files.editedFullsizeFile : files.fullsizeFile, + previewFile: requestingEdited ? files.editedPreviewFile : files.previewFile, + thumbnailFile: requestingEdited ? files.editedThumbnailFile : files.thumbnailFile, + }; + let filepath = previewFile?.path; if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) { filepath = thumbnailFile.path; } else if (size === AssetMediaSize.FULLSIZE) { - if (mimeTypes.isWebSupportedImage(asset.originalPath)) { + if (mimeTypes.isWebSupportedImage(asset.originalPath) && !dto.edited) { // use original file for web supported images return { targetSize: 'original' }; } @@ -465,7 +489,7 @@ export class AssetMediaService extends BaseService { } private async findOrFail(id: string) { - const asset = await this.assetRepository.getById(id, { files: true }); + const asset = await this.assetRepository.getById(id, { files: true, edits: true }); if (!asset) { throw new NotFoundException('Asset not found'); } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 5e1cce2cc..00708c9d1 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -704,6 +704,7 @@ describe(AssetService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.ocr.getByAssetId.mockResolvedValue([ocr1, ocr2]); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([ocr1, ocr2]); @@ -718,7 +719,7 @@ describe(AssetService.name, () => { it('should return empty array when no OCR data exists', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); mocks.ocr.getByAssetId.mockResolvedValue([]); - + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.getOcr(authStub.admin, 'asset-1')).resolves.toEqual([]); expect(mocks.ocr.getByAssetId).toHaveBeenCalledWith('asset-1'); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 1e776bd25..26775a5ce 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -21,13 +21,32 @@ import { mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditAction, AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; -import { AssetFileType, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; +import { + AssetFileType, + AssetStatus, + AssetType, + AssetVisibility, + JobName, + JobStatus, + Permission, + QueueName, +} from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { requireElevatedPermission } from 'src/utils/access'; -import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; +import { + getAssetFiles, + getDimensions, + getMyPartnerIds, + isPanorama, + onAfterUnlink, + onBeforeLink, + onBeforeUnlink, +} from 'src/utils/asset.util'; import { updateLockedColumns } from 'src/utils/database'; +import { transformOcrBoundingBox } from 'src/utils/transform'; @Injectable() export class AssetService extends BaseService { @@ -62,6 +81,7 @@ export class AssetService extends BaseService { owner: true, faces: { person: true }, stack: { assets: true }, + edits: true, tags: true, }); @@ -339,11 +359,19 @@ export class AssetService extends BaseService { } } - const { fullsizeFile, previewFile, thumbnailFile, sidecarFile } = getAssetFiles(asset.files ?? []); - const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath]; + const assetFiles = getAssetFiles(asset.files ?? []); + const files = [ + assetFiles.thumbnailFile?.path, + assetFiles.previewFile?.path, + assetFiles.fullsizeFile?.path, + assetFiles.editedFullsizeFile?.path, + assetFiles.editedPreviewFile?.path, + assetFiles.editedThumbnailFile?.path, + asset.encodedVideoPath, + ]; if (deleteOnDisk && !asset.isOffline) { - files.push(sidecarFile?.path, asset.originalPath); + files.push(assetFiles.sidecarFile?.path, asset.originalPath); } await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: files.filter(Boolean) } }); @@ -372,7 +400,16 @@ export class AssetService extends BaseService { async getOcr(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); - return this.ocrRepository.getByAssetId(id); + const ocr = await this.ocrRepository.getByAssetId(id); + const asset = await this.assetRepository.getById(id, { exifInfo: true, edits: true }); + + if (!asset || !asset.exifInfo || !asset.edits) { + throw new BadRequestException('Asset not found'); + } + + const dimensions = getDimensions(asset.exifInfo); + + return ocr.map((item) => transformOcrBoundingBox(item, asset.edits!, dimensions)); } async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise { @@ -478,4 +515,78 @@ export class AssetService extends BaseService { await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } }); } } + + async getAssetEdits(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); + const edits = await this.assetEditRepository.getAll(id); + return { + assetId: id, + edits, + }; + } + + async editAsset(auth: AuthDto, id: string, dto: AssetEditActionListDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetEditCreate, ids: [id] }); + + const asset = await this.assetRepository.getById(id, { exifInfo: true }); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + + if (asset.type !== AssetType.Image) { + throw new BadRequestException('Only images can be edited'); + } + + if (asset.livePhotoVideoId) { + throw new BadRequestException('Editing live photos is not supported'); + } + + if (isPanorama(asset)) { + throw new BadRequestException('Editing panorama images is not supported'); + } + + if (asset.originalPath?.toLowerCase().endsWith('.gif')) { + throw new BadRequestException('Editing GIF images is not supported'); + } + + if (asset.originalPath?.toLowerCase().endsWith('.svg')) { + throw new BadRequestException('Editing SVG images is not supported'); + } + + // check that crop parameters will not go out of bounds + const { width: assetWidth, height: assetHeight } = getDimensions(asset.exifInfo!); + + if (!assetWidth || !assetHeight) { + throw new BadRequestException('Asset dimensions are not available for editing'); + } + + const crop = dto.edits.find((e) => e.action === AssetEditAction.Crop)?.parameters; + if (crop) { + const { x, y, width, height } = crop; + if (x + width > assetWidth || y + height > assetHeight) { + throw new BadRequestException('Crop parameters are out of bounds'); + } + } + + const newEdits = await this.assetEditRepository.replaceAll(id, dto.edits); + await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } }); + + // Return the asset and its applied edits + return { + assetId: id, + edits: newEdits, + }; + } + + async removeAssetEdits(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetEditDelete, ids: [id] }); + + const asset = await this.assetRepository.getById(id); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + + await this.assetEditRepository.replaceAll(id, []); + await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } }); + } } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 9c422818b..b3a50a07a 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -11,6 +11,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; @@ -69,6 +70,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ ApiKeyRepository, AppRepository, AssetRepository, + AssetEditRepository, AssetJobRepository, AuditRepository, ConfigRepository, @@ -127,6 +129,7 @@ export class BaseService { protected apiKeyRepository: ApiKeyRepository, protected appRepository: AppRepository, protected assetRepository: AssetRepository, + protected assetEditRepository: AssetEditRepository, protected assetJobRepository: AssetJobRepository, protected auditRepository: AuditRepository, protected configRepository: ConfigRepository, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index b57a20378..c47d75dc2 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -96,6 +96,16 @@ export class JobService extends BaseService { break; } + case JobName.AssetEditThumbnailGeneration: { + const asset = await this.assetRepository.getById(item.data.id); + + if (asset) { + this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, { assetId: item.data.id }); + } + + break; + } + case JobName.AssetGenerateThumbnails: { if (!item.data.notify && item.data.source !== 'upload') { break; @@ -141,6 +151,8 @@ export class JobService extends BaseService { livePhotoVideoId: asset.livePhotoVideoId, stackId: asset.stackId, libraryId: asset.libraryId, + width: asset.width, + height: asset.height, }, exif: { assetId: exif.assetId, diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 861793053..b94c5843a 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -18,13 +18,17 @@ import { } from 'src/enum'; import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; -import { assetStub } from 'test/fixtures/asset.stub'; +import { assetStub, previewFile } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub, personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; +const fullsizeBuffer = Buffer.from('embedded image data'); +const rawBuffer = Buffer.from('raw image data'); +const extractedBuffer = Buffer.from('embedded image file'); + describe(MediaService.name, () => { let sut: MediaService; let mocks: ServiceMocks; @@ -160,6 +164,42 @@ describe(MediaService.name, () => { expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); + + it('should queue assets with edits but missing edited thumbnails', async () => { + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + mocks.person.getAll.mockReturnValue(makeStream()); + await sut.handleQueueGenerateThumbnails({ force: false }); + + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { + name: JobName.AssetEditThumbnailGeneration, + data: { id: assetStub.withCropEdit.id }, + }, + ]); + + expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + }); + + it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => { + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit])); + mocks.person.getAll.mockReturnValue(makeStream()); + await sut.handleQueueGenerateThumbnails({ force: true }); + + expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { + name: JobName.AssetGenerateThumbnails, + data: { id: assetStub.withCropEdit.id }, + }, + { + name: JobName.AssetEditThumbnailGeneration, + data: { id: assetStub.withCropEdit.id }, + }, + ]); + + expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); + }); }); describe('handleQueueMigration', () => { @@ -222,16 +262,12 @@ describe(MediaService.name, () => { }); describe('handleGenerateThumbnails', () => { - let rawBuffer: Buffer; - let fullsizeBuffer: Buffer; - let extractedBuffer: Buffer; let rawInfo: RawImageInfo; beforeEach(() => { - fullsizeBuffer = Buffer.from('embedded image data'); - rawBuffer = Buffer.from('raw image data'); - extractedBuffer = Buffer.from('embedded image file'); rawInfo = { width: 100, height: 100, channels: 3 }; + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); mocks.media.decodeImage.mockImplementation((input) => Promise.resolve( typeof input === 'string' @@ -281,7 +317,12 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mocks.storage.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { + files: expect.arrayContaining([previewFile.path]), + }, + }); }); it('should generate P3 thumbnails for a wide gamut image', async () => { @@ -313,6 +354,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -325,6 +367,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -334,6 +377,7 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, processInvalidImages: false, raw: rawInfo, + edits: [], }); expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ @@ -527,6 +571,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, previewPath, ); @@ -539,6 +584,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, thumbnailPath, ); @@ -572,6 +618,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, previewPath, ); @@ -584,6 +631,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, thumbnailPath, ); @@ -595,7 +643,12 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mocks.storage.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { + files: expect.arrayContaining([previewFile.path]), + }, + }); }); it('should extract embedded image if enabled and available', async () => { @@ -641,7 +694,6 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 1440, }); - expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); }); it('should resize original image if embedded image extraction is not enabled', async () => { @@ -657,7 +709,6 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 1440, }); - expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); }); it('should process invalid images if enabled', async () => { @@ -691,7 +742,6 @@ describe(MediaService.name, () => { expect.objectContaining({ processInvalidImages: false }), ); - expect(mocks.media.getImageDimensions).not.toHaveBeenCalled(); vi.unstubAllEnvs(); }); @@ -722,6 +772,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -752,6 +803,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -764,6 +816,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -792,6 +845,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -804,6 +858,7 @@ describe(MediaService.name, () => { size: 1440, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -833,6 +888,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -888,6 +944,7 @@ describe(MediaService.name, () => { quality: 80, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); @@ -926,12 +983,166 @@ describe(MediaService.name, () => { quality: 90, processInvalidImages: false, raw: rawInfo, + edits: [], }, expect.any(String), ); }); }); + describe('handleAssetEditThumbnailGeneration', () => { + let rawInfo: RawImageInfo; + + beforeEach(() => { + rawInfo = { width: 100, height: 100, channels: 3 }; + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); + mocks.media.decodeImage.mockImplementation((input) => + Promise.resolve( + typeof input === 'string' + ? { data: rawBuffer, info: rawInfo as OutputInfo } // string implies original file + : { data: fullsizeBuffer, info: rawInfo as OutputInfo }, // buffer implies embedded image extracted + ), + ); + }); + + it('should skip videos', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); + + await expect(sut.handleAssetEditThumbnailGeneration({ id: assetStub.video.id })).resolves.toBe(JobStatus.Success); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + }); + + it('should upsert 3 edited files for edit jobs', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withCropEdit, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); + + await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ type: AssetFileType.FullSizeEdited }), + expect.objectContaining({ type: AssetFileType.PreviewEdited }), + expect.objectContaining({ type: AssetFileType.ThumbnailEdited }), + ]), + ); + }); + + it('should apply edits when generating thumbnails', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withCropEdit, + }); + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); + + await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ + edits: [ + { + action: 'crop', + parameters: { height: 1152, width: 1512, x: 216, y: 1512 }, + }, + ], + }), + expect.any(String), + ); + }); + + it('should clean up edited files if an asset has no edits', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withoutEdits, + }); + + const status = await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { + files: expect.arrayContaining([ + '/uploads/user-id/fullsize/path_edited.jpg', + '/uploads/user-id/preview/path_edited.jpg', + '/uploads/user-id/thumbnail/path_edited.jpg', + ]), + }, + }); + + expect(mocks.asset.deleteFiles).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ path: '/uploads/user-id/preview/path_edited.jpg' }), + expect.objectContaining({ path: '/uploads/user-id/thumbnail/path_edited.jpg' }), + expect.objectContaining({ path: '/uploads/user-id/fullsize/path_edited.jpg' }), + ]), + ); + + expect(status).toBe(JobStatus.Success); + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + }); + + it('should generate all 3 edited files if an asset has edits', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withCropEdit, + }); + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); + + await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + + expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.anything(), + expect.stringContaining('edited_preview.jpeg'), + ); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.anything(), + expect.stringContaining('edited_thumbnail.webp'), + ); + expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.anything(), + expect.stringContaining('edited_fullsize.jpeg'), + ); + }); + + it('should generate the original thumbhash if no edits exist', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withoutEdits, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); + + await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id, source: 'upload' }); + + expect(mocks.media.generateThumbhash).toHaveBeenCalled(); + }); + + it('should apply thumbhash if job source is edit and edits exist', async () => { + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...assetStub.withCropEdit, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); + mocks.person.getFaces.mockResolvedValue([]); + mocks.ocr.getByAssetId.mockResolvedValue([]); + + await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); + + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + thumbhash: thumbhashBuffer, + }), + ); + }); + }); + describe('handleGeneratePersonThumbnail', () => { it('should skip if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); @@ -981,12 +1192,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 238, - top: 163, - width: 274, - height: 274, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 274, + width: 274, + x: 238, + y: 163, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1020,12 +1236,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 238, - top: 163, - width: 274, - height: 274, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 274, + width: 274, + x: 238, + y: 163, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1057,12 +1278,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 0, - top: 85, - width: 510, - height: 510, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 510, + width: 510, + x: 0, + y: 85, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1094,12 +1320,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 591, - top: 591, - width: 408, - height: 408, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 408, + width: 408, + x: 591, + y: 591, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1131,12 +1362,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 0, - top: 62, - width: 412, - height: 412, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 412, + width: 412, + x: 0, + y: 62, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1168,12 +1404,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - left: 4485, - top: 94, - width: 138, - height: 138, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 138, + width: 138, + x: 4485, + y: 94, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -1210,12 +1451,17 @@ describe(MediaService.name, () => { colorspace: Colorspace.P3, format: ImageFormat.Jpeg, quality: 80, - crop: { - height: 844, - left: 388, - top: 730, - width: 844, - }, + edits: [ + { + action: 'crop', + parameters: { + height: 844, + width: 844, + x: 388, + y: 730, + }, + }, + ], raw: info, processInvalidImages: false, size: 250, @@ -2999,4 +3245,147 @@ describe(MediaService.name, () => { expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true); }); }); + + describe('syncFiles', () => { + it('should upsert new files when they do not exist', async () => { + const asset = { + id: 'asset-id', + files: [], + }; + + await sut['syncFiles'](asset, [ + { type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, + { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' }, + ]); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, + { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail }, + ]); + expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + }); + + it('should replace existing files with new paths', async () => { + const asset = { + id: 'asset-id', + files: [ + { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, + ], + }; + + await sut['syncFiles'](asset, [ + { type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, + { type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' }, + ]); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, + { assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail }, + ]); + expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { files: ['/old/preview.jpg', '/old/thumbnail.jpg'] }, + }); + }); + + it('should delete files when newPath is not provided', async () => { + const asset = { + id: 'asset-id', + files: [ + { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, + ], + }; + + await sut['syncFiles'](asset, [{ type: AssetFileType.Preview }, { type: AssetFileType.Thumbnail }]); + + expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ + { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, + ]); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { files: ['/old/preview.jpg', '/old/thumbnail.jpg'] }, + }); + }); + + it('should not make changes when file paths already match', async () => { + const asset = { + id: 'asset-id', + files: [ + { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/same/preview.jpg' }, + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/same/thumbnail.jpg' }, + ], + }; + + await sut['syncFiles'](asset, [ + { type: AssetFileType.Preview, newPath: '/same/preview.jpg' }, + { type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg' }, + ]); + + expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + }); + + it('should handle mixed operations (upsert, replace, delete)', async () => { + const asset = { + id: 'asset-id', + files: [ + { id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }, + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, + ], + }; + + await sut['syncFiles'](asset, [ + { type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, // replace + { type: AssetFileType.Thumbnail }, // delete + { type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg' }, // new + ]); + + expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ + { assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview }, + { assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize }, + ]); + expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([ + { id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' }, + ]); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.FileDelete, + data: { files: ['/old/preview.jpg', '/old/thumbnail.jpg'] }, + }); + }); + + it('should handle empty file list', async () => { + const asset = { + id: 'asset-id', + files: [], + }; + + await sut['syncFiles'](asset, []); + + expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + }); + + it('should delete non-existent file types when newPath is not provided', async () => { + const asset = { + id: 'asset-id', + files: [{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }], + }; + + await sut['syncFiles'](asset, [ + { type: AssetFileType.Thumbnail }, // file doesn't exist, newPath not provided + ]); + + expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); + expect(mocks.asset.deleteFiles).not.toHaveBeenCalled(); + expect(mocks.job.queue).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 917df1d8f..f66cbbaa0 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,8 +1,10 @@ import { Injectable } from '@nestjs/common'; +import { SystemConfig } from 'src/config'; import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; -import { Exif } from 'src/database'; +import { AssetFile, Exif } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; +import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetFileType, @@ -24,12 +26,13 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; +import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { BoundingBox } from 'src/repositories/machine-learning.repository'; import { BaseService } from 'src/services/base.service'; import { AudioStreamInfo, - CropOptions, DecodeToBufferOptions, + GenerateThumbnailOptions, ImageDimensions, JobItem, JobOf, @@ -37,16 +40,20 @@ import { VideoInterfaces, VideoStreamInfo, } from 'src/types'; -import { getAssetFiles } from 'src/utils/asset.util'; +import { getAssetFiles, getDimensions } from 'src/utils/asset.util'; +import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; +import { getOutputDimensions } from 'src/utils/transform'; interface UpsertFileOptions { assetId: string; type: AssetFileType; path: string; } +type ThumbnailAsset = NonNullable>>; + @Injectable() export class MediaService extends BaseService { videoInterfaces: VideoInterfaces = { dri: [], mali: false }; @@ -67,12 +74,19 @@ export class MediaService extends BaseService { }; for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) { - const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + const assetFiles = getAssetFiles(asset.files); - if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { + if (!assetFiles.previewFile || !assetFiles.thumbnailFile || !asset.thumbhash || force) { jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }); } + if ( + asset.edits.length > 0 && + (!assetFiles.editedPreviewFile || !assetFiles.editedThumbnailFile || !assetFiles.editedFullsizeFile || force) + ) { + jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } }); + } + if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await queueAll(); } @@ -154,9 +168,45 @@ export class MediaService extends BaseService { return JobStatus.Success; } + @OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor }) + async handleAssetEditThumbnailGeneration({ id }: JobOf): Promise { + const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); + + if (!asset) { + this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`); + return JobStatus.Failed; + } + + const generated = await this.generateEditedThumbnails(asset); + + let thumbhash: Buffer | undefined = generated?.thumbhash; + if (!thumbhash) { + const { image } = await this.getConfig({ withCache: true }); + const extractedImage = await this.extractOriginalImage(asset, image); + const { info, data, colorspace } = extractedImage; + + thumbhash = await this.mediaRepository.generateThumbhash(data, { + colorspace, + processInvalidImages: false, + raw: info, + edits: [], + }); + } + + if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) { + await this.assetRepository.update({ id: asset.id, thumbhash }); + } + + const fullsizeDimensions = generated?.fullsizeDimensions ?? getDimensions(asset.exifInfo!); + await this.assetRepository.update({ id: asset.id, ...fullsizeDimensions }); + + return JobStatus.Success; + } + @OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration }) async handleGenerateThumbnails({ id }: JobOf): Promise { const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); + if (!asset) { this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`); return JobStatus.Failed; @@ -172,6 +222,7 @@ export class MediaService extends BaseService { thumbnailPath: string; fullsizePath?: string; thumbhash: Buffer; + fullsizeDimensions?: ImageDimensions; }; if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`); @@ -184,54 +235,19 @@ export class MediaService extends BaseService { return JobStatus.Skipped; } - const { previewFile, thumbnailFile, fullsizeFile } = getAssetFiles(asset.files); - const toUpsert: UpsertFileOptions[] = []; - if (previewFile?.path !== generated.previewPath) { - toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.Preview }); - } + await this.syncFiles(asset, [ + { type: AssetFileType.Preview, newPath: generated.previewPath }, + { type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath }, + { type: AssetFileType.FullSize, newPath: generated.fullsizePath }, + ]); - if (thumbnailFile?.path !== generated.thumbnailPath) { - toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.Thumbnail }); - } + const editiedGenerated = await this.generateEditedThumbnails(asset); + const thumbhash = editiedGenerated?.thumbhash || generated.thumbhash; - if (generated.fullsizePath && fullsizeFile?.path !== generated.fullsizePath) { - toUpsert.push({ assetId: asset.id, path: generated.fullsizePath, type: AssetFileType.FullSize }); + if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) { + await this.assetRepository.update({ id: asset.id, thumbhash }); } - if (toUpsert.length > 0) { - await this.assetRepository.upsertFiles(toUpsert); - } - - const pathsToDelete: string[] = []; - if (previewFile && previewFile.path !== generated.previewPath) { - this.logger.debug(`Deleting old preview for asset ${asset.id}`); - pathsToDelete.push(previewFile.path); - } - - if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) { - this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); - pathsToDelete.push(thumbnailFile.path); - } - - if (fullsizeFile && fullsizeFile.path !== generated.fullsizePath) { - this.logger.debug(`Deleting old fullsize preview image for asset ${asset.id}`); - pathsToDelete.push(fullsizeFile.path); - if (!generated.fullsizePath) { - // did not generate a new fullsize image, delete the existing record - await this.assetRepository.deleteFiles([fullsizeFile]); - } - } - - if (pathsToDelete.length > 0) { - await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); - } - - if (!asset.thumbhash || Buffer.compare(asset.thumbhash, generated.thumbhash) !== 0) { - await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash }); - } - - await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() }); - return JobStatus.Success; } @@ -258,27 +274,20 @@ export class MediaService extends BaseService { return { info, data, colorspace }; } - private async generateImageThumbnails(asset: { - id: string; - ownerId: string; - originalFileName: string; - originalPath: string; - exifInfo: Exif; - }) { - const { image } = await this.getConfig({ withCache: true }); - const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format); - const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format); - this.storageCore.ensureFolders(previewPath); - - // Handle embedded preview extraction for RAW files + private async extractOriginalImage( + asset: NonNullable, + image: SystemConfig['image'], + useEdits = false, + ) { const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName); const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null; const generateFullsize = - (image.fullsize.enabled || asset.exifInfo.projectionType == 'EQUIRECTANGULAR') && - !mimeTypes.isWebSupportedImage(asset.originalPath); + ((image.fullsize.enabled || asset.exifInfo.projectionType === 'EQUIRECTANGULAR') && + !mimeTypes.isWebSupportedImage(asset.originalPath)) || + useEdits; const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`)); - const { info, data, colorspace } = await this.decodeImage( + const { data, info, colorspace } = await this.decodeImage( extracted ? extracted.buffer : asset.originalPath, // only specify orientation to extracted images which don't have EXIF orientation data // or it can double rotate the image @@ -286,20 +295,64 @@ export class MediaService extends BaseService { convertFullsize ? undefined : image.preview.size, ); + return { + extracted, + data, + info, + colorspace, + convertFullsize, + generateFullsize, + }; + } + + private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) { + const { image } = await this.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath( + asset, + useEdits ? AssetPathType.EditedPreview : AssetPathType.Preview, + image.preview.format, + ); + const thumbnailPath = StorageCore.getImagePath( + asset, + useEdits ? AssetPathType.EditedThumbnail : AssetPathType.Thumbnail, + image.thumbnail.format, + ); + this.storageCore.ensureFolders(previewPath); + + // Handle embedded preview extraction for RAW files + const extractedImage = await this.extractOriginalImage(asset, image, useEdits); + const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage; + // generate final images - const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info }; + const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] }; const promises = [ this.mediaRepository.generateThumbhash(data, thumbnailOptions), - this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailPath), - this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewPath), + this.mediaRepository.generateThumbnail( + data, + { ...image.thumbnail, ...thumbnailOptions, edits: useEdits ? asset.edits : [] }, + thumbnailPath, + ), + this.mediaRepository.generateThumbnail( + data, + { ...image.preview, ...thumbnailOptions, edits: useEdits ? asset.edits : [] }, + previewPath, + ), ]; let fullsizePath: string | undefined; if (convertFullsize) { // convert a new fullsize image from the same source as the thumbnail - fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, image.fullsize.format); - const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, ...thumbnailOptions }; + fullsizePath = StorageCore.getImagePath( + asset, + useEdits ? AssetPathType.EditedFullSize : AssetPathType.FullSize, + image.fullsize.format, + ); + const fullsizeOptions = { + format: image.fullsize.format, + quality: image.fullsize.quality, + ...thumbnailOptions, + }; promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format); @@ -328,7 +381,10 @@ export class MediaService extends BaseService { await Promise.all(promises); } - return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer }; + const decodedDimensions = { width: info.width, height: info.height }; + const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions; + + return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer, fullsizeDimensions }; } @OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration }) @@ -369,17 +425,22 @@ export class MediaService extends BaseService { const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId }); this.storageCore.ensureFolders(thumbnailPath); - const thumbnailOptions = { + const thumbnailOptions: GenerateThumbnailOptions = { colorspace: image.colorspace, format: ImageFormat.Jpeg, raw: info, quality: image.thumbnail.quality, - crop: this.getCrop( - { old: { width: oldWidth, height: oldHeight }, new: { width: info.width, height: info.height } }, - { x1, y1, x2, y2 }, - ), processInvalidImages: false, size: FACE_THUMBNAIL_SIZE, + edits: [ + { + action: AssetEditAction.Crop, + parameters: this.getCrop( + { old: { width: oldWidth, height: oldHeight }, new: { width: info.width, height: info.height } }, + { x1, y1, x2, y2 }, + ), + }, + ], }; await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath); @@ -388,7 +449,10 @@ export class MediaService extends BaseService { return JobStatus.Success; } - private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions { + private getCrop( + dims: { old: ImageDimensions; new: ImageDimensions }, + { x1, y1, x2, y2 }: BoundingBox, + ): CropParameters { // face bounding boxes can spill outside the image dimensions const clampedX1 = clamp(x1, 0, dims.old.width); const clampedY1 = clamp(y1, 0, dims.old.height); @@ -416,8 +480,8 @@ export class MediaService extends BaseService { ); return { - left: middleX - newHalfSize, - top: middleY - newHalfSize, + x: middleX - newHalfSize, + y: middleY - newHalfSize, width: newHalfSize * 2, height: newHalfSize * 2, }; @@ -454,7 +518,12 @@ export class MediaService extends BaseService { processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', }); - return { previewPath, thumbnailPath, thumbhash }; + return { + previewPath, + thumbnailPath, + thumbhash, + fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height }, + }; } @OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion }) @@ -707,4 +776,84 @@ export class MediaService extends BaseService { return false; } } + + private async syncFiles( + asset: { id: string; files: AssetFile[] }, + files: { type: AssetFileType; newPath?: string }[], + ) { + const toUpsert: UpsertFileOptions[] = []; + const pathsToDelete: string[] = []; + const toDelete: AssetFile[] = []; + + for (const { type, newPath } of files) { + const existingFile = asset.files.find((file) => file.type === type); + + // upsert new file path + if (newPath && existingFile?.path !== newPath) { + toUpsert.push({ assetId: asset.id, path: newPath, type }); + + // delete old file from disk + if (existingFile) { + this.logger.debug(`Deleting old ${type} image for asset ${asset.id} in favor of a replacement`); + pathsToDelete.push(existingFile.path); + } + } + + // delete old file from disk and database + if (!newPath && existingFile) { + this.logger.debug(`Deleting old ${type} image for asset ${asset.id}`); + + pathsToDelete.push(existingFile.path); + toDelete.push(existingFile); + } + } + + if (toUpsert.length > 0) { + await this.assetRepository.upsertFiles(toUpsert); + } + + if (toDelete.length > 0) { + await this.assetRepository.deleteFiles(toDelete); + } + + if (pathsToDelete.length > 0) { + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: pathsToDelete } }); + } + } + + private async generateEditedThumbnails(asset: ThumbnailAsset) { + if (asset.type !== AssetType.Image) { + return; + } + + const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined; + + await this.syncFiles(asset, [ + { type: AssetFileType.PreviewEdited, newPath: generated?.previewPath }, + { type: AssetFileType.ThumbnailEdited, newPath: generated?.thumbnailPath }, + { type: AssetFileType.FullSizeEdited, newPath: generated?.fullsizePath }, + ]); + + const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop); + const cropBox = crop + ? { + x1: crop.parameters.x, + y1: crop.parameters.y, + x2: crop.parameters.x + crop.parameters.width, + y2: crop.parameters.y + crop.parameters.height, + } + : undefined; + + const originalDimensions = getDimensions(asset.exifInfo!); + const assetFaces = await this.personRepository.getFaces(asset.id, {}); + const ocrData = await this.ocrRepository.getByAssetId(asset.id, {}); + + const faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox); + await this.personRepository.updateVisibility(faceStatuses.visible, faceStatuses.hidden); + + const ocrStatuses = checkOcrVisibility(ocrData, originalDimensions, cropBox); + await this.ocrRepository.updateOcrVisibilities(asset.id, ocrStatuses.visible, ocrStatuses.hidden); + + return generated; + } } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 98c906d9c..e6d6a523b 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -224,6 +224,8 @@ describe(MetadataService.name, () => { fileCreatedAt: fileModifiedAt, fileModifiedAt, localDateTime: fileModifiedAt, + width: null, + height: null, }); }); @@ -251,6 +253,8 @@ describe(MetadataService.name, () => { fileCreatedAt, fileModifiedAt, localDateTime: fileCreatedAt, + width: null, + height: null, }); }); @@ -297,6 +301,8 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.image.fileCreatedAt, fileModifiedAt: assetStub.image.fileCreatedAt, localDateTime: assetStub.image.fileCreatedAt, + width: null, + height: null, }); }); @@ -327,6 +333,8 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.withLocation.fileCreatedAt, fileModifiedAt: assetStub.withLocation.fileModifiedAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), + width: null, + height: null, }); }); @@ -357,6 +365,8 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.withLocation.fileCreatedAt, fileModifiedAt: assetStub.withLocation.fileModifiedAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), + width: null, + height: null, }); }); @@ -1560,6 +1570,49 @@ describe(MetadataService.name, () => { { lockedPropertiesBehavior: 'skip' }, ); }); + + it('should properly set width/height for normal images', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + width: 1000, + height: 2000, + }), + ); + }); + + it('should properly swap asset width/height for rotated images', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + width: 2000, + height: 1000, + }), + ); + }); + + it('should not overwrite existing width/height if they already exist', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.image, + width: 1920, + height: 1080, + }); + mockReadTags({ ImageWidth: 1280, ImageHeight: 720 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + width: 1280, + height: 720, + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 3e5b220c0..c9535f361 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -196,6 +196,15 @@ export class MetadataService extends BaseService { await this.eventRepository.emit('AssetHide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); } + private isOrientationSidewards(orientation: ExifOrientation | number): boolean { + return [ + ExifOrientation.MirrorHorizontalRotate270CW, + ExifOrientation.Rotate90CW, + ExifOrientation.MirrorHorizontalRotate90CW, + ExifOrientation.Rotate270CW, + ].includes(orientation); + } + @OnJob({ name: JobName.AssetExtractMetadataQueueAll, queue: QueueName.MetadataExtraction }) async handleQueueMetadataExtraction(job: JobOf): Promise { const { force } = job; @@ -289,6 +298,10 @@ export class MetadataService extends BaseService { autoStackId: this.getAutoStackId(exifTags), }; + const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation); + const assetWidth = isSidewards ? validate(height) : validate(width); + const assetHeight = isSidewards ? validate(width) : validate(height); + const promises: Promise[] = [ this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }), this.assetRepository.update({ @@ -297,6 +310,11 @@ export class MetadataService extends BaseService { localDateTime: dates.localDateTime, fileCreatedAt: dates.dateTimeOriginal ?? undefined, fileModifiedAt: stats.mtime, + + // only update the dimensions if they don't already exist + // we don't want to overwrite width/height that are modified by edits + width: asset.width == null ? assetWidth : undefined, + height: asset.height == null ? assetHeight : undefined, }), this.applyTagList(asset, exifTags), ]; @@ -716,12 +734,7 @@ export class MetadataService extends BaseService { return regionInfo; } - const isSidewards = [ - ExifOrientation.MirrorHorizontalRotate270CW, - ExifOrientation.Rotate90CW, - ExifOrientation.MirrorHorizontalRotate90CW, - ExifOrientation.Rotate270CW, - ].includes(orientation); + const isSidewards = this.isOrientationSidewards(orientation); // swap image dimensions in AppliedToDimensions if orientation is sidewards const adjustedAppliedToDimensions = isSidewards @@ -971,9 +984,17 @@ export class MetadataService extends BaseService { private async getVideoTags(originalPath: string) { const { videoStreams, format } = await this.mediaRepository.probe(originalPath); - const tags: Pick = {}; + const tags: Pick = {}; if (videoStreams[0]) { + // Set video dimensions + if (videoStreams[0].width) { + tags.ImageWidth = videoStreams[0].width; + } + if (videoStreams[0].height) { + tags.ImageHeight = videoStreams[0].height; + } + switch (videoStreams[0].rotation) { case -90: { tags.Orientation = ExifOrientation.Rotate90CW; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 41c44ea47..b57a5e107 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -354,6 +354,7 @@ describe(PersonService.name, () => { it('should get the bounding boxes for an asset', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); mocks.person.getFaces.mockResolvedValue([faceStub.primaryFace1]); + mocks.asset.getById.mockResolvedValue(assetStub.image); await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ mapFaces(faceStub.primaryFace1, authStub.admin), ]); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 6fa9b3fdd..dfbb56bd1 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -40,6 +40,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; +import { getDimensions } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFacialRecognitionEnabled } from 'src/utils/misc'; @@ -126,7 +127,10 @@ export class PersonService extends BaseService { async getFacesById(auth: AuthDto, dto: FaceDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] }); const faces = await this.personRepository.getFaces(dto.id); - return faces.map((asset) => mapFaces(asset, auth)); + const asset = await this.assetRepository.getById(dto.id, { edits: true, exifInfo: true }); + const assetDimensions = getDimensions(asset!.exifInfo!); + + return faces.map((face) => mapFaces(face, auth, asset!.edits!, assetDimensions)); } async createNewFeaturePhoto(changeFeaturePhoto: string[]) { diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts index f5cf20413..2c76fee87 100644 --- a/server/src/services/queue.service.spec.ts +++ b/server/src/services/queue.service.spec.ts @@ -23,7 +23,7 @@ describe(QueueService.name, () => { it('should update concurrency', () => { sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); - expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(17); + expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(18); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5); @@ -77,6 +77,7 @@ describe(QueueService.name, () => { [QueueName.BackupDatabase]: expected, [QueueName.Ocr]: expected, [QueueName.Workflow]: expected, + [QueueName.Editor]: expected, }); }); }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index fbdd655bb..fdeabd3a9 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -41,6 +41,7 @@ const updatedConfig = Object.freeze({ [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, [QueueName.Workflow]: { concurrency: 5 }, + [QueueName.Editor]: { concurrency: 2 }, }, backup: { database: { diff --git a/server/src/types.ts b/server/src/types.ts index 779de1ee3..398408730 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -3,6 +3,7 @@ import { VECTOR_EXTENSIONS } from 'src/constants'; import { Asset, AssetFile } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetOrder, AssetType, @@ -25,13 +26,6 @@ export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial = Pick; -export interface CropOptions { - top: number; - left: number; - width: number; - height: number; -} - export interface FullsizeImageOptions { format: ImageFormat; quality: number; @@ -52,9 +46,9 @@ export interface RawImageInfo { interface DecodeImageOptions { colorspace: string; - crop?: CropOptions; processInvalidImages: boolean; raw?: RawImageInfo; + edits?: AssetEditActionItem[]; } export interface DecodeToBufferOptions extends DecodeImageOptions { @@ -72,7 +66,6 @@ export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { ra export interface GenerateThumbnailsOptions { colorspace: string; - crop?: CropOptions; preview?: ImageOptions; processInvalidImages: boolean; thumbhash?: boolean; @@ -186,7 +179,7 @@ export interface IDelayedJob extends IBaseJob { delay?: number; } -export type JobSource = 'upload' | 'sidecar-write' | 'copy'; +export type JobSource = 'upload' | 'sidecar-write' | 'copy' | 'edit'; export interface IEntityJob extends IBaseJob { id: string; source?: JobSource; @@ -385,7 +378,10 @@ export type JobItem = | { name: JobName.Ocr; data: IEntityJob } // Workflow - | { name: JobName.WorkflowRun; data: IWorkflowJob }; + | { name: JobName.WorkflowRun; data: IWorkflowJob } + + // Editor + | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }; export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index f8d5f0ca0..7431cb329 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -157,6 +157,18 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } + case Permission.AssetEditGet: { + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + + case Permission.AssetEditCreate: { + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + + case Permission.AssetEditDelete: { + return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AlbumRead: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f3f807c82..94f7f231a 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,9 +1,10 @@ import { BadRequestException } from '@nestjs/common'; import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; -import { AssetFile } from 'src/database'; +import { AssetFile, Exif } from 'src/database'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { ExifResponseDto } from 'src/dtos/exif.dto'; import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -22,6 +23,10 @@ export const getAssetFiles = (files: AssetFile[]) => ({ previewFile: getAssetFile(files, AssetFileType.Preview), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), sidecarFile: getAssetFile(files, AssetFileType.Sidecar), + + editedFullsizeFile: getAssetFile(files, AssetFileType.FullSizeEdited), + editedPreviewFile: getAssetFile(files, AssetFileType.PreviewEdited), + editedThumbnailFile: getAssetFile(files, AssetFileType.ThumbnailEdited), }); export const addAssets = async ( @@ -199,3 +204,26 @@ export const asUploadRequest = (request: AuthRequest, file: Express.Multer.File) file: mapToUploadFile(file as ImmichFile), }; }; + +const isFlipped = (orientation?: string | null) => { + const value = Number(orientation); + return value && [5, 6, 7, 8, -90, 90].includes(value); +}; + +export const getDimensions = (exifInfo: ExifResponseDto | Exif) => { + const { exifImageWidth: width, exifImageHeight: height } = exifInfo; + + if (!width || !height) { + return { width: 0, height: 0 }; + } + + if (isFlipped(exifInfo.orientation)) { + return { width: height, height: width }; + } + + return { width, height }; +}; + +export const isPanorama = (asset: { exifInfo?: Exif | null; originalFileName: string }) => { + return asset.exifInfo?.projectionType === 'EQUIRECTANGULAR' || asset.originalFileName.toLowerCase().endsWith('.insp'); +}; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 95998eb44..a041946a2 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,4 +1,5 @@ import { + AliasedRawBuilder, DeduplicateJoinsPlugin, Expression, ExpressionBuilder, @@ -16,6 +17,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { parse } from 'pg-connection-string'; import postgres, { Notice, PostgresError } from 'postgres'; import { columns, Exif, lockableProperties, LockableProperty, Person } from 'src/database'; +import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; @@ -180,13 +182,14 @@ export function withSmartSearch(qb: SelectQueryBuilder) { .select((eb) => toJson(eb, 'smart_search').as('smartSearch')); } -export function withFaces(eb: ExpressionBuilder, withDeletedFace?: boolean) { +export function withFaces(eb: ExpressionBuilder, withHidden?: boolean, withDeletedFace?: boolean) { return jsonArrayFrom( eb .selectFrom('asset_face') .selectAll('asset_face') .whereRef('asset_face.assetId', '=', 'asset.id') - .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)), + .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) + .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', '=', true)), ).as('faces'); } @@ -208,7 +211,11 @@ export function withFilePath(eb: ExpressionBuilder, type: AssetFile .where('asset_file.type', '=', type); } -export function withFacesAndPeople(eb: ExpressionBuilder, withDeletedFace?: boolean) { +export function withFacesAndPeople( + eb: ExpressionBuilder, + withHidden?: boolean, + withDeletedFace?: boolean, +) { return jsonArrayFrom( eb .selectFrom('asset_face') @@ -220,7 +227,8 @@ export function withFacesAndPeople(eb: ExpressionBuilder, withDelet .selectAll('asset_face') .select((eb) => eb.table('person').$castTo().as('person')) .whereRef('asset_face.assetId', '=', 'asset.id') - .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)), + .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) + .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)), ).as('faces'); } @@ -232,6 +240,7 @@ export function hasPeople(qb: SelectQueryBuilder, personIds: .select('assetId') .where('personId', '=', anyUuid(personIds!)) .where('deletedAt', 'is', null) + .where('isVisible', 'is', true) .groupBy('assetId') .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) .as('has_people'), @@ -346,6 +355,17 @@ export const tokenizeForSearch = (text: string): string[] => { return tokens; }; +// needed to properly type the return with the EditActionItem discriminated union type +type AliasedEditActions = AliasedRawBuilder; +export function withEdits(eb: ExpressionBuilder): AliasedEditActions { + return jsonArrayFrom( + eb + .selectFrom('asset_edit') + .select(['asset_edit.action', 'asset_edit.parameters']) + .whereRef('asset_edit.assetId', '=', 'asset.id'), + ).as('edits') as AliasedEditActions; +} + const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ diff --git a/server/src/utils/editor.spec.ts b/server/src/utils/editor.spec.ts new file mode 100644 index 000000000..17db0d9da --- /dev/null +++ b/server/src/utils/editor.spec.ts @@ -0,0 +1,505 @@ +import { AssetFace } from 'src/database'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; +import { SourceType } from 'src/enum'; +import { boundingBoxOverlap, checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor'; +import { describe, expect, it } from 'vitest'; + +describe('boundingBoxOverlap', () => { + it('should return 1 for identical boxes', () => { + const box = { x1: 0, y1: 0, x2: 100, y2: 100 }; + expect(boundingBoxOverlap(box, box)).toBe(1); + }); + + it('should return 0 for non-overlapping boxes', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 200, y1: 200, x2: 300, y2: 300 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(0); + }); + + it('should return 0.5 for 50% overlap', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 50, y1: 0, x2: 150, y2: 100 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(0.5); + }); + + it('should return 0.25 for 25% overlap', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 50, y1: 50, x2: 150, y2: 150 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(0.25); + }); + + it('should return 1 when boxA is fully contained in boxB', () => { + const boxA = { x1: 25, y1: 25, x2: 75, y2: 75 }; + const boxB = { x1: 0, y1: 0, x2: 100, y2: 100 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(1); + }); + + it('should handle partial containment correctly', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 25, y1: 25, x2: 75, y2: 75 }; + // boxB is fully inside boxA, so overlap area is 50*50=2500, boxA area is 10000 + expect(boundingBoxOverlap(boxA, boxB)).toBe(0.25); + }); + + it('should handle boxes that touch at edges (no overlap)', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 100, y1: 0, x2: 200, y2: 100 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(0); + }); + + it('should handle vertical partial overlap', () => { + const boxA = { x1: 0, y1: 0, x2: 100, y2: 100 }; + const boxB = { x1: 0, y1: 50, x2: 100, y2: 150 }; + expect(boundingBoxOverlap(boxA, boxB)).toBe(0.5); + }); +}); + +const createFace = (params: Partial = {}): AssetFace => ({ + id: 'face-id', + deletedAt: null, + assetId: 'asset-id', + boundingBoxX1: 100, + boundingBoxX2: 200, + boundingBoxY1: 100, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 1000, + personId: null, + sourceType: SourceType.MachineLearning, + person: null, + updatedAt: new Date(), + updateId: 'update-id', + isVisible: true, + ...params, +}); + +describe('checkFaceVisibility', () => { + const assetDimensions = { width: 1000, height: 1000 }; + + it('should return only non-visible faces when no crop is provided', () => { + const faces = [ + createFace({ id: 'face-1', isVisible: true }), + createFace({ id: 'face-2', isVisible: false }), + createFace({ id: 'face-3', isVisible: false }), + ]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(0); + expect(result.visible.map((f) => f.id)).toEqual(['face-2', 'face-3']); + }); + + it('should return all faces as visible when all are marked not visible and no crop provided', () => { + const faces = [createFace({ id: 'face-1', isVisible: false }), createFace({ id: 'face-2', isVisible: false })]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(0); + }); + + it('should return empty visible array when all faces are already visible and no crop provided', () => { + const faces = [createFace({ id: 'face-1', isVisible: true }), createFace({ id: 'face-2', isVisible: true })]; + const result = checkFaceVisibility(faces, assetDimensions); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(0); + }); + + it('should return empty arrays when no faces provided', () => { + const result = checkFaceVisibility([], assetDimensions); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark face as visible when fully inside crop area', () => { + const faces = [createFace({ boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200 })]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark face as hidden when fully outside crop area', () => { + const faces = [createFace({ boundingBoxX1: 600, boundingBoxY1: 600, boundingBoxX2: 700, boundingBoxY2: 700 })]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(1); + }); + + it('should mark face as visible when at least 50% overlaps with crop', () => { + // Face spans 100-200 (100px), crop starts at 150, so 50% overlap + const faces = [createFace({ boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200 })]; + const crop = { x1: 150, y1: 100, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark face as hidden when less than 50% overlaps with crop', () => { + // Face spans 100-200 (100px), crop starts at 160, so 40% overlap + const faces = [createFace({ boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200 })]; + const crop = { x1: 160, y1: 100, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(1); + }); + + it('should correctly categorize multiple faces', () => { + const faces = [ + createFace({ id: 'face-inside', boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, boundingBoxY2: 200 }), + createFace({ + id: 'face-outside', + boundingBoxX1: 800, + boundingBoxY1: 800, + boundingBoxX2: 900, + boundingBoxY2: 900, + }), + // face-partial: 400-500 overlaps with crop (100x100=10000 overlap, face is 200x200=40000, so 25% - hidden) + createFace({ + id: 'face-partial', + boundingBoxX1: 400, + boundingBoxY1: 400, + boundingBoxX2: 600, + boundingBoxY2: 600, + }), + ]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + // face-inside is fully visible, face-partial has 25% overlap (hidden), face-outside is fully hidden + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(2); + expect(result.visible.map((f) => f.id)).toContain('face-inside'); + expect(result.hidden.map((f) => f.id)).toContain('face-partial'); + expect(result.hidden.map((f) => f.id)).toContain('face-outside'); + }); + + it('should handle face coordinates scaled to different image dimensions', () => { + // Face stored at 50-100 in a 500x500 image, scaled to 1000x1000 becomes 100-200 + const faces = [ + createFace({ + boundingBoxX1: 50, + boundingBoxY1: 50, + boundingBoxX2: 100, + boundingBoxY2: 100, + imageWidth: 500, + imageHeight: 500, + }), + ]; + const crop = { x1: 0, y1: 0, x2: 200, y2: 200 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should categorize based on crop overlap when crop is provided, regardless of isVisible property', () => { + const faces = [ + createFace({ + id: 'face-inside-visible', + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + isVisible: true, + }), + createFace({ + id: 'face-inside-not-visible', + boundingBoxX1: 250, + boundingBoxY1: 250, + boundingBoxX2: 350, + boundingBoxY2: 350, + isVisible: false, + }), + createFace({ + id: 'face-outside-visible', + boundingBoxX1: 800, + boundingBoxY1: 800, + boundingBoxX2: 900, + boundingBoxY2: 900, + isVisible: true, + }), + createFace({ + id: 'face-outside-not-visible', + boundingBoxX1: 700, + boundingBoxY1: 700, + boundingBoxX2: 800, + boundingBoxY2: 800, + isVisible: false, + }), + ]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkFaceVisibility(faces, assetDimensions, crop); + + // When crop is provided, only overlap matters, not isVisible property + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(2); + expect(result.visible.map((f) => f.id)).toContain('face-inside-visible'); + expect(result.visible.map((f) => f.id)).toContain('face-inside-not-visible'); + expect(result.hidden.map((f) => f.id)).toContain('face-outside-visible'); + expect(result.hidden.map((f) => f.id)).toContain('face-outside-not-visible'); + }); + + it('should handle mixed visibility states with partial overlap and crop', () => { + const faces = [ + createFace({ + id: 'face-partial-50', + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + isVisible: true, + }), + createFace({ + id: 'face-partial-40', + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + isVisible: false, + }), + ]; + const crop1 = { x1: 150, y1: 100, x2: 500, y2: 500 }; // 50% overlap + const crop2 = { x1: 160, y1: 100, x2: 500, y2: 500 }; // 40% overlap + + const result1 = checkFaceVisibility([faces[0]], assetDimensions, crop1); + const result2 = checkFaceVisibility([faces[1]], assetDimensions, crop2); + + // 50% overlap should be visible + expect(result1.visible).toHaveLength(1); + expect(result1.hidden).toHaveLength(0); + + // 40% overlap should be hidden + expect(result2.visible).toHaveLength(0); + expect(result2.hidden).toHaveLength(1); + }); +}); + +const createOcr = ( + params: Partial = {}, +): AssetOcrResponseDto & { isVisible: boolean } => ({ + id: 'ocr-id', + assetId: 'asset-id', + x1: 0.1, + y1: 0.1, + x2: 0.2, + y2: 0.1, + x3: 0.2, + y3: 0.2, + x4: 0.1, + y4: 0.2, + boxScore: 0.9, + textScore: 0.9, + text: 'Sample Text', + isVisible: true, + ...params, +}); + +describe('checkOcrVisibility', () => { + const assetDimensions = { width: 1000, height: 1000 }; + + it('should return only non-visible OCR entries when no crop is provided', () => { + const ocrs = [ + createOcr({ id: 'ocr-1', isVisible: true }), + createOcr({ id: 'ocr-2', isVisible: false }), + createOcr({ id: 'ocr-3', isVisible: false }), + ]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(0); + expect(result.visible.map((o) => o.id)).toEqual(['ocr-2', 'ocr-3']); + }); + + it('should return all OCR entries as visible when all are marked not visible and no crop provided', () => { + const ocrs = [createOcr({ id: 'ocr-1', isVisible: false }), createOcr({ id: 'ocr-2', isVisible: false })]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(0); + }); + + it('should return empty visible array when all OCR entries are already visible and no crop provided', () => { + const ocrs = [createOcr({ id: 'ocr-1', isVisible: true }), createOcr({ id: 'ocr-2', isVisible: true })]; + const result = checkOcrVisibility(ocrs, assetDimensions); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(0); + }); + + it('should return empty arrays when no OCR entries provided', () => { + const result = checkOcrVisibility([], assetDimensions); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark OCR as visible when fully inside crop area', () => { + // OCR box at normalized coords 0.1-0.2 = 100-200px in 1000x1000 image + const ocrs = [createOcr()]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark OCR as hidden when fully outside crop area', () => { + // OCR box at normalized coords 0.8-0.9 = 800-900px + const ocrs = [createOcr({ x1: 0.8, y1: 0.8, x2: 0.9, y2: 0.8, x3: 0.9, y3: 0.9, x4: 0.8, y4: 0.9 })]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(1); + }); + + it('should mark OCR as visible when at least 50% overlaps with crop', () => { + // OCR at 100-200px (0.1-0.2 normalized), crop starts at 150 + const ocrs = [createOcr()]; + const crop = { x1: 150, y1: 100, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should mark OCR as hidden when less than 50% overlaps with crop', () => { + // OCR at 100-200px, crop starts at 160 = 40% overlap + const ocrs = [createOcr()]; + const crop = { x1: 160, y1: 100, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(0); + expect(result.hidden).toHaveLength(1); + }); + + it('should correctly categorize multiple OCR entries', () => { + const ocrs = [ + createOcr({ id: 'ocr-inside', x1: 0.1, y1: 0.1, x2: 0.2, y2: 0.1, x3: 0.2, y3: 0.2, x4: 0.1, y4: 0.2 }), + createOcr({ id: 'ocr-outside', x1: 0.8, y1: 0.8, x2: 0.9, y2: 0.8, x3: 0.9, y3: 0.9, x4: 0.8, y4: 0.9 }), + ]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(1); + expect(result.visible[0].id).toBe('ocr-inside'); + expect(result.hidden[0].id).toBe('ocr-outside'); + }); + + it('should handle rotated/skewed OCR polygons by using bounding box', () => { + // Rotated rectangle - the function should compute the bounding box correctly + const ocrs = [ + createOcr({ + id: 'ocr-rotated', + x1: 0.15, + y1: 0.1, // top + x2: 0.2, + y2: 0.15, // right + x3: 0.15, + y3: 0.2, // bottom + x4: 0.1, + y4: 0.15, // left + }), + ]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should handle different asset dimensions', () => { + const smallDimensions = { width: 500, height: 500 }; + // OCR at 0.1-0.2 normalized = 50-100px in 500x500 image + const ocrs = [createOcr()]; + const crop = { x1: 0, y1: 0, x2: 200, y2: 200 }; + + const result = checkOcrVisibility(ocrs, smallDimensions, crop); + + expect(result.visible).toHaveLength(1); + expect(result.hidden).toHaveLength(0); + }); + + it('should categorize based on crop overlap when crop is provided, regardless of isVisible property', () => { + const ocrs = [ + createOcr({ id: 'ocr-inside-visible', isVisible: true }), // Inside crop, already visible + createOcr({ id: 'ocr-inside-not-visible', isVisible: false }), // Inside crop, not visible + createOcr({ + id: 'ocr-outside-visible', + x1: 0.8, + y1: 0.8, + x2: 0.9, + y2: 0.8, + x3: 0.9, + y3: 0.9, + x4: 0.8, + y4: 0.9, + isVisible: true, + }), // Outside crop, already visible + createOcr({ + id: 'ocr-outside-not-visible', + x1: 0.8, + y1: 0.8, + x2: 0.9, + y2: 0.8, + x3: 0.9, + y3: 0.9, + x4: 0.8, + y4: 0.9, + isVisible: false, + }), // Outside crop, not visible + ]; + const crop = { x1: 0, y1: 0, x2: 500, y2: 500 }; + + const result = checkOcrVisibility(ocrs, assetDimensions, crop); + + // When crop is provided, only overlap matters, not isVisible property + expect(result.visible).toHaveLength(2); + expect(result.hidden).toHaveLength(2); + expect(result.visible.map((o) => o.id)).toContain('ocr-inside-visible'); + expect(result.visible.map((o) => o.id)).toContain('ocr-inside-not-visible'); + expect(result.hidden.map((o) => o.id)).toContain('ocr-outside-visible'); + expect(result.hidden.map((o) => o.id)).toContain('ocr-outside-not-visible'); + }); + + it('should handle mixed visibility states with partial overlap and crop', () => { + const ocrs = [ + createOcr({ id: 'ocr-partial-50', isVisible: true }), // 50% overlap + createOcr({ id: 'ocr-partial-40', isVisible: false }), // 40% overlap + ]; + const crop1 = { x1: 150, y1: 100, x2: 500, y2: 500 }; // 50% overlap + const crop2 = { x1: 160, y1: 100, x2: 500, y2: 500 }; // 40% overlap + + const result1 = checkOcrVisibility([ocrs[0]], assetDimensions, crop1); + const result2 = checkOcrVisibility([ocrs[1]], assetDimensions, crop2); + + // 50% overlap should be visible + expect(result1.visible).toHaveLength(1); + expect(result1.hidden).toHaveLength(0); + + // 40% overlap should be hidden + expect(result2.visible).toHaveLength(0); + expect(result2.hidden).toHaveLength(1); + }); +}); diff --git a/server/src/utils/editor.ts b/server/src/utils/editor.ts new file mode 100644 index 000000000..21678f2a8 --- /dev/null +++ b/server/src/utils/editor.ts @@ -0,0 +1,107 @@ +import { AssetFace } from 'src/database'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; +import { ImageDimensions } from 'src/types'; + +type BoundingBox = { + x1: number; + y1: number; + x2: number; + y2: number; +}; + +export const boundingBoxOverlap = (boxA: BoundingBox, boxB: BoundingBox) => { + const overlapX1 = Math.max(boxA.x1, boxB.x1); + const overlapY1 = Math.max(boxA.y1, boxB.y1); + const overlapX2 = Math.min(boxA.x2, boxB.x2); + const overlapY2 = Math.min(boxA.y2, boxB.y2); + + const overlapArea = Math.max(0, overlapX2 - overlapX1) * Math.max(0, overlapY2 - overlapY1); + const faceArea = (boxA.x2 - boxA.x1) * (boxA.y2 - boxA.y1); + return overlapArea / faceArea; +}; + +const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensions) => { + const { width: sourceWidth = 1, height: sourceHeight = 1 } = source ?? {}; + + return { + x1: (box.x1 / sourceWidth) * target.width, + y1: (box.y1 / sourceHeight) * target.height, + x2: (box.x2 / sourceWidth) * target.width, + y2: (box.y2 / sourceHeight) * target.height, + }; +}; + +export const checkFaceVisibility = ( + faces: AssetFace[], + originalAssetDimensions: ImageDimensions, + crop?: BoundingBox, +): { visible: AssetFace[]; hidden: AssetFace[] } => { + if (!crop) { + return { + visible: faces.filter((face) => !face.isVisible), + hidden: [], + }; + } + + const status = faces.map((face) => { + const scaledFace = scale( + { + x1: face.boundingBoxX1, + y1: face.boundingBoxY1, + x2: face.boundingBoxX2, + y2: face.boundingBoxY2, + }, + originalAssetDimensions, + { width: face.imageWidth, height: face.imageHeight }, + ); + + const overlapPercentage = boundingBoxOverlap(scaledFace, crop); + + return { + face, + isVisible: overlapPercentage >= 0.5, + }; + }); + + return { + visible: status.filter((s) => s.isVisible).map((s) => s.face), + hidden: status.filter((s) => !s.isVisible).map((s) => s.face), + }; +}; + +export const checkOcrVisibility = ( + ocrs: (AssetOcrResponseDto & { isVisible: boolean })[], + originalAssetDimensions: ImageDimensions, + crop?: BoundingBox, +): { visible: AssetOcrResponseDto[]; hidden: AssetOcrResponseDto[] } => { + if (!crop) { + return { + visible: ocrs.filter((ocr) => !ocr.isVisible), + hidden: [], + }; + } + + const status = ocrs.map((ocr) => { + const ocrBox = scale( + { + x1: Math.min(ocr.x1, ocr.x2, ocr.x3, ocr.x4), + y1: Math.min(ocr.y1, ocr.y2, ocr.y3, ocr.y4), + x2: Math.max(ocr.x1, ocr.x2, ocr.x3, ocr.x4), + y2: Math.max(ocr.y1, ocr.y2, ocr.y3, ocr.y4), + }, + originalAssetDimensions, + ); + + const overlapPercentage = boundingBoxOverlap(ocrBox, crop); + + return { + ocr, + isVisible: overlapPercentage >= 0.5, + }; + }); + + return { + visible: status.filter((s) => s.isVisible).map((s) => s.ocr), + hidden: status.filter((s) => !s.isVisible).map((s) => s.ocr), + }; +}; diff --git a/server/src/utils/transform.spec.ts b/server/src/utils/transform.spec.ts new file mode 100644 index 000000000..5efeac02a --- /dev/null +++ b/server/src/utils/transform.spec.ts @@ -0,0 +1,293 @@ +import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; +import { transformFaceBoundingBox, transformOcrBoundingBox } from 'src/utils/transform'; +import { describe, expect, it } from 'vitest'; + +describe('transformFaceBoundingBox', () => { + const baseFace = { + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageWidth: 1000, + imageHeight: 800, + }; + + const baseDimensions = { width: 1000, height: 800 }; + + describe('with no edits', () => { + it('should return unchanged bounding box', () => { + const result = transformFaceBoundingBox(baseFace, [], baseDimensions); + expect(result).toEqual(baseFace); + }); + }); + + describe('with crop edit', () => { + it('should adjust bounding box for crop offset', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 400, height: 300 } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.boundingBoxX1).toBe(50); + expect(result.boundingBoxY1).toBe(50); + expect(result.boundingBoxX2).toBe(150); + expect(result.boundingBoxY2).toBe(150); + expect(result.imageWidth).toBe(400); + expect(result.imageHeight).toBe(300); + }); + + it('should handle face partially outside crop area', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 150, y: 150, width: 400, height: 300 } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.boundingBoxX1).toBe(-50); + expect(result.boundingBoxY1).toBe(-50); + expect(result.boundingBoxX2).toBe(50); + expect(result.boundingBoxY2).toBe(50); + }); + }); + + describe('with rotate edit', () => { + it('should rotate 90 degrees clockwise', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.imageWidth).toBe(800); + expect(result.imageHeight).toBe(1000); + + expect(result.boundingBoxX1).toBe(600); + expect(result.boundingBoxY1).toBe(100); + expect(result.boundingBoxX2).toBe(700); + expect(result.boundingBoxY2).toBe(200); + }); + + it('should rotate 180 degrees', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 180 } }]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.imageWidth).toBe(1000); + expect(result.imageHeight).toBe(800); + + expect(result.boundingBoxX1).toBe(800); + expect(result.boundingBoxY1).toBe(600); + expect(result.boundingBoxX2).toBe(900); + expect(result.boundingBoxY2).toBe(700); + }); + + it('should rotate 270 degrees', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 270 } }]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.imageWidth).toBe(800); + expect(result.imageHeight).toBe(1000); + }); + }); + + describe('with mirror edit', () => { + it('should mirror horizontally', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.boundingBoxX1).toBe(800); + expect(result.boundingBoxY1).toBe(100); + expect(result.boundingBoxX2).toBe(900); + expect(result.boundingBoxY2).toBe(200); + expect(result.imageWidth).toBe(1000); + expect(result.imageHeight).toBe(800); + }); + + it('should mirror vertically', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.boundingBoxX1).toBe(100); + expect(result.boundingBoxY1).toBe(600); + expect(result.boundingBoxX2).toBe(200); + expect(result.boundingBoxY2).toBe(700); + expect(result.imageWidth).toBe(1000); + expect(result.imageHeight).toBe(800); + }); + }); + + describe('with combined edits', () => { + it('should apply crop then rotate', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 400, height: 300 } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.imageWidth).toBe(300); + expect(result.imageHeight).toBe(400); + }); + + it('should apply crop then mirror', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 400 } }, + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, baseDimensions); + + expect(result.boundingBoxX1).toBe(100); + expect(result.boundingBoxX2).toBe(200); + expect(result.boundingBoxY1).toBe(200); + expect(result.boundingBoxY2).toBe(300); + }); + }); + + describe('with scaled dimensions', () => { + it('should scale face to match different image dimensions', () => { + const scaledDimensions = { width: 500, height: 400 }; // Half the original size + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 200, height: 150 } }, + ]; + const result = transformFaceBoundingBox(baseFace, edits, scaledDimensions); + + expect(result.boundingBoxX1).toBe(0); + expect(result.boundingBoxY1).toBe(0); + expect(result.boundingBoxX2).toBe(50); + expect(result.boundingBoxY2).toBe(50); + }); + }); +}); + +describe('transformOcrBoundingBox', () => { + const baseOcr: AssetOcrResponseDto = { + id: 'ocr-1', + assetId: 'asset-1', + x1: 0.1, + y1: 0.1, + x2: 0.2, + y2: 0.1, + x3: 0.2, + y3: 0.2, + x4: 0.1, + y4: 0.2, + boxScore: 0.9, + textScore: 0.85, + text: 'Test OCR', + }; + + const baseDimensions = { width: 1000, height: 800 }; + + describe('with no edits', () => { + it('should return unchanged bounding box', () => { + const result = transformOcrBoundingBox(baseOcr, [], baseDimensions); + expect(result).toEqual(baseOcr); + }); + }); + + describe('with crop edit', () => { + it('should adjust normalized coordinates for crop', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 100, y: 80, width: 400, height: 320 } }, + ]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + // Original OCR: (0.1,0.1)-(0.2,0.2) on 1000x800 = (100,80)-(200,160) + // After crop offset (100,80): (0,0)-(100,80) + // Normalized to 400x320: (0,0)-(0.25,0.25) + expect(result.x1).toBeCloseTo(0, 5); + expect(result.y1).toBeCloseTo(0, 5); + expect(result.x2).toBeCloseTo(0.25, 5); + expect(result.y2).toBeCloseTo(0, 5); + expect(result.x3).toBeCloseTo(0.25, 5); + expect(result.y3).toBeCloseTo(0.25, 5); + expect(result.x4).toBeCloseTo(0, 5); + expect(result.y4).toBeCloseTo(0.25, 5); + }); + }); + + describe('with rotate edit', () => { + it('should rotate normalized coordinates 90 degrees and reorder points', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.id).toBe(baseOcr.id); + expect(result.text).toBe(baseOcr.text); + expect(result.x1).toBeCloseTo(0.8, 5); + expect(result.y1).toBeCloseTo(0.1, 5); + expect(result.x2).toBeCloseTo(0.9, 5); + expect(result.y2).toBeCloseTo(0.1, 5); + expect(result.x3).toBeCloseTo(0.9, 5); + expect(result.y3).toBeCloseTo(0.2, 5); + expect(result.x4).toBeCloseTo(0.8, 5); + expect(result.y4).toBeCloseTo(0.2, 5); + }); + + it('should rotate 180 degrees and reorder points', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 180 } }]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.x1).toBeCloseTo(0.8, 5); + expect(result.y1).toBeCloseTo(0.8, 5); + expect(result.x2).toBeCloseTo(0.9, 5); + expect(result.y2).toBeCloseTo(0.8, 5); + expect(result.x3).toBeCloseTo(0.9, 5); + expect(result.y3).toBeCloseTo(0.9, 5); + expect(result.x4).toBeCloseTo(0.8, 5); + expect(result.y4).toBeCloseTo(0.9, 5); + }); + + it('should rotate 270 degrees and reorder points', () => { + const edits: AssetEditActionItem[] = [{ action: AssetEditAction.Rotate, parameters: { angle: 270 } }]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.id).toBe(baseOcr.id); + expect(result.text).toBe(baseOcr.text); + expect(result.x1).toBeCloseTo(0.1, 5); + expect(result.y1).toBeCloseTo(0.8, 5); + expect(result.x2).toBeCloseTo(0.2, 5); + expect(result.y2).toBeCloseTo(0.8, 5); + expect(result.x3).toBeCloseTo(0.2, 5); + expect(result.y3).toBeCloseTo(0.9, 5); + expect(result.x4).toBeCloseTo(0.1, 5); + expect(result.y4).toBeCloseTo(0.9, 5); + }); + }); + + describe('with mirror edit', () => { + it('should mirror horizontally', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } }, + ]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.x1).toBeCloseTo(0.9, 5); + expect(result.y1).toBeCloseTo(0.1, 5); + }); + + it('should mirror vertically', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }, + ]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.x1).toBeCloseTo(0.1, 5); + expect(result.y1).toBeCloseTo(0.9, 5); + }); + }); + + describe('with combined edits', () => { + it('should preserve OCR metadata through transforms', () => { + const edits: AssetEditActionItem[] = [ + { action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 400 } }, + { action: AssetEditAction.Rotate, parameters: { angle: 90 } }, + ]; + const result = transformOcrBoundingBox(baseOcr, edits, baseDimensions); + + expect(result.id).toBe(baseOcr.id); + expect(result.assetId).toBe(baseOcr.assetId); + expect(result.boxScore).toBe(baseOcr.boxScore); + expect(result.textScore).toBe(baseOcr.textScore); + expect(result.text).toBe(baseOcr.text); + }); + }); +}); diff --git a/server/src/utils/transform.ts b/server/src/utils/transform.ts new file mode 100644 index 000000000..b57a198cc --- /dev/null +++ b/server/src/utils/transform.ts @@ -0,0 +1,227 @@ +import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto'; +import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; +import { ImageDimensions } from 'src/types'; +import { applyToPoint, compose, flipX, flipY, identity, Matrix, rotate, scale, translate } from 'transformation-matrix'; + +export const getOutputDimensions = ( + edits: AssetEditActionItem[], + startingDimensions: ImageDimensions, +): ImageDimensions => { + let { width, height } = startingDimensions; + + const crop = edits.find((edit) => edit.action === AssetEditAction.Crop); + if (crop) { + width = crop.parameters.width; + height = crop.parameters.height; + } + + for (const edit of edits) { + if (edit.action === AssetEditAction.Rotate) { + const angleDegrees = edit.parameters.angle; + if (angleDegrees === 90 || angleDegrees === 270) { + [width, height] = [height, width]; + } + } + } + + return { width, height }; +}; + +export const createAffineMatrix = ( + edits: AssetEditActionItem[], + scalingParameters?: { + pointSpace: ImageDimensions; + targetSpace: ImageDimensions; + }, +): Matrix => { + let scalingMatrix: Matrix = identity(); + + if (scalingParameters) { + const { pointSpace, targetSpace } = scalingParameters; + const scaleX = targetSpace.width / pointSpace.width; + scalingMatrix = scale(scaleX); + } + + return compose( + scalingMatrix, + ...edits.map((edit) => { + switch (edit.action) { + case 'rotate': { + const angleInRadians = (-edit.parameters.angle * Math.PI) / 180; + return rotate(angleInRadians); + } + case 'mirror': { + return edit.parameters.axis === 'horizontal' ? flipY() : flipX(); + } + default: { + return identity(); + } + } + }), + ); +}; + +type Point = { x: number; y: number }; + +type TransformState = { + points: Point[]; + currentWidth: number; + currentHeight: number; +}; + +/** + * Transforms an array of points through a series of edit operations (crop, rotate, mirror). + * Points should be in absolute pixel coordinates relative to the starting dimensions. + */ +const transformPoints = ( + points: Point[], + edits: AssetEditActionItem[], + startingDimensions: ImageDimensions, +): TransformState => { + let currentWidth = startingDimensions.width; + let currentHeight = startingDimensions.height; + let transformedPoints = [...points]; + + // Handle crop first + const crop = edits.find((edit) => edit.action === 'crop'); + if (crop) { + const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters; + transformedPoints = transformedPoints.map((p) => ({ + x: p.x - cropX, + y: p.y - cropY, + })); + currentWidth = cropWidth; + currentHeight = cropHeight; + } + + // Apply rotate and mirror transforms + for (const edit of edits) { + let matrix: Matrix = identity(); + if (edit.action === 'rotate') { + const angleDegrees = edit.parameters.angle; + const angleRadians = (angleDegrees * Math.PI) / 180; + const newWidth = angleDegrees === 90 || angleDegrees === 270 ? currentHeight : currentWidth; + const newHeight = angleDegrees === 90 || angleDegrees === 270 ? currentWidth : currentHeight; + + matrix = compose( + translate(newWidth / 2, newHeight / 2), + rotate(angleRadians), + translate(-currentWidth / 2, -currentHeight / 2), + ); + + currentWidth = newWidth; + currentHeight = newHeight; + } else if (edit.action === 'mirror') { + matrix = compose( + translate(currentWidth / 2, currentHeight / 2), + edit.parameters.axis === 'horizontal' ? flipY() : flipX(), + translate(-currentWidth / 2, -currentHeight / 2), + ); + } else { + // Skip non-affine transformations + continue; + } + + transformedPoints = transformedPoints.map((p) => applyToPoint(matrix, p)); + } + + return { + points: transformedPoints, + currentWidth, + currentHeight, + }; +}; + +type FaceBoundingBox = { + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; + imageWidth: number; + imageHeight: number; +}; + +export const transformFaceBoundingBox = ( + box: FaceBoundingBox, + edits: AssetEditActionItem[], + imageDimensions: ImageDimensions, +): FaceBoundingBox => { + if (edits.length === 0) { + return box; + } + + const scaleX = imageDimensions.width / box.imageWidth; + const scaleY = imageDimensions.height / box.imageHeight; + + const points: Point[] = [ + { x: box.boundingBoxX1 * scaleX, y: box.boundingBoxY1 * scaleY }, + { x: box.boundingBoxX2 * scaleX, y: box.boundingBoxY2 * scaleY }, + ]; + + const { points: transformedPoints, currentWidth, currentHeight } = transformPoints(points, edits, imageDimensions); + + // Ensure x1,y1 is top-left and x2,y2 is bottom-right + const [p1, p2] = transformedPoints; + return { + boundingBoxX1: Math.min(p1.x, p2.x), + boundingBoxY1: Math.min(p1.y, p2.y), + boundingBoxX2: Math.max(p1.x, p2.x), + boundingBoxY2: Math.max(p1.y, p2.y), + imageWidth: currentWidth, + imageHeight: currentHeight, + }; +}; + +const reorderQuadPointsForRotation = (points: Point[], rotationDegrees: number): Point[] => { + const [p1, p2, p3, p4] = points; + switch (rotationDegrees) { + case 90: { + return [p4, p1, p2, p3]; + } + case 180: { + return [p3, p4, p1, p2]; + } + case 270: { + return [p2, p3, p4, p1]; + } + default: { + return points; + } + } +}; + +export const transformOcrBoundingBox = ( + box: AssetOcrResponseDto, + edits: AssetEditActionItem[], + imageDimensions: ImageDimensions, +): AssetOcrResponseDto => { + if (edits.length === 0) { + return box; + } + + const points: Point[] = [ + { x: box.x1 * imageDimensions.width, y: box.y1 * imageDimensions.height }, + { x: box.x2 * imageDimensions.width, y: box.y2 * imageDimensions.height }, + { x: box.x3 * imageDimensions.width, y: box.y3 * imageDimensions.height }, + { x: box.x4 * imageDimensions.width, y: box.y4 * imageDimensions.height }, + ]; + + const { points: transformedPoints, currentWidth, currentHeight } = transformPoints(points, edits, imageDimensions); + + // Reorder points to maintain semantic ordering (topLeft, topRight, bottomRight, bottomLeft) + const netRotation = edits.find((e) => e.action == AssetEditAction.Rotate)?.parameters.angle ?? 0 % 360; + const reorderedPoints = reorderQuadPointsForRotation(transformedPoints, netRotation); + + const [p1, p2, p3, p4] = reorderedPoints; + return { + ...box, + x1: p1.x / currentWidth, + y1: p1.y / currentHeight, + x2: p2.x / currentWidth, + y2: p2.y / currentHeight, + x3: p3.x / currentWidth, + y3: p3.y / currentHeight, + x4: p4.x / currentWidth, + y4: p4.y / currentHeight, + }; +}; diff --git a/server/src/validation.ts b/server/src/validation.ts index 6d4bbfbe3..1ac21020c 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -81,6 +81,49 @@ export const ValidateUUID = (options?: UUIDOptions & ApiPropertyOptions) => { ); }; +export function IsAxisAlignedRotation() { + return ValidateBy( + { + name: 'isAxisAlignedRotation', + validator: { + validate(value: any) { + return [0, 90, 180, 270].includes(value); + }, + defaultMessage: buildMessage( + (eachPrefix) => eachPrefix + '$property must be one of the following values: 0, 90, 180, 270', + {}, + ), + }, + }, + {}, + ); +} + +@ValidatorConstraint({ name: 'uniqueEditActions' }) +class UniqueEditActionsValidator implements ValidatorConstraintInterface { + validate(edits: { action: string; parameters?: unknown }[]): boolean { + if (!Array.isArray(edits)) { + return true; + } + + const actionSet = new Set(); + for (const edit of edits) { + const key = edit.action === 'mirror' ? `${edit.action}-${JSON.stringify(edit.parameters)}` : edit.action; + if (actionSet.has(key)) { + return false; + } + actionSet.add(key); + } + return true; + } + + defaultMessage(): string { + return 'Duplicate edit actions are not allowed'; + } +} + +export const IsUniqueEditActions = () => Validate(UniqueEditActionsValidator); + export class UUIDParamDto { @IsNotEmpty() @IsUUID('4') diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 6e4193c11..3478e31fe 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -1,43 +1,61 @@ import { AssetFace, AssetFile, Exif } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; +import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { StorageAsset } from 'src/types'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { userStub } from 'test/fixtures/user.stub'; +import { factory } from 'test/small.factory'; -export const previewFile: AssetFile = { - id: 'file-1', - type: AssetFileType.Preview, - path: '/uploads/user-id/thumbs/path.jpg', -}; +export const previewFile = factory.assetFile({ type: AssetFileType.Preview }); -const thumbnailFile: AssetFile = { - id: 'file-2', +const thumbnailFile = factory.assetFile({ type: AssetFileType.Thumbnail, path: '/uploads/user-id/webp/path.ext', -}; +}); -const fullsizeFile: AssetFile = { - id: 'file-3', +const fullsizeFile = factory.assetFile({ type: AssetFileType.FullSize, path: '/uploads/user-id/fullsize/path.webp', -}; +}); -const sidecarFileWithExt: AssetFile = { - id: 'sidecar-with-ext', +const sidecarFileWithExt = factory.assetFile({ type: AssetFileType.Sidecar, path: '/original/path.ext.xmp', -}; +}); -const sidecarFileWithoutExt: AssetFile = { - id: 'sidecar-without-ext', +const sidecarFileWithoutExt = factory.assetFile({ type: AssetFileType.Sidecar, path: '/original/path.xmp', -}; +}); + +const editedPreviewFile = factory.assetFile({ + type: AssetFileType.PreviewEdited, + path: '/uploads/user-id/preview/path_edited.jpg', +}); + +const editedThumbnailFile = factory.assetFile({ + type: AssetFileType.ThumbnailEdited, + path: '/uploads/user-id/thumbnail/path_edited.jpg', +}); + +const editedFullsizeFile = factory.assetFile({ + type: AssetFileType.FullSizeEdited, + path: '/uploads/user-id/fullsize/path_edited.jpg', +}); const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile]; +const editedFiles: AssetFile[] = [ + fullsizeFile, + previewFile, + thumbnailFile, + editedFullsizeFile, + editedPreviewFile, + editedThumbnailFile, +]; + export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => { return { id: stackId, @@ -104,6 +122,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), noWebpPath: Object.freeze({ @@ -142,6 +163,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), noThumbhash: Object.freeze({ @@ -177,6 +201,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), primaryImage: Object.freeze({ @@ -222,6 +249,9 @@ export const assetStub = { updateId: '42', libraryId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), image: Object.freeze({ @@ -264,9 +294,10 @@ export const assetStub = { stack: null, orientation: '', projectionType: null, - height: 3840, - width: 2160, + height: null, + width: null, visibility: AssetVisibility.Timeline, + edits: [], }), trashed: Object.freeze({ @@ -307,6 +338,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), trashedOffline: Object.freeze({ @@ -347,6 +381,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), archived: Object.freeze({ id: 'asset-id', @@ -386,6 +423,9 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), external: Object.freeze({ @@ -425,6 +465,9 @@ export const assetStub = { stackId: null, stack: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), image1: Object.freeze({ @@ -464,6 +507,9 @@ export const assetStub = { libraryId: null, stack: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), imageFrom2015: Object.freeze({ @@ -502,6 +548,9 @@ export const assetStub = { duplicateId: null, isOffline: false, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), video: Object.freeze({ @@ -542,6 +591,9 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), livePhotoMotionAsset: Object.freeze({ @@ -559,7 +611,10 @@ export const assetStub = { files: [] as AssetFile[], libraryId: null, visibility: AssetVisibility.Hidden, - } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }), + width: null, + height: null, + edits: [] as AssetEditActionItem[], + } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }), livePhotoStillAsset: Object.freeze({ id: 'live-photo-still-asset', @@ -577,7 +632,10 @@ export const assetStub = { files, faces: [] as AssetFace[], visibility: AssetVisibility.Timeline, - } as MapAsset & { faces: AssetFace[]; files: AssetFile[] }), + width: null, + height: null, + edits: [] as AssetEditActionItem[], + } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', @@ -597,7 +655,10 @@ export const assetStub = { libraryId: null, faces: [] as AssetFace[], visibility: AssetVisibility.Timeline, - } as MapAsset & { faces: AssetFace[]; files: AssetFile[] }), + width: null, + height: null, + edits: [] as AssetEditActionItem[], + } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }), withLocation: Object.freeze({ id: 'asset-with-favorite-id', @@ -641,6 +702,9 @@ export const assetStub = { isOffline: false, tags: [], visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), sidecar: Object.freeze({ @@ -676,6 +740,9 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), sidecarWithoutExt: Object.freeze({ @@ -708,6 +775,9 @@ export const assetStub = { duplicateId: null, isOffline: false, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), hasEncodedVideo: Object.freeze({ @@ -747,6 +817,9 @@ export const assetStub = { stackId: null, stack: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), hasFileExtension: Object.freeze({ @@ -783,6 +856,9 @@ export const assetStub = { duplicateId: null, isOffline: false, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), imageDng: Object.freeze({ @@ -823,6 +899,9 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), imageHif: Object.freeze({ @@ -863,6 +942,9 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], }), panoramaTif: Object.freeze({ id: 'asset-id', @@ -902,5 +984,110 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, + edits: [], + }), + withCropEdit: Object.freeze({ + id: 'asset-id', + status: AssetStatus.Active, + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + files, + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.Image, + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2025-01-01T01:02:03.456Z'), + isFavorite: true, + duration: null, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + updateId: 'foo', + libraryId: null, + stackId: null, + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, + } as Exif, + duplicateId: null, + isOffline: false, + stack: null, + orientation: '', + projectionType: null, + height: 3840, + width: 2160, + visibility: AssetVisibility.Timeline, + edits: [ + { + action: AssetEditAction.Crop, + parameters: { + width: 1512, + height: 1152, + x: 216, + y: 1512, + }, + }, + ] as AssetEditActionItem[], + }), + withoutEdits: Object.freeze({ + id: 'asset-id', + status: AssetStatus.Active, + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.jpg', + files: editedFiles, + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.Image, + thumbhash: Buffer.from('blablabla', 'base64'), + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2025-01-01T01:02:03.456Z'), + isFavorite: true, + duration: null, + isExternal: false, + livePhotoVideo: null, + livePhotoVideoId: null, + updateId: 'foo', + libraryId: null, + stackId: null, + sharedLinks: [], + originalFileName: 'asset-id.jpg', + faces: [], + deletedAt: null, + sidecarPath: null, + exifInfo: { + fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, + } as Exif, + duplicateId: null, + isOffline: false, + stack: null, + orientation: '', + projectionType: null, + height: 3840, + width: 2160, + visibility: AssetVisibility.Timeline, + edits: [], }), }; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index f655a3944..94a2dcff2 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -25,6 +25,7 @@ export const faceStub = { deletedAt: new Date(), updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), primaryFace1: Object.freeze({ id: 'assetFaceId2', @@ -43,6 +44,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), mergeFace1: Object.freeze({ id: 'assetFaceId3', @@ -61,6 +63,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), noPerson1: Object.freeze({ id: 'assetFaceId8', @@ -79,6 +82,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), noPerson2: Object.freeze({ id: 'assetFaceId9', @@ -97,6 +101,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), fromExif1: Object.freeze({ id: 'assetFaceId9', @@ -114,6 +119,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), fromExif2: Object.freeze({ id: 'assetFaceId9', @@ -131,6 +137,7 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), withBirthDate: Object.freeze({ id: 'assetFaceId10', @@ -148,5 +155,6 @@ export const faceStub = { deletedAt: null, updatedAt: new Date('2023-01-01T00:00:00Z'), updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', + isVisible: true, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 802b46a98..6aa76dd4d 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -142,6 +142,11 @@ export const sharedLinkStub = { rating: 3, updatedAt: today, updateId: '42', + libraryId: null, + stackId: null, + visibility: AssetVisibility.Timeline, + width: 500, + height: 500, }, sharedLinks: [], faces: [], @@ -152,6 +157,8 @@ export const sharedLinkStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: 500, + height: 500, }, ], albumId: null, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 44ca231d8..17b0e232b 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -581,6 +581,7 @@ const assetFaceInsert = (assetFace: Partial & { assetId: string }) => imageWidth: assetFace.imageWidth ?? 10, personId: assetFace.personId ?? null, sourceType: assetFace.sourceType ?? SourceType.MachineLearning, + isVisible: assetFace.isVisible ?? true, }; return { diff --git a/server/test/medium/specs/services/ocr.service.spec.ts b/server/test/medium/specs/services/ocr.service.spec.ts index 45c34dd09..d9d3a9f9b 100644 --- a/server/test/medium/specs/services/ocr.service.spec.ts +++ b/server/test/medium/specs/services/ocr.service.spec.ts @@ -57,6 +57,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'Test OCR', textScore: 0.95, + isVisible: true, x1: 10, y1: 10, x2: 50, @@ -106,6 +107,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'One', textScore: 0.9, + isVisible: true, x1: 0, y1: 1, x2: 2, @@ -121,6 +123,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'Two', textScore: 0.89, + isVisible: true, x1: 8, y1: 9, x2: 10, @@ -136,6 +139,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'Three', textScore: 0.88, + isVisible: true, x1: 16, y1: 17, x2: 18, @@ -151,6 +155,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'Four', textScore: 0.87, + isVisible: true, x1: 24, y1: 25, x2: 26, @@ -166,6 +171,7 @@ describe(OcrService.name, () => { id: expect.any(String), text: 'Five', textScore: 0.86, + isVisible: true, x1: 32, y1: 33, x2: 34, diff --git a/server/test/medium/specs/sync/sync-album-asset.spec.ts b/server/test/medium/specs/sync/sync-album-asset.spec.ts index 4f053937b..6c094c112 100644 --- a/server/test/medium/specs/sync/sync-album-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset.spec.ts @@ -52,6 +52,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => { livePhotoVideoId: null, stackId: null, libraryId: null, + width: 1920, + height: 1080, }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); @@ -79,6 +81,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => { livePhotoVideoId: asset.livePhotoVideoId, stackId: asset.stackId, libraryId: asset.libraryId, + width: asset.width, + height: asset.height, }, type: SyncEntityType.AlbumAssetCreateV1, }, diff --git a/server/test/medium/specs/sync/sync-asset.spec.ts b/server/test/medium/specs/sync/sync-asset.spec.ts index 066cb2de4..acba274b4 100644 --- a/server/test/medium/specs/sync/sync-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-asset.spec.ts @@ -37,6 +37,8 @@ describe(SyncEntityType.AssetV1, () => { deletedAt: null, duration: '0:10:00.00000', libraryId: null, + width: 1920, + height: 1080, }); const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); @@ -60,6 +62,8 @@ describe(SyncEntityType.AssetV1, () => { stackId: null, livePhotoVideoId: null, libraryId: asset.libraryId, + width: asset.width, + height: asset.height, }, type: 'AssetV1', }, diff --git a/server/test/medium/specs/sync/sync-partner-asset.spec.ts b/server/test/medium/specs/sync/sync-partner-asset.spec.ts index c30cfcf6b..421423a74 100644 --- a/server/test/medium/specs/sync/sync-partner-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-partner-asset.spec.ts @@ -66,6 +66,8 @@ describe(SyncRequestType.PartnerAssetsV1, () => { stackId: null, livePhotoVideoId: null, libraryId: asset.libraryId, + width: null, + height: null, }, type: SyncEntityType.PartnerAssetV1, }, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 53d7c7079..65ee7be07 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,6 +1,7 @@ import { Activity, ApiKey, + AssetFile, AuthApiKey, AuthSharedLink, AuthUser, @@ -250,6 +251,8 @@ const assetFactory = (asset: Partial = {}) => ({ thumbhash: null, type: AssetType.Image, visibility: AssetVisibility.Timeline, + width: null, + height: null, ...asset, }); @@ -358,6 +361,7 @@ const assetOcrFactory = ( boxScore?: number; textScore?: number; text?: string; + isVisible?: boolean; } = {}, ) => ({ id: newUuid(), @@ -373,13 +377,22 @@ const assetOcrFactory = ( boxScore: 0.95, textScore: 0.92, text: 'Sample Text', + isVisible: true, ...ocr, }); +const assetFileFactory = (file: Partial = {}): AssetFile => ({ + id: newUuid(), + type: AssetFileType.Preview, + path: '/uploads/user-id/thumbs/path.jpg', + ...file, +}); + export const factory = { activity: activityFactory, apiKey: apiKeyFactory, asset: assetFactory, + assetFile: assetFileFactory, assetOcr: assetOcrFactory, auth: authFactory, authApiKey: authApiKeyFactory, diff --git a/server/test/utils.ts b/server/test/utils.ts index 77853f897..6e159f1c5 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -20,6 +20,7 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; @@ -216,6 +217,7 @@ export type ServiceOverrides = { app: AppRepository; audit: AuditRepository; asset: AssetRepository; + assetEdit: AssetEditRepository; assetJob: AssetJobRepository; config: ConfigRepository; cron: CronRepository; @@ -289,6 +291,7 @@ export const getMocks = () => { album: automock(AlbumRepository, { strict: false }), albumUser: automock(AlbumUserRepository), asset: newAssetRepositoryMock(), + assetEdit: automock(AssetEditRepository), assetJob: automock(AssetJobRepository), app: automock(AppRepository, { strict: false }), config: newConfigRepositoryMock(), @@ -356,6 +359,7 @@ export const newTestService = ( overrides.apiKey || (mocks.apiKey as As), overrides.app || (mocks.app as As), overrides.asset || (mocks.asset as As), + overrides.assetEdit || (mocks.assetEdit as As), overrides.assetJob || (mocks.assetJob as As), overrides.audit || (mocks.audit as As), overrides.config || (mocks.config as As as ConfigRepository), diff --git a/web/src/lib/components/asset-viewer/actions/edit-action.svelte b/web/src/lib/components/asset-viewer/actions/edit-action.svelte new file mode 100644 index 000000000..2a630f169 --- /dev/null +++ b/web/src/lib/components/asset-viewer/actions/edit-action.svelte @@ -0,0 +1,20 @@ + + + onAction()} +/> 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 60bde6e11..faa81a6e9 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 @@ -72,7 +72,7 @@ onUndoDelete?: OnUndoDelete; onRunJob: (name: AssetJobName) => void; onPlaySlideshow: () => void; - // export let showEditorHandler: () => void; + // onEdit: () => void; onClose?: () => void; playOriginalVideo: boolean; setPlayOriginalVideo: (value: boolean) => void; @@ -92,6 +92,7 @@ onRunJob, onPlaySlideshow, onClose, + // onEdit, playOriginalVideo = false, setPlayOriginalVideo, }: Props = $props(); @@ -114,15 +115,18 @@ const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } = $derived(getAssetActions($t, asset)); - // $: showEditorButton = + // TODO: Enable when edits are ready for release + // let showEditorButton = $derived( // 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; + // asset.type === AssetTypeEnum.Image && + // !( + // asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + // (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + // ) && + // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && + // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) && + // !asset.livePhotoVideoId, + // ); {/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 9344867a7..118d1a52f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -10,8 +10,8 @@ import { activityManager } from '$lib/managers/activity-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; + import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte'; import { preloadManager } from '$lib/managers/PreloadManager.svelte'; - import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { ocrManager } from '$lib/stores/ocr.svelte'; import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store'; @@ -44,8 +44,8 @@ import ActivityStatus from './activity-status.svelte'; import ActivityViewer from './activity-viewer.svelte'; import DetailPanel from './detail-panel.svelte'; - import CropArea from './editor/crop-tool/crop-area.svelte'; import EditorPanel from './editor/editor-panel.svelte'; + import CropArea from './editor/transform-tool/crop-area.svelte'; import ImagePanoramaViewer from './image-panorama-viewer.svelte'; import OcrButton from './ocr-button.svelte'; import PhotoViewer from './photo-viewer.svelte'; @@ -67,6 +67,7 @@ isShared?: boolean; album?: AlbumResponseDto; person?: PersonResponseDto; + onAssetChange?: (asset: AssetResponseDto) => void; preAction?: PreAction; onAction?: OnAction; onUndoDelete?: OnUndoDelete; @@ -84,6 +85,7 @@ isShared = false, album, person, + onAssetChange, preAction, onAction, onUndoDelete, @@ -112,7 +114,6 @@ let isShowEditor = $state(false); let fullscreenElement = $state(); let unsubscribes: (() => void)[] = []; - let selectedEditType: string = $state(''); let stack: StackResponseDto | null = $state(null); let zoomToggle = $state(() => void 0); @@ -200,10 +201,15 @@ onClose?.(asset); }; - const closeEditor = () => { - closeEditorCofirm(() => { - isShowEditor = false; - }); + const closeEditor = async () => { + if (editManager.hasAppliedEdits) { + console.log(asset); + const refreshedAsset = await getAssetInfo({ id: asset.id }); + console.log(refreshedAsset); + onAssetChange?.(refreshedAsset); + assetViewingStore.setAsset(refreshedAsset); + } + isShowEditor = false; }; const tracker = new InvocationTracker(); @@ -249,6 +255,13 @@ }); }; + // const showEditor = () => { + // if (assetViewerManager.isShowActivityPanel) { + // assetViewerManager.isShowActivityPanel = false; + // } + // isShowEditor = !isShowEditor; + // }; + const handleRunJob = async (name: AssetJobName) => { try { await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } }); @@ -346,10 +359,6 @@ onAction?.(action); }; - const handleUpdateSelectedEditType = (type: string) => { - selectedEditType = type; - }; - let isFullScreen = $derived(fullscreenElement !== null); $effect(() => { @@ -498,7 +507,7 @@ .toLowerCase() .endsWith('.insp'))} - {:else if isShowEditor && selectedEditType === 'crop'} + {:else if isShowEditor && editManager.selectedTool?.type === EditToolType.Transform} {:else} - + {/if} 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 deleted file mode 100644 index a0390d2d4..000000000 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { CropAspectRatio, CropSettings } from '$lib/stores/asset-editor.store'; -import { get } from 'svelte/store'; -import { cropAreaEl } from './crop-store'; -import { checkEdits } from './mouse-handlers'; - -export function recalculateCrop( - crop: CropSettings, - canvas: HTMLElement, - aspectRatio: CropAspectRatio, - returnNewCrop = false, -): CropSettings | null { - const canvasW = canvas.clientWidth; - const canvasH = canvas.clientHeight; - - let newWidth = crop.width; - let newHeight = crop.height; - - const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, aspectRatio); - - if (w > canvasW) { - newWidth = canvasW; - newHeight = canvasW / (w / h); - } else if (h > canvasH) { - newHeight = canvasH; - newWidth = canvasH * (w / h); - } else { - newWidth = w; - newHeight = h; - } - - const newX = Math.max(0, Math.min(crop.x, canvasW - newWidth)); - const newY = Math.max(0, Math.min(crop.y, canvasH - newHeight)); - - const newCrop = { - width: newWidth, - height: newHeight, - x: newX, - y: newY, - }; - - if (returnNewCrop) { - setTimeout(() => { - checkEdits(); - }, 1); - return newCrop; - } else { - crop.width = newWidth; - crop.height = newHeight; - crop.x = newX; - crop.y = newY; - return null; - } -} - -export function animateCropChange(crop: CropSettings, newCrop: CropSettings, draw: () => void, duration = 100) { - const cropArea = get(cropAreaEl); - if (!cropArea) { - return; - } - - const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement; - if (!cropFrame) { - return; - } - - const startTime = performance.now(); - const initialCrop = { ...crop }; - - const animate = (currentTime: number) => { - const elapsedTime = currentTime - startTime; - const progress = Math.min(elapsedTime / duration, 1); - - crop.x = initialCrop.x + (newCrop.x - initialCrop.x) * progress; - crop.y = initialCrop.y + (newCrop.y - initialCrop.y) * progress; - crop.width = initialCrop.width + (newCrop.width - initialCrop.width) * progress; - crop.height = initialCrop.height + (newCrop.height - initialCrop.height) * progress; - - draw(); - - if (progress < 1) { - requestAnimationFrame(animate); - } - }; - - requestAnimationFrame(animate); -} - -export function keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: CropAspectRatio) { - const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); - - if (widthRatio && heightRatio) { - const calculatedWidth = (newHeight * widthRatio) / heightRatio; - return { newWidth: calculatedWidth, newHeight }; - } - - return { newWidth, newHeight }; -} - -export function adjustDimensions( - newWidth: number, - newHeight: number, - aspectRatio: CropAspectRatio, - xLimit: number, - yLimit: number, - minSize: number, -) { - let w = newWidth; - let h = newHeight; - - let aspectMultiplier: number; - - if (aspectRatio === 'free') { - aspectMultiplier = newWidth / newHeight; - } else { - const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); - aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight; - } - - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } - - if (w > xLimit) { - w = xLimit; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } - } - if (h > yLimit) { - h = yLimit; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } - } - - if (w < minSize) { - w = minSize; - if (aspectRatio !== 'free') { - h = w / aspectMultiplier; - } - } - if (h < minSize) { - h = minSize; - if (aspectRatio !== 'free') { - w = h * aspectMultiplier; - } - } - - if (aspectRatio !== 'free' && w / h !== aspectMultiplier) { - if (w < minSize) { - h = w / aspectMultiplier; - } - if (h < minSize) { - w = h * aspectMultiplier; - } - } - - return { newWidth: w, newHeight: h }; -} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts deleted file mode 100644 index 8e27d41f2..000000000 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-store.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { writable } from 'svelte/store'; - -export const darkenLevel = writable(0.65); -export const isResizingOrDragging = writable(false); -export const animationFrame = writable | null>(null); -export const canvasCursor = writable('default'); -export const dragOffset = writable({ x: 0, y: 0 }); -export const resizeSide = writable(''); -export const imgElement = writable(null); -export const cropAreaEl = writable(null); -export const isDragging = writable(false); - -export const overlayEl = writable(null); -export const cropFrame = writable(null); - -export function resetCropStore() { - darkenLevel.set(0.65); - isResizingOrDragging.set(false); - animationFrame.set(null); - canvasCursor.set('default'); - dragOffset.set({ x: 0, y: 0 }); - resizeSide.set(''); - imgElement.set(null); - cropAreaEl.set(null); - isDragging.set(false); - overlayEl.set(null); -} 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 deleted file mode 100644 index 8ad4d884c..000000000 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte +++ /dev/null @@ -1,172 +0,0 @@ - - -
-
-

{$t('editor_crop_tool_h2_aspect_ratios')}

-
- {#each sizesRows as sizesRow, index (index)} -
    - {#each sizesRow as size (size.name)} - - {/each} -
- {/each} -
-

{$t('editor_crop_tool_h2_rotation')}

-
-
    -
  • - rotate(false)} - icon={mdiRotateLeft} - /> -
  • -
  • - rotate(true)} - icon={mdiRotateRight} - /> -
  • -
-
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 deleted file mode 100644 index 85e7f4b1c..000000000 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { CropSettings } from '$lib/stores/asset-editor.store'; -import { get } from 'svelte/store'; -import { cropFrame, overlayEl } from './crop-store'; - -export function draw(crop: CropSettings) { - const mCropFrame = get(cropFrame); - - if (!mCropFrame) { - return; - } - - mCropFrame.style.left = `${crop.x}px`; - mCropFrame.style.top = `${crop.y}px`; - mCropFrame.style.width = `${crop.width}px`; - mCropFrame.style.height = `${crop.height}px`; - - drawOverlay(crop); -} - -export function drawOverlay(crop: CropSettings) { - const overlay = get(overlayEl); - if (!overlay) { - return; - } - - overlay.style.clipPath = ` - polygon( - 0% 0%, - 0% 100%, - 100% 100%, - 100% 0%, - 0% 0%, - ${crop.x}px ${crop.y}px, - ${crop.x + crop.width}px ${crop.y}px, - ${crop.x + crop.width}px ${crop.y + crop.height}px, - ${crop.x}px ${crop.y + crop.height}px, - ${crop.x}px ${crop.y}px - ) - `; -} 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 deleted file mode 100644 index 63a42b8b9..000000000 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { cropImageScale, cropImageSize, cropSettings, type CropSettings } from '$lib/stores/asset-editor.store'; -import { get } from 'svelte/store'; -import { cropAreaEl, cropFrame, imgElement } from './crop-store'; -import { draw } from './drawing'; - -export function onImageLoad(resetSize: boolean = false) { - const img = get(imgElement); - const cropArea = get(cropAreaEl); - - if (!cropArea || !img) { - return; - } - - const containerWidth = cropArea.clientWidth ?? 0; - const containerHeight = cropArea.clientHeight ?? 0; - - const scale = calculateScale(img, containerWidth, containerHeight); - - cropImageSize.set([img.width, img.height]); - - if (resetSize) { - cropSettings.update((crop) => { - crop.x = 0; - crop.y = 0; - crop.width = img.width * scale; - crop.height = img.height * scale; - return crop; - }); - } else { - const cropFrameEl = get(cropFrame); - cropFrameEl?.classList.add('transition'); - cropSettings.update((crop) => normalizeCropArea(crop, img, scale)); - cropFrameEl?.classList.add('transition'); - cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), { - passive: true, - }); - } - cropImageScale.set(scale); - - img.style.width = `${img.width * scale}px`; - img.style.height = `${img.height * scale}px`; - - draw(get(cropSettings)); -} - -export function calculateScale(img: HTMLImageElement, containerWidth: number, containerHeight: number): number { - const imageAspectRatio = img.width / img.height; - let scale: number; - - if (imageAspectRatio > 1) { - scale = containerWidth / img.width; - if (img.height * scale > containerHeight) { - scale = containerHeight / img.height; - } - } else { - scale = containerHeight / img.height; - if (img.width * scale > containerWidth) { - scale = containerWidth / img.width; - } - } - - return scale; -} - -export function normalizeCropArea(crop: CropSettings, img: HTMLImageElement, scale: number) { - const prevScale = get(cropImageScale); - const scaleRatio = scale / prevScale; - - crop.x *= scaleRatio; - crop.y *= scaleRatio; - crop.width *= scaleRatio; - crop.height *= scaleRatio; - - crop.width = Math.min(crop.width, img.width * scale); - crop.height = Math.min(crop.height, img.height * scale); - crop.x = Math.max(0, Math.min(crop.x, img.width * scale - crop.width)); - crop.y = Math.max(0, Math.min(crop.y, img.height * scale - crop.height)); - - return crop; -} - -export function resizeCanvas() { - const img = get(imgElement); - const cropArea = get(cropAreaEl); - - if (!cropArea || !img) { - return; - } - - const containerWidth = cropArea?.clientWidth ?? 0; - const containerHeight = cropArea?.clientHeight ?? 0; - const imageAspectRatio = img.width / img.height; - - let scale; - if (imageAspectRatio > 1) { - scale = containerWidth / img.width; - if (img.height * scale > containerHeight) { - scale = containerHeight / img.height; - } - } else { - scale = containerHeight / img.height; - if (img.width * scale > containerWidth) { - scale = containerWidth / img.width; - } - } - - img.style.width = `${img.width * scale}px`; - img.style.height = `${img.height * scale}px`; - - const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement; - if (cropFrame) { - cropFrame.style.width = `${img.width * scale}px`; - cropFrame.style.height = `${img.height * scale}px`; - } - - draw(get(cropSettings)); -} 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 deleted file mode 100644 index 832f0e433..000000000 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { - cropAspectRatio, - cropImageScale, - cropImageSize, - cropSettings, - cropSettingsChanged, - normaizedRorateDegrees, - rotateDegrees, - showCancelConfirmDialog, - type CropSettings, -} from '$lib/stores/asset-editor.store'; -import { get } from 'svelte/store'; -import { adjustDimensions, keepAspectRatio } from './crop-settings'; -import { - canvasCursor, - cropAreaEl, - dragOffset, - isDragging, - isResizingOrDragging, - overlayEl, - resizeSide, -} from './crop-store'; -import { draw } from './drawing'; - -export function handleMouseDown(e: MouseEvent) { - const canvas = get(cropAreaEl); - if (!canvas) { - return; - } - - const crop = get(cropSettings); - const { mouseX, mouseY } = getMousePosition(e); - - const { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = isOnCropBoundary(mouseX, mouseY, crop); - - if ( - onTopLeftCorner || - onTopRightCorner || - onBottomLeftCorner || - onBottomRightCorner || - onLeftBoundary || - onRightBoundary || - onTopBoundary || - onBottomBoundary - ) { - setResizeSide(mouseX, mouseY); - } else if (isInCropArea(mouseX, mouseY, crop)) { - startDragging(mouseX, mouseY); - } - - document.body.style.userSelect = 'none'; - globalThis.addEventListener('mouseup', handleMouseUp, { passive: true }); -} - -export function handleMouseMove(e: MouseEvent) { - const canvas = get(cropAreaEl); - if (!canvas) { - return; - } - - const resizeSideValue = get(resizeSide); - const { mouseX, mouseY } = getMousePosition(e); - - if (get(isDragging)) { - moveCrop(mouseX, mouseY); - } else if (resizeSideValue) { - resizeCrop(mouseX, mouseY); - } else { - updateCursor(mouseX, mouseY); - } -} - -export function handleMouseUp() { - globalThis.removeEventListener('mouseup', handleMouseUp); - document.body.style.userSelect = ''; - stopInteraction(); -} - -function getMousePosition(e: MouseEvent) { - let offsetX = e.clientX; - let offsetY = e.clientY; - const clienRect = getBoundingClientRectCached(get(cropAreaEl)); - const rotateDeg = get(normaizedRorateDegrees); - - if (rotateDeg == 90) { - offsetX = e.clientY - (clienRect?.top ?? 0); - offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); - } else if (rotateDeg == 180) { - offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); - offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); - } else if (rotateDeg == 270) { - offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); - offsetY = e.clientX - (clienRect?.left ?? 0); - } else if (rotateDeg == 0) { - offsetX -= clienRect?.left ?? 0; - offsetY -= clienRect?.top ?? 0; - } - return { mouseX: offsetX, mouseY: offsetY }; -} - -type BoundingClientRect = ReturnType; -let getBoundingClientRectCache: { data: BoundingClientRect | null; time: number } = { - data: null, - time: 0, -}; -rotateDegrees.subscribe(() => { - getBoundingClientRectCache.time = 0; -}); -function getBoundingClientRectCached(el: HTMLElement | null) { - if (Date.now() - getBoundingClientRectCache.time > 5000 || getBoundingClientRectCache.data === null) { - getBoundingClientRectCache = { - time: Date.now(), - data: el?.getBoundingClientRect() ?? null, - }; - } - return getBoundingClientRectCache.data; -} - -function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) { - const { x, y, width, height } = crop; - const sensitivity = 10; - const cornerSensitivity = 15; - - const outOfBound = mouseX > get(cropImageSize)[0] || mouseY > get(cropImageSize)[1] || mouseX < 0 || mouseY < 0; - if (outOfBound) { - return { - onLeftBoundary: false, - onRightBoundary: false, - onTopBoundary: false, - onBottomBoundary: false, - onTopLeftCorner: false, - onTopRightCorner: false, - onBottomLeftCorner: false, - onBottomRightCorner: false, - }; - } - - const onLeftBoundary = mouseX >= x - sensitivity && mouseX <= x + sensitivity && mouseY >= y && mouseY <= y + height; - const onRightBoundary = - mouseX >= x + width - sensitivity && mouseX <= x + width + sensitivity && mouseY >= y && mouseY <= y + height; - const onTopBoundary = mouseY >= y - sensitivity && mouseY <= y + sensitivity && mouseX >= x && mouseX <= x + width; - const onBottomBoundary = - mouseY >= y + height - sensitivity && mouseY <= y + height + sensitivity && mouseX >= x && mouseX <= x + width; - - const onTopLeftCorner = - mouseX >= x - cornerSensitivity && - mouseX <= x + cornerSensitivity && - mouseY >= y - cornerSensitivity && - mouseY <= y + cornerSensitivity; - const onTopRightCorner = - mouseX >= x + width - cornerSensitivity && - mouseX <= x + width + cornerSensitivity && - mouseY >= y - cornerSensitivity && - mouseY <= y + cornerSensitivity; - const onBottomLeftCorner = - mouseX >= x - cornerSensitivity && - mouseX <= x + cornerSensitivity && - mouseY >= y + height - cornerSensitivity && - mouseY <= y + height + cornerSensitivity; - const onBottomRightCorner = - mouseX >= x + width - cornerSensitivity && - mouseX <= x + width + cornerSensitivity && - mouseY >= y + height - cornerSensitivity && - mouseY <= y + height + cornerSensitivity; - - return { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - }; -} - -function isInCropArea(mouseX: number, mouseY: number, crop: CropSettings) { - const { x, y, width, height } = crop; - return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; -} - -function setResizeSide(mouseX: number, mouseY: number) { - const crop = get(cropSettings); - const { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = isOnCropBoundary(mouseX, mouseY, crop); - - if (onTopLeftCorner) { - resizeSide.set('top-left'); - } else if (onTopRightCorner) { - resizeSide.set('top-right'); - } else if (onBottomLeftCorner) { - resizeSide.set('bottom-left'); - } else if (onBottomRightCorner) { - resizeSide.set('bottom-right'); - } else if (onLeftBoundary) { - resizeSide.set('left'); - } else if (onRightBoundary) { - resizeSide.set('right'); - } else if (onTopBoundary) { - resizeSide.set('top'); - } else if (onBottomBoundary) { - resizeSide.set('bottom'); - } -} - -function startDragging(mouseX: number, mouseY: number) { - isDragging.set(true); - const crop = get(cropSettings); - isResizingOrDragging.set(true); - dragOffset.set({ x: mouseX - crop.x, y: mouseY - crop.y }); - fadeOverlay(false); -} - -function moveCrop(mouseX: number, mouseY: number) { - const cropArea = get(cropAreaEl); - if (!cropArea) { - return; - } - - const crop = get(cropSettings); - const { x, y } = get(dragOffset); - - let newX = mouseX - x; - let newY = mouseY - y; - - newX = Math.max(0, Math.min(cropArea.clientWidth - crop.width, newX)); - newY = Math.max(0, Math.min(cropArea.clientHeight - crop.height, newY)); - - cropSettings.update((crop) => { - crop.x = newX; - crop.y = newY; - return crop; - }); - - draw(crop); -} - -function resizeCrop(mouseX: number, mouseY: number) { - const canvas = get(cropAreaEl); - const crop = get(cropSettings); - const resizeSideValue = get(resizeSide); - if (!canvas || !resizeSideValue) { - return; - } - fadeOverlay(false); - - const { x, y, width, height } = crop; - const minSize = 50; - let newWidth = width; - let newHeight = height; - switch (resizeSideValue) { - case 'left': { - newWidth = width + x - mouseX; - newHeight = height; - if (newWidth >= minSize && mouseX >= 0) { - const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio)); - cropSettings.update((crop) => { - crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth)); - crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight)); - crop.x = Math.max(0, x + width - crop.width); - return crop; - }); - } - break; - } - case 'right': { - newWidth = mouseX - x; - newHeight = height; - if (newWidth >= minSize && mouseX <= canvas.clientWidth) { - const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio)); - cropSettings.update((crop) => { - crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth - x)); - crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight)); - return crop; - }); - } - break; - } - case 'top': { - newHeight = height + y - mouseY; - newWidth = width; - if (newHeight >= minSize && mouseY >= 0) { - const { newWidth: w, newHeight: h } = adjustDimensions( - newWidth, - newHeight, - get(cropAspectRatio), - canvas.clientWidth, - canvas.clientHeight, - minSize, - ); - cropSettings.update((crop) => { - crop.y = Math.max(0, y + height - h); - crop.width = w; - crop.height = h; - return crop; - }); - } - break; - } - case 'bottom': { - newHeight = mouseY - y; - newWidth = width; - if (newHeight >= minSize && mouseY <= canvas.clientHeight) { - const { newWidth: w, newHeight: h } = adjustDimensions( - newWidth, - newHeight, - get(cropAspectRatio), - canvas.clientWidth, - canvas.clientHeight - y, - minSize, - ); - cropSettings.update((crop) => { - crop.width = w; - crop.height = h; - return crop; - }); - } - break; - } - case 'top-left': { - newWidth = width + x - Math.max(mouseX, 0); - newHeight = height + y - Math.max(mouseY, 0); - const { newWidth: w, newHeight: h } = adjustDimensions( - newWidth, - newHeight, - get(cropAspectRatio), - canvas.clientWidth, - canvas.clientHeight, - minSize, - ); - cropSettings.update((crop) => { - crop.width = w; - crop.height = h; - crop.x = Math.max(0, x + width - crop.width); - crop.y = Math.max(0, y + height - crop.height); - return crop; - }); - break; - } - case 'top-right': { - newWidth = Math.max(mouseX, 0) - x; - newHeight = height + y - Math.max(mouseY, 0); - const { newWidth: w, newHeight: h } = adjustDimensions( - newWidth, - newHeight, - get(cropAspectRatio), - canvas.clientWidth - x, - y + height, - minSize, - ); - cropSettings.update((crop) => { - crop.width = w; - crop.height = h; - crop.y = Math.max(0, y + height - crop.height); - return crop; - }); - break; - } - case 'bottom-left': { - newWidth = width + x - Math.max(mouseX, 0); - newHeight = Math.max(mouseY, 0) - y; - const { newWidth: w, newHeight: h } = adjustDimensions( - newWidth, - newHeight, - get(cropAspectRatio), - canvas.clientWidth, - canvas.clientHeight - y, - minSize, - ); - cropSettings.update((crop) => { - crop.width = w; - crop.height = h; - crop.x = Math.max(0, x + width - crop.width); - return crop; - }); - break; - } - case 'bottom-right': { - newWidth = Math.max(mouseX, 0) - x; - newHeight = Math.max(mouseY, 0) - y; - const { newWidth: w, newHeight: h } = adjustDimensions( - newWidth, - newHeight, - get(cropAspectRatio), - canvas.clientWidth - x, - canvas.clientHeight - y, - minSize, - ); - cropSettings.update((crop) => { - crop.width = w; - crop.height = h; - return crop; - }); - break; - } - } - - cropSettings.update((crop) => { - crop.x = Math.max(0, Math.min(crop.x, canvas.clientWidth - crop.width)); - crop.y = Math.max(0, Math.min(crop.y, canvas.clientHeight - crop.height)); - return crop; - }); - - draw(crop); -} - -function updateCursor(mouseX: number, mouseY: number) { - const canvas = get(cropAreaEl); - if (!canvas) { - return; - } - - const crop = get(cropSettings); - const rotateDeg = get(normaizedRorateDegrees); - - let { - onLeftBoundary, - onRightBoundary, - onTopBoundary, - onBottomBoundary, - onTopLeftCorner, - onTopRightCorner, - onBottomLeftCorner, - onBottomRightCorner, - } = isOnCropBoundary(mouseX, mouseY, crop); - - if (rotateDeg == 90) { - [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ - onLeftBoundary, - onTopBoundary, - onRightBoundary, - onBottomBoundary, - ]; - - [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ - onBottomLeftCorner, - onTopLeftCorner, - onTopRightCorner, - onBottomRightCorner, - ]; - } else if (rotateDeg == 180) { - [onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary]; - [onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary]; - - [onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner]; - [onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner]; - } else if (rotateDeg == 270) { - [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ - onRightBoundary, - onBottomBoundary, - onLeftBoundary, - onTopBoundary, - ]; - - [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ - onTopRightCorner, - onBottomRightCorner, - onBottomLeftCorner, - onTopLeftCorner, - ]; - } - if (onTopLeftCorner || onBottomRightCorner) { - setCursor('nwse-resize'); - } else if (onTopRightCorner || onBottomLeftCorner) { - setCursor('nesw-resize'); - } else if (onLeftBoundary || onRightBoundary) { - setCursor('ew-resize'); - } else if (onTopBoundary || onBottomBoundary) { - setCursor('ns-resize'); - } else if (isInCropArea(mouseX, mouseY, crop)) { - setCursor('move'); - } else { - setCursor('default'); - } - - function setCursor(cursorName: string) { - if (get(canvasCursor) != cursorName && canvas && !get(showCancelConfirmDialog)) { - canvasCursor.set(cursorName); - document.body.style.cursor = cursorName; - canvas.style.cursor = cursorName; - } - } -} - -function stopInteraction() { - isResizingOrDragging.set(false); - isDragging.set(false); - resizeSide.set(''); - fadeOverlay(true); // Darken the background - - setTimeout(() => { - checkEdits(); - }, 1); -} - -export function checkEdits() { - const cropImageSizeParams = get(cropSettings); - const originalImgSize = get(cropImageSize).map((el) => el * get(cropImageScale)); - const changed = - Math.abs(originalImgSize[0] - cropImageSizeParams.width) > 2 || - Math.abs(originalImgSize[1] - cropImageSizeParams.height) > 2; - cropSettingsChanged.set(changed); -} - -function fadeOverlay(toDark: boolean) { - const overlay = get(overlayEl); - const cropFrame = document.querySelector('.crop-frame'); - - if (toDark) { - overlay?.classList.remove('light'); - cropFrame?.classList.remove('resizing'); - } else { - overlay?.classList.add('light'); - cropFrame?.classList.add('resizing'); - } - - isResizingOrDragging.set(!toDark); -} 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 203f1c658..2622bcece 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -1,11 +1,11 @@ -
-
- -

{$t('editor')}

-
-
-
    - {#each editTypes as etype (etype.name)} -
  • - selectType(etype.name)} - /> -
  • - {/each} -
-
+
+ + + +

{$t('editor')}

+
+ +
+
- + {#if editManager.selectedTool} + + {/if} +
+
+
+
- -{#if $showCancelConfirmDialog} - (confirmed ? onConfirm() : ($showCancelConfirmDialog = false))} - /> -{/if} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte similarity index 56% rename from web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte rename to web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte index d61a534ed..c88838b71 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte @@ -1,24 +1,9 @@ -
+
@@ -161,6 +149,7 @@ max-width: 100%; height: 100%; user-select: none; + transition: transform 0.15s ease; } .crop-frame { diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-preset.svelte similarity index 93% rename from web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte rename to web/src/lib/components/asset-viewer/editor/transform-tool/crop-preset.svelte index fe25ac7a4..9817870d9 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-preset.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-preset.svelte @@ -1,5 +1,5 @@ + +
+
+

{$t('editor_orientation')}

+
+ + rotateImage(-90)} + /> + rotateImage(90)} + /> + mirrorImage('horizontal')} + /> + mirrorImage('vertical')} + /> + + +
+

{$t('crop')}

+
+ + +
+ {#each aspectRatios as ratio (ratio.value)} + + + {ratio.label} + + {/each} +
+
diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 4d1a80273..f80edb20b 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -104,6 +104,10 @@ }; }); + const updateCurrentAsset = (asset: AssetResponseDto) => { + assets[currentIndex] = asset; + }; + const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0); const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true }); @@ -484,6 +488,7 @@ onPrevious={handlePrevious} onNext={handleNext} onRandom={handleRandom} + onAssetChange={updateCurrentAsset} onClose={() => { assetViewingStore.showAssetViewer(false); handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); diff --git a/web/src/lib/components/timeline/TimelineAssetViewer.svelte b/web/src/lib/components/timeline/TimelineAssetViewer.svelte index 44b96f964..0dd555754 100644 --- a/web/src/lib/components/timeline/TimelineAssetViewer.svelte +++ b/web/src/lib/components/timeline/TimelineAssetViewer.svelte @@ -225,6 +225,9 @@ {isShared} {album} {person} + onAssetChange={(asset) => { + timelineManager?.upsertAssets([toTimelineAsset(asset)]); + }} preAction={handlePreAction} onAction={(action) => { handleAction(action); diff --git a/web/src/lib/managers/edit/edit-manager.svelte.ts b/web/src/lib/managers/edit/edit-manager.svelte.ts new file mode 100644 index 000000000..b8ebea1cf --- /dev/null +++ b/web/src/lib/managers/edit/edit-manager.svelte.ts @@ -0,0 +1,145 @@ +import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte'; +import { transformManager } from '$lib/managers/edit/transform-manager.svelte'; +import { waitForWebsocketEvent } from '$lib/stores/websocket'; +import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk'; +import { ConfirmModal, modalManager, toastManager } from '@immich/ui'; +import { mdiCropRotate } from '@mdi/js'; +import type { Component } from 'svelte'; + +export type EditAction = AssetEditsDto['edits'][number]; +export type EditActions = EditAction[]; + +export interface EditToolManager { + onActivate: (asset: AssetResponseDto, edits: EditActions) => Promise; + onDeactivate: () => void; + resetAllChanges: () => Promise; + hasChanges: boolean; + edits: EditAction[]; +} + +export enum EditToolType { + Transform = 'transform', +} + +export interface EditTool { + type: EditToolType; + icon: string; + component: Component; + manager: EditToolManager; +} + +export class EditManager { + tools: EditTool[] = [ + { + type: EditToolType.Transform, + icon: mdiCropRotate, + component: TransformTool, + manager: transformManager, + }, + ]; + + currentAsset = $state(null); + selectedTool = $state(null); + hasChanges = $derived(this.tools.some((t) => t.manager.hasChanges)); + + // used to disable multiple confirm dialogs and mouse events while one is open + isShowingConfirmDialog = $state(false); + isApplyingEdits = $state(false); + hasAppliedEdits = $state(false); + + async closeConfirm(): Promise { + // Prevent multiple dialogs (usually happens with rapid escape key presses) + if (this.isShowingConfirmDialog) { + return false; + } + if (!this.hasChanges || this.hasAppliedEdits) { + return true; + } + + this.isShowingConfirmDialog = true; + + const confirmed = await modalManager.show(ConfirmModal, { + title: 'Discard Edits?', + prompt: 'You have unsaved edits. Are you sure you want to discard them?', + confirmText: 'Discard Edits', + }); + + this.isShowingConfirmDialog = false; + + return confirmed; + } + + reset() { + for (const tool of this.tools) { + tool.manager.onDeactivate?.(); + } + this.selectedTool = this.tools[0]; + } + + async activateTool(toolType: EditToolType, asset: AssetResponseDto, edits: AssetEditsDto) { + this.hasAppliedEdits = false; + if (this.selectedTool?.type === toolType) { + return; + } + + this.currentAsset = asset; + + this.selectedTool?.manager.onDeactivate?.(); + const newTool = this.tools.find((t) => t.type === toolType); + if (newTool) { + this.selectedTool = newTool; + await newTool.manager.onActivate?.(asset, edits.edits); + } + } + + cleanup() { + for (const tool of this.tools) { + tool.manager.onDeactivate?.(); + } + this.currentAsset = null; + this.selectedTool = null; + } + + async resetAllChanges() { + for (const tool of this.tools) { + await tool.manager.resetAllChanges(); + } + } + + async applyEdits(): Promise { + this.isApplyingEdits = true; + + const edits = this.tools.flatMap((tool) => tool.manager.edits); + + try { + // Setup the websocket listener before sending the edit request + const editCompleted = waitForWebsocketEvent( + 'AssetEditReadyV1', + (event) => event.assetId === this.currentAsset!.id, + 10_000, + ); + + await (edits.length === 0 + ? removeAssetEdits({ id: this.currentAsset!.id }) + : editAsset({ + id: this.currentAsset!.id, + assetEditActionListDto: { + edits, + }, + })); + + await editCompleted; + toastManager.success('Edits applied successfully'); + this.hasAppliedEdits = true; + + return true; + } catch { + toastManager.danger('Failed to apply edits'); + return false; + } finally { + this.isApplyingEdits = false; + } + } +} + +export const editManager = new EditManager(); diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts new file mode 100644 index 000000000..109a2ee90 --- /dev/null +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -0,0 +1,1116 @@ +import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte'; +import { getAssetThumbnailUrl } from '$lib/utils'; +import { getDimensions } from '$lib/utils/asset-utils'; +import { handleError } from '$lib/utils/handle-error'; +import { + AssetEditAction, + AssetMediaSize, + MirrorAxis, + type AssetResponseDto, + type CropParameters, + type MirrorParameters, + type RotateParameters, +} from '@immich/sdk'; +import { tick } from 'svelte'; + +export type CropAspectRatio = + | '1:1' + | '16:9' + | '4:3' + | '3:2' + | '7:5' + | '9:16' + | '3:4' + | '2:3' + | '5:7' + | 'free' + | 'reset'; + +type Region = { + x: number; + y: number; + width: number; + height: number; +}; + +type ImageDimensions = { + width: number; + height: number; +}; + +type RegionConvertParams = { + region: Region; + from: ImageDimensions; + to: ImageDimensions; +}; + +class TransformManager implements EditToolManager { + hasChanges: boolean = $derived.by(() => this.checkEdits()); + + darkenLevel = $state(0.65); + isInteracting = $state(false); + isDragging = $state(false); + animationFrame = $state | null>(null); + canvasCursor = $state('default'); + dragOffset = $state({ x: 0, y: 0 }); + resizeSide = $state(''); + imgElement = $state(null); + cropAreaEl = $state(null); + overlayEl = $state(null); + cropFrame = $state(null); + cropImageSize = $state({ width: 1000, height: 1000 }); + cropImageScale = $state(1); + cropAspectRatio = $state('free'); + originalImageSize = $state({ width: 1000, height: 1000 }); + region = $state({ x: 0, y: 0, width: 100, height: 100 }); + preveiwImgSize = $derived({ + width: this.cropImageSize.width * this.cropImageScale, + height: this.cropImageSize.height * this.cropImageScale, + }); + + imageRotation = $state(0); + mirrorHorizontal = $state(false); + mirrorVertical = $state(false); + normalizedRotation = $derived.by(() => { + const newAngle = this.imageRotation % 360; + return newAngle < 0 ? newAngle + 360 : newAngle; + }); + orientationChanged = $derived.by(() => this.normalizedRotation % 180 > 0); + + edits = $derived.by(() => this.getEdits()); + + setAspectRatio(aspectRatio: string) { + this.cropAspectRatio = aspectRatio; + + if (!this.imgElement || !this.cropAreaEl) { + return; + } + + const newCrop = transformManager.recalculateCrop(aspectRatio); + if (newCrop) { + transformManager.animateCropChange(this.cropAreaEl, this.region, newCrop); + this.region = newCrop; + } + } + + checkEdits() { + return ( + Math.abs(this.preveiwImgSize.width - this.region.width) > 2 || + Math.abs(this.preveiwImgSize.height - this.region.height) > 2 || + this.mirrorHorizontal || + this.mirrorVertical || + this.normalizedRotation !== 0 + ); + } + + checkCropEdits() { + return ( + Math.abs(this.preveiwImgSize.width - this.region.width) > 2 || + Math.abs(this.preveiwImgSize.height - this.region.height) > 2 + ); + } + + getEdits(): EditActions { + const edits: EditActions = []; + + if (this.checkCropEdits()) { + // Convert from display coordinates to loaded preview image coordinates + let cropRegion = this.getRegionInPreviewCoords(this.region); + + // Transform crop coordinates to account for mirroring + // The preview shows the mirrored image, but crop is applied before mirror on the server + // So we need to "unmirror" the crop coordinates + cropRegion = this.applyMirrorToCoords(cropRegion, this.cropImageSize); + + // Convert from preview image coordinates to original image coordinates + cropRegion = this.convertRegion({ + region: cropRegion, + from: this.cropImageSize, + to: this.originalImageSize, + }); + + // Constrain to original image bounds (fixes possible rounding errors) + cropRegion = this.constrainToBounds(cropRegion, this.originalImageSize); + + edits.push({ + action: AssetEditAction.Crop, + parameters: cropRegion, + }); + } + + // Mirror edits come before rotate in array so that compose applies rotate first, then mirror + // This matches CSS where parent has rotate and child img has mirror transforms + if (this.mirrorHorizontal) { + edits.push({ + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Horizontal, + }, + }); + } + + if (this.mirrorVertical) { + edits.push({ + action: AssetEditAction.Mirror, + parameters: { + axis: MirrorAxis.Vertical, + }, + }); + } + + if (this.normalizedRotation !== 0) { + edits.push({ + action: AssetEditAction.Rotate, + parameters: { + angle: this.normalizedRotation, + }, + }); + } + + return edits; + } + + async resetAllChanges() { + this.imageRotation = 0; + this.mirrorHorizontal = false; + this.mirrorVertical = false; + await tick(); + + this.onImageLoad([]); + } + + async onActivate(asset: AssetResponseDto, edits: EditActions): Promise { + const originalSize = getDimensions(asset.exifInfo!); + this.originalImageSize = { width: originalSize.width ?? 0, height: originalSize.height ?? 0 }; + + this.imgElement = new Image(); + + const imageURL = getAssetThumbnailUrl({ + id: asset.id, + cacheKey: asset.thumbhash, + edited: false, + size: AssetMediaSize.Preview, + }); + + this.imgElement.src = imageURL; + this.imgElement.addEventListener('load', () => transformManager.onImageLoad(edits), { passive: true }); + this.imgElement.addEventListener('error', (error) => handleError(error, 'ErrorLoadingImage'), { + passive: true, + }); + + globalThis.addEventListener('mousemove', (e) => transformManager.handleMouseMove(e), { passive: true }); + + // set the rotation before loading the image + const rotateEdit = edits.find((e) => e.action === 'rotate'); + if (rotateEdit) { + this.imageRotation = (rotateEdit.parameters as RotateParameters).angle; + } + + // set mirror state from edits + const mirrorEdits = edits.filter((e) => e.action === 'mirror'); + for (const mirrorEdit of mirrorEdits) { + const axis = (mirrorEdit.parameters as MirrorParameters).axis; + if (axis === MirrorAxis.Horizontal) { + this.mirrorHorizontal = true; + } else if (axis === MirrorAxis.Vertical) { + this.mirrorVertical = true; + } + } + + await tick(); + + this.resizeCanvas(); + } + + onDeactivate() { + globalThis.removeEventListener('mousemove', transformManager.handleMouseMove); + + this.reset(); + } + + reset() { + this.darkenLevel = 0.65; + this.isInteracting = false; + this.animationFrame = null; + this.canvasCursor = 'default'; + this.dragOffset = { x: 0, y: 0 }; + this.resizeSide = ''; + this.imgElement = null; + this.cropAreaEl = null; + this.isDragging = false; + this.overlayEl = null; + this.imageRotation = 0; + this.mirrorHorizontal = false; + this.mirrorVertical = false; + this.region = { x: 0, y: 0, width: 100, height: 100 }; + this.cropImageSize = { width: 1000, height: 1000 }; + this.originalImageSize = { width: 1000, height: 1000 }; + this.cropImageScale = 1; + this.cropAspectRatio = 'free'; + } + + mirror(axis: 'horizontal' | 'vertical') { + if (this.imageRotation % 180 !== 0) { + axis = axis === 'horizontal' ? 'vertical' : 'horizontal'; + } + + if (axis === 'horizontal') { + this.mirrorHorizontal = !this.mirrorHorizontal; + } else { + this.mirrorVertical = !this.mirrorVertical; + } + } + + async rotate(angle: number) { + this.imageRotation += angle; + await tick(); + this.onImageLoad(); + } + + recalculateCrop(aspectRatio: string = this.cropAspectRatio): Region { + if (!this.cropAreaEl) { + return this.region; + } + + const canvasW = this.cropAreaEl.clientWidth; + const canvasH = this.cropAreaEl.clientHeight; + + // Calculate new dimensions with aspect ratio + const { newWidth: w, newHeight: h } = this.keepAspectRatio(this.region.width, this.region.height, aspectRatio); + + // Scale down if needed to fit in canvas + let newWidth = w; + let newHeight = h; + + if (w > canvasW) { + newWidth = canvasW; + newHeight = canvasW / (w / h); + } else if (h > canvasH) { + newHeight = canvasH; + newWidth = canvasH * (w / h); + } + + // Constrain position to keep crop area within canvas + return { + x: Math.max(0, Math.min(this.region.x, canvasW - newWidth)), + y: Math.max(0, Math.min(this.region.y, canvasH - newHeight)), + width: newWidth, + height: newHeight, + }; + } + + animateCropChange(element: HTMLElement, from: Region, to: Region, duration = 100) { + const cropFrame = element.querySelector('.crop-frame') as HTMLElement; + if (!cropFrame) { + return; + } + + const startTime = performance.now(); + const initialCrop = { ...from }; + + const animate = (currentTime: number) => { + const elapsedTime = currentTime - startTime; + const progress = Math.min(elapsedTime / duration, 1); + + from.x = initialCrop.x + (to.x - initialCrop.x) * progress; + from.y = initialCrop.y + (to.y - initialCrop.y) * progress; + from.width = initialCrop.width + (to.width - initialCrop.width) * progress; + from.height = initialCrop.height + (to.height - initialCrop.height) * progress; + + this.draw(from); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + requestAnimationFrame(animate); + } + + keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: string = this.cropAspectRatio) { + const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); + + if (widthRatio && heightRatio) { + const calculatedWidth = (newHeight * widthRatio) / heightRatio; + return { newWidth: calculatedWidth, newHeight }; + } + + return { newWidth, newHeight }; + } + + // Calculate constrained dimensions based on aspect ratio and limits + getConstrainedDimensions( + desiredWidth: number, + desiredHeight: number, + maxWidth: number, + maxHeight: number, + minSize = 50, + ) { + const { newWidth, newHeight } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + maxWidth, + maxHeight, + minSize, + ); + return { + width: Math.max(minSize, Math.min(newWidth, maxWidth)), + height: Math.max(minSize, Math.min(newHeight, maxHeight)), + }; + } + + adjustDimensions( + newWidth: number, + newHeight: number, + aspectRatio: string, + xLimit: number, + yLimit: number, + minSize: number, + ) { + let w = newWidth; + let h = newHeight; + + let aspectMultiplier: number; + + if (aspectRatio === 'free') { + aspectMultiplier = newWidth / newHeight; + } else { + const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number); + aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight; + } + + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + + if (w > xLimit) { + w = xLimit; + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + } + if (h > yLimit) { + h = yLimit; + if (aspectRatio !== 'free') { + w = h * aspectMultiplier; + } + } + + if (w < minSize) { + w = minSize; + if (aspectRatio !== 'free') { + h = w / aspectMultiplier; + } + } + if (h < minSize) { + h = minSize; + if (aspectRatio !== 'free') { + w = h * aspectMultiplier; + } + } + + if (aspectRatio !== 'free' && w / h !== aspectMultiplier) { + if (w < minSize) { + h = w / aspectMultiplier; + } + if (h < minSize) { + w = h * aspectMultiplier; + } + } + + return { newWidth: w, newHeight: h }; + } + + draw(crop: Region = this.region) { + if (!this.cropFrame) { + return; + } + + this.cropFrame.style.left = `${crop.x}px`; + this.cropFrame.style.top = `${crop.y}px`; + this.cropFrame.style.width = `${crop.width}px`; + this.cropFrame.style.height = `${crop.height}px`; + + this.drawOverlay(crop); + } + + drawOverlay(crop: Region) { + if (!this.overlayEl) { + return; + } + + this.overlayEl.style.clipPath = ` + polygon( + 0% 0%, + 0% 100%, + 100% 100%, + 100% 0%, + 0% 0%, + ${crop.x}px ${crop.y}px, + ${crop.x + crop.width}px ${crop.y}px, + ${crop.x + crop.width}px ${crop.y + crop.height}px, + ${crop.x}px ${crop.y + crop.height}px, + ${crop.x}px ${crop.y}px + ) + `; + } + + onImageLoad(edits: EditActions | null = null) { + const img = this.imgElement; + if (!this.cropAreaEl || !img) { + return; + } + + this.cropImageSize = { width: img.width, height: img.height }; + const scale = this.calculateScale(); + + if (edits === null) { + const cropFrameEl = this.cropFrame; + cropFrameEl?.classList.add('transition'); + this.region = this.normalizeCropArea(scale); + cropFrameEl?.classList.add('transition'); + cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), { + passive: true, + }); + } else { + const cropEdit = edits.find((e) => e.action === AssetEditAction.Crop); + + if (cropEdit) { + const params = cropEdit.parameters as CropParameters; + + // convert from original image coordinates to loaded preview image coordinates + // eslint-disable-next-line prefer-const + let { x, y, width, height } = this.convertRegion({ + region: params, + from: this.originalImageSize, + to: this.cropImageSize, + }); + + // Transform crop coordinates to account for mirroring + // The stored coordinates are for the original image, but we display mirrored + // So we need to mirror the crop coordinates to match the preview + if (this.mirrorHorizontal) { + x = img.width - x - width; + } + if (this.mirrorVertical) { + y = img.height - y - height; + } + + // Convert from absolute pixel coordinates to display coordinates + this.region = { + x: x * scale, + y: y * scale, + width: width * scale, + height: height * scale, + }; + } else { + this.region = { + x: 0, + y: 0, + width: img.width * scale, + height: img.height * scale, + }; + } + } + this.cropImageScale = scale; + + img.style.width = `${img.width * scale}px`; + img.style.height = `${img.height * scale}px`; + + this.draw(); + } + + calculateScale(): number { + const img = this.imgElement; + const cropArea = this.cropAreaEl; + + if (!cropArea || !img) { + return 1; + } + + const containerWidth = cropArea.clientWidth; + const containerHeight = cropArea.clientHeight; + + // Fit image to container while maintaining aspect ratio + let scale = containerWidth / img.width; + if (img.height * scale > containerHeight) { + scale = containerHeight / img.height; + } + + return scale; + } + + normalizeCropArea(scale: number) { + const img = this.imgElement; + if (!img) { + return { ...this.region }; + } + + const scaleRatio = scale / this.cropImageScale; + const scaledRegion = { + x: this.region.x * scaleRatio, + y: this.region.y * scaleRatio, + width: this.region.width * scaleRatio, + height: this.region.height * scaleRatio, + }; + + // Constrain to scaled image bounds + return this.constrainToBounds(scaledRegion, { + width: img.width * scale, + height: img.height * scale, + }); + } + + resizeCanvas() { + const img = this.imgElement; + const cropArea = this.cropAreaEl; + + if (!cropArea || !img) { + return; + } + + const scale = this.calculateScale(); + this.region = this.normalizeCropArea(scale); + this.cropImageScale = scale; + + img.style.width = `${img.width * scale}px`; + img.style.height = `${img.height * scale}px`; + + this.draw(); + } + + handleMouseDown(e: MouseEvent) { + const canvas = this.cropAreaEl; + if (!canvas) { + return; + } + + const { mouseX, mouseY } = this.getMousePosition(e); + + const { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + } = this.isOnCropBoundary(mouseX, mouseY); + + if ( + onTopLeftCorner || + onTopRightCorner || + onBottomLeftCorner || + onBottomRightCorner || + onLeftBoundary || + onRightBoundary || + onTopBoundary || + onBottomBoundary + ) { + this.setResizeSide(mouseX, mouseY); + } else if (this.isInCropArea(mouseX, mouseY)) { + this.startDragging(mouseX, mouseY); + } + + document.body.style.userSelect = 'none'; + globalThis.addEventListener('mouseup', () => this.handleMouseUp(), { passive: true }); + } + + handleMouseMove(e: MouseEvent) { + const canvas = this.cropAreaEl; + if (!canvas) { + return; + } + + const resizeSideValue = this.resizeSide; + const { mouseX, mouseY } = this.getMousePosition(e); + + if (this.isDragging) { + this.moveCrop(mouseX, mouseY); + } else if (resizeSideValue) { + this.resizeCrop(mouseX, mouseY); + } else { + this.updateCursor(mouseX, mouseY); + } + } + + handleMouseUp() { + globalThis.removeEventListener('mouseup', this.handleMouseUp); + document.body.style.userSelect = ''; + + this.isInteracting = false; + this.isDragging = false; + this.resizeSide = ''; + this.fadeOverlay(true); // Darken the background + } + + getMousePosition(e: MouseEvent) { + let offsetX = e.clientX; + let offsetY = e.clientY; + const clienRect = this.cropAreaEl?.getBoundingClientRect(); + const rotateDeg = this.normalizedRotation; + + if (rotateDeg == 90) { + offsetX = e.clientY - (clienRect?.top ?? 0); + offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); + } else if (rotateDeg == 180) { + offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0)); + offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); + } else if (rotateDeg == 270) { + offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0)); + offsetY = e.clientX - (clienRect?.left ?? 0); + } else if (rotateDeg == 0) { + offsetX -= clienRect?.left ?? 0; + offsetY -= clienRect?.top ?? 0; + } + return { mouseX: offsetX, mouseY: offsetY }; + } + + // Boundary detection helpers + private isInRange(value: number, target: number, sensitivity: number): boolean { + return value >= target - sensitivity && value <= target + sensitivity; + } + + private isWithinBounds(value: number, min: number, max: number): boolean { + return value >= min && value <= max; + } + + isOnCropBoundary(mouseX: number, mouseY: number) { + const { x, y, width, height } = this.region; + const sensitivity = 10; + const cornerSensitivity = 15; + const { width: imgWidth, height: imgHeight } = this.cropImageSize; + + const outOfBound = mouseX > imgWidth || mouseY > imgHeight || mouseX < 0 || mouseY < 0; + if (outOfBound) { + return { + onLeftBoundary: false, + onRightBoundary: false, + onTopBoundary: false, + onBottomBoundary: false, + onTopLeftCorner: false, + onTopRightCorner: false, + onBottomLeftCorner: false, + onBottomRightCorner: false, + }; + } + + const onLeftBoundary = this.isInRange(mouseX, x, sensitivity) && this.isWithinBounds(mouseY, y, y + height); + const onRightBoundary = + this.isInRange(mouseX, x + width, sensitivity) && this.isWithinBounds(mouseY, y, y + height); + const onTopBoundary = this.isInRange(mouseY, y, sensitivity) && this.isWithinBounds(mouseX, x, x + width); + const onBottomBoundary = + this.isInRange(mouseY, y + height, sensitivity) && this.isWithinBounds(mouseX, x, x + width); + + const onTopLeftCorner = + this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity); + const onTopRightCorner = + this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity); + const onBottomLeftCorner = + this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity); + const onBottomRightCorner = + this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity); + + return { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + }; + } + + isInCropArea(mouseX: number, mouseY: number) { + const { x, y, width, height } = this.region; + return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; + } + + setResizeSide(mouseX: number, mouseY: number) { + const { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + } = this.isOnCropBoundary(mouseX, mouseY); + + if (onTopLeftCorner) { + this.resizeSide = 'top-left'; + } else if (onTopRightCorner) { + this.resizeSide = 'top-right'; + } else if (onBottomLeftCorner) { + this.resizeSide = 'bottom-left'; + } else if (onBottomRightCorner) { + this.resizeSide = 'bottom-right'; + } else if (onLeftBoundary) { + this.resizeSide = 'left'; + } else if (onRightBoundary) { + this.resizeSide = 'right'; + } else if (onTopBoundary) { + this.resizeSide = 'top'; + } else if (onBottomBoundary) { + this.resizeSide = 'bottom'; + } + } + + startDragging(mouseX: number, mouseY: number) { + this.isDragging = true; + const crop = this.region; + this.isInteracting = true; + this.dragOffset = { x: mouseX - crop.x, y: mouseY - crop.y }; + this.fadeOverlay(false); + } + + moveCrop(mouseX: number, mouseY: number) { + const cropArea = this.cropAreaEl; + if (!cropArea) { + return; + } + + const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width)); + const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height)); + + this.region = { + ...this.region, + x: newX, + y: newY, + }; + + this.draw(); + } + + resizeCrop(mouseX: number, mouseY: number) { + const canvas = this.cropAreaEl; + const crop = this.region; + const resizeSideValue = this.resizeSide; + if (!canvas || !resizeSideValue) { + return; + } + this.fadeOverlay(false); + + const { x, y, width, height } = crop; + const minSize = 50; + let newRegion = { ...crop }; + + switch (resizeSideValue) { + case 'left': { + const desiredWidth = width + (x - mouseX); + if (desiredWidth >= minSize && mouseX >= 0) { + const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); + const finalWidth = Math.max(minSize, Math.min(w, canvas.clientWidth)); + const finalHeight = Math.max(minSize, Math.min(h, canvas.clientHeight)); + newRegion = { + x: Math.max(0, x + width - finalWidth), + y, + width: finalWidth, + height: finalHeight, + }; + } + break; + } + case 'right': { + const desiredWidth = mouseX - x; + if (desiredWidth >= minSize && mouseX <= canvas.clientWidth) { + const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height); + newRegion = { + ...newRegion, + width: Math.max(minSize, Math.min(w, canvas.clientWidth - x)), + height: Math.max(minSize, Math.min(h, canvas.clientHeight)), + }; + } + break; + } + case 'top': { + const desiredHeight = height + (y - mouseY); + if (desiredHeight >= minSize && mouseY >= 0) { + const { newWidth: w, newHeight: h } = this.adjustDimensions( + width, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight, + minSize, + ); + newRegion = { + x, + y: Math.max(0, y + height - h), + width: w, + height: h, + }; + } + break; + } + case 'bottom': { + const desiredHeight = mouseY - y; + if (desiredHeight >= minSize && mouseY <= canvas.clientHeight) { + const { newWidth: w, newHeight: h } = this.adjustDimensions( + width, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight - y, + minSize, + ); + newRegion = { + ...newRegion, + width: w, + height: h, + }; + } + break; + } + case 'top-left': { + const desiredWidth = width + (x - Math.max(mouseX, 0)); + const desiredHeight = height + (y - Math.max(mouseY, 0)); + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight, + minSize, + ); + newRegion = { + x: Math.max(0, x + width - w), + y: Math.max(0, y + height - h), + width: w, + height: h, + }; + break; + } + case 'top-right': { + const desiredWidth = Math.max(mouseX, 0) - x; + const desiredHeight = height + (y - Math.max(mouseY, 0)); + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth - x, + y + height, + minSize, + ); + newRegion = { + x, + y: Math.max(0, y + height - h), + width: w, + height: h, + }; + break; + } + case 'bottom-left': { + const desiredWidth = width + (x - Math.max(mouseX, 0)); + const desiredHeight = Math.max(mouseY, 0) - y; + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth, + canvas.clientHeight - y, + minSize, + ); + newRegion = { + x: Math.max(0, x + width - w), + y, + width: w, + height: h, + }; + break; + } + case 'bottom-right': { + const desiredWidth = Math.max(mouseX, 0) - x; + const desiredHeight = Math.max(mouseY, 0) - y; + const { newWidth: w, newHeight: h } = this.adjustDimensions( + desiredWidth, + desiredHeight, + this.cropAspectRatio, + canvas.clientWidth - x, + canvas.clientHeight - y, + minSize, + ); + newRegion = { + ...newRegion, + width: w, + height: h, + }; + break; + } + } + + // Constrain the region to canvas bounds + this.region = { + ...newRegion, + x: Math.max(0, Math.min(newRegion.x, canvas.clientWidth - newRegion.width)), + y: Math.max(0, Math.min(newRegion.y, canvas.clientHeight - newRegion.height)), + }; + + this.draw(); + } + + updateCursor(mouseX: number, mouseY: number) { + if (!this.cropAreaEl) { + return; + } + + let { + onLeftBoundary, + onRightBoundary, + onTopBoundary, + onBottomBoundary, + onTopLeftCorner, + onTopRightCorner, + onBottomLeftCorner, + onBottomRightCorner, + } = this.isOnCropBoundary(mouseX, mouseY); + + if (this.normalizedRotation == 90) { + [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ + onLeftBoundary, + onTopBoundary, + onRightBoundary, + onBottomBoundary, + ]; + + [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ + onBottomLeftCorner, + onTopLeftCorner, + onTopRightCorner, + onBottomRightCorner, + ]; + } else if (this.normalizedRotation == 180) { + [onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary]; + [onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary]; + + [onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner]; + [onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner]; + } else if (this.normalizedRotation == 270) { + [onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [ + onRightBoundary, + onBottomBoundary, + onLeftBoundary, + onTopBoundary, + ]; + + [onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [ + onTopRightCorner, + onBottomRightCorner, + onBottomLeftCorner, + onTopLeftCorner, + ]; + } + + let cursorName = ''; + if (onTopLeftCorner || onBottomRightCorner) { + cursorName = 'nwse-resize'; + } else if (onTopRightCorner || onBottomLeftCorner) { + cursorName = 'nesw-resize'; + } else if (onLeftBoundary || onRightBoundary) { + cursorName = 'ew-resize'; + } else if (onTopBoundary || onBottomBoundary) { + cursorName = 'ns-resize'; + } else if (this.isInCropArea(mouseX, mouseY)) { + cursorName = 'move'; + } else { + cursorName = 'default'; + } + + if (this.canvasCursor != cursorName && this.cropAreaEl && !editManager.isShowingConfirmDialog) { + this.canvasCursor = cursorName; + document.body.style.cursor = cursorName; + this.cropAreaEl.style.cursor = cursorName; + } + } + + fadeOverlay(toDark: boolean) { + const overlay = this.overlayEl; + const cropFrame = document.querySelector('.crop-frame'); + + if (toDark) { + overlay?.classList.remove('light'); + cropFrame?.classList.remove('resizing'); + } else { + overlay?.classList.add('light'); + cropFrame?.classList.add('resizing'); + } + + this.isInteracting = !toDark; + } + + resetCrop() { + this.cropAspectRatio = 'free'; + this.region = { + x: 0, + y: 0, + width: this.cropImageSize.width * this.cropImageScale - 1, + height: this.cropImageSize.height * this.cropImageScale - 1, + }; + } + + rotateAspectRatio() { + const aspectRatio = this.cropAspectRatio; + if (aspectRatio === 'free' || aspectRatio === 'reset') { + return; + } + + const [widthRatio, heightRatio] = aspectRatio.split(':'); + this.setAspectRatio(`${heightRatio}:${widthRatio}`); + } + + // Coordinate conversion helpers + convertRegion(settings: RegionConvertParams) { + const { region, from: fromSize, to: toSize } = settings; + const scaleX = toSize.width / fromSize.width; + const scaleY = toSize.height / fromSize.height; + + return { + x: Math.round(region.x * scaleX), + y: Math.round(region.y * scaleY), + width: Math.round(region.width * scaleX), + height: Math.round(region.height * scaleY), + }; + } + + getRegionInPreviewCoords(region: Region) { + return { + x: Math.round(region.x / this.cropImageScale), + y: Math.round(region.y / this.cropImageScale), + width: Math.round(region.width / this.cropImageScale), + height: Math.round(region.height / this.cropImageScale), + }; + } + + // Apply mirror transformation to coordinates + applyMirrorToCoords(region: Region, imageSize: ImageDimensions): Region { + let { x, y } = region; + const { width, height } = region; + + if (this.mirrorHorizontal) { + x = imageSize.width - x - width; + } + if (this.mirrorVertical) { + y = imageSize.height - y - height; + } + + return { x, y, width, height }; + } + + // Constrain region to bounds + constrainToBounds(region: Region, bounds: ImageDimensions): Region { + const { x, y, width, height } = region; + return { + x: Math.max(0, Math.min(x, bounds.width - width)), + y: Math.max(0, Math.min(y, bounds.height - height)), + width: Math.min(width, bounds.width - Math.max(0, x)), + height: Math.min(height, bounds.height - Math.max(0, y)), + }; + } +} + +export const transformManager = new TransformManager(); diff --git a/web/src/lib/services/queue.service.ts b/web/src/lib/services/queue.service.ts index d7063c29e..19e84b071 100644 --- a/web/src/lib/services/queue.service.ts +++ b/web/src/lib/services/queue.service.ts @@ -29,6 +29,7 @@ import { mdiLibraryShelves, mdiOcr, mdiPause, + mdiPencil, mdiPlay, mdiPlus, mdiStateMachine, @@ -241,6 +242,10 @@ export const asQueueItem = ($t: MessageFormatter, queue: { name: QueueName }): Q icon: mdiStateMachine, title: $t('workflows'), }, + [QueueName.Editor]: { + icon: mdiPencil, + title: $t('editor'), + }, }; return items[queue.name]; diff --git a/web/src/lib/stores/asset-editor.store.ts b/web/src/lib/stores/asset-editor.store.ts index ec06c2cef..cc764cf7a 100644 --- a/web/src/lib/stores/asset-editor.store.ts +++ b/web/src/lib/stores/asset-editor.store.ts @@ -1,74 +1,4 @@ -import CropTool from '$lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte'; -import { mdiCropRotate } from '@mdi/js'; -import { derived, get, writable } from 'svelte/store'; +import { writable } from 'svelte/store'; -//---------crop -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'); -export const cropSettingsChanged = writable(false); -//---------rotate -export const rotateDegrees = writable(0); -export const normaizedRorateDegrees = derived(rotateDegrees, (v) => { - const newAngle = v % 360; - return newAngle < 0 ? newAngle + 360 : newAngle; -}); -export const changedOriention = derived(normaizedRorateDegrees, () => get(normaizedRorateDegrees) % 180 > 0); //-----other -export const showCancelConfirmDialog = writable(false); export const lastChosenLocation = writable<{ lng: number; lat: number } | null>(null); - -export const editTypes = [ - { - name: 'crop', - icon: mdiCropRotate, - component: CropTool, - changesFlag: cropSettingsChanged, - }, -]; - -export function closeEditorCofirm(closeCallback: CallableFunction) { - if (get(hasChanges)) { - showCancelConfirmDialog.set(closeCallback); - } else { - closeCallback(); - } -} - -export const hasChanges = derived( - editTypes.map((t) => t.changesFlag), - ($flags) => { - return $flags.some(Boolean); - }, -); - -export function resetGlobalCropStore() { - cropSettings.set({ x: 0, y: 0, width: 100, height: 100 }); - cropImageSize.set([1000, 1000]); - cropImageScale.set(1); - cropAspectRatio.set('free'); - cropSettingsChanged.set(false); - showCancelConfirmDialog.set(false); - rotateDegrees.set(0); -} - -export type CropAspectRatio = - | '1:1' - | '16:9' - | '4:3' - | '3:2' - | '7:5' - | '9:16' - | '3:4' - | '2:3' - | '5:7' - | 'free' - | 'reset'; - -export type CropSettings = { - x: number; - y: number; - width: number; - height: number; -}; diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 534fcd6a4..5b985e305 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -31,6 +31,7 @@ export interface Events { on_notification: (notification: NotificationDto) => void; AppRestartV1: (event: AppRestartEvent) => void; + AssetEditReadyV1: (data: { assetId: string }) => void; } const websocket: Socket = io({ @@ -73,3 +74,25 @@ export const openWebsocketConnection = () => { export const closeWebsocketConnection = () => { websocket.disconnect(); }; + +export const waitForWebsocketEvent = ( + event: T, + predicate?: (...args: Parameters) => boolean, + timeout: number = 10_000, +): Promise> => { + return new Promise((resolve, reject) => { + // @ts-expect-error: The typings are weird on this? + const cleanup = websocketEvents.on(event, (...args: Parameters) => { + if (!predicate || predicate(...args)) { + cleanup(); + clearTimeout(timer); + resolve(args); + } + }); + + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout waiting for event: ${String(event)}`)); + }, timeout); + }); +}; diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index c640fa31b..da1826a0b 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -166,6 +166,7 @@ export const getQueueName = derived(t, ($t) => { [QueueName.BackupDatabase]: $t('admin.backup_database'), [QueueName.Ocr]: $t('admin.machine_learning_ocr'), [QueueName.Workflow]: $t('workflows'), + [QueueName.Editor]: $t('editor'), }; return names[name]; @@ -192,7 +193,7 @@ const createUrl = (path: string, parameters?: Record) => { return getBaseUrl() + url.pathname + url.search + url.hash; }; -type AssetUrlOptions = { id: string; cacheKey?: string | null }; +type AssetUrlOptions = { id: string; cacheKey?: string | null; edited?: boolean }; export const getAssetUrl = ({ asset, @@ -232,16 +233,16 @@ export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => { if (typeof options === 'string') { options = { id: options }; } - const { id, cacheKey } = options; - return createUrl(getAssetOriginalPath(id), { ...authManager.params, c: cacheKey }); + const { id, cacheKey, edited = true } = options; + return createUrl(getAssetOriginalPath(id), { ...authManager.params, c: cacheKey, edited }); }; export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size?: AssetMediaSize })) => { if (typeof options === 'string') { options = { id: options }; } - const { id, size, cacheKey } = options; - return createUrl(getAssetThumbnailPath(id), { ...authManager.params, size, c: cacheKey }); + const { id, size, cacheKey, edited = true } = options; + return createUrl(getAssetThumbnailPath(id), { ...authManager.params, size, c: cacheKey, edited }); }; export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 9d6965343..64a660755 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -277,25 +277,18 @@ export function getFileSize(asset: AssetResponseDto, maxPrecision = 4): string { } export function getAssetResolution(asset: AssetResponseDto): string { - const { width, height } = getAssetRatio(asset); - - if (width === 235 && height === 235) { + if (!asset.width || !asset.height) { return 'Invalid Data'; } - return `${width} x ${height}`; + return `${asset.width} x ${asset.height}`; } /** * Returns aspect ratio for the asset */ export function getAssetRatio(asset: AssetResponseDto) { - let height = asset.exifInfo?.exifImageHeight || 235; - let width = asset.exifInfo?.exifImageWidth || 235; - if (isFlipped(asset.exifInfo?.orientation)) { - [width, height] = [height, width]; - } - return { width, height }; + return asset.width && asset.height ? asset.width / asset.height : null; } // list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg diff --git a/web/src/lib/utils/layout-utils.ts b/web/src/lib/utils/layout-utils.ts index 16adb79f6..3533851dd 100644 --- a/web/src/lib/utils/layout-utils.ts +++ b/web/src/lib/utils/layout-utils.ts @@ -49,8 +49,7 @@ function wasmLayoutFromTimeline(assets: TimelineAsset[], options: LayoutOptions) function wasmLayoutFromDto(assets: AssetResponseDto[], options: LayoutOptions) { const aspectRatios = new Float32Array(assets.length); for (let i = 0; i < assets.length; i++) { - const { width, height } = getAssetRatio(assets[i]); - aspectRatios[i] = width / height; + aspectRatios[i] = getAssetRatio(assets[i]) ?? 1; } return new JustifiedLayout(aspectRatios, options); } @@ -111,7 +110,7 @@ export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], op }; const result = createJustifiedLayout( - assets.map((asset) => (isTimelineAsset(asset) ? asset.ratio : getAssetRatio(asset))), + assets.map((asset) => (isTimelineAsset(asset) ? asset.ratio : (getAssetRatio(asset) ?? 1))), adapter, ); return new Adapter(result); diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index f8fb49b61..deccdd7d6 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -159,8 +159,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): return unknownAsset; } const assetResponse = unknownAsset; - const { width, height } = getAssetRatio(assetResponse); - const ratio = width / height; + const ratio = getAssetRatio(assetResponse) ?? 1; const city = assetResponse.exifInfo?.city; const country = assetResponse.exifInfo?.country; const people = assetResponse.people?.map((person) => person.name) || []; diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 88316ccaf..a5a59261c 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -28,6 +28,8 @@ export const assetFactory = Sync.makeFactory({ isOffline: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()), visibility: AssetVisibility.Timeline, + width: faker.number.int({ min: 100, max: 1000 }), + height: faker.number.int({ min: 100, max: 1000 }), }); export const timelineAssetFactory = Sync.makeFactory({