From 4fcc88487cde591dcb242923ca01b90fdf891f8b Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 20 Jan 2026 10:23:43 -0600 Subject: [PATCH] feat: mobile editing --- i18n/en.json | 2 + .../domain/models/asset/base_asset.model.dart | 2 + .../lib/domain/models/asset_edit.model.dart | 21 + mobile/lib/domain/models/exif.model.dart | 14 + mobile/lib/domain/services/asset.service.dart | 9 + .../domain/services/sync_stream.service.dart | 23 + .../entities/asset_edit.entity.dart | 32 + .../entities/asset_edit.entity.drift.dart | 748 ++++++++++++++++++ .../infrastructure/entities/exif.entity.dart | 2 + .../repositories/db.repository.dart | 2 + .../repositories/remote_asset.repository.dart | 35 + .../repositories/sync_api.repository.dart | 3 + .../repositories/sync_stream.repository.dart | 50 +- .../presentation/pages/drift_edit.page.dart | 476 +++++++++++ .../pages/editing/drift_crop.page.dart | 179 ----- .../pages/editing/drift_edit.page.dart | 171 ---- .../pages/editing/drift_filter.page.dart | 159 ---- .../edit_image_action_button.widget.dart | 20 +- .../asset_viewer/bottom_bar.widget.dart | 4 +- .../asset_viewer/bottom_sheet.widget.dart | 2 +- .../widgets/images/image_provider.dart | 8 +- .../widgets/images/remote_image_provider.dart | 32 +- .../infrastructure/action.provider.dart | 22 +- mobile/lib/providers/websocket.provider.dart | 21 + .../repositories/asset_api.repository.dart | 22 +- mobile/lib/routing/router.dart | 10 +- mobile/lib/routing/router.gr.dart | 126 +-- mobile/lib/services/action.service.dart | 11 + mobile/lib/utils/editor.utils.dart | 75 ++ .../lib/utils/hooks/crop_controller_hook.dart | 14 +- mobile/lib/utils/matrix.utils.dart | 50 ++ mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api_client.dart | 4 + .../lib/model/sync_asset_edit_delete_v1.dart | 99 +++ .../openapi/lib/model/sync_asset_edit_v1.dart | 131 +++ mobile/test/utils/editor_test.dart | 321 ++++++++ open-api/bin/generate-open-api.sh | 1 + open-api/immich-openapi-specs.json | 213 ++--- ...dit_action_list_dto_edits_inner.dart.patch | 20 + open-api/typescript-sdk/src/fetch-client.ts | 13 + server/src/dtos/sync.dto.ts | 21 + server/src/enum.ts | 3 + .../src/repositories/asset-edit.repository.ts | 12 + server/src/repositories/sync.repository.ts | 26 + .../src/repositories/websocket.repository.ts | 4 +- server/src/schema/functions.ts | 13 + server/src/schema/index.ts | 3 + .../migrations/1769026065658-AssetEditSync.ts | 47 ++ .../schema/tables/asset-edit-audit.table.ts | 14 + server/src/schema/tables/asset-edit.table.ts | 12 +- server/src/services/asset.service.ts | 1 + server/src/services/job.service.ts | 2 + server/src/services/sync.service.ts | 17 + .../medium/specs/sync/sync-asset-edit.spec.ts | 306 +++++++ web/src/lib/stores/websocket.ts | 3 +- 55 files changed, 2817 insertions(+), 816 deletions(-) create mode 100644 mobile/lib/domain/models/asset_edit.model.dart create mode 100644 mobile/lib/infrastructure/entities/asset_edit.entity.dart create mode 100644 mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart create mode 100644 mobile/lib/presentation/pages/drift_edit.page.dart delete mode 100644 mobile/lib/presentation/pages/editing/drift_crop.page.dart delete mode 100644 mobile/lib/presentation/pages/editing/drift_edit.page.dart delete mode 100644 mobile/lib/presentation/pages/editing/drift_filter.page.dart create mode 100644 mobile/lib/utils/editor.utils.dart create mode 100644 mobile/lib/utils/matrix.utils.dart create mode 100644 mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_asset_edit_v1.dart create mode 100644 mobile/test/utils/editor_test.dart create mode 100644 open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch create mode 100644 server/src/schema/migrations/1769026065658-AssetEditSync.ts create mode 100644 server/src/schema/tables/asset-edit-audit.table.ts create mode 100644 server/test/medium/specs/sync/sync-asset-edit.spec.ts diff --git a/i18n/en.json b/i18n/en.json index dedbea1bfe..a106764c86 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -561,6 +561,8 @@ "asset_adding_to_album": "Adding to album…", "asset_created": "Asset created", "asset_description_updated": "Asset description has been updated", + "asset_edit_failed": "Asset edit failed", + "asset_edit_success": "Asset edited successfully", "asset_filename_is_offline": "Asset {filename} is offline", "asset_has_unassigned_faces": "Asset has unassigned faces", "asset_hashing": "Hashing…", diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 310e30ea62..880aaa82e4 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -56,6 +56,8 @@ sealed class BaseAsset { bool get isLocalOnly => storage == AssetState.local; bool get isRemoteOnly => storage == AssetState.remote; + bool get isEditable => isImage && !isMotionPhoto && this is RemoteAsset; + // Overridden in subclasses AssetState get storage; String? get localId; diff --git a/mobile/lib/domain/models/asset_edit.model.dart b/mobile/lib/domain/models/asset_edit.model.dart new file mode 100644 index 0000000000..b3266dba46 --- /dev/null +++ b/mobile/lib/domain/models/asset_edit.model.dart @@ -0,0 +1,21 @@ +import "package:openapi/api.dart" as api show AssetEditAction; + +enum AssetEditAction { rotate, crop, mirror, other } + +extension AssetEditActionExtension on AssetEditAction { + api.AssetEditAction? toDto() { + return switch (this) { + AssetEditAction.rotate => api.AssetEditAction.rotate, + AssetEditAction.crop => api.AssetEditAction.crop, + AssetEditAction.mirror => api.AssetEditAction.mirror, + AssetEditAction.other => null, + }; + } +} + +class AssetEdit { + final AssetEditAction action; + final Map parameters; + + const AssetEdit({required this.action, required this.parameters}); +} diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart index d0f78b59de..45b787d586 100644 --- a/mobile/lib/domain/models/exif.model.dart +++ b/mobile/lib/domain/models/exif.model.dart @@ -7,6 +7,8 @@ class ExifInfo { final String? timeZone; final DateTime? dateTimeOriginal; final int? rating; + final int? width; + final int? height; // GPS final double? latitude; @@ -48,6 +50,8 @@ class ExifInfo { this.timeZone, this.dateTimeOriginal, this.rating, + this.width, + this.height, this.isFlipped = false, this.latitude, this.longitude, @@ -74,6 +78,8 @@ class ExifInfo { other.timeZone == timeZone && other.dateTimeOriginal == dateTimeOriginal && other.rating == rating && + other.width == width && + other.height == height && other.latitude == latitude && other.longitude == longitude && other.city == city && @@ -98,6 +104,8 @@ class ExifInfo { timeZone.hashCode ^ dateTimeOriginal.hashCode ^ rating.hashCode ^ + width.hashCode ^ + height.hashCode ^ latitude.hashCode ^ longitude.hashCode ^ city.hashCode ^ @@ -123,6 +131,8 @@ isFlipped: $isFlipped, timeZone: ${timeZone ?? 'NA'}, dateTimeOriginal: ${dateTimeOriginal ?? 'NA'}, rating: ${rating ?? 'NA'}, +width: ${width ?? 'NA'}, +height: ${height ?? 'NA'}, latitude: ${latitude ?? 'NA'}, longitude: ${longitude ?? 'NA'}, city: ${city ?? 'NA'}, @@ -146,6 +156,8 @@ exposureSeconds: ${exposureSeconds ?? 'NA'}, String? timeZone, DateTime? dateTimeOriginal, int? rating, + int? width, + int? height, double? latitude, double? longitude, String? city, @@ -168,6 +180,8 @@ exposureSeconds: ${exposureSeconds ?? 'NA'}, timeZone: timeZone ?? this.timeZone, dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, rating: rating ?? this.rating, + width: width ?? this.width, + height: height ?? this.height, isFlipped: isFlipped ?? this.isFlipped, latitude: latitude ?? this.latitude, longitude: longitude ?? this.longitude, diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 198733b3c8..924634ba15 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -1,5 +1,6 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; @@ -116,4 +117,12 @@ class AssetService { Future> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) { return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection); } + + Future> getAssetEdits(String assetId) { + return _remoteAssetRepository.getAssetEdits(assetId); + } + + Future editAsset(String assetId, List edits) { + return _remoteAssetRepository.editAsset(assetId, edits); + } } diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index af1c94ca71..4b7053a049 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -201,6 +201,10 @@ class SyncStreamService { return _syncStreamRepository.deleteAssetsV1(data.cast()); case SyncEntityType.assetExifV1: return _syncStreamRepository.updateAssetsExifV1(data.cast()); + case SyncEntityType.assetEditV1: + return _syncStreamRepository.updateAssetEditsV1(data.cast()); + case SyncEntityType.assetEditDeleteV1: + return _syncStreamRepository.deleteAssetEditsV1(data.cast()); case SyncEntityType.assetMetadataV1: return _syncStreamRepository.updateAssetsMetadataV1(data.cast()); case SyncEntityType.assetMetadataDeleteV1: @@ -336,6 +340,7 @@ class SyncStreamService { _logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events'); final List assets = []; + final List assetEdits = []; try { for (final data in batchData) { @@ -345,6 +350,7 @@ class SyncStreamService { final payload = data; final assetData = payload['asset']; + final editData = payload['edit']; if (assetData == null) { continue; @@ -354,11 +360,28 @@ class SyncStreamService { if (asset != null) { assets.add(asset); + + // Edits are only send on v2.6.0+ + if (editData != null) { + final edits = (editData as List) + .map((e) => SyncAssetEditV1.fromJson(e)) + .whereType() + .toList(); + + assetEdits.addAll(edits); + } } } if (assets.isNotEmpty) { await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit'); + + // edits that are sent replace previous edits, so we delete existing ones first + await _syncStreamRepository.deleteAssetEditsV1( + assets.map((asset) => SyncAssetEditDeleteV1(assetId: asset.id)).toList(), + debugLabel: 'websocket-edit', + ); + await _syncStreamRepository.updateAssetEditsV1(assetEdits, debugLabel: 'websocket-edit'); _logger.info('Successfully processed ${assets.length} edited assets'); } } catch (error, stackTrace) { diff --git a/mobile/lib/infrastructure/entities/asset_edit.entity.dart b/mobile/lib/infrastructure/entities/asset_edit.entity.dart new file mode 100644 index 0000000000..5a5692dd5e --- /dev/null +++ b/mobile/lib/infrastructure/entities/asset_edit.entity.dart @@ -0,0 +1,32 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class AssetEditEntity extends Table with DriftDefaultsMixin { + const AssetEditEntity(); + + TextColumn get id => text()(); + + TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)(); + + IntColumn get action => intEnum()(); + + BlobColumn get parameters => blob().map(editParameterConverter)(); + + IntColumn get sequence => integer()(); + + @override + Set get primaryKey => {id}; +} + +final JsonTypeConverter2, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb( + fromJson: (json) => json as Map, +); + +extension AssetEditEntityDataDomainEx on AssetEditEntityData { + AssetEdit toDto() { + return AssetEdit(action: action, parameters: parameters); + } +} diff --git a/mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart b/mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart new file mode 100644 index 0000000000..7b2c889ec7 --- /dev/null +++ b/mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart @@ -0,0 +1,748 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart' + as i1; +import 'package:immich_mobile/domain/models/asset_edit.model.dart' as i2; +import 'dart:typed_data' as i3; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart' + as i4; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' + as i5; +import 'package:drift/internal/modular.dart' as i6; + +typedef $$AssetEditEntityTableCreateCompanionBuilder = + i1.AssetEditEntityCompanion Function({ + required String id, + required String assetId, + required i2.AssetEditAction action, + required Map parameters, + required int sequence, + }); +typedef $$AssetEditEntityTableUpdateCompanionBuilder = + i1.AssetEditEntityCompanion Function({ + i0.Value id, + i0.Value assetId, + i0.Value action, + i0.Value> parameters, + i0.Value sequence, + }); + +final class $$AssetEditEntityTableReferences + extends + i0.BaseReferences< + i0.GeneratedDatabase, + i1.$AssetEditEntityTable, + i1.AssetEditEntityData + > { + $$AssetEditEntityTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) => + i6.ReadDatabaseContainer(db) + .resultSet('remote_asset_entity') + .createAlias( + i0.$_aliasNameGenerator( + i6.ReadDatabaseContainer(db) + .resultSet('asset_edit_entity') + .assetId, + i6.ReadDatabaseContainer( + db, + ).resultSet('remote_asset_entity').id, + ), + ); + + i5.$$RemoteAssetEntityTableProcessedTableManager get assetId { + final $_column = $_itemColumn('asset_id')!; + + final manager = i5 + .$$RemoteAssetEntityTableTableManager( + $_db, + i6.ReadDatabaseContainer( + $_db, + ).resultSet('remote_asset_entity'), + ) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_assetIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$AssetEditEntityTableFilterComposer + extends i0.Composer { + $$AssetEditEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnFilters(column), + ); + + i0.ColumnWithTypeConverterFilters + get action => $composableBuilder( + column: $table.action, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnWithTypeConverterFilters< + Map, + Map, + i3.Uint8List + > + get parameters => $composableBuilder( + column: $table.parameters, + builder: (column) => i0.ColumnWithTypeConverterFilters(column), + ); + + i0.ColumnFilters get sequence => $composableBuilder( + column: $table.sequence, + builder: (column) => i0.ColumnFilters(column), + ); + + i5.$$RemoteAssetEntityTableFilterComposer get assetId { + final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableFilterComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$AssetEditEntityTableOrderingComposer + extends i0.Composer { + $$AssetEditEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get action => $composableBuilder( + column: $table.action, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get parameters => $composableBuilder( + column: $table.parameters, + builder: (column) => i0.ColumnOrderings(column), + ); + + i0.ColumnOrderings get sequence => $composableBuilder( + column: $table.sequence, + builder: (column) => i0.ColumnOrderings(column), + ); + + i5.$$RemoteAssetEntityTableOrderingComposer get assetId { + final i5.$$RemoteAssetEntityTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableOrderingComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$AssetEditEntityTableAnnotationComposer + extends i0.Composer { + $$AssetEditEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter get action => + $composableBuilder(column: $table.action, builder: (column) => column); + + i0.GeneratedColumnWithTypeConverter, i3.Uint8List> + get parameters => $composableBuilder( + column: $table.parameters, + builder: (column) => column, + ); + + i0.GeneratedColumn get sequence => + $composableBuilder(column: $table.sequence, builder: (column) => column); + + i5.$$RemoteAssetEntityTableAnnotationComposer get assetId { + final i5.$$RemoteAssetEntityTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.assetId, + referencedTable: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => i5.$$RemoteAssetEntityTableAnnotationComposer( + $db: $db, + $table: i6.ReadDatabaseContainer( + $db, + ).resultSet('remote_asset_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$AssetEditEntityTableTableManager + extends + i0.RootTableManager< + i0.GeneratedDatabase, + i1.$AssetEditEntityTable, + i1.AssetEditEntityData, + i1.$$AssetEditEntityTableFilterComposer, + i1.$$AssetEditEntityTableOrderingComposer, + i1.$$AssetEditEntityTableAnnotationComposer, + $$AssetEditEntityTableCreateCompanionBuilder, + $$AssetEditEntityTableUpdateCompanionBuilder, + (i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences), + i1.AssetEditEntityData, + i0.PrefetchHooks Function({bool assetId}) + > { + $$AssetEditEntityTableTableManager( + i0.GeneratedDatabase db, + i1.$AssetEditEntityTable table, + ) : super( + i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$AssetEditEntityTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$$AssetEditEntityTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => i1 + .$$AssetEditEntityTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + i0.Value id = const i0.Value.absent(), + i0.Value assetId = const i0.Value.absent(), + i0.Value action = const i0.Value.absent(), + i0.Value> parameters = + const i0.Value.absent(), + i0.Value sequence = const i0.Value.absent(), + }) => i1.AssetEditEntityCompanion( + id: id, + assetId: assetId, + action: action, + parameters: parameters, + sequence: sequence, + ), + createCompanionCallback: + ({ + required String id, + required String assetId, + required i2.AssetEditAction action, + required Map parameters, + required int sequence, + }) => i1.AssetEditEntityCompanion.insert( + id: id, + assetId: assetId, + action: action, + parameters: parameters, + sequence: sequence, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + i1.$$AssetEditEntityTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({assetId = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends i0.TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (assetId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.assetId, + referencedTable: i1 + .$$AssetEditEntityTableReferences + ._assetIdTable(db), + referencedColumn: i1 + .$$AssetEditEntityTableReferences + ._assetIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$AssetEditEntityTableProcessedTableManager = + i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$AssetEditEntityTable, + i1.AssetEditEntityData, + i1.$$AssetEditEntityTableFilterComposer, + i1.$$AssetEditEntityTableOrderingComposer, + i1.$$AssetEditEntityTableAnnotationComposer, + $$AssetEditEntityTableCreateCompanionBuilder, + $$AssetEditEntityTableUpdateCompanionBuilder, + (i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences), + i1.AssetEditEntityData, + i0.PrefetchHooks Function({bool assetId}) + >; + +class $AssetEditEntityTable extends i4.AssetEditEntity + with i0.TableInfo<$AssetEditEntityTable, i1.AssetEditEntityData> { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $AssetEditEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id'); + @override + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + ); + static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta( + 'assetId', + ); + @override + late final i0.GeneratedColumn assetId = i0.GeneratedColumn( + 'asset_id', + aliasedName, + false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE', + ), + ); + @override + late final i0.GeneratedColumnWithTypeConverter + action = + i0.GeneratedColumn( + 'action', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + ).withConverter( + i1.$AssetEditEntityTable.$converteraction, + ); + @override + late final i0.GeneratedColumnWithTypeConverter< + Map, + i3.Uint8List + > + parameters = + i0.GeneratedColumn( + 'parameters', + aliasedName, + false, + type: i0.DriftSqlType.blob, + requiredDuringInsert: true, + ).withConverter>( + i1.$AssetEditEntityTable.$converterparameters, + ); + static const i0.VerificationMeta _sequenceMeta = const i0.VerificationMeta( + 'sequence', + ); + @override + late final i0.GeneratedColumn sequence = i0.GeneratedColumn( + 'sequence', + aliasedName, + false, + type: i0.DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + assetId, + action, + parameters, + sequence, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'asset_edit_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable instance, { + bool isInserting = false, + }) { + final context = i0.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('asset_id')) { + context.handle( + _assetIdMeta, + assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta), + ); + } else if (isInserting) { + context.missing(_assetIdMeta); + } + if (data.containsKey('sequence')) { + context.handle( + _sequenceMeta, + sequence.isAcceptableOrUnknown(data['sequence']!, _sequenceMeta), + ); + } else if (isInserting) { + context.missing(_sequenceMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + i1.AssetEditEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.AssetEditEntityData( + id: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + assetId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, + data['${effectivePrefix}asset_id'], + )!, + action: i1.$AssetEditEntityTable.$converteraction.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}action'], + )!, + ), + parameters: i1.$AssetEditEntityTable.$converterparameters.fromSql( + attachedDatabase.typeMapping.read( + i0.DriftSqlType.blob, + data['${effectivePrefix}parameters'], + )!, + ), + sequence: attachedDatabase.typeMapping.read( + i0.DriftSqlType.int, + data['${effectivePrefix}sequence'], + )!, + ); + } + + @override + $AssetEditEntityTable createAlias(String alias) { + return $AssetEditEntityTable(attachedDatabase, alias); + } + + static i0.JsonTypeConverter2 $converteraction = + const i0.EnumIndexConverter( + i2.AssetEditAction.values, + ); + static i0.JsonTypeConverter2, i3.Uint8List, Object?> + $converterparameters = i4.editParameterConverter; + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class AssetEditEntityData extends i0.DataClass + implements i0.Insertable { + final String id; + final String assetId; + final i2.AssetEditAction action; + final Map parameters; + final int sequence; + const AssetEditEntityData({ + required this.id, + required this.assetId, + required this.action, + required this.parameters, + required this.sequence, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['asset_id'] = i0.Variable(assetId); + { + map['action'] = i0.Variable( + i1.$AssetEditEntityTable.$converteraction.toSql(action), + ); + } + { + map['parameters'] = i0.Variable( + i1.$AssetEditEntityTable.$converterparameters.toSql(parameters), + ); + } + map['sequence'] = i0.Variable(sequence); + return map; + } + + factory AssetEditEntityData.fromJson( + Map json, { + i0.ValueSerializer? serializer, + }) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return AssetEditEntityData( + id: serializer.fromJson(json['id']), + assetId: serializer.fromJson(json['assetId']), + action: i1.$AssetEditEntityTable.$converteraction.fromJson( + serializer.fromJson(json['action']), + ), + parameters: i1.$AssetEditEntityTable.$converterparameters.fromJson( + serializer.fromJson(json['parameters']), + ), + sequence: serializer.fromJson(json['sequence']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'assetId': serializer.toJson(assetId), + 'action': serializer.toJson( + i1.$AssetEditEntityTable.$converteraction.toJson(action), + ), + 'parameters': serializer.toJson( + i1.$AssetEditEntityTable.$converterparameters.toJson(parameters), + ), + 'sequence': serializer.toJson(sequence), + }; + } + + i1.AssetEditEntityData copyWith({ + String? id, + String? assetId, + i2.AssetEditAction? action, + Map? parameters, + int? sequence, + }) => i1.AssetEditEntityData( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + action: action ?? this.action, + parameters: parameters ?? this.parameters, + sequence: sequence ?? this.sequence, + ); + AssetEditEntityData copyWithCompanion(i1.AssetEditEntityCompanion data) { + return AssetEditEntityData( + id: data.id.present ? data.id.value : this.id, + assetId: data.assetId.present ? data.assetId.value : this.assetId, + action: data.action.present ? data.action.value : this.action, + parameters: data.parameters.present + ? data.parameters.value + : this.parameters, + sequence: data.sequence.present ? data.sequence.value : this.sequence, + ); + } + + @override + String toString() { + return (StringBuffer('AssetEditEntityData(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('action: $action, ') + ..write('parameters: $parameters, ') + ..write('sequence: $sequence') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, assetId, action, parameters, sequence); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.AssetEditEntityData && + other.id == this.id && + other.assetId == this.assetId && + other.action == this.action && + other.parameters == this.parameters && + other.sequence == this.sequence); +} + +class AssetEditEntityCompanion + extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value assetId; + final i0.Value action; + final i0.Value> parameters; + final i0.Value sequence; + const AssetEditEntityCompanion({ + this.id = const i0.Value.absent(), + this.assetId = const i0.Value.absent(), + this.action = const i0.Value.absent(), + this.parameters = const i0.Value.absent(), + this.sequence = const i0.Value.absent(), + }); + AssetEditEntityCompanion.insert({ + required String id, + required String assetId, + required i2.AssetEditAction action, + required Map parameters, + required int sequence, + }) : id = i0.Value(id), + assetId = i0.Value(assetId), + action = i0.Value(action), + parameters = i0.Value(parameters), + sequence = i0.Value(sequence); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? assetId, + i0.Expression? action, + i0.Expression? parameters, + i0.Expression? sequence, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (assetId != null) 'asset_id': assetId, + if (action != null) 'action': action, + if (parameters != null) 'parameters': parameters, + if (sequence != null) 'sequence': sequence, + }); + } + + i1.AssetEditEntityCompanion copyWith({ + i0.Value? id, + i0.Value? assetId, + i0.Value? action, + i0.Value>? parameters, + i0.Value? sequence, + }) { + return i1.AssetEditEntityCompanion( + id: id ?? this.id, + assetId: assetId ?? this.assetId, + action: action ?? this.action, + parameters: parameters ?? this.parameters, + sequence: sequence ?? this.sequence, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (assetId.present) { + map['asset_id'] = i0.Variable(assetId.value); + } + if (action.present) { + map['action'] = i0.Variable( + i1.$AssetEditEntityTable.$converteraction.toSql(action.value), + ); + } + if (parameters.present) { + map['parameters'] = i0.Variable( + i1.$AssetEditEntityTable.$converterparameters.toSql(parameters.value), + ); + } + if (sequence.present) { + map['sequence'] = i0.Variable(sequence.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AssetEditEntityCompanion(') + ..write('id: $id, ') + ..write('assetId: $assetId, ') + ..write('action: $action, ') + ..write('parameters: $parameters, ') + ..write('sequence: $sequence') + ..write(')')) + .toString(); + } +} diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index 77cae5dbbe..06262f4afc 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -152,6 +152,8 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData { fileSize: fileSize, dateTimeOriginal: dateTimeOriginal, rating: rating, + width: width, + height: height, timeZone: timeZone, make: make, model: model, diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 652e9de943..5e2d05135e 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; @@ -66,6 +67,7 @@ class IsarDatabaseRepository implements IDatabaseRepository { AssetFaceEntity, StoreEntity, TrashedLocalAssetEntity, + AssetEditEntity, ], include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, ) diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index df4172df99..cb09590575 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -1,7 +1,10 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/stack.model.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' hide ExifInfo; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; @@ -9,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift. import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:uuid/uuid.dart'; class RemoteAssetRepository extends DriftDatabaseRepository { final Drift _db; @@ -264,4 +268,35 @@ class RemoteAssetRepository extends DriftDatabaseRepository { Future getCount() { return _db.managers.remoteAssetEntity.count(); } + + Future> getAssetEdits(String assetId) async { + final query = _db.assetEditEntity.select() + ..where((row) => row.assetId.equals(assetId)) + ..orderBy([(row) => OrderingTerm.asc(row.sequence)]); + + return query.map((row) => row.toDto()).get(); + } + + Future editAsset(String assetId, List edits) async { + await _db.transaction(() async { + await _db.batch((batch) async { + // delete existing edits + batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(assetId)); + + // insert new edits + for (var i = 0; i < edits.length; i++) { + final edit = edits[i]; + final companion = AssetEditEntityCompanion( + id: Value(const Uuid().v4()), + assetId: Value(assetId), + action: Value(edit.action), + parameters: Value(edit.parameters), + sequence: Value(i), + ); + + batch.insert(_db.assetEditEntity, companion); + } + }); + }); + } } diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index d13083d706..66ccebe40b 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -49,6 +49,7 @@ class SyncApiRepository { SyncRequestType.usersV1, SyncRequestType.assetsV1, SyncRequestType.assetExifsV1, + SyncRequestType.assetEditsV1, SyncRequestType.assetMetadataV1, SyncRequestType.partnersV1, SyncRequestType.partnerAssetsV1, @@ -153,6 +154,8 @@ const _kResponseMap = { SyncEntityType.assetV1: SyncAssetV1.fromJson, SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson, SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson, + SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson, + SyncEntityType.assetEditDeleteV1: SyncAssetEditDeleteV1.fromJson, SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson, SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson, SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson, diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 26f89432a5..09a6a8da10 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -5,9 +5,11 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user_metadata.model.dart'; +import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; @@ -26,8 +28,8 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey; -import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey; +import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction; +import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction; class SyncStreamRepository extends DriftDatabaseRepository { final Logger _logger = Logger('DriftSyncStreamRepository'); @@ -58,6 +60,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { await _db.userEntity.deleteAll(); await _db.userMetadataEntity.deleteAll(); await _db.remoteAssetCloudIdEntity.deleteAll(); + await _db.assetEditEntity.deleteAll(); }); await _db.customStatement('PRAGMA foreign_keys = ON'); }); @@ -278,6 +281,40 @@ class SyncStreamRepository extends DriftDatabaseRepository { } } + Future updateAssetEditsV1(Iterable data, {String debugLabel = 'user'}) async { + try { + await _db.batch((batch) { + for (final edit in data) { + final companion = AssetEditEntityCompanion( + id: Value(edit.id), + assetId: Value(edit.assetId), + action: Value(edit.action.toAssetEditAction()), + parameters: Value(edit.parameters as Map), + sequence: Value(edit.sequence), + ); + + batch.insert(_db.assetEditEntity, companion, onConflict: DoUpdate((_) => companion)); + } + }); + } catch (error, stack) { + _logger.severe('Error: updateAssetEditsV1 - $debugLabel', error, stack); + rethrow; + } + } + + Future deleteAssetEditsV1(Iterable data, {String debugLabel = 'user'}) async { + try { + await _db.batch((batch) { + for (final edit in data) { + batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(edit.assetId)); + } + }); + } catch (error, stack) { + _logger.severe('Error: deleteAssetEditsV1 - $debugLabel', error, stack); + rethrow; + } + } + Future deleteAssetsMetadataV1(Iterable data) async { try { await _db.batch((batch) { @@ -767,3 +804,12 @@ extension on String { extension on UserAvatarColor { AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value); } + +extension on api.AssetEditAction { + AssetEditAction toAssetEditAction() => switch (this) { + api.AssetEditAction.crop => AssetEditAction.crop, + api.AssetEditAction.rotate => AssetEditAction.rotate, + api.AssetEditAction.mirror => AssetEditAction.mirror, + _ => AssetEditAction.other, + }; +} diff --git a/mobile/lib/presentation/pages/drift_edit.page.dart b/mobile/lib/presentation/pages/drift_edit.page.dart new file mode 100644 index 0000000000..501ab0ca5e --- /dev/null +++ b/mobile/lib/presentation/pages/drift_edit.page.dart @@ -0,0 +1,476 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; +import 'package:crop_image/crop_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/theme.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/theme/theme_data.dart'; +import 'package:immich_mobile/utils/editor.utils.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:openapi/api.dart' show CropParameters, RotateParameters, MirrorParameters, MirrorAxis; + +@RoutePage() +class DriftEditImagePage extends ConsumerStatefulWidget { + final Image image; + final BaseAsset asset; + final List edits; + final ExifInfo exifInfo; + + const DriftEditImagePage({ + super.key, + required this.image, + required this.asset, + required this.edits, + required this.exifInfo, + }); + + @override + ConsumerState createState() => _DriftEditImagePageState(); +} + +class _DriftEditImagePageState extends ConsumerState with TickerProviderStateMixin { + late final CropController cropController; + + Duration _rotationAnimationDuration = const Duration(milliseconds: 250); + + int _rotationAngle = 0; + bool _flipHorizontal = false; + bool _flipVertical = false; + + double? aspectRatio; + + late final originalWidth = widget.exifInfo.isFlipped ? widget.exifInfo.height : widget.exifInfo.width; + late final originalHeight = widget.exifInfo.isFlipped ? widget.exifInfo.width : widget.exifInfo.height; + + bool isEditing = false; + + void initEditor() { + final existingCrop = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.crop); + + Rect crop = existingCrop != null && originalWidth != null && originalHeight != null + ? convertCropParametersToRect( + CropParameters.fromJson(existingCrop.parameters)!, + originalWidth!, + originalHeight!, + ) + : const Rect.fromLTRB(0, 0, 1, 1); + + cropController = CropController(defaultCrop: crop); + + final (rotationAngle, flipHorizontal, flipVertical) = normalizeTransformEdits(widget.edits); + + // dont animate to initial rotation + _rotationAnimationDuration = const Duration(milliseconds: 0); + _rotationAngle = rotationAngle.toInt(); + + _flipHorizontal = flipHorizontal; + _flipVertical = flipVertical; + } + + Future _saveEditedImage() async { + setState(() { + isEditing = true; + }); + + final cropParameters = convertRectToCropParameters(cropController.crop, originalWidth ?? 0, originalHeight ?? 0); + final normalizedRotation = (_rotationAngle % 360 + 360) % 360; + final edits = []; + + if (cropParameters.width != originalWidth || cropParameters.height != originalHeight) { + edits.add(AssetEdit(action: AssetEditAction.crop, parameters: cropParameters.toJson())); + } + + if (_flipHorizontal) { + edits.add( + AssetEdit( + action: AssetEditAction.mirror, + parameters: MirrorParameters(axis: MirrorAxis.horizontal).toJson(), + ), + ); + } + + if (_flipVertical) { + edits.add( + AssetEdit( + action: AssetEditAction.mirror, + parameters: MirrorParameters(axis: MirrorAxis.vertical).toJson(), + ), + ); + } + + if (normalizedRotation != 0) { + edits.add( + AssetEdit( + action: AssetEditAction.rotate, + parameters: RotateParameters(angle: normalizedRotation).toJson(), + ), + ); + } + + try { + final completer = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) { + final eventData = data as Map; + return eventData["asset"]['id'] == widget.asset.remoteId; + }, const Duration(seconds: 10)); + + await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits); + await completer; + + ImmichToast.show(context: context, msg: 'asset_edit_success'.tr(), toastType: ToastType.success); + + context.pop(); + } catch (e) { + if (mounted) { + ImmichToast.show(context: context, msg: 'asset_edit_failed'.tr(), toastType: ToastType.error); + } + return; + } finally { + setState(() { + isEditing = false; + }); + } + } + + @override + void initState() { + super.initState(); + initEditor(); + } + + @override + void dispose() { + cropController.dispose(); + super.dispose(); + } + + Widget _buildProgressIndicator() { + return const Padding( + padding: EdgeInsets.all(8.0), + child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2.5)), + ); + } + + void _rotateLeft() { + setState(() { + _rotationAnimationDuration = const Duration(milliseconds: 150); + _rotationAngle -= 90; + }); + } + + void _rotateRight() { + setState(() { + _rotationAnimationDuration = const Duration(milliseconds: 150); + _rotationAngle += 90; + }); + } + + void _flipHorizontally() { + setState(() { + if (_rotationAngle % 180 != 0) { + // When rotated 90 or 270 degrees, flipping horizontally is equivalent to flipping vertically + _flipVertical = !_flipVertical; + } else { + _flipHorizontal = !_flipHorizontal; + } + }); + } + + void _flipVertically() { + setState(() { + if (_rotationAngle % 180 != 0) { + // When rotated 90 or 270 degrees, flipping vertically is equivalent to flipping horizontally + _flipHorizontal = !_flipHorizontal; + } else { + _flipVertical = !_flipVertical; + } + }); + } + + @override + Widget build(BuildContext context) { + return Theme( + data: getThemeData(colorScheme: ref.watch(immichThemeProvider).dark, locale: context.locale), + child: Scaffold( + appBar: AppBar( + backgroundColor: Colors.black, + title: Text("edit".tr()), + leading: const ImmichCloseButton(), + actions: [ + isEditing + ? _buildProgressIndicator() + : ImmichIconButton( + icon: Icons.done_rounded, + color: ImmichColor.primary, + variant: ImmichVariant.ghost, + onPressed: _saveEditedImage, + ), + ], + ), + backgroundColor: Colors.black, + body: SafeArea( + bottom: false, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + // Calculate the bounding box size needed for the rotated container + final baseWidth = constraints.maxWidth * 0.9; + final baseHeight = constraints.maxHeight * 0.8; + + return Column( + children: [ + SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight * 0.7, + child: Center( + child: AnimatedRotation( + turns: _rotationAngle / 360, + duration: _rotationAnimationDuration, + curve: Curves.easeInOut, + child: Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..scaleByDouble(_flipHorizontal ? -1.0 : 1.0, _flipVertical ? -1.0 : 1.0, 1.0, 1.0), + child: Container( + padding: const EdgeInsets.all(10), + width: (_rotationAngle % 180 == 0) ? baseWidth : baseHeight, + height: (_rotationAngle % 180 == 0) ? baseHeight : baseWidth, + child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white), + ), + ), + ), + ), + ), + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: context.scaffoldBackgroundColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + ImmichIconButton( + icon: Icons.rotate_left, + variant: ImmichVariant.ghost, + color: ImmichColor.secondary, + onPressed: _rotateLeft, + ), + const SizedBox(width: 8), + ImmichIconButton( + icon: Icons.rotate_right, + variant: ImmichVariant.ghost, + color: ImmichColor.secondary, + onPressed: _rotateRight, + ), + ], + ), + Row( + children: [ + ImmichIconButton( + icon: Icons.flip, + variant: ImmichVariant.ghost, + color: _flipHorizontal ? ImmichColor.primary : ImmichColor.secondary, + onPressed: _flipHorizontally, + ), + const SizedBox(width: 8), + Transform.rotate( + angle: pi / 2, + child: ImmichIconButton( + icon: Icons.flip, + variant: ImmichVariant.ghost, + color: _flipVertical ? ImmichColor.primary : ImmichColor.secondary, + onPressed: _flipVertically, + ), + ), + ], + ), + ], + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + spacing: 12, + children: [ + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: null, + label: 'Free', + onPressed: () { + setState(() { + aspectRatio = null; + cropController.aspectRatio = null; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 1.0, + label: '1:1', + onPressed: () { + setState(() { + aspectRatio = 1.0; + cropController.aspectRatio = 1.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 16.0 / 9.0, + label: '16:9', + onPressed: () { + setState(() { + aspectRatio = 16.0 / 9.0; + cropController.aspectRatio = 16.0 / 9.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 3.0 / 2.0, + label: '3:2', + onPressed: () { + setState(() { + aspectRatio = 3.0 / 2.0; + cropController.aspectRatio = 3.0 / 2.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 7.0 / 5.0, + label: '7:5', + onPressed: () { + setState(() { + aspectRatio = 7.0 / 5.0; + cropController.aspectRatio = 7.0 / 5.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 9.0 / 16.0, + label: '9:16', + onPressed: () { + setState(() { + aspectRatio = 9.0 / 16.0; + cropController.aspectRatio = 9.0 / 16.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 2.0 / 3.0, + label: '2:3', + onPressed: () { + setState(() { + aspectRatio = 2.0 / 3.0; + cropController.aspectRatio = 2.0 / 3.0; + }); + }, + ), + _AspectRatioButton( + cropController: cropController, + currentAspectRatio: aspectRatio, + ratio: 5.0 / 7.0, + label: '5:7', + onPressed: () { + setState(() { + aspectRatio = 5.0 / 7.0; + cropController.aspectRatio = 5.0 / 7.0; + }); + }, + ), + ], + ), + ), + const Spacer(), + ], + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _AspectRatioButton extends StatelessWidget { + final CropController cropController; + final double? currentAspectRatio; + final double? ratio; + final String label; + final VoidCallback onPressed; + + const _AspectRatioButton({ + required this.cropController, + required this.currentAspectRatio, + required this.ratio, + required this.label, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + IconButton( + iconSize: 36, + icon: Transform.rotate( + angle: (ratio ?? 1.0) < 1.0 ? pi / 2 : 0, + child: Icon(switch (label) { + 'Free' => Icons.crop_free_rounded, + '1:1' => Icons.crop_square_rounded, + '16:9' => Icons.crop_16_9_rounded, + '3:2' => Icons.crop_3_2_rounded, + '7:5' => Icons.crop_7_5_rounded, + '9:16' => Icons.crop_16_9_rounded, + '2:3' => Icons.crop_3_2_rounded, + '5:7' => Icons.crop_7_5_rounded, + _ => Icons.crop_free_rounded, + }, color: currentAspectRatio == ratio ? context.primaryColor : context.themeData.iconTheme.color), + ), + onPressed: onPressed, + ), + Text(label, style: context.textTheme.displayMedium), + ], + ); + } +} diff --git a/mobile/lib/presentation/pages/editing/drift_crop.page.dart b/mobile/lib/presentation/pages/editing/drift_crop.page.dart deleted file mode 100644 index a213e4c640..0000000000 --- a/mobile/lib/presentation/pages/editing/drift_crop.page.dart +++ /dev/null @@ -1,179 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:crop_image/crop_image.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart'; -import 'package:immich_ui/immich_ui.dart'; - -/// A widget for cropping an image. -/// This widget uses [HookWidget] to manage its lifecycle and state. It allows -/// users to crop an image and then navigate to the [EditImagePage] with the -/// cropped image. - -@RoutePage() -class DriftCropImagePage extends HookWidget { - final Image image; - final BaseAsset asset; - const DriftCropImagePage({super.key, required this.image, required this.asset}); - - @override - Widget build(BuildContext context) { - final cropController = useCropController(); - final aspectRatio = useState(null); - - return Scaffold( - appBar: AppBar( - backgroundColor: context.scaffoldBackgroundColor, - title: Text("crop".tr()), - leading: const ImmichCloseButton(), - actions: [ - ImmichIconButton( - icon: Icons.done_rounded, - color: ImmichColor.primary, - variant: ImmichVariant.ghost, - onPressed: () async { - final croppedImage = await cropController.croppedImage(); - unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true))); - }, - ), - ], - ), - backgroundColor: context.scaffoldBackgroundColor, - body: SafeArea( - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return Column( - children: [ - Container( - padding: const EdgeInsets.only(top: 20), - width: constraints.maxWidth * 0.9, - height: constraints.maxHeight * 0.6, - child: CropImage(controller: cropController, image: image, gridColor: Colors.white), - ), - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: context.scaffoldBackgroundColor, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), - ), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ImmichIconButton( - icon: Icons.rotate_left, - variant: ImmichVariant.ghost, - color: ImmichColor.secondary, - onPressed: () => cropController.rotateLeft(), - ), - ImmichIconButton( - icon: Icons.rotate_right, - variant: ImmichVariant.ghost, - color: ImmichColor.secondary, - onPressed: () => cropController.rotateRight(), - ), - ], - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: null, - label: 'Free', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 1.0, - label: '1:1', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 16.0 / 9.0, - label: '16:9', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 3.0 / 2.0, - label: '3:2', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 7.0 / 5.0, - label: '7:5', - ), - ], - ), - ], - ), - ), - ), - ), - ], - ); - }, - ), - ), - ); - } -} - -class _AspectRatioButton extends StatelessWidget { - final CropController cropController; - final ValueNotifier aspectRatio; - final double? ratio; - final String label; - - const _AspectRatioButton({ - required this.cropController, - required this.aspectRatio, - required this.ratio, - required this.label, - }); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(switch (label) { - 'Free' => Icons.crop_free_rounded, - '1:1' => Icons.crop_square_rounded, - '16:9' => Icons.crop_16_9_rounded, - '3:2' => Icons.crop_3_2_rounded, - '7:5' => Icons.crop_7_5_rounded, - _ => Icons.crop_free_rounded, - }, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color), - onPressed: () { - cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9); - aspectRatio.value = ratio; - cropController.aspectRatio = ratio; - }, - ), - Text(label, style: context.textTheme.displayMedium), - ], - ); - } -} diff --git a/mobile/lib/presentation/pages/editing/drift_edit.page.dart b/mobile/lib/presentation/pages/editing/drift_edit.page.dart deleted file mode 100644 index 7e49348e19..0000000000 --- a/mobile/lib/presentation/pages/editing/drift_edit.page.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:auto_route/auto_route.dart'; -import 'package:cancellation_token_http/http.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/background_sync.provider.dart'; -import 'package:immich_mobile/repositories/file_media.repository.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/services/foreground_upload.service.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as p; - -/// A stateless widget that provides functionality for editing an image. -/// -/// This widget allows users to edit an image provided either as an [Asset] or -/// directly as an [Image]. It ensures that exactly one of these is provided. -/// -/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone -/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server. -@immutable -@RoutePage() -class DriftEditImagePage extends ConsumerWidget { - final BaseAsset asset; - final Image image; - final bool isEdited; - - const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited}); - Future _imageToUint8List(Image image) async { - final Completer completer = Completer(); - image.image - .resolve(const ImageConfiguration()) - .addListener( - ImageStreamListener((ImageInfo info, bool _) { - info.image.toByteData(format: ImageByteFormat.png).then((byteData) { - if (byteData != null) { - completer.complete(byteData.buffer.asUint8List()); - } else { - completer.completeError('Failed to convert image to bytes'); - } - }); - }, onError: (exception, stackTrace) => completer.completeError(exception)), - ); - return completer.future; - } - - void _exitEditing(BuildContext context) { - // this assumes that the only way to get to this page is from the AssetViewerRoute - context.navigator.popUntil((route) => route.data?.name == AssetViewerRoute.name); - } - - Future _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async { - try { - final Uint8List imageData = await _imageToUint8List(image); - LocalAsset? localAsset; - - try { - localAsset = await ref - .read(fileMediaRepositoryProvider) - .saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg"); - } on PlatformException catch (e) { - // OS might not return the saved image back, so we handle that gracefully - // This can happen if app does not have full library access - Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e); - } - - unawaited(ref.read(backgroundSyncProvider).syncLocal(full: true)); - _exitEditing(context); - ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!'); - - if (localAsset == null) { - return; - } - - await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken()); - } catch (e) { - ImmichToast.show( - durationInSecond: 6, - context: context, - msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}), - ); - } - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - appBar: AppBar( - title: Text("edit".tr()), - backgroundColor: context.scaffoldBackgroundColor, - leading: IconButton( - icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24), - onPressed: () => _exitEditing(context), - ), - actions: [ - TextButton( - onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null, - child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)), - ), - ], - ), - backgroundColor: context.scaffoldBackgroundColor, - body: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9), - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(7)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - spreadRadius: 2, - blurRadius: 10, - offset: const Offset(0, 3), - ), - ], - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(7)), - child: Image(image: image.image, fit: BoxFit.contain), - ), - ), - ), - ), - bottomNavigationBar: Container( - height: 70, - margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10), - decoration: BoxDecoration( - color: context.scaffoldBackgroundColor, - borderRadius: const BorderRadius.all(Radius.circular(30)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25), - onPressed: () { - context.pushRoute(DriftCropImageRoute(asset: asset, image: image)); - }, - ), - Text("crop".tr(), style: context.textTheme.displayMedium), - ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25), - onPressed: () { - context.pushRoute(DriftFilterImageRoute(asset: asset, image: image)); - }, - ), - Text("filter".tr(), style: context.textTheme.displayMedium), - ], - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/pages/editing/drift_filter.page.dart b/mobile/lib/presentation/pages/editing/drift_filter.page.dart deleted file mode 100644 index 8198a41bbe..0000000000 --- a/mobile/lib/presentation/pages/editing/drift_filter.page.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/constants/filters.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/routing/router.dart'; - -/// A widget for filtering an image. -/// This widget uses [HookWidget] to manage its lifecycle and state. It allows -/// users to add filters to an image and then navigate to the [EditImagePage] with the -/// final composition.' -@RoutePage() -class DriftFilterImagePage extends HookWidget { - final Image image; - final BaseAsset asset; - - const DriftFilterImagePage({super.key, required this.image, required this.asset}); - - @override - Widget build(BuildContext context) { - final colorFilter = useState(filters[0]); - final selectedFilterIndex = useState(0); - - Future createFilteredImage(ui.Image inputImage, ColorFilter filter) { - final completer = Completer(); - final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble()); - final recorder = ui.PictureRecorder(); - final canvas = Canvas(recorder); - - final paint = Paint()..colorFilter = filter; - canvas.drawImage(inputImage, Offset.zero, paint); - - recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) { - completer.complete(image); - }); - - return completer.future; - } - - void applyFilter(ColorFilter filter, int index) { - colorFilter.value = filter; - selectedFilterIndex.value = index; - } - - Future applyFilterAndConvert(ColorFilter filter) async { - final completer = Completer(); - image.image - .resolve(ImageConfiguration.empty) - .addListener( - ImageStreamListener((ImageInfo info, bool _) { - completer.complete(info.image); - }), - ); - final uiImage = await completer.future; - - final filteredUiImage = await createFilteredImage(uiImage, filter); - final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png); - final pngBytes = byteData!.buffer.asUint8List(); - - return Image.memory(pngBytes, fit: BoxFit.contain); - } - - return Scaffold( - appBar: AppBar( - backgroundColor: context.scaffoldBackgroundColor, - title: Text("filter".tr()), - leading: CloseButton(color: context.primaryColor), - actions: [ - IconButton( - icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24), - onPressed: () async { - final filteredImage = await applyFilterAndConvert(colorFilter.value); - unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true))); - }, - ), - ], - ), - backgroundColor: context.scaffoldBackgroundColor, - body: Column( - children: [ - SizedBox( - height: context.height * 0.7, - child: Center( - child: ColorFiltered(colorFilter: colorFilter.value, child: image), - ), - ), - SizedBox( - height: 120, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: filters.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: _FilterButton( - image: image, - label: filterNames[index], - filter: filters[index], - isSelected: selectedFilterIndex.value == index, - onTap: () => applyFilter(filters[index], index), - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -class _FilterButton extends StatelessWidget { - final Image image; - final String label; - final ColorFilter filter; - final bool isSelected; - final VoidCallback onTap; - - const _FilterButton({ - required this.image, - required this.label, - required this.filter, - required this.isSelected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - GestureDetector( - onTap: onTap, - child: Container( - width: 80, - height: 80, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(10)), - border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null, - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(10)), - child: ColorFiltered( - colorFilter: filter, - child: FittedBox(fit: BoxFit.cover, child: image), - ), - ), - ), - ), - const SizedBox(height: 10), - Text(label, style: context.themeData.textTheme.bodyMedium), - ], - ); - } -} diff --git a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart index 4c7b6ffbdc..d60fc849d1 100644 --- a/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/edit_image_action_button.widget.dart @@ -1,9 +1,12 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -14,13 +17,22 @@ class EditImageActionButton extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final currentAsset = ref.watch(currentAssetNotifier); - onPress() { - if (currentAsset == null) { + Future onPress() async { + if (currentAsset == null || currentAsset.remoteId == null) { return; } - final image = Image(image: getFullImageProvider(currentAsset)); - context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, isEdited: false)); + final imageProvider = getFullImageProvider(currentAsset, edited: false); + + final image = Image(image: imageProvider); + final edits = await ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!); + final exifInfo = await ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!); + + if (exifInfo == null) { + return; + } + + await context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, edits: edits, exifInfo: exifInfo)); } return BaseActionButton( diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 537f2fc31d..4017486427 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -3,12 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; @@ -45,7 +45,7 @@ class ViewerBottomBar extends ConsumerWidget { if (!isInLockedView) ...[ if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer), - if (asset.type == AssetType.image) const EditImageActionButton(), + if (asset.isEditable) const EditImageActionButton(), if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), if (isOwner) ...[ diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 2e10e6856b..795a15a614 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -78,7 +78,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget { final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); final timezone = 'GMT${timeZoneOffset.formatAsOffset()}'; - return '$date$_kSeparator$time $timezone'; + return '${exifInfo?.width}x${exifInfo?.height} $date$_kSeparator$time $timezone'; } String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 3c3ed460b4..1709166340 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -105,7 +105,7 @@ mixin CancellableImageProviderMixin on CancellableImageProvide } } -ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) { +ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) { // Create new provider and cache it final ImageProvider provider; if (_shouldUseLocalAsset(asset)) { @@ -123,13 +123,13 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080 } else { throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); } - provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type); + provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type, edited: edited); } return provider; } -ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) { +ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) { if (_shouldUseLocalAsset(asset)) { final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; return LocalThumbProvider(id: id, size: size, assetType: asset.type); @@ -137,7 +137,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId; final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : ""; - return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash) : null; + return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null; } bool _shouldUseLocalAsset(BaseAsset asset) => diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 20db0cc1e1..de2846a134 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -16,8 +16,8 @@ class RemoteImageProvider extends CancellableImageProvider RemoteImageProvider({required this.url}); - RemoteImageProvider.thumbnail({required String assetId, required String thumbhash}) - : url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash); + RemoteImageProvider.thumbnail({required String assetId, required String thumbhash, bool edited = true}) + : url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited); @override Future obtainKey(ImageConfiguration configuration) { @@ -59,8 +59,14 @@ class RemoteFullImageProvider extends CancellableImageProvider obtainKey(ImageConfiguration configuration) { @@ -71,7 +77,9 @@ class RemoteFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), @@ -90,7 +98,12 @@ class RemoteFullImageProvider extends CancellableImageProvider assetId.hashCode ^ thumbhash.hashCode; + int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ edited.hashCode; } diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 75f40ca290..586b6532b3 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -6,19 +6,20 @@ import 'package:cancellation_token_http/http.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/download.service.dart'; -import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; +import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -473,6 +474,23 @@ class ActionNotifier extends Notifier { }); } } + + Future applyEdits(ActionSource source, List edits) async { + final ids = _getOwnedRemoteIdsForSource(source); + + if (ids.length != 1) { + _logger.warning('applyEdits called with multiple assets, expected single asset'); + return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits'); + } + + try { + await _service.applyEdits(ids.first, edits); + return const ActionResult(count: 1, success: true); + } catch (error, stack) { + _logger.severe('Failed to apply edits to assets', error, stack); + return ActionResult(count: ids.length, success: false, error: error.toString()); + } + } } extension on Iterable { diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index f9473ce440..2ad0a2d755 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -206,6 +206,27 @@ class WebsocketNotifier extends StateNotifier { state.socket?.on('on_upload_success', _handleOnUploadSuccess); } + Future waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) { + final completer = Completer(); + + void handler(dynamic data) { + if (predicate == null || predicate(data)) { + completer.complete(); + state.socket?.off(event, handler); + } + } + + state.socket?.on(event, handler); + + return completer.future.timeout( + timeout, + onTimeout: () { + state.socket?.off(event, handler); + throw TimeoutException("Timeout waiting for event: $event"); + }, + ); + } + void addPendingChange(PendingAction action, dynamic value) { final now = DateTime.now(); state = state.copyWith( diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 011b1edc94..517c591c57 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,12 +1,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:openapi/api.dart'; +import 'package:openapi/api.dart' hide AssetEditAction; final assetApiRepositoryProvider = Provider( (ref) => AssetApiRepository( @@ -105,6 +106,25 @@ class AssetApiRepository extends ApiRepository { Future updateRating(String assetId, int rating) { return _api.updateAsset(assetId, UpdateAssetDto(rating: rating)); } + + Future editAsset(String assetId, List edits) async { + final editDtos = edits + .map((edit) { + if (edit.action == AssetEditAction.other) { + return null; + } + + return AssetEditActionListDtoEditsInner(action: edit.action.toDto()!, parameters: edit.parameters); + }) + .whereType() + .toList(); + + await _api.editAsset(assetId, AssetEditActionListDto(edits: editDtos)); + } + + Future removeEdits(String assetId) async { + await _api.removeAssetEdits(assetId); + } } extension on StackResponseDto { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 9468b105e5..cdece894b4 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; @@ -78,6 +80,7 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/settings/sync_status.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; +import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart'; @@ -88,8 +91,8 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart'; -import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart'; import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_edit.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart'; @@ -106,9 +109,6 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; -import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart'; -import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart'; -import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; @@ -332,8 +332,6 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftEditImageRoute.page), - AutoRoute(page: DriftCropImageRoute.page), - AutoRoute(page: DriftFilterImageRoute.page), AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b287d73114..344021caf2 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -982,70 +982,24 @@ class DriftCreateAlbumRoute extends PageRouteInfo { ); } -/// generated route for -/// [DriftCropImagePage] -class DriftCropImageRoute extends PageRouteInfo { - DriftCropImageRoute({ - Key? key, - required Image image, - required BaseAsset asset, - List? children, - }) : super( - DriftCropImageRoute.name, - args: DriftCropImageRouteArgs(key: key, image: image, asset: asset), - initialChildren: children, - ); - - static const String name = 'DriftCropImageRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return DriftCropImagePage( - key: args.key, - image: args.image, - asset: args.asset, - ); - }, - ); -} - -class DriftCropImageRouteArgs { - const DriftCropImageRouteArgs({ - this.key, - required this.image, - required this.asset, - }); - - final Key? key; - - final Image image; - - final BaseAsset asset; - - @override - String toString() { - return 'DriftCropImageRouteArgs{key: $key, image: $image, asset: $asset}'; - } -} - /// generated route for /// [DriftEditImagePage] class DriftEditImageRoute extends PageRouteInfo { DriftEditImageRoute({ Key? key, - required BaseAsset asset, required Image image, - required bool isEdited, + required BaseAsset asset, + required List edits, + required ExifInfo exifInfo, List? children, }) : super( DriftEditImageRoute.name, args: DriftEditImageRouteArgs( key: key, - asset: asset, image: image, - isEdited: isEdited, + asset: asset, + edits: edits, + exifInfo: exifInfo, ), initialChildren: children, ); @@ -1058,9 +1012,10 @@ class DriftEditImageRoute extends PageRouteInfo { final args = data.argsAs(); return DriftEditImagePage( key: args.key, - asset: args.asset, image: args.image, - isEdited: args.isEdited, + asset: args.asset, + edits: args.edits, + exifInfo: args.exifInfo, ); }, ); @@ -1069,22 +1024,25 @@ class DriftEditImageRoute extends PageRouteInfo { class DriftEditImageRouteArgs { const DriftEditImageRouteArgs({ this.key, - required this.asset, required this.image, - required this.isEdited, + required this.asset, + required this.edits, + required this.exifInfo, }); final Key? key; - final BaseAsset asset; - final Image image; - final bool isEdited; + final BaseAsset asset; + + final List edits; + + final ExifInfo exifInfo; @override String toString() { - return 'DriftEditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}'; + return 'DriftEditImageRouteArgs{key: $key, image: $image, asset: $asset, edits: $edits, exifInfo: $exifInfo}'; } } @@ -1104,54 +1062,6 @@ class DriftFavoriteRoute extends PageRouteInfo { ); } -/// generated route for -/// [DriftFilterImagePage] -class DriftFilterImageRoute extends PageRouteInfo { - DriftFilterImageRoute({ - Key? key, - required Image image, - required BaseAsset asset, - List? children, - }) : super( - DriftFilterImageRoute.name, - args: DriftFilterImageRouteArgs(key: key, image: image, asset: asset), - initialChildren: children, - ); - - static const String name = 'DriftFilterImageRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - final args = data.argsAs(); - return DriftFilterImagePage( - key: args.key, - image: args.image, - asset: args.asset, - ); - }, - ); -} - -class DriftFilterImageRouteArgs { - const DriftFilterImageRouteArgs({ - this.key, - required this.image, - required this.asset, - }); - - final Key? key; - - final Image image; - - final BaseAsset asset; - - @override - String toString() { - return 'DriftFilterImageRouteArgs{key: $key, image: $image, asset: $asset}'; - } -} - /// generated route for /// [DriftLibraryPage] class DriftLibraryRoute extends PageRouteInfo { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 3d3ef1494c..704fdd8edd 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; @@ -240,6 +241,16 @@ class ActionService { return _downloadRepository.downloadAllAssets(assets); } + Future applyEdits(String remoteId, List edits) async { + if (edits.isEmpty) { + await _assetApiRepository.removeEdits(remoteId); + } else { + await _assetApiRepository.editAsset(remoteId, edits); + } + + await _remoteAssetRepository.editAsset(remoteId, edits); + } + Future _deleteLocalAssets(List localIds) async { final deletedIds = await _assetMediaRepository.deleteAll(localIds); if (deletedIds.isEmpty) { diff --git a/mobile/lib/utils/editor.utils.dart b/mobile/lib/utils/editor.utils.dart new file mode 100644 index 0000000000..e660e5adbc --- /dev/null +++ b/mobile/lib/utils/editor.utils.dart @@ -0,0 +1,75 @@ +import 'dart:math'; + +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/utils/matrix.utils.dart'; +import 'package:openapi/api.dart' hide AssetEditAction; + +Rect convertCropParametersToRect(CropParameters parameters, int originalWidth, int originalHeight) { + return Rect.fromLTWH( + parameters.x.toDouble() / originalWidth, + parameters.y.toDouble() / originalHeight, + parameters.width.toDouble() / originalWidth, + parameters.height.toDouble() / originalHeight, + ); +} + +CropParameters convertRectToCropParameters(Rect rect, int originalWidth, int originalHeight) { + final x = (rect.left * originalWidth).round(); + final y = (rect.top * originalHeight).round(); + final width = (rect.width * originalWidth).round(); + final height = (rect.height * originalHeight).round(); + + return CropParameters( + x: max(x, 0).clamp(0, originalWidth), + y: max(y, 0).clamp(0, originalHeight), + width: max(width, 0).clamp(0, originalWidth - x), + height: max(height, 0).clamp(0, originalHeight - y), + ); +} + +AffineMatrix buildAffineFromEdits(List edits) { + return AffineMatrix.compose( + edits.map((edit) { + switch (edit.action) { + case AssetEditAction.rotate: + final angleInDegrees = edit.parameters["angle"] as num; + final angleInRadians = angleInDegrees * pi / 180; + return AffineMatrix.rotate(angleInRadians); + case AssetEditAction.mirror: + final axis = edit.parameters["axis"] as String; + return axis == "horizontal" ? AffineMatrix.flipY() : AffineMatrix.flipX(); + default: + return AffineMatrix.identity(); + } + }).toList(), + ); +} + +(double, bool, bool) normalizeTransformEdits(List edits) { + double rotation = 0; + bool flipX = false; + bool flipY = false; + + final matrix = buildAffineFromEdits(edits); + + // round to avoid floating point precision issues + int a = matrix.a.round(); + int b = matrix.b.round(); + int c = matrix.c.round(); + int d = matrix.d.round(); + + // [ +/-1, 0, 0, +/-1 ] indicates a 0° or 180° rotation with possible mirrors + // [ 0, +/-1, +/-1, 0 ] indicates a 90° or 270° rotation with possible mirrors + if (a.abs() == 1 && b.abs() == 0 && c.abs() == 0 && d.abs() == 1) { + rotation = a > 0 ? 0 : 180; + flipX = rotation == 0 ? a < 0 : a > 0; + flipY = rotation == 0 ? d < 0 : d > 0; + } else if (a.abs() == 0 && b.abs() == 1 && c.abs() == 1 && d.abs() == 0) { + rotation = c > 0 ? 90 : 270; + flipX = rotation == 90 ? c < 0 : c > 0; + flipY = rotation == 90 ? b > 0 : b < 0; + } + + return (rotation, flipX, flipY); +} diff --git a/mobile/lib/utils/hooks/crop_controller_hook.dart b/mobile/lib/utils/hooks/crop_controller_hook.dart index 663bca3dbf..b5bd536ecb 100644 --- a/mobile/lib/utils/hooks/crop_controller_hook.dart +++ b/mobile/lib/utils/hooks/crop_controller_hook.dart @@ -1,8 +1,14 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:crop_image/crop_image.dart'; import 'dart:ui'; // Import the dart:ui library for Rect +import 'package:crop_image/crop_image.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + /// A hook that provides a [CropController] instance. -CropController useCropController() { - return useMemoized(() => CropController(defaultCrop: const Rect.fromLTRB(0, 0, 1, 1))); +CropController useCropController({Rect? initialCrop, CropRotation? initialRotation}) { + return useMemoized( + () => CropController( + defaultCrop: initialCrop ?? const Rect.fromLTRB(0, 0, 1, 1), + rotation: initialRotation ?? CropRotation.up, + ), + ); } diff --git a/mobile/lib/utils/matrix.utils.dart b/mobile/lib/utils/matrix.utils.dart new file mode 100644 index 0000000000..8363a8b93d --- /dev/null +++ b/mobile/lib/utils/matrix.utils.dart @@ -0,0 +1,50 @@ +import 'dart:math'; + +class AffineMatrix { + final double a; + final double b; + final double c; + final double d; + final double e; + final double f; + + const AffineMatrix(this.a, this.b, this.c, this.d, this.e, this.f); + + @override + String toString() { + return 'AffineMatrix(a: $a, b: $b, c: $c, d: $d, e: $e, f: $f)'; + } + + factory AffineMatrix.identity() { + return const AffineMatrix(1, 0, 0, 1, 0, 0); + } + + AffineMatrix multiply(AffineMatrix other) { + return AffineMatrix( + a * other.a + c * other.b, + b * other.a + d * other.b, + a * other.c + c * other.d, + b * other.c + d * other.d, + a * other.e + c * other.f + e, + b * other.e + d * other.f + f, + ); + } + + factory AffineMatrix.compose([List transformations = const []]) { + return transformations.fold(AffineMatrix.identity(), (acc, matrix) => acc.multiply(matrix)); + } + + factory AffineMatrix.rotate(double angle) { + final cosAngle = cos(angle); + final sinAngle = sin(angle); + return AffineMatrix(cosAngle, -sinAngle, sinAngle, cosAngle, 0, 0); + } + + factory AffineMatrix.flipY() { + return const AffineMatrix(-1, 0, 0, 1, 0, 0); + } + + factory AffineMatrix.flipX() { + return const AffineMatrix(1, 0, 0, -1, 0, 0); + } +} diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 90e426b547..04634b7da6 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -314,6 +314,8 @@ part 'model/sync_album_user_delete_v1.dart'; part 'model/sync_album_user_v1.dart'; part 'model/sync_album_v1.dart'; part 'model/sync_asset_delete_v1.dart'; +part 'model/sync_asset_edit_delete_v1.dart'; +part 'model/sync_asset_edit_v1.dart'; part 'model/sync_asset_exif_v1.dart'; part 'model/sync_asset_face_delete_v1.dart'; part 'model/sync_asset_face_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 7f5cd50ed4..891fcb4335 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -674,6 +674,10 @@ class ApiClient { return SyncAlbumV1.fromJson(value); case 'SyncAssetDeleteV1': return SyncAssetDeleteV1.fromJson(value); + case 'SyncAssetEditDeleteV1': + return SyncAssetEditDeleteV1.fromJson(value); + case 'SyncAssetEditV1': + return SyncAssetEditV1.fromJson(value); case 'SyncAssetExifV1': return SyncAssetExifV1.fromJson(value); case 'SyncAssetFaceDeleteV1': diff --git a/mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart new file mode 100644 index 0000000000..86facd9534 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart @@ -0,0 +1,99 @@ +// +// 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 SyncAssetEditDeleteV1 { + /// Returns a new [SyncAssetEditDeleteV1] instance. + SyncAssetEditDeleteV1({ + required this.assetId, + }); + + String assetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditDeleteV1 && + other.assetId == assetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode); + + @override + String toString() => 'SyncAssetEditDeleteV1[assetId=$assetId]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + return json; + } + + /// Returns a new [SyncAssetEditDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetEditDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetEditDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetEditDeleteV1( + assetId: mapValueOfType(json, r'assetId')!, + ); + } + 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 = SyncAssetEditDeleteV1.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 = SyncAssetEditDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetEditDeleteV1-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] = SyncAssetEditDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_edit_v1.dart b/mobile/openapi/lib/model/sync_asset_edit_v1.dart new file mode 100644 index 0000000000..3cc2673bfc --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_edit_v1.dart @@ -0,0 +1,131 @@ +// +// 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 SyncAssetEditV1 { + /// Returns a new [SyncAssetEditV1] instance. + SyncAssetEditV1({ + required this.action, + required this.assetId, + required this.id, + required this.parameters, + required this.sequence, + }); + + AssetEditAction action; + + String assetId; + + String id; + + Object parameters; + + int sequence; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditV1 && + other.action == action && + other.assetId == assetId && + other.id == id && + other.parameters == parameters && + other.sequence == sequence; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (action.hashCode) + + (assetId.hashCode) + + (id.hashCode) + + (parameters.hashCode) + + (sequence.hashCode); + + @override + String toString() => 'SyncAssetEditV1[action=$action, assetId=$assetId, id=$id, parameters=$parameters, sequence=$sequence]'; + + Map toJson() { + final json = {}; + json[r'action'] = this.action; + json[r'assetId'] = this.assetId; + json[r'id'] = this.id; + json[r'parameters'] = this.parameters; + json[r'sequence'] = this.sequence; + return json; + } + + /// Returns a new [SyncAssetEditV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetEditV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetEditV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetEditV1( + action: AssetEditAction.fromJson(json[r'action'])!, + assetId: mapValueOfType(json, r'assetId')!, + id: mapValueOfType(json, r'id')!, + parameters: mapValueOfType(json, r'parameters')!, + sequence: mapValueOfType(json, r'sequence')!, + ); + } + 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 = SyncAssetEditV1.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 = SyncAssetEditV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetEditV1-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] = SyncAssetEditV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'assetId', + 'id', + 'parameters', + 'sequence', + }; +} + diff --git a/mobile/test/utils/editor_test.dart b/mobile/test/utils/editor_test.dart new file mode 100644 index 0000000000..86beb637bc --- /dev/null +++ b/mobile/test/utils/editor_test.dart @@ -0,0 +1,321 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/utils/editor.utils.dart'; + +List normalizedToEdits(double rotation, bool mirrorH, bool mirrorV) { + List edits = []; + + if (mirrorH) { + edits.add(const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"})); + } + + if (mirrorV) { + edits.add(const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"})); + } + + if (rotation != 0) { + edits.add(AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": rotation})); + } + + return edits; +} + +bool compareEditAffines(List editsA, List editsB) { + final normA = buildAffineFromEdits(editsA); + final normB = buildAffineFromEdits(editsB); + + return ((normA.a - normB.a).abs() < 0.0001 && + (normA.b - normB.b).abs() < 0.0001 && + (normA.c - normB.c).abs() < 0.0001 && + (normA.d - normB.d).abs() < 0.0001); +} + +void main() { + group('normalizeEdits', () { + test('should handle no edits', () { + final edits = []; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle a single 90° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle a single 180° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle a single 270° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle a single horizontal mirror', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle a single vertical mirror', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 90° rotation + horizontal mirror', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 90° rotation + vertical mirror', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 90° rotation + both mirrors', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 180° rotation + horizontal mirror', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 180° rotation + vertical mirror', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 180° rotation + both mirrors', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 270° rotation + horizontal mirror', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 270° rotation + vertical mirror', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle 270° rotation + both mirrors', () { + final edits = [ + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle horizontal mirror + 90° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle horizontal mirror + 180° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle horizontal mirror + 270° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle vertical mirror + 90° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle vertical mirror + 180° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle vertical mirror + 270° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle both mirrors + 90° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle both mirrors + 180° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + + test('should handle both mirrors + 270° rotation', () { + final edits = [ + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}), + const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}), + const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}), + ]; + + final result = normalizeTransformEdits(edits); + final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3); + + expect(compareEditAffines(normalizedEdits, edits), true); + }); + }); +} diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 522063185f..324f065d21 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -21,6 +21,7 @@ function dart { patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api_client.dart <./patch/api_client.dart.patch patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api.dart <./patch/api.dart.patch patch --no-backup-if-mismatch -u ../mobile/openapi/pubspec.yaml <./patch/pubspec_immich_mobile.yaml.patch + patch --no-backup-if-mismatch -u ../mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart <./patch/asset_edit_action_list_dto_edits_inner.dart.patch # Don't include analysis_options.yaml for the generated openapi files # so that language servers can properly exclude the mobile/openapi directory rm ../mobile/openapi/analysis_options.yaml diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d9743e3d4b..3cc25ecc51 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -15954,12 +15954,10 @@ "AssetDeltaSyncDto": { "properties": { "updatedAfter": { - "description": "Sync assets updated after this date", "format": "date-time", "type": "string" }, "userIds": { - "description": "User IDs to sync", "items": { "format": "uuid", "type": "string" @@ -15976,18 +15974,15 @@ "AssetDeltaSyncResponseDto": { "properties": { "deleted": { - "description": "Deleted asset IDs", "items": { "type": "string" }, "type": "array" }, "needsFullSync": { - "description": "Whether full sync is needed", "type": "boolean" }, "upserted": { - "description": "Upserted assets", "items": { "$ref": "#/components/schemas/AssetResponseDto" }, @@ -16338,22 +16333,18 @@ "AssetFullSyncDto": { "properties": { "lastId": { - "description": "Last asset ID (pagination)", "format": "uuid", "type": "string" }, "limit": { - "description": "Maximum number of assets to return", "minimum": 1, "type": "integer" }, "updatedUntil": { - "description": "Sync assets updated until this date", "format": "date-time", "type": "string" }, "userId": { - "description": "Filter by user ID", "format": "uuid", "type": "string" } @@ -22266,7 +22257,6 @@ "SyncAckDeleteDto": { "properties": { "types": { - "description": "Sync entity types to delete acks for", "items": { "$ref": "#/components/schemas/SyncEntityType" }, @@ -22278,7 +22268,6 @@ "SyncAckDto": { "properties": { "ack": { - "description": "Acknowledgment ID", "type": "string" }, "type": { @@ -22286,8 +22275,7 @@ { "$ref": "#/components/schemas/SyncEntityType" } - ], - "description": "Sync entity type" + ] } }, "required": [ @@ -22299,7 +22287,6 @@ "SyncAckSetDto": { "properties": { "acks": { - "description": "Acknowledgment IDs (max 1000)", "items": { "type": "string" }, @@ -22319,7 +22306,6 @@ "SyncAlbumDeleteV1": { "properties": { "albumId": { - "description": "Album ID", "type": "string" } }, @@ -22331,11 +22317,9 @@ "SyncAlbumToAssetDeleteV1": { "properties": { "albumId": { - "description": "Album ID", "type": "string" }, "assetId": { - "description": "Asset ID", "type": "string" } }, @@ -22348,11 +22332,9 @@ "SyncAlbumToAssetV1": { "properties": { "albumId": { - "description": "Album ID", "type": "string" }, "assetId": { - "description": "Asset ID", "type": "string" } }, @@ -22365,11 +22347,9 @@ "SyncAlbumUserDeleteV1": { "properties": { "albumId": { - "description": "Album ID", "type": "string" }, "userId": { - "description": "User ID", "type": "string" } }, @@ -22382,7 +22362,6 @@ "SyncAlbumUserV1": { "properties": { "albumId": { - "description": "Album ID", "type": "string" }, "role": { @@ -22390,11 +22369,9 @@ { "$ref": "#/components/schemas/AlbumUserRole" } - ], - "description": "Album user role" + ] }, "userId": { - "description": "User ID", "type": "string" } }, @@ -22408,24 +22385,19 @@ "SyncAlbumV1": { "properties": { "createdAt": { - "description": "Created at", "format": "date-time", "type": "string" }, "description": { - "description": "Album description", "type": "string" }, "id": { - "description": "Album ID", "type": "string" }, "isActivityEnabled": { - "description": "Is activity enabled", "type": "boolean" }, "name": { - "description": "Album name", "type": "string" }, "order": { @@ -22436,16 +22408,13 @@ ] }, "ownerId": { - "description": "Owner ID", "type": "string" }, "thumbnailAssetId": { - "description": "Thumbnail asset ID", "nullable": true, "type": "string" }, "updatedAt": { - "description": "Updated at", "format": "date-time", "type": "string" } @@ -22466,7 +22435,6 @@ "SyncAssetDeleteV1": { "properties": { "assetId": { - "description": "Asset ID", "type": "string" } }, @@ -22475,136 +22443,153 @@ ], "type": "object" }, + "SyncAssetEditDeleteV1": { + "properties": { + "assetId": { + "type": "string" + } + }, + "required": [ + "assetId" + ], + "type": "object" + }, + "SyncAssetEditV1": { + "properties": { + "action": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetEditAction" + } + ] + }, + "assetId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "parameters": { + "type": "object" + }, + "sequence": { + "type": "integer" + } + }, + "required": [ + "action", + "assetId", + "id", + "parameters", + "sequence" + ], + "type": "object" + }, "SyncAssetExifV1": { "properties": { "assetId": { - "description": "Asset ID", "type": "string" }, "city": { - "description": "City", "nullable": true, "type": "string" }, "country": { - "description": "Country", "nullable": true, "type": "string" }, "dateTimeOriginal": { - "description": "Date time original", "format": "date-time", "nullable": true, "type": "string" }, "description": { - "description": "Description", "nullable": true, "type": "string" }, "exifImageHeight": { - "description": "Exif image height", "nullable": true, "type": "integer" }, "exifImageWidth": { - "description": "Exif image width", "nullable": true, "type": "integer" }, "exposureTime": { - "description": "Exposure time", "nullable": true, "type": "string" }, "fNumber": { - "description": "F number", "format": "double", "nullable": true, "type": "number" }, "fileSizeInByte": { - "description": "File size in byte", "nullable": true, "type": "integer" }, "focalLength": { - "description": "Focal length", "format": "double", "nullable": true, "type": "number" }, "fps": { - "description": "FPS", "format": "double", "nullable": true, "type": "number" }, "iso": { - "description": "ISO", "nullable": true, "type": "integer" }, "latitude": { - "description": "Latitude", "format": "double", "nullable": true, "type": "number" }, "lensModel": { - "description": "Lens model", "nullable": true, "type": "string" }, "longitude": { - "description": "Longitude", "format": "double", "nullable": true, "type": "number" }, "make": { - "description": "Make", "nullable": true, "type": "string" }, "model": { - "description": "Model", "nullable": true, "type": "string" }, "modifyDate": { - "description": "Modify date", "format": "date-time", "nullable": true, "type": "string" }, "orientation": { - "description": "Orientation", "nullable": true, "type": "string" }, "profileDescription": { - "description": "Profile description", "nullable": true, "type": "string" }, "projectionType": { - "description": "Projection type", "nullable": true, "type": "string" }, "rating": { - "description": "Rating", "nullable": true, "type": "integer" }, "state": { - "description": "State", "nullable": true, "type": "string" }, "timeZone": { - "description": "Time zone", "nullable": true, "type": "string" } @@ -22641,7 +22626,6 @@ "SyncAssetFaceDeleteV1": { "properties": { "assetFaceId": { - "description": "Asset face ID", "type": "string" } }, @@ -22653,7 +22637,6 @@ "SyncAssetFaceV1": { "properties": { "assetId": { - "description": "Asset ID", "type": "string" }, "boundingBoxX1": { @@ -22669,7 +22652,6 @@ "type": "integer" }, "id": { - "description": "Asset face ID", "type": "string" }, "imageHeight": { @@ -22679,12 +22661,10 @@ "type": "integer" }, "personId": { - "description": "Person ID", "nullable": true, "type": "string" }, "sourceType": { - "description": "Source type", "type": "string" } }, @@ -22705,11 +22685,9 @@ "SyncAssetMetadataDeleteV1": { "properties": { "assetId": { - "description": "Asset ID", "type": "string" }, "key": { - "description": "Key", "type": "string" } }, @@ -22722,15 +22700,12 @@ "SyncAssetMetadataV1": { "properties": { "assetId": { - "description": "Asset ID", "type": "string" }, "key": { - "description": "Key", "type": "string" }, "value": { - "description": "Value", "type": "object" } }, @@ -22744,80 +22719,64 @@ "SyncAssetV1": { "properties": { "checksum": { - "description": "Checksum", "type": "string" }, "deletedAt": { - "description": "Deleted at", "format": "date-time", "nullable": true, "type": "string" }, "duration": { - "description": "Duration", "nullable": true, "type": "string" }, "fileCreatedAt": { - "description": "File created at", "format": "date-time", "nullable": true, "type": "string" }, "fileModifiedAt": { - "description": "File modified at", "format": "date-time", "nullable": true, "type": "string" }, "height": { - "description": "Asset height", "nullable": true, "type": "integer" }, "id": { - "description": "Asset ID", "type": "string" }, "isEdited": { - "description": "Is edited", "type": "boolean" }, "isFavorite": { - "description": "Is favorite", "type": "boolean" }, "libraryId": { - "description": "Library ID", "nullable": true, "type": "string" }, "livePhotoVideoId": { - "description": "Live photo video ID", "nullable": true, "type": "string" }, "localDateTime": { - "description": "Local date time", "format": "date-time", "nullable": true, "type": "string" }, "originalFileName": { - "description": "Original file name", "type": "string" }, "ownerId": { - "description": "Owner ID", "type": "string" }, "stackId": { - "description": "Stack ID", "nullable": true, "type": "string" }, "thumbhash": { - "description": "Thumbhash", "nullable": true, "type": "string" }, @@ -22826,19 +22785,16 @@ { "$ref": "#/components/schemas/AssetTypeEnum" } - ], - "description": "Asset type" + ] }, "visibility": { "allOf": [ { "$ref": "#/components/schemas/AssetVisibility" } - ], - "description": "Asset visibility" + ] }, "width": { - "description": "Asset width", "nullable": true, "type": "integer" } @@ -22874,46 +22830,36 @@ "$ref": "#/components/schemas/UserAvatarColor" } ], - "description": "User avatar color", "nullable": true }, "deletedAt": { - "description": "User deleted at", "format": "date-time", "nullable": true, "type": "string" }, "email": { - "description": "User email", "type": "string" }, "hasProfileImage": { - "description": "User has profile image", "type": "boolean" }, "id": { - "description": "User ID", "type": "string" }, "isAdmin": { - "description": "User is admin", "type": "boolean" }, "name": { - "description": "User name", "type": "string" }, "oauthId": { - "description": "User OAuth ID", "type": "string" }, "pinCode": { - "description": "User pin code", "nullable": true, "type": "string" }, "profileChangedAt": { - "description": "User profile changed at", "format": "date-time", "type": "string" }, @@ -22925,7 +22871,6 @@ "type": "integer" }, "storageLabel": { - "description": "User storage label", "nullable": true, "type": "string" } @@ -22952,7 +22897,6 @@ "type": "object" }, "SyncEntityType": { - "description": "Sync entity type", "enum": [ "AuthUserV1", "UserV1", @@ -22960,6 +22904,8 @@ "AssetV1", "AssetDeleteV1", "AssetExifV1", + "AssetEditV1", + "AssetEditDeleteV1", "AssetMetadataV1", "AssetMetadataDeleteV1", "PartnerV1", @@ -23007,11 +22953,9 @@ "SyncMemoryAssetDeleteV1": { "properties": { "assetId": { - "description": "Asset ID", "type": "string" }, "memoryId": { - "description": "Memory ID", "type": "string" } }, @@ -23024,11 +22968,9 @@ "SyncMemoryAssetV1": { "properties": { "assetId": { - "description": "Asset ID", "type": "string" }, "memoryId": { - "description": "Memory ID", "type": "string" } }, @@ -23041,7 +22983,6 @@ "SyncMemoryDeleteV1": { "properties": { "memoryId": { - "description": "Memory ID", "type": "string" } }, @@ -23053,51 +22994,41 @@ "SyncMemoryV1": { "properties": { "createdAt": { - "description": "Created at", "format": "date-time", "type": "string" }, "data": { - "description": "Data", "type": "object" }, "deletedAt": { - "description": "Deleted at", "format": "date-time", "nullable": true, "type": "string" }, "hideAt": { - "description": "Hide at", "format": "date-time", "nullable": true, "type": "string" }, "id": { - "description": "Memory ID", "type": "string" }, "isSaved": { - "description": "Is saved", "type": "boolean" }, "memoryAt": { - "description": "Memory at", "format": "date-time", "type": "string" }, "ownerId": { - "description": "Owner ID", "type": "string" }, "seenAt": { - "description": "Seen at", "format": "date-time", "nullable": true, "type": "string" }, "showAt": { - "description": "Show at", "format": "date-time", "nullable": true, "type": "string" @@ -23107,11 +23038,9 @@ { "$ref": "#/components/schemas/MemoryType" } - ], - "description": "Memory type" + ] }, "updatedAt": { - "description": "Updated at", "format": "date-time", "type": "string" } @@ -23135,11 +23064,9 @@ "SyncPartnerDeleteV1": { "properties": { "sharedById": { - "description": "Shared by ID", "type": "string" }, "sharedWithId": { - "description": "Shared with ID", "type": "string" } }, @@ -23152,15 +23079,12 @@ "SyncPartnerV1": { "properties": { "inTimeline": { - "description": "In timeline", "type": "boolean" }, "sharedById": { - "description": "Shared by ID", "type": "string" }, "sharedWithId": { - "description": "Shared with ID", "type": "string" } }, @@ -23174,7 +23098,6 @@ "SyncPersonDeleteV1": { "properties": { "personId": { - "description": "Person ID", "type": "string" } }, @@ -23186,48 +23109,38 @@ "SyncPersonV1": { "properties": { "birthDate": { - "description": "Birth date", "format": "date-time", "nullable": true, "type": "string" }, "color": { - "description": "Color", "nullable": true, "type": "string" }, "createdAt": { - "description": "Created at", "format": "date-time", "type": "string" }, "faceAssetId": { - "description": "Face asset ID", "nullable": true, "type": "string" }, "id": { - "description": "Person ID", "type": "string" }, "isFavorite": { - "description": "Is favorite", "type": "boolean" }, "isHidden": { - "description": "Is hidden", "type": "boolean" }, "name": { - "description": "Person name", "type": "string" }, "ownerId": { - "description": "Owner ID", "type": "string" }, "updatedAt": { - "description": "Updated at", "format": "date-time", "type": "string" } @@ -23247,7 +23160,6 @@ "type": "object" }, "SyncRequestType": { - "description": "Sync request types", "enum": [ "AlbumsV1", "AlbumUsersV1", @@ -23256,6 +23168,7 @@ "AlbumAssetExifsV1", "AssetsV1", "AssetExifsV1", + "AssetEditsV1", "AssetMetadataV1", "AuthUsersV1", "MemoriesV1", @@ -23279,7 +23192,6 @@ "SyncStackDeleteV1": { "properties": { "stackId": { - "description": "Stack ID", "type": "string" } }, @@ -23291,24 +23203,19 @@ "SyncStackV1": { "properties": { "createdAt": { - "description": "Created at", "format": "date-time", "type": "string" }, "id": { - "description": "Stack ID", "type": "string" }, "ownerId": { - "description": "Owner ID", "type": "string" }, "primaryAssetId": { - "description": "Primary asset ID", "type": "string" }, "updatedAt": { - "description": "Updated at", "format": "date-time", "type": "string" } @@ -23325,11 +23232,9 @@ "SyncStreamDto": { "properties": { "reset": { - "description": "Reset sync state", "type": "boolean" }, "types": { - "description": "Sync request types", "items": { "$ref": "#/components/schemas/SyncRequestType" }, @@ -23344,7 +23249,6 @@ "SyncUserDeleteV1": { "properties": { "userId": { - "description": "User ID", "type": "string" } }, @@ -23360,11 +23264,9 @@ { "$ref": "#/components/schemas/UserMetadataKey" } - ], - "description": "User metadata key" + ] }, "userId": { - "description": "User ID", "type": "string" } }, @@ -23381,15 +23283,12 @@ { "$ref": "#/components/schemas/UserMetadataKey" } - ], - "description": "User metadata key" + ] }, "userId": { - "description": "User ID", "type": "string" }, "value": { - "description": "User metadata value", "type": "object" } }, @@ -23408,33 +23307,26 @@ "$ref": "#/components/schemas/UserAvatarColor" } ], - "description": "User avatar color", "nullable": true }, "deletedAt": { - "description": "User deleted at", "format": "date-time", "nullable": true, "type": "string" }, "email": { - "description": "User email", "type": "string" }, "hasProfileImage": { - "description": "User has profile image", "type": "boolean" }, "id": { - "description": "User ID", "type": "string" }, "name": { - "description": "User name", "type": "string" }, "profileChangedAt": { - "description": "User profile changed at", "format": "date-time", "type": "string" } @@ -25333,7 +25225,6 @@ "type": "object" }, "UserMetadataKey": { - "description": "User metadata key", "enum": [ "preferences", "license", diff --git a/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch b/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch new file mode 100644 index 0000000000..7c0010a354 --- /dev/null +++ b/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch @@ -0,0 +1,20 @@ +--- /tmp/asset_edit_orig.dart 2026-01-20 10:38:05 ++++ /tmp/asset_edit_final.dart 2026-01-20 10:40:33 +@@ -19,7 +19,7 @@ + + AssetEditAction action; + +- MirrorParameters parameters; ++ Map parameters; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner && +@@ -62,7 +62,7 @@ + + return AssetEditActionListDtoEditsInner( + action: AssetEditAction.fromJson(json[r'action'])!, +- parameters: MirrorParameters.fromJson(json[r'parameters'])!, ++ parameters: json[r'parameters'], + ); + } + return null; diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 28a8bc495e..b24747ad00 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2955,6 +2955,16 @@ export type SyncAssetDeleteV1 = { /** Asset ID */ assetId: string; }; +export type SyncAssetEditDeleteV1 = { + assetId: string; +}; +export type SyncAssetEditV1 = { + action: AssetEditAction; + assetId: string; + id: string; + parameters: object; + sequence: number; +}; export type SyncAssetExifV1 = { /** Asset ID */ assetId: string; @@ -7178,6 +7188,8 @@ export enum SyncEntityType { AssetV1 = "AssetV1", AssetDeleteV1 = "AssetDeleteV1", AssetExifV1 = "AssetExifV1", + AssetEditV1 = "AssetEditV1", + AssetEditDeleteV1 = "AssetEditDeleteV1", AssetMetadataV1 = "AssetMetadataV1", AssetMetadataDeleteV1 = "AssetMetadataDeleteV1", PartnerV1 = "PartnerV1", @@ -7228,6 +7240,7 @@ export enum SyncRequestType { AlbumAssetExifsV1 = "AlbumAssetExifsV1", AssetsV1 = "AssetsV1", AssetExifsV1 = "AssetExifsV1", + AssetEditsV1 = "AssetEditsV1", AssetMetadataV1 = "AssetMetadataV1", AuthUsersV1 = "AuthUsersV1", MemoriesV1 = "MemoriesV1", diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 59d7d373f0..fd3967eb8f 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { AssetEditAction } from 'src/dtos/editing.dto'; import { AlbumUserRole, AssetOrder, @@ -218,6 +219,24 @@ export class SyncAssetExifV1 { fps!: number | null; } +@ExtraModel() +export class SyncAssetEditV1 { + id!: string; + assetId!: string; + + @ValidateEnum({ enum: AssetEditAction, name: 'AssetEditAction' }) + action!: AssetEditAction; + parameters!: object; + + @ApiProperty({ type: 'integer' }) + sequence!: number; +} + +@ExtraModel() +export class SyncAssetEditDeleteV1 { + assetId!: string; +} + @ExtraModel() export class SyncAssetMetadataV1 { @ApiProperty({ description: 'Asset ID' }) @@ -466,6 +485,8 @@ export type SyncItem = { [SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1; [SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1; [SyncEntityType.AssetExifV1]: SyncAssetExifV1; + [SyncEntityType.AssetEditV1]: SyncAssetEditV1; + [SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1; [SyncEntityType.PartnerAssetV1]: SyncAssetV1; [SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1; [SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index 8f509754da..b70db75ad3 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -720,6 +720,7 @@ export enum SyncRequestType { AlbumAssetExifsV1 = 'AlbumAssetExifsV1', AssetsV1 = 'AssetsV1', AssetExifsV1 = 'AssetExifsV1', + AssetEditsV1 = 'AssetEditsV1', AssetMetadataV1 = 'AssetMetadataV1', AuthUsersV1 = 'AuthUsersV1', MemoriesV1 = 'MemoriesV1', @@ -744,6 +745,8 @@ export enum SyncEntityType { AssetV1 = 'AssetV1', AssetDeleteV1 = 'AssetDeleteV1', AssetExifV1 = 'AssetExifV1', + AssetEditV1 = 'AssetEditV1', + AssetEditDeleteV1 = 'AssetEditDeleteV1', AssetMetadataV1 = 'AssetMetadataV1', AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1', diff --git a/server/src/repositories/asset-edit.repository.ts b/server/src/repositories/asset-edit.repository.ts index 088cb1ccff..791a3dfa5d 100644 --- a/server/src/repositories/asset-edit.repository.ts +++ b/server/src/repositories/asset-edit.repository.ts @@ -39,4 +39,16 @@ export class AssetEditRepository { .orderBy('sequence', 'asc') .execute() as Promise; } + + @GenerateSql({ + params: [DummyValue.UUID], + }) + getWithSyncInfo(assetId: string) { + return this.db + .selectFrom('asset_edit') + .select(['id', 'assetId', 'sequence', 'action', 'parameters']) + .where('assetId', '=', assetId) + .orderBy('sequence', 'asc') + .execute(); + } } diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 511d7b589f..af5f575e10 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -53,6 +53,7 @@ export class SyncRepository { albumUser: AlbumUserSync; asset: AssetSync; assetExif: AssetExifSync; + assetEdit: AssetEditSync; assetFace: AssetFaceSync; assetMetadata: AssetMetadataSync; authUser: AuthUserSync; @@ -75,6 +76,7 @@ export class SyncRepository { this.albumUser = new AlbumUserSync(this.db); this.asset = new AssetSync(this.db); this.assetExif = new AssetExifSync(this.db); + this.assetEdit = new AssetEditSync(this.db); this.assetFace = new AssetFaceSync(this.db); this.assetMetadata = new AssetMetadataSync(this.db); this.authUser = new AuthUserSync(this.db); @@ -499,6 +501,30 @@ class AssetExifSync extends BaseSync { } } +class AssetEditSync extends BaseSync { + @GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true }) + getDeletes(options: SyncQueryOptions) { + return this.auditQuery('asset_edit_audit', options) + .select(['asset_edit_audit.id', 'assetId']) + .leftJoin('asset', 'asset.id', 'asset_edit_audit.assetId') + .where('asset.ownerId', '=', options.userId) + .stream(); + } + + cleanupAuditTable(daysAgo: number) { + return this.auditCleanup('asset_edit_audit', daysAgo); + } + + @GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true }) + getUpserts(options: SyncQueryOptions) { + return this.upsertQuery('asset_edit', options) + .select(['asset_edit.id', 'assetId', 'action', 'parameters', 'sequence', 'asset_edit.updateId']) + .innerJoin('asset', 'asset.id', 'asset_edit.assetId') + .where('asset.ownerId', '=', options.userId) + .stream(); + } +} + class MemorySync extends BaseSync { @GenerateSql({ params: [dummyQueryOptions], stream: true }) getDeletes(options: SyncQueryOptions) { diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index bfed556895..235d2f2a84 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -11,7 +11,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { NotificationDto } from 'src/dtos/notification.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; +import { SyncAssetEditV1, SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto'; import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { handlePromiseError } from 'src/utils/misc'; @@ -37,7 +37,7 @@ export interface ClientEventMap { AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }]; AppRestartV1: [AppRestartEvent]; - AssetEditReadyV1: [{ asset: SyncAssetV1 }]; + AssetEditReadyV1: [{ asset: SyncAssetV1; edit: SyncAssetEditV1[] }]; } export type AuthFn = (client: Socket) => Promise; diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index d7dabfef4c..d4e9364600 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -286,3 +286,16 @@ export const asset_edit_delete = registerFunction({ END `, }); + +export const asset_edit_audit = registerFunction({ + name: 'asset_edit_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO asset_edit_audit ("assetId") + SELECT "assetId" + FROM OLD; + RETURN NULL; + END`, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 59c9f53d1a..0b12efc59d 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -28,6 +28,7 @@ import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; +import { AssetEditAuditTable } from 'src/schema/tables/asset-edit-audit.table'; import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; @@ -88,6 +89,7 @@ export class ImmichDatabase { ApiKeyTable, AssetAuditTable, AssetEditTable, + AssetEditAuditTable, AssetFaceTable, AssetFaceAuditTable, AssetMetadataTable, @@ -182,6 +184,7 @@ export interface DB { asset: AssetTable; asset_audit: AssetAuditTable; asset_edit: AssetEditTable; + asset_edit_audit: AssetEditAuditTable; asset_exif: AssetExifTable; asset_face: AssetFaceTable; asset_face_audit: AssetFaceAuditTable; diff --git a/server/src/schema/migrations/1769026065658-AssetEditSync.ts b/server/src/schema/migrations/1769026065658-AssetEditSync.ts new file mode 100644 index 0000000000..86d3c6b86c --- /dev/null +++ b/server/src/schema/migrations/1769026065658-AssetEditSync.ts @@ -0,0 +1,47 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION asset_edit_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO asset_edit_audit ("assetId") + SELECT "assetId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "asset_edit_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "assetId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "asset_edit_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "asset_edit_audit_assetId_idx" ON "asset_edit_audit" ("assetId");`.execute(db); + await sql`CREATE INDEX "asset_edit_audit_deletedAt_idx" ON "asset_edit_audit" ("deletedAt");`.execute(db); + await sql`ALTER TABLE "asset_edit" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`CREATE INDEX "asset_edit_updateId_idx" ON "asset_edit" ("updateId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_edit_audit" + AFTER DELETE ON "asset_edit" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION asset_edit_audit();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_edit_audit', '{"type":"function","name":"asset_edit_audit","sql":"CREATE OR REPLACE FUNCTION asset_edit_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_edit_audit (\\"assetId\\")\\n SELECT \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_edit_audit', '{"type":"trigger","name":"asset_edit_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_edit_audit\\"\\n AFTER DELETE ON \\"asset_edit\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_edit_audit();"}'::jsonb);`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "asset_edit_audit" ON "asset_edit";`.execute(db); + await sql`DROP INDEX "asset_edit_updateId_idx";`.execute(db); + await sql`ALTER TABLE "asset_edit" DROP COLUMN "updateId";`.execute(db); + await sql`DROP TABLE "asset_edit_audit";`.execute(db); + await sql`DROP FUNCTION asset_edit_audit;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_edit_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_edit_audit';`.execute(db); +} diff --git a/server/src/schema/tables/asset-edit-audit.table.ts b/server/src/schema/tables/asset-edit-audit.table.ts new file mode 100644 index 0000000000..d08350ef70 --- /dev/null +++ b/server/src/schema/tables/asset-edit-audit.table.ts @@ -0,0 +1,14 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; + +@Table('asset_edit_audit') +export class AssetEditAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + assetId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/asset-edit.table.ts b/server/src/schema/tables/asset-edit.table.ts index 886b62dc0b..b248e59568 100644 --- a/server/src/schema/tables/asset-edit.table.ts +++ b/server/src/schema/tables/asset-edit.table.ts @@ -1,5 +1,6 @@ +import { UpdateIdColumn } from 'src/decorators'; import { AssetEditAction, AssetEditActionParameter } from 'src/dtos/editing.dto'; -import { asset_edit_delete, asset_edit_insert } from 'src/schema/functions'; +import { asset_edit_audit, asset_edit_delete, asset_edit_insert } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; import { AfterDeleteTrigger, @@ -20,6 +21,12 @@ import { referencingOldTableAs: 'deleted_edit', when: 'pg_trigger_depth() = 0', }) +@AfterDeleteTrigger({ + scope: 'statement', + function: asset_edit_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) @Unique({ columns: ['assetId', 'sequence'] }) export class AssetEditTable { @PrimaryGeneratedColumn() @@ -36,4 +43,7 @@ export class AssetEditTable { @Column({ type: 'integer' }) sequence!: number; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; } diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index ed427684f1..cf98b17cca 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -542,6 +542,7 @@ export class AssetService extends BaseService { async getAssetEdits(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const edits = await this.assetEditRepository.getAll(id); + return { assetId: id, edits, diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 2a47745a6c..7c9581ff9a 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -98,6 +98,7 @@ export class JobService extends BaseService { case JobName.AssetEditThumbnailGeneration: { const asset = await this.assetRepository.getById(item.data.id); + const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id); if (asset) { this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, { @@ -122,6 +123,7 @@ export class JobService extends BaseService { height: asset.height, isEdited: asset.isEdited, }, + edit: edits, }); } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index f354a71791..4df564acb0 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -87,6 +87,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.AssetFacesV1, SyncRequestType.UserMetadataV1, SyncRequestType.AssetMetadataV1, + SyncRequestType.AssetEditsV1, ]; const throwSessionRequired = () => { @@ -173,6 +174,7 @@ export class SyncService extends BaseService { [SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap), [SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap), [SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap), + [SyncRequestType.AssetEditsV1]: () => this.syncAssetEditsV1(options, response, checkpointMap), [SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id), [SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth), [SyncRequestType.PartnerAssetExifsV1]: () => @@ -349,6 +351,21 @@ export class SyncService extends BaseService { } } + private async syncAssetEditsV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.AssetEditDeleteV1; + const deletes = this.syncRepository.assetEdit.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + const upsertType = SyncEntityType.AssetEditV1; + const upserts = this.syncRepository.assetEdit.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async syncPartnerAssetExifsV1( options: SyncQueryOptions, response: Writable, diff --git a/server/test/medium/specs/sync/sync-asset-edit.spec.ts b/server/test/medium/specs/sync/sync-asset-edit.spec.ts new file mode 100644 index 0000000000..1287489348 --- /dev/null +++ b/server/test/medium/specs/sync/sync-asset-edit.spec.ts @@ -0,0 +1,306 @@ +import { Kysely } from 'kysely'; +import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncRequestType.AssetEditsV1, () => { + it('should detect and sync the first asset edit', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset.id, + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + sequence: 0, + }, + type: SyncEntityType.AssetEditV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should detect and sync multiple asset edits for the same asset', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + { + action: AssetEditAction.Rotate, + parameters: { angle: 90 }, + }, + { + action: AssetEditAction.Mirror, + parameters: { axis: MirrorAxis.Horizontal }, + }, + ]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset.id, + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + sequence: 0, + }, + type: SyncEntityType.AssetEditV1, + }, + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset.id, + action: AssetEditAction.Rotate, + parameters: { angle: 90 }, + sequence: 1, + }, + type: SyncEntityType.AssetEditV1, + }, + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset.id, + action: AssetEditAction.Mirror, + parameters: { axis: MirrorAxis.Horizontal }, + sequence: 2, + }, + type: SyncEntityType.AssetEditV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]), + ); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should detect and sync updated edits', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + // Create initial edit + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + const response1 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + await ctx.syncAckAll(auth, response1); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + + // Update the edit + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 50, y: 60, width: 150, height: 250 }, + }, + ]); + + const response2 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + expect(response2).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + }, + type: SyncEntityType.AssetEditDeleteV1, + }, + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset.id, + action: AssetEditAction.Crop, + parameters: { x: 50, y: 60, width: 150, height: 250 }, + sequence: 0, + }, + type: SyncEntityType.AssetEditV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]), + ); + + await ctx.syncAckAll(auth, response2); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should detect and sync deleted asset edits', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + // Create initial edit + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + const response1 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + await ctx.syncAckAll(auth, response1); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + + // Delete all edits + await assetEditRepo.replaceAll(asset.id, []); + + const response2 = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + expect(response2).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + assetId: asset.id, + }, + type: SyncEntityType.AssetEditDeleteV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]), + ); + + await ctx.syncAckAll(auth, response2); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should only sync asset edits for own user', async () => { + const { auth, ctx } = await setup(); + const { user: user2 } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + const { session } = await ctx.newSession({ userId: user2.id }); + const auth2 = factory.auth({ session, user: user2 }); + + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + // User 2 should see their own edit + await expect(ctx.syncStream(auth2, [SyncRequestType.AssetEditsV1])).resolves.toEqual([ + expect.objectContaining({ type: SyncEntityType.AssetEditV1 }), + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + // User 1 should not see user 2's edit + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should sync edits for multiple assets', async () => { + const { auth, ctx } = await setup(); + const { asset: asset1 } = await ctx.newAsset({ ownerId: auth.user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: auth.user.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + await assetEditRepo.replaceAll(asset1.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + await assetEditRepo.replaceAll(asset2.id, [ + { + action: AssetEditAction.Rotate, + parameters: { angle: 270 }, + }, + ]); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetEditsV1]); + expect(response).toEqual( + expect.arrayContaining([ + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset1.id, + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + sequence: 0, + }, + type: SyncEntityType.AssetEditV1, + }, + { + ack: expect.any(String), + data: { + id: expect.any(String), + assetId: asset2.id, + action: AssetEditAction.Rotate, + parameters: { angle: 270 }, + sequence: 0, + }, + type: SyncEntityType.AssetEditV1, + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]), + ); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); + + it('should not sync edits for partner assets', async () => { + const { auth, ctx } = await setup(); + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: partner.id, sharedWithId: auth.user.id }); + const { asset } = await ctx.newAsset({ ownerId: partner.id }); + const assetEditRepo = ctx.get(AssetEditRepository); + + await assetEditRepo.replaceAll(asset.id, [ + { + action: AssetEditAction.Crop, + parameters: { x: 10, y: 20, width: 100, height: 200 }, + }, + ]); + + // Should not see partner's asset edits in own sync + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetEditsV1]); + }); +}); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 335ec188ea..83faf8392e 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -12,6 +12,7 @@ import { type MaintenanceStatusResponseDto, type NotificationDto, type ServerVersionResponseDto, + type SyncAssetEditV1, type SyncAssetV1, } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; @@ -41,7 +42,7 @@ export interface Events { AppRestartV1: (event: AppRestartEvent) => void; MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void; - AssetEditReadyV1: (data: { asset: SyncAssetV1 }) => void; + AssetEditReadyV1: (data: { asset: SyncAssetV1; edits: SyncAssetEditV1[] }) => void; } const websocket: Socket = io({