Crop an image in editor

This commit is contained in:
Ming Ming 2022-09-07 15:46:29 +08:00
parent 48d3b75607
commit 75d05e767e
10 changed files with 710 additions and 14 deletions

View file

@ -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": {

View file

@ -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",

View file

@ -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,7 +115,18 @@ class _ImageEditorState extends State<ImageEditor> {
_buildAppBar(context),
Expanded(
child: _isDoneInit
? Image(
? _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,
@ -136,6 +149,15 @@ class _ImageEditorState extends State<ImageEditor> {
_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<ImageEditor> {
isSelected: _activeTool == _ToolType.color,
onPressed: () {
setState(() {
_activeTool = _ToolType.color;
_setActiveTool(_ToolType.color);
});
},
),
@ -192,7 +214,7 @@ class _ImageEditorState extends State<ImageEditor> {
isSelected: _activeTool == _ToolType.transform,
onPressed: () {
setState(() {
_activeTool = _ToolType.transform;
_setActiveTool(_ToolType.transform);
});
},
),
@ -249,9 +271,15 @@ class _ImageEditorState extends State<ImageEditor> {
Navigator.of(context).pop();
}
void _setActiveTool(_ToolType tool) {
_activeTool = tool;
_isCropMode = false;
}
List<ImageFilter> _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<ImageEditor> {
}
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 = <ColorArguments>[];
var _transformFilters = <TransformArguments>[];
TransformArguments? _cropFilter;
}
enum _ToolType {

View file

@ -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<TransformArguments>? onCropChanged;
}
class _CropControllerState extends State<CropController> {
@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<Rect> {
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;
}

View file

@ -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<TransformArguments> initialState;
final ValueChanged<Iterable<TransformArguments>> onActiveFiltersChanged;
final ValueChanged<bool> isCropModeChanged;
final VoidCallback onCropToolDeactivated;
}
class _TransformToolbarState extends State<TransformToolbar> {
@ -33,7 +38,7 @@ class _TransformToolbarState extends State<TransformToolbar> {
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<TransformToolbar> {
child = _buildOrientationOption(context);
break;
case TransformToolType.crop:
case null:
child = null;
break;
@ -73,6 +79,14 @@ class _TransformToolbarState extends State<TransformToolbar> {
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<TransformToolbar> {
_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<TransformToolbar> {
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;
}

View file

@ -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

View file

@ -0,0 +1,69 @@
#include <cstdint>
#include <cstring>
#include <exception>
#include <jni.h>
#include <vector>
#include "../exception.h"
#include "../log.h"
#include "../util.h"
using namespace plugin;
using namespace std;
namespace {
class Crop {
public:
std::vector<uint8_t> 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<jbyte> cRgba8(
[&]() { return env->GetByteArrayElements(rgba8, nullptr); },
[&](jbyte *obj) {
env->ReleaseByteArrayElements(rgba8, obj, JNI_ABORT);
});
const auto result =
Crop().apply(reinterpret_cast<uint8_t *>(cRgba8.get()), width, height,
top, left, dstWidth, dstHeight);
auto resultAry = env->NewByteArray(result.size());
env->SetByteArrayRegion(resultAry, 0, result.size(),
reinterpret_cast<const int8_t *>(result.data()));
return resultAry;
} catch (const exception &e) {
throwJavaException(env, e.what());
return nullptr;
}
}
namespace {
vector<uint8_t> 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<uint8_t> 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

View file

@ -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"]}"
)

View file

@ -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
}

View file

@ -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);