diff --git a/e2e/src/generators/timeline/rest-response.ts b/e2e/src/generators/timeline/rest-response.ts index 6fcfe52fc2..21cf59e793 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 0b49f02941..397a1656e8 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 473bd6f37b..c66d1d3443 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 eb78ea0c8e..198733b3c8 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 5ab1844571..b6dc7a2868 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 a93b826f03..e366cf70f1 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 4edd6855a8..664cdb358d 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 7a063a8672..d9a736861f 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 7ad290c152..6edf226e8b 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 893e534084..f48ee06fdd 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 55f5ff77c6..32d90a28d9 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 0944f7ce3e..becef728da 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 657e62bf6d..4b62ee7877 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -102,7 +102,9 @@ Class | Method | HTTP request | Description *AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | Delete assets *AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata *AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset +*AssetsApi* | [**editAsset**](doc//AssetsApi.md#editasset) | **PUT** /assets/{id}/edits | Apply edits to an existing asset *AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID +*AssetsApi* | [**getAssetEdits**](doc//AssetsApi.md#getassetedits) | **GET** /assets/{id}/edits | Retrieve edits for an existing asset *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset *AssetsApi* | [**getAssetMetadata**](doc//AssetsApi.md#getassetmetadata) | **GET** /assets/{id}/metadata | Get asset metadata *AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | Retrieve asset metadata by key @@ -110,6 +112,7 @@ Class | Method | HTTP request | Description *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | Get asset statistics *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | Get random assets *AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video +*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset *AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset *AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset @@ -346,6 +349,13 @@ Class | Method | HTTP request | Description - [AssetCopyDto](doc//AssetCopyDto.md) - [AssetDeltaSyncDto](doc//AssetDeltaSyncDto.md) - [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md) + - [AssetEditAction](doc//AssetEditAction.md) + - [AssetEditActionCrop](doc//AssetEditActionCrop.md) + - [AssetEditActionListDto](doc//AssetEditActionListDto.md) + - [AssetEditActionListDtoEditsInner](doc//AssetEditActionListDtoEditsInner.md) + - [AssetEditActionMirror](doc//AssetEditActionMirror.md) + - [AssetEditActionRotate](doc//AssetEditActionRotate.md) + - [AssetEditsDto](doc//AssetEditsDto.md) - [AssetFaceCreateDto](doc//AssetFaceCreateDto.md) - [AssetFaceDeleteDto](doc//AssetFaceDeleteDto.md) - [AssetFaceResponseDto](doc//AssetFaceResponseDto.md) @@ -393,6 +403,7 @@ Class | Method | HTTP request | Description - [CreateAlbumDto](doc//CreateAlbumDto.md) - [CreateLibraryDto](doc//CreateLibraryDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) + - [CropParameters](doc//CropParameters.md) - [DatabaseBackupConfig](doc//DatabaseBackupConfig.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) @@ -437,6 +448,8 @@ Class | Method | HTTP request | Description - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MergePersonDto](doc//MergePersonDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md) + - [MirrorAxis](doc//MirrorAxis.md) + - [MirrorParameters](doc//MirrorParameters.md) - [NotificationCreateDto](doc//NotificationCreateDto.md) - [NotificationDeleteAllDto](doc//NotificationDeleteAllDto.md) - [NotificationDto](doc//NotificationDto.md) @@ -497,6 +510,7 @@ Class | Method | HTTP request | Description - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) + - [RotateParameters](doc//RotateParameters.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) - [SearchExploreItem](doc//SearchExploreItem.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 59f31d1392..50120d96a6 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -95,6 +95,13 @@ part 'model/asset_bulk_upload_check_result.dart'; part 'model/asset_copy_dto.dart'; part 'model/asset_delta_sync_dto.dart'; part 'model/asset_delta_sync_response_dto.dart'; +part 'model/asset_edit_action.dart'; +part 'model/asset_edit_action_crop.dart'; +part 'model/asset_edit_action_list_dto.dart'; +part 'model/asset_edit_action_list_dto_edits_inner.dart'; +part 'model/asset_edit_action_mirror.dart'; +part 'model/asset_edit_action_rotate.dart'; +part 'model/asset_edits_dto.dart'; part 'model/asset_face_create_dto.dart'; part 'model/asset_face_delete_dto.dart'; part 'model/asset_face_response_dto.dart'; @@ -142,6 +149,7 @@ part 'model/contributor_count_response_dto.dart'; part 'model/create_album_dto.dart'; part 'model/create_library_dto.dart'; part 'model/create_profile_image_response_dto.dart'; +part 'model/crop_parameters.dart'; part 'model/database_backup_config.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; @@ -186,6 +194,8 @@ part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; part 'model/merge_person_dto.dart'; part 'model/metadata_search_dto.dart'; +part 'model/mirror_axis.dart'; +part 'model/mirror_parameters.dart'; part 'model/notification_create_dto.dart'; part 'model/notification_delete_all_dto.dart'; part 'model/notification_dto.dart'; @@ -246,6 +256,7 @@ part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; +part 'model/rotate_parameters.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_response_dto.dart'; part 'model/search_explore_item.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index ac50d015ed..03d91c9dae 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -336,10 +336,12 @@ class AssetsApi { /// /// * [String] id (required): /// + /// * [bool] edited: + /// /// * [String] key: /// /// * [String] slug: - Future downloadAssetWithHttpInfo(String id, { String? key, String? slug, }) async { + Future downloadAssetWithHttpInfo(String id, { bool? edited, String? key, String? slug, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/original' .replaceAll('{id}', id); @@ -351,6 +353,9 @@ class AssetsApi { final headerParams = {}; final formParams = {}; + if (edited != null) { + queryParams.addAll(_queryParams('', 'edited', edited)); + } if (key != null) { queryParams.addAll(_queryParams('', 'key', key)); } @@ -380,11 +385,13 @@ class AssetsApi { /// /// * [String] id (required): /// + /// * [bool] edited: + /// /// * [String] key: /// /// * [String] slug: - Future downloadAsset(String id, { String? key, String? slug, }) async { - final response = await downloadAssetWithHttpInfo(id, key: key, slug: slug, ); + Future downloadAsset(String id, { bool? edited, String? key, String? slug, }) async { + final response = await downloadAssetWithHttpInfo(id, edited: edited, key: key, slug: slug, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -398,6 +405,67 @@ class AssetsApi { return null; } + /// Apply edits to an existing asset + /// + /// Apply a series of edit actions (crop, rotate, mirror) to the specified asset. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetEditActionListDto] assetEditActionListDto (required): + Future editAssetWithHttpInfo(String id, AssetEditActionListDto assetEditActionListDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/edits' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = assetEditActionListDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Apply edits to an existing asset + /// + /// Apply a series of edit actions (crop, rotate, mirror) to the specified asset. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [AssetEditActionListDto] assetEditActionListDto (required): + Future editAsset(String id, AssetEditActionListDto assetEditActionListDto,) async { + final response = await editAssetWithHttpInfo(id, assetEditActionListDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto; + + } + return null; + } + /// Retrieve assets by device ID /// /// Get all asset of a device that are in the database, ID only. @@ -458,6 +526,63 @@ class AssetsApi { return null; } + /// Retrieve edits for an existing asset + /// + /// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getAssetEditsWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/edits' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve edits for an existing asset + /// + /// Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getAssetEdits(String id,) async { + final response = await getAssetEditsWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetEditsDto',) as AssetEditsDto; + + } + return null; + } + /// Retrieve an asset /// /// Retrieve detailed information about a specific asset. @@ -921,6 +1046,55 @@ class AssetsApi { return null; } + /// Remove edits from an existing asset + /// + /// Removes all edit actions (crop, rotate, mirror) associated with the specified asset. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future removeAssetEditsWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/edits' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Remove edits from an existing asset + /// + /// Removes all edit actions (crop, rotate, mirror) associated with the specified asset. + /// + /// Parameters: + /// + /// * [String] id (required): + Future removeAssetEdits(String id,) async { + final response = await removeAssetEditsWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Replace asset /// /// Replace the asset with new file, without changing its id. @@ -1525,12 +1699,14 @@ class AssetsApi { /// /// * [String] id (required): /// + /// * [bool] edited: + /// /// * [String] key: /// /// * [AssetMediaSize] size: /// /// * [String] slug: - Future viewAssetWithHttpInfo(String id, { String? key, AssetMediaSize? size, String? slug, }) async { + Future viewAssetWithHttpInfo(String id, { bool? edited, String? key, AssetMediaSize? size, String? slug, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/thumbnail' .replaceAll('{id}', id); @@ -1542,6 +1718,9 @@ class AssetsApi { final headerParams = {}; final formParams = {}; + if (edited != null) { + queryParams.addAll(_queryParams('', 'edited', edited)); + } if (key != null) { queryParams.addAll(_queryParams('', 'key', key)); } @@ -1574,13 +1753,15 @@ class AssetsApi { /// /// * [String] id (required): /// + /// * [bool] edited: + /// /// * [String] key: /// /// * [AssetMediaSize] size: /// /// * [String] slug: - Future viewAsset(String id, { String? key, AssetMediaSize? size, String? slug, }) async { - final response = await viewAssetWithHttpInfo(id, key: key, size: size, slug: slug, ); + Future viewAsset(String id, { bool? edited, String? key, AssetMediaSize? size, String? slug, }) async { + final response = await viewAssetWithHttpInfo(id, edited: edited, key: key, size: size, slug: slug, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 6f68e44cef..9d13b3e034 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -238,6 +238,20 @@ class ApiClient { return AssetDeltaSyncDto.fromJson(value); case 'AssetDeltaSyncResponseDto': return AssetDeltaSyncResponseDto.fromJson(value); + case 'AssetEditAction': + return AssetEditActionTypeTransformer().decode(value); + case 'AssetEditActionCrop': + return AssetEditActionCrop.fromJson(value); + case 'AssetEditActionListDto': + return AssetEditActionListDto.fromJson(value); + case 'AssetEditActionListDtoEditsInner': + return AssetEditActionListDtoEditsInner.fromJson(value); + case 'AssetEditActionMirror': + return AssetEditActionMirror.fromJson(value); + case 'AssetEditActionRotate': + return AssetEditActionRotate.fromJson(value); + case 'AssetEditsDto': + return AssetEditsDto.fromJson(value); case 'AssetFaceCreateDto': return AssetFaceCreateDto.fromJson(value); case 'AssetFaceDeleteDto': @@ -332,6 +346,8 @@ class ApiClient { return CreateLibraryDto.fromJson(value); case 'CreateProfileImageResponseDto': return CreateProfileImageResponseDto.fromJson(value); + case 'CropParameters': + return CropParameters.fromJson(value); case 'DatabaseBackupConfig': return DatabaseBackupConfig.fromJson(value); case 'DownloadArchiveInfo': @@ -420,6 +436,10 @@ class ApiClient { return MergePersonDto.fromJson(value); case 'MetadataSearchDto': return MetadataSearchDto.fromJson(value); + case 'MirrorAxis': + return MirrorAxisTypeTransformer().decode(value); + case 'MirrorParameters': + return MirrorParameters.fromJson(value); case 'NotificationCreateDto': return NotificationCreateDto.fromJson(value); case 'NotificationDeleteAllDto': @@ -540,6 +560,8 @@ class ApiClient { return ReactionTypeTypeTransformer().decode(value); case 'ReverseGeocodingStateResponseDto': return ReverseGeocodingStateResponseDto.fromJson(value); + case 'RotateParameters': + return RotateParameters.fromJson(value); case 'SearchAlbumResponseDto': return SearchAlbumResponseDto.fromJson(value); case 'SearchAssetResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 1a5f703c78..18fa3d5e31 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -58,6 +58,9 @@ String parameterToString(dynamic value) { if (value is AlbumUserRole) { return AlbumUserRoleTypeTransformer().encode(value).toString(); } + if (value is AssetEditAction) { + return AssetEditActionTypeTransformer().encode(value).toString(); + } if (value is AssetJobName) { return AssetJobNameTypeTransformer().encode(value).toString(); } @@ -109,6 +112,9 @@ String parameterToString(dynamic value) { if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); } + if (value is MirrorAxis) { + return MirrorAxisTypeTransformer().encode(value).toString(); + } if (value is NotificationLevel) { return NotificationLevelTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_edit_action.dart b/mobile/openapi/lib/model/asset_edit_action.dart new file mode 100644 index 0000000000..12aacfb68a --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AssetEditAction { + /// Instantiate a new enum with the provided [value]. + const AssetEditAction._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const crop = AssetEditAction._(r'crop'); + static const rotate = AssetEditAction._(r'rotate'); + static const mirror = AssetEditAction._(r'mirror'); + + /// List of all possible values in this [enum][AssetEditAction]. + static const values = [ + crop, + rotate, + mirror, + ]; + + static AssetEditAction? fromJson(dynamic value) => AssetEditActionTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditAction.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetEditAction] to String, +/// and [decode] dynamic data back to [AssetEditAction]. +class AssetEditActionTypeTransformer { + factory AssetEditActionTypeTransformer() => _instance ??= const AssetEditActionTypeTransformer._(); + + const AssetEditActionTypeTransformer._(); + + String encode(AssetEditAction data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetEditAction. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AssetEditAction? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'crop': return AssetEditAction.crop; + case r'rotate': return AssetEditAction.rotate; + case r'mirror': return AssetEditAction.mirror; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetEditActionTypeTransformer] instance. + static AssetEditActionTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/asset_edit_action_crop.dart b/mobile/openapi/lib/model/asset_edit_action_crop.dart new file mode 100644 index 0000000000..3b55a105d9 --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_crop.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionCrop { + /// Returns a new [AssetEditActionCrop] instance. + AssetEditActionCrop({ + required this.action, + required this.parameters, + }); + + AssetEditAction action; + + CropParameters parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionCrop && + other.action == action && + other.parameters == parameters; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (parameters.hashCode); + + @override + String toString() => 'AssetEditActionCrop[action=$action, parameters=$parameters]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'parameters'] = this.parameters; + return json; + } + + /// Returns a new [AssetEditActionCrop] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionCrop? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionCrop"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionCrop( + action: AssetEditAction.fromJson(json[r'action'])!, + parameters: CropParameters.fromJson(json[r'parameters'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionCrop.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionCrop.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionCrop-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionCrop.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'parameters', + }; +} + diff --git a/mobile/openapi/lib/model/asset_edit_action_list_dto.dart b/mobile/openapi/lib/model/asset_edit_action_list_dto.dart new file mode 100644 index 0000000000..48c1e15922 --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_list_dto.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionListDto { + /// Returns a new [AssetEditActionListDto] instance. + AssetEditActionListDto({ + this.edits = const [], + }); + + /// list of edits + List edits; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDto && + _deepEquality.equals(other.edits, edits); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (edits.hashCode); + + @override + String toString() => 'AssetEditActionListDto[edits=$edits]'; + + Map toJson() { + final json = {}; + json[r'edits'] = this.edits; + return json; + } + + /// Returns a new [AssetEditActionListDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionListDto? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionListDto"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionListDto( + edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionListDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionListDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionListDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionListDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'edits', + }; +} + 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 0000000000..c4c0496631 --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionListDtoEditsInner { + /// Returns a new [AssetEditActionListDtoEditsInner] instance. + AssetEditActionListDtoEditsInner({ + required this.action, + required this.parameters, + }); + + AssetEditAction action; + + MirrorParameters parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner && + other.action == action && + other.parameters == parameters; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (parameters.hashCode); + + @override + String toString() => 'AssetEditActionListDtoEditsInner[action=$action, parameters=$parameters]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'parameters'] = this.parameters; + return json; + } + + /// Returns a new [AssetEditActionListDtoEditsInner] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionListDtoEditsInner? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionListDtoEditsInner"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionListDtoEditsInner( + action: AssetEditAction.fromJson(json[r'action'])!, + parameters: MirrorParameters.fromJson(json[r'parameters'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionListDtoEditsInner.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionListDtoEditsInner.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionListDtoEditsInner-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionListDtoEditsInner.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'parameters', + }; +} + 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 0000000000..782d317b7b --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_mirror.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionMirror { + /// Returns a new [AssetEditActionMirror] instance. + AssetEditActionMirror({ + required this.action, + required this.parameters, + }); + + AssetEditAction action; + + MirrorParameters parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionMirror && + other.action == action && + other.parameters == parameters; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (parameters.hashCode); + + @override + String toString() => 'AssetEditActionMirror[action=$action, parameters=$parameters]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'parameters'] = this.parameters; + return json; + } + + /// Returns a new [AssetEditActionMirror] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionMirror? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionMirror"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionMirror( + action: AssetEditAction.fromJson(json[r'action'])!, + parameters: MirrorParameters.fromJson(json[r'parameters'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionMirror.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionMirror.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionMirror-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionMirror.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'parameters', + }; +} + 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 0000000000..1104c06307 --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_rotate.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditActionRotate { + /// Returns a new [AssetEditActionRotate] instance. + AssetEditActionRotate({ + required this.action, + required this.parameters, + }); + + AssetEditAction action; + + RotateParameters parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionRotate && + other.action == action && + other.parameters == parameters; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (parameters.hashCode); + + @override + String toString() => 'AssetEditActionRotate[action=$action, parameters=$parameters]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'parameters'] = this.parameters; + return json; + } + + /// Returns a new [AssetEditActionRotate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditActionRotate? fromJson(dynamic value) { + upgradeDto(value, "AssetEditActionRotate"); + if (value is Map) { + final json = value.cast(); + + return AssetEditActionRotate( + action: AssetEditAction.fromJson(json[r'action'])!, + parameters: RotateParameters.fromJson(json[r'parameters'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionRotate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditActionRotate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditActionRotate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionRotate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'parameters', + }; +} + 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 0000000000..26757dce3b --- /dev/null +++ b/mobile/openapi/lib/model/asset_edits_dto.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class AssetEditsDto { + /// Returns a new [AssetEditsDto] instance. + AssetEditsDto({ + required this.assetId, + this.edits = const [], + }); + + String assetId; + + /// list of edits + List edits; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditsDto && + other.assetId == assetId && + _deepEquality.equals(other.edits, edits); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (edits.hashCode); + + @override + String toString() => 'AssetEditsDto[assetId=$assetId, edits=$edits]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'edits'] = this.edits; + return json; + } + + /// Returns a new [AssetEditsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetEditsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetEditsDto"); + if (value is Map) { + final json = value.cast(); + + return AssetEditsDto( + assetId: mapValueOfType(json, r'assetId')!, + edits: AssetEditActionListDtoEditsInner.listFromJson(json[r'edits']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditsDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetEditsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetEditsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'edits', + }; +} + diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 8d49986359..c9581b19dd 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -23,6 +23,7 @@ class AssetResponseDto { required this.fileCreatedAt, required this.fileModifiedAt, required this.hasMetadata, + required this.height, required this.id, required this.isArchived, required this.isFavorite, @@ -45,6 +46,7 @@ class AssetResponseDto { this.unassignedFaces = const [], required this.updatedAt, required this.visibility, + required this.width, }); /// base64 encoded sha1 hash @@ -77,6 +79,8 @@ class AssetResponseDto { bool hasMetadata; + num? height; + String id; bool isArchived; @@ -141,6 +145,8 @@ class AssetResponseDto { AssetVisibility visibility; + num? width; + @override bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && other.checksum == checksum && @@ -153,6 +159,7 @@ class AssetResponseDto { other.fileCreatedAt == fileCreatedAt && other.fileModifiedAt == fileModifiedAt && other.hasMetadata == hasMetadata && + other.height == height && other.id == id && other.isArchived == isArchived && other.isFavorite == isFavorite && @@ -174,7 +181,8 @@ class AssetResponseDto { other.type == type && _deepEquality.equals(other.unassignedFaces, unassignedFaces) && other.updatedAt == updatedAt && - other.visibility == visibility; + other.visibility == visibility && + other.width == width; @override int get hashCode => @@ -189,6 +197,7 @@ class AssetResponseDto { (fileCreatedAt.hashCode) + (fileModifiedAt.hashCode) + (hasMetadata.hashCode) + + (height == null ? 0 : height!.hashCode) + (id.hashCode) + (isArchived.hashCode) + (isFavorite.hashCode) + @@ -210,10 +219,11 @@ class AssetResponseDto { (type.hashCode) + (unassignedFaces.hashCode) + (updatedAt.hashCode) + - (visibility.hashCode); + (visibility.hashCode) + + (width == null ? 0 : width!.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]'; + String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; Map toJson() { final json = {}; @@ -235,6 +245,11 @@ class AssetResponseDto { json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String(); json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String(); json[r'hasMetadata'] = this.hasMetadata; + if (this.height != null) { + json[r'height'] = this.height; + } else { + // json[r'height'] = null; + } json[r'id'] = this.id; json[r'isArchived'] = this.isArchived; json[r'isFavorite'] = this.isFavorite; @@ -285,6 +300,11 @@ class AssetResponseDto { json[r'unassignedFaces'] = this.unassignedFaces; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); json[r'visibility'] = this.visibility; + if (this.width != null) { + json[r'width'] = this.width; + } else { + // json[r'width'] = null; + } return json; } @@ -307,6 +327,9 @@ class AssetResponseDto { fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, hasMetadata: mapValueOfType(json, r'hasMetadata')!, + height: json[r'height'] == null + ? null + : num.parse('${json[r'height']}'), id: mapValueOfType(json, r'id')!, isArchived: mapValueOfType(json, r'isArchived')!, isFavorite: mapValueOfType(json, r'isFavorite')!, @@ -329,6 +352,9 @@ class AssetResponseDto { unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), updatedAt: mapDateTime(json, r'updatedAt', r'')!, visibility: AssetVisibility.fromJson(json[r'visibility'])!, + width: json[r'width'] == null + ? null + : num.parse('${json[r'width']}'), ); } return null; @@ -384,6 +410,7 @@ class AssetResponseDto { 'fileCreatedAt', 'fileModifiedAt', 'hasMetadata', + 'height', 'id', 'isArchived', 'isFavorite', @@ -397,6 +424,7 @@ class AssetResponseDto { 'type', 'updatedAt', 'visibility', + 'width', }; } diff --git a/mobile/openapi/lib/model/crop_parameters.dart b/mobile/openapi/lib/model/crop_parameters.dart new file mode 100644 index 0000000000..8c5b884596 --- /dev/null +++ b/mobile/openapi/lib/model/crop_parameters.dart @@ -0,0 +1,135 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class CropParameters { + /// Returns a new [CropParameters] instance. + CropParameters({ + required this.height, + required this.width, + required this.x, + required this.y, + }); + + /// Height of the crop + /// + /// Minimum value: 1 + num height; + + /// Width of the crop + /// + /// Minimum value: 1 + num width; + + /// Top-Left X coordinate of crop + /// + /// Minimum value: 0 + num x; + + /// Top-Left Y coordinate of crop + /// + /// Minimum value: 0 + num y; + + @override + bool operator ==(Object other) => identical(this, other) || other is CropParameters && + other.height == height && + other.width == width && + other.x == x && + other.y == y; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (height.hashCode) + + (width.hashCode) + + (x.hashCode) + + (y.hashCode); + + @override + String toString() => 'CropParameters[height=$height, width=$width, x=$x, y=$y]'; + + Map toJson() { + final json = {}; + json[r'height'] = this.height; + json[r'width'] = this.width; + json[r'x'] = this.x; + json[r'y'] = this.y; + return json; + } + + /// Returns a new [CropParameters] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static CropParameters? fromJson(dynamic value) { + upgradeDto(value, "CropParameters"); + if (value is Map) { + final json = value.cast(); + + return CropParameters( + height: num.parse('${json[r'height']}'), + width: num.parse('${json[r'width']}'), + x: num.parse('${json[r'x']}'), + y: num.parse('${json[r'y']}'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = CropParameters.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = CropParameters.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of CropParameters-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = CropParameters.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'height', + 'width', + 'x', + 'y', + }; +} + diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index 038a17a8e6..b027c92114 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -29,6 +29,7 @@ class JobName { static const assetDetectFaces = JobName._(r'AssetDetectFaces'); static const assetDetectDuplicatesQueueAll = JobName._(r'AssetDetectDuplicatesQueueAll'); static const assetDetectDuplicates = JobName._(r'AssetDetectDuplicates'); + static const assetEditThumbnailGeneration = JobName._(r'AssetEditThumbnailGeneration'); static const assetEncodeVideoQueueAll = JobName._(r'AssetEncodeVideoQueueAll'); static const assetEncodeVideo = JobName._(r'AssetEncodeVideo'); static const assetEmptyTrash = JobName._(r'AssetEmptyTrash'); @@ -87,6 +88,7 @@ class JobName { assetDetectFaces, assetDetectDuplicatesQueueAll, assetDetectDuplicates, + assetEditThumbnailGeneration, assetEncodeVideoQueueAll, assetEncodeVideo, assetEmptyTrash, @@ -180,6 +182,7 @@ class JobNameTypeTransformer { case r'AssetDetectFaces': return JobName.assetDetectFaces; case r'AssetDetectDuplicatesQueueAll': return JobName.assetDetectDuplicatesQueueAll; case r'AssetDetectDuplicates': return JobName.assetDetectDuplicates; + case r'AssetEditThumbnailGeneration': return JobName.assetEditThumbnailGeneration; case r'AssetEncodeVideoQueueAll': return JobName.assetEncodeVideoQueueAll; case r'AssetEncodeVideo': return JobName.assetEncodeVideo; case r'AssetEmptyTrash': return JobName.assetEmptyTrash; diff --git a/mobile/openapi/lib/model/mirror_axis.dart b/mobile/openapi/lib/model/mirror_axis.dart new file mode 100644 index 0000000000..4deeeb047c --- /dev/null +++ b/mobile/openapi/lib/model/mirror_axis.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +/// Axis to mirror along +class MirrorAxis { + /// Instantiate a new enum with the provided [value]. + const MirrorAxis._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const horizontal = MirrorAxis._(r'horizontal'); + static const vertical = MirrorAxis._(r'vertical'); + + /// List of all possible values in this [enum][MirrorAxis]. + static const values = [ + horizontal, + vertical, + ]; + + static MirrorAxis? fromJson(dynamic value) => MirrorAxisTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MirrorAxis.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [MirrorAxis] to String, +/// and [decode] dynamic data back to [MirrorAxis]. +class MirrorAxisTypeTransformer { + factory MirrorAxisTypeTransformer() => _instance ??= const MirrorAxisTypeTransformer._(); + + const MirrorAxisTypeTransformer._(); + + String encode(MirrorAxis data) => data.value; + + /// Decodes a [dynamic value][data] to a MirrorAxis. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + MirrorAxis? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'horizontal': return MirrorAxis.horizontal; + case r'vertical': return MirrorAxis.vertical; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [MirrorAxisTypeTransformer] instance. + static MirrorAxisTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/mirror_parameters.dart b/mobile/openapi/lib/model/mirror_parameters.dart new file mode 100644 index 0000000000..e8b8db685b --- /dev/null +++ b/mobile/openapi/lib/model/mirror_parameters.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class MirrorParameters { + /// Returns a new [MirrorParameters] instance. + MirrorParameters({ + required this.axis, + }); + + /// Axis to mirror along + MirrorAxis axis; + + @override + bool operator ==(Object other) => identical(this, other) || other is MirrorParameters && + other.axis == axis; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (axis.hashCode); + + @override + String toString() => 'MirrorParameters[axis=$axis]'; + + Map toJson() { + final json = {}; + json[r'axis'] = this.axis; + return json; + } + + /// Returns a new [MirrorParameters] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static MirrorParameters? fromJson(dynamic value) { + upgradeDto(value, "MirrorParameters"); + if (value is Map) { + final json = value.cast(); + + return MirrorParameters( + axis: MirrorAxis.fromJson(json[r'axis'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = MirrorParameters.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = MirrorParameters.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of MirrorParameters-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = MirrorParameters.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'axis', + }; +} + diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 3b9a3964b6..d762946c3f 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -43,6 +43,10 @@ class Permission { static const assetPeriodUpload = Permission._(r'asset.upload'); static const assetPeriodReplace = Permission._(r'asset.replace'); static const assetPeriodCopy = Permission._(r'asset.copy'); + static const assetPeriodDerive = Permission._(r'asset.derive'); + static const assetPeriodEditPeriodGet = Permission._(r'asset.edit.get'); + static const assetPeriodEditPeriodCreate = Permission._(r'asset.edit.create'); + static const assetPeriodEditPeriodDelete = Permission._(r'asset.edit.delete'); static const albumPeriodCreate = Permission._(r'album.create'); static const albumPeriodRead = Permission._(r'album.read'); static const albumPeriodUpdate = Permission._(r'album.update'); @@ -191,6 +195,10 @@ class Permission { assetPeriodUpload, assetPeriodReplace, assetPeriodCopy, + assetPeriodDerive, + assetPeriodEditPeriodGet, + assetPeriodEditPeriodCreate, + assetPeriodEditPeriodDelete, albumPeriodCreate, albumPeriodRead, albumPeriodUpdate, @@ -374,6 +382,10 @@ class PermissionTypeTransformer { case r'asset.upload': return Permission.assetPeriodUpload; case r'asset.replace': return Permission.assetPeriodReplace; case r'asset.copy': return Permission.assetPeriodCopy; + case r'asset.derive': return Permission.assetPeriodDerive; + case r'asset.edit.get': return Permission.assetPeriodEditPeriodGet; + case r'asset.edit.create': return Permission.assetPeriodEditPeriodCreate; + case r'asset.edit.delete': return Permission.assetPeriodEditPeriodDelete; case r'album.create': return Permission.albumPeriodCreate; case r'album.read': return Permission.albumPeriodRead; case r'album.update': return Permission.albumPeriodUpdate; diff --git a/mobile/openapi/lib/model/queue_name.dart b/mobile/openapi/lib/model/queue_name.dart index bcc4159fce..d94304d0d3 100644 --- a/mobile/openapi/lib/model/queue_name.dart +++ b/mobile/openapi/lib/model/queue_name.dart @@ -40,6 +40,7 @@ class QueueName { static const backupDatabase = QueueName._(r'backupDatabase'); static const ocr = QueueName._(r'ocr'); static const workflow = QueueName._(r'workflow'); + static const editor = QueueName._(r'editor'); /// List of all possible values in this [enum][QueueName]. static const values = [ @@ -60,6 +61,7 @@ class QueueName { backupDatabase, ocr, workflow, + editor, ]; static QueueName? fromJson(dynamic value) => QueueNameTypeTransformer().decode(value); @@ -115,6 +117,7 @@ class QueueNameTypeTransformer { case r'backupDatabase': return QueueName.backupDatabase; case r'ocr': return QueueName.ocr; case r'workflow': return QueueName.workflow; + case r'editor': return QueueName.editor; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/queues_response_legacy_dto.dart b/mobile/openapi/lib/model/queues_response_legacy_dto.dart index 4aab6d863b..c7bc23cb4d 100644 --- a/mobile/openapi/lib/model/queues_response_legacy_dto.dart +++ b/mobile/openapi/lib/model/queues_response_legacy_dto.dart @@ -16,6 +16,7 @@ class QueuesResponseLegacyDto { required this.backgroundTask, required this.backupDatabase, required this.duplicateDetection, + required this.editor, required this.faceDetection, required this.facialRecognition, required this.library_, @@ -38,6 +39,8 @@ class QueuesResponseLegacyDto { QueueResponseLegacyDto duplicateDetection; + QueueResponseLegacyDto editor; + QueueResponseLegacyDto faceDetection; QueueResponseLegacyDto facialRecognition; @@ -71,6 +74,7 @@ class QueuesResponseLegacyDto { other.backgroundTask == backgroundTask && other.backupDatabase == backupDatabase && other.duplicateDetection == duplicateDetection && + other.editor == editor && other.faceDetection == faceDetection && other.facialRecognition == facialRecognition && other.library_ == library_ && @@ -92,6 +96,7 @@ class QueuesResponseLegacyDto { (backgroundTask.hashCode) + (backupDatabase.hashCode) + (duplicateDetection.hashCode) + + (editor.hashCode) + (faceDetection.hashCode) + (facialRecognition.hashCode) + (library_.hashCode) + @@ -108,13 +113,14 @@ class QueuesResponseLegacyDto { (workflow.hashCode); @override - String toString() => 'QueuesResponseLegacyDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; + String toString() => 'QueuesResponseLegacyDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, editor=$editor, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; json[r'backgroundTask'] = this.backgroundTask; json[r'backupDatabase'] = this.backupDatabase; json[r'duplicateDetection'] = this.duplicateDetection; + json[r'editor'] = this.editor; json[r'faceDetection'] = this.faceDetection; json[r'facialRecognition'] = this.facialRecognition; json[r'library'] = this.library_; @@ -144,6 +150,7 @@ class QueuesResponseLegacyDto { backgroundTask: QueueResponseLegacyDto.fromJson(json[r'backgroundTask'])!, backupDatabase: QueueResponseLegacyDto.fromJson(json[r'backupDatabase'])!, duplicateDetection: QueueResponseLegacyDto.fromJson(json[r'duplicateDetection'])!, + editor: QueueResponseLegacyDto.fromJson(json[r'editor'])!, faceDetection: QueueResponseLegacyDto.fromJson(json[r'faceDetection'])!, facialRecognition: QueueResponseLegacyDto.fromJson(json[r'facialRecognition'])!, library_: QueueResponseLegacyDto.fromJson(json[r'library'])!, @@ -208,6 +215,7 @@ class QueuesResponseLegacyDto { 'backgroundTask', 'backupDatabase', 'duplicateDetection', + 'editor', 'faceDetection', 'facialRecognition', 'library', diff --git a/mobile/openapi/lib/model/rotate_parameters.dart b/mobile/openapi/lib/model/rotate_parameters.dart new file mode 100644 index 0000000000..33609e83e5 --- /dev/null +++ b/mobile/openapi/lib/model/rotate_parameters.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class RotateParameters { + /// Returns a new [RotateParameters] instance. + RotateParameters({ + required this.angle, + }); + + /// Rotation angle in degrees + num angle; + + @override + bool operator ==(Object other) => identical(this, other) || other is RotateParameters && + other.angle == angle; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (angle.hashCode); + + @override + String toString() => 'RotateParameters[angle=$angle]'; + + Map toJson() { + final json = {}; + json[r'angle'] = this.angle; + return json; + } + + /// Returns a new [RotateParameters] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static RotateParameters? fromJson(dynamic value) { + upgradeDto(value, "RotateParameters"); + if (value is Map) { + final json = value.cast(); + + return RotateParameters( + angle: num.parse('${json[r'angle']}'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = RotateParameters.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = RotateParameters.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of RotateParameters-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = RotateParameters.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'angle', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart index f0d5097ea4..a2c89eb5c1 100644 --- a/mobile/openapi/lib/model/sync_asset_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_v1.dart @@ -18,6 +18,7 @@ class SyncAssetV1 { required this.duration, required this.fileCreatedAt, required this.fileModifiedAt, + required this.height, required this.id, required this.isFavorite, required this.libraryId, @@ -29,6 +30,7 @@ class SyncAssetV1 { required this.thumbhash, required this.type, required this.visibility, + required this.width, }); String checksum; @@ -41,6 +43,8 @@ class SyncAssetV1 { DateTime? fileModifiedAt; + int? height; + String id; bool isFavorite; @@ -63,6 +67,8 @@ class SyncAssetV1 { AssetVisibility visibility; + int? width; + @override bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 && other.checksum == checksum && @@ -70,6 +76,7 @@ class SyncAssetV1 { other.duration == duration && other.fileCreatedAt == fileCreatedAt && other.fileModifiedAt == fileModifiedAt && + other.height == height && other.id == id && other.isFavorite == isFavorite && other.libraryId == libraryId && @@ -80,7 +87,8 @@ class SyncAssetV1 { other.stackId == stackId && other.thumbhash == thumbhash && other.type == type && - other.visibility == visibility; + other.visibility == visibility && + other.width == width; @override int get hashCode => @@ -90,6 +98,7 @@ class SyncAssetV1 { (duration == null ? 0 : duration!.hashCode) + (fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) + (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) + + (height == null ? 0 : height!.hashCode) + (id.hashCode) + (isFavorite.hashCode) + (libraryId == null ? 0 : libraryId!.hashCode) + @@ -100,10 +109,11 @@ class SyncAssetV1 { (stackId == null ? 0 : stackId!.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) + (type.hashCode) + - (visibility.hashCode); + (visibility.hashCode) + + (width == null ? 0 : width!.hashCode); @override - String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility]'; + String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]'; Map toJson() { final json = {}; @@ -127,6 +137,11 @@ class SyncAssetV1 { json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String(); } else { // json[r'fileModifiedAt'] = null; + } + if (this.height != null) { + json[r'height'] = this.height; + } else { + // json[r'height'] = null; } json[r'id'] = this.id; json[r'isFavorite'] = this.isFavorite; @@ -159,6 +174,11 @@ class SyncAssetV1 { } json[r'type'] = this.type; json[r'visibility'] = this.visibility; + if (this.width != null) { + json[r'width'] = this.width; + } else { + // json[r'width'] = null; + } return json; } @@ -176,6 +196,7 @@ class SyncAssetV1 { duration: mapValueOfType(json, r'duration'), fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''), fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''), + height: mapValueOfType(json, r'height'), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite')!, libraryId: mapValueOfType(json, r'libraryId'), @@ -187,6 +208,7 @@ class SyncAssetV1 { thumbhash: mapValueOfType(json, r'thumbhash'), type: AssetTypeEnum.fromJson(json[r'type'])!, visibility: AssetVisibility.fromJson(json[r'visibility'])!, + width: mapValueOfType(json, r'width'), ); } return null; @@ -239,6 +261,7 @@ class SyncAssetV1 { 'duration', 'fileCreatedAt', 'fileModifiedAt', + 'height', 'id', 'isFavorite', 'libraryId', @@ -250,6 +273,7 @@ class SyncAssetV1 { 'thumbhash', 'type', 'visibility', + 'width', }; } diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 461420b3e3..d54db6809f 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -14,6 +14,7 @@ class SystemConfigJobDto { /// Returns a new [SystemConfigJobDto] instance. SystemConfigJobDto({ required this.backgroundTask, + required this.editor, required this.faceDetection, required this.library_, required this.metadataExtraction, @@ -30,6 +31,8 @@ class SystemConfigJobDto { JobSettingsDto backgroundTask; + JobSettingsDto editor; + JobSettingsDto faceDetection; JobSettingsDto library_; @@ -57,6 +60,7 @@ class SystemConfigJobDto { @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigJobDto && other.backgroundTask == backgroundTask && + other.editor == editor && other.faceDetection == faceDetection && other.library_ == library_ && other.metadataExtraction == metadataExtraction && @@ -74,6 +78,7 @@ class SystemConfigJobDto { int get hashCode => // ignore: unnecessary_parenthesis (backgroundTask.hashCode) + + (editor.hashCode) + (faceDetection.hashCode) + (library_.hashCode) + (metadataExtraction.hashCode) + @@ -88,11 +93,12 @@ class SystemConfigJobDto { (workflow.hashCode); @override - String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; + String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, editor=$editor, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; json[r'backgroundTask'] = this.backgroundTask; + json[r'editor'] = this.editor; json[r'faceDetection'] = this.faceDetection; json[r'library'] = this.library_; json[r'metadataExtraction'] = this.metadataExtraction; @@ -118,6 +124,7 @@ class SystemConfigJobDto { return SystemConfigJobDto( backgroundTask: JobSettingsDto.fromJson(json[r'backgroundTask'])!, + editor: JobSettingsDto.fromJson(json[r'editor'])!, faceDetection: JobSettingsDto.fromJson(json[r'faceDetection'])!, library_: JobSettingsDto.fromJson(json[r'library'])!, metadataExtraction: JobSettingsDto.fromJson(json[r'metadataExtraction'])!, @@ -178,6 +185,7 @@ class SystemConfigJobDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'backgroundTask', + 'editor', 'faceDetection', 'library', 'metadataExtraction', diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart new file mode 100644 index 0000000000..d39446ada3 --- /dev/null +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -0,0 +1,185 @@ +import 'package:drift/drift.dart' as drift; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; +import 'package:openapi/api.dart'; + +SyncUserV1 _createUser({String id = 'user-1'}) { + return SyncUserV1( + id: id, + name: 'Test User', + email: 'test@test.com', + deletedAt: null, + avatarColor: null, + hasProfileImage: false, + profileChangedAt: DateTime(2024, 1, 1), + ); +} + +SyncAssetV1 _createAsset({ + required String id, + required String checksum, + required String fileName, + String ownerId = 'user-1', + int? width, + int? height, +}) { + return SyncAssetV1( + id: id, + checksum: checksum, + originalFileName: fileName, + type: AssetTypeEnum.IMAGE, + ownerId: ownerId, + isFavorite: false, + fileCreatedAt: DateTime(2024, 1, 1), + fileModifiedAt: DateTime(2024, 1, 1), + localDateTime: DateTime(2024, 1, 1), + visibility: AssetVisibility.timeline, + width: width, + height: height, + deletedAt: null, + duration: null, + libraryId: null, + livePhotoVideoId: null, + stackId: null, + thumbhash: null, + ); +} + +SyncAssetExifV1 _createExif({ + required String assetId, + required int width, + required int height, + required String orientation, +}) { + return SyncAssetExifV1( + assetId: assetId, + exifImageWidth: width, + exifImageHeight: height, + orientation: orientation, + city: null, + country: null, + dateTimeOriginal: null, + description: null, + exposureTime: null, + fNumber: null, + fileSizeInByte: null, + focalLength: null, + fps: null, + iso: null, + latitude: null, + lensModel: null, + longitude: null, + make: null, + model: null, + modifyDate: null, + profileDescription: null, + projectionType: null, + rating: null, + state: null, + timeZone: null, + ); +} + +void main() { + late Drift db; + late SyncStreamRepository sut; + + setUp(() async { + db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + sut = SyncStreamRepository(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('SyncStreamRepository - Dimension swapping based on orientation', () { + test('swaps dimensions for asset with rotated orientation', () async { + final flippedOrientations = ['5', '6', '7', '8', '90', '-90']; + + for (final orientation in flippedOrientations) { + final assetId = 'asset-$orientation-degrees'; + + await sut.updateUsersV1([_createUser()]); + + final asset = _createAsset( + id: assetId, + checksum: 'checksum-$orientation', + fileName: 'rotated_$orientation.jpg', + ); + await sut.updateAssetsV1([asset]); + + final exif = _createExif( + assetId: assetId, + width: 1920, + height: 1080, + orientation: orientation, // EXIF orientation value for 90 degrees CW + ); + await sut.updateAssetsExifV1([exif]); + + final query = db.remoteAssetEntity.select()..where((tbl) => 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 ca9defc332..04e49f89f9 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 523984f966..69f6c1753f 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 b956c4bfb9..a577b0544f 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 70f482c5b2..2f160e6bed 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 afc583f91d..496e6906a2 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 e3eb433e60..287faee4f0 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 81f1181e66..2e54b11de8 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 c18acd79f8..9b5fafd605 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 d52a40d7dd..788ee0c0ed 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 56c9d18049..cf8b80be38 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 ba9ec865f9..988623360b 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 96623092f1..d688857de1 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 9f4494b720..95bc98bae4 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 262e2f9637..f5207d3048 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 e228cd8f9f..1607c15085 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 854c244ba9..5ac79a9895 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 0000000000..56bd09f3ea --- /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 3c90cfdc59..5bf6854d34 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 79155e3f74..e3b48fa869 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 7f811af371..6baf3c8ac7 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 c835073c31..31b8145034 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 b150cdbfb3..8f0526d0e5 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 0000000000..d11bc7fe70 --- /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 ae2b5110c2..ccd90680bb 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 27e40139e1..aaa7dd46fb 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 d9fe049031..fc8991dea0 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 8ad5b96bbc..356f5af8f6 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 7c1dc3b6b4..e7595b3d1e 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 0000000000..fdfbc4e1d8 --- /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 8beb053aac..39e658a5a8 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 e1d16b8a6a..7ae6a277b7 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 c59110d674..361a2e7179 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 0000000000..a5380852ee --- /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 a8e96709ff..699c31ba5b 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 a39f0d368c..63375cf57d 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 725304938c..b03112821b 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 437e32da16..511d7b589f 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 d87bf76351..c2da06786c 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 9e206826e6..59c9f53d1a 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 0000000000..90ae32bebf --- /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 0000000000..ef2ef74726 --- /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 0000000000..74e4d3bf17 --- /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 0000000000..84d95ca3c9 --- /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 5041d945e2..8b156f2a17 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 6ab159b531..b9b0838cbe 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 b28fc99e4a..96ea0a98d8 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 95eb8b3c97..c19a1ad92e 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 5683c6ae15..c2df6397b4 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 5e1cce2ccf..00708c9d1f 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 1e776bd256..26775a5ce4 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 9c422818b3..b3a50a07ae 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 b57a203788..c47d75dc2a 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 8617930534..b94c5843ad 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 917df1d8fd..f66cbbaa0b 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 98c906d9c7..e6d6a523b1 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 3e5b220c04..c9535f3612 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 41c44ea476..b57a5e1072 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 6fa9b3fdd2..dfbb56bd1e 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 f5cf20413e..2c76fee877 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 fbdd655bbc..fdeabd3a90 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 779de1ee37..3984087301 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 f8d5f0ca08..7431cb3293 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 f3f807c829..94f7f231a8 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 95998eb44b..a041946a28 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 0000000000..17db0d9da3 --- /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 0000000000..21678f2a82 --- /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 0000000000..5efeac02a6 --- /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 0000000000..b57a198cc6 --- /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 6d4bbfbe36..1ac21020c5 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 6e4193c110..3478e31fe9 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 f655a3944e..94a2dcff22 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 802b46a986..6aa76dd4dc 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 44ca231d8f..17b0e232b6 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 45c34dd09e..d9d3a9f9b9 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 4f053937b8..6c094c1121 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 066cb2de4d..acba274b4f 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 c30cfcf6bd..421423a741 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 53d7c70794..65ee7be07d 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 77853f897a..6e159f1c5c 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 0000000000..2a630f1697 --- /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 60bde6e114..faa81a6e92 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 9344867a7b..118d1a52f8 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 a0390d2d4d..0000000000 --- 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 8e27d41f21..0000000000 --- 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 8ad4d884c6..0000000000 --- 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 85e7f4b1c4..0000000000 --- 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 63a42b8b96..0000000000 --- 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 832f0e4339..0000000000 --- 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 203f1c6587..2622bcece7 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 d61a534ed1..c88838b716 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 fe25ac7a46..9817870d93 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 4d1a802730..f80edb20ba 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 44b96f9644..0dd555754b 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 0000000000..b8ebea1cf0 --- /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 0000000000..109a2ee90a --- /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 d7063c29eb..19e84b071b 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 ec06c2cef5..cc764cf7ad 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 534fcd6a48..5b985e3050 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 c640fa31bb..da1826a0bc 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 9d69653439..64a6607558 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 16adb79f67..3533851ddf 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 f8fb49b61f..deccdd7d6e 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 88316ccafb..a5a59261cd 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({