mirror of
https://github.com/immich-app/immich.git
synced 2026-01-26 11:24:44 -08:00
Compare commits
19 Commits
renovate/g
...
feat/edit-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa2aa4c58 | ||
|
|
8835e54bf4 | ||
|
|
ae9bb0aa80 | ||
|
|
2e4cfa80a9 | ||
|
|
8653e20cc5 | ||
|
|
871de53bca | ||
|
|
f5db6dfa09 | ||
|
|
7f857ab9a0 | ||
|
|
37297ce420 | ||
|
|
bdc45da106 | ||
|
|
8770cf0961 | ||
|
|
128e798516 | ||
|
|
3a87746ce6 | ||
|
|
279e706c77 | ||
|
|
7acc0d5c82 | ||
|
|
48f4282056 | ||
|
|
74297ec429 | ||
|
|
34b6e2ca99 | ||
|
|
5bb6bcf70b |
@@ -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",
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v18.json
generated
Normal file
1
mobile/drift_schemas/main/drift_schema_v18.json
generated
Normal file
File diff suppressed because one or more lines are too long
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
22
mobile/lib/domain/models/asset_edit.model.dart
Normal file
22
mobile/lib/domain/models/asset_edit.model.dart
Normal 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});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
32
mobile/lib/infrastructure/entities/asset_edit.entity.dart
Normal file
32
mobile/lib/infrastructure/entities/asset_edit.entity.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
748
mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart
generated
Normal file
748
mobile/lib/infrastructure/entities/asset_edit.entity.drift.dart
generated
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,8 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
|
||||
fileSize: fileSize,
|
||||
dateTimeOriginal: dateTimeOriginal,
|
||||
rating: rating,
|
||||
width: width,
|
||||
height: height,
|
||||
timeZone: timeZone,
|
||||
make: make,
|
||||
model: model,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
624
mobile/lib/presentation/pages/drift_edit.page.dart
Normal file
624
mobile/lib/presentation/pages/drift_edit.page.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
124
mobile/lib/utils/editor.utils.dart
Normal file
124
mobile/lib/utils/editor.utils.dart
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
50
mobile/lib/utils/matrix.utils.dart
Normal file
50
mobile/lib/utils/matrix.utils.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
@@ -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)
|
||||
|
||||
4
mobile/openapi/lib/api.dart
generated
4
mobile/openapi/lib/api.dart
generated
@@ -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';
|
||||
|
||||
8
mobile/openapi/lib/api_client.dart
generated
8
mobile/openapi/lib/api_client.dart
generated
@@ -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':
|
||||
|
||||
3
mobile/openapi/lib/model/asset_edit_action.dart
generated
3
mobile/openapi/lib/model/asset_edit_action.dart
generated
@@ -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');
|
||||
|
||||
107
mobile/openapi/lib/model/asset_edit_action_filter.dart
generated
Normal file
107
mobile/openapi/lib/model/asset_edit_action_filter.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
208
mobile/openapi/lib/model/filter_parameters.dart
generated
Normal file
208
mobile/openapi/lib/model/filter_parameters.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
99
mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart
generated
Normal file
99
mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
131
mobile/openapi/lib/model/sync_asset_edit_v1.dart
generated
Normal file
131
mobile/openapi/lib/model/sync_asset_edit_v1.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
6
mobile/openapi/lib/model/sync_entity_type.dart
generated
6
mobile/openapi/lib/model/sync_entity_type.dart
generated
@@ -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;
|
||||
|
||||
3
mobile/openapi/lib/model/sync_request_type.dart
generated
3
mobile/openapi/lib/model/sync_request_type.dart
generated
@@ -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;
|
||||
|
||||
4
mobile/test/drift/main/generated/schema.dart
generated
4
mobile/test/drift/main/generated/schema.dart
generated
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
8632
mobile/test/drift/main/generated/schema_v18.dart
generated
Normal file
8632
mobile/test/drift/main/generated/schema_v18.dart
generated
Normal file
File diff suppressed because it is too large
Load Diff
321
mobile/test/utils/editor_test.dart
Normal file
321
mobile/test/utils/editor_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
47
server/src/schema/migrations/1769026065658-AssetEditSync.ts
Normal file
47
server/src/schema/migrations/1769026065658-AssetEditSync.ts
Normal 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);
|
||||
}
|
||||
14
server/src/schema/tables/asset-edit-audit.table.ts
Normal file
14
server/src/schema/tables/asset-edit-audit.table.ts
Normal 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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
14
server/src/utils/color_filter.ts
Normal file
14
server/src/utils/color_filter.ts
Normal 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 };
|
||||
}
|
||||
306
server/test/medium/specs/sync/sync-asset-edit.spec.ts
Normal file
306
server/test/medium/specs/sync/sync-asset-edit.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
42
web/src/lib/managers/edit/filter-manager.svelte.ts
Normal file
42
web/src/lib/managers/edit/filter-manager.svelte.ts
Normal 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();
|
||||
@@ -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({
|
||||
|
||||
218
web/src/lib/utils/filters.ts
Normal file
218
web/src/lib/utils/filters.ts
Normal 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'),
|
||||
];
|
||||
Reference in New Issue
Block a user