Compare commits

...

19 Commits

Author SHA1 Message Date
bwees
bfa2aa4c58 chore: fix failing ci 2026-01-25 20:59:31 -06:00
bwees
8835e54bf4 feat: web editor 2026-01-25 20:44:32 -06:00
bwees
ae9bb0aa80 feat: mobile filter sending/recv 2026-01-25 15:27:05 -06:00
bwees
2e4cfa80a9 feat: server support for filters 2026-01-25 15:26:55 -06:00
bwees
8653e20cc5 fix: selected filter state 2026-01-25 14:25:01 -06:00
bwees
871de53bca feat: mobile filtering 2026-01-25 14:20:38 -06:00
bwees
f5db6dfa09 chore: cleanup 2026-01-25 12:07:39 -06:00
bwees
7f857ab9a0 fix: affine matrix deconstruction 2026-01-24 17:55:30 -06:00
bwees
37297ce420 feat: mirror support 2026-01-24 17:55:29 -06:00
bwees
bdc45da106 chore: cleanup edit import 2026-01-24 17:55:29 -06:00
bwees
8770cf0961 chore: cleanup old filter page 2026-01-24 17:55:29 -06:00
bwees
128e798516 fix: change condition for editable image 2026-01-24 17:55:29 -06:00
bwees
3a87746ce6 chore: editor improvements 2026-01-24 17:55:29 -06:00
bwees
279e706c77 feat: working editor 2026-01-24 17:55:12 -06:00
bwees
7acc0d5c82 fix: update assetEditReady event on web 2026-01-24 17:52:13 -06:00
bwees
48f4282056 fix: websocket backwards compatibility 2026-01-24 17:52:13 -06:00
bwees
74297ec429 feat: send edit information on AssetEditReadyV1 2026-01-24 17:52:13 -06:00
bwees
34b6e2ca99 feat: mobile edits sync 2026-01-24 17:52:13 -06:00
bwees
5bb6bcf70b feat: fix discriminated type parsing 2026-01-24 17:52:11 -06:00
83 changed files with 13492 additions and 779 deletions

View File

@@ -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…",
@@ -992,6 +994,7 @@
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?",
"editor_filters": "Filters",
"editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_orientation": "Orientation",

File diff suppressed because one or more lines are too long

View File

@@ -1,52 +1,278 @@
import 'package:flutter/material.dart';
const List<ColorFilter> filters = [
class EditFilter {
final String name;
final double rrBias;
final double rgBias;
final double rbBias;
final double grBias;
final double ggBias;
final double gbBias;
final double brBias;
final double bgBias;
final double bbBias;
final double rOffset;
final double gOffset;
final double bOffset;
const EditFilter({
required this.name,
required this.rrBias,
required this.rgBias,
required this.rbBias,
required this.grBias,
required this.ggBias,
required this.gbBias,
required this.brBias,
required this.bgBias,
required this.bbBias,
required this.rOffset,
required this.gOffset,
required this.bOffset,
});
bool get isIdentity =>
rrBias == 1 &&
rgBias == 0 &&
rbBias == 0 &&
grBias == 0 &&
ggBias == 1 &&
gbBias == 0 &&
brBias == 0 &&
bgBias == 0 &&
bbBias == 1 &&
rOffset == 0 &&
gOffset == 0 &&
bOffset == 0;
factory EditFilter.fromMatrix(List<double> matrix, String name) {
if (matrix.length != 20) {
throw ArgumentError('Color filter matrix must have 20 elements');
}
return EditFilter(
name: name,
rrBias: matrix[0],
rgBias: matrix[1],
rbBias: matrix[2],
grBias: matrix[5],
ggBias: matrix[6],
gbBias: matrix[7],
brBias: matrix[10],
bgBias: matrix[11],
bbBias: matrix[12],
rOffset: matrix[4],
gOffset: matrix[9],
bOffset: matrix[14],
);
}
factory EditFilter.fromDtoParams(Map<String, dynamic> params, String name) {
print(params);
return EditFilter(
name: name,
rrBias: (params['rrBias'] as num).toDouble(),
rgBias: (params['rgBias'] as num).toDouble(),
rbBias: (params['rbBias'] as num).toDouble(),
grBias: (params['grBias'] as num).toDouble(),
ggBias: (params['ggBias'] as num).toDouble(),
gbBias: (params['gbBias'] as num).toDouble(),
brBias: (params['brBias'] as num).toDouble(),
bgBias: (params['bgBias'] as num).toDouble(),
bbBias: (params['bbBias'] as num).toDouble(),
rOffset: (params['rOffset'] as num).toDouble(),
gOffset: (params['gOffset'] as num).toDouble(),
bOffset: (params['bOffset'] as num).toDouble(),
);
}
ColorFilter get colorFilter {
final colorMatrix = <double>[
rrBias,
rgBias,
rbBias,
0,
rOffset,
grBias,
ggBias,
gbBias,
0,
gOffset,
brBias,
bgBias,
bbBias,
0,
bOffset,
0,
0,
0,
1,
0,
];
return ColorFilter.matrix(colorMatrix);
}
Map<String, dynamic> get dtoParameters {
return {
"rrBias": rrBias,
"rgBias": rgBias,
"rbBias": rbBias,
"grBias": grBias,
"ggBias": ggBias,
"gbBias": gbBias,
"brBias": brBias,
"bgBias": bgBias,
"bbBias": bbBias,
"rOffset": rOffset,
"gOffset": gOffset,
"bOffset": bOffset,
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! EditFilter) return false;
return rrBias == other.rrBias &&
rgBias == other.rgBias &&
rbBias == other.rbBias &&
grBias == other.grBias &&
ggBias == other.ggBias &&
gbBias == other.gbBias &&
brBias == other.brBias &&
bgBias == other.bgBias &&
bbBias == other.bbBias &&
rOffset == other.rOffset &&
gOffset == other.gOffset &&
bOffset == other.bOffset;
}
@override
int get hashCode =>
name.hashCode ^
rrBias.hashCode ^
rgBias.hashCode ^
rbBias.hashCode ^
grBias.hashCode ^
ggBias.hashCode ^
gbBias.hashCode ^
brBias.hashCode ^
bgBias.hashCode ^
bbBias.hashCode ^
rOffset.hashCode ^
gOffset.hashCode ^
bOffset.hashCode;
}
final List<EditFilter> filters = [
//Original
ColorFilter.matrix([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0], "Original"),
//Vintage
ColorFilter.matrix([0.8, 0.1, 0.1, 0, 20, 0.1, 0.8, 0.1, 0, 20, 0.1, 0.1, 0.8, 0, 20, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([0.8, 0.1, 0.1, 0, 20, 0.1, 0.8, 0.1, 0, 20, 0.1, 0.1, 0.8, 0, 20, 0, 0, 0, 1, 0], "Vintage"),
//Mood
ColorFilter.matrix([1.2, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 10, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.2, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 10, 0, 0, 0, 1, 0], "Mood"),
//Crisp
ColorFilter.matrix([1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], "Crisp"),
//Cool
ColorFilter.matrix([0.9, 0, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([0.9, 0, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], "Cool"),
//Blush
ColorFilter.matrix([1.1, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 5, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.1, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 5, 0, 0, 0, 1, 0], "Blush"),
//Sunkissed
ColorFilter.matrix([1.3, 0, 0.1, 0, 15, 0, 1.1, 0.1, 0, 10, 0, 0, 0.9, 0, 5, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.3, 0, 0.1, 0, 15, 0, 1.1, 0.1, 0, 10, 0, 0, 0.9, 0, 5, 0, 0, 0, 1, 0], "Sunkissed"),
//Fresh
ColorFilter.matrix([1.2, 0, 0, 0, 20, 0, 1.2, 0, 0, 20, 0, 0, 1.1, 0, 20, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.2, 0, 0, 0, 20, 0, 1.2, 0, 0, 20, 0, 0, 1.1, 0, 20, 0, 0, 0, 1, 0], "Fresh"),
//Classic
ColorFilter.matrix([1.1, 0, -0.1, 0, 10, -0.1, 1.1, 0.1, 0, 5, 0, -0.1, 1.1, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.1, 0, -0.1, 0, 10, -0.1, 1.1, 0.1, 0, 5, 0, -0.1, 1.1, 0, 0, 0, 0, 0, 1, 0], "Classic"),
//Lomo-ish
ColorFilter.matrix([1.5, 0, 0.1, 0, 0, 0, 1.45, 0, 0, 0, 0.1, 0, 1.3, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.5, 0, 0.1, 0, 0, 0, 1.45, 0, 0, 0, 0.1, 0, 1.3, 0, 0, 0, 0, 0, 1, 0], "Lomo-ish"),
//Nashville
ColorFilter.matrix([1.2, 0.15, -0.15, 0, 15, 0.1, 1.1, 0.1, 0, 10, -0.05, 0.2, 1.25, 0, 5, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([
1.2,
0.15,
-0.15,
0,
15,
0.1,
1.1,
0.1,
0,
10,
-0.05,
0.2,
1.25,
0,
5,
0,
0,
0,
1,
0,
], "Nashville"),
//Valencia
ColorFilter.matrix([1.15, 0.1, 0.1, 0, 20, 0.1, 1.1, 0, 0, 10, 0.1, 0.1, 1.2, 0, 5, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.15, 0.1, 0.1, 0, 20, 0.1, 1.1, 0, 0, 10, 0.1, 0.1, 1.2, 0, 5, 0, 0, 0, 1, 0], "Valencia"),
//Clarendon
ColorFilter.matrix([1.2, 0, 0, 0, 10, 0, 1.25, 0, 0, 10, 0, 0, 1.3, 0, 10, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.2, 0, 0, 0, 10, 0, 1.25, 0, 0, 10, 0, 0, 1.3, 0, 10, 0, 0, 0, 1, 0], "Clarendon"),
//Moon
ColorFilter.matrix([0.33, 0.33, 0.33, 0, 0, 0.33, 0.33, 0.33, 0, 0, 0.33, 0.33, 0.33, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([
0.33,
0.33,
0.33,
0,
0,
0.33,
0.33,
0.33,
0,
0,
0.33,
0.33,
0.33,
0,
0,
0,
0,
0,
1,
0,
], "Moon"),
//Willow
ColorFilter.matrix([0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0, 0, 0, 1, 0], "Willow"),
//Kodak
ColorFilter.matrix([1.3, 0.1, -0.1, 0, 10, 0, 1.25, 0.1, 0, 10, 0, -0.1, 1.1, 0, 5, 0, 0, 0, 1, 0]),
//Frost
ColorFilter.matrix([0.8, 0.2, 0.1, 0, 0, 0.2, 1.1, 0.1, 0, 0, 0.1, 0.1, 1.2, 0, 10, 0, 0, 0, 1, 0]),
//Night Vision
ColorFilter.matrix([0.1, 0.95, 0.2, 0, 0, 0.1, 1.5, 0.1, 0, 0, 0.2, 0.7, 0, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.3, 0.1, -0.1, 0, 10, 0, 1.25, 0.1, 0, 10, 0, -0.1, 1.1, 0, 5, 0, 0, 0, 1, 0], "Kodak"),
//Sunset
ColorFilter.matrix([1.5, 0.2, 0, 0, 0, 0.1, 0.9, 0.1, 0, 0, -0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.5, 0.2, 0, 0, 0, 0.1, 0.9, 0.1, 0, 0, -0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0], "Sunset"),
//Noir
ColorFilter.matrix([1.3, -0.3, 0.1, 0, 0, -0.1, 1.2, -0.1, 0, 0, 0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.3, -0.3, 0.1, 0, 0, -0.1, 1.2, -0.1, 0, 0, 0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0], "Noir"),
//Dreamy
ColorFilter.matrix([1.1, 0.1, 0.1, 0, 0, 0.1, 1.1, 0.1, 0, 0, 0.1, 0.1, 1.1, 0, 15, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.1, 0.1, 0.1, 0, 0, 0.1, 1.1, 0.1, 0, 0, 0.1, 0.1, 1.1, 0, 15, 0, 0, 0, 1, 0], "Dreamy"),
//Sepia
ColorFilter.matrix([0.393, 0.769, 0.189, 0, 0, 0.349, 0.686, 0.168, 0, 0, 0.272, 0.534, 0.131, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([
0.393,
0.769,
0.189,
0,
0,
0.349,
0.686,
0.168,
0,
0,
0.272,
0.534,
0.131,
0,
0,
0,
0,
0,
1,
0,
], "Sepia"),
//Radium
ColorFilter.matrix([
EditFilter.fromMatrix([
1.438,
-0.062,
-0.062,
@@ -67,9 +293,9 @@ const List<ColorFilter> filters = [
0,
1,
0,
]),
], "Radium"),
//Aqua
ColorFilter.matrix([
EditFilter.fromMatrix([
0.2126,
0.7152,
0.0722,
@@ -90,59 +316,23 @@ const List<ColorFilter> filters = [
0,
1,
0,
]),
], "Aqua"),
//Purple Haze
ColorFilter.matrix([1.3, 0, 1.2, 0, 0, 0, 1.1, 0, 0, 0, 0.2, 0, 1.3, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.3, 0, 1.2, 0, 0, 0, 1.1, 0, 0, 0, 0.2, 0, 1.3, 0, 0, 0, 0, 0, 1, 0], "Purple Haze"),
//Lemonade
ColorFilter.matrix([1.2, 0.1, 0, 0, 0, 0, 1.1, 0.2, 0, 0, 0.1, 0, 0.7, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.2, 0.1, 0, 0, 0, 0, 1.1, 0.2, 0, 0, 0.1, 0, 0.7, 0, 0, 0, 0, 0, 1, 0], "Lemonade"),
//Caramel
ColorFilter.matrix([1.6, 0.2, 0, 0, 0, 0.1, 1.3, 0.1, 0, 0, 0, 0.1, 0.9, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.6, 0.2, 0, 0, 0, 0.1, 1.3, 0.1, 0, 0, 0, 0.1, 0.9, 0, 0, 0, 0, 0, 1, 0], "Caramel"),
//Peachy
ColorFilter.matrix([1.3, 0.5, 0, 0, 0, 0.2, 1.1, 0.3, 0, 0, 0.1, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.3, 0.5, 0, 0, 0, 0.2, 1.1, 0.3, 0, 0, 0.1, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0], "Peachy"),
//Neon
ColorFilter.matrix([1, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 1, 0], "Neon"),
//Cold Morning
ColorFilter.matrix([0.9, 0.1, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([0.9, 0.1, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], "Cold Morning"),
//Lush
ColorFilter.matrix([0.9, 0.2, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.1, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([0.9, 0.2, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.1, 0, 0, 0, 0, 0, 1, 0], "Lush"),
//Urban Neon
ColorFilter.matrix([1.1, 0, 0.3, 0, 0, 0, 0.9, 0.3, 0, 0, 0.3, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.1, 0, 0.3, 0, 0, 0, 0.9, 0.3, 0, 0, 0.3, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0], "Urban Neon"),
//Monochrome
ColorFilter.matrix([0.6, 0.2, 0.2, 0, 0, 0.2, 0.6, 0.2, 0, 0, 0.2, 0.2, 0.7, 0, 0, 0, 0, 0, 1, 0]),
];
const List<String> filterNames = [
'Original',
'Vintage',
'Mood',
'Crisp',
'Cool',
'Blush',
'Sunkissed',
'Fresh',
'Classic',
'Lomo-ish',
'Nashville',
'Valencia',
'Clarendon',
'Moon',
'Willow',
'Kodak',
'Frost',
'Night Vision',
'Sunset',
'Noir',
'Dreamy',
'Sepia',
'Radium',
'Aqua',
'Purple Haze',
'Lemonade',
'Caramel',
'Peachy',
'Neon',
'Cold Morning',
'Lush',
'Urban Neon',
'Monochrome',
EditFilter.fromMatrix([0.6, 0.2, 0.2, 0, 0, 0.2, 0.6, 0.2, 0, 0, 0.2, 0.2, 0.7, 0, 0, 0, 0, 0, 1, 0], "Monochrome"),
];

View File

@@ -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;

View File

@@ -0,0 +1,22 @@
import "package:openapi/api.dart" as api show AssetEditAction;
enum AssetEditAction { rotate, crop, mirror, filter, 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.filter => api.AssetEditAction.filter,
AssetEditAction.other => null,
};
}
}
class AssetEdit {
final AssetEditAction action;
final Map<String, dynamic> parameters;
const AssetEdit({required this.action, required this.parameters});
}

View File

@@ -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,

View File

@@ -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<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
Future<List<AssetEdit>> getAssetEdits(String assetId) {
return _remoteAssetRepository.getAssetEdits(assetId);
}
Future<void> editAsset(String assetId, List<AssetEdit> edits) {
return _remoteAssetRepository.editAsset(assetId, edits);
}
}

View File

@@ -118,6 +118,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:
@@ -253,6 +257,7 @@ class SyncStreamService {
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
final List<SyncAssetV1> assets = [];
final List<SyncAssetEditV1> assetEdits = [];
try {
for (final data in batchData) {
@@ -262,6 +267,7 @@ class SyncStreamService {
final payload = data;
final assetData = payload['asset'];
final editData = payload['edit'];
if (assetData == null) {
continue;
@@ -271,11 +277,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<dynamic>)
.map((e) => SyncAssetEditV1.fromJson(e))
.whereType<SyncAssetEditV1>()
.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) {

View File

@@ -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<AssetEditAction>()();
BlobColumn get parameters => blob().map(editParameterConverter)();
IntColumn get sequence => integer()();
@override
Set<Column> get primaryKey => {id};
}
final JsonTypeConverter2<Map<String, Object?>, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb(
fromJson: (json) => json as Map<String, Object?>,
);
extension AssetEditEntityDataDomainEx on AssetEditEntityData {
AssetEdit toDto() {
return AssetEdit(action: action, parameters: parameters);
}
}

View File

@@ -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<String, Object?> parameters,
required int sequence,
});
typedef $$AssetEditEntityTableUpdateCompanionBuilder =
i1.AssetEditEntityCompanion Function({
i0.Value<String> id,
i0.Value<String> assetId,
i0.Value<i2.AssetEditAction> action,
i0.Value<Map<String, Object?>> parameters,
i0.Value<int> 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<i5.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$AssetEditEntityTable>('asset_edit_entity')
.assetId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
i5.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;
final manager = i5
.$$RemoteAssetEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer(
$_db,
).resultSet<i5.$RemoteAssetEntityTable>('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<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnWithTypeConverterFilters<i2.AssetEditAction, i2.AssetEditAction, int>
get action => $composableBuilder(
column: $table.action,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnWithTypeConverterFilters<
Map<String, Object?>,
Map<String, Object>,
i3.Uint8List
>
get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnFilters<int> 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<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get action => $composableBuilder(
column: $table.action,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<i3.Uint8List> get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> 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<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int> get action =>
$composableBuilder(column: $table.action, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<Map<String, Object?>, i3.Uint8List>
get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => column,
);
i0.GeneratedColumn<int> 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<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('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<String> id = const i0.Value.absent(),
i0.Value<String> assetId = const i0.Value.absent(),
i0.Value<i2.AssetEditAction> action = const i0.Value.absent(),
i0.Value<Map<String, Object?>> parameters =
const i0.Value.absent(),
i0.Value<int> 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<String, Object?> 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<String> id = i0.GeneratedColumn<String>(
'id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
'assetId',
);
@override
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
'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<i2.AssetEditAction, int>
action =
i0.GeneratedColumn<int>(
'action',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
).withConverter<i2.AssetEditAction>(
i1.$AssetEditEntityTable.$converteraction,
);
@override
late final i0.GeneratedColumnWithTypeConverter<
Map<String, Object?>,
i3.Uint8List
>
parameters =
i0.GeneratedColumn<i3.Uint8List>(
'parameters',
aliasedName,
false,
type: i0.DriftSqlType.blob,
requiredDuringInsert: true,
).withConverter<Map<String, Object?>>(
i1.$AssetEditEntityTable.$converterparameters,
);
static const i0.VerificationMeta _sequenceMeta = const i0.VerificationMeta(
'sequence',
);
@override
late final i0.GeneratedColumn<int> sequence = i0.GeneratedColumn<int>(
'sequence',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<i0.GeneratedColumn> 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<i1.AssetEditEntityData> 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<i0.GeneratedColumn> get $primaryKey => {id};
@override
i1.AssetEditEntityData map(Map<String, dynamic> 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<i2.AssetEditAction, int, int> $converteraction =
const i0.EnumIndexConverter<i2.AssetEditAction>(
i2.AssetEditAction.values,
);
static i0.JsonTypeConverter2<Map<String, Object?>, i3.Uint8List, Object?>
$converterparameters = i4.editParameterConverter;
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class AssetEditEntityData extends i0.DataClass
implements i0.Insertable<i1.AssetEditEntityData> {
final String id;
final String assetId;
final i2.AssetEditAction action;
final Map<String, Object?> parameters;
final int sequence;
const AssetEditEntityData({
required this.id,
required this.assetId,
required this.action,
required this.parameters,
required this.sequence,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<String>(id);
map['asset_id'] = i0.Variable<String>(assetId);
{
map['action'] = i0.Variable<int>(
i1.$AssetEditEntityTable.$converteraction.toSql(action),
);
}
{
map['parameters'] = i0.Variable<i3.Uint8List>(
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters),
);
}
map['sequence'] = i0.Variable<int>(sequence);
return map;
}
factory AssetEditEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return AssetEditEntityData(
id: serializer.fromJson<String>(json['id']),
assetId: serializer.fromJson<String>(json['assetId']),
action: i1.$AssetEditEntityTable.$converteraction.fromJson(
serializer.fromJson<int>(json['action']),
),
parameters: i1.$AssetEditEntityTable.$converterparameters.fromJson(
serializer.fromJson<Object?>(json['parameters']),
),
sequence: serializer.fromJson<int>(json['sequence']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'assetId': serializer.toJson<String>(assetId),
'action': serializer.toJson<int>(
i1.$AssetEditEntityTable.$converteraction.toJson(action),
),
'parameters': serializer.toJson<Object?>(
i1.$AssetEditEntityTable.$converterparameters.toJson(parameters),
),
'sequence': serializer.toJson<int>(sequence),
};
}
i1.AssetEditEntityData copyWith({
String? id,
String? assetId,
i2.AssetEditAction? action,
Map<String, Object?>? 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<i1.AssetEditEntityData> {
final i0.Value<String> id;
final i0.Value<String> assetId;
final i0.Value<i2.AssetEditAction> action;
final i0.Value<Map<String, Object?>> parameters;
final i0.Value<int> 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<String, Object?> 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<i1.AssetEditEntityData> custom({
i0.Expression<String>? id,
i0.Expression<String>? assetId,
i0.Expression<int>? action,
i0.Expression<i3.Uint8List>? parameters,
i0.Expression<int>? 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<String>? id,
i0.Value<String>? assetId,
i0.Value<i2.AssetEditAction>? action,
i0.Value<Map<String, Object?>>? parameters,
i0.Value<int>? 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<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<String>(id.value);
}
if (assetId.present) {
map['asset_id'] = i0.Variable<String>(assetId.value);
}
if (action.present) {
map['action'] = i0.Variable<int>(
i1.$AssetEditEntityTable.$converteraction.toSql(action.value),
);
}
if (parameters.present) {
map['parameters'] = i0.Variable<i3.Uint8List>(
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters.value),
);
}
if (sequence.present) {
map['sequence'] = i0.Variable<int>(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();
}
}

View File

@@ -152,6 +152,8 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
fileSize: fileSize,
dateTimeOriginal: dateTimeOriginal,
rating: rating,
width: width,
height: height,
timeZone: timeZone,
make: make,
model: model,

View File

@@ -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'},
)
@@ -97,7 +99,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 17;
int get schemaVersion => 18;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -204,6 +206,9 @@ class Drift extends $Drift implements IDatabaseRepository {
from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
},
from17To18: (m, v18) async {
await m.createTable(v18.assetEditEntity);
},
),
);

View File

@@ -41,9 +41,11 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i19;
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
as i20;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i21;
import 'package:drift/internal/modular.dart' as i22;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i22;
import 'package:drift/internal/modular.dart' as i23;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -85,9 +87,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
.$TrashedLocalAssetEntityTable(this);
i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer(
late final i21.$AssetEditEntityTable assetEditEntity = i21
.$AssetEditEntityTable(this);
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
this,
).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new);
).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -119,6 +123,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
i11.idxLatLng,
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
@@ -313,6 +318,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
),
result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)],
),
]);
@override
i0.DriftDatabaseOptions get options =>
@@ -372,4 +384,6 @@ class $DriftManager {
_db,
_db.trashedLocalAssetEntity,
);
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
}

View File

@@ -7408,6 +7408,497 @@ i1.GeneratedColumn<bool> _column_101(String aliasedName) =>
),
defaultValue: const CustomExpression('0'),
);
final class Schema18 extends i0.VersionedSchema {
Schema18({required super.database}) : super(version: 18);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
idxLatLng,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape28 remoteAssetEntity = Shape28(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
_column_101,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 localAssetEntity = Shape26(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_98,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 trashedLocalAssetEntity = Shape25(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
_column_97,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape29 assetEditEntity = Shape29(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_36, _column_102, _column_103, _column_104],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape29 extends i0.VersionedTable {
Shape29({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get action =>
columnsByName['action']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<i2.Uint8List> get parameters =>
columnsByName['parameters']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<int> get sequence =>
columnsByName['sequence']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_102(String aliasedName) =>
i1.GeneratedColumn<int>(
'action',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i1.GeneratedColumn<i2.Uint8List> _column_103(String aliasedName) =>
i1.GeneratedColumn<i2.Uint8List>(
'parameters',
aliasedName,
false,
type: i1.DriftSqlType.blob,
);
i1.GeneratedColumn<int> _column_104(String aliasedName) =>
i1.GeneratedColumn<int>(
'sequence',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -7425,6 +7916,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -7508,6 +8000,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from16To17(migrator, schema);
return 17;
case 17:
final schema = Schema18(database: database);
final migrator = i1.Migrator(database, schema);
await from17To18(migrator, schema);
return 18;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -7531,6 +8028,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -7549,5 +8047,6 @@ i1.OnUpgrade stepByStep({
from14To15: from14To15,
from15To16: from15To16,
from16To17: from16To17,
from17To18: from17To18,
),
);

View File

@@ -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<int> getCount() {
return _db.managers.remoteAssetEntity.count();
}
Future<List<AssetEdit>> 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<void> editAsset(String assetId, List<AssetEdit> 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);
}
});
});
}
}

View File

@@ -45,6 +45,7 @@ class SyncApiRepository {
SyncRequestType.usersV1,
SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1,
SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1,
@@ -149,6 +150,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
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,

View File

@@ -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<void> updateAssetEditsV1(Iterable<SyncAssetEditV1> 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<String, Object?>),
sequence: Value(edit.sequence),
);
batch.insert(_db.assetEditEntity, companion, onConflict: DoUpdate((_) => companion));
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetEditsV1 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> deleteAssetEditsV1(Iterable<SyncAssetEditDeleteV1> 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<void> deleteAssetsMetadataV1(Iterable<SyncAssetMetadataDeleteV1> data) async {
try {
await _db.batch((batch) {
@@ -767,3 +804,13 @@ 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,
api.AssetEditAction.filter => AssetEditAction.filter,
_ => AssetEditAction.other,
};
}

View File

@@ -23,7 +23,7 @@ class FilterImagePage extends HookWidget {
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final colorFilter = useState<EditFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
@@ -42,12 +42,12 @@ class FilterImagePage extends HookWidget {
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
void applyFilter(EditFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
Future<Image> applyFilterAndConvert(EditFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
@@ -58,7 +58,7 @@ class FilterImagePage extends HookWidget {
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final filteredUiImage = await createFilteredImage(uiImage, filter.colorFilter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
@@ -86,7 +86,7 @@ class FilterImagePage extends HookWidget {
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
child: ColorFiltered(colorFilter: colorFilter.value.colorFilter, child: image),
),
),
SizedBox(
@@ -99,7 +99,7 @@ class FilterImagePage extends HookWidget {
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
label: filters[index].name,
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
@@ -117,7 +117,7 @@ class FilterImagePage extends HookWidget {
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final EditFilter filter;
final bool isSelected;
final VoidCallback onTap;
@@ -145,7 +145,7 @@ class _FilterButton extends StatelessWidget {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
colorFilter: filter.colorFilter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),

View File

@@ -0,0 +1,624 @@
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/constants/filters.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<AssetEdit> edits;
final ExifInfo exifInfo;
const DriftEditImagePage({
super.key,
required this.image,
required this.asset,
required this.edits,
required this.exifInfo,
});
@override
ConsumerState<DriftEditImagePage> createState() => _DriftEditImagePageState();
}
class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with TickerProviderStateMixin {
late final CropController cropController;
Duration _rotationAnimationDuration = const Duration(milliseconds: 250);
int _rotationAngle = 0;
bool _flipHorizontal = false;
bool _flipVertical = false;
EditFilter? _filter;
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;
String selectedSegment = 'transform';
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);
final existingFilter = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.filter);
if (existingFilter != null) {
final parsedFilter = EditFilter.fromDtoParams(existingFilter.parameters, 'Custom');
_filter = filters.firstWhereOrNull((filter) => filter == parsedFilter);
}
// dont animate to initial rotation
_rotationAnimationDuration = const Duration(milliseconds: 0);
_rotationAngle = rotationAngle.toInt();
_flipHorizontal = flipHorizontal;
_flipVertical = flipVertical;
}
Future<void> _saveEditedImage() async {
setState(() {
isEditing = true;
});
final cropParameters = convertRectToCropParameters(cropController.crop, originalWidth ?? 0, originalHeight ?? 0);
final normalizedRotation = (_rotationAngle % 360 + 360) % 360;
final edits = <AssetEdit>[];
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(),
),
);
}
if (_filter != null && !_filter!.isIdentity) {
edits.add(AssetEdit(action: AssetEditAction.filter, parameters: _filter!.dtoParameters));
}
try {
final completer = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
final eventData = data as Map<String, dynamic>;
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;
}
});
}
void _applyAspectRatio(double? ratio) {
setState(() {
cropController.aspectRatio = ratio;
_aspectRatio = ratio;
});
}
void _applyFilter(EditFilter? filter) {
setState(() {
_filter = filter;
});
}
void _resetEdits() {
setState(() {
cropController.aspectRatio = null;
cropController.crop = const Rect.fromLTRB(0, 0, 1, 1);
_rotationAnimationDuration = const Duration(milliseconds: 250);
_rotationAngle = 0;
_flipHorizontal = false;
_flipVertical = false;
_filter = null;
_aspectRatio = null;
});
}
bool get hasEdits {
final isCropped = cropController.crop != const Rect.fromLTRB(0, 0, 1, 1);
final isRotated = (_rotationAngle % 360 + 360) % 360 != 0;
final isFlipped = _flipHorizontal || _flipVertical;
final isFiltered = _filter != null && !_filter!.isIdentity;
return isCropped || isRotated || isFlipped || isFiltered;
}
@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: Column(
children: [
Expanded(
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.95;
return 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: FutureBuilder(
future: resolveImage(widget.image.image),
builder: (context, data) {
if (!data.hasData) {
return const Center(child: CircularProgressIndicator());
}
return CropImage(
controller: cropController,
image: widget.image,
gridColor: Colors.white,
overlayPainter: MatrixAdjustmentPainter(
image: data.data!,
filter: _filter?.colorFilter,
),
);
},
),
),
),
),
);
},
),
),
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
alignment: Alignment.bottomCenter,
clipBehavior: Clip.none,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: ref.watch(immichThemeProvider).dark.surface,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedCrossFade(
duration: const Duration(milliseconds: 250),
firstCurve: Curves.easeInOut,
secondCurve: Curves.easeInOut,
sizeCurve: Curves.easeInOut,
crossFadeState: selectedSegment == 'transform'
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: _TransformControls(
onRotateLeft: _rotateLeft,
onRotateRight: _rotateRight,
onFlipHorizontal: _flipHorizontally,
onFlipVertical: _flipVertically,
onAspectRatioSelected: _applyAspectRatio,
aspectRatio: _aspectRatio,
),
secondChild: _FilterControls(
currentFilter: _filter,
previewImage: widget.image,
onApplyFilter: _applyFilter,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 36, left: 24, right: 24),
child: Row(
children: [
SegmentedButton(
segments: [
const ButtonSegment<String>(
value: 'transform',
label: Text('Transform'),
icon: Icon(Icons.transform),
),
const ButtonSegment<String>(
value: 'filters',
label: Text('Filters'),
icon: Icon(Icons.color_lens),
),
],
selected: {selectedSegment},
onSelectionChanged: (value) => setState(() {
selectedSegment = value.first;
}),
showSelectedIcon: false,
),
const Spacer(),
ImmichTextButton(
labelText: "Reset",
onPressed: _resetEdits,
variant: ImmichVariant.filled,
expanded: false,
disabled: !hasEdits,
),
],
),
),
],
),
),
),
],
),
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final double? currentAspectRatio;
final double? ratio;
final String label;
final VoidCallback onPressed;
const _AspectRatioButton({
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),
],
);
}
}
class _AspectRatioSelector extends StatelessWidget {
final double? currentAspectRatio;
final void Function(double?) onAspectRatioSelected;
const _AspectRatioSelector({required this.currentAspectRatio, required this.onAspectRatioSelected});
@override
Widget build(BuildContext context) {
final aspectRatios = <String, double?>{
'Free': null,
'1:1': 1.0,
'16:9': 16 / 9,
'3:2': 3 / 2,
'7:5': 7 / 5,
'9:16': 9 / 16,
'2:3': 2 / 3,
'5:7': 5 / 7,
};
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: aspectRatios.entries.map((entry) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _AspectRatioButton(
currentAspectRatio: currentAspectRatio,
ratio: entry.value,
label: entry.key,
onPressed: () => onAspectRatioSelected(entry.value),
),
);
}).toList(),
),
);
}
}
class _TransformControls extends StatelessWidget {
final VoidCallback onRotateLeft;
final VoidCallback onRotateRight;
final VoidCallback onFlipHorizontal;
final VoidCallback onFlipVertical;
final void Function(double?) onAspectRatioSelected;
final double? aspectRatio;
const _TransformControls({
required this.onRotateLeft,
required this.onRotateRight,
required this.onFlipHorizontal,
required this.onFlipVertical,
required this.onAspectRatioSelected,
required this.aspectRatio,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
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: onRotateLeft,
),
const SizedBox(width: 8),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: onRotateRight,
),
],
),
Row(
children: [
ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: onFlipHorizontal,
),
const SizedBox(width: 8),
Transform.rotate(
angle: pi / 2,
child: ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: onFlipVertical,
),
),
],
),
],
),
),
_AspectRatioSelector(currentAspectRatio: aspectRatio, onAspectRatioSelected: onAspectRatioSelected),
const SizedBox(height: 32),
],
);
}
}
class _FilterControls extends StatelessWidget {
final EditFilter? currentFilter;
final Image previewImage;
final void Function(EditFilter?) onApplyFilter;
const _FilterControls({required this.currentFilter, required this.previewImage, required this.onApplyFilter});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 24),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: filters.map((filter) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: previewImage,
filter: filter,
isSelected: currentFilter == filter,
onTap: () => onApplyFilter(filter),
),
);
}).toList(),
),
),
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final EditFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({required this.image, 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(12)),
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
),
child: ClipRRect(
borderRadius: BorderRadius.all(isSelected ? const Radius.circular(9) : const Radius.circular(12)),
child: ColorFiltered(
colorFilter: filter.colorFilter,
child: Image(image: image.image, fit: BoxFit.cover),
),
),
),
),
const SizedBox(height: 10),
Text(filter.name, style: context.themeData.textTheme.bodyMedium),
],
);
}
}

View File

@@ -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<double?>(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: <Widget>[
_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<double?> 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),
],
);
}
}

View File

@@ -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<Uint8List> _imageToUint8List(Image image) async {
final Completer<Uint8List> 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<void> _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: <Widget>[
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: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
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: <Widget>[
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),
],
),
],
),
),
);
}
}

View File

@@ -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<ColorFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
final completer = Completer<ui.Image>();
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<Image> applyFilterAndConvert(ColorFilter filter) async {
final completer = Completer<ui.Image>();
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),
],
);
}
}

View File

@@ -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<void> 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(

View File

@@ -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) ...[

View File

@@ -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) {

View File

@@ -102,7 +102,7 @@ mixin CancellableImageProviderMixin<T extends Object> 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)) {
@@ -120,13 +120,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);
@@ -134,7 +134,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 ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>

View File

@@ -14,8 +14,9 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> {
final String assetId;
final String thumbhash;
final bool edited;
RemoteThumbProvider({required this.assetId, required this.thumbhash});
RemoteThumbProvider({required this.assetId, required this.thumbhash, this.edited = true});
@override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -36,7 +37,7 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash, edited: key.edited),
headers: ApiService.getRequestHeaders(),
);
return loadRequest(request, decode);
@@ -46,14 +47,14 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteThumbProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
return assetId == other.assetId && thumbhash == other.thumbhash && edited == other.edited;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ edited.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
@@ -61,8 +62,14 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final String assetId;
final String thumbhash;
final AssetType assetType;
final bool edited;
RemoteFullImageProvider({required this.assetId, required this.thumbhash, required this.assetType});
RemoteFullImageProvider({
required this.assetId,
required this.thumbhash,
required this.assetType,
this.edited = true,
});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -73,7 +80,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
initialImage: getInitialImage(
RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash, edited: key.edited),
),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
@@ -92,7 +101,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final headers = ApiService.getRequestHeaders();
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
uri: getThumbnailUrlForRemoteId(
key.assetId,
type: AssetMediaSize.preview,
thumbhash: key.thumbhash,
edited: key.edited,
),
headers: headers,
);
yield* loadRequest(previewRequest, decode);
@@ -106,7 +120,10 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return;
}
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
final originalRequest = request = RemoteImageRequest(
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
headers: headers,
);
yield* loadRequest(originalRequest, decode);
}
@@ -114,12 +131,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
return assetId == other.assetId && thumbhash == other.thumbhash && edited == other.edited;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ edited.hashCode;
}

View File

@@ -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';
@@ -469,6 +470,23 @@ class ActionNotifier extends Notifier<void> {
});
}
}
Future<ActionResult> applyEdits(ActionSource source, List<AssetEdit> 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<RemoteAsset> {

View File

@@ -206,6 +206,27 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
state.socket?.on('on_upload_success', _handleOnUploadSuccess);
}
Future<void> waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) {
final completer = Completer<void>();
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(

View File

@@ -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<void> updateRating(String assetId, int rating) {
return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
}
Future<void> editAsset(String assetId, List<AssetEdit> edits) async {
final editDtos = edits
.map((edit) {
if (edit.action == AssetEditAction.other) {
return null;
}
return AssetEditActionListDtoEditsInner(action: edit.action.toDto()!, parameters: edit.parameters);
})
.whereType<AssetEditActionListDtoEditsInner>()
.toList();
await _api.editAsset(assetId, AssetEditActionListDto(edits: editDtos));
}
Future<void> removeEdits(String assetId) async {
await _api.removeAssetEdits(assetId);
}
}
extension on StackResponseDto {

View File

@@ -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]),

View File

@@ -982,70 +982,24 @@ class DriftCreateAlbumRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftCropImagePage]
class DriftCropImageRoute extends PageRouteInfo<DriftCropImageRouteArgs> {
DriftCropImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? 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<DriftCropImageRouteArgs>();
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<DriftEditImageRouteArgs> {
DriftEditImageRoute({
Key? key,
required BaseAsset asset,
required Image image,
required bool isEdited,
required BaseAsset asset,
required List<AssetEdit> edits,
required ExifInfo exifInfo,
List<PageRouteInfo>? 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<DriftEditImageRouteArgs> {
final args = data.argsAs<DriftEditImageRouteArgs>();
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<DriftEditImageRouteArgs> {
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<AssetEdit> 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<void> {
);
}
/// generated route for
/// [DriftFilterImagePage]
class DriftFilterImageRoute extends PageRouteInfo<DriftFilterImageRouteArgs> {
DriftFilterImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? 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<DriftFilterImageRouteArgs>();
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<void> {

View File

@@ -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<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
if (edits.isEmpty) {
await _assetApiRepository.removeEdits(remoteId);
} else {
await _assetApiRepository.editAsset(remoteId, edits);
}
await _remoteAssetRepository.editAsset(remoteId, edits);
}
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {

View File

@@ -0,0 +1,124 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.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<AssetEdit> edits) {
return AffineMatrix.compose(
edits.map<AffineMatrix>((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<AssetEdit> 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);
}
class MatrixAdjustmentPainter extends CustomPainter {
final ui.Image image;
final ColorFilter? filter;
const MatrixAdjustmentPainter({required this.image, this.filter});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..colorFilter = filter;
final srcRect = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
final dstRect = Rect.fromLTWH(0, 0, size.width, size.height);
canvas.drawImageRect(image, srcRect, dstRect, paint);
}
@override
bool shouldRepaint(covariant MatrixAdjustmentPainter oldDelegate) {
return oldDelegate.image != image || oldDelegate.filter != filter;
}
}
/// Helper to resolve an ImageProvider to a ui.Image
Future<ui.Image> resolveImage(ImageProvider provider) {
final completer = Completer<ui.Image>();
final stream = provider.resolve(const ImageConfiguration());
late final ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool sync) {
if (!completer.isCompleted) {
completer.complete(info.image);
}
stream.removeListener(listener);
},
onError: (error, stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
stream.removeListener(listener);
},
);
stream.addListener(listener);
return completer.future;
}

View File

@@ -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,
),
);
}

View File

@@ -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<AffineMatrix> transformations = const []]) {
return transformations.fold<AffineMatrix>(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);
}
}

View File

@@ -358,6 +358,7 @@ Class | Method | HTTP request | Description
- [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md)
- [AssetEditAction](doc//AssetEditAction.md)
- [AssetEditActionCrop](doc//AssetEditActionCrop.md)
- [AssetEditActionFilter](doc//AssetEditActionFilter.md)
- [AssetEditActionListDto](doc//AssetEditActionListDto.md)
- [AssetEditActionListDtoEditsInner](doc//AssetEditActionListDtoEditsInner.md)
- [AssetEditActionMirror](doc//AssetEditActionMirror.md)
@@ -427,6 +428,7 @@ Class | Method | HTTP request | Description
- [ExifResponseDto](doc//ExifResponseDto.md)
- [FaceDto](doc//FaceDto.md)
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
- [FilterParameters](doc//FilterParameters.md)
- [FoldersResponse](doc//FoldersResponse.md)
- [FoldersUpdate](doc//FoldersUpdate.md)
- [ImageFormat](doc//ImageFormat.md)
@@ -574,6 +576,8 @@ Class | Method | HTTP request | Description
- [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
- [SyncAlbumV1](doc//SyncAlbumV1.md)
- [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
- [SyncAssetEditDeleteV1](doc//SyncAssetEditDeleteV1.md)
- [SyncAssetEditV1](doc//SyncAssetEditV1.md)
- [SyncAssetExifV1](doc//SyncAssetExifV1.md)
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)

View File

@@ -98,6 +98,7 @@ part 'model/asset_delta_sync_dto.dart';
part 'model/asset_delta_sync_response_dto.dart';
part 'model/asset_edit_action.dart';
part 'model/asset_edit_action_crop.dart';
part 'model/asset_edit_action_filter.dart';
part 'model/asset_edit_action_list_dto.dart';
part 'model/asset_edit_action_list_dto_edits_inner.dart';
part 'model/asset_edit_action_mirror.dart';
@@ -167,6 +168,7 @@ part 'model/email_notifications_update.dart';
part 'model/exif_response_dto.dart';
part 'model/face_dto.dart';
part 'model/facial_recognition_config.dart';
part 'model/filter_parameters.dart';
part 'model/folders_response.dart';
part 'model/folders_update.dart';
part 'model/image_format.dart';
@@ -314,6 +316,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';

View File

@@ -242,6 +242,8 @@ class ApiClient {
return AssetEditActionTypeTransformer().decode(value);
case 'AssetEditActionCrop':
return AssetEditActionCrop.fromJson(value);
case 'AssetEditActionFilter':
return AssetEditActionFilter.fromJson(value);
case 'AssetEditActionListDto':
return AssetEditActionListDto.fromJson(value);
case 'AssetEditActionListDtoEditsInner':
@@ -380,6 +382,8 @@ class ApiClient {
return FaceDto.fromJson(value);
case 'FacialRecognitionConfig':
return FacialRecognitionConfig.fromJson(value);
case 'FilterParameters':
return FilterParameters.fromJson(value);
case 'FoldersResponse':
return FoldersResponse.fromJson(value);
case 'FoldersUpdate':
@@ -674,6 +678,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':

View File

@@ -26,12 +26,14 @@ class AssetEditAction {
static const crop = AssetEditAction._(r'crop');
static const rotate = AssetEditAction._(r'rotate');
static const mirror = AssetEditAction._(r'mirror');
static const filter = AssetEditAction._(r'filter');
/// List of all possible values in this [enum][AssetEditAction].
static const values = <AssetEditAction>[
crop,
rotate,
mirror,
filter,
];
static AssetEditAction? fromJson(dynamic value) => AssetEditActionTypeTransformer().decode(value);
@@ -73,6 +75,7 @@ class AssetEditActionTypeTransformer {
case r'crop': return AssetEditAction.crop;
case r'rotate': return AssetEditAction.rotate;
case r'mirror': return AssetEditAction.mirror;
case r'filter': return AssetEditAction.filter;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditActionFilter {
/// Returns a new [AssetEditActionFilter] instance.
AssetEditActionFilter({
required this.action,
required this.parameters,
});
AssetEditAction action;
FilterParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionFilter &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'AssetEditActionFilter[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [AssetEditActionFilter] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditActionFilter? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionFilter");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditActionFilter(
action: AssetEditAction.fromJson(json[r'action'])!,
parameters: FilterParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<AssetEditActionFilter> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionFilter>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditActionFilter.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditActionFilter> mapFromJson(dynamic json) {
final map = <String, AssetEditActionFilter>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditActionFilter.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditActionFilter-objects as value to a dart map
static Map<String, List<AssetEditActionFilter>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionFilter>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditActionFilter.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}

View File

@@ -19,7 +19,7 @@ class AssetEditActionListDtoEditsInner {
AssetEditAction action;
MirrorParameters parameters;
Map<String, dynamic> parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner &&
@@ -52,7 +52,7 @@ class AssetEditActionListDtoEditsInner {
return AssetEditActionListDtoEditsInner(
action: AssetEditAction.fromJson(json[r'action'])!,
parameters: MirrorParameters.fromJson(json[r'parameters'])!,
parameters: json[r'parameters'],
);
}
return null;

View File

@@ -0,0 +1,208 @@
//
// 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 FilterParameters {
/// Returns a new [FilterParameters] instance.
FilterParameters({
required this.bOffset,
required this.bbBias,
required this.bgBias,
required this.brBias,
required this.gOffset,
required this.gbBias,
required this.ggBias,
required this.grBias,
required this.rOffset,
required this.rbBias,
required this.rgBias,
required this.rrBias,
});
/// B Offset (-255 -> 255)
///
/// Minimum value: -255
/// Maximum value: 255
num bOffset;
/// BB Bias
num bbBias;
/// BG Bias
num bgBias;
/// BR Bias
num brBias;
/// G Offset (-255 -> 255)
///
/// Minimum value: -255
/// Maximum value: 255
num gOffset;
/// GB Bias
num gbBias;
/// GG Bias
num ggBias;
/// GR Bias
num grBias;
/// R Offset (-255 -> 255)
///
/// Minimum value: -255
/// Maximum value: 255
num rOffset;
/// RB Bias
num rbBias;
/// RG Bias
num rgBias;
/// RR Bias
num rrBias;
@override
bool operator ==(Object other) => identical(this, other) || other is FilterParameters &&
other.bOffset == bOffset &&
other.bbBias == bbBias &&
other.bgBias == bgBias &&
other.brBias == brBias &&
other.gOffset == gOffset &&
other.gbBias == gbBias &&
other.ggBias == ggBias &&
other.grBias == grBias &&
other.rOffset == rOffset &&
other.rbBias == rbBias &&
other.rgBias == rgBias &&
other.rrBias == rrBias;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(bOffset.hashCode) +
(bbBias.hashCode) +
(bgBias.hashCode) +
(brBias.hashCode) +
(gOffset.hashCode) +
(gbBias.hashCode) +
(ggBias.hashCode) +
(grBias.hashCode) +
(rOffset.hashCode) +
(rbBias.hashCode) +
(rgBias.hashCode) +
(rrBias.hashCode);
@override
String toString() => 'FilterParameters[bOffset=$bOffset, bbBias=$bbBias, bgBias=$bgBias, brBias=$brBias, gOffset=$gOffset, gbBias=$gbBias, ggBias=$ggBias, grBias=$grBias, rOffset=$rOffset, rbBias=$rbBias, rgBias=$rgBias, rrBias=$rrBias]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'bOffset'] = this.bOffset;
json[r'bbBias'] = this.bbBias;
json[r'bgBias'] = this.bgBias;
json[r'brBias'] = this.brBias;
json[r'gOffset'] = this.gOffset;
json[r'gbBias'] = this.gbBias;
json[r'ggBias'] = this.ggBias;
json[r'grBias'] = this.grBias;
json[r'rOffset'] = this.rOffset;
json[r'rbBias'] = this.rbBias;
json[r'rgBias'] = this.rgBias;
json[r'rrBias'] = this.rrBias;
return json;
}
/// Returns a new [FilterParameters] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static FilterParameters? fromJson(dynamic value) {
upgradeDto(value, "FilterParameters");
if (value is Map) {
final json = value.cast<String, dynamic>();
return FilterParameters(
bOffset: num.parse('${json[r'bOffset']}'),
bbBias: num.parse('${json[r'bbBias']}'),
bgBias: num.parse('${json[r'bgBias']}'),
brBias: num.parse('${json[r'brBias']}'),
gOffset: num.parse('${json[r'gOffset']}'),
gbBias: num.parse('${json[r'gbBias']}'),
ggBias: num.parse('${json[r'ggBias']}'),
grBias: num.parse('${json[r'grBias']}'),
rOffset: num.parse('${json[r'rOffset']}'),
rbBias: num.parse('${json[r'rbBias']}'),
rgBias: num.parse('${json[r'rgBias']}'),
rrBias: num.parse('${json[r'rrBias']}'),
);
}
return null;
}
static List<FilterParameters> listFromJson(dynamic json, {bool growable = false,}) {
final result = <FilterParameters>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = FilterParameters.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, FilterParameters> mapFromJson(dynamic json) {
final map = <String, FilterParameters>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = FilterParameters.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of FilterParameters-objects as value to a dart map
static Map<String, List<FilterParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<FilterParameters>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = FilterParameters.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'bOffset',
'bbBias',
'bgBias',
'brBias',
'gOffset',
'gbBias',
'ggBias',
'grBias',
'rOffset',
'rbBias',
'rgBias',
'rrBias',
};
}

View File

@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SyncAssetEditDeleteV1(
assetId: mapValueOfType<String>(json, r'assetId')!,
);
}
return null;
}
static List<SyncAssetEditDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetEditDeleteV1>[];
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<String, SyncAssetEditDeleteV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetEditDeleteV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SyncAssetEditDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetEditDeleteV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'assetId',
};
}

View File

@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SyncAssetEditV1(
action: AssetEditAction.fromJson(json[r'action'])!,
assetId: mapValueOfType<String>(json, r'assetId')!,
id: mapValueOfType<String>(json, r'id')!,
parameters: mapValueOfType<Object>(json, r'parameters')!,
sequence: mapValueOfType<int>(json, r'sequence')!,
);
}
return null;
}
static List<SyncAssetEditV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetEditV1>[];
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<String, SyncAssetEditV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetEditV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SyncAssetEditV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetEditV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'action',
'assetId',
'id',
'parameters',
'sequence',
};
}

View File

@@ -29,6 +29,8 @@ class SyncEntityType {
static const assetV1 = SyncEntityType._(r'AssetV1');
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
static const assetEditV1 = SyncEntityType._(r'AssetEditV1');
static const assetEditDeleteV1 = SyncEntityType._(r'AssetEditDeleteV1');
static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1');
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
static const partnerV1 = SyncEntityType._(r'PartnerV1');
@@ -79,6 +81,8 @@ class SyncEntityType {
assetV1,
assetDeleteV1,
assetExifV1,
assetEditV1,
assetEditDeleteV1,
assetMetadataV1,
assetMetadataDeleteV1,
partnerV1,
@@ -164,6 +168,8 @@ class SyncEntityTypeTypeTransformer {
case r'AssetV1': return SyncEntityType.assetV1;
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
case r'AssetExifV1': return SyncEntityType.assetExifV1;
case r'AssetEditV1': return SyncEntityType.assetEditV1;
case r'AssetEditDeleteV1': return SyncEntityType.assetEditDeleteV1;
case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1;
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
case r'PartnerV1': return SyncEntityType.partnerV1;

View File

@@ -30,6 +30,7 @@ class SyncRequestType {
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
static const assetsV1 = SyncRequestType._(r'AssetsV1');
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1');
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
@@ -53,6 +54,7 @@ class SyncRequestType {
albumAssetExifsV1,
assetsV1,
assetExifsV1,
assetEditsV1,
assetMetadataV1,
authUsersV1,
memoriesV1,
@@ -111,6 +113,7 @@ class SyncRequestTypeTypeTransformer {
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
case r'AssetsV1': return SyncRequestType.assetsV1;
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
case r'AssetEditsV1': return SyncRequestType.assetEditsV1;
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
case r'MemoriesV1': return SyncRequestType.memoriesV1;

View File

@@ -20,6 +20,7 @@ import 'schema_v14.dart' as v14;
import 'schema_v15.dart' as v15;
import 'schema_v16.dart' as v16;
import 'schema_v17.dart' as v17;
import 'schema_v18.dart' as v18;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -59,6 +60,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v16.DatabaseAtV16(db);
case 17:
return v17.DatabaseAtV17(db);
case 18:
return v18.DatabaseAtV18(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -82,5 +85,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
15,
16,
17,
18,
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<AssetEdit> normalizedToEdits(double rotation, bool mirrorH, bool mirrorV) {
List<AssetEdit> 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<AssetEdit> editsA, List<AssetEdit> 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 = <AssetEdit>[];
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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 = <AssetEdit>[
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);
});
});
}

View File

@@ -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

View File

@@ -15797,7 +15797,8 @@
"enum": [
"crop",
"rotate",
"mirror"
"mirror",
"filter"
],
"type": "string"
},
@@ -15820,6 +15821,25 @@
],
"type": "object"
},
"AssetEditActionFilter": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
]
},
"parameters": {
"$ref": "#/components/schemas/FilterParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"AssetEditActionListDto": {
"properties": {
"edits": {
@@ -15834,6 +15854,9 @@
},
{
"$ref": "#/components/schemas/AssetEditActionMirror"
},
{
"$ref": "#/components/schemas/AssetEditActionFilter"
}
]
},
@@ -15902,6 +15925,9 @@
},
{
"$ref": "#/components/schemas/AssetEditActionMirror"
},
{
"$ref": "#/components/schemas/AssetEditActionFilter"
}
]
},
@@ -17545,6 +17571,79 @@
],
"type": "object"
},
"FilterParameters": {
"properties": {
"bOffset": {
"description": "B Offset (-255 -> 255)",
"maximum": 255,
"minimum": -255,
"type": "number"
},
"bbBias": {
"description": "BB Bias",
"type": "number"
},
"bgBias": {
"description": "BG Bias",
"type": "number"
},
"brBias": {
"description": "BR Bias",
"type": "number"
},
"gOffset": {
"description": "G Offset (-255 -> 255)",
"maximum": 255,
"minimum": -255,
"type": "number"
},
"gbBias": {
"description": "GB Bias",
"type": "number"
},
"ggBias": {
"description": "GG Bias",
"type": "number"
},
"grBias": {
"description": "GR Bias",
"type": "number"
},
"rOffset": {
"description": "R Offset (-255 -> 255)",
"maximum": 255,
"minimum": -255,
"type": "number"
},
"rbBias": {
"description": "RB Bias",
"type": "number"
},
"rgBias": {
"description": "RG Bias",
"type": "number"
},
"rrBias": {
"description": "RR Bias",
"type": "number"
}
},
"required": [
"bOffset",
"bbBias",
"bgBias",
"brBias",
"gOffset",
"gbBias",
"ggBias",
"grBias",
"rOffset",
"rbBias",
"rgBias",
"rrBias"
],
"type": "object"
},
"FoldersResponse": {
"properties": {
"enabled": {
@@ -21513,6 +21612,48 @@
],
"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": {
@@ -21932,6 +22073,8 @@
"AssetV1",
"AssetDeleteV1",
"AssetExifV1",
"AssetEditV1",
"AssetEditDeleteV1",
"AssetMetadataV1",
"AssetMetadataDeleteV1",
"PartnerV1",
@@ -22194,6 +22337,7 @@
"AlbumAssetExifsV1",
"AssetsV1",
"AssetExifsV1",
"AssetEditsV1",
"AssetMetadataV1",
"AuthUsersV1",
"MemoriesV1",

View File

@@ -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;
- FilterParameters parameters;
+ Map<String, dynamic> 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: FilterParameters.fromJson(json[r'parameters'])!,
+ parameters: json[r'parameters'],
);
}
return null;

View File

@@ -637,14 +637,44 @@ export type AssetEditActionMirror = {
action: AssetEditAction;
parameters: MirrorParameters;
};
export type FilterParameters = {
/** B Offset (-255 -> 255) */
bOffset: number;
/** BB Bias */
bbBias: number;
/** BG Bias */
bgBias: number;
/** BR Bias */
brBias: number;
/** G Offset (-255 -> 255) */
gOffset: number;
/** GB Bias */
gbBias: number;
/** GG Bias */
ggBias: number;
/** GR Bias */
grBias: number;
/** R Offset (-255 -> 255) */
rOffset: number;
/** RB Bias */
rbBias: number;
/** RG Bias */
rgBias: number;
/** RR Bias */
rrBias: number;
};
export type AssetEditActionFilter = {
action: AssetEditAction;
parameters: FilterParameters;
};
export type AssetEditsDto = {
assetId: string;
/** list of edits */
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[];
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter)[];
};
export type AssetEditActionListDto = {
/** list of edits */
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[];
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter)[];
};
export type AssetMetadataResponseDto = {
key: string;
@@ -1912,6 +1942,16 @@ export type SyncAlbumV1 = {
export type SyncAssetDeleteV1 = {
assetId: string;
};
export type SyncAssetEditDeleteV1 = {
assetId: string;
};
export type SyncAssetEditV1 = {
action: AssetEditAction;
assetId: string;
id: string;
parameters: object;
sequence: number;
};
export type SyncAssetExifV1 = {
assetId: string;
city: string | null;
@@ -5860,7 +5900,8 @@ export enum AssetJobName {
export enum AssetEditAction {
Crop = "crop",
Rotate = "rotate",
Mirror = "mirror"
Mirror = "mirror",
Filter = "filter"
}
export enum MirrorAxis {
Horizontal = "horizontal",
@@ -6018,6 +6059,8 @@ export enum SyncEntityType {
AssetV1 = "AssetV1",
AssetDeleteV1 = "AssetDeleteV1",
AssetExifV1 = "AssetExifV1",
AssetEditV1 = "AssetEditV1",
AssetEditDeleteV1 = "AssetEditDeleteV1",
AssetMetadataV1 = "AssetMetadataV1",
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
PartnerV1 = "PartnerV1",
@@ -6068,6 +6111,7 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
AssetsV1 = "AssetsV1",
AssetExifsV1 = "AssetExifsV1",
AssetEditsV1 = "AssetEditsV1",
AssetMetadataV1 = "AssetMetadataV1",
AuthUsersV1 = "AuthUsersV1",
MemoriesV1 = "MemoriesV1",

View File

@@ -1,12 +1,13 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer';
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
import { ArrayMinSize, IsEnum, IsInt, IsNumber, Max, Min, ValidateNested } from 'class-validator';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation';
export enum AssetEditAction {
Crop = 'crop',
Rotate = 'rotate',
Mirror = 'mirror',
Filter = 'filter',
}
export enum MirrorAxis {
@@ -48,6 +49,68 @@ export class MirrorParameters {
axis!: MirrorAxis;
}
// Sharp supports a 3x3 matrix for color manipulation and rgb offsets
// The matrix representation of a filter is as follows:
// | rrBias rgBias rbBias | | r_offset |
// Image x | grBias ggBias gbBias | + | g_offset |
// | brBias bgBias bbBias | | b_offset |
export class FilterParameters {
@IsNumber()
@ApiProperty({ description: 'RR Bias' })
rrBias!: number;
@IsNumber()
@ApiProperty({ description: 'RG Bias' })
rgBias!: number;
@IsNumber()
@ApiProperty({ description: 'RB Bias' })
rbBias!: number;
@IsNumber()
@ApiProperty({ description: 'GR Bias' })
grBias!: number;
@IsNumber()
@ApiProperty({ description: 'GG Bias' })
ggBias!: number;
@IsNumber()
@ApiProperty({ description: 'GB Bias' })
gbBias!: number;
@IsNumber()
@ApiProperty({ description: 'BR Bias' })
brBias!: number;
@IsNumber()
@ApiProperty({ description: 'BG Bias' })
bgBias!: number;
@IsNumber()
@ApiProperty({ description: 'BB Bias' })
bbBias!: number;
@IsInt()
@Min(-255)
@Max(255)
@ApiProperty({ description: 'R Offset (-255 -> 255)' })
rOffset!: number;
@IsInt()
@Min(-255)
@Max(255)
@ApiProperty({ description: 'G Offset (-255 -> 255)' })
gOffset!: number;
@IsInt()
@Min(-255)
@Max(255)
@ApiProperty({ description: 'B Offset (-255 -> 255)' })
bOffset!: number;
}
class AssetEditActionBase {
@IsEnum(AssetEditAction)
@ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction' })
@@ -74,6 +137,12 @@ export class AssetEditActionMirror extends AssetEditActionBase {
@ApiProperty({ type: MirrorParameters })
parameters!: MirrorParameters;
}
export class AssetEditActionFilter extends AssetEditActionBase {
@ValidateNested()
@Type(() => FilterParameters)
@ApiProperty({ type: FilterParameters })
parameters!: FilterParameters;
}
export type AssetEditActionItem =
| {
@@ -87,25 +156,31 @@ export type AssetEditActionItem =
| {
action: AssetEditAction.Mirror;
parameters: MirrorParameters;
}
| {
action: AssetEditAction.Filter;
parameters: FilterParameters;
};
export type AssetEditActionParameter = {
[AssetEditAction.Crop]: CropParameters;
[AssetEditAction.Rotate]: RotateParameters;
[AssetEditAction.Mirror]: MirrorParameters;
[AssetEditAction.Filter]: FilterParameters;
};
type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror;
type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter;
const actionToClass: Record<AssetEditAction, ClassConstructor<AssetEditActions>> = {
[AssetEditAction.Crop]: AssetEditActionCrop,
[AssetEditAction.Rotate]: AssetEditActionRotate,
[AssetEditAction.Mirror]: AssetEditActionMirror,
[AssetEditAction.Filter]: AssetEditActionFilter,
} as const;
const getActionClass = (item: { action: AssetEditAction }): ClassConstructor<AssetEditActions> =>
actionToClass[item.action];
@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop)
@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop, AssetEditActionFilter)
export class AssetEditActionListDto {
/** list of edits */
@ArrayMinSize(1)

View File

@@ -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,
@@ -169,6 +170,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 {
assetId!: string;
@@ -354,6 +373,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;

View File

@@ -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',

View File

@@ -17,3 +17,17 @@ where
"assetId" = $1
order by
"sequence" asc
-- AssetEditRepository.getWithSyncInfo
select
"id",
"assetId",
"sequence",
"action",
"parameters"
from
"asset_edit"
where
"assetId" = $1
order by
"sequence" asc

View File

@@ -514,6 +514,38 @@ where
order by
"asset_exif"."updateId" asc
-- SyncRepository.assetEdit.getDeletes
select
"asset_edit_audit"."id",
"assetId"
from
"asset_edit_audit" as "asset_edit_audit"
left join "asset" on "asset"."id" = "asset_edit_audit"."assetId"
where
"asset_edit_audit"."id" < $1
and "asset_edit_audit"."id" > $2
and "asset"."ownerId" = $3
order by
"asset_edit_audit"."id" asc
-- SyncRepository.assetEdit.getUpserts
select
"asset_edit"."id",
"assetId",
"action",
"parameters",
"sequence",
"asset_edit"."updateId"
from
"asset_edit" as "asset_edit"
inner join "asset" on "asset"."id" = "asset_edit"."assetId"
where
"asset_edit"."updateId" < $1
and "asset_edit"."updateId" > $2
and "asset"."ownerId" = $3
order by
"asset_edit"."updateId" asc
-- SyncRepository.assetFace.getDeletes
select
"asset_face_audit"."id",

View File

@@ -39,4 +39,16 @@ export class AssetEditRepository {
.orderBy('sequence', 'asc')
.execute() as Promise<AssetEditActionItem[]>;
}
@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();
}
}

View File

@@ -165,6 +165,38 @@ describe(MediaRepository.name, () => {
// bottom-right should now be top-right (blue)
expect(await getPixelColor(bufferVertical, 990, 990)).toEqual({ r: 0, g: 255, b: 0 });
});
it('should apply filter edit correctly', async () => {
const resultHorizontal = await sut['applyEdits'](sharp(await buildTestQuadImage()), [
{
action: AssetEditAction.Filter,
parameters: {
rrBias: 1,
rgBias: 0.5,
rbBias: 0.5,
grBias: 0.5,
ggBias: 1,
gbBias: 0.5,
brBias: 0.5,
bgBias: 0.5,
bbBias: 1,
rOffset: 5,
gOffset: 10,
bOffset: 15,
},
},
]);
const bufferHorizontal = await resultHorizontal.toBuffer();
const metadataHorizontal = await resultHorizontal.metadata();
expect(metadataHorizontal.width).toBe(1000);
expect(metadataHorizontal.height).toBe(1000);
expect(await getPixelColor(bufferHorizontal, 10, 10)).toEqual({ r: 255, g: 137, b: 142 });
expect(await getPixelColor(bufferHorizontal, 990, 10)).toEqual({ r: 132, g: 255, b: 142 });
expect(await getPixelColor(bufferHorizontal, 10, 990)).toEqual({ r: 132, g: 137, b: 255 });
expect(await getPixelColor(bufferHorizontal, 990, 990)).toEqual({ r: 255, g: 255, b: 255 });
});
});
describe('applyEdits (multiple sequential edits)', () => {
@@ -307,12 +339,29 @@ describe(MediaRepository.name, () => {
expect(await getPixelColor(buffer, 490, 490)).toEqual({ r: 255, g: 0, b: 0 });
});
it('should apply all operations: crop, rotate, mirror', async () => {
it('should apply all operations: crop, rotate, mirror, filter', async () => {
const imageBuffer = await buildTestQuadImage();
const result = await sut['applyEdits'](sharp(imageBuffer), [
{ action: AssetEditAction.Crop, parameters: { x: 0, y: 0, width: 500, height: 1000 } },
{ action: AssetEditAction.Rotate, parameters: { angle: 90 } },
{ action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal } },
{
action: AssetEditAction.Filter,
parameters: {
rrBias: 1,
rgBias: 0,
rbBias: 0,
grBias: 0,
ggBias: 1,
gbBias: 0,
brBias: 0,
bgBias: 0,
bbBias: 1,
rOffset: -10,
gOffset: 20,
bOffset: -30,
},
},
]);
const buffer = await result.png().toBuffer();
@@ -320,8 +369,8 @@ describe(MediaRepository.name, () => {
expect(metadata.width).toBe(1000);
expect(metadata.height).toBe(500);
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 255, g: 0, b: 0 });
expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 0, b: 255 });
expect(await getPixelColor(buffer, 10, 10)).toEqual({ r: 245, g: 20, b: 0 });
expect(await getPixelColor(buffer, 990, 10)).toEqual({ r: 0, g: 20, b: 225 });
});
});

View File

@@ -19,6 +19,7 @@ import {
TranscodeCommand,
VideoInfo,
} from 'src/types';
import { convertColorFilterToMatricies } from 'src/utils/color_filter';
import { handlePromiseError } from 'src/utils/misc';
import { createAffineMatrix } from 'src/utils/transform';
@@ -167,6 +168,12 @@ export class MediaRepository {
[c, d],
]);
const filter = edits.find((edit) => edit.action === 'filter');
if (filter) {
const { biasMatrix, offsetMatrix } = convertColorFilterToMatricies(filter.parameters);
pipeline = pipeline.recomb(biasMatrix).linear([1, 1, 1], offsetMatrix);
}
return pipeline;
}

View File

@@ -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) {

View File

@@ -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<AuthDto>;

View File

@@ -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`,
});

View File

@@ -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;

View File

@@ -0,0 +1,47 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}

View File

@@ -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<string>;
@Column({ type: 'uuid', index: true })
assetId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}

View File

@@ -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<T extends AssetEditAction = AssetEditAction> {
@PrimaryGeneratedColumn()
@@ -36,4 +43,7 @@ export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
@Column({ type: 'integer' })
sequence!: number;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}

View File

@@ -540,6 +540,7 @@ export class AssetService extends BaseService {
async getAssetEdits(auth: AuthDto, id: string): Promise<AssetEditsDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const edits = await this.assetEditRepository.getAll(id);
return {
assetId: id,
edits,

View File

@@ -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,
});
}

View File

@@ -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,

View File

@@ -0,0 +1,14 @@
import { Matrix3x3 } from 'sharp';
import { FilterParameters } from 'src/dtos/editing.dto';
export function convertColorFilterToMatricies(filter: FilterParameters) {
const biasMatrix: Matrix3x3 = [
[filter.rrBias, filter.rgBias, filter.rbBias],
[filter.grBias, filter.ggBias, filter.gbBias],
[filter.brBias, filter.bgBias, filter.bbBias],
];
const offsetMatrix = [filter.rOffset, filter.gOffset, filter.bOffset];
return { biasMatrix, offsetMatrix };
}

View File

@@ -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<DB>;
const setup = async (db?: Kysely<DB>) => {
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]);
});
});

View File

@@ -10,7 +10,7 @@
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { editManager } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Route } from '$lib/route';
@@ -408,7 +408,7 @@
) {
return 'ImagePanaramaViewer';
}
if (assetViewerManager.isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
if (assetViewerManager.isShowEditor) {
return 'CropArea';
}
return 'PhotoViewer';

View File

@@ -4,7 +4,7 @@
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetEdits, type AssetResponseDto } from '@immich/sdk';
import { Button, HStack, IconButton } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { mdiClose, mdiCrop, mdiPalette } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -23,11 +23,12 @@
onMount(async () => {
const edits = await getAssetEdits({ id: asset.id });
await editManager.activateTool(EditToolType.Transform, asset, edits);
await editManager.init(asset, edits);
editManager.activateTool(EditToolType.Transform);
});
onDestroy(() => {
editManager.cleanup();
onDestroy(async () => {
await editManager.cleanup();
});
async function applyEdits() {
@@ -65,6 +66,31 @@
<Button shape="round" size="small" onclick={applyEdits} loading={editManager.isApplyingEdits}>{$t('save')}</Button>
</HStack>
<HStack class="mt-4 gap-0 mx-4">
<Button
leadingIcon={mdiCrop}
variant={editManager.selectedTool?.type === EditToolType.Transform ? 'filled' : 'outline'}
onclick={() => editManager.activateTool(EditToolType.Transform)}
class="rounded-r-none"
shape="round"
size="small"
fullWidth
>
Transform
</Button>
<Button
leadingIcon={mdiPalette}
variant={editManager.selectedTool?.type === EditToolType.Filter ? 'filled' : 'outline'}
onclick={() => editManager.activateTool(EditToolType.Filter)}
class="rounded-l-none"
shape="round"
size="small"
fullWidth
>
Filter
</Button>
</HStack>
<section>
{#if editManager.selectedTool}
<editManager.selectedTool.component />

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { editManager } from '$lib/managers/edit/edit-manager.svelte';
import { filterManager } from '$lib/managers/edit/filter-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { filters } from '$lib/utils/filters';
import { AssetMediaSize } from '@immich/sdk';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
let asset = $derived(editManager.currentAsset);
</script>
<div class="mt-3 px-4">
<div class="flex h-10 w-full items-center justify-between text-sm mt-2">
<h2>{$t('editor_filters')}</h2>
</div>
<div class="grid grid-cols-3 gap-4 mt-2">
{#if asset}
{#each filters as filter (filter.name)}
{@const isSelected = filterManager.selectedFilter === filter}
<button type="button" onclick={() => filterManager.selectFilter(filter)} class="flex flex-col items-center">
<div class="w-20 h-20 rounded-md overflow-hidden {isSelected ? 'ring-3 ring-immich-primary' : ''}">
<img
src={getAssetMediaUrl({
id: asset.id,
cacheKey: asset.thumbhash,
edited: false,
size: AssetMediaSize.Thumbnail,
})}
alt="{filter.name} thumbnail"
class="w-full h-full object-cover"
style="filter: url(#{filter.cssId})"
/>
</div>
<Text size="small" class="mt-1" color={isSelected ? 'primary' : undefined}>{filter.name}</Text>
</button>
{/each}
{/if}
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute">
<defs>
{#each filters as filter (filter.name)}
<filter id={filter.cssId} color-interpolation-filters="sRGB">
<feColorMatrix type="matrix" values={filter.svgFilter} />
</filter>
{/each}
</defs>
</svg>
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { filterManager } from '$lib/managers/edit/filter-manager.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
@@ -78,6 +79,14 @@
bind:this={transformManager.overlayEl}
></div>
</button>
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute">
<defs>
<filter id="currentFilter" color-interpolation-filters="sRGB">
<feColorMatrix type="matrix" values={filterManager.selectedFilter.svgFilter} />
</filter>
</defs>
</svg>
</div>
<style>
@@ -150,6 +159,7 @@
height: 100%;
user-select: none;
transition: transform 0.15s ease;
filter: url(#currentFilter);
}
.crop-frame {

View File

@@ -1,24 +1,27 @@
import FilterTool from '$lib/components/asset-viewer/editor/filter-tool/filter-tool.svelte';
import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte';
import { filterManager } from '$lib/managers/edit/filter-manager.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { waitForWebsocketEvent } from '$lib/stores/websocket';
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
import { ConfirmModal, modalManager, toastManager } from '@immich/ui';
import { mdiCropRotate } from '@mdi/js';
import { ConfirmModal, modalManager, toastManager, type MaybePromise } from '@immich/ui';
import { mdiCropRotate, mdiPalette } from '@mdi/js';
import type { Component } from 'svelte';
export type EditAction = AssetEditsDto['edits'][number];
export type EditActions = EditAction[];
export interface EditToolManager {
onActivate: (asset: AssetResponseDto, edits: EditActions) => Promise<void>;
onDeactivate: () => void;
resetAllChanges: () => Promise<void>;
onActivate: (asset: AssetResponseDto, edits: EditActions) => MaybePromise<void>;
onDeactivate: () => MaybePromise<void>;
resetAllChanges: () => MaybePromise<void>;
hasChanges: boolean;
edits: EditAction[];
}
export enum EditToolType {
Transform = 'transform',
Filter = 'filter',
}
export interface EditTool {
@@ -36,6 +39,12 @@ export class EditManager {
component: TransformTool,
manager: transformManager,
},
{
type: EditToolType.Filter,
icon: mdiPalette,
component: FilterTool,
manager: filterManager,
},
];
currentAsset = $state<AssetResponseDto | null>(null);
@@ -69,32 +78,32 @@ export class EditManager {
return confirmed;
}
reset() {
async reset() {
for (const tool of this.tools) {
tool.manager.onDeactivate?.();
await tool.manager.onDeactivate?.();
}
this.selectedTool = this.tools[0];
}
async activateTool(toolType: EditToolType, asset: AssetResponseDto, edits: AssetEditsDto) {
this.hasAppliedEdits = false;
if (this.selectedTool?.type === toolType) {
return;
}
async init(asset: AssetResponseDto, edits: AssetEditsDto) {
this.currentAsset = asset;
this.selectedTool?.manager.onDeactivate?.();
for (const tool of this.tools) {
await tool.manager.onActivate?.(asset, edits.edits);
}
this.selectedTool = this.tools[0];
}
activateTool(toolType: EditToolType) {
const newTool = this.tools.find((t) => t.type === toolType);
if (newTool) {
this.selectedTool = newTool;
await newTool.manager.onActivate?.(asset, edits.edits);
}
}
cleanup() {
async cleanup() {
for (const tool of this.tools) {
tool.manager.onDeactivate?.();
await tool.manager.onDeactivate?.();
}
this.currentAsset = null;
this.selectedTool = null;

View File

@@ -0,0 +1,42 @@
import type { EditActions, EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
import { EditFilter, filters } from '$lib/utils/filters';
import { AssetEditAction, type AssetEditActionFilter, type AssetResponseDto, type FilterParameters } from '@immich/sdk';
class FilterManager implements EditToolManager {
selectedFilter: EditFilter = $state(filters[0]);
hasChanges = $derived(!this.selectedFilter.isIdentity);
edits = $derived<EditActions>(
this.hasChanges
? [
{
action: AssetEditAction.Filter,
parameters: this.selectedFilter.dtoParameters,
} as AssetEditActionFilter,
]
: [],
);
resetAllChanges() {
this.selectedFilter = filters[0];
}
onActivate(asset: AssetResponseDto, edits: EditActions) {
const filterEdits = edits.filter((edit) => edit.action === AssetEditAction.Filter);
if (filterEdits.length > 0) {
const dtoFilter = EditFilter.fromDto(filterEdits[0].parameters as FilterParameters, 'Custom');
this.selectedFilter = filters.find((filter) => filter.equals(dtoFilter)) ?? filters[0];
}
}
onDeactivate() {
this.resetAllChanges();
}
selectFilter(filter: EditFilter) {
this.selectedFilter = filter;
console.log('Selected filter:', filter);
}
}
export const filterManager = new FilterManager();

View File

@@ -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<Events> = io({

View File

@@ -0,0 +1,218 @@
import type { FilterParameters } from '@immich/sdk';
export class EditFilter {
name: string;
rrBias: number;
rgBias: number;
rbBias: number;
grBias: number;
ggBias: number;
gbBias: number;
brBias: number;
bgBias: number;
bbBias: number;
rOffset: number;
gOffset: number;
bOffset: number;
static identity = new EditFilter('Normal', 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0);
constructor(
name: string,
rrBias: number,
rgBias: number,
rbBias: number,
grBias: number,
ggBias: number,
gbBias: number,
brBias: number,
bgBias: number,
bbBias: number,
rOffset: number,
gOffset: number,
bOffset: number,
) {
this.name = name;
this.rrBias = rrBias;
this.rgBias = rgBias;
this.rbBias = rbBias;
this.grBias = grBias;
this.ggBias = ggBias;
this.gbBias = gbBias;
this.brBias = brBias;
this.bgBias = bgBias;
this.bbBias = bbBias;
this.rOffset = rOffset;
this.gOffset = gOffset;
this.bOffset = bOffset;
}
get dtoParameters(): FilterParameters {
return {
rrBias: this.rrBias,
rgBias: this.rgBias,
rbBias: this.rbBias,
grBias: this.grBias,
ggBias: this.ggBias,
gbBias: this.gbBias,
brBias: this.brBias,
bgBias: this.bgBias,
bbBias: this.bbBias,
rOffset: this.rOffset,
gOffset: this.gOffset,
bOffset: this.bOffset,
};
}
get svgFilter(): string {
return `
${this.rrBias} ${this.rgBias} ${this.rbBias} 0 ${this.rOffset}
${this.grBias} ${this.ggBias} ${this.gbBias} 0 ${this.gOffset}
${this.brBias} ${this.bgBias} ${this.bbBias} 0 ${this.bOffset}
0 0 0 1 0
`;
}
static fromDto(params: FilterParameters, name: string): EditFilter {
return new EditFilter(
name,
params.rrBias,
params.rgBias,
params.rbBias,
params.grBias,
params.ggBias,
params.gbBias,
params.brBias,
params.bgBias,
params.bbBias,
params.rOffset,
params.gOffset,
params.bOffset,
);
}
static fromMatrix(matrix: number[], name: string): EditFilter {
return new EditFilter(
name,
matrix[0],
matrix[1],
matrix[2],
matrix[5],
matrix[6],
matrix[7],
matrix[10],
matrix[11],
matrix[12],
matrix[15],
matrix[16],
matrix[17],
);
}
get isIdentity(): boolean {
return this.equals(EditFilter.identity);
}
equals(other: EditFilter): boolean {
return (
this.rrBias === other.rrBias &&
this.rgBias === other.rgBias &&
this.rbBias === other.rbBias &&
this.grBias === other.grBias &&
this.ggBias === other.ggBias &&
this.gbBias === other.gbBias &&
this.brBias === other.brBias &&
this.bgBias === other.bgBias &&
this.bbBias === other.bbBias &&
this.rOffset === other.rOffset &&
this.gOffset === other.gOffset &&
this.bOffset === other.bOffset
);
}
get cssId(): string {
return this.name.toLowerCase().replaceAll(/\s+/g, '-');
}
}
export const filters: EditFilter[] = [
//Original
EditFilter.fromMatrix([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0], 'Original'),
//Vintage
EditFilter.fromMatrix([0.8, 0.1, 0.1, 0, 20, 0.1, 0.8, 0.1, 0, 20, 0.1, 0.1, 0.8, 0, 20, 0, 0, 0, 1, 0], 'Vintage'),
//Mood
EditFilter.fromMatrix([1.2, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 10, 0, 0, 0, 1, 0], 'Mood'),
//Crisp
EditFilter.fromMatrix([1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], 'Crisp'),
//Cool
EditFilter.fromMatrix([0.9, 0, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], 'Cool'),
//Blush
EditFilter.fromMatrix([1.1, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 5, 0, 0, 0, 1, 0], 'Blush'),
//Sunkissed
EditFilter.fromMatrix([1.3, 0, 0.1, 0, 15, 0, 1.1, 0.1, 0, 10, 0, 0, 0.9, 0, 5, 0, 0, 0, 1, 0], 'Sunkissed'),
//Fresh
EditFilter.fromMatrix([1.2, 0, 0, 0, 20, 0, 1.2, 0, 0, 20, 0, 0, 1.1, 0, 20, 0, 0, 0, 1, 0], 'Fresh'),
//Classic
EditFilter.fromMatrix([1.1, 0, -0.1, 0, 10, -0.1, 1.1, 0.1, 0, 5, 0, -0.1, 1.1, 0, 0, 0, 0, 0, 1, 0], 'Classic'),
//Lomo-ish
EditFilter.fromMatrix([1.5, 0, 0.1, 0, 0, 0, 1.45, 0, 0, 0, 0.1, 0, 1.3, 0, 0, 0, 0, 0, 1, 0], 'Lomo-ish'),
//Nashville
EditFilter.fromMatrix(
[1.2, 0.15, -0.15, 0, 15, 0.1, 1.1, 0.1, 0, 10, -0.05, 0.2, 1.25, 0, 5, 0, 0, 0, 1, 0],
'Nashville',
),
//Valencia
EditFilter.fromMatrix([1.15, 0.1, 0.1, 0, 20, 0.1, 1.1, 0, 0, 10, 0.1, 0.1, 1.2, 0, 5, 0, 0, 0, 1, 0], 'Valencia'),
//Clarendon
EditFilter.fromMatrix([1.2, 0, 0, 0, 10, 0, 1.25, 0, 0, 10, 0, 0, 1.3, 0, 10, 0, 0, 0, 1, 0], 'Clarendon'),
//Moon
EditFilter.fromMatrix(
[0.33, 0.33, 0.33, 0, 0, 0.33, 0.33, 0.33, 0, 0, 0.33, 0.33, 0.33, 0, 0, 0, 0, 0, 1, 0],
'Moon',
),
//Willow
EditFilter.fromMatrix([0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0, 0, 0, 1, 0], 'Willow'),
//Kodak
EditFilter.fromMatrix([1.3, 0.1, -0.1, 0, 10, 0, 1.25, 0.1, 0, 10, 0, -0.1, 1.1, 0, 5, 0, 0, 0, 1, 0], 'Kodak'),
//Sunset
EditFilter.fromMatrix([1.5, 0.2, 0, 0, 0, 0.1, 0.9, 0.1, 0, 0, -0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0], 'Sunset'),
//Noir
EditFilter.fromMatrix([1.3, -0.3, 0.1, 0, 0, -0.1, 1.2, -0.1, 0, 0, 0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0], 'Noir'),
//Dreamy
EditFilter.fromMatrix([1.1, 0.1, 0.1, 0, 0, 0.1, 1.1, 0.1, 0, 0, 0.1, 0.1, 1.1, 0, 15, 0, 0, 0, 1, 0], 'Dreamy'),
//Sepia
EditFilter.fromMatrix(
[0.393, 0.769, 0.189, 0, 0, 0.349, 0.686, 0.168, 0, 0, 0.272, 0.534, 0.131, 0, 0, 0, 0, 0, 1, 0],
'Sepia',
),
//Radium
EditFilter.fromMatrix(
[1.438, -0.062, -0.062, 0, 0, -0.122, 1.378, -0.122, 0, 0, -0.016, -0.016, 1.483, 0, 0, 0, 0, 0, 1, 0],
'Radium',
),
//Aqua
EditFilter.fromMatrix(
[0.2126, 0.7152, 0.0722, 0, 0, 0.2126, 0.7152, 0.0722, 0, 0, 0.7873, 0.2848, 0.9278, 0, 0, 0, 0, 0, 1, 0],
'Aqua',
),
//Purple Haze
EditFilter.fromMatrix([1.3, 0, 1.2, 0, 0, 0, 1.1, 0, 0, 0, 0.2, 0, 1.3, 0, 0, 0, 0, 0, 1, 0], 'Purple Haze'),
//Lemonade
EditFilter.fromMatrix([1.2, 0.1, 0, 0, 0, 0, 1.1, 0.2, 0, 0, 0.1, 0, 0.7, 0, 0, 0, 0, 0, 1, 0], 'Lemonade'),
//Caramel
EditFilter.fromMatrix([1.6, 0.2, 0, 0, 0, 0.1, 1.3, 0.1, 0, 0, 0, 0.1, 0.9, 0, 0, 0, 0, 0, 1, 0], 'Caramel'),
//Peachy
EditFilter.fromMatrix([1.3, 0.5, 0, 0, 0, 0.2, 1.1, 0.3, 0, 0, 0.1, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0], 'Peachy'),
//Neon
EditFilter.fromMatrix([1, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 1, 0], 'Neon'),
//Cold Morning
EditFilter.fromMatrix([0.9, 0.1, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], 'Cold Morning'),
//Lush
EditFilter.fromMatrix([0.9, 0.2, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.1, 0, 0, 0, 0, 0, 1, 0], 'Lush'),
//Urban Neon
EditFilter.fromMatrix([1.1, 0, 0.3, 0, 0, 0, 0.9, 0.3, 0, 0, 0.3, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0], 'Urban Neon'),
//Monochrome
EditFilter.fromMatrix([0.6, 0.2, 0.2, 0, 0, 0.2, 0.6, 0.2, 0, 0, 0.2, 0.2, 0.7, 0, 0, 0, 0, 0, 1, 0], 'Monochrome'),
];