feat: edit color filters

This commit is contained in:
bwees
2026-01-25 14:20:38 -06:00
parent 54e6dd1697
commit 5a2649a59c
28 changed files with 1659 additions and 217 deletions
+3
View File
@@ -1009,9 +1009,12 @@
"editor_discard_edits_title": "Discard edits?",
"editor_edits_applied_error": "Failed to apply edits",
"editor_edits_applied_success": "Edits applied successfully",
"editor_filters": "Filters",
"editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_orientation": "Orientation",
"editor_panel_filter": "Filter",
"editor_panel_transform": "Transform",
"editor_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise",
"editor_rotate_right": "Rotate 90° clockwise",
+262 -74
View File
@@ -1,52 +1,276 @@
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) {
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 +291,9 @@ const List<ColorFilter> filters = [
0,
1,
0,
]),
], "Radium"),
//Aqua
ColorFilter.matrix([
EditFilter.fromMatrix([
0.2126,
0.7152,
0.0722,
@@ -90,59 +314,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,
};
}
+8 -8
View File
@@ -23,7 +23,7 @@ class FilterImagePage extends HookWidget {
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final colorFilter = useState<EditFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
@@ -42,12 +42,12 @@ class FilterImagePage extends HookWidget {
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
void applyFilter(EditFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
Future<Image> applyFilterAndConvert(EditFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
@@ -58,7 +58,7 @@ class FilterImagePage extends HookWidget {
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final filteredUiImage = await createFilteredImage(uiImage, filter.colorFilter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
@@ -86,7 +86,7 @@ class FilterImagePage extends HookWidget {
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
child: ColorFiltered(colorFilter: colorFilter.value.colorFilter, child: image),
),
),
SizedBox(
@@ -99,7 +99,7 @@ class FilterImagePage extends HookWidget {
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
label: filters[index].name,
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
@@ -117,7 +117,7 @@ class FilterImagePage extends HookWidget {
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final EditFilter filter;
final bool isSelected;
final VoidCallback onTap;
@@ -145,7 +145,7 @@ class _FilterButton extends StatelessWidget {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
colorFilter: filter.colorFilter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),
@@ -7,6 +7,7 @@ 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/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,13 +49,14 @@ 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';
List<AspectRatio> aspectRatios = const [
(ratio: null, label: 'Free'),
@@ -82,6 +84,12 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
final transform = 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 = transform.rotation.toInt();
@@ -130,6 +138,10 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
);
}
if (_filter != null && !_filter!.isIdentity) {
edits.add(AssetEdit(action: AssetEditAction.filter, parameters: _filter!.dtoParameters));
}
await widget.applyEdits(edits);
setState(() {
@@ -185,6 +197,46 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
});
}
void _applyAspectRatio(double? ratio) {
setState(() {
if (ratio != null && _rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, swap width and height for aspect ratio calculations
ratio = 1 / ratio!;
}
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(
@@ -211,18 +263,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,
@@ -235,101 +285,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: aspectRatios.map((aspect) {
return _AspectRatioButton(
cropController: cropController,
currentAspectRatio: aspectRatio,
ratio: aspect.ratio,
label: aspect.label,
onPressed: () {
setState(() {
aspectRatio = aspect.ratio;
cropController.aspectRatio = aspect.ratio;
});
},
);
}).toList(),
),
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,
),
],
),
),
),
],
),
],
);
},
),
),
],
),
),
),
@@ -337,15 +392,13 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
}
}
class _AspectRatioButton extends StatelessWidget {
final CropController cropController;
class _AspectRatioButton extends StatelessWidget {
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,
@@ -380,3 +433,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),
],
);
}
}
+50 -1
View File
@@ -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;
@@ -68,3 +70,50 @@ NormalizedTransform normalizeTransformEdits(List<AssetEdit> edits) {
mirrorVertical: isCloseToZero(a) ? b == c : a == -d,
);
}
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
View File
@@ -359,6 +359,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)
@@ -429,6 +430,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
View File
@@ -98,6 +98,7 @@ part 'model/asset_delta_sync_dto.dart';
part 'model/asset_delta_sync_response_dto.dart';
part 'model/asset_edit_action.dart';
part 'model/asset_edit_action_crop.dart';
part 'model/asset_edit_action_filter.dart';
part 'model/asset_edit_action_list_dto.dart';
part 'model/asset_edit_action_list_dto_edits_inner.dart';
part 'model/asset_edit_action_mirror.dart';
@@ -168,6 +169,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
View File
@@ -242,6 +242,8 @@ class ApiClient {
return AssetEditActionTypeTransformer().decode(value);
case 'AssetEditActionCrop':
return AssetEditActionCrop.fromJson(value);
case 'AssetEditActionFilter':
return AssetEditActionFilter.fromJson(value);
case 'AssetEditActionListDto':
return AssetEditActionListDto.fromJson(value);
case 'AssetEditActionListDtoEditsInner':
@@ -382,6 +384,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
View File
@@ -26,12 +26,14 @@ class AssetEditAction {
static const crop = AssetEditAction._(r'crop');
static const rotate = AssetEditAction._(r'rotate');
static const mirror = AssetEditAction._(r'mirror');
static const filter = AssetEditAction._(r'filter');
/// List of all possible values in this [enum][AssetEditAction].
static const values = <AssetEditAction>[
crop,
rotate,
mirror,
filter,
];
static AssetEditAction? fromJson(dynamic value) => AssetEditActionTypeTransformer().decode(value);
@@ -73,6 +75,7 @@ class AssetEditActionTypeTransformer {
case r'crop': return AssetEditAction.crop;
case r'rotate': return AssetEditAction.rotate;
case r'mirror': return AssetEditAction.mirror;
case r'filter': return AssetEditAction.filter;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
+108
View File
@@ -0,0 +1,108 @@
//
// 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,
});
/// Type of edit action to perform
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
View File
@@ -0,0 +1,208 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class FilterParameters {
/// Returns a new [FilterParameters] instance.
FilterParameters({
required this.bOffset,
required this.bbBias,
required this.bgBias,
required this.brBias,
required this.gOffset,
required this.gbBias,
required this.ggBias,
required this.grBias,
required this.rOffset,
required this.rbBias,
required this.rgBias,
required this.rrBias,
});
/// B Offset (-255 -> 255)
///
/// Minimum value: -255
/// Maximum value: 255
num bOffset;
/// BB Bias
num bbBias;
/// BG Bias
num bgBias;
/// BR Bias
num brBias;
/// G Offset (-255 -> 255)
///
/// Minimum value: -255
/// Maximum value: 255
num gOffset;
/// GB Bias
num gbBias;
/// GG Bias
num ggBias;
/// GR Bias
num grBias;
/// R Offset (-255 -> 255)
///
/// Minimum value: -255
/// Maximum value: 255
num rOffset;
/// RB Bias
num rbBias;
/// RG Bias
num rgBias;
/// RR Bias
num rrBias;
@override
bool operator ==(Object other) => identical(this, other) || other is FilterParameters &&
other.bOffset == bOffset &&
other.bbBias == bbBias &&
other.bgBias == bgBias &&
other.brBias == brBias &&
other.gOffset == gOffset &&
other.gbBias == gbBias &&
other.ggBias == ggBias &&
other.grBias == grBias &&
other.rOffset == rOffset &&
other.rbBias == rbBias &&
other.rgBias == rgBias &&
other.rrBias == rrBias;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(bOffset.hashCode) +
(bbBias.hashCode) +
(bgBias.hashCode) +
(brBias.hashCode) +
(gOffset.hashCode) +
(gbBias.hashCode) +
(ggBias.hashCode) +
(grBias.hashCode) +
(rOffset.hashCode) +
(rbBias.hashCode) +
(rgBias.hashCode) +
(rrBias.hashCode);
@override
String toString() => 'FilterParameters[bOffset=$bOffset, bbBias=$bbBias, bgBias=$bgBias, brBias=$brBias, gOffset=$gOffset, gbBias=$gbBias, ggBias=$ggBias, grBias=$grBias, rOffset=$rOffset, rbBias=$rbBias, rgBias=$rgBias, rrBias=$rrBias]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'bOffset'] = this.bOffset;
json[r'bbBias'] = this.bbBias;
json[r'bgBias'] = this.bgBias;
json[r'brBias'] = this.brBias;
json[r'gOffset'] = this.gOffset;
json[r'gbBias'] = this.gbBias;
json[r'ggBias'] = this.ggBias;
json[r'grBias'] = this.grBias;
json[r'rOffset'] = this.rOffset;
json[r'rbBias'] = this.rbBias;
json[r'rgBias'] = this.rgBias;
json[r'rrBias'] = this.rrBias;
return json;
}
/// Returns a new [FilterParameters] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static FilterParameters? fromJson(dynamic value) {
upgradeDto(value, "FilterParameters");
if (value is Map) {
final json = value.cast<String, dynamic>();
return FilterParameters(
bOffset: num.parse('${json[r'bOffset']}'),
bbBias: num.parse('${json[r'bbBias']}'),
bgBias: num.parse('${json[r'bgBias']}'),
brBias: num.parse('${json[r'brBias']}'),
gOffset: num.parse('${json[r'gOffset']}'),
gbBias: num.parse('${json[r'gbBias']}'),
ggBias: num.parse('${json[r'ggBias']}'),
grBias: num.parse('${json[r'grBias']}'),
rOffset: num.parse('${json[r'rOffset']}'),
rbBias: num.parse('${json[r'rbBias']}'),
rgBias: num.parse('${json[r'rgBias']}'),
rrBias: num.parse('${json[r'rrBias']}'),
);
}
return null;
}
static List<FilterParameters> listFromJson(dynamic json, {bool growable = false,}) {
final result = <FilterParameters>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = FilterParameters.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, FilterParameters> mapFromJson(dynamic json) {
final map = <String, FilterParameters>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = FilterParameters.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of FilterParameters-objects as value to a dart map
static Map<String, List<FilterParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<FilterParameters>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = FilterParameters.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'bOffset',
'bbBias',
'bgBias',
'brBias',
'gOffset',
'gbBias',
'ggBias',
'grBias',
'rOffset',
'rbBias',
'rgBias',
'rrBias',
};
}
+101 -1
View File
@@ -16078,7 +16078,8 @@
"enum": [
"crop",
"rotate",
"mirror"
"mirror",
"filter"
],
"type": "string"
},
@@ -16102,6 +16103,26 @@
],
"type": "object"
},
"AssetEditActionFilter": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
],
"description": "Type of edit action to perform"
},
"parameters": {
"$ref": "#/components/schemas/FilterParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"AssetEditActionListDto": {
"properties": {
"edits": {
@@ -16116,6 +16137,9 @@
},
{
"$ref": "#/components/schemas/AssetEditActionMirror"
},
{
"$ref": "#/components/schemas/AssetEditActionFilter"
}
]
},
@@ -16187,6 +16211,9 @@
},
{
"$ref": "#/components/schemas/AssetEditActionMirror"
},
{
"$ref": "#/components/schemas/AssetEditActionFilter"
}
]
},
@@ -18053,6 +18080,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'],
);
}
+35 -3
View File
@@ -982,15 +982,46 @@ 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 = {
/** Type of edit action to perform */
action: AssetEditAction;
parameters: FilterParameters;
};
export type AssetEditsDto = {
/** Asset ID to apply edits to */
assetId: string;
/** List of edit actions to apply (crop, rotate, or mirror) */
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[];
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter)[];
};
export type AssetEditActionListDto = {
/** List of edit actions to apply (crop, rotate, or mirror) */
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[];
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter)[];
};
export type AssetMetadataResponseDto = {
/** Metadata key */
@@ -7060,7 +7091,8 @@ export enum AssetJobName {
export enum AssetEditAction {
Crop = "crop",
Rotate = "rotate",
Mirror = "mirror"
Mirror = "mirror",
Filter = "filter"
}
export enum MirrorAxis {
Horizontal = "horizontal",
+78 -3
View File
@@ -1,12 +1,13 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer';
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
import { ArrayMinSize, IsEnum, IsInt, IsNumber, Max, Min, ValidateNested } from 'class-validator';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation';
export enum AssetEditAction {
Crop = 'crop',
Rotate = 'rotate',
Mirror = 'mirror',
Filter = 'filter',
}
export enum MirrorAxis {
@@ -48,6 +49,68 @@ export class MirrorParameters {
axis!: MirrorAxis;
}
// Sharp supports a 3x3 matrix for color manipulation and rgb offsets
// The matrix representation of a filter is as follows:
// | rrBias rgBias rbBias | | r_offset |
// Image x | grBias ggBias gbBias | + | g_offset |
// | brBias bgBias bbBias | | b_offset |
export class FilterParameters {
@IsNumber()
@ApiProperty({ description: 'RR Bias' })
rrBias!: number;
@IsNumber()
@ApiProperty({ description: 'RG Bias' })
rgBias!: number;
@IsNumber()
@ApiProperty({ description: 'RB Bias' })
rbBias!: number;
@IsNumber()
@ApiProperty({ description: 'GR Bias' })
grBias!: number;
@IsNumber()
@ApiProperty({ description: 'GG Bias' })
ggBias!: number;
@IsNumber()
@ApiProperty({ description: 'GB Bias' })
gbBias!: number;
@IsNumber()
@ApiProperty({ description: 'BR Bias' })
brBias!: number;
@IsNumber()
@ApiProperty({ description: 'BG Bias' })
bgBias!: number;
@IsNumber()
@ApiProperty({ description: 'BB Bias' })
bbBias!: number;
@IsInt()
@Min(-255)
@Max(255)
@ApiProperty({ description: 'R Offset (-255 -> 255)' })
rOffset!: number;
@IsInt()
@Min(-255)
@Max(255)
@ApiProperty({ description: 'G Offset (-255 -> 255)' })
gOffset!: number;
@IsInt()
@Min(-255)
@Max(255)
@ApiProperty({ description: 'B Offset (-255 -> 255)' })
bOffset!: number;
}
class AssetEditActionBase {
@IsEnum(AssetEditAction)
@ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction', description: 'Type of edit action to perform' })
@@ -77,6 +140,12 @@ export class AssetEditActionMirror extends AssetEditActionBase {
@ApiProperty({ description: undefined })
parameters!: MirrorParameters;
}
export class AssetEditActionFilter extends AssetEditActionBase {
@ValidateNested()
@Type(() => FilterParameters)
@ApiProperty({ type: FilterParameters })
parameters!: FilterParameters;
}
export type AssetEditActionItem =
| {
@@ -90,25 +159,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)
@@ -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 { convertColorFilterToMatrices } 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 } = convertColorFilterToMatrices(filter.parameters);
pipeline = pipeline.recomb(biasMatrix).linear([1, 1, 1], offsetMatrix);
}
return pipeline;
}
+14
View File
@@ -0,0 +1,14 @@
import { Matrix3x3 } from 'sharp';
import { FilterParameters } from 'src/dtos/editing.dto';
export function convertColorFilterToMatrices(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';
@@ -409,7 +409,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
>
{$t('editor_panel_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
>
{$t('editor_panel_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,20 +1,22 @@
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 { eventManager } from '$lib/managers/event-manager.svelte';
import { waitForWebsocketEvent } from '$lib/stores/websocket';
import { getFormatter } from '$lib/utils/i18n';
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;
canReset: boolean;
edits: EditAction[];
@@ -22,6 +24,7 @@ export interface EditToolManager {
export enum EditToolType {
Transform = 'transform',
Filter = 'filter',
}
export interface EditTool {
@@ -39,6 +42,12 @@ export class EditManager {
component: TransformTool,
manager: transformManager,
},
{
type: EditToolType.Filter,
icon: mdiPalette,
component: FilterTool,
manager: filterManager,
},
];
currentAsset = $state<AssetResponseDto | null>(null);
@@ -77,32 +86,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;
@@ -0,0 +1,43 @@
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]);
canReset: boolean = $derived(!this.selectedFilter.isIdentity);
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();
+221
View File
@@ -0,0 +1,221 @@
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({
name: 'Normal',
rrBias: 1,
rgBias: 0,
rbBias: 0,
grBias: 0,
ggBias: 1,
gbBias: 0,
brBias: 0,
bgBias: 0,
bbBias: 1,
rOffset: 0,
gOffset: 0,
bOffset: 0,
});
constructor(filter: {
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 = filter.name;
this.rrBias = filter.rrBias;
this.rgBias = filter.rgBias;
this.rbBias = filter.rbBias;
this.grBias = filter.grBias;
this.ggBias = filter.ggBias;
this.gbBias = filter.gbBias;
this.brBias = filter.brBias;
this.bgBias = filter.bgBias;
this.bbBias = filter.bbBias;
this.rOffset = filter.rOffset;
this.gOffset = filter.gOffset;
this.bOffset = filter.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,
});
}
static fromMatrix(matrix: number[], name: string): EditFilter {
return new EditFilter({
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[15],
gOffset: matrix[16],
bOffset: 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'),
];