From c70443cc75242da967a437c24d980eefb4bedd64 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 3 Aug 2026 15:43:47 -0300 Subject: [PATCH] Add blur faces to media send v3. --- .../scribbles/ImageEditorFragment.java | 45 +++-------- .../signal/mediasend/MediaSendViewModel.kt | 20 +++++ .../signal/mediasend/edit/ImageController.kt | 78 +++++++++++++++++++ .../signal/mediasend/edit/MediaEditScreen.kt | 12 +++ .../mediasend/edit/MediaEditScreenDialogs.kt | 14 ++++ .../mediasend/edit/MediaEditScreenEvent.kt | 1 + .../mediasend/edit/image/BlurFacesBar.kt | 62 +++++++++++++++ .../edit/image/FaceDetectionResult.kt | 77 ++++++++++++++++++ .../mediasend/edit/image/ImageEditorState.kt | 4 +- .../src/main/res/values/strings.xml | 3 + .../core}/AndroidFaceDetector.java | 4 +- .../imageeditor/core}/FaceDetector.java | 4 +- .../imageeditor/core/model/EditorModel.java | 43 ++++++++++ 13 files changed, 327 insertions(+), 40 deletions(-) create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/edit/image/BlurFacesBar.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/edit/image/FaceDetectionResult.kt rename {app/src/main/java/org/thoughtcrime/securesms/scribbles => lib/image-editor/src/main/java/org/signal/imageeditor/core}/AndroidFaceDetector.java (96%) rename {app/src/main/java/org/thoughtcrime/securesms/scribbles => lib/image-editor/src/main/java/org/signal/imageeditor/core}/FaceDetector.java (81%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 53b6cabccb..0f410fc18f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -39,8 +39,9 @@ import org.signal.core.util.ThrottledDebouncer; import org.signal.core.util.concurrent.LifecycleDisposable; import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; -import org.signal.imageeditor.core.Bounds; +import org.signal.imageeditor.core.AndroidFaceDetector; import org.signal.imageeditor.core.ColorableRenderer; +import org.signal.imageeditor.core.FaceDetector; import org.signal.imageeditor.core.ImageEditorView; import org.signal.imageeditor.core.Renderer; import org.signal.imageeditor.core.SelectableRenderer; @@ -48,7 +49,6 @@ import org.signal.imageeditor.core.TappableRenderer; import org.signal.imageeditor.core.model.EditorElement; import org.signal.imageeditor.core.model.EditorModel; import org.signal.imageeditor.core.renderers.BezierDrawingRenderer; -import org.signal.imageeditor.core.renderers.FaceBlurRenderer; import org.signal.imageeditor.core.renderers.MultiLineTextRenderer; import org.signal.imageeditor.core.renderers.UriGlideRenderer; import org.signal.mediasend.MediaConstraints; @@ -76,6 +76,7 @@ import java.io.ByteArrayOutputStream; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -840,34 +841,12 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu } private void renderFaceBlurs(@NonNull FaceDetectionResult result) { - List faces = result.faces; - - if (faces.isEmpty()) { + if (result.faces.isEmpty()) { cachedFaceDetection = null; return; } - imageEditorView.getModel().pushUndoPoint(); - - Matrix faceMatrix = new Matrix(); - - for (FaceDetector.Face face : faces) { - Renderer faceBlurRenderer = new FaceBlurRenderer(); - EditorElement element = new EditorElement(faceBlurRenderer, EditorModel.Z_MASK); - Matrix localMatrix = element.getLocalMatrix(); - - faceMatrix.setRectToRect(Bounds.FULL_BOUNDS, face.getBounds(), Matrix.ScaleToFit.FILL); - - localMatrix.set(result.position); - localMatrix.preConcat(faceMatrix); - - element.getFlags().setEditable(false) - .setSelectable(false) - .persist(); - - imageEditorView.getModel().addElementWithoutPushUndo(element); - } - + imageEditorView.getModel().addFaceBlurs(result.faces, result.imageSize, result.position); imageEditorView.invalidate(); cachedFaceDetection = new Pair<>(getUri(), result); @@ -1101,16 +1080,14 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu } private static class FaceDetectionResult { - private final List faces; - private final Matrix position; + private final List faces; + private final Point imageSize; + private final Matrix position; private FaceDetectionResult(@NonNull List faces, @NonNull Point imageSize, @NonNull Matrix position) { - this.faces = faces; - this.position = new Matrix(position); - - Matrix imageProjectionMatrix = new Matrix(); - imageProjectionMatrix.setRectToRect(new RectF(0, 0, imageSize.x, imageSize.y), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.FILL); - this.position.preConcat(imageProjectionMatrix); + this.faces = faces.stream().map(FaceDetector.Face::getBounds).collect(Collectors.toList()); + this.imageSize = imageSize; + this.position = new Matrix(position); } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index 815c9a9319..d48c4397ba 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -325,6 +325,10 @@ class MediaSendViewModel( is MediaEditScreenEvent.BrushWidthChanged -> { setBrushWidth(mediaEditScreenEvent.tool, mediaEditScreenEvent.fraction) } + + is MediaEditScreenEvent.ToggleBlurFaces -> { + setBlurFacesEnabled(mediaEditScreenEvent.enabled) + } } } @@ -350,6 +354,22 @@ class MediaSendViewModel( return imageControllers.getOrCreate(uri, editorState.model) } + /** + * Masks or unmasks the faces in the focused image. Enabling this is what triggers the detection itself, which the + * editor's own state reports on, so that the work outlives the composition it was requested from. + */ + private fun setBlurFacesEnabled(enabled: Boolean) { + val controller = focusedImageController() ?: return + + if (enabled) { + viewModelScope.launch { + controller.blurFaces(MediaSendDependencies.application) + } + } else { + controller.clearFaceBlurs() + } + } + private fun setBrushWidth(tool: BrushTool, fraction: Float) { val brushWidths = state.value.brushWidths.with(tool, fraction) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt index 5dfb9199e6..c61b65bb3b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt @@ -5,6 +5,7 @@ package org.signal.mediasend.edit +import android.content.Context import android.graphics.Paint import android.net.Uri import androidx.compose.runtime.Stable @@ -13,6 +14,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.signal.imageeditor.core.Renderer import org.signal.imageeditor.core.SelectableRenderer import org.signal.imageeditor.core.TappableRenderer @@ -21,8 +24,10 @@ import org.signal.imageeditor.core.model.EditorModel import org.signal.imageeditor.core.renderers.MultiLineTextRenderer import org.signal.mediasend.edit.image.BrushTool import org.signal.mediasend.edit.image.BrushWidthsState +import org.signal.mediasend.edit.image.FaceDetectionResult import org.signal.mediasend.edit.image.HSVColorBarState import org.signal.mediasend.edit.image.ImageEditorState +import org.signal.mediasend.edit.image.detectFaces /** * Holds the editor state for a single image (modes, undo, selection, etc.). @@ -107,6 +112,22 @@ internal class ImageController( val isUserDrawing: Boolean by derivedStateOf { mode == Mode.DRAW || mode == Mode.HIGHLIGHT } val isUserBlurring: Boolean by derivedStateOf { mode == Mode.BLUR } + var isDetectingFaces: Boolean by mutableStateOf(false) + private set + + /** + * Whether the image's faces are masked, which is what the blur-faces toggle reflects. Taken from the model rather than + * the toggle so that undoing or clearing the masks turns it back off. + */ + val isBlurringFaces: Boolean by derivedStateOf { + // Reading the revision is what re-derives this when masks are added to or removed from the model. + imageEditorState.revision + isDetectingFaces || editorModel.hasFaceRenderer() + } + + private var cachedFaceDetection: FaceDetectionResult? = null + private var isFaceBlurRequested: Boolean = false + val brushTool: BrushTool? by derivedStateOf { when (mode) { Mode.DRAW -> BrushTool.MARKER @@ -284,6 +305,63 @@ internal class ImageController( } } + /** + * Masks every face in the image, finding them first unless a previous detection still describes the image as it is now + * cropped. Suspends for as long as the detection takes, which [isDetectingFaces] reports so the screen can say so. + */ + suspend fun blurFaces(context: Context) { + isFaceBlurRequested = true + + if (isDetectingFaces) { + return + } + + val cached = cachedFaceDetection?.takeIf { it.matches(editorModel) } + if (cached != null) { + applyFaceBlurs(cached) + return + } + + isDetectingFaces = true + val result = try { + withContext(Dispatchers.Default) { + detectFaces(context, editorModel, imageEditorState.typefaceProvider) + } + } finally { + isDetectingFaces = false + } + + // The toggle can go back off while detection runs, and finding faces after that must not mask them anyway. + if (isFaceBlurRequested) { + applyFaceBlurs(result) + } + } + + fun clearFaceBlurs() { + isFaceBlurRequested = false + + if (!editorModel.hasFaceRenderer()) { + return + } + + editorModel.clearFaceRenderers() + drawSessionDirty = true + imageEditorState.invalidate() + } + + private fun applyFaceBlurs(result: FaceDetectionResult) { + if (result.faces.isEmpty()) { + // Not worth keeping: a re-run once the image has loaded, or after a crop, may well find something. + cachedFaceDetection = null + return + } + + editorModel.addFaceBlurs(result.faces, result.renderSize, result.cropPosition) + cachedFaceDetection = result + drawSessionDirty = true + imageEditorState.invalidate() + } + fun setDrawColor(color: Int) { imageEditorState.drawColor = brushTool?.applyAlpha(color) ?: color } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index 639ecbb69f..f46da8c627 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -55,6 +55,7 @@ import org.signal.mediasend.MediaSendDependencies import org.signal.mediasend.MediaSendMetrics import org.signal.mediasend.MediaSendState import org.signal.mediasend.edit.document.DocumentPage +import org.signal.mediasend.edit.image.BlurFacesBar import org.signal.mediasend.edit.image.BrushWidthBar import org.signal.mediasend.edit.image.BrushWidthPreview import org.signal.mediasend.edit.image.DrawModeColorBar @@ -298,6 +299,15 @@ internal fun MediaEditScreen( DrawModeColorBar(imageEditorController = controller) } } + + if (controller.isUserBlurring) { + MediaEditControl(faded = isDragging) { + BlurFacesBar( + checked = controller.isBlurringFaces, + onCheckedChange = { onEvent(MediaEditScreenEvent.ToggleBlurFaces(it)) } + ) + } + } } if (isSmallWindowBreakpoint) { @@ -389,6 +399,8 @@ internal fun MediaEditScreen( if (state.isSavingMedia) { MediaEditScreenDialogs.SavingToStorageProgressDialog() } + + MediaEditScreenDialogs.DetectingFacesProgressDialog(visible = imageController?.isDetectingFaces == true) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenDialogs.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenDialogs.kt index e8ce926c93..38b490f71d 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenDialogs.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenDialogs.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.dp import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.mediasend.R +import kotlin.time.Duration.Companion.milliseconds object MediaEditScreenDialogs { @@ -121,6 +122,19 @@ object MediaEditScreenDialogs { fun SavingToStorageProgressDialog() { Dialogs.IndeterminateProgressDialog(message = stringResource(R.string.MediaSendDialogs__saving_media)) } + + /** + * Covers face detection, which blocks the editor for as long as it runs. Detection on a small image can finish in a + * frame or two, so the spinner waits before showing itself rather than flashing. + */ + @Composable + fun DetectingFacesProgressDialog(visible: Boolean) { + Dialogs.IndeterminateProgressDialog( + visible = visible, + delayDuration = 200.milliseconds, + minimumDisplayDuration = 400.milliseconds + ) + } } @Preview diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt index fe4b64d3d4..775465ddab 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt @@ -22,6 +22,7 @@ sealed interface MediaEditScreenEvent { data object ToggleMediaQuality : MediaEditScreenEvent data object ToggleViewOnce : MediaEditScreenEvent data class BrushWidthChanged(val tool: BrushTool, val fraction: Float) : MediaEditScreenEvent + data class ToggleBlurFaces(val enabled: Boolean) : MediaEditScreenEvent data object SaveMedia : MediaEditScreenEvent data class VideoTrimChanged(val videoTrimData: VideoTrimData, val editingComplete: Boolean) : MediaEditScreenEvent data class VideoSeek(val positionUs: Long, val editingComplete: Boolean) : MediaEditScreenEvent diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BlurFacesBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BlurFacesBar.kt new file mode 100644 index 0000000000..dcd0489dc2 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BlurFacesBar.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.image + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.mediasend.R + +/** + * Takes the place of the color bar while the user is blurring, offering to mask every face in the image for them. + */ +@Composable +internal fun BlurFacesBar( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .widthIn(max = 256.dp) + .fillMaxWidth() + .background(color = SignalTheme.colors.colorSurface5, shape = CircleShape) + .padding(start = 24.dp, end = 12.dp, top = 4.dp, bottom = 4.dp) + ) { + Text(text = stringResource(R.string.BlurFacesBar__blur_faces)) + Spacer(modifier = Modifier.weight(1f)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + } +} + +@DayNightPreviews +@Composable +private fun BlurFacesBarPreview() { + Previews.Preview { + BlurFacesBar( + checked = true, + onCheckedChange = {} + ) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/FaceDetectionResult.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/FaceDetectionResult.kt new file mode 100644 index 0000000000..136607ff3f --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/FaceDetectionResult.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit.image + +import android.content.Context +import android.graphics.Matrix +import android.graphics.Point +import android.graphics.RectF +import androidx.annotation.WorkerThread +import org.signal.imageeditor.core.AndroidFaceDetector +import org.signal.imageeditor.core.RendererContext +import org.signal.imageeditor.core.model.EditorModel +import org.signal.imageeditor.core.renderers.UriGlideRenderer + +/** Widest render we hand to the detector. Faces are found just as well in a smaller image, and far faster. */ +private const val MAX_DETECTION_WIDTH = 1000 + +/** + * The faces found in an image, along with the render they were found in. + */ +internal class FaceDetectionResult( + val faces: List, + val renderSize: Point, + val cropPosition: Matrix +) { + private val cropPositionValues = FloatArray(9).also { cropPosition.getValues(it) } + + /** + * Whether these results still describe [model]. A crop or rotation made since detection moves every face, so the + * image has to be found again rather than masked from stale bounds. + */ + fun matches(model: EditorModel): Boolean { + val currentValues = FloatArray(9).also { model.inverseCropPosition.getValues(it) } + return cropPositionValues.contentEquals(currentValues) + } +} + +/** + * Renders [model] and finds the faces in the result. + * + * The main image's children are left out of that render, so edits already made -- previous masks included -- neither + * hide a face from the detector nor get mistaken for one. + */ +@WorkerThread +internal fun detectFaces( + context: Context, + model: EditorModel, + typefaceProvider: RendererContext.TypefaceProvider +): FaceDetectionResult { + val cropPosition = model.inverseCropPosition + val mainImage = model.mainImage + + // Nothing to detect in an image that has not finished loading: the render would only capture its placeholder. + if (mainImage == null || (mainImage.renderer as? UriGlideRenderer)?.bitmap == null) { + return FaceDetectionResult(emptyList(), Point(0, 0), cropPosition) + } + + mainImage.flags.setChildrenVisible(false) + val render = try { + model.render(context, model.getOutputSizeMaxWidth(MAX_DETECTION_WIDTH), typefaceProvider) + } finally { + mainImage.flags.reset() + } + + return try { + FaceDetectionResult( + faces = AndroidFaceDetector().detect(render).map { it.bounds }, + renderSize = Point(render.width, render.height), + cropPosition = cropPosition + ) + } finally { + render.recycle() + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt index 8c55266108..885a57d8d8 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt @@ -74,7 +74,7 @@ internal class ImageEditorState( revision++ } - private val defaultTypefaceProvider = RendererContext.TypefaceProvider { _: Context, _: Renderer, _: RendererContext.Invalidate -> + val typefaceProvider = RendererContext.TypefaceProvider { _: Context, _: Renderer, _: RendererContext.Invalidate -> Typeface.DEFAULT } @@ -126,7 +126,7 @@ internal class ImageEditorState( fun getOrCreateRendererContext(context: Context, canvas: Canvas): RendererContext { val current = rendererContext if (current != null && current.canvas === canvas) return current - return RendererContext(context, canvas, rendererReady, rendererInvalidate, defaultTypefaceProvider).also { + return RendererContext(context, canvas, rendererReady, rendererInvalidate, typefaceProvider).also { rendererContext = it } } diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index 982ba9b317..a92f5623a1 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -67,6 +67,9 @@ Brush + + Blur faces + Toggle between text styles diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/AndroidFaceDetector.java b/lib/image-editor/src/main/java/org/signal/imageeditor/core/AndroidFaceDetector.java similarity index 96% rename from app/src/main/java/org/thoughtcrime/securesms/scribbles/AndroidFaceDetector.java rename to lib/image-editor/src/main/java/org/signal/imageeditor/core/AndroidFaceDetector.java index f8d1dc6561..1d64cc14fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/AndroidFaceDetector.java +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/AndroidFaceDetector.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.scribbles; +package org.signal.imageeditor.core; import android.graphics.Bitmap; import android.graphics.Canvas; @@ -17,7 +17,7 @@ import java.util.stream.Stream; /** * Detects faces with the built in Android face detection. */ -final class AndroidFaceDetector implements FaceDetector { +public final class AndroidFaceDetector implements FaceDetector { private static final String TAG = Log.tag(AndroidFaceDetector.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/FaceDetector.java b/lib/image-editor/src/main/java/org/signal/imageeditor/core/FaceDetector.java similarity index 81% rename from app/src/main/java/org/thoughtcrime/securesms/scribbles/FaceDetector.java rename to lib/image-editor/src/main/java/org/signal/imageeditor/core/FaceDetector.java index 4365dbd3e2..4e32d7e3ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/FaceDetector.java +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/FaceDetector.java @@ -1,4 +1,4 @@ -package org.thoughtcrime.securesms.scribbles; +package org.signal.imageeditor.core; import android.graphics.Bitmap; import android.graphics.RectF; @@ -7,7 +7,7 @@ import androidx.annotation.NonNull; import java.util.List; -interface FaceDetector { +public interface FaceDetector { List detect(@NonNull Bitmap bitmap); interface Face { diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/model/EditorModel.java b/lib/image-editor/src/main/java/org/signal/imageeditor/core/model/EditorModel.java index da9f96c591..e2cc778d0d 100644 --- a/lib/image-editor/src/main/java/org/signal/imageeditor/core/model/EditorModel.java +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/model/EditorModel.java @@ -25,6 +25,7 @@ import org.signal.imageeditor.core.renderers.MultiLineTextRenderer; import java.util.HashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -916,6 +917,48 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { updateUndoRedoAvailableState(undoRedoStacks); } + /** + * Masks each of {@code faces} with a blur, as a single undoable edit. + * + * @param faces Bounds of each face, in the coordinate space of a render of this model at {@code renderSize}. + * @param renderSize Size of the render the faces were detected in. + * @param position The inverse crop position at the time of that render, which is what keeps the masks on the faces + * when the image is cropped or rotated. + */ + public void addFaceBlurs(@NonNull List faces, @NonNull Point renderSize, @NonNull Matrix position) { + if (faces.isEmpty()) { + return; + } + + pushUndoPoint(); + + Matrix renderProjection = new Matrix(); + renderProjection.setRectToRect(new RectF(0, 0, renderSize.x, renderSize.y), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.FILL); + + Matrix facePosition = new Matrix(position); + facePosition.preConcat(renderProjection); + + Matrix faceMatrix = new Matrix(); + + for (RectF face : faces) { + EditorElement element = new EditorElement(new FaceBlurRenderer(), Z_MASK); + Matrix localMatrix = element.getLocalMatrix(); + + faceMatrix.setRectToRect(Bounds.FULL_BOUNDS, face, Matrix.ScaleToFit.FILL); + + localMatrix.set(facePosition); + localMatrix.preConcat(faceMatrix); + + element.getFlags().setEditable(false) + .setSelectable(false) + .persist(); + + addElementWithoutPushUndo(element); + } + + invalidate.run(); + } + public void clearFaceRenderers() { EditorElement mainImage = editorElementHierarchy.getMainImage(); if (mainImage != null) {