diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 89a4fb8e3b062..3231e49cd5f4f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -129,6 +129,7 @@ Class | Method | HTTP request | Description *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | +*EditorApi* | [**createAssetFromEdits**](doc//EditorApi.md#createassetfromedits) | **POST** /editor | *FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | *FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} | *FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix | @@ -314,6 +315,14 @@ Class | Method | HTTP request | Description - [DownloadUpdate](doc//DownloadUpdate.md) - [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md) - [DuplicateResponseDto](doc//DuplicateResponseDto.md) + - [EditorActionAdjust](doc//EditorActionAdjust.md) + - [EditorActionBlur](doc//EditorActionBlur.md) + - [EditorActionCrop](doc//EditorActionCrop.md) + - [EditorActionRotate](doc//EditorActionRotate.md) + - [EditorActionType](doc//EditorActionType.md) + - [EditorCreateAssetDto](doc//EditorCreateAssetDto.md) + - [EditorCreateAssetDtoEditsInner](doc//EditorCreateAssetDtoEditsInner.md) + - [EditorCropRegion](doc//EditorCropRegion.md) - [EmailNotificationsResponse](doc//EmailNotificationsResponse.md) - [EmailNotificationsUpdate](doc//EmailNotificationsUpdate.md) - [EntityType](doc//EntityType.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e7aaf38de70ca..c2acad3af93df 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -38,6 +38,7 @@ part 'api/authentication_api.dart'; part 'api/deprecated_api.dart'; part 'api/download_api.dart'; part 'api/duplicates_api.dart'; +part 'api/editor_api.dart'; part 'api/faces_api.dart'; part 'api/file_reports_api.dart'; part 'api/jobs_api.dart'; @@ -125,6 +126,14 @@ part 'model/download_response_dto.dart'; part 'model/download_update.dart'; part 'model/duplicate_detection_config.dart'; part 'model/duplicate_response_dto.dart'; +part 'model/editor_action_adjust.dart'; +part 'model/editor_action_blur.dart'; +part 'model/editor_action_crop.dart'; +part 'model/editor_action_rotate.dart'; +part 'model/editor_action_type.dart'; +part 'model/editor_create_asset_dto.dart'; +part 'model/editor_create_asset_dto_edits_inner.dart'; +part 'model/editor_crop_region.dart'; part 'model/email_notifications_response.dart'; part 'model/email_notifications_update.dart'; part 'model/entity_type.dart'; diff --git a/mobile/openapi/lib/api/editor_api.dart b/mobile/openapi/lib/api/editor_api.dart new file mode 100644 index 0000000000000..3236a0ccf2a94 --- /dev/null +++ b/mobile/openapi/lib/api/editor_api.dart @@ -0,0 +1,65 @@ +// +// 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 EditorApi { + EditorApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'POST /editor' operation and returns the [Response]. + /// Parameters: + /// + /// * [EditorCreateAssetDto] editorCreateAssetDto (required): + Future createAssetFromEditsWithHttpInfo(EditorCreateAssetDto editorCreateAssetDto,) async { + // ignore: prefer_const_declarations + final path = r'/editor'; + + // ignore: prefer_final_locals + Object? postBody = editorCreateAssetDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [EditorCreateAssetDto] editorCreateAssetDto (required): + Future createAssetFromEdits(EditorCreateAssetDto editorCreateAssetDto,) async { + final response = await createAssetFromEditsWithHttpInfo(editorCreateAssetDto,); + 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), 'AssetResponseDto',) as AssetResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4fe810b886e47..a804934fe8924 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -308,6 +308,22 @@ class ApiClient { return DuplicateDetectionConfig.fromJson(value); case 'DuplicateResponseDto': return DuplicateResponseDto.fromJson(value); + case 'EditorActionAdjust': + return EditorActionAdjust.fromJson(value); + case 'EditorActionBlur': + return EditorActionBlur.fromJson(value); + case 'EditorActionCrop': + return EditorActionCrop.fromJson(value); + case 'EditorActionRotate': + return EditorActionRotate.fromJson(value); + case 'EditorActionType': + return EditorActionTypeTypeTransformer().decode(value); + case 'EditorCreateAssetDto': + return EditorCreateAssetDto.fromJson(value); + case 'EditorCreateAssetDtoEditsInner': + return EditorCreateAssetDtoEditsInner.fromJson(value); + case 'EditorCropRegion': + return EditorCropRegion.fromJson(value); case 'EmailNotificationsResponse': return EmailNotificationsResponse.fromJson(value); case 'EmailNotificationsUpdate': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 04fcaa3463e48..011f57fb98609 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -82,6 +82,9 @@ String parameterToString(dynamic value) { if (value is Colorspace) { return ColorspaceTypeTransformer().encode(value).toString(); } + if (value is EditorActionType) { + return EditorActionTypeTypeTransformer().encode(value).toString(); + } if (value is EntityType) { return EntityTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/editor_action_adjust.dart b/mobile/openapi/lib/model/editor_action_adjust.dart new file mode 100644 index 0000000000000..8c91e70fc9cd8 --- /dev/null +++ b/mobile/openapi/lib/model/editor_action_adjust.dart @@ -0,0 +1,130 @@ +// +// 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 EditorActionAdjust { + /// Returns a new [EditorActionAdjust] instance. + EditorActionAdjust({ + required this.action, + required this.brightness, + required this.hue, + required this.lightness, + required this.saturation, + }); + + EditorActionType action; + + int brightness; + + int hue; + + int lightness; + + int saturation; + + @override + bool operator ==(Object other) => identical(this, other) || other is EditorActionAdjust && + other.action == action && + other.brightness == brightness && + other.hue == hue && + other.lightness == lightness && + other.saturation == saturation; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (brightness.hashCode) + + (hue.hashCode) + + (lightness.hashCode) + + (saturation.hashCode); + + @override + String toString() => 'EditorActionAdjust[action=$action, brightness=$brightness, hue=$hue, lightness=$lightness, saturation=$saturation]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'brightness'] = this.brightness; + json[r'hue'] = this.hue; + json[r'lightness'] = this.lightness; + json[r'saturation'] = this.saturation; + return json; + } + + /// Returns a new [EditorActionAdjust] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EditorActionAdjust? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return EditorActionAdjust( + action: EditorActionType.fromJson(json[r'action'])!, + brightness: mapValueOfType(json, r'brightness')!, + hue: mapValueOfType(json, r'hue')!, + lightness: mapValueOfType(json, r'lightness')!, + saturation: mapValueOfType(json, r'saturation')!, + ); + } + 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 = EditorActionAdjust.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 = EditorActionAdjust.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EditorActionAdjust-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] = EditorActionAdjust.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'brightness', + 'hue', + 'lightness', + 'saturation', + }; +} + diff --git a/mobile/openapi/lib/model/editor_action_blur.dart b/mobile/openapi/lib/model/editor_action_blur.dart new file mode 100644 index 0000000000000..66a2c94540579 --- /dev/null +++ b/mobile/openapi/lib/model/editor_action_blur.dart @@ -0,0 +1,98 @@ +// +// 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 EditorActionBlur { + /// Returns a new [EditorActionBlur] instance. + EditorActionBlur({ + required this.action, + }); + + EditorActionType action; + + @override + bool operator ==(Object other) => identical(this, other) || other is EditorActionBlur && + other.action == action; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode); + + @override + String toString() => 'EditorActionBlur[action=$action]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + return json; + } + + /// Returns a new [EditorActionBlur] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EditorActionBlur? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return EditorActionBlur( + action: EditorActionType.fromJson(json[r'action'])!, + ); + } + 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 = EditorActionBlur.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 = EditorActionBlur.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EditorActionBlur-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] = EditorActionBlur.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + }; +} + diff --git a/mobile/openapi/lib/model/editor_action_crop.dart b/mobile/openapi/lib/model/editor_action_crop.dart new file mode 100644 index 0000000000000..44cbddeded91d --- /dev/null +++ b/mobile/openapi/lib/model/editor_action_crop.dart @@ -0,0 +1,106 @@ +// +// 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 EditorActionCrop { + /// Returns a new [EditorActionCrop] instance. + EditorActionCrop({ + required this.action, + required this.region, + }); + + EditorActionType action; + + EditorCropRegion region; + + @override + bool operator ==(Object other) => identical(this, other) || other is EditorActionCrop && + other.action == action && + other.region == region; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (region.hashCode); + + @override + String toString() => 'EditorActionCrop[action=$action, region=$region]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'region'] = this.region; + return json; + } + + /// Returns a new [EditorActionCrop] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EditorActionCrop? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return EditorActionCrop( + action: EditorActionType.fromJson(json[r'action'])!, + region: EditorCropRegion.fromJson(json[r'region'])!, + ); + } + 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 = EditorActionCrop.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 = EditorActionCrop.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EditorActionCrop-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] = EditorActionCrop.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'region', + }; +} + diff --git a/mobile/openapi/lib/model/editor_action_rotate.dart b/mobile/openapi/lib/model/editor_action_rotate.dart new file mode 100644 index 0000000000000..77f30588e0bc0 --- /dev/null +++ b/mobile/openapi/lib/model/editor_action_rotate.dart @@ -0,0 +1,106 @@ +// +// 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 EditorActionRotate { + /// Returns a new [EditorActionRotate] instance. + EditorActionRotate({ + required this.action, + required this.angle, + }); + + EditorActionType action; + + int angle; + + @override + bool operator ==(Object other) => identical(this, other) || other is EditorActionRotate && + other.action == action && + other.angle == angle; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (angle.hashCode); + + @override + String toString() => 'EditorActionRotate[action=$action, angle=$angle]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'angle'] = this.angle; + return json; + } + + /// Returns a new [EditorActionRotate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EditorActionRotate? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return EditorActionRotate( + action: EditorActionType.fromJson(json[r'action'])!, + angle: mapValueOfType(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 = EditorActionRotate.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 = EditorActionRotate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EditorActionRotate-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] = EditorActionRotate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'angle', + }; +} + diff --git a/mobile/openapi/lib/model/editor_action_type.dart b/mobile/openapi/lib/model/editor_action_type.dart new file mode 100644 index 0000000000000..0f29ec9981a7d --- /dev/null +++ b/mobile/openapi/lib/model/editor_action_type.dart @@ -0,0 +1,91 @@ +// +// 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 EditorActionType { + /// Instantiate a new enum with the provided [value]. + const EditorActionType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const crop = EditorActionType._(r'crop'); + static const rotate = EditorActionType._(r'rotate'); + static const blur = EditorActionType._(r'blur'); + static const adjust = EditorActionType._(r'adjust'); + + /// List of all possible values in this [enum][EditorActionType]. + static const values = [ + crop, + rotate, + blur, + adjust, + ]; + + static EditorActionType? fromJson(dynamic value) => EditorActionTypeTypeTransformer().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 = EditorActionType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [EditorActionType] to String, +/// and [decode] dynamic data back to [EditorActionType]. +class EditorActionTypeTypeTransformer { + factory EditorActionTypeTypeTransformer() => _instance ??= const EditorActionTypeTypeTransformer._(); + + const EditorActionTypeTypeTransformer._(); + + String encode(EditorActionType data) => data.value; + + /// Decodes a [dynamic value][data] to a EditorActionType. + /// + /// 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. + EditorActionType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'crop': return EditorActionType.crop; + case r'rotate': return EditorActionType.rotate; + case r'blur': return EditorActionType.blur; + case r'adjust': return EditorActionType.adjust; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [EditorActionTypeTypeTransformer] instance. + static EditorActionTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/editor_create_asset_dto.dart b/mobile/openapi/lib/model/editor_create_asset_dto.dart new file mode 100644 index 0000000000000..1e9c384b4854f --- /dev/null +++ b/mobile/openapi/lib/model/editor_create_asset_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 EditorCreateAssetDto { + /// Returns a new [EditorCreateAssetDto] instance. + EditorCreateAssetDto({ + this.edits = const [], + required this.id, + this.stack, + }); + + /// list of edits + List edits; + + /// Source asset id + String id; + + /// Stack the edit and the original + /// + /// 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. + /// + bool? stack; + + @override + bool operator ==(Object other) => identical(this, other) || other is EditorCreateAssetDto && + _deepEquality.equals(other.edits, edits) && + other.id == id && + other.stack == stack; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (edits.hashCode) + + (id.hashCode) + + (stack == null ? 0 : stack!.hashCode); + + @override + String toString() => 'EditorCreateAssetDto[edits=$edits, id=$id, stack=$stack]'; + + Map toJson() { + final json = {}; + json[r'edits'] = this.edits; + json[r'id'] = this.id; + if (this.stack != null) { + json[r'stack'] = this.stack; + } else { + // json[r'stack'] = null; + } + return json; + } + + /// Returns a new [EditorCreateAssetDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EditorCreateAssetDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return EditorCreateAssetDto( + edits: EditorCreateAssetDtoEditsInner.listFromJson(json[r'edits']), + id: mapValueOfType(json, r'id')!, + stack: mapValueOfType(json, r'stack'), + ); + } + 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 = EditorCreateAssetDto.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 = EditorCreateAssetDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EditorCreateAssetDto-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] = EditorCreateAssetDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'edits', + 'id', + }; +} + diff --git a/mobile/openapi/lib/model/editor_create_asset_dto_edits_inner.dart b/mobile/openapi/lib/model/editor_create_asset_dto_edits_inner.dart new file mode 100644 index 0000000000000..9624a5fc8db5d --- /dev/null +++ b/mobile/openapi/lib/model/editor_create_asset_dto_edits_inner.dart @@ -0,0 +1,146 @@ +// +// 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 EditorCreateAssetDtoEditsInner { + /// Returns a new [EditorCreateAssetDtoEditsInner] instance. + EditorCreateAssetDtoEditsInner({ + required this.action, + required this.region, + required this.angle, + required this.brightness, + required this.hue, + required this.lightness, + required this.saturation, + }); + + EditorActionType action; + + EditorCropRegion region; + + int angle; + + int brightness; + + int hue; + + int lightness; + + int saturation; + + @override + bool operator ==(Object other) => identical(this, other) || other is EditorCreateAssetDtoEditsInner && + other.action == action && + other.region == region && + other.angle == angle && + other.brightness == brightness && + other.hue == hue && + other.lightness == lightness && + other.saturation == saturation; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (region.hashCode) + + (angle.hashCode) + + (brightness.hashCode) + + (hue.hashCode) + + (lightness.hashCode) + + (saturation.hashCode); + + @override + String toString() => 'EditorCreateAssetDtoEditsInner[action=$action, region=$region, angle=$angle, brightness=$brightness, hue=$hue, lightness=$lightness, saturation=$saturation]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'region'] = this.region; + json[r'angle'] = this.angle; + json[r'brightness'] = this.brightness; + json[r'hue'] = this.hue; + json[r'lightness'] = this.lightness; + json[r'saturation'] = this.saturation; + return json; + } + + /// Returns a new [EditorCreateAssetDtoEditsInner] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EditorCreateAssetDtoEditsInner? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return EditorCreateAssetDtoEditsInner( + action: EditorActionType.fromJson(json[r'action'])!, + region: EditorCropRegion.fromJson(json[r'region'])!, + angle: mapValueOfType(json, r'angle')!, + brightness: mapValueOfType(json, r'brightness')!, + hue: mapValueOfType(json, r'hue')!, + lightness: mapValueOfType(json, r'lightness')!, + saturation: mapValueOfType(json, r'saturation')!, + ); + } + 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 = EditorCreateAssetDtoEditsInner.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 = EditorCreateAssetDtoEditsInner.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EditorCreateAssetDtoEditsInner-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] = EditorCreateAssetDtoEditsInner.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'region', + 'angle', + 'brightness', + 'hue', + 'lightness', + 'saturation', + }; +} + diff --git a/mobile/openapi/lib/model/editor_crop_region.dart b/mobile/openapi/lib/model/editor_crop_region.dart new file mode 100644 index 0000000000000..fb2f39630d8bb --- /dev/null +++ b/mobile/openapi/lib/model/editor_crop_region.dart @@ -0,0 +1,122 @@ +// +// 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 EditorCropRegion { + /// Returns a new [EditorCropRegion] instance. + EditorCropRegion({ + required this.height, + required this.left, + required this.top, + required this.width, + }); + + int height; + + int left; + + int top; + + int width; + + @override + bool operator ==(Object other) => identical(this, other) || other is EditorCropRegion && + other.height == height && + other.left == left && + other.top == top && + other.width == width; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (height.hashCode) + + (left.hashCode) + + (top.hashCode) + + (width.hashCode); + + @override + String toString() => 'EditorCropRegion[height=$height, left=$left, top=$top, width=$width]'; + + Map toJson() { + final json = {}; + json[r'height'] = this.height; + json[r'left'] = this.left; + json[r'top'] = this.top; + json[r'width'] = this.width; + return json; + } + + /// Returns a new [EditorCropRegion] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EditorCropRegion? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return EditorCropRegion( + height: mapValueOfType(json, r'height')!, + left: mapValueOfType(json, r'left')!, + top: mapValueOfType(json, r'top')!, + width: mapValueOfType(json, r'width')!, + ); + } + 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 = EditorCropRegion.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 = EditorCropRegion.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EditorCropRegion-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] = EditorCropRegion.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'height', + 'left', + 'top', + 'width', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c30c43fabfd86..86d591c1dc003 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2469,6 +2469,48 @@ ] } }, + "/editor": { + "post": { + "operationId": "createAssetFromEdits", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorCreateAssetDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Editor" + ] + } + }, "/faces": { "get": { "operationId": "getFaces", @@ -8562,6 +8604,144 @@ ], "type": "object" }, + "EditorActionAdjust": { + "properties": { + "action": { + "$ref": "#/components/schemas/EditorActionType" + }, + "brightness": { + "type": "integer" + }, + "hue": { + "type": "integer" + }, + "lightness": { + "type": "integer" + }, + "saturation": { + "type": "integer" + } + }, + "required": [ + "action", + "brightness", + "hue", + "lightness", + "saturation" + ], + "type": "object" + }, + "EditorActionBlur": { + "properties": { + "action": { + "$ref": "#/components/schemas/EditorActionType" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "EditorActionCrop": { + "properties": { + "action": { + "$ref": "#/components/schemas/EditorActionType" + }, + "region": { + "$ref": "#/components/schemas/EditorCropRegion" + } + }, + "required": [ + "action", + "region" + ], + "type": "object" + }, + "EditorActionRotate": { + "properties": { + "action": { + "$ref": "#/components/schemas/EditorActionType" + }, + "angle": { + "type": "integer" + } + }, + "required": [ + "action", + "angle" + ], + "type": "object" + }, + "EditorActionType": { + "enum": [ + "crop", + "rotate", + "blur", + "adjust" + ], + "type": "string" + }, + "EditorCreateAssetDto": { + "properties": { + "edits": { + "description": "list of edits", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorActionCrop" + }, + { + "$ref": "#/components/schemas/EditorActionRotate" + }, + { + "$ref": "#/components/schemas/EditorActionBlur" + }, + { + "$ref": "#/components/schemas/EditorActionAdjust" + } + ] + }, + "type": "array" + }, + "id": { + "description": "Source asset id", + "format": "uuid", + "type": "string" + }, + "stack": { + "description": "Stack the edit and the original", + "type": "boolean" + } + }, + "required": [ + "edits", + "id" + ], + "type": "object" + }, + "EditorCropRegion": { + "properties": { + "height": { + "type": "integer" + }, + "left": { + "type": "integer" + }, + "top": { + "type": "integer" + }, + "width": { + "type": "integer" + } + }, + "required": [ + "height", + "left", + "top", + "width" + ], + "type": "object" + }, "EmailNotificationsResponse": { "properties": { "albumInvite": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 184052a4f603b..535ddb7238ee7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -444,6 +444,38 @@ export type DuplicateResponseDto = { assets: AssetResponseDto[]; duplicateId: string; }; +export type EditorCropRegion = { + height: number; + left: number; + top: number; + width: number; +}; +export type EditorActionCrop = { + action: EditorActionType; + region: EditorCropRegion; +}; +export type EditorActionRotate = { + action: EditorActionType; + angle: number; +}; +export type EditorActionBlur = { + action: EditorActionType; +}; +export type EditorActionAdjust = { + action: EditorActionType; + brightness: number; + hue: number; + lightness: number; + saturation: number; +}; +export type EditorCreateAssetDto = { + /** list of edits */ + edits: (EditorActionCrop | EditorActionRotate | EditorActionBlur | EditorActionAdjust)[]; + /** Source asset id */ + id: string; + /** Stack the edit and the original */ + stack?: boolean; +}; export type PersonResponseDto = { birthDate: string | null; id: string; @@ -1836,6 +1868,18 @@ export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function createAssetFromEdits({ editorCreateAssetDto }: { + editorCreateAssetDto: EditorCreateAssetDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: AssetResponseDto; + }>("/editor", oazapfts.json({ + ...opts, + method: "POST", + body: editorCreateAssetDto + }))); +} export function getFaces({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { @@ -3138,6 +3182,12 @@ export enum EntityType { Asset = "ASSET", Album = "ALBUM" } +export enum EditorActionType { + Crop = "crop", + Rotate = "rotate", + Blur = "blur", + Adjust = "adjust" +} export enum JobName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", diff --git a/server/src/controllers/editor.controller.ts b/server/src/controllers/editor.controller.ts new file mode 100644 index 0000000000000..80280aafcbfab --- /dev/null +++ b/server/src/controllers/editor.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { EditorCreateAssetDto } from 'src/dtos/editor.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { EditorService } from 'src/services/editor.service'; + +@ApiTags('Editor') +@Controller('editor') +export class EditorController { + constructor(private service: EditorService) {} + + @Post() + @Authenticated() + createAssetFromEdits(@Auth() auth: AuthDto, @Body() dto: EditorCreateAssetDto): Promise { + return this.service.createAssetFromEdits(auth, dto); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 9675cf6d3be20..64c2a18e29509 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -8,6 +8,7 @@ import { AuditController } from 'src/controllers/audit.controller'; import { AuthController } from 'src/controllers/auth.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller'; +import { EditorController } from 'src/controllers/editor.controller'; import { FaceController } from 'src/controllers/face.controller'; import { ReportController } from 'src/controllers/file-report.controller'; import { JobController } from 'src/controllers/job.controller'; @@ -43,6 +44,7 @@ export const controllers = [ AuthController, DownloadController, DuplicateController, + EditorController, FaceController, JobController, LibraryController, diff --git a/server/src/dtos/editor.dto.ts b/server/src/dtos/editor.dto.ts new file mode 100644 index 0000000000000..37301d1c68782 --- /dev/null +++ b/server/src/dtos/editor.dto.ts @@ -0,0 +1,98 @@ +import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; +import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer'; +import { IsEnum, IsInt, ValidateNested } from 'class-validator'; +import { ValidateBoolean, ValidateUUID } from 'src/validation'; + +export enum EditorActionType { + Crop = 'crop', + Rotate = 'rotate', + Blur = 'blur', + Adjust = 'adjust', +} + +export class EditorActionItem { + @IsEnum(EditorActionType) + @ApiProperty({ enum: EditorActionType, enumName: 'EditorActionType' }) + action!: EditorActionType; +} + +export class EditorActionAdjust extends EditorActionItem { + @IsInt() + @ApiProperty({ type: 'integer' }) + brightness!: number; + + @IsInt() + @ApiProperty({ type: 'integer' }) + saturation!: number; + + @IsInt() + @ApiProperty({ type: 'integer' }) + hue!: number; + + @IsInt() + @ApiProperty({ type: 'integer' }) + lightness!: number; +} + +export class EditorActionBlur extends EditorActionItem {} + +class EditorCropRegion { + @IsInt() + @ApiProperty({ type: 'integer' }) + left!: number; + + @IsInt() + @ApiProperty({ type: 'integer' }) + top!: number; + + @IsInt() + @ApiProperty({ type: 'integer' }) + width!: number; + + @IsInt() + @ApiProperty({ type: 'integer' }) + height!: number; +} + +export class EditorActionCrop extends EditorActionItem { + @Type(() => EditorCropRegion) + @ValidateNested() + region!: EditorCropRegion; +} + +export class EditorActionRotate extends EditorActionItem { + @IsInt() + @ApiProperty({ type: 'integer' }) + angle!: number; +} + +export type EditorAction = EditorActionRotate | EditorActionBlur | EditorActionCrop | EditorActionAdjust; + +const actionToClass: Record> = { + [EditorActionType.Crop]: EditorActionCrop, + [EditorActionType.Rotate]: EditorActionRotate, + [EditorActionType.Blur]: EditorActionBlur, + [EditorActionType.Adjust]: EditorActionAdjust, +}; + +const getActionClass = (item: EditorActionItem): ClassConstructor => + actionToClass[item.action] || EditorActionItem; + +@ApiExtraModels(EditorActionRotate, EditorActionBlur, EditorActionCrop, EditorActionAdjust) +export class EditorCreateAssetDto { + /** Source asset id */ + @ValidateUUID() + id!: string; + + /** Stack the edit and the original */ + @ValidateBoolean({ optional: true }) + stack?: boolean; + + /** list of edits */ + @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!: EditorAction[]; +} diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 0fd35167af8f2..e6a8b66134b36 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -117,7 +117,7 @@ export interface IBaseJob { export interface IEntityJob extends IBaseJob { id: string; - source?: 'upload' | 'sidecar-write' | 'copy'; + source?: 'upload' | 'sidecar-write' | 'copy' | 'editor'; } export interface IAssetDeleteJob extends IEntityJob { diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index f7389d3d068cd..74e713e3ba610 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -1,4 +1,5 @@ import { Writable } from 'node:stream'; +import { Region } from 'sharp'; import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/config'; export const IMediaRepository = 'IMediaRepository'; @@ -71,6 +72,20 @@ export interface BitrateDistribution { unit: string; } +export type MediaEditItem = + | { action: 'crop'; region: Region } + | { action: 'rotate'; angle: number } + | { action: 'blur' } + | { + action: 'modulate'; + brightness?: number; + saturation?: number; + hue?: number; + lightness?: number; + }; + +export type MediaEdits = MediaEditItem[]; + export interface VideoCodecSWConfig { getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand; } @@ -89,4 +104,7 @@ export interface IMediaRepository { // video probe(input: string): Promise; transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise; + + // editor + applyEdits(input: string, output: string, edits: MediaEditItem[]): Promise; } diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index a84ef6f596f4e..6ac9f1cf0a227 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -8,8 +8,9 @@ import sharp from 'sharp'; import { Colorspace } from 'src/config'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { - IMediaRepository, ImageDimensions, + IMediaRepository, + MediaEdits, ThumbnailOptions, TranscodeCommand, VideoInfo, @@ -44,6 +45,41 @@ export class MediaRepository implements IMediaRepository { return true; } + async applyEdits(input: string, output: string, edits: MediaEdits) { + const pipeline = sharp(input, { failOn: 'error', limitInputPixels: false }).keepMetadata(); + + for (const edit of edits) { + switch (edit.action) { + case 'crop': { + pipeline.extract(edit.region); + break; + } + + case 'rotate': { + pipeline.rotate(edit.angle); + break; + } + + case 'blur': { + pipeline.blur(true); + break; + } + + case 'modulate': { + pipeline.modulate({ + brightness: edit.brightness, + saturation: edit.saturation, + hue: edit.hue, + lightness: edit.lightness, + }); + break; + } + } + } + + await pipeline.toFile(output); + } + 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 }) diff --git a/server/src/services/editor.service.ts b/server/src/services/editor.service.ts new file mode 100644 index 0000000000000..dfcf2df8650c1 --- /dev/null +++ b/server/src/services/editor.service.ts @@ -0,0 +1,128 @@ +import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { dirname } from 'node:path'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + EditorAction, + EditorActionAdjust, + EditorActionBlur, + EditorActionCrop, + EditorActionRotate, + EditorActionType, + EditorCreateAssetDto, +} from 'src/dtos/editor.dto'; +import { AssetType } from 'src/entities/asset.entity'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IJobRepository, JobName } from 'src/interfaces/job.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IMediaRepository, MediaEditItem } from 'src/interfaces/media.interface'; +import { IStorageRepository } from 'src/interfaces/storage.interface'; + +@Injectable() +export class EditorService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(IMediaRepository) private mediaRepository: IMediaRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + ) { + this.access = AccessCore.create(accessRepository); + } + + async createAssetFromEdits(auth: AuthDto, dto: EditorCreateAssetDto): Promise { + await this.access.requirePermission(auth, Permission.ASSET_VIEW, dto.id); + + const asset = await this.assetRepository.getById(dto.id); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + + if (asset.type !== AssetType.IMAGE) { + throw new BadRequestException('Only images can be edited'); + } + + const uuid = this.cryptoRepository.randomUUID(); + const outputFile = StorageCore.getNestedPath(StorageFolder.UPLOAD, auth.user.id, uuid); + this.storageRepository.mkdirSync(dirname(outputFile)); + + await this.mediaRepository.applyEdits(asset.originalPath, outputFile, this.asMediaEdits(dto.edits)); + + try { + const checksum = await this.cryptoRepository.hashFile(outputFile); + const { size } = await this.storageRepository.stat(outputFile); + + const newAsset = await this.assetRepository.create({ + id: uuid, + ownerId: auth.user.id, + deviceId: 'immich-editor', + deviceAssetId: asset.deviceAssetId + `-edit-${Date.now()}`, + libraryId: null, + type: asset.type, + originalPath: outputFile, + localDateTime: asset.localDateTime, + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + isFavorite: false, + isArchived: false, + isExternal: false, + isOffline: false, + checksum, + isVisible: true, + originalFileName: asset.originalFileName, + sidecarPath: null, + tags: asset.tags, + duplicateId: null, + }); + + await this.assetRepository.upsertExif({ assetId: newAsset.id, fileSizeInByte: size }); + await this.jobRepository.queue({ + name: JobName.METADATA_EXTRACTION, + data: { id: newAsset.id, source: 'editor' }, + }); + + return mapAsset(newAsset, { auth }); + } catch (error: Error | any) { + this.logger.error(`Failed to create asset from edits: ${error}`, error?.stack); + await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [outputFile] } }); + throw new InternalServerErrorException('Failed to create asset from edits'); + } + } + + private asMediaEdits(edits: EditorAction[]) { + const mediaEdits: MediaEditItem[] = []; + for (const { action, ...options } of edits) { + switch (action) { + case EditorActionType.Crop: { + mediaEdits.push({ ...(options as EditorActionCrop), action: 'crop' }); + break; + } + + case EditorActionType.Rotate: { + mediaEdits.push({ ...(options as EditorActionRotate), action: 'rotate' }); + break; + } + + case EditorActionType.Blur: { + mediaEdits.push({ ...(options as EditorActionBlur), action: 'blur' }); + break; + } + + case EditorActionType.Adjust: { + mediaEdits.push({ ...(options as EditorActionAdjust), action: 'modulate' }); + break; + } + } + } + + return mediaEdits; + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index ab680f15e3111..3e0400c3d2908 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -10,6 +10,7 @@ import { CliService } from 'src/services/cli.service'; import { DatabaseService } from 'src/services/database.service'; import { DownloadService } from 'src/services/download.service'; import { DuplicateService } from 'src/services/duplicate.service'; +import { EditorService } from 'src/services/editor.service'; import { JobService } from 'src/services/job.service'; import { LibraryService } from 'src/services/library.service'; import { MapService } from 'src/services/map.service'; @@ -50,6 +51,7 @@ export const services = [ DatabaseService, DownloadService, DuplicateService, + EditorService, JobService, LibraryService, MapService, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index f232c4ac77892..5bf1afe7a6237 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -250,7 +250,7 @@ export class JobService { } case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { - if (item.data.source === 'upload' || item.data.source === 'copy') { + if (item.data.source === 'upload' || item.data.source === 'copy' || item.data.source === 'editor') { await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data }); } break; @@ -271,7 +271,7 @@ export class JobService { { name: JobName.GENERATE_THUMBHASH, data: item.data }, ]; - if (item.data.source === 'upload') { + if (item.data.source === 'upload' || item.data.source === 'editor') { jobs.push({ name: JobName.SMART_SEARCH, data: item.data }, { name: JobName.FACE_DETECTION, data: item.data }); const [asset] = await this.assetRepository.getByIds([item.data.id]); @@ -289,7 +289,7 @@ export class JobService { } case JobName.GENERATE_THUMBNAIL: { - if (item.data.source !== 'upload') { + if (item.data.source !== 'upload' && item.data.source !== 'editor') { break; } diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 4c344a986679f..a00af6851c320 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -9,5 +9,6 @@ export const newMediaRepositoryMock = (): Mocked => { probe: vitest.fn(), transcode: vitest.fn(), getImageDimensions: vitest.fn(), + applyEdits: vitest.fn(), }; };