mirror of
https://github.com/immich-app/immich.git
synced 2026-01-26 11:24:44 -08:00
Compare commits
6 Commits
feat/mobil
...
feat/edit-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa2aa4c58 | ||
|
|
8835e54bf4 | ||
|
|
ae9bb0aa80 | ||
|
|
2e4cfa80a9 | ||
|
|
8653e20cc5 | ||
|
|
871de53bca |
@@ -994,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,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"),
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "package:openapi/api.dart" as api show AssetEditAction;
|
||||
|
||||
enum AssetEditAction { rotate, crop, mirror, other }
|
||||
enum AssetEditAction { rotate, crop, mirror, filter, other }
|
||||
|
||||
extension AssetEditActionExtension on AssetEditAction {
|
||||
api.AssetEditAction? toDto() {
|
||||
@@ -8,6 +8,7 @@ extension AssetEditActionExtension on AssetEditAction {
|
||||
AssetEditAction.rotate => api.AssetEditAction.rotate,
|
||||
AssetEditAction.crop => api.AssetEditAction.crop,
|
||||
AssetEditAction.mirror => api.AssetEditAction.mirror,
|
||||
AssetEditAction.filter => api.AssetEditAction.filter,
|
||||
AssetEditAction.other => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -810,6 +810,7 @@ extension on api.AssetEditAction {
|
||||
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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -48,14 +49,16 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
int _rotationAngle = 0;
|
||||
bool _flipHorizontal = false;
|
||||
bool _flipVertical = false;
|
||||
|
||||
double? aspectRatio;
|
||||
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);
|
||||
|
||||
@@ -71,6 +74,12 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
|
||||
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();
|
||||
@@ -119,6 +128,10 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -198,6 +211,41 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -221,18 +269,16 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
// Calculate the bounding box size needed for the rotated container
|
||||
final baseWidth = constraints.maxWidth * 0.9;
|
||||
final baseHeight = constraints.maxHeight * 0.8;
|
||||
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 Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: constraints.maxHeight * 0.7,
|
||||
child: Center(
|
||||
return Center(
|
||||
child: AnimatedRotation(
|
||||
turns: _rotationAngle / 360,
|
||||
duration: _rotationAnimationDuration,
|
||||
@@ -245,185 +291,106 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
padding: const EdgeInsets.all(10),
|
||||
width: (_rotationAngle % 180 == 0) ? baseWidth : baseHeight,
|
||||
height: (_rotationAngle % 180 == 0) ? baseHeight : baseWidth,
|
||||
child: CropImage(controller: cropController, image: widget.image, gridColor: Colors.white),
|
||||
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)),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: context.scaffoldBackgroundColor,
|
||||
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,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 36, left: 24, right: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ImmichIconButton(
|
||||
icon: Icons.rotate_left,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: _rotateLeft,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ImmichIconButton(
|
||||
icon: Icons.rotate_right,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: ImmichColor.secondary,
|
||||
onPressed: _rotateRight,
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
ImmichIconButton(
|
||||
icon: Icons.flip,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: _flipHorizontal ? ImmichColor.primary : ImmichColor.secondary,
|
||||
onPressed: _flipHorizontally,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Transform.rotate(
|
||||
angle: pi / 2,
|
||||
child: ImmichIconButton(
|
||||
icon: Icons.flip,
|
||||
variant: ImmichVariant.ghost,
|
||||
color: _flipVertical ? ImmichColor.primary : ImmichColor.secondary,
|
||||
onPressed: _flipVertically,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: <Widget>[
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: null,
|
||||
label: 'Free',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = null;
|
||||
cropController.aspectRatio = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 1.0,
|
||||
label: '1:1',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 1.0;
|
||||
cropController.aspectRatio = 1.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 16.0 / 9.0,
|
||||
label: '16:9',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 16.0 / 9.0;
|
||||
cropController.aspectRatio = 16.0 / 9.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 3.0 / 2.0,
|
||||
label: '3:2',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 3.0 / 2.0;
|
||||
cropController.aspectRatio = 3.0 / 2.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 7.0 / 5.0,
|
||||
label: '7:5',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 7.0 / 5.0;
|
||||
cropController.aspectRatio = 7.0 / 5.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 9.0 / 16.0,
|
||||
label: '9:16',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 9.0 / 16.0;
|
||||
cropController.aspectRatio = 9.0 / 16.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 2.0 / 3.0,
|
||||
label: '2:3',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 2.0 / 3.0;
|
||||
cropController.aspectRatio = 2.0 / 3.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
_AspectRatioButton(
|
||||
cropController: cropController,
|
||||
currentAspectRatio: aspectRatio,
|
||||
ratio: 5.0 / 7.0,
|
||||
label: '5:7',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
aspectRatio = 5.0 / 7.0;
|
||||
cropController.aspectRatio = 5.0 / 7.0;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -432,14 +399,12 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
}
|
||||
|
||||
class _AspectRatioButton extends StatelessWidget {
|
||||
final CropController cropController;
|
||||
final double? currentAspectRatio;
|
||||
final double? ratio;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _AspectRatioButton({
|
||||
required this.cropController,
|
||||
required this.currentAspectRatio,
|
||||
required this.ratio,
|
||||
required this.label,
|
||||
@@ -474,3 +439,186 @@ class _AspectRatioButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
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;
|
||||
@@ -73,3 +75,50 @@ AffineMatrix buildAffineFromEdits(List<AssetEdit> edits) {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
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)
|
||||
|
||||
2
mobile/openapi/lib/api.dart
generated
2
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';
|
||||
|
||||
4
mobile/openapi/lib/api_client.dart
generated
4
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':
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
AssetEditAction action;
|
||||
|
||||
- MirrorParameters parameters;
|
||||
- FilterParameters parameters;
|
||||
+ Map<String, dynamic> parameters;
|
||||
|
||||
@override
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
return AssetEditActionListDtoEditsInner(
|
||||
action: AssetEditAction.fromJson(json[r'action'])!,
|
||||
- parameters: MirrorParameters.fromJson(json[r'parameters'])!,
|
||||
- parameters: FilterParameters.fromJson(json[r'parameters'])!,
|
||||
+ parameters: json[r'parameters'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -5870,7 +5900,8 @@ export enum AssetJobName {
|
||||
export enum AssetEditAction {
|
||||
Crop = "crop",
|
||||
Rotate = "rotate",
|
||||
Mirror = "mirror"
|
||||
Mirror = "mirror",
|
||||
Filter = "filter"
|
||||
}
|
||||
export enum MirrorAxis {
|
||||
Horizontal = "horizontal",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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();
|
||||
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