Image Editor - Keep image within crop bounds.

* 4% of original pixels must be visible.
* The entire crop must be within the image.
* On release, try to scale crop area and image to fit if the crop is invalid.
* Undo to last valid position if that didn't work.
* Additionally, center thumbs now do not respect aspect ratio lock.
This commit is contained in:
Alan Evans 2019-05-16 17:50:49 -03:00 committed by Greyson Parrelli
parent 068ffc2167
commit bf759711ef
14 changed files with 304 additions and 35 deletions

View File

@ -1,6 +1,9 @@
package org.thoughtcrime.securesms.imageeditor;
import android.graphics.Matrix;
import android.graphics.RectF;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
/**
* The local extent of a {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement}.
@ -21,9 +24,51 @@ public final class Bounds {
public static final float[] CENTRE = new float[]{ CENTRE_X, CENTRE_Y };
private static final float[] POINTS = { Bounds.LEFT, Bounds.TOP,
Bounds.RIGHT, Bounds.TOP,
Bounds.RIGHT, Bounds.BOTTOM,
Bounds.LEFT, Bounds.BOTTOM };
static RectF newFullBounds() {
return new RectF(LEFT, TOP, RIGHT, BOTTOM);
}
public static RectF FULL_BOUNDS = newFullBounds();
public static boolean contains(float x, float y) {
return x >= FULL_BOUNDS.left && x <= FULL_BOUNDS.right &&
y >= FULL_BOUNDS.top && y <= FULL_BOUNDS.bottom;
}
/**
* Maps all the points of bounds with the supplied matrix and determines whether they are still in bounds.
*
* @param matrix matrix to transform points by, null is treated as identity.
* @return true iff all points remain in bounds after transformation.
*/
public static boolean boundsRemainInBounds(@Nullable Matrix matrix) {
if (matrix == null) return true;
float[] dst = new float[POINTS.length];
matrix.mapPoints(dst, POINTS);
return allWithinBounds(dst);
}
private static boolean allWithinBounds(@NonNull float[] points) {
boolean allHit = true;
for (int i = 0; i < points.length / 2; i++) {
float x = points[2 * i];
float y = points[2 * i + 1];
if (!Bounds.contains(x, y)) {
allHit = false;
break;
}
}
return allHit;
}
}

View File

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.imageeditor;
import android.graphics.Matrix;
import android.graphics.PointF;
import android.support.annotation.NonNull;
import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
@ -18,13 +19,13 @@ import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
*/
interface EditSession {
void movePoint(int p, PointF point);
void movePoint(int p, @NonNull PointF point);
EditorElement getSelected();
EditSession newPoint(Matrix newInverse, PointF point, int p);
EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p);
EditSession removePoint(Matrix newInverse, int p);
EditSession removePoint(@NonNull Matrix newInverse, int p);
void commit();
}

View File

@ -31,12 +31,12 @@ final class ElementDragEditSession extends ElementEditSession {
}
@Override
public EditSession newPoint(Matrix newInverse, PointF point, int p) {
public EditSession newPoint(@NonNull Matrix newInverse, PointF point, int p) {
return ElementScaleEditSession.startScale(this, newInverse, point, p);
}
@Override
public EditSession removePoint(Matrix newInverse, int p) {
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
return this;
}
}

View File

@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
abstract class ElementEditSession implements EditSession {
private final Matrix inverseMatrix;
private final Matrix inverseMatrix;
final EditorElement selected;
@ -60,7 +60,7 @@ abstract class ElementEditSession implements EditSession {
* @param matrix Matrix to transform point with.
* @param src Input point.
*/
static void mapPoint(@NonNull PointF dst, @NonNull Matrix matrix, @NonNull PointF src) {
private static void mapPoint(@NonNull PointF dst, @NonNull Matrix matrix, @NonNull PointF src) {
float[] in = { src.x, src.y };
float[] out = new float[2];
matrix.mapPoints(out, in);

View File

@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
final class ElementScaleEditSession extends ElementEditSession {
private ElementScaleEditSession(EditorElement selected, Matrix inverseMatrix) {
private ElementScaleEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
super(selected, inverseMatrix);
}
@ -56,20 +56,20 @@ final class ElementScaleEditSession extends ElementEditSession {
}
@Override
public EditSession newPoint(Matrix newInverse, PointF point, int p) {
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
return this;
}
@Override
public EditSession removePoint(Matrix newInverse, int p) {
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
return convertToDrag(p, newInverse);
}
private static double angle(PointF a, PointF b) {
private static double angle(@NonNull PointF a, @NonNull PointF b) {
return Math.atan2(a.y - b.y, a.x - b.x);
}
private ElementDragEditSession convertToDrag(int p, Matrix inverse) {
private ElementDragEditSession convertToDrag(int p, @NonNull Matrix inverse) {
return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p]);
}

View File

@ -70,6 +70,7 @@ public final class ImageEditorView extends FrameLayout {
@Nullable
private EditSession editSession;
private boolean moreThanOnePointerUsedInSession;
public ImageEditorView(Context context) {
super(context);
@ -215,6 +216,7 @@ public final class ImageEditorView extends FrameLayout {
PointF point = getPoint(event);
EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse);
moreThanOnePointerUsedInSession = false;
model.pushUndoPoint();
editSession = startEdit(inverse, point, selected);
@ -230,9 +232,19 @@ public final class ImageEditorView extends FrameLayout {
}
case MotionEvent.ACTION_MOVE: {
if (editSession != null) {
for (int p = 0; p < Math.min(2, event.getPointerCount()); p++) {
int historySize = event.getHistorySize();
int pointerCount = Math.min(2, event.getPointerCount());
for (int h = 0; h < historySize; h++) {
for (int p = 0; p < pointerCount; p++) {
editSession.movePoint(p, getHistoricalPoint(event, p, h));
}
}
for (int p = 0; p < pointerCount; p++) {
editSession.movePoint(p, getPoint(event, p));
}
model.moving(editSession.getSelected());
invalidate();
return true;
}
@ -240,11 +252,16 @@ public final class ImageEditorView extends FrameLayout {
}
case MotionEvent.ACTION_POINTER_DOWN: {
if (editSession != null && event.getPointerCount() == 2) {
moreThanOnePointerUsedInSession = true;
editSession.commit();
model.pushUndoPoint();
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
editSession = editSession.newPoint(newInverse, getPoint(event, event.getActionIndex()), event.getActionIndex());
if (newInverse != null) {
editSession = editSession.newPoint(newInverse, getPoint(event, event.getActionIndex()), event.getActionIndex());
} else {
editSession = null;
}
if (editSession == null) {
dragDropRelease();
}
@ -259,7 +276,11 @@ public final class ImageEditorView extends FrameLayout {
dragDropRelease();
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
editSession = editSession.removePoint(newInverse, event.getActionIndex());
if (newInverse != null) {
editSession = editSession.removePoint(newInverse, event.getActionIndex());
} else {
editSession = null;
}
return true;
}
break;
@ -270,8 +291,11 @@ public final class ImageEditorView extends FrameLayout {
dragDropRelease();
editSession = null;
model.postEdit(moreThanOnePointerUsedInSession);
invalidate();
return true;
} else {
model.postEdit(moreThanOnePointerUsedInSession);
}
break;
}
@ -349,6 +373,11 @@ public final class ImageEditorView extends FrameLayout {
return new PointF(event.getX(p), event.getY(p));
}
private static PointF getHistoricalPoint(MotionEvent event, int p, int historicalIndex) {
return new PointF(event.getHistoricalX(p, historicalIndex),
event.getHistoricalY(p, historicalIndex));
}
public EditorModel getModel() {
return model;
}

View File

@ -96,6 +96,10 @@ public final class RendererContext {
canvasMatrix.restore();
}
public void getCurrent(@NonNull Matrix into) {
canvasMatrix.getCurrent(into);
}
public interface Ready {
Ready NULL = (renderer, cropMatrix, size) -> {

View File

@ -39,7 +39,7 @@ class ThumbDragEditSession extends ElementEditSession {
editorMatrix.postTranslate(-x, -y);
boolean aspectLocked = selected.getFlags().isAspectLocked();
boolean aspectLocked = selected.getFlags().isAspectLocked() && !controlPoint.isCenter();
float defaultScale = aspectLocked ? 2 : 1;
@ -57,12 +57,12 @@ class ThumbDragEditSession extends ElementEditSession {
}
@Override
public EditSession newPoint(Matrix newInverse, PointF point, int p) {
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
return null;
}
@Override
public EditSession removePoint(Matrix newInverse, int p) {
public EditSession removePoint(@NonNull Matrix newInverse, int p) {
return null;
}
}

View File

@ -66,7 +66,7 @@ final class EditorElementHierarchy {
private EditorElementHierarchy(@NonNull EditorElement root) {
this.root = root;
this.view = this.root.getChild(0);
this.view = this.root.getChild(0);
this.flipRotate = this.view.getChild(0);
this.imageRoot = this.flipRotate.getChild(0);
this.overlay = this.flipRotate.getChild(1);
@ -237,6 +237,30 @@ final class EditorElementHierarchy {
return matrix;
}
/**
* Returns a matrix that maps points from the crop on to the visible image.
* <p>
* i.e. if a mapped point is in bounds, then the point is on the visible image.
*/
@Nullable Matrix imageMatrixRelativeToCrop() {
EditorElement mainImage = getMainImage();
if (mainImage == null) return null;
Matrix matrix1 = new Matrix(imageCrop.getLocalMatrix());
matrix1.preConcat(cropEditorElement.getLocalMatrix());
matrix1.preConcat(cropEditorElement.getEditorMatrix());
Matrix matrix2 = new Matrix(mainImage.getLocalMatrix());
matrix2.preConcat(mainImage.getEditorMatrix());
matrix2.preConcat(imageCrop.getLocalMatrix());
Matrix inverse = new Matrix();
matrix2.invert(inverse);
inverse.preConcat(matrix1);
return inverse;
}
void dragDropRelease(@NonNull RectF visibleViewPort, @NonNull Runnable invalidate) {
if (cropEditorElement.getFlags().isVisible()) {
updateViewToCrop(visibleViewPort, invalidate);
@ -299,9 +323,10 @@ final class EditorElementHierarchy {
matrix.preConcat(flipRotate.getLocalMatrix());
matrix.preConcat(cropEditorElement.getLocalMatrix());
matrix.preConcat(cropEditorElement.getEditorMatrix());
EditorElement mainImage = getMainImage();
if (mainImage != null) {
float xScale = 1f / xScale(mainImage.getLocalMatrix());
float xScale = 1f / (xScale(mainImage.getLocalMatrix()) * xScale(mainImage.getEditorMatrix()));
matrix.preScale(xScale, xScale);
}

View File

@ -37,11 +37,14 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
private static final int MINIMUM_OUTPUT_WIDTH = 0;
private static final float MAXIMUM_CROP = 0.20f;
@NonNull
private Runnable invalidate = NULL_RUNNABLE;
private final UndoRedoStacks undoRedoStacks;
private final UndoRedoStacks cropUndoRedoStacks;
private final InBoundsMemory inBoundsMemory = new InBoundsMemory();
private EditorElementHierarchy editorElementHierarchy;
@ -88,7 +91,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
return null;
}
private @Nullable Matrix findElementMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) {
public @Nullable Matrix findElementMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) {
Matrix inverse = findElementInverseMatrix(element, viewMatrix);
if (inverse != null) {
Matrix regular = new Matrix();
@ -107,7 +110,12 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
}
public void pushUndoPoint() {
UndoRedoStacks stacks = isCropping() ? cropUndoRedoStacks : undoRedoStacks;
boolean cropping = isCropping();
if (cropping && !currentCropIsAcceptable()) {
return;
}
UndoRedoStacks stacks = cropping ? cropUndoRedoStacks : undoRedoStacks;
if (stacks.getUndoStack().tryPush(editorElementHierarchy.getRoot())) {
stacks.getRedoStack().clear();
@ -140,12 +148,14 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
// re-zoom image root as the view port might be different now
editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
}
}
private static void restoreStateWithAnimations(@NonNull EditorElement fromRootElement, @NonNull EditorElement toRootElement, @NonNull Runnable onInvalidate, boolean keepEditorState) {
Map<UUID, EditorElement> fromMap = getElementMap(fromRootElement);
Map<UUID, EditorElement> toMap = getElementMap(toRootElement);
Map<UUID, EditorElement> toMap = getElementMap(toRootElement);
for (EditorElement fromElement : fromMap.values()) {
fromElement.stopAnimation();
@ -188,6 +198,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
cropUndoRedoStacks.getUndoStack().clear();
cropUndoRedoStacks.getUndoStack().clear();
editorElementHierarchy.startCrop(invalidate);
inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement());
}
public void doneCrop() {
@ -196,9 +207,11 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
public void setCropAspectLock(boolean locked) {
EditorFlags flags = editorElementHierarchy.getCropEditorElement().getFlags();
int currentState = flags.setAspectLocked(locked).getCurrentState();
int currentState = flags.setAspectLocked(locked).getCurrentState();
flags.reset();
flags.setAspectLocked(locked).persist();
flags.setAspectLocked(locked)
.persist();
flags.restoreState(currentState);
}
@ -206,10 +219,113 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
return editorElementHierarchy.getCropEditorElement().getFlags().isAspectLocked();
}
public void postEdit(boolean allowScaleToRepairCrop) {
if (isCropping()) {
ensureFitsBounds(allowScaleToRepairCrop);
}
}
private void ensureFitsBounds(boolean allowScaleToRepairCrop) {
EditorElement mainImage = editorElementHierarchy.getMainImage();
if (mainImage == null) return;
EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement();
if (!currentCropIsAcceptable()) {
if (allowScaleToRepairCrop) {
if (!tryToScaleToFit(cropEditorElement, 0.9f)) {
tryToScaleToFit(mainImage, 2f);
}
}
if (!currentCropIsAcceptable()) {
inBoundsMemory.restore(mainImage, cropEditorElement, invalidate);
} else {
inBoundsMemory.push(mainImage, cropEditorElement);
}
}
editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate);
}
/**
* Attempts to scale the supplied element such that {@link #cropIsWithinMainImageBounds} is true.
* <p>
* Does not respect minimum scale, so does need a further check to {@link #currentCropIsAcceptable} afterwards.
*
* @param element The element to be scaled. If successful, it will be animated to the correct position.
* @param scaleAtMost The amount of scale to apply at most. Use < 1 for the crop, and > 1 for the image.
* @return true if successfully scaled the element. false if the element was left unchanged.
*/
private boolean tryToScaleToFit(@NonNull EditorElement element, float scaleAtMost) {
Matrix elementMatrix = element.getLocalMatrix();
Matrix original = new Matrix(elementMatrix);
Matrix lastSuccessful = new Matrix();
boolean success = false;
float unsuccessfulScale = 1;
int attempt = 0;
do {
float tryScale = (scaleAtMost + unsuccessfulScale) / 2f;
elementMatrix.set(original);
elementMatrix.preScale(tryScale, tryScale);
if (cropIsWithinMainImageBounds(editorElementHierarchy)) {
scaleAtMost = tryScale;
success = true;
lastSuccessful.set(elementMatrix);
} else {
unsuccessfulScale = tryScale;
}
attempt++;
} while (attempt < 16 && Math.abs(scaleAtMost - unsuccessfulScale) > 0.001f);
elementMatrix.set(original);
if (success) {
element.animateLocalTo(lastSuccessful, invalidate);
}
return success;
}
public void dragDropRelease() {
editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate);
}
/**
* Pixel count must be no smaller than the MAXIMUM_CROP of its original size and all points must be within the bounds.
*/
private boolean currentCropIsAcceptable() {
Point outputSize = getOutputSize();
int outputPixelCount = outputSize.x * outputSize.y;
int minimumPixelCount = (int) (size.x * size.y * MAXIMUM_CROP * MAXIMUM_CROP);
return outputPixelCount >= minimumPixelCount &&
cropIsWithinMainImageBounds(editorElementHierarchy);
}
/**
* @return true if and only if the current crop rect is fully in the bounds.
*/
private static boolean cropIsWithinMainImageBounds(@NonNull EditorElementHierarchy hierarchy) {
return Bounds.boundsRemainInBounds(hierarchy.imageMatrixRelativeToCrop());
}
/**
* Called as edits are underway.
*/
public void moving(@NonNull EditorElement editorElement) {
if (!isCropping()) return;
EditorElement mainImage = editorElementHierarchy.getMainImage();
EditorElement cropEditorElement = editorElementHierarchy.getCropEditorElement();
if (editorElement == mainImage || editorElement == cropEditorElement) {
if (currentCropIsAcceptable()) {
inBoundsMemory.push(mainImage, cropEditorElement);
}
}
}
public void setVisibleViewPort(@NonNull RectF visibleViewPort) {
this.visibleViewPort.set(visibleViewPort);
this.editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
@ -364,23 +480,38 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
return findCropRelativeTo(editorElementHierarchy.getRoot());
}
private RectF findCropRelativeTo(EditorElement element) {
RectF findCropRelativeTo(EditorElement element) {
return findRelativeBounds(editorElementHierarchy.getCropEditorElement(), element);
}
private RectF findRelativeBounds(EditorElement from, EditorElement to) {
Matrix matrix1 = findElementMatrix(from, new Matrix());
Matrix matrix2 = findElementInverseMatrix(to, new Matrix());
RectF findRelativeBounds(EditorElement from, EditorElement to) {
Matrix relative = findRelativeMatrix(from, to);
RectF dst = new RectF(Bounds.FULL_BOUNDS);
if (matrix1 != null) {
matrix1.preConcat(matrix2);
matrix1.mapRect(dst, Bounds.FULL_BOUNDS);
if (relative != null) {
relative.mapRect(dst, Bounds.FULL_BOUNDS);
}
return dst;
}
/**
* Returns a matrix that maps points in the {@param from} element in to points in the {@param to} element.
*
* @param from
* @param to
* @return
*/
@Nullable Matrix findRelativeMatrix(@NonNull EditorElement from, @NonNull EditorElement to) {
Matrix matrix = findElementInverseMatrix(to, new Matrix());
Matrix outOf = findElementMatrix(from, new Matrix());
if (outOf != null && matrix != null) {
matrix.preConcat(outOf);
return matrix;
}
return null;
}
public void rotate90clockwise() {
pushUndoPoint();
editorElementHierarchy.flipRotate(90, 1, 1, visibleViewPort, invalidate);

View File

@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.imageeditor.model;
import android.graphics.Matrix;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
final class InBoundsMemory {
private final Matrix lastGoodUserCrop = new Matrix();
private final Matrix lastGoodMainImage = new Matrix();
void push(@Nullable EditorElement mainImage, @NonNull EditorElement userCrop) {
if (mainImage == null) {
lastGoodMainImage.reset();
} else {
lastGoodMainImage.set(mainImage.getLocalMatrix());
lastGoodMainImage.preConcat(mainImage.getEditorMatrix());
}
lastGoodUserCrop.set(userCrop.getLocalMatrix());
lastGoodUserCrop.preConcat(userCrop.getEditorMatrix());
}
void restore(@Nullable EditorElement mainImage, @NonNull EditorElement cropEditorElement, @Nullable Runnable invalidate) {
if (mainImage != null) {
mainImage.animateLocalTo(lastGoodMainImage, invalidate);
}
cropEditorElement.animateLocalTo(lastGoodUserCrop, invalidate);
}
}

View File

@ -65,6 +65,10 @@ public interface ThumbRenderer extends Renderer {
public boolean isVerticalCenter() {
return this == ControlPoint.TOP_CENTER || this == ControlPoint.BOTTOM_CENTER;
}
public boolean isCenter() {
return isHorizontalCenter() || isVerticalCenter();
}
}
ControlPoint getControlPoint();

View File

@ -106,7 +106,7 @@ public final class CropAreaRenderer implements Renderer {
@Override
public boolean hitTest(float x, float y) {
return !Bounds.FULL_BOUNDS.contains(x, y);
return !Bounds.contains(x, y);
}
public static final Creator<CropAreaRenderer> CREATOR = new Creator<CropAreaRenderer>() {

View File

@ -44,7 +44,7 @@ public final class InverseFillRenderer implements Renderer {
@Override
public boolean hitTest(float x, float y) {
return !Bounds.FULL_BOUNDS.contains(x, y);
return !Bounds.contains(x, y);
}
public static final Creator<InverseFillRenderer> CREATOR = new Creator<InverseFillRenderer>() {