diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1da4463a1225b..f42065d248eef 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -306,6 +306,7 @@ Class | Method | HTTP request | Description - [CreateLibraryDto](doc//CreateLibraryDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) - [CreateTagDto](doc//CreateTagDto.md) + - [CropOptionsDto](doc//CropOptionsDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponse](doc//DownloadResponse.md) @@ -316,6 +317,7 @@ Class | Method | HTTP request | Description - [EmailNotificationsResponse](doc//EmailNotificationsResponse.md) - [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md) - [EntityType](doc//EntityType.md) + - [ExifOrientation](doc//ExifOrientation.md) - [ExifResponseDto](doc//ExifResponseDto.md) - [FaceDto](doc//FaceDto.md) - [FacialRecognitionConfig](doc//FacialRecognitionConfig.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 05a43c8af7031..27b546e5caf49 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -121,6 +121,7 @@ part 'model/create_album_dto.dart'; part 'model/create_library_dto.dart'; part 'model/create_profile_image_response_dto.dart'; part 'model/create_tag_dto.dart'; +part 'model/crop_options_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response.dart'; @@ -131,6 +132,7 @@ part 'model/duplicate_response_dto.dart'; part 'model/email_notifications_response.dart'; part 'model/email_notifications_update.dart'; part 'model/entity_type.dart'; +part 'model/exif_orientation.dart'; part 'model/exif_response_dto.dart'; part 'model/face_dto.dart'; part 'model/facial_recognition_config.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c9ed2a508d78b..01463608243bc 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -297,6 +297,8 @@ class ApiClient { return CreateProfileImageResponseDto.fromJson(value); case 'CreateTagDto': return CreateTagDto.fromJson(value); + case 'CropOptionsDto': + return CropOptionsDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': @@ -317,6 +319,8 @@ class ApiClient { return EmailNotificationsUpdate.fromJson(value); case 'EntityType': return EntityTypeTypeTransformer().decode(value); + case 'ExifOrientation': + return ExifOrientationTypeTransformer().decode(value); case 'ExifResponseDto': return ExifResponseDto.fromJson(value); case 'FaceDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 7f46e145b15eb..ab9c43e0c9dfc 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -85,6 +85,9 @@ String parameterToString(dynamic value) { if (value is EntityType) { return EntityTypeTypeTransformer().encode(value).toString(); } + if (value is ExifOrientation) { + return ExifOrientationTypeTransformer().encode(value).toString(); + } if (value is ImageFormat) { return ImageFormatTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/crop_options_dto.dart b/mobile/openapi/lib/model/crop_options_dto.dart new file mode 100644 index 0000000000000..3e39dd8cba022 --- /dev/null +++ b/mobile/openapi/lib/model/crop_options_dto.dart @@ -0,0 +1,126 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class CropOptionsDto { + /// Returns a new [CropOptionsDto] instance. + CropOptionsDto({ + required this.height, + required this.width, + required this.x, + required this.y, + }); + + /// Minimum value: 1 + num height; + + /// Minimum value: 1 + num width; + + /// Minimum value: 1 + num x; + + /// Minimum value: 1 + num y; + + @override + bool operator ==(Object other) => identical(this, other) || other is CropOptionsDto && + other.height == height && + other.width == width && + other.x == x && + other.y == y; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (height.hashCode) + + (width.hashCode) + + (x.hashCode) + + (y.hashCode); + + @override + String toString() => 'CropOptionsDto[height=$height, width=$width, x=$x, y=$y]'; + + Map toJson() { + final json = {}; + json[r'height'] = this.height; + json[r'width'] = this.width; + json[r'x'] = this.x; + json[r'y'] = this.y; + return json; + } + + /// Returns a new [CropOptionsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static CropOptionsDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return CropOptionsDto( + height: num.parse('${json[r'height']}'), + width: num.parse('${json[r'width']}'), + x: num.parse('${json[r'x']}'), + y: num.parse('${json[r'y']}'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = CropOptionsDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = CropOptionsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of CropOptionsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = CropOptionsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'height', + 'width', + 'x', + 'y', + }; +} + diff --git a/mobile/openapi/lib/model/exif_orientation.dart b/mobile/openapi/lib/model/exif_orientation.dart new file mode 100644 index 0000000000000..ae76b09c0a3cb --- /dev/null +++ b/mobile/openapi/lib/model/exif_orientation.dart @@ -0,0 +1,103 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ExifOrientation { + /// Instantiate a new enum with the provided [value]. + const ExifOrientation._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const n1 = ExifOrientation._(r'1'); + static const n2 = ExifOrientation._(r'2'); + static const n3 = ExifOrientation._(r'3'); + static const n4 = ExifOrientation._(r'4'); + static const n5 = ExifOrientation._(r'5'); + static const n6 = ExifOrientation._(r'6'); + static const n7 = ExifOrientation._(r'7'); + static const n8 = ExifOrientation._(r'8'); + + /// List of all possible values in this [enum][ExifOrientation]. + static const values = [ + n1, + n2, + n3, + n4, + n5, + n6, + n7, + n8, + ]; + + static ExifOrientation? fromJson(dynamic value) => ExifOrientationTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ExifOrientation.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ExifOrientation] to String, +/// and [decode] dynamic data back to [ExifOrientation]. +class ExifOrientationTypeTransformer { + factory ExifOrientationTypeTransformer() => _instance ??= const ExifOrientationTypeTransformer._(); + + const ExifOrientationTypeTransformer._(); + + String encode(ExifOrientation data) => data.value; + + /// Decodes a [dynamic value][data] to a ExifOrientation. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ExifOrientation? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'1': return ExifOrientation.n1; + case r'2': return ExifOrientation.n2; + case r'3': return ExifOrientation.n3; + case r'4': return ExifOrientation.n4; + case r'5': return ExifOrientation.n5; + case r'6': return ExifOrientation.n6; + case r'7': return ExifOrientation.n7; + case r'8': return ExifOrientation.n8; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ExifOrientationTypeTransformer] instance. + static ExifOrientationTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 0185f300fac5b..38a7df2df09cc 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -15,6 +15,10 @@ class ExifResponseDto { ExifResponseDto({ this.city, this.country, + this.cropHeight, + this.cropLeft, + this.cropTop, + this.cropWidth, this.dateTimeOriginal, this.description, this.exifImageHeight, @@ -41,6 +45,14 @@ class ExifResponseDto { String? country; + num? cropHeight; + + num? cropLeft; + + num? cropTop; + + num? cropWidth; + DateTime? dateTimeOriginal; String? description; @@ -85,6 +97,10 @@ class ExifResponseDto { bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto && other.city == city && other.country == country && + other.cropHeight == cropHeight && + other.cropLeft == cropLeft && + other.cropTop == cropTop && + other.cropWidth == cropWidth && other.dateTimeOriginal == dateTimeOriginal && other.description == description && other.exifImageHeight == exifImageHeight && @@ -111,6 +127,10 @@ class ExifResponseDto { // ignore: unnecessary_parenthesis (city == null ? 0 : city!.hashCode) + (country == null ? 0 : country!.hashCode) + + (cropHeight == null ? 0 : cropHeight!.hashCode) + + (cropLeft == null ? 0 : cropLeft!.hashCode) + + (cropTop == null ? 0 : cropTop!.hashCode) + + (cropWidth == null ? 0 : cropWidth!.hashCode) + (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + (description == null ? 0 : description!.hashCode) + (exifImageHeight == null ? 0 : exifImageHeight!.hashCode) + @@ -133,7 +153,7 @@ class ExifResponseDto { (timeZone == null ? 0 : timeZone!.hashCode); @override - String toString() => 'ExifResponseDto[city=$city, country=$country, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, projectionType=$projectionType, rating=$rating, state=$state, timeZone=$timeZone]'; + String toString() => 'ExifResponseDto[city=$city, country=$country, cropHeight=$cropHeight, cropLeft=$cropLeft, cropTop=$cropTop, cropWidth=$cropWidth, dateTimeOriginal=$dateTimeOriginal, description=$description, exifImageHeight=$exifImageHeight, exifImageWidth=$exifImageWidth, exposureTime=$exposureTime, fNumber=$fNumber, fileSizeInByte=$fileSizeInByte, focalLength=$focalLength, iso=$iso, latitude=$latitude, lensModel=$lensModel, longitude=$longitude, make=$make, model=$model, modifyDate=$modifyDate, orientation=$orientation, projectionType=$projectionType, rating=$rating, state=$state, timeZone=$timeZone]'; Map toJson() { final json = {}; @@ -147,6 +167,26 @@ class ExifResponseDto { } else { // json[r'country'] = null; } + if (this.cropHeight != null) { + json[r'cropHeight'] = this.cropHeight; + } else { + // json[r'cropHeight'] = null; + } + if (this.cropLeft != null) { + json[r'cropLeft'] = this.cropLeft; + } else { + // json[r'cropLeft'] = null; + } + if (this.cropTop != null) { + json[r'cropTop'] = this.cropTop; + } else { + // json[r'cropTop'] = null; + } + if (this.cropWidth != null) { + json[r'cropWidth'] = this.cropWidth; + } else { + // json[r'cropWidth'] = null; + } if (this.dateTimeOriginal != null) { json[r'dateTimeOriginal'] = this.dateTimeOriginal!.toUtc().toIso8601String(); } else { @@ -260,6 +300,18 @@ class ExifResponseDto { return ExifResponseDto( city: mapValueOfType(json, r'city'), country: mapValueOfType(json, r'country'), + cropHeight: json[r'cropHeight'] == null + ? null + : num.parse('${json[r'cropHeight']}'), + cropLeft: json[r'cropLeft'] == null + ? null + : num.parse('${json[r'cropLeft']}'), + cropTop: json[r'cropTop'] == null + ? null + : num.parse('${json[r'cropTop']}'), + cropWidth: json[r'cropWidth'] == null + ? null + : num.parse('${json[r'cropWidth']}'), dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''), description: mapValueOfType(json, r'description'), exifImageHeight: json[r'exifImageHeight'] == null diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 391836c444bb3..294f60460c456 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -13,15 +13,25 @@ part of openapi.api; class UpdateAssetDto { /// Returns a new [UpdateAssetDto] instance. UpdateAssetDto({ + this.crop, this.dateTimeOriginal, this.description, this.isArchived, this.isFavorite, this.latitude, this.longitude, + this.orientation, this.rating, }); + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + CropOptionsDto? crop; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -70,6 +80,14 @@ class UpdateAssetDto { /// num? longitude; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + ExifOrientation? orientation; + /// Minimum value: 0 /// Maximum value: 5 /// @@ -82,30 +100,39 @@ class UpdateAssetDto { @override bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto && + other.crop == crop && other.dateTimeOriginal == dateTimeOriginal && other.description == description && other.isArchived == isArchived && other.isFavorite == isFavorite && other.latitude == latitude && other.longitude == longitude && + other.orientation == orientation && other.rating == rating; @override int get hashCode => // ignore: unnecessary_parenthesis + (crop == null ? 0 : crop!.hashCode) + (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + (description == null ? 0 : description!.hashCode) + (isArchived == null ? 0 : isArchived!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + + (orientation == null ? 0 : orientation!.hashCode) + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; + String toString() => 'UpdateAssetDto[crop=$crop, dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, orientation=$orientation, rating=$rating]'; Map toJson() { final json = {}; + if (this.crop != null) { + json[r'crop'] = this.crop; + } else { + // json[r'crop'] = null; + } if (this.dateTimeOriginal != null) { json[r'dateTimeOriginal'] = this.dateTimeOriginal; } else { @@ -136,6 +163,11 @@ class UpdateAssetDto { } else { // json[r'longitude'] = null; } + if (this.orientation != null) { + json[r'orientation'] = this.orientation; + } else { + // json[r'orientation'] = null; + } if (this.rating != null) { json[r'rating'] = this.rating; } else { @@ -152,12 +184,14 @@ class UpdateAssetDto { final json = value.cast(); return UpdateAssetDto( + crop: CropOptionsDto.fromJson(json[r'crop']), dateTimeOriginal: mapValueOfType(json, r'dateTimeOriginal'), description: mapValueOfType(json, r'description'), isArchived: mapValueOfType(json, r'isArchived'), isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), + orientation: ExifOrientation.fromJson(json[r'orientation']), rating: num.parse('${json[r'rating']}'), ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 02a887370af02..735f6cdd34a1b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8704,6 +8704,33 @@ ], "type": "object" }, + "CropOptionsDto": { + "properties": { + "height": { + "minimum": 1, + "type": "number" + }, + "width": { + "minimum": 1, + "type": "number" + }, + "x": { + "minimum": 1, + "type": "number" + }, + "y": { + "minimum": 1, + "type": "number" + } + }, + "required": [ + "height", + "width", + "x", + "y" + ], + "type": "object" + }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -8868,6 +8895,19 @@ ], "type": "string" }, + "ExifOrientation": { + "enum": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8" + ], + "type": "string" + }, "ExifResponseDto": { "properties": { "city": { @@ -8880,6 +8920,26 @@ "nullable": true, "type": "string" }, + "cropHeight": { + "default": null, + "nullable": true, + "type": "number" + }, + "cropLeft": { + "default": null, + "nullable": true, + "type": "number" + }, + "cropTop": { + "default": null, + "nullable": true, + "type": "number" + }, + "cropWidth": { + "default": null, + "nullable": true, + "type": "number" + }, "dateTimeOriginal": { "default": null, "format": "date-time", @@ -11968,6 +12028,9 @@ }, "UpdateAssetDto": { "properties": { + "crop": { + "$ref": "#/components/schemas/CropOptionsDto" + }, "dateTimeOriginal": { "type": "string" }, @@ -11986,6 +12049,9 @@ "longitude": { "type": "number" }, + "orientation": { + "$ref": "#/components/schemas/ExifOrientation" + }, "rating": { "maximum": 5, "minimum": 0, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9642f4c8171d4..e597d506a77c9 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -148,6 +148,10 @@ export type AlbumUserResponseDto = { export type ExifResponseDto = { city?: string | null; country?: string | null; + cropHeight?: number | null; + cropLeft?: number | null; + cropTop?: number | null; + cropWidth?: number | null; dateTimeOriginal?: string | null; description?: string | null; exifImageHeight?: number | null; @@ -385,13 +389,21 @@ export type AssetStatsResponseDto = { total: number; videos: number; }; +export type CropOptionsDto = { + height: number; + width: number; + x: number; + y: number; +}; export type UpdateAssetDto = { + crop?: CropOptionsDto; dateTimeOriginal?: string; description?: string; isArchived?: boolean; isFavorite?: boolean; latitude?: number; longitude?: number; + orientation?: ExifOrientation; rating?: number; }; export type AssetMediaReplaceDto = { @@ -3281,6 +3293,16 @@ export enum AssetJobName { RefreshMetadata = "refresh-metadata", TranscodeVideo = "transcode-video" } +export enum ExifOrientation { + $1 = "1", + $2 = "2", + $3 = "3", + $4 = "4", + $5 = "5", + $6 = "6", + $7 = "7", + $8 = "8" +} export enum AssetMediaSize { Preview = "preview", Thumbnail = "thumbnail" diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 5a2fdb51200d7..6014ea6bb9636 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -16,6 +16,7 @@ import { import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AssetType } from 'src/enum'; import { AssetStats } from 'src/interfaces/asset.interface'; +import { CropOptions, ExifOrientation } from 'src/interfaces/metadata.interface'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @@ -64,10 +65,40 @@ export class AssetBulkUpdateDto extends UpdateAssetBase { duplicateId?: string | null; } +export class CropOptionsDto implements CropOptions { + @IsInt() + @IsPositive() + @Type(() => Number) + x!: number; + + @IsInt() + @IsPositive() + @Type(() => Number) + y!: number; + + @IsInt() + @IsPositive() + @Type(() => Number) + width!: number; + + @IsInt() + @IsPositive() + @Type(() => Number) + height!: number; +} + export class UpdateAssetDto extends UpdateAssetBase { @Optional() @IsString() description?: string; + + @Optional() + @ApiProperty({ enumName: 'ExifOrientation', enum: ExifOrientation }) + @IsEnum(ExifOrientation) + orientation?: ExifOrientation; + + @Optional() + crop?: CropOptionsDto; } export class RandomAssetsDto { diff --git a/server/src/dtos/exif.dto.ts b/server/src/dtos/exif.dto.ts index 079891ae56cb7..0864b2f2568c7 100644 --- a/server/src/dtos/exif.dto.ts +++ b/server/src/dtos/exif.dto.ts @@ -26,6 +26,10 @@ export class ExifResponseDto { description?: string | null = null; projectionType?: string | null = null; rating?: number | null = null; + cropLeft?: number | null = null; + cropTop?: number | null = null; + cropWidth?: number | null = null; + cropHeight?: number | null = null; } export function mapExif(entity: ExifEntity): ExifResponseDto { @@ -52,6 +56,10 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { description: entity.description, projectionType: entity.projectionType, rating: entity.rating, + cropLeft: entity.cropLeft, + cropTop: entity.cropTop, + cropWidth: entity.cropWidth, + cropHeight: entity.cropHeight, }; } diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts index c9c29d732a3d9..60d4516a93c43 100644 --- a/server/src/entities/exif.entity.ts +++ b/server/src/entities/exif.entity.ts @@ -101,4 +101,17 @@ export class ExifEntity { /* Video info */ @Column({ type: 'float8', nullable: true }) fps?: number | null; + + /* User crop options */ + @Column({ type: 'integer', nullable: true }) + cropLeft!: number | null; + + @Column({ type: 'integer', nullable: true }) + cropTop!: number | null; + + @Column({ type: 'integer', nullable: true }) + cropWidth!: number | null; + + @Column({ type: 'integer', nullable: true }) + cropHeight!: number | null; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 7776d2bd370b5..87747703fe31c 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -1,3 +1,4 @@ +import { CropOptions } from 'src/interfaces/metadata.interface'; import { EmailImageAttachment } from 'src/interfaces/notification.interface'; export enum QueueName { @@ -147,7 +148,9 @@ export interface ISidecarWriteJob extends IEntityJob { dateTimeOriginal?: string; latitude?: number; longitude?: number; + orientation?: string; rating?: number; + crop?: CropOptions; } export interface IDeferrableJob extends IEntityJob { diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index f7389d3d068cd..451df398989da 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -1,21 +1,17 @@ import { Writable } from 'node:stream'; import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/config'; +import { CropOptions } from 'src/interfaces/metadata.interface'; export const IMediaRepository = 'IMediaRepository'; -export interface CropOptions { - top: number; - left: number; - width: number; - height: number; -} - export interface ThumbnailOptions { size: number; format: ImageFormat; colorspace: string; quality: number; crop?: CropOptions; + angle?: number; + mirror?: boolean; processInvalidImages: boolean; } diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 386f69a9e740c..03b240d79ed62 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -1,4 +1,4 @@ -import { BinaryField, Tags } from 'exiftool-vendored'; +import { BinaryField, Tags, WriteTags } from 'exiftool-vendored'; export const IMetadataRepository = 'IMetadataRepository'; @@ -7,6 +7,24 @@ export interface ExifDuration { Scale?: number; } +export enum ExifOrientation { + Horizontal = '1', + MirrorHorizontal = '2', + Rotate180 = '3', + MirrorVertical = '4', + MirrorHorizontalRotate270CW = '5', + Rotate90CW = '6', + MirrorHorizontalRotate90CW = '7', + Rotate270CW = '8', +} + +export interface CropOptions { + x: number; + y: number; + width: number; + height: number; +} + export interface ImmichTags extends Omit { ContentIdentifier?: string; MotionPhoto?: number; @@ -28,7 +46,7 @@ export interface ImmichTags extends Omit; readTags(path: string): Promise; - writeTags(path: string, tags: Partial): Promise; + writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; getCountries(userId: string): Promise>; getStates(userId: string, country?: string): Promise>; diff --git a/server/src/migrations/1723654247229-AddXmpCropData.ts b/server/src/migrations/1723654247229-AddXmpCropData.ts new file mode 100644 index 0000000000000..97b4cf0e54de9 --- /dev/null +++ b/server/src/migrations/1723654247229-AddXmpCropData.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddXmpCropData1723654247229 implements MigrationInterface { + name = 'AddXmpCropData1723654247229' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" ADD "cropLeft" integer`); + await queryRunner.query(`ALTER TABLE "exif" ADD "cropTop" integer`); + await queryRunner.query(`ALTER TABLE "exif" ADD "cropWidth" integer`); + await queryRunner.query(`ALTER TABLE "exif" ADD "cropHeight" integer`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "cropHeight"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "cropWidth"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "cropTop"`); + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "cropLeft"`); + } + +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index fd5dc15c0a647..1e543c3fc32a3 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -63,7 +63,11 @@ SELECT "files"."createdAt" AS "files_createdAt", "files"."updatedAt" AS "files_updatedAt", "files"."type" AS "files_type", - "files"."path" AS "files_path" + "files"."path" AS "files_path", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight" FROM "assets" "entity" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id" @@ -180,6 +184,10 @@ SELECT "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", "AssetEntity__AssetEntity_exifInfo"."rating" AS "AssetEntity__AssetEntity_exifInfo_rating", "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", + "AssetEntity__AssetEntity_exifInfo"."cropLeft" AS "AssetEntity__AssetEntity_exifInfo_cropLeft", + "AssetEntity__AssetEntity_exifInfo"."cropTop" AS "AssetEntity__AssetEntity_exifInfo_cropTop", + "AssetEntity__AssetEntity_exifInfo"."cropWidth" AS "AssetEntity__AssetEntity_exifInfo_cropWidth", + "AssetEntity__AssetEntity_exifInfo"."cropHeight" AS "AssetEntity__AssetEntity_exifInfo_cropHeight", "AssetEntity__AssetEntity_smartInfo"."assetId" AS "AssetEntity__AssetEntity_smartInfo_assetId", "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags", "AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects", @@ -633,6 +641,10 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", "stack"."primaryAssetId" AS "stack_primaryAssetId", @@ -779,6 +791,10 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", "stack"."primaryAssetId" AS "stack_primaryAssetId", @@ -900,6 +916,10 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", "stack"."primaryAssetId" AS "stack_primaryAssetId", @@ -1073,6 +1093,10 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", "stack"."primaryAssetId" AS "stack_primaryAssetId" @@ -1155,6 +1179,10 @@ SELECT "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", "exifInfo"."rating" AS "exifInfo_rating", "exifInfo"."fps" AS "exifInfo_fps", + "exifInfo"."cropLeft" AS "exifInfo_cropLeft", + "exifInfo"."cropTop" AS "exifInfo_cropTop", + "exifInfo"."cropWidth" AS "exifInfo_cropWidth", + "exifInfo"."cropHeight" AS "exifInfo_cropHeight", "stack"."id" AS "stack_id", "stack"."ownerId" AS "stack_ownerId", "stack"."primaryAssetId" AS "stack_primaryAssetId" diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 9c94232d20857..82475e9c94e8b 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -319,7 +319,11 @@ FROM "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", "AssetEntity__AssetEntity_exifInfo"."rating" AS "AssetEntity__AssetEntity_exifInfo_rating", - "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps" + "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", + "AssetEntity__AssetEntity_exifInfo"."cropLeft" AS "AssetEntity__AssetEntity_exifInfo_cropLeft", + "AssetEntity__AssetEntity_exifInfo"."cropTop" AS "AssetEntity__AssetEntity_exifInfo_cropTop", + "AssetEntity__AssetEntity_exifInfo"."cropWidth" AS "AssetEntity__AssetEntity_exifInfo_cropWidth", + "AssetEntity__AssetEntity_exifInfo"."cropHeight" AS "AssetEntity__AssetEntity_exifInfo_cropHeight" FROM "assets" "AssetEntity" LEFT JOIN "asset_faces" "AssetEntity__AssetEntity_faces" ON "AssetEntity__AssetEntity_faces"."assetId" = "AssetEntity"."id" diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index e9e94400ad454..bff1fbe272fae 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -393,7 +393,11 @@ SELECT "exif"."colorspace" AS "exif_colorspace", "exif"."bitsPerSample" AS "exif_bitsPerSample", "exif"."rating" AS "exif_rating", - "exif"."fps" AS "exif_fps" + "exif"."fps" AS "exif_fps", + "exif"."cropLeft" AS "exif_cropLeft", + "exif"."cropTop" AS "exif_cropTop", + "exif"."cropWidth" AS "exif_cropWidth", + "exif"."cropHeight" AS "exif_cropHeight" FROM "assets" "asset" INNER JOIN "exif" "exif" ON "exif"."assetId" = "asset"."id" diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 10af8d17dbddb..1c2fa896074c1 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -77,6 +77,10 @@ FROM "9b1d35b344d838023994a3233afd6ffe098be6d8"."bitsPerSample" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_bitsPerSample", "9b1d35b344d838023994a3233afd6ffe098be6d8"."rating" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_rating", "9b1d35b344d838023994a3233afd6ffe098be6d8"."fps" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_fps", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."cropLeft" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_cropLeft", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."cropTop" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_cropTop", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."cropWidth" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_cropWidth", + "9b1d35b344d838023994a3233afd6ffe098be6d8"."cropHeight" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_cropHeight", "SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id", "SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId", "SharedLinkEntity__SharedLinkEntity_album"."albumName" AS "SharedLinkEntity__SharedLinkEntity_album_albumName", @@ -143,6 +147,10 @@ FROM "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."bitsPerSample" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_bitsPerSample", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."rating" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_rating", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."fps" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_fps", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."cropLeft" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_cropLeft", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."cropTop" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_cropTop", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."cropWidth" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_cropWidth", + "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."cropHeight" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_cropHeight", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."isAdmin" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_isAdmin", diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index a84ef6f596f4e..92252f118f0b3 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -46,12 +46,26 @@ export class MediaRepository implements IMediaRepository { async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise { // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes - const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false }) - .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') - .rotate(); + let pipeline = sharp(input, { + failOn: options.processInvalidImages ? 'none' : 'error', + limitInputPixels: false, + }).pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16'); if (options.crop) { - pipeline.extract(options.crop); + const { x, y, width, height } = options.crop; + pipeline.extract({ left: x, top: y, width, height }); + } + + pipeline = sharp(await pipeline.toBuffer()); + + if (options.mirror) { + pipeline.flop(); + } + + if (options.angle) { + pipeline.rotate(options.angle); + } else { + pipeline.rotate(); // auto-rotate based on EXIF orientation } await pipeline diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 832cffbee6ae1..cfc1565eab8cc 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; +import { DefaultReadTaskOptions, ExifTool, WriteTags } from 'exiftool-vendored'; import geotz from 'geo-tz'; import { DummyValue, GenerateSql } from 'src/decorators'; import { ExifEntity } from 'src/entities/exif.entity'; @@ -47,7 +47,7 @@ export class MetadataRepository implements IMetadataRepository { return this.exiftool.extractBinaryTagToBuffer(tagName, path); } - async writeTags(path: string, tags: Partial): Promise { + async writeTags(path: string, tags: Partial): Promise { try { await this.exiftool.write(path, tags); } catch (error) { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index bfd3a0c4d26b5..8ab47120666ef 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -158,8 +158,8 @@ export class AssetService { async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); - const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; - await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); + const { description, dateTimeOriginal, latitude, longitude, orientation, rating, crop, ...rest } = dto; + await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, orientation, rating, crop }); await this.assetRepository.update({ id, ...rest }); const asset = await this.assetRepository.getById(id, { @@ -319,8 +319,19 @@ export class AssetService { } private async updateMetadata(dto: ISidecarWriteJob) { - const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; - const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); + const { id, description, dateTimeOriginal, latitude, longitude, orientation, rating, crop } = dto; + const writes = _.omitBy( + { + description, + dateTimeOriginal, + latitude, + longitude, + orientation, + rating, + crop, + }, + _.isUndefined, + ); if (Object.keys(writes).length > 0) { await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 634cd790ebd0f..dd8e3473fab35 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -297,6 +297,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.SRGB, processInvalidImages: false, + angle: 0, + mirror: false, }); expect(assetMock.upsertFile).toHaveBeenCalledWith({ assetId: 'asset-id', @@ -330,6 +332,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ); expect(assetMock.upsertFile).toHaveBeenCalledWith({ @@ -476,6 +480,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.SRGB, processInvalidImages: false, + angle: 0, + mirror: false, }); expect(assetMock.upsertFile).toHaveBeenCalledWith({ assetId: 'asset-id', @@ -509,6 +515,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ); expect(assetMock.upsertFile).toHaveBeenCalledWith({ @@ -537,6 +545,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ], ]); @@ -562,6 +572,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ], ]); @@ -585,6 +597,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ); expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); @@ -606,6 +620,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: false, + angle: 0, + mirror: false, }, ); expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); @@ -627,6 +643,8 @@ describe(MediaService.name, () => { quality: 80, colorspace: Colorspace.P3, processInvalidImages: true, + angle: 0, + mirror: false, }, ); expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index b48d00a7a8180..a66dc443b0dbc 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; +import _ from 'lodash'; import { dirname } from 'node:path'; import { AudioCodec, @@ -30,6 +31,7 @@ import { } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AudioStreamInfo, IMediaRepository, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { ExifOrientation } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -219,12 +221,20 @@ export class MediaService { try { const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize)); const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const { cropLeft, cropTop, cropWidth, cropHeight } = asset.exifInfo ?? {}; + const crop = _.every([cropLeft, cropTop, cropWidth, cropHeight], _.isNumber) + ? { x: cropLeft!, y: cropTop!, width: cropWidth!, height: cropHeight! } + : undefined; const imageOptions = { format, size, colorspace, quality: image.quality, processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', + ...this.decodeExifOrientation( + (asset.exifInfo?.orientation as ExifOrientation) ?? ExifOrientation.Horizontal, + ), + crop, }; const outputPath = useExtracted ? extractedPath : asset.originalPath; @@ -507,6 +517,40 @@ export class MediaService { } } + private decodeExifOrientation(orientation: ExifOrientation) { + let angle = 0; + switch (orientation) { + case ExifOrientation.Rotate180: + case ExifOrientation.MirrorVertical: { + angle = 180; + break; + } + case ExifOrientation.Rotate90CW: + case ExifOrientation.MirrorHorizontalRotate90CW: { + angle = 90; + break; + } + case ExifOrientation.MirrorHorizontalRotate270CW: + case ExifOrientation.Rotate270CW: { + angle = 270; + break; + } + case ExifOrientation.Horizontal: + case ExifOrientation.MirrorHorizontal: { + angle = 0; + break; + } + } + const mirror = [ + ExifOrientation.MirrorHorizontal, + ExifOrientation.MirrorVertical, + ExifOrientation.MirrorHorizontalRotate90CW, + ExifOrientation.MirrorHorizontalRotate270CW, + ].includes(orientation); + + return { angle, mirror }; + } + private parseBitrateToBps(bitrateString: string) { const bitrateValue = Number.parseInt(bitrateString); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 6585b8c2ee0cc..29d127e7306c2 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -13,13 +13,13 @@ import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; -import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; +import { ExifOrientation, IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { MetadataService, Orientation } from 'src/services/metadata.service'; +import { MetadataService } from 'src/services/metadata.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; @@ -379,7 +379,7 @@ describe(MetadataService.name, () => { expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ orientation: Orientation.Rotate270CW }), + expect.objectContaining({ orientation: ExifOrientation.Rotate270CW }), ); }); @@ -641,6 +641,10 @@ describe(MetadataService.name, () => { bitsPerSample: expect.any(Number), autoStackId: null, colorspace: tags.ColorSpace, + cropHeight: null, + cropLeft: null, + cropTop: null, + cropWidth: null, dateTimeOriginal: new Date('1970-01-01'), description: tags.ImageDescription, exifImageHeight: null, @@ -957,6 +961,7 @@ describe(MetadataService.name, () => { const description = 'this is a description'; const gps = 12; const date = '2023-11-22T04:56:12.196Z'; + const orientation = 6; assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); await expect( @@ -966,6 +971,7 @@ describe(MetadataService.name, () => { latitude: gps, longitude: gps, dateTimeOriginal: date, + orientation: orientation.toString(), }), ).resolves.toBe(JobStatus.SUCCESS); expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { @@ -974,6 +980,7 @@ describe(MetadataService.name, () => { DateTimeOriginal: date, GPSLatitude: gps, GPSLongitude: gps, + 'Orientation#': orientation, }); }); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index dcdf07b8c3f1a..5483e89b52de2 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored'; +import { ContainerDirectoryItem, ExifDateTime, Tags, WriteTags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import _ from 'lodash'; import { Duration } from 'luxon'; @@ -30,7 +30,7 @@ import { import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; -import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; +import { ExifOrientation, IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -50,17 +50,6 @@ const EXIF_DATE_TAGS: Array = [ 'DateTimeCreated', ]; -export enum Orientation { - Horizontal = '1', - MirrorHorizontal = '2', - Rotate180 = '3', - MirrorVertical = '4', - MirrorHorizontalRotate270CW = '5', - Rotate90CW = '6', - MirrorHorizontalRotate90CW = '7', - Rotate270CW = '8', -} - type ExifEntityWithoutGeocodeAndTypeOrm = Omit & { dateTimeOriginal: Date; }; @@ -279,21 +268,27 @@ export class MetadataService { } async handleSidecarWrite(job: ISidecarWriteJob): Promise { - const { id, description, dateTimeOriginal, latitude, longitude, rating } = job; + const { id, description, dateTimeOriginal, latitude, longitude, orientation, rating, crop } = job; const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { return JobStatus.FAILED; } const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; - const exif = _.omitBy( + this.logger.warn(`Writing sidecar for asset ${asset.id} with ${JSON.stringify(job)}`); + const exif = _.omitBy( { Description: description, ImageDescription: description, DateTimeOriginal: dateTimeOriginal, GPSLatitude: latitude, GPSLongitude: longitude, + 'Orientation#': _.isUndefined(orientation) ? undefined : Number.parseInt(orientation ?? '1', 10), Rating: rating, + CropLeft: crop?.x?.toString(), + CropTop: crop?.y.toString(), + CropWidth: crop?.width, + CropHeight: crop?.height, }, _.isUndefined, ); @@ -488,6 +483,10 @@ export class MetadataService { assetId: asset.id, bitsPerSample: this.getBitsPerSample(tags), colorspace: tags.ColorSpace ?? null, + cropLeft: _.isUndefined(tags.CropLeft) ? null : Number.parseInt(tags.CropLeft), + cropTop: _.isUndefined(tags.CropTop) ? null : Number.parseInt(tags.CropTop), + cropWidth: validate(tags.CropWidth), + cropHeight: validate(tags.CropHeight), dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt, description: String(tags.ImageDescription || tags.Description || '').trim(), exifImageHeight: validate(tags.ImageHeight), @@ -560,19 +559,19 @@ export class MetadataService { if (videoStreams[0]) { switch (videoStreams[0].rotation) { case -90: { - exifData.orientation = Orientation.Rotate90CW; + exifData.orientation = ExifOrientation.Rotate90CW; break; } case 0: { - exifData.orientation = Orientation.Horizontal; + exifData.orientation = ExifOrientation.Horizontal; break; } case 90: { - exifData.orientation = Orientation.Rotate270CW; + exifData.orientation = ExifOrientation.Rotate270CW; break; } case 180: { - exifData.orientation = Orientation.Rotate180; + exifData.orientation = ExifOrientation.Rotate180; break; } } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 6f2283b72c6e8..debcb63202ad7 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -43,7 +43,8 @@ import { } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { BoundingBox, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { CropOptions, IMediaRepository, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; +import { IMediaRepository, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; +import { CropOptions } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; @@ -693,8 +694,8 @@ export class PersonService { ); return { - left: middleX - newHalfSize, - top: middleY - newHalfSize, + x: middleX - newHalfSize, + y: middleY - newHalfSize, width: newHalfSize * 2, height: newHalfSize * 2, }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 9ea252b5f7ec3..ca2e153a4cd7c 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -251,6 +251,10 @@ export const sharedLinkStub = { colorspace: 'sRGB', autoStackId: null, rating: 3, + cropLeft: 100, + cropTop: 100, + cropWidth: 600, + cropHeight: 400, }, tags: [], sharedLinks: [], diff --git a/web/src/lib/components/asset-viewer/actions/action.ts b/web/src/lib/components/asset-viewer/actions/action.ts index d6136f2d1867e..d9194f4c23dbe 100644 --- a/web/src/lib/components/asset-viewer/actions/action.ts +++ b/web/src/lib/components/asset-viewer/actions/action.ts @@ -12,6 +12,7 @@ type ActionMap = { [AssetAction.ADD]: { asset: AssetResponseDto }; [AssetAction.ADD_TO_ALBUM]: { asset: AssetResponseDto; album: AlbumResponseDto }; [AssetAction.UNSTACK]: { assets: AssetResponseDto[] }; + [AssetAction.ROTATE]: { asset: AssetResponseDto }; }; export type Action = { diff --git a/web/src/lib/components/asset-viewer/actions/rotate-action.svelte b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte new file mode 100644 index 0000000000000..f83a797cc0fa2 --- /dev/null +++ b/web/src/lib/components/asset-viewer/actions/rotate-action.svelte @@ -0,0 +1,51 @@ + + + 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 a57a7faef8a51..3f2f58b4d64d1 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -7,6 +7,7 @@ import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte'; import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte'; import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte'; + import RotateAction from '$lib/components/asset-viewer/actions/rotate-action.svelte'; import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte'; import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte'; import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte'; @@ -15,6 +16,7 @@ import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; + import { ProjectionType } from '$lib/constants'; import { user } from '$lib/stores/user.store'; import { photoZoomState } from '$lib/stores/zoom-image.store'; import { getAssetJobName, getSharedLink } from '$lib/utils'; @@ -32,6 +34,7 @@ mdiContentCopy, mdiDatabaseRefreshOutline, mdiDotsVertical, + mdiImageEditOutline, mdiImageRefreshOutline, mdiMagnifyMinusOutline, mdiMagnifyPlusOutline, @@ -52,22 +55,23 @@ export let onRunJob: (name: AssetJobName) => void; export let onPlaySlideshow: () => void; export let onShowDetail: () => void; - // export let showEditorHandler: () => void; + export let showEditorHandler: () => void; export let onClose: () => void; + export let refreshEditedAsset: () => Promise; const sharedLink = getSharedLink(); $: isOwner = $user && asset.ownerId === $user?.id; $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; - // $: showEditorButton = - // isOwner && - // asset.type === AssetTypeEnum.Image && - // !( - // asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || - // (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) - // ) && - // !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && - // !asset.livePhotoVideoId; + $: showEditorButton = + isOwner && + asset.type === AssetTypeEnum.Image && + !( + asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || + (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp')) + ) && + !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) && + !asset.livePhotoVideoId;
{/if} - + {/if} {#if isOwner} @@ -141,6 +145,10 @@ {/if} {#if isOwner} + {#if !asset.isTrashed} + + + {/if} {#if stack} {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 3ed955848b347..cabfd00c981f8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -8,7 +8,7 @@ import { AssetAction, ProjectionType } from '$lib/constants'; import { updateNumberOfComments } from '$lib/stores/activity.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import type { AssetStore } from '$lib/stores/assets.store'; + import { type AssetStore, photoViewer } from '$lib/stores/assets.store'; import { isShowDetail } from '$lib/stores/preferences.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { user } from '$lib/stores/user.store'; @@ -48,7 +48,7 @@ import VideoViewer from './video-wrapper-viewer.svelte'; import EditorPanel from './editor/editor-panel.svelte'; import CropArea from './editor/crop-tool/crop-area.svelte'; - import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; + import { applyChanges, closeEditorConfirm as closeEditorConfirm } from '$lib/stores/asset-editor.store'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; export let preloadAssets: AssetResponseDto[] = []; @@ -272,11 +272,18 @@ }; const closeEditor = () => { - closeEditorCofirm(() => { + closeEditorConfirm(() => { isShowEditor = false; }); }; + const closeEditorAfterSave = async () => { + await applyChanges(asset, () => {}); + await new Promise((resolve) => setTimeout(resolve, 500)); // TODO: otherwise it doesn't work + isShowEditor = false; + await refreshEditedAsset(); + }; + const navigateAssetRandom = async () => { if (!assetStore) { return; @@ -320,12 +327,12 @@ dispatch(order); }; - // const showEditorHandler = () => { - // if (isShowActivity) { - // isShowActivity = false; - // } - // isShowEditor = !isShowEditor; - // }; + const showEditorHandler = () => { + if (isShowActivity) { + isShowActivity = false; + } + isShowEditor = !isShowEditor; + }; const handleRunJob = async (name: AssetJobName) => { try { @@ -395,6 +402,17 @@ onAction?.(action); }; + const refreshEditedAsset = async () => { + await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name: AssetJobName.RegenerateThumbnail } }); + setTimeout(() => { + // force the image to refresh the thumbnail + // TODO: this is not reliable. Ultimately the new files table will be used. + const oldSrc = new URL($photoViewer!.src); + oldSrc.searchParams.set('t', Date.now().toString()); + $photoViewer!.src = oldSrc.toString(); + }, 500); + }; + let selectedEditType: string = ''; function handleUpdateSelectedEditType(type: string) { @@ -417,6 +435,8 @@ {album} {stack} showDetailButton={enableDetailPanel} + {showEditorHandler} + {refreshEditedAsset} showSlideshow={!!assetStore} onZoomImage={zoomToggle} onCopyImage={copyImage} @@ -558,7 +578,12 @@ class="z-[1002] row-start-1 row-span-4 w-[400px] overflow-y-auto bg-immich-bg transition-all dark:border-l dark:border-l-immich-dark-gray dark:bg-immich-dark-bg" translate="yes" > - +
{/if} diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte index c35fd915197aa..9c26786172c7c 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-area.svelte @@ -17,9 +17,12 @@ resetGlobalCropStore, rotateDegrees, } from '$lib/stores/asset-editor.store'; + import type { AssetResponseDto } from '@immich/sdk'; + import _ from 'lodash'; - export let asset; + export let asset: AssetResponseDto; let img: HTMLImageElement; + let resizeObserver: ResizeObserver; $: imgElement.set(img); @@ -40,15 +43,27 @@ img.src = getAssetOriginalUrl({ id: asset.id, checksum: asset.checksum }); - img.addEventListener('load', () => onImageLoad(true)); + const { cropLeft, cropTop, cropWidth, cropHeight } = asset.exifInfo ?? {}; + const crop = _.every([cropLeft, cropTop, cropWidth, cropHeight], _.isNumber) + ? { x: cropLeft!, y: cropTop!, width: cropWidth!, height: cropHeight! } + : null; + + img.addEventListener('load', () => onImageLoad({ crop })); img.addEventListener('error', (error) => { handleError(error, $t('error_loading_image')); }); window.addEventListener('mousemove', handleMouseMove); + + resizeObserver = new ResizeObserver(() => { + resizeCanvas(); + }); + + resizeObserver.observe(document.body); }); onDestroy(() => { + resizeObserver.unobserve(document.body); window.removeEventListener('mousemove', handleMouseMove); resetCropStore(); resetGlobalCropStore(); diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts index a0390d2d4d47e..329ff7e9708ec 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-settings.ts @@ -1,14 +1,15 @@ -import type { CropAspectRatio, CropSettings } from '$lib/stores/asset-editor.store'; +import type { CropAspectRatio } from '$lib/stores/asset-editor.store'; +import type { CropOptionsDto } from '@immich/sdk'; import { get } from 'svelte/store'; import { cropAreaEl } from './crop-store'; import { checkEdits } from './mouse-handlers'; export function recalculateCrop( - crop: CropSettings, + crop: CropOptionsDto, canvas: HTMLElement, aspectRatio: CropAspectRatio, returnNewCrop = false, -): CropSettings | null { +): CropOptionsDto | null { const canvasW = canvas.clientWidth; const canvasH = canvas.clientHeight; @@ -52,7 +53,7 @@ export function recalculateCrop( } } -export function animateCropChange(crop: CropSettings, newCrop: CropSettings, draw: () => void, duration = 100) { +export function animateCropChange(crop: CropOptionsDto, newCrop: CropOptionsDto, draw: () => void, duration = 100) { const cropArea = get(cropAreaEl); if (!cropArea) { return; diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte index dba3be5d671ff..17a20fd098436 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte @@ -6,7 +6,7 @@ cropImageSize, cropSettings, cropSettingsChanged, - normaizedRorateDegrees, + normalizedRotateDegrees, rotateDegrees, type CropAspectRatio, } from '$lib/stores/asset-editor.store'; @@ -16,7 +16,7 @@ import { tick } from 'svelte'; import CropPreset from './crop-preset.svelte'; - $: rotateHorizontal = [90, 270].includes($normaizedRorateDegrees); + $: rotateHorizontal = [90, 270].includes($normalizedRotateDegrees); const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`; const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`; const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`; diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts index 85e7f4b1c408d..cbb825f4dd960 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/drawing.ts @@ -1,8 +1,8 @@ -import type { CropSettings } from '$lib/stores/asset-editor.store'; +import type { CropOptionsDto } from '@immich/sdk'; import { get } from 'svelte/store'; import { cropFrame, overlayEl } from './crop-store'; -export function draw(crop: CropSettings) { +export function draw(crop: CropOptionsDto) { const mCropFrame = get(cropFrame); if (!mCropFrame) { @@ -17,7 +17,7 @@ export function draw(crop: CropSettings) { drawOverlay(crop); } -export function drawOverlay(crop: CropSettings) { +export function drawOverlay(crop: CropOptionsDto) { const overlay = get(overlayEl); if (!overlay) { return; diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts index bce90efd9e1f7..0f8c778d5368a 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/image-loading.ts @@ -1,9 +1,10 @@ -import { cropImageScale, cropImageSize, cropSettings, type CropSettings } from '$lib/stores/asset-editor.store'; +import { cropImageScale, cropImageSize, cropSettings } from '$lib/stores/asset-editor.store'; +import type { CropOptionsDto } from '@immich/sdk'; import { get } from 'svelte/store'; import { cropAreaEl, cropFrame, imgElement } from './crop-store'; import { draw } from './drawing'; -export function onImageLoad(resetSize: boolean = false) { +export function onImageLoad(resetSize: { crop: CropOptionsDto | null } | null = null) { const img = get(imgElement); const cropArea = get(cropAreaEl); @@ -19,11 +20,12 @@ export function onImageLoad(resetSize: boolean = false) { cropImageSize.set([img.width, img.height]); if (resetSize) { + const rect = resetSize.crop ?? { x: 0, y: 0, width: img.width, height: img.height }; cropSettings.update((crop) => { - crop.x = 0; - crop.y = 0; - crop.width = img.width * scale; - crop.height = img.height * scale; + crop.x = rect.x * scale; + crop.y = rect.y * scale; + crop.width = rect.width * scale; + crop.height = rect.height * scale; return crop; }); } else { @@ -62,7 +64,7 @@ export function calculateScale(img: HTMLImageElement, containerWidth: number, co return scale; } -export function normalizeCropArea(crop: CropSettings, img: HTMLImageElement, scale: number) { +export function normalizeCropArea(crop: CropOptionsDto, img: HTMLImageElement, scale: number) { const prevScale = get(cropImageScale); const scaleRatio = scale / prevScale; @@ -91,6 +93,8 @@ export function resizeCanvas() { const containerHeight = cropArea?.clientHeight ?? 0; const imageAspectRatio = img.width / img.height; + console.log('resizeCanvas', containerWidth, containerHeight, imageAspectRatio, img.width, img.height); + let scale; if (imageAspectRatio > 1) { scale = containerWidth / img.width; @@ -113,5 +117,19 @@ export function resizeCanvas() { cropFrame.style.height = `${img.height * scale}px`; } + const oldScale = get(cropImageScale); + + const factor = scale / oldScale; + + cropSettings.update((crop) => { + crop.x *= factor; + crop.y *= factor; + crop.width *= factor; + crop.height *= factor; + return crop; + }); + + cropImageScale.set(scale); + draw(get(cropSettings)); } diff --git a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts index 656fd09294abb..2d92f42ee9698 100644 --- a/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts +++ b/web/src/lib/components/asset-viewer/editor/crop-tool/mouse-handlers.ts @@ -4,11 +4,11 @@ import { cropImageSize, cropSettings, cropSettingsChanged, - normaizedRorateDegrees, + normalizedRotateDegrees, rotateDegrees, showCancelConfirmDialog, - type CropSettings, } from '$lib/stores/asset-editor.store'; +import type { CropOptionsDto } from '@immich/sdk'; import { get } from 'svelte/store'; import { adjustDimensions, keepAspectRatio } from './crop-settings'; import { @@ -89,7 +89,7 @@ function getMousePosition(e: MouseEvent) { let offsetX = e.clientX; let offsetY = e.clientY; const clienRect = getBoundingClientRectCached(get(cropAreaEl)); - const rotateDeg = get(normaizedRorateDegrees); + const rotateDeg = get(normalizedRotateDegrees); if (rotateDeg == 90) { offsetX = e.clientY - (clienRect?.top ?? 0); @@ -125,7 +125,7 @@ function getBoundingClientRectCached(el: HTMLElement | null) { return getBoundingClientRectCache.data; } -function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) { +function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropOptionsDto) { const { x, y, width, height } = crop; const sensitivity = 10; const cornerSensitivity = 15; @@ -184,7 +184,7 @@ function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) { }; } -function isInCropArea(mouseX: number, mouseY: number, crop: CropSettings) { +function isInCropArea(mouseX: number, mouseY: number, crop: CropOptionsDto) { const { x, y, width, height } = crop; return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height; } @@ -429,7 +429,7 @@ function updateCursor(mouseX: number, mouseY: number) { } const crop = get(cropSettings); - const rotateDeg = get(normaizedRorateDegrees); + const rotateDeg = get(normalizedRotateDegrees); let { onLeftBoundary, diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte index 1adef3273502d..76045eceb7cb5 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -5,9 +5,10 @@ import { onMount } from 'svelte'; import CircleIconButton from '../../elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; - import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store'; + import { editTypes, showCancelConfirmDialog, hasLossyChanges, applyChanges } from '$lib/stores/asset-editor.store'; import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import { shortcut } from '$lib/actions/shortcut'; + import { get } from 'svelte/store'; export let asset: AssetResponseDto; @@ -21,6 +22,7 @@ export let onUpdateSelectedType: (type: string) => void; export let onClose: () => void; + export let onSave: () => Promise; let selectedType: string = editTypes[0].name; $: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0]; @@ -37,9 +39,18 @@
-
- -

{$t('editor')}

+
+
+ +

{$t('editor')}

+
+
    diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 3919033e4af98..db93ddbcf35f4 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -154,7 +154,11 @@
{:else if !imageError} -
+
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} ({ x: 0, y: 0, width: 100, height: 100 }); +export const cropSettings = writable({ x: 0, y: 0, width: 100, height: 100 }); export const cropImageSize = writable([1000, 1000]); export const cropImageScale = writable(1); export const cropAspectRatio = writable('free'); export const cropSettingsChanged = writable(false); //---------rotate export const rotateDegrees = writable(0); -export const normaizedRorateDegrees = derived(rotateDegrees, (v) => { +export const normalizedRotateDegrees = derived(rotateDegrees, (v) => { const newAngle = v % 360; return newAngle < 0 ? newAngle + 360 : newAngle; }); -export const changedOriention = derived(normaizedRorateDegrees, () => get(normaizedRorateDegrees) % 180 > 0); +export const changedOriention = derived(normalizedRotateDegrees, () => get(normalizedRotateDegrees) % 180 > 0); //-----other export const showCancelConfirmDialog = writable(false); @@ -24,10 +25,18 @@ export const editTypes = [ icon: mdiCropRotate, component: CropTool, changesFlag: cropSettingsChanged, + lossless: true, + async apply(asset: AssetResponseDto) { + const crop = get(cropSettings); + const scale = get(cropImageScale); + const [x, y, width, height] = [crop.x, crop.y, crop.width, crop.height].map((v) => Math.round(v / scale)); + await updateAsset({ id: asset.id, updateAssetDto: { crop: { x, y, width, height } } }); + asset.exifInfo = { ...asset.exifInfo, cropLeft: x, cropTop: y, cropWidth: width, cropHeight: height }; + }, }, ]; -export function closeEditorCofirm(closeCallback: CallableFunction) { +export function closeEditorConfirm(closeCallback: CallableFunction) { if (get(hasChanges)) { showCancelConfirmDialog.set(closeCallback); } else { @@ -42,6 +51,28 @@ export const hasChanges = derived( }, ); +export const hasLossyChanges = derived( + editTypes.filter((t) => !t.lossless).map((t) => t.changesFlag), + ($flags) => { + return $flags.some(Boolean); + }, +); + +export async function applyChanges(asset: AssetResponseDto, closeCallback: CallableFunction) { + if (get(hasLossyChanges)) { + closeCallback(); + return; // not supported yet + } + + for (const type of editTypes) { + if (get(type.changesFlag)) { + await type.apply(asset); + } + } + + setTimeout(closeCallback, 1000); +} + export function resetGlobalCropStore() { cropSettings.set({ x: 0, y: 0, width: 100, height: 100 }); cropImageSize.set([1000, 1000]); @@ -64,10 +95,3 @@ export type CropAspectRatio = | '5:7' | 'free' | 'reset'; - -export type CropSettings = { - x: number; - y: number; - width: number; - height: number; -};