From 75d05e767ed7d5398568e16772c6095b6cc0d186 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 7 Sep 2022 15:46:29 +0800 Subject: [PATCH] Crop an image in editor --- app/lib/l10n/app_en.arb | 4 + app/lib/l10n/untranslated-messages.txt | 11 + app/lib/widget/image_editor.dart | 52 +- .../widget/image_editor/crop_controller.dart | 494 ++++++++++++++++++ .../image_editor/transform_toolbar.dart | 46 +- plugin/android/src/main/cpp/CMakeLists.txt | 1 + plugin/android/src/main/cpp/filter/crop.cpp | 69 +++ .../plugin/ImageProcessorChannelHandler.kt | 4 + .../nc_photos/plugin/image_processor/Crop.kt | 25 + plugin/lib/src/image_processor.dart | 18 + 10 files changed, 710 insertions(+), 14 deletions(-) create mode 100644 app/lib/widget/image_editor/crop_controller.dart create mode 100644 plugin/android/src/main/cpp/filter/crop.cpp create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Crop.kt diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 4c365b90..31aaa44a 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1346,6 +1346,10 @@ "@imageEditTransformOrientationCounterclockwise": { "description": "Indicate a counterclockwise rotation. This text must be short as there's only minimal space" }, + "imageEditTransformCrop": "Crop", + "@imageEditTransformCrop": { + "description": "Crop the image" + }, "categoriesLabel": "Categories", "searchLandingPeopleListEmptyText": "Press help to learn how to setup", "@searchLandingPeopleListEmptyText": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index d88ac539..ea179029 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -134,6 +134,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "categoriesLabel", "searchLandingPeopleListEmptyText", "searchLandingCategoryVideosLabel", @@ -308,6 +309,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "categoriesLabel", "searchLandingPeopleListEmptyText", "searchLandingCategoryVideosLabel", @@ -363,6 +365,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "categoriesLabel", "searchLandingPeopleListEmptyText", "searchLandingCategoryVideosLabel", @@ -399,6 +402,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "showAllButtonLabel", "gpsPlaceText", "gpsPlaceAboutDialogTitle", @@ -416,6 +420,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "gpsPlaceText", "gpsPlaceAboutDialogTitle", "gpsPlaceAboutDialogContent", @@ -477,6 +482,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "categoriesLabel", "searchLandingPeopleListEmptyText", "searchLandingCategoryVideosLabel", @@ -573,6 +579,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "categoriesLabel", "searchLandingPeopleListEmptyText", "searchLandingCategoryVideosLabel", @@ -648,6 +655,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "categoriesLabel", "searchLandingPeopleListEmptyText", "searchLandingCategoryVideosLabel", @@ -723,6 +731,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "categoriesLabel", "searchLandingPeopleListEmptyText", "searchLandingCategoryVideosLabel", @@ -798,6 +807,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "categoriesLabel", "searchLandingPeopleListEmptyText", "searchLandingCategoryVideosLabel", @@ -873,6 +883,7 @@ "imageEditTransformOrientation", "imageEditTransformOrientationClockwise", "imageEditTransformOrientationCounterclockwise", + "imageEditTransformCrop", "categoriesLabel", "searchLandingPeopleListEmptyText", "searchLandingCategoryVideosLabel", diff --git a/app/lib/widget/image_editor.dart b/app/lib/widget/image_editor.dart index bc5540d6..9d8652c2 100644 --- a/app/lib/widget/image_editor.dart +++ b/app/lib/widget/image_editor.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:nc_photos/account.dart'; import 'package:nc_photos/api/api.dart'; @@ -15,6 +16,7 @@ import 'package:nc_photos/theme.dart'; import 'package:nc_photos/url_launcher_util.dart'; import 'package:nc_photos/widget/handler/permission_handler.dart'; import 'package:nc_photos/widget/image_editor/color_toolbar.dart'; +import 'package:nc_photos/widget/image_editor/crop_controller.dart'; import 'package:nc_photos/widget/image_editor/transform_toolbar.dart'; import 'package:nc_photos_plugin/nc_photos_plugin.dart'; @@ -113,12 +115,23 @@ class _ImageEditorState extends State { _buildAppBar(context), Expanded( child: _isDoneInit - ? Image( - image: (_dst ?? _src).run((obj) => - PixelImage(obj.pixel, obj.width, obj.height)), - fit: BoxFit.contain, - gaplessPlayback: true, - ) + ? _isCropMode + ? CropController( + // crop always work on the src, otherwise we'll be + // cropping repeatedly + image: _src, + initialState: _cropFilter, + onCropChanged: (cropFilter) { + _cropFilter = cropFilter; + _applyFilters(); + }, + ) + : Image( + image: (_dst ?? _src).run((obj) => + PixelImage(obj.pixel, obj.width, obj.height)), + fit: BoxFit.contain, + gaplessPlayback: true, + ) : Container(), ), if (_activeTool == _ToolType.color) @@ -136,6 +149,15 @@ class _ImageEditorState extends State { _transformFilters = transformFilters.toList(); _applyFilters(); }, + isCropModeChanged: (value) { + setState(() { + _isCropMode = value; + }); + }, + onCropToolDeactivated: () { + _cropFilter = null; + _applyFilters(); + }, ), const SizedBox(height: 4), _buildToolBar(context), @@ -182,7 +204,7 @@ class _ImageEditorState extends State { isSelected: _activeTool == _ToolType.color, onPressed: () { setState(() { - _activeTool = _ToolType.color; + _setActiveTool(_ToolType.color); }); }, ), @@ -192,7 +214,7 @@ class _ImageEditorState extends State { isSelected: _activeTool == _ToolType.transform, onPressed: () { setState(() { - _activeTool = _ToolType.transform; + _setActiveTool(_ToolType.transform); }); }, ), @@ -249,9 +271,15 @@ class _ImageEditorState extends State { Navigator.of(context).pop(); } + void _setActiveTool(_ToolType tool) { + _activeTool = tool; + _isCropMode = false; + } + List _buildFilterList() { return [ - ..._transformFilters.map((f) => f.toImageFilter()), + if (_cropFilter != null) _cropFilter!.toImageFilter()!, + ..._transformFilters.map((f) => f.toImageFilter()).whereNotNull(), ..._colorFilters.map((f) => f.toImageFilter()), ]; } @@ -264,15 +292,19 @@ class _ImageEditorState extends State { } bool get _isModified => - _transformFilters.isNotEmpty || _colorFilters.isNotEmpty; + _cropFilter != null || + _transformFilters.isNotEmpty || + _colorFilters.isNotEmpty; bool _isDoneInit = false; late final Rgba8Image _src; Rgba8Image? _dst; var _activeTool = _ToolType.color; + var _isCropMode = false; var _colorFilters = []; var _transformFilters = []; + TransformArguments? _cropFilter; } enum _ToolType { diff --git a/app/lib/widget/image_editor/crop_controller.dart b/app/lib/widget/image_editor/crop_controller.dart new file mode 100644 index 00000000..05ab5349 --- /dev/null +++ b/app/lib/widget/image_editor/crop_controller.dart @@ -0,0 +1,494 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/pixel_image_provider.dart'; +import 'package:nc_photos/widget/image_editor/transform_toolbar.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; + +/// Crop editor +/// +/// This widget only work when width == device width! +class CropController extends StatefulWidget { + const CropController({ + Key? key, + required this.image, + required this.initialState, + this.onCropChanged, + }) : super(key: key); + + @override + createState() => _CropControllerState(); + + final Rgba8Image image; + final TransformArguments? initialState; + final ValueChanged? onCropChanged; +} + +class _CropControllerState extends State { + @override + initState() { + super.initState(); + if (widget.initialState?.getToolType() == TransformToolType.crop) { + _initialState = widget.initialState as _CropArguments; + } + } + + @override + build(BuildContext context) { + return OrientationBuilder( + builder: (context, orientation) { + _prevOrientation ??= orientation; + if (_prevOrientation != orientation) { + _onOrientationChanged(orientation); + } else { + _tryUpdateSize(context); + } + + return Stack( + fit: StackFit.passthrough, + clipBehavior: Clip.none, + children: [ + Opacity( + opacity: .35, + child: Image( + image: PixelImage(widget.image.pixel, widget.image.width, + widget.image.height), + fit: BoxFit.contain, + gaplessPlayback: true, + ), + ), + GestureDetector( + onPanStart: (_) { + _canMoveRect = true; + }, + onPanUpdate: (details) { + if (!_canMoveRect) { + return; + } + setState(() { + if (_size == null) return; + final pos = details.localPosition; + if (pos.dx > 0 && + pos.dx < _size!.width && + pos.dy > _offsetY && + pos.dy < _size!.height + _offsetY) { + _moveRectBy(details.delta); + } else { + _canMoveRect = false; + } + }); + }, + onPanEnd: (_) { + widget.onCropChanged?.call(_getCropArgs()); + }, + child: ClipRect( + clipper: _CropClipper( + _left, + _top + _offsetY, + _size == null ? double.infinity : _size!.width - _right, + _size == null + ? double.infinity + : _size!.height - _bottom + _offsetY, + ), + child: Image( + image: PixelImage(widget.image.pixel, widget.image.width, + widget.image.height), + fit: BoxFit.contain, + gaplessPlayback: true, + ), + ), + ), + if (_size != null) ...[ + Positioned( + top: _top + _offsetY, + left: _left, + bottom: _bottom + _offsetY, + right: _right, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.white), + ), + ), + ), + ), + Positioned( + top: _top + _offsetY, + left: _left, + child: GestureDetector( + onPanStart: (_) { + _topDrain.reset(); + _leftDrain.reset(); + }, + onPanUpdate: (details) { + setState(() { + if (_size == null) return; + _moveTopByDy(details.delta.dy); + _moveLeftByDx(details.delta.dx); + }); + }, + onPanEnd: (_) { + widget.onCropChanged?.call(_getCropArgs()); + }, + child: const _TouchDot(), + ), + ), + Positioned( + top: _top + _offsetY, + right: _right, + child: GestureDetector( + onPanStart: (_) { + _topDrain.reset(); + _rightDrain.reset(); + }, + onPanUpdate: (details) { + setState(() { + if (_size == null) return; + _moveTopByDy(details.delta.dy); + _moveRightByDx(details.delta.dx); + }); + }, + onPanEnd: (_) { + widget.onCropChanged?.call(_getCropArgs()); + }, + child: const _TouchDot(), + ), + ), + Positioned( + bottom: _bottom + _offsetY, + left: _left, + child: GestureDetector( + onPanStart: (_) { + _bottomDrain.reset(); + _leftDrain.reset(); + }, + onPanUpdate: (details) { + setState(() { + if (_size == null) return; + _moveBottomByDy(details.delta.dy); + _moveLeftByDx(details.delta.dx); + }); + }, + onPanEnd: (_) { + widget.onCropChanged?.call(_getCropArgs()); + }, + child: const _TouchDot(), + ), + ), + Positioned( + bottom: _bottom + _offsetY, + right: _right, + child: GestureDetector( + onPanStart: (_) { + _bottomDrain.reset(); + _rightDrain.reset(); + }, + onPanUpdate: (details) { + setState(() { + if (_size == null) return; + _moveBottomByDy(details.delta.dy); + _moveRightByDx(details.delta.dx); + }); + }, + onPanEnd: (_) { + widget.onCropChanged?.call(_getCropArgs()); + }, + child: const _TouchDot(), + ), + ), + ], + ], + ); + }, + ); + } + + void _onOrientationChanged(Orientation orientation) { + _reset(); + _prevOrientation = orientation; + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() {}); + }); + } + + void _tryUpdateSize(BuildContext context) { + if (_size == null) { + final renderObj = context.findRenderObject() as RenderBox?; + if (renderObj?.hasSize == true && renderObj!.size.width > 16) { + // the renderbox height is always max + if (renderObj.size.width == MediaQuery.of(context).size.width) { + final height = + renderObj.size.width / widget.image.width * widget.image.height; + _size = Size(renderObj.size.width, height); + _offsetY = (renderObj.size.height - height) / 2; + } else { + _size = renderObj.size; + } + } + _log.info("[_tryUpdateSize] size = $_size, offsetY: $_offsetY"); + if (_size == null) { + _log.info("[_tryUpdateSize] Schedule next"); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() {}); + } + }); + } else { + // apply initial state after getting size + if (_initialState != null && !_isInitialRestored) { + _restoreCropArgs(_initialState!); + _isInitialRestored = true; + } + } + } + } + + void _moveTopByDy(double dy) { + if (_topDrain.isAvailable) { + dy = _topDrain.consume(dy); + } + // add distance outside of the view to drain + _topDrain.add(_addTop(dy)); + } + + double _addTop(double dy) { + final old = _top; + final upper = _size!.height - _bottom - _TouchDot.size * 2 - _threshold; + // ignore if image is too small to allow cropping in this axis + if (upper >= _top) { + _top = (_top + dy).clamp(0, upper); + } else if (dy < 0 && _size!.height - _bottom >= 0) { + // allow expanding only + _top = math.max(_top + dy, 0); + } + return (old + dy) - _top; + } + + void _moveLeftByDx(double dx) { + if (_leftDrain.isAvailable) { + dx = _leftDrain.consume(dx); + } + _leftDrain.add(_addLeft(dx)); + } + + double _addLeft(double dx) { + final old = _left; + final upper = _size!.width - _right - _TouchDot.size * 2 - _threshold; + if (upper >= _left) { + _left = (_left + dx).clamp(0, upper); + } else if (dx < 0 && _size!.width - _right >= 0) { + _left = math.max(_left + dx, 0); + } + return (old + dx) - _left; + } + + void _moveBottomByDy(double dy) { + if (_bottomDrain.isAvailable) { + dy = _bottomDrain.consume(dy); + } + _bottomDrain.add(_addBottom(dy)); + } + + double _addBottom(double dy) { + final old = _bottom; + final upper = _size!.height - _top - _TouchDot.size * 2 - _threshold; + if (upper >= _bottom) { + _bottom = (_bottom - dy).clamp(0, upper); + } else if (dy > 0 && _size!.height - _top >= 0) { + _bottom = math.max(_bottom - dy, 0); + } + return _bottom - (old - dy); + } + + void _moveRightByDx(double dx) { + if (_rightDrain.isAvailable) { + dx = _rightDrain.consume(dx); + } + _rightDrain.add(_addRight(dx)); + } + + double _addRight(double dx) { + final old = _right; + final upper = _size!.width - _left - _TouchDot.size * 2 - _threshold; + if (upper >= _right) { + _right = (_right - dx).clamp(0, upper); + } else if (dx > 0 && _size!.width - _left >= 0) { + _right = math.max(_right - dx, 0); + } + return _right - (old - dx); + } + + void _moveRectBy(Offset offset) { + if (offset.dy < 0) { + // up + final actual = math.min(_top, -offset.dy); + _top -= actual; + _bottom += actual; + } else { + // down + final actual = math.min(_bottom, offset.dy); + _top += actual; + _bottom -= actual; + } + if (offset.dx < 0) { + // left + final actual = math.min(_left, -offset.dx); + _left -= actual; + _right += actual; + } else { + // right + final actual = math.min(_right, offset.dx); + _left += actual; + _right -= actual; + } + } + + _CropArguments _getCropArgs() { + final topPercent = _top / _size!.height; + final leftPercent = _left / _size!.width; + final bottomPercent = (_size!.height - _bottom) / _size!.height; + final rightPercent = (_size!.width - _right) / _size!.width; + return _CropArguments(topPercent, leftPercent, bottomPercent, rightPercent); + } + + void _restoreCropArgs(_CropArguments args) { + _top = args.top * _size!.height; + _left = args.left * _size!.width; + _bottom = _size!.height - args.bottom * _size!.height; + _right = _size!.width - args.right * _size!.width; + } + + /// Reset state after orientation change + void _reset() { + _log.info("[reset] Reset state"); + if (_initialState != null) { + // this is needed to also reset the state of the observer + widget.onCropChanged?.call(_initialState!); + } + _isInitialRestored = false; + _size = null; + _offsetY = 0; + _top = 0; + _left = 0; + _bottom = 0; + _right = 0; + } + + _CropArguments? _initialState; + bool _isInitialRestored = false; + Size? _size; + double _offsetY = 0; + + var _top = 0.0; + final _topDrain = _Drain(); + var _left = 0.0; + final _leftDrain = _Drain(); + var _bottom = 0.0; + final _bottomDrain = _Drain(); + var _right = 0.0; + final _rightDrain = _Drain(); + // set this to false when pointer moved outside of the area, making user to + // start a new pan session to move the rect + var _canMoveRect = true; + + Orientation? _prevOrientation; + + static const _threshold = 24; + static final _log = + Logger("widget.image_editor.crop_controller._CropControllerState"); +} + +class _TouchDot extends StatelessWidget { + static const double size = 24; + + const _TouchDot({Key? key}) : super(key: key); + + @override + build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + border: Border.all( + color: Colors.white, + width: 2, + ), + color: Colors.white60, + ), + ); + } +} + +class _CropClipper extends CustomClipper { + const _CropClipper(this.left, this.top, this.right, this.bottom); + + @override + getClip(Size size) => Rect.fromLTRB(left, top, right, bottom); + + @override + shouldReclip(CustomClipper oldClipper) { + if (oldClipper is! _CropClipper) { + return true; + } + return left != oldClipper.left || + top != oldClipper.top || + right != oldClipper.right || + bottom != oldClipper.bottom; + } + + final double left; + final double top; + final double right; + final double bottom; +} + +/// Store exceeding values and consume them if needed +class _Drain { + void add(double v) { + _drain += v; + } + + void reset() { + _drain = 0; + } + + /// Consume by [v], and return whatever that remain in [v] + double consume(double v) { + if (_drain.sign == v.sign) { + // add more to drain + _drain += v; + v = 0; + } else { + // consume from drain + _drain += v; + if (_drain.sign == v.sign) { + // consumed all, dy = remaining + v = _drain; + _drain = 0; + } else { + v = 0; + } + } + return v; + } + + bool get isAvailable => _drain != 0; + + double _drain = 0; +} + +class _CropArguments implements TransformArguments { + const _CropArguments(this.top, this.left, this.bottom, this.right); + + @override + toImageFilter() => TransformCropFilter(top, left, bottom, right); + + @override + getToolType() => TransformToolType.crop; + + final double top; + final double left; + final double bottom; + final double right; +} diff --git a/app/lib/widget/image_editor/transform_toolbar.dart b/app/lib/widget/image_editor/transform_toolbar.dart index f2fc1dc2..2f2bf96e 100644 --- a/app/lib/widget/image_editor/transform_toolbar.dart +++ b/app/lib/widget/image_editor/transform_toolbar.dart @@ -5,13 +5,14 @@ import 'package:nc_photos/widget/image_editor/toolbar_button.dart'; import 'package:nc_photos_plugin/nc_photos_plugin.dart'; enum TransformToolType { + crop, orientation, } abstract class TransformArguments { - ImageFilter toImageFilter(); + ImageFilter? toImageFilter(); - TransformToolType _getToolType(); + TransformToolType getToolType(); } class TransformToolbar extends StatefulWidget { @@ -19,6 +20,8 @@ class TransformToolbar extends StatefulWidget { Key? key, required this.initialState, required this.onActiveFiltersChanged, + required this.isCropModeChanged, + required this.onCropToolDeactivated, }) : super(key: key); @override @@ -26,6 +29,8 @@ class TransformToolbar extends StatefulWidget { final List initialState; final ValueChanged> onActiveFiltersChanged; + final ValueChanged isCropModeChanged; + final VoidCallback onCropToolDeactivated; } class _TransformToolbarState extends State { @@ -33,7 +38,7 @@ class _TransformToolbarState extends State { initState() { super.initState(); for (final s in widget.initialState) { - _filters[s._getToolType()] = s; + _filters[s.getToolType()] = s; } } @@ -52,6 +57,7 @@ class _TransformToolbarState extends State { child = _buildOrientationOption(context); break; + case TransformToolType.crop: case null: child = null; break; @@ -73,6 +79,14 @@ class _TransformToolbarState extends State { child: Row( children: [ const SizedBox(width: 16), + ToolbarButton( + icon: Icons.crop_outlined, + label: L10n.global().imageEditTransformCrop, + onPressed: _onCropPressed, + isSelected: _selectedFilter == TransformToolType.crop, + activationOrder: + _filters.containsKey(TransformToolType.crop) ? -1 : null, + ), ToolbarButton( icon: Icons.rotate_90_degrees_ccw_outlined, label: L10n.global().imageEditTransformOrientation, @@ -165,15 +179,27 @@ class _TransformToolbarState extends State { _selectedFilter = null; _filters.remove(type); }); + if (type == TransformToolType.crop) { + widget.isCropModeChanged(false); + widget.onCropToolDeactivated(); + } } else { + if (_selectedFilter == TransformToolType.crop) { + widget.isCropModeChanged(false); + } setState(() { _selectedFilter = type; _filters[type] ??= defArgs; }); + if (type == TransformToolType.crop) { + widget.isCropModeChanged(true); + } } _notifyFiltersChanged(); } + void _onCropPressed() => + _onFilterPressed(TransformToolType.crop, const _DummyCropArguments()); void _onOrientationPressed() => _onFilterPressed( TransformToolType.orientation, const _OrientationArguments(0)); @@ -192,6 +218,18 @@ class _TransformToolbarState extends State { TransformToolType? _selectedFilter; } +// arguments for crop is handled by its controller, this is used to restore +// state in the toolbar only +class _DummyCropArguments implements TransformArguments { + const _DummyCropArguments(); + + @override + toImageFilter() => null; + + @override + getToolType() => TransformToolType.crop; +} + class _OrientationArguments implements TransformArguments { const _OrientationArguments(this.value); @@ -199,7 +237,7 @@ class _OrientationArguments implements TransformArguments { toImageFilter() => TransformOrientationFilter(value); @override - _getToolType() => TransformToolType.orientation; + getToolType() => TransformToolType.orientation; final int value; } diff --git a/plugin/android/src/main/cpp/CMakeLists.txt b/plugin/android/src/main/cpp/CMakeLists.txt index 66150d92..c1c6c6f6 100644 --- a/plugin/android/src/main/cpp/CMakeLists.txt +++ b/plugin/android/src/main/cpp/CMakeLists.txt @@ -36,6 +36,7 @@ add_library( # Sets the name of the library. filter/brightness.cpp filter/color_levels.cpp filter/contrast.cpp + filter/crop.cpp filter/curve.cpp filter/hslhsv.cpp filter/orientation.cpp diff --git a/plugin/android/src/main/cpp/filter/crop.cpp b/plugin/android/src/main/cpp/filter/crop.cpp new file mode 100644 index 00000000..1d8e3815 --- /dev/null +++ b/plugin/android/src/main/cpp/filter/crop.cpp @@ -0,0 +1,69 @@ +#include +#include +#include +#include +#include + +#include "../exception.h" +#include "../log.h" +#include "../util.h" + +using namespace plugin; +using namespace std; + +namespace { + +class Crop { +public: + std::vector apply(const uint8_t *rgba8, const size_t width, + const size_t height, const int top, const int left, + const int dstWidth, const int dstHeight); + +private: + static constexpr const char *TAG = "Crop"; +}; + +} // namespace + +extern "C" JNIEXPORT jbyteArray JNICALL +Java_com_nkming_nc_1photos_plugin_image_1processor_Crop_applyNative( + JNIEnv *env, jobject *thiz, jbyteArray rgba8, jint width, jint height, + jint top, jint left, jint dstWidth, jint dstHeight) { + try { + initOpenMp(); + RaiiContainer cRgba8( + [&]() { return env->GetByteArrayElements(rgba8, nullptr); }, + [&](jbyte *obj) { + env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT); + }); + const auto result = + Crop().apply(reinterpret_cast(cRgba8.get()), width, height, + top, left, dstWidth, dstHeight); + auto resultAry = env->NewByteArray(result.size()); + env->SetByteArrayRegion(resultAry, 0, result.size(), + reinterpret_cast(result.data())); + return resultAry; + } catch (const exception &e) { + throwJavaException(env, e.what()); + return nullptr; + } +} + +namespace { + +vector Crop::apply(const uint8_t *rgba8, const size_t width, + const size_t height, const int top, const int left, + const int dstWidth, const int dstHeight) { + LOGI(TAG, "[apply] top: %d, left: %d, width: %d, height: %d", top, left, + dstWidth, dstHeight); + vector output(dstWidth * dstHeight * 4); +#pragma omp parallel for + for (size_t y = 0; y < dstHeight; ++y) { + const auto srcY = y + top; + memcpy(output.data() + (y * dstWidth * 4), + rgba8 + (srcY * width + left) * 4, dstWidth * 4); + } + return output; +} + +} // namespace diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt index 7ca1aeb9..fa6b04ec 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ImageProcessorChannelHandler.kt @@ -238,6 +238,10 @@ interface ImageFilter { "warmth" -> Warmth((json["weight"] as Double).toFloat()) "tint" -> Tint((json["weight"] as Double).toFloat()) "orientation" -> Orientation(json["degree"] as Int) + "crop" -> Crop( + json["top"] as Double, json["left"] as Double, + json["bottom"] as Double, json["right"] as Double + ) else -> throw IllegalArgumentException( "Unknown type: ${json["type"]}" ) diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Crop.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Crop.kt new file mode 100644 index 00000000..be04fb61 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/image_processor/Crop.kt @@ -0,0 +1,25 @@ +package com.nkming.nc_photos.plugin.image_processor + +import com.nkming.nc_photos.plugin.ImageFilter +import java.lang.Integer.max + +class Crop( + val top: Double, val left: Double, val bottom: Double, val right: Double +) : ImageFilter { + override fun apply(rgba8: Rgba8Image): Rgba8Image { + // prevent w/h == 0 + val width = max((rgba8.width * (right - left)).toInt(), 1) + val height = max((rgba8.height * (bottom - top)).toInt(), 1) + val top = (rgba8.height * top).toInt() + val left = (rgba8.width * left).toInt() + val data = applyNative( + rgba8.pixel, rgba8.width, rgba8.height, top, left, width, height + ) + return Rgba8Image(data, width, height) + } + + private external fun applyNative( + rgba8: ByteArray, width: Int, height: Int, top: Int, left: Int, + dstWidth: Int, dstHeight: Int + ): ByteArray +} diff --git a/plugin/lib/src/image_processor.dart b/plugin/lib/src/image_processor.dart index 0bb00332..289a220e 100644 --- a/plugin/lib/src/image_processor.dart +++ b/plugin/lib/src/image_processor.dart @@ -44,6 +44,24 @@ class ColorTintFilter extends _SingleWeightFilter { const ColorTintFilter(double weight) : super("tint", weight); } +class TransformCropFilter implements ImageFilter { + const TransformCropFilter(this.top, this.left, this.bottom, this.right); + + @override + toJson() => { + "type": "crop", + "top": top, + "left": left, + "bottom": bottom, + "right": right, + }; + + final double top; + final double left; + final double bottom; + final double right; +} + class TransformOrientationFilter implements ImageFilter { const TransformOrientationFilter(this.degree);