diff --git a/i18n/en.json b/i18n/en.json index 0e0825fbc3..5408af00b0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/mobile/lib/constants/filters.dart b/mobile/lib/constants/filters.dart index e77bcfbf1f..a02ed626e6 100644 --- a/mobile/lib/constants/filters.dart +++ b/mobile/lib/constants/filters.dart @@ -1,52 +1,276 @@ import 'package:flutter/material.dart'; -const List 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 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 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 = [ + 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 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 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 filters = [ 0, 1, 0, - ]), + ], "Radium"), //Aqua - ColorFilter.matrix([ + EditFilter.fromMatrix([ 0.2126, 0.7152, 0.0722, @@ -90,59 +314,23 @@ const List 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 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"), ]; diff --git a/mobile/lib/domain/models/asset_edit.model.dart b/mobile/lib/domain/models/asset_edit.model.dart index b3266dba46..01bdd273a6 100644 --- a/mobile/lib/domain/models/asset_edit.model.dart +++ b/mobile/lib/domain/models/asset_edit.model.dart @@ -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, }; } diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 09a6a8da10..60295b8f48 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -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, }; } diff --git a/mobile/lib/pages/editing/filter.page.dart b/mobile/lib/pages/editing/filter.page.dart index f8b144bb96..611e6c23d0 100644 --- a/mobile/lib/pages/editing/filter.page.dart +++ b/mobile/lib/pages/editing/filter.page.dart @@ -23,7 +23,7 @@ class FilterImagePage extends HookWidget { @override Widget build(BuildContext context) { - final colorFilter = useState(filters[0]); + final colorFilter = useState(filters[0]); final selectedFilterIndex = useState(0); Future 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 applyFilterAndConvert(ColorFilter filter) async { + Future applyFilterAndConvert(EditFilter filter) async { final completer = Completer(); 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), ), ), diff --git a/mobile/lib/presentation/pages/drift_edit.page.dart b/mobile/lib/presentation/pages/drift_edit.page.dart index 4ee2b57657..808a8f9983 100644 --- a/mobile/lib/presentation/pages/drift_edit.page.dart +++ b/mobile/lib/presentation/pages/drift_edit.page.dart @@ -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 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 aspectRatios = const [ (ratio: null, label: 'Free'), @@ -82,6 +84,12 @@ class _DriftEditImagePageState extends ConsumerState 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 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 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 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 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( + value: 'transform', + label: Text('Transform'), + icon: Icon(Icons.transform), + ), + const ButtonSegment( + 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 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 = { + '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), + ], + ); + } +} diff --git a/mobile/lib/utils/editor.utils.dart b/mobile/lib/utils/editor.utils.dart index 35bb800165..5bdc690f98 100644 --- a/mobile/lib/utils/editor.utils.dart +++ b/mobile/lib/utils/editor.utils.dart @@ -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 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 resolveImage(ImageProvider provider) { + final completer = Completer(); + 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; +} diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index a2a00f46b4..967b3310f0 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -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) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index a81a451abe..13eb741004 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -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'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 0305f5d2cd..41feb922d2 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -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': diff --git a/mobile/openapi/lib/model/asset_edit_action.dart b/mobile/openapi/lib/model/asset_edit_action.dart index 3754cb4501..26c89d9f06 100644 --- a/mobile/openapi/lib/model/asset_edit_action.dart +++ b/mobile/openapi/lib/model/asset_edit_action.dart @@ -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 = [ 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'); diff --git a/mobile/openapi/lib/model/asset_edit_action_filter.dart b/mobile/openapi/lib/model/asset_edit_action_filter.dart new file mode 100644 index 0000000000..bf4232666a --- /dev/null +++ b/mobile/openapi/lib/model/asset_edit_action_filter.dart @@ -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 toJson() { + final json = {}; + 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(); + + return AssetEditActionFilter( + action: AssetEditAction.fromJson(json[r'action'])!, + parameters: FilterParameters.fromJson(json[r'parameters'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetEditActionFilter.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetEditActionFilter.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'action', + 'parameters', + }; +} + diff --git a/mobile/openapi/lib/model/filter_parameters.dart b/mobile/openapi/lib/model/filter_parameters.dart new file mode 100644 index 0000000000..5ea09067a7 --- /dev/null +++ b/mobile/openapi/lib/model/filter_parameters.dart @@ -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 toJson() { + final json = {}; + 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(); + + 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 listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FilterParameters.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'bOffset', + 'bbBias', + 'bgBias', + 'brBias', + 'gOffset', + 'gbBias', + 'ggBias', + 'grBias', + 'rOffset', + 'rbBias', + 'rgBias', + 'rrBias', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6d675df8a4..adf8b5c859 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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": { diff --git a/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch b/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch index 7c0010a354..47c03ca33b 100644 --- a/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch +++ b/open-api/patch/asset_edit_action_list_dto_edits_inner.dart.patch @@ -4,7 +4,7 @@ AssetEditAction action; -- MirrorParameters parameters; +- FilterParameters parameters; + Map 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'], ); } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index bfe2256255..ef8f36280f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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", diff --git a/server/src/dtos/editing.dto.ts b/server/src/dtos/editing.dto.ts index 8bb1eef47b..0f57d667a8 100644 --- a/server/src/dtos/editing.dto.ts +++ b/server/src/dtos/editing.dto.ts @@ -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.Crop]: AssetEditActionCrop, [AssetEditAction.Rotate]: AssetEditActionRotate, [AssetEditAction.Mirror]: AssetEditActionMirror, + [AssetEditAction.Filter]: AssetEditActionFilter, } as const; const getActionClass = (item: { action: AssetEditAction }): ClassConstructor => actionToClass[item.action]; -@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop) +@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop, AssetEditActionFilter) export class AssetEditActionListDto { /** list of edits */ @ArrayMinSize(1) diff --git a/server/src/repositories/media.repository.spec.ts b/server/src/repositories/media.repository.spec.ts index a5380852ee..ccb70d38c7 100644 --- a/server/src/repositories/media.repository.spec.ts +++ b/server/src/repositories/media.repository.spec.ts @@ -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 }); }); }); diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 33025e73cf..aab1c71a3e 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -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; } diff --git a/server/src/utils/color_filter.ts b/server/src/utils/color_filter.ts new file mode 100644 index 0000000000..ccbeca47e8 --- /dev/null +++ b/server/src/utils/color_filter.ts @@ -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 }; +} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 7decdac835..a3627e74b2 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -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'; diff --git a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte index d9a344cdc8..373b39c58e 100644 --- a/web/src/lib/components/asset-viewer/editor/editor-panel.svelte +++ b/web/src/lib/components/asset-viewer/editor/editor-panel.svelte @@ -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 @@ + + + + +
{#if editManager.selectedTool} diff --git a/web/src/lib/components/asset-viewer/editor/filter-tool/filter-preview.svelte b/web/src/lib/components/asset-viewer/editor/filter-tool/filter-preview.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/lib/components/asset-viewer/editor/filter-tool/filter-tool.svelte b/web/src/lib/components/asset-viewer/editor/filter-tool/filter-tool.svelte new file mode 100644 index 0000000000..667f8c6183 --- /dev/null +++ b/web/src/lib/components/asset-viewer/editor/filter-tool/filter-tool.svelte @@ -0,0 +1,51 @@ + + +
+
+

{$t('editor_filters')}

+
+ +
+ {#if asset} + {#each filters as filter (filter.name)} + {@const isSelected = filterManager.selectedFilter === filter} + + {/each} + {/if} +
+ + + + {#each filters as filter (filter.name)} + + + + {/each} + + +
diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte index 7a84612fe8..39e25a119c 100644 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/crop-area.svelte @@ -1,4 +1,5 @@