From ea952d1497378abe9617520051fce9e7fa78449c Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Tue, 16 Jul 2019 23:35:30 -0300 Subject: [PATCH] Feature/overlays (#502) * Overlays (#421) * get overlay working * fix overlay drawing * allow disabling overlay in pictures or videos * Fix picture snapshot colors when there is an overlay * Bug fixes * Update example with watermark * Fix bug * Fix overlay orientation in pictures * Fix overlay orientation in videos * Fix overlay when changing preview size * Fix bug * Experiment * Refactor EglViewport * Refactor SnapshotPictureRecorder * Use single EglViewport * Refactor SnapshotVideoRecorder * Bug fix * fix some of the requested changes * clean adding View to OverlayLayout * Specify where to draw the overlay * Refactor * Remove unnecessary variable from CameraPreview * Use mWithOverlay in SnapshotVideoRecorder * Use multiple OverlayLayout * Add explanation for OverlayLayoutManager * override removeView * Remove DisableOverlayFor * Reorder to overlay package * Address issues * Draw selectively on preview, picture or video * Use single Overlay with three targets * Fix picture snapshots * Add demo app control * Fix video snapshot rotation for Camera2 * Fix video snapshot overlay rotation for Camera2 only * Fix tests, improve performance * Add animating watermark * Add tests in CameraViewTest * Add integration tests * Fix race condition * Improve README * Remove isOverlay * Remove isOverlay from docs * Add documentation empty page * Add documentation links * Add real documentation * Remove isOverlay from attrs * Add doc links to main README * Fix tests and logs * Small changes in AudioMediaEncoder * Add changelog line --- README.md | 86 ++++++- .../otaliastudios/cameraview/BaseTest.java | 2 - .../cameraview/CameraViewTest.java | 55 +++++ .../engine/CameraIntegrationTest.java | 86 ++++++- .../cameraview/overlay/OverlayLayoutTest.java | 197 ++++++++++++++++ .../androidTest/res/layout/not_overlay.xml | 4 + .../src/androidTest/res/layout/overlay.xml | 8 + .../otaliastudios/cameraview/CameraView.java | 43 +++- .../cameraview/engine/Camera1Engine.java | 14 +- .../cameraview/engine/Camera2Engine.java | 20 +- .../cameraview/engine/CameraEngine.java | 31 ++- .../cameraview/engine/offset/Angles.java | 14 ++ .../cameraview/internal/egl/EglViewport.java | 6 + .../cameraview/internal/utils/Pool.java | 13 +- .../cameraview/overlay/Overlay.java | 33 +++ .../cameraview/overlay/OverlayLayout.java | 211 ++++++++++++++++++ .../picture/SnapshotGlPictureRecorder.java | 95 ++++++-- .../cameraview/preview/CameraPreview.java | 32 +++ .../cameraview/preview/GlCameraPreview.java | 1 - .../video/SnapshotVideoRecorder.java | 79 +++++-- .../video/encoding/AudioMediaEncoder.java | 139 ++++++++---- .../video/encoding/MediaEncoder.java | 34 +-- .../video/encoding/MediaEncoderEngine.java | 72 +++--- .../video/encoding/TextureMediaEncoder.java | 34 ++- .../video/encoding/VideoMediaEncoder.java | 2 +- cameraview/src/main/res/values/attrs.xml | 51 +++-- .../cameraview/demo/CameraActivity.java | 62 ++++- .../otaliastudios/cameraview/demo/Option.java | 187 +++++++++------- .../cameraview/demo/OptionView.java | 22 +- demo/src/main/res/layout/activity_camera.xml | 19 +- docs/_posts/2018-12-20-changelog.md | 4 + docs/_posts/2018-12-20-debugging.md | 2 +- docs/_posts/2018-12-20-error-handling.md | 2 +- docs/_posts/2018-12-20-more-features.md | 2 +- docs/_posts/2018-12-20-previews.md | 2 +- docs/_posts/2018-12-20-runtime-permissions.md | 2 +- .../2019-07-14-watermarks-and-overlays.md | 73 ++++++ docs/index.md | 19 +- 38 files changed, 1444 insertions(+), 314 deletions(-) create mode 100644 cameraview/src/androidTest/java/com/otaliastudios/cameraview/overlay/OverlayLayoutTest.java create mode 100644 cameraview/src/androidTest/res/layout/not_overlay.xml create mode 100644 cameraview/src/androidTest/res/layout/overlay.xml create mode 100644 cameraview/src/main/java/com/otaliastudios/cameraview/overlay/Overlay.java create mode 100644 cameraview/src/main/java/com/otaliastudios/cameraview/overlay/OverlayLayout.java create mode 100644 docs/_posts/2019-07-14-watermarks-and-overlays.md diff --git a/README.md b/README.md index 78f591cd..eea2942f 100644 --- a/README.md +++ b/README.md @@ -26,22 +26,22 @@ compile 'com.otaliastudios:cameraview:2.0.0-beta06' ``` - Fast & reliable -- Gestures support -- Camera1 or Camera2 powered engine -- Frame processing support -- OpenGL powered preview -- Take high-quality content with `takePicture` and `takeVideo` -- Take super-fast snapshots with `takePictureSnapshot` and `takeVideoSnapshot` -- Smart sizing: create a `CameraView` of any size -- Control HDR, flash, zoom, white balance, exposure, location, grid drawing & more +- Gestures support [[docs]](https://natario1.github.io/CameraView/docs/gestures.html) +- Camera1 or Camera2 powered engine [[docs]](https://natario1.github.io/CameraView/docs/previews.html) +- Frame processing support [[docs]](https://natario1.github.io/CameraView/docs/frame-processing.html) +- Watermarks & animated overlays [[docs]](https://natario1.github.io/CameraView/docs/watermarks-and-overlays.html) +- OpenGL powered preview [[docs]](https://natario1.github.io/CameraView/docs/previews.html) +- Take high-quality content with `takePicture` and `takeVideo` [[docs]](https://natario1.github.io/CameraView/docs/capturing-media.html) +- Take super-fast snapshots with `takePictureSnapshot` and `takeVideoSnapshot` [[docs]](https://natario1.github.io/CameraView/docs/capturing-media.html) +- Smart sizing: create a `CameraView` of any size [[docs]](https://natario1.github.io/CameraView/docs/preview-size.html) +- Control HDR, flash, zoom, white balance, exposure, location, grid drawing & more [[docs]](https://natario1.github.io/CameraView/docs/controls.html) - Lightweight - Works down to API level 15 - Well tested Read the [official website](https://natario1.github.io/CameraView) for setup instructions and documentation. - -- Coming from v1? Take a look at the [migration guide](https://natario1.github.io/CameraView/extra/v1-migration-guide.html) -- Changelog is hosted [here](https://natario1.github.io/CameraView/about/changelog.html) +You might also be interested in [changelog](https://natario1.github.io/CameraView/about/changelog.html) +or in the [v1 migration guide](https://natario1.github.io/CameraView/extra/v1-migration-guide.html).

@@ -56,6 +56,70 @@ donation or become a sponsor, in which case your company logo will immediately s Thank you for any contribution - it is a nice reward for what has been done until now, and a motivation boost to push the library forward. +```xml + + + + + + +``` + ## Backers Thanks to all backers! [Become a backer.](https://opencollective.com/cameraview#backer) diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java index 03ab2945..f7b53309 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/BaseTest.java @@ -33,8 +33,6 @@ import static org.mockito.Mockito.mock; public class BaseTest { - public static CameraLogger LOG = CameraLogger.create("Test"); - private static KeyguardManager.KeyguardLock keyguardLock; private static PowerManager.WakeLock wakeLock; diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java index b66c20a3..2bdd952f 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java @@ -8,7 +8,12 @@ import android.location.Location; import androidx.annotation.NonNull; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.MediumTest; + +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.LayoutInflater; import android.view.MotionEvent; +import android.view.View; import android.view.ViewGroup; import com.otaliastudios.cameraview.controls.Audio; @@ -36,6 +41,7 @@ import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.markers.AutoFocusMarker; import com.otaliastudios.cameraview.markers.DefaultAutoFocusMarker; import com.otaliastudios.cameraview.markers.MarkerLayout; +import com.otaliastudios.cameraview.overlay.OverlayLayout; import com.otaliastudios.cameraview.preview.MockCameraPreview; import com.otaliastudios.cameraview.preview.CameraPreview; import com.otaliastudios.cameraview.size.Size; @@ -48,6 +54,7 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; +import org.w3c.dom.Attr; import static org.junit.Assert.*; @@ -777,5 +784,53 @@ public class CameraViewTest extends BaseTest { //endregion + //region Overlays + + @Test + public void testOverlays_generateLayoutParams() { + cameraView.mOverlayLayout = spy(cameraView.mOverlayLayout); + LayoutInflater inflater = LayoutInflater.from(context()); + View overlay = inflater.inflate(com.otaliastudios.cameraview.test.R.layout.overlay, cameraView, false); + assertTrue(overlay.getLayoutParams() instanceof OverlayLayout.LayoutParams); + verify(cameraView.mOverlayLayout, times(1)).isOverlay(any(AttributeSet.class)); + verify(cameraView.mOverlayLayout, times(1)).generateLayoutParams(any(AttributeSet.class)); + + } + + @Test + public void testOverlays_dontGenerateLayoutParams() { + cameraView.mOverlayLayout = spy(cameraView.mOverlayLayout); + LayoutInflater inflater = LayoutInflater.from(context()); + View overlay = inflater.inflate(com.otaliastudios.cameraview.test.R.layout.not_overlay, cameraView, false); + assertFalse(overlay.getLayoutParams() instanceof OverlayLayout.LayoutParams); + verify(cameraView.mOverlayLayout, times(1)).isOverlay(any(AttributeSet.class)); + verify(cameraView.mOverlayLayout, never()).generateLayoutParams(any(AttributeSet.class)); + } + + @Test + public void testOverlays_addOverlayView() { + cameraView.mOverlayLayout = spy(cameraView.mOverlayLayout); + View overlay = new View(context()); + OverlayLayout.LayoutParams params = new OverlayLayout.LayoutParams(10, 10); + int count = cameraView.getChildCount(); + cameraView.addView(overlay, 0, params); + assertEquals(count, cameraView.getChildCount()); // Not added to CameraView + verify(cameraView.mOverlayLayout, times(1)).isOverlay(params); + verify(cameraView.mOverlayLayout, times(1)).addView(overlay, params); + } + + @Test + public void testOverlays_dontAddOverlayView() { + cameraView.mOverlayLayout = spy(cameraView.mOverlayLayout); + View overlay = new View(context()); + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(10, 10); + int count = cameraView.getChildCount(); + cameraView.addView(overlay, 0, params); + assertEquals(count + 1, cameraView.getChildCount()); + verify(cameraView.mOverlayLayout, times(1)).isOverlay(params); + verify(cameraView.mOverlayLayout, never()).addView(overlay, params); + } + + //endregion // TODO: test permissions } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java index f3300011..3b9fd5a2 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/engine/CameraIntegrationTest.java @@ -2,6 +2,7 @@ package com.otaliastudios.cameraview.engine; import android.graphics.Bitmap; +import android.graphics.Canvas; import android.graphics.PointF; import android.hardware.Camera; import android.os.Build; @@ -9,6 +10,7 @@ import android.os.Build; import com.otaliastudios.cameraview.BaseTest; import com.otaliastudios.cameraview.CameraException; import com.otaliastudios.cameraview.CameraListener; +import com.otaliastudios.cameraview.CameraLogger; import com.otaliastudios.cameraview.CameraOptions; import com.otaliastudios.cameraview.CameraUtils; import com.otaliastudios.cameraview.CameraView; @@ -25,6 +27,7 @@ import com.otaliastudios.cameraview.frame.Frame; import com.otaliastudios.cameraview.frame.FrameProcessor; import com.otaliastudios.cameraview.internal.utils.Op; import com.otaliastudios.cameraview.internal.utils.WorkerHandler; +import com.otaliastudios.cameraview.overlay.Overlay; import com.otaliastudios.cameraview.size.Size; import androidx.annotation.NonNull; @@ -48,14 +51,21 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Matchers.anyBoolean; import static org.mockito.Mockito.any; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public abstract class CameraIntegrationTest extends BaseTest { - private final static long DELAY = 9000; + private final static CameraLogger LOG = CameraLogger.create(CameraIntegrationTest.class.getSimpleName()); + private final static long DELAY = 8000; + private final static long VIDEO_DELAY = 16000; @Rule public ActivityTestRule rule = new ActivityTestRule<>(TestActivity.class); @@ -75,6 +85,7 @@ public abstract class CameraIntegrationTest extends BaseTest { @Before public void setUp() { + LOG.e("Test started. Setting up camera."); WorkerHandler.destroy(); ui(new Runnable() { @@ -113,7 +124,7 @@ public abstract class CameraIntegrationTest extends BaseTest { @After public void tearDown() { - camera.stopVideo(); + LOG.e("Test ended. Tearing down camera."); camera.destroy(); WorkerHandler.destroy(); } @@ -133,6 +144,7 @@ public abstract class CameraIntegrationTest extends BaseTest { if (expectSuccess) { assertNotNull("Can open", result); // Extra wait for the bind state. + // TODO fix this and other while {} in this class in a more elegant way. while (controller.getBindState() != CameraEngine.STATE_STARTED) {} } else { assertNull("Should not open", result); @@ -163,7 +175,7 @@ public abstract class CameraIntegrationTest extends BaseTest { return argument.getReason() == CameraException.REASON_VIDEO_FAILED; } })); - VideoResult result = video.await(DELAY); + VideoResult result = video.await(VIDEO_DELAY); if (expectSuccess) { assertNotNull("Should end video", result); } else { @@ -219,6 +231,7 @@ public abstract class CameraIntegrationTest extends BaseTest { } } + @SuppressWarnings("unused") private void takeVideoSnapshotSync(boolean expectSuccess) { takeVideoSnapshotSync(expectSuccess,0); } @@ -431,7 +444,6 @@ public abstract class CameraIntegrationTest extends BaseTest { @Test public void testSetAudio() { - // TODO: when permissions are managed, check that Audio.ON triggers the audio permission openSync(true); Audio[] values = Audio.values(); for (Audio value : values) { @@ -472,7 +484,7 @@ public abstract class CameraIntegrationTest extends BaseTest { assertEquals(oldValue, camera.getPlaySounds()); } } else { - // TODO do when Camera2 is completed + assertEquals(newValue, camera.getPlaySounds()); } } @@ -502,6 +514,14 @@ public abstract class CameraIntegrationTest extends BaseTest { waitForVideoResult(true); } + @Test + public void testStartEndVideoSnapshot() { + // TODO should check api level for snapshot? + openSync(true); + takeVideoSnapshotSync(true, 4000); + waitForVideoResult(true); + } + @Test public void testEndVideo_withoutStarting() { camera.setMode(Mode.VIDEO); @@ -519,6 +539,15 @@ public abstract class CameraIntegrationTest extends BaseTest { waitForVideoResult(true); } + @Test + public void testEndVideoSnapshot_withMaxSize() { + // TODO + // camera.setVideoMaxSize(3000*1000); + // waitForOpen(true); + // waitForVideoStart(); + // waitForVideoEnd(true); + } + @Test public void testEndVideo_withMaxDuration() { camera.setMode(Mode.VIDEO); @@ -528,6 +557,15 @@ public abstract class CameraIntegrationTest extends BaseTest { waitForVideoResult(true); } + @Test + public void testEndVideoSnapshot_withMaxDuration() { + // TODO + // camera.setVideoMaxDuration(4000); + // waitForOpen(true); + // waitForVideoStart(); + // waitForVideoEnd(true); + } + //endregion //region startAutoFocus @@ -589,18 +627,22 @@ public abstract class CameraIntegrationTest extends BaseTest { camera.takePicture(); boolean did = latch.await(4, TimeUnit.SECONDS); assertFalse(did); - assertEquals(latch.getCount(), 1); + assertEquals(1, latch.getCount()); } + @SuppressWarnings("StatementWithEmptyBody") @Test public void testCapturePicture_size() throws Exception { openSync(true); // PictureSize can still be null after opened. + // TODO be more elegant while (camera.getPictureSize() == null) {} Size size = camera.getPictureSize(); camera.takePicture(); PictureResult result = waitForPictureResult(true); + assertNotNull(result); Bitmap bitmap = CameraUtils.decodeBitmap(result.getData(), Integer.MAX_VALUE, Integer.MAX_VALUE); + assertNotNull(bitmap); assertEquals(result.getSize(), size); assertEquals(bitmap.getWidth(), size.getWidth()); assertEquals(bitmap.getHeight(), size.getHeight()); @@ -638,16 +680,20 @@ public abstract class CameraIntegrationTest extends BaseTest { assertEquals(1, latch.getCount()); } + @SuppressWarnings("StatementWithEmptyBody") @Test public void testCaptureSnapshot_size() throws Exception { openSync(true); // SnapshotSize can still be null after opened. + // TODO be more elegant while (camera.getSnapshotSize() == null) {} Size size = camera.getSnapshotSize(); camera.takePictureSnapshot(); PictureResult result = waitForPictureResult(true); + assertNotNull(result); Bitmap bitmap = CameraUtils.decodeBitmap(result.getData(), Integer.MAX_VALUE, Integer.MAX_VALUE); + assertNotNull(bitmap); assertEquals(result.getSize(), size); assertEquals(bitmap.getWidth(), size.getWidth()); assertEquals(bitmap.getHeight(), size.getHeight()); @@ -735,4 +781,32 @@ public abstract class CameraIntegrationTest extends BaseTest { } //endregion + + //region Overlays + + @Test + public void testOverlay_forPictureSnapshot() { + Overlay overlay = mock(Overlay.class); + when(overlay.drawsOn(any(Overlay.Target.class))).thenReturn(true); + controller.setOverlay(overlay); + openSync(true); + camera.takePictureSnapshot(); + waitForPictureResult(true); + verify(overlay, atLeastOnce()).drawsOn(Overlay.Target.PICTURE_SNAPSHOT); + verify(overlay, times(1)).drawOn(eq(Overlay.Target.PICTURE_SNAPSHOT), any(Canvas.class)); + } + + @Test + public void testOverlay_forVideoSnapshot() { + Overlay overlay = mock(Overlay.class); + when(overlay.drawsOn(any(Overlay.Target.class))).thenReturn(true); + controller.setOverlay(overlay); + openSync(true); + takeVideoSnapshotSync(true, 4000); + waitForVideoResult(true); + verify(overlay, atLeastOnce()).drawsOn(Overlay.Target.VIDEO_SNAPSHOT); + verify(overlay, atLeastOnce()).drawOn(eq(Overlay.Target.VIDEO_SNAPSHOT), any(Canvas.class)); + } + + //endregion } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/overlay/OverlayLayoutTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/overlay/OverlayLayoutTest.java new file mode 100644 index 00000000..729385d3 --- /dev/null +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/overlay/OverlayLayoutTest.java @@ -0,0 +1,197 @@ +package com.otaliastudios.cameraview.overlay; + + +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.content.res.XmlResourceParser; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.util.Xml; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.test.annotation.UiThreadTest; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; + +import com.otaliastudios.cameraview.BaseTest; +import com.otaliastudios.cameraview.R; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatcher; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.w3c.dom.Attr; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(AndroidJUnit4.class) +@SmallTest +public class OverlayLayoutTest extends BaseTest { + + private OverlayLayout overlayLayout; + + @Before + public void setUp() { + overlayLayout = spy(new OverlayLayout(context())); + } + + @After + public void tearDown() { + overlayLayout = null; + } + + @Test + public void testIsOverlay_LayoutParams() { + ViewGroup.LayoutParams params; + + params = new ViewGroup.LayoutParams(10, 10); + assertFalse(overlayLayout.isOverlay(params)); + + params = new OverlayLayout.LayoutParams(10, 10); + assertTrue(overlayLayout.isOverlay(params)); + } + + @Test + public void testIsOverlay_attributeSet() throws Exception { + int layout1 = com.otaliastudios.cameraview.test.R.layout.overlay; + int layout2 = com.otaliastudios.cameraview.test.R.layout.not_overlay; + + AttributeSet set1 = getAttributeSet(layout1); + assertTrue(overlayLayout.isOverlay(set1)); + + AttributeSet set2 = getAttributeSet(layout2); + assertFalse(overlayLayout.isOverlay(set2)); + } + + @NonNull + private AttributeSet getAttributeSet(int layout) throws Exception { + // Get the attribute set in the correct state: use a parser and move to START_TAG + XmlResourceParser parser = context().getResources().getLayout(layout); + //noinspection StatementWithEmptyBody + while (parser.next() != XmlResourceParser.START_TAG) {} + return Xml.asAttributeSet(parser); + } + + @Test + public void testLayoutParams_drawsOn() { + OverlayLayout.LayoutParams params = new OverlayLayout.LayoutParams(10, 10); + + assertFalse(params.drawsOn(Overlay.Target.PREVIEW)); + assertFalse(params.drawsOn(Overlay.Target.PICTURE_SNAPSHOT)); + assertFalse(params.drawsOn(Overlay.Target.VIDEO_SNAPSHOT)); + + params.drawOnPreview = true; + assertTrue(params.drawsOn(Overlay.Target.PREVIEW)); + params.drawOnPictureSnapshot = true; + assertTrue(params.drawsOn(Overlay.Target.PICTURE_SNAPSHOT)); + params.drawOnVideoSnapshot = true; + assertTrue(params.drawsOn(Overlay.Target.VIDEO_SNAPSHOT)); + } + + @Test + public void testLayoutParams_toString() { + OverlayLayout.LayoutParams params = new OverlayLayout.LayoutParams(10, 10); + String string = params.toString(); + assertTrue(string.contains("drawOnPreview")); + assertTrue(string.contains("drawOnPictureSnapshot")); + assertTrue(string.contains("drawOnVideoSnapshot")); + } + + @Test + public void testDrawChild() { + Canvas canvas = new Canvas(); + OverlayLayout.LayoutParams params = new OverlayLayout.LayoutParams(10, 10); + View child = new View(context()); + child.setLayoutParams(params); + when(overlayLayout.doDrawChild(canvas, child, 0)).thenReturn(true); + + overlayLayout.currentTarget = Overlay.Target.PREVIEW; + assertFalse(overlayLayout.drawChild(canvas, child, 0)); + params.drawOnPreview = true; + assertTrue(overlayLayout.drawChild(canvas, child, 0)); + + overlayLayout.currentTarget = Overlay.Target.PICTURE_SNAPSHOT; + assertFalse(overlayLayout.drawChild(canvas, child, 0)); + params.drawOnPictureSnapshot = true; + assertTrue(overlayLayout.drawChild(canvas, child, 0)); + + overlayLayout.currentTarget = Overlay.Target.VIDEO_SNAPSHOT; + assertFalse(overlayLayout.drawChild(canvas, child, 0)); + params.drawOnVideoSnapshot = true; + assertTrue(overlayLayout.drawChild(canvas, child, 0)); + } + + @UiThreadTest + @Test + public void testDraw() { + Canvas canvas = new Canvas(); + when(overlayLayout.drawsOn(Overlay.Target.PREVIEW)).thenReturn(false); + overlayLayout.draw(canvas); + verify(overlayLayout, never()).drawOn(Overlay.Target.PREVIEW, canvas); + + when(overlayLayout.drawsOn(Overlay.Target.PREVIEW)).thenReturn(true); + overlayLayout.draw(canvas); + verify(overlayLayout, times(1)).drawOn(Overlay.Target.PREVIEW, canvas); + } + + @UiThreadTest + @Test + public void testDrawOn() { + Canvas canvas = spy(new Canvas()); + View child = new View(context()); + OverlayLayout.LayoutParams params = new OverlayLayout.LayoutParams(10, 10); + params.drawOnPreview = true; + params.drawOnPictureSnapshot = true; + params.drawOnVideoSnapshot = true; + overlayLayout.addView(child, params); + + overlayLayout.drawOn(Overlay.Target.PREVIEW, canvas); + verify(canvas, never()).scale(anyFloat(), anyFloat()); + verify(overlayLayout, times(1)).doDrawChild(eq(canvas), eq(child), anyLong()); + reset(canvas); + reset(overlayLayout); + + overlayLayout.drawOn(Overlay.Target.PICTURE_SNAPSHOT, canvas); + verify(canvas, times(1)).scale(anyFloat(), anyFloat()); + verify(overlayLayout, times(1)).doDrawChild(eq(canvas), eq(child), anyLong()); + reset(canvas); + reset(overlayLayout); + + overlayLayout.drawOn(Overlay.Target.VIDEO_SNAPSHOT, canvas); + verify(canvas, times(1)).scale(anyFloat(), anyFloat()); + verify(overlayLayout, times(1)).doDrawChild(eq(canvas), eq(child), anyLong()); + reset(canvas); + reset(overlayLayout); + } +} diff --git a/cameraview/src/androidTest/res/layout/not_overlay.xml b/cameraview/src/androidTest/res/layout/not_overlay.xml new file mode 100644 index 00000000..158a0406 --- /dev/null +++ b/cameraview/src/androidTest/res/layout/not_overlay.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/cameraview/src/androidTest/res/layout/overlay.xml b/cameraview/src/androidTest/res/layout/overlay.xml new file mode 100644 index 00000000..ff33576e --- /dev/null +++ b/cameraview/src/androidTest/res/layout/overlay.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java index 2c6292fa..f8e0d6ff 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java @@ -28,6 +28,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import android.util.AttributeSet; import android.view.MotionEvent; +import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; @@ -62,6 +63,7 @@ import com.otaliastudios.cameraview.internal.utils.CropHelper; import com.otaliastudios.cameraview.internal.utils.OrientationHelper; import com.otaliastudios.cameraview.internal.utils.WorkerHandler; import com.otaliastudios.cameraview.markers.MarkerParser; +import com.otaliastudios.cameraview.overlay.OverlayLayout; import com.otaliastudios.cameraview.preview.CameraPreview; import com.otaliastudios.cameraview.preview.GlCameraPreview; import com.otaliastudios.cameraview.preview.SurfaceCameraPreview; @@ -125,12 +127,15 @@ public class CameraView extends FrameLayout implements LifecycleObserver { @VisibleForTesting ScrollGestureFinder mScrollGestureFinder; // Views - GridLinesLayout mGridLinesLayout; - MarkerLayout mMarkerLayout; + @VisibleForTesting GridLinesLayout mGridLinesLayout; + @VisibleForTesting MarkerLayout mMarkerLayout; private boolean mKeepScreenOn; @SuppressWarnings({"FieldCanBeLocal", "unused"}) private boolean mExperimental; + // Overlays + @VisibleForTesting OverlayLayout mOverlayLayout; + // Threading private Handler mUiHandler; private WorkerHandler mFrameProcessorsHandler; @@ -187,9 +192,11 @@ public class CameraView extends FrameLayout implements LifecycleObserver { // Views mGridLinesLayout = new GridLinesLayout(context); + mOverlayLayout = new OverlayLayout(context); mMarkerLayout = new MarkerLayout(context); addView(mGridLinesLayout); addView(mMarkerLayout); + addView(mOverlayLayout); // Create the engine doInstantiateEngine(); @@ -237,7 +244,10 @@ public class CameraView extends FrameLayout implements LifecycleObserver { * {@link #setEngine(Engine)} is called. */ private void doInstantiateEngine() { + LOG.w("doInstantiateEngine:", "instantiating. engine:", mEngine); mCameraEngine = instantiateCameraEngine(mEngine, mCameraCallbacks); + LOG.w("doInstantiateEngine:", "instantiated. engine:", mCameraEngine.getClass().getSimpleName()); + mCameraEngine.setOverlay(mOverlayLayout); } /** @@ -247,7 +257,9 @@ public class CameraView extends FrameLayout implements LifecycleObserver { */ @VisibleForTesting void doInstantiatePreview() { + LOG.w("doInstantiateEngine:", "instantiating. preview:", mPreview); mCameraPreview = instantiatePreview(mPreview, getContext(), this); + LOG.w("doInstantiateEngine:", "instantiated. preview:", mCameraPreview.getClass().getSimpleName()); mCameraEngine.setPreview(mCameraPreview); } @@ -279,7 +291,6 @@ public class CameraView extends FrameLayout implements LifecycleObserver { */ @NonNull protected CameraPreview instantiatePreview(@NonNull Preview preview, @NonNull Context context, @NonNull ViewGroup container) { - LOG.w("preview:", "isHardwareAccelerated:", isHardwareAccelerated()); switch (preview) { case SURFACE: return new SurfaceCameraPreview(context, container); @@ -300,6 +311,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { protected void onAttachedToWindow() { super.onAttachedToWindow(); if (mCameraPreview == null) { + // isHardwareAccelerated will return the real value only after we are // attached. That's why we instantiate the preview here. doInstantiatePreview(); @@ -384,7 +396,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { // other than respect it. The preview will eventually be cropped at the sides (by PreviewImpl scaling) // except the case in which these fixed dimensions manage to fit exactly the preview aspect ratio. if (widthMode == EXACTLY && heightMode == EXACTLY) { - LOG.w("onMeasure:", "both are MATCH_PARENT or fixed value. We adapt.", + LOG.i("onMeasure:", "both are MATCH_PARENT or fixed value. We adapt.", "This means CROP_CENTER.", "(" + widthValue + "x" + heightValue + ")"); super.onMeasure(widthMeasureSpec, heightMeasureSpec); return; @@ -1218,6 +1230,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { mCameraEngine.setAutoFocusResetDelay(delayMillis); } + /** * Returns the current delay in milliseconds to reset the focus after an autofocus process. * @return the current autofocus reset delay in milliseconds. @@ -2078,4 +2091,26 @@ public class CameraView extends FrameLayout implements LifecycleObserver { } //endregion + + //region Overlays + + @Override + public LayoutParams generateLayoutParams(AttributeSet attributeSet) { + if (mOverlayLayout.isOverlay(attributeSet)) { + return mOverlayLayout.generateLayoutParams(attributeSet); + } + return super.generateLayoutParams(attributeSet); + } + + // We don't support removeView on overlays for now. + @Override + public void addView(View child, int index, ViewGroup.LayoutParams params) { + if (mOverlayLayout.isOverlay(params)) { + mOverlayLayout.addView(child, params); + } else { + super.addView(child, index, params); + } + } + + //endregion } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java index 0efad6b2..5ee4a480 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera1Engine.java @@ -312,7 +312,7 @@ public class Camera1Engine extends CameraEngine implements AspectRatio outputRatio = getAngles().flip(Reference.OUTPUT, Reference.VIEW) ? viewAspectRatio.flip() : viewAspectRatio; if (mPreview instanceof GlCameraPreview && Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - mPictureRecorder = new SnapshotGlPictureRecorder(stub, this, (GlCameraPreview) mPreview, outputRatio); + mPictureRecorder = new SnapshotGlPictureRecorder(stub, this, (GlCameraPreview) mPreview, outputRatio, getOverlay()); } else { mPictureRecorder = new Snapshot1PictureRecorder(stub, this, mCamera, outputRatio); } @@ -359,10 +359,20 @@ public class Camera1Engine extends CameraEngine implements Rect outputCrop = CropHelper.computeCrop(outputSize, outputRatio); outputSize = new Size(outputCrop.width(), outputCrop.height()); stub.size = outputSize; + // Vertical: 0 (270-0-0) + // Left (unlocked): 0 (270-90-270) + // Right (unlocked): 0 (270-270-90) + // Upside down (unlocked): 0 (270-180-180) + // Left (locked): 270 (270-0-270) + // Right (locked): 90 (270-0-90) + // Upside down (locked): 180 (270-0-180) + // The correct formula seems to be deviceOrientation+displayOffset, + // which means offset(Reference.VIEW, Reference.OUTPUT, Axis.ABSOLUTE). stub.rotation = getAngles().offset(Reference.VIEW, Reference.OUTPUT, Axis.ABSOLUTE); + LOG.i("onTakeVideoSnapshot", "rotation:", stub.rotation, "size:", stub.size); // Start. - mVideoRecorder = new SnapshotVideoRecorder(Camera1Engine.this, glPreview); + mVideoRecorder = new SnapshotVideoRecorder(Camera1Engine.this, glPreview, getOverlay(), stub.rotation); mVideoRecorder.start(stub); } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java index 964eb976..392529e1 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/Camera2Engine.java @@ -615,7 +615,7 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv stub.rotation = getAngles().offset(Reference.SENSOR, Reference.OUTPUT, Axis.RELATIVE_TO_SENSOR); // Actually it will be rotated and set to 0. AspectRatio outputRatio = getAngles().flip(Reference.OUTPUT, Reference.VIEW) ? viewAspectRatio.flip() : viewAspectRatio; if (mPreview instanceof GlCameraPreview) { - mPictureRecorder = new SnapshotGlPictureRecorder(stub, this, (GlCameraPreview) mPreview, outputRatio); + mPictureRecorder = new SnapshotGlPictureRecorder(stub, this, (GlCameraPreview) mPreview, outputRatio, getOverlay()); } else { throw new RuntimeException("takePictureSnapshot with Camera2 is only supported with Preview.GL_SURFACE"); } @@ -670,7 +670,7 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv private void doTakeVideo(@NonNull final VideoResult.Stub stub) { if (!(mVideoRecorder instanceof Full2VideoRecorder)) { - mVideoRecorder = new Full2VideoRecorder(this, mCameraId); + throw new IllegalStateException("doTakeVideo called, but video recorder is not a Full2VideoRecorder! " + mVideoRecorder); } Full2VideoRecorder recorder = (Full2VideoRecorder) mVideoRecorder; try { @@ -706,10 +706,22 @@ public class Camera2Engine extends CameraEngine implements ImageReader.OnImageAv Rect outputCrop = CropHelper.computeCrop(outputSize, outputRatio); outputSize = new Size(outputCrop.width(), outputCrop.height()); stub.size = outputSize; - stub.rotation = getAngles().offset(Reference.VIEW, Reference.OUTPUT, Axis.ABSOLUTE); + // Vertical: 0 (270-0-0) + // Left (unlocked): 270 (270-90-270) + // Right (unlocked): 90 (270-270-90) + // Upside down (unlocked): 180 (270-180-180) + // Left (locked): 270 (270-0-270) + // Right (locked): 90 (270-0-90) + // Upside down (locked): 180 (270-0-180) + // Unlike Camera1, the correct formula seems to be deviceOrientation, + // which means offset(Reference.BASE, Reference.OUTPUT, Axis.ABSOLUTE). + stub.rotation = getAngles().offset(Reference.BASE, Reference.OUTPUT, Axis.ABSOLUTE); + LOG.i("onTakeVideoSnapshot", "rotation:", stub.rotation, "size:", stub.size); // Start. - mVideoRecorder = new SnapshotVideoRecorder(this, glPreview); + // The overlay rotation should alway be VIEW-OUTPUT, just liek Camera1Engine. + int overlayRotation = getAngles().offset(Reference.VIEW, Reference.OUTPUT, Axis.ABSOLUTE); + mVideoRecorder = new SnapshotVideoRecorder(this, glPreview, getOverlay(), overlayRotation); mVideoRecorder.start(stub); } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java index 9de9388c..3a82de46 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/CameraEngine.java @@ -18,6 +18,7 @@ import com.otaliastudios.cameraview.CameraException; import com.otaliastudios.cameraview.CameraLogger; import com.otaliastudios.cameraview.CameraOptions; import com.otaliastudios.cameraview.PictureResult; +import com.otaliastudios.cameraview.overlay.Overlay; import com.otaliastudios.cameraview.VideoResult; import com.otaliastudios.cameraview.engine.offset.Angles; import com.otaliastudios.cameraview.engine.offset.Reference; @@ -184,6 +185,7 @@ public abstract class CameraEngine implements private long mAutoFocusResetDelayMillis; private int mSnapshotMaxWidth = Integer.MAX_VALUE; // in REF_VIEW for consistency with SizeSelectors private int mSnapshotMaxHeight = Integer.MAX_VALUE; // in REF_VIEW for consistency with SizeSelectors + private Overlay overlay; // Steps private final Step.Callback mStepCallback = new Step.Callback() { @@ -781,6 +783,15 @@ public abstract class CameraEngine implements //region Final setters and getters + public final void setOverlay(@Nullable Overlay overlay) { + this.overlay = overlay; + } + + @Nullable + public final Overlay getOverlay() { + return overlay; + } + @SuppressWarnings("WeakerAccess") public final Angles getAngles() { return mAngles; @@ -1218,27 +1229,31 @@ public abstract class CameraEngine implements @Nullable public final Size getPictureSize(@SuppressWarnings("SameParameterValue") @NonNull Reference reference) { - if (mCaptureSize == null || mMode == Mode.VIDEO) return null; - return getAngles().flip(Reference.SENSOR, reference) ? mCaptureSize.flip() : mCaptureSize; + Size size = mCaptureSize; + if (size == null || mMode == Mode.VIDEO) return null; + return getAngles().flip(Reference.SENSOR, reference) ? size.flip() : size; } @Nullable public final Size getVideoSize(@SuppressWarnings("SameParameterValue") @NonNull Reference reference) { - if (mCaptureSize == null || mMode == Mode.PICTURE) return null; - return getAngles().flip(Reference.SENSOR, reference) ? mCaptureSize.flip() : mCaptureSize; + Size size = mCaptureSize; + if (size == null || mMode == Mode.PICTURE) return null; + return getAngles().flip(Reference.SENSOR, reference) ? size.flip() : size; } @Nullable public final Size getPreviewStreamSize(@NonNull Reference reference) { - if (mPreviewStreamSize == null) return null; - return getAngles().flip(Reference.SENSOR, reference) ? mPreviewStreamSize.flip() : mPreviewStreamSize; + Size size = mPreviewStreamSize; + if (size == null) return null; + return getAngles().flip(Reference.SENSOR, reference) ? size.flip() : size; } @SuppressWarnings("SameParameterValue") @Nullable private Size getPreviewSurfaceSize(@NonNull Reference reference) { - if (mPreview == null) return null; - return getAngles().flip(Reference.VIEW, reference) ? mPreview.getSurfaceSize().flip() : mPreview.getSurfaceSize(); + CameraPreview preview = mPreview; + if (preview == null) return null; + return getAngles().flip(Reference.VIEW, reference) ? preview.getSurfaceSize().flip() : preview.getSurfaceSize(); } /** diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/offset/Angles.java b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/offset/Angles.java index 56c9199b..a8718418 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/engine/offset/Angles.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/engine/offset/Angles.java @@ -3,6 +3,7 @@ package com.otaliastudios.cameraview.engine.offset; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import com.otaliastudios.cameraview.CameraLogger; import com.otaliastudios.cameraview.controls.Facing; /** @@ -21,6 +22,9 @@ import com.otaliastudios.cameraview.controls.Facing; */ public class Angles { + private final static String TAG = Angles.class.getSimpleName(); + private final static CameraLogger LOG = CameraLogger.create(TAG); + private Facing mSensorFacing; @VisibleForTesting int mSensorOffset = 0; @VisibleForTesting int mDisplayOffset = 0; @@ -40,6 +44,7 @@ public class Angles { if (mSensorFacing == Facing.FRONT) { mSensorOffset = sanitizeOutput(360 - mSensorOffset); } + print(); } /** @@ -49,6 +54,7 @@ public class Angles { public void setDisplayOffset(int displayOffset) { sanitizeInput(displayOffset); mDisplayOffset = displayOffset; + print(); } /** @@ -58,6 +64,14 @@ public class Angles { public void setDeviceOrientation(int deviceOrientation) { sanitizeInput(deviceOrientation); mDeviceOrientation = deviceOrientation; + print(); + } + + private void print() { + LOG.i("Angles changed:", + "sensorOffset:", mSensorOffset, + "displayOffset:", mDisplayOffset, + "deviceOrientation:", mDeviceOrientation); } /** diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/egl/EglViewport.java b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/egl/EglViewport.java index 3496bf95..54114c9d 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/egl/EglViewport.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/egl/EglViewport.java @@ -145,6 +145,12 @@ public class EglViewport extends EglElement { GLES20.glUseProgram(mProgramHandle); check("glUseProgram"); + // enable blending, from: http://www.learnopengles.com/android-lesson-five-an-introduction-to-blending/ + GLES20.glDisable(GLES20.GL_CULL_FACE); + GLES20.glDisable(GLES20.GL_DEPTH_TEST); + GLES20.glEnable(GLES20.GL_BLEND); + GLES20.glBlendFunc(GLES20.GL_SRC_ALPHA, GLES20.GL_ONE_MINUS_SRC_ALPHA); + // Set the texture. GLES20.glActiveTexture(GLES20.GL_TEXTURE0); GLES20.glBindTexture(mTextureTarget, textureId); diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Pool.java b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Pool.java index b59a0819..4a0408b9 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Pool.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/internal/utils/Pool.java @@ -63,17 +63,17 @@ public class Pool { T item = mQueue.poll(); if (item != null) { activeCount++; // poll decreases, this fixes - LOG.v("GET: Reusing recycled item.", this); + LOG.v("GET - Reusing recycled item.", this); return item; } if (isEmpty()) { - LOG.v("GET: Returning null. Too much items requested.", this); + LOG.v("GET - Returning null. Too much items requested.", this); return null; } activeCount++; - LOG.v("GET: Creating a new item.", this); + LOG.v("GET - Creating a new item.", this); return factory.create(); } @@ -84,7 +84,7 @@ public class Pool { * @param item used item */ public void recycle(@NonNull T item) { - LOG.v("RECYCLE: Recycling item.", this); + LOG.v("RECYCLE - Recycling item.", this); if (--activeCount < 0) { throw new IllegalStateException("Trying to recycle an item which makes activeCount < 0." + "This means that this or some previous items being recycled were not coming from " + @@ -112,6 +112,7 @@ public class Pool { * * @return count */ + @SuppressWarnings("WeakerAccess") public final int count() { return activeCount() + recycledCount(); } @@ -122,6 +123,7 @@ public class Pool { * * @return active count */ + @SuppressWarnings("WeakerAccess") public final int activeCount() { return activeCount; } @@ -133,6 +135,7 @@ public class Pool { * * @return recycled count */ + @SuppressWarnings("WeakerAccess") public final int recycledCount() { return mQueue.size(); } @@ -140,6 +143,6 @@ public class Pool { @NonNull @Override public String toString() { - return getClass().getSimpleName() + " -- count:" + count() + ", active:" + activeCount() + ", recycled:" + recycledCount(); + return getClass().getSimpleName() + " - count:" + count() + ", active:" + activeCount() + ", recycled:" + recycledCount(); } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/overlay/Overlay.java b/cameraview/src/main/java/com/otaliastudios/cameraview/overlay/Overlay.java new file mode 100644 index 00000000..44e1661d --- /dev/null +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/overlay/Overlay.java @@ -0,0 +1,33 @@ +package com.otaliastudios.cameraview.overlay; + +import android.graphics.Canvas; + +import androidx.annotation.NonNull; + +/** + * Base interface for overlays. + */ +public interface Overlay { + + enum Target { + PREVIEW, PICTURE_SNAPSHOT, VIDEO_SNAPSHOT + } + + /** + * Called for this overlay to draw itself on the specified target and canvas. + * + * @param target target + * @param canvas target canvas + */ + void drawOn(@NonNull Target target, @NonNull Canvas canvas); + + /** + * Called to understand if this overlay would like to draw onto the given + * target or not. If true is returned, {@link #drawOn(Target, Canvas)} can be + * called at a future time. + * + * @param target the target + * @return true to draw on it + */ + boolean drawsOn(@NonNull Target target); +} diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/overlay/OverlayLayout.java b/cameraview/src/main/java/com/otaliastudios/cameraview/overlay/OverlayLayout.java new file mode 100644 index 00000000..931a5905 --- /dev/null +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/overlay/OverlayLayout.java @@ -0,0 +1,211 @@ +package com.otaliastudios.cameraview.overlay; + + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.otaliastudios.cameraview.CameraLogger; +import com.otaliastudios.cameraview.R; + + +@SuppressLint("CustomViewStyleable") +public class OverlayLayout extends FrameLayout implements Overlay { + + private static final String TAG = OverlayLayout.class.getSimpleName(); + private static final CameraLogger LOG = CameraLogger.create(TAG); + + @VisibleForTesting Target currentTarget = Target.PREVIEW; + + /** + * We set {@link #setWillNotDraw(boolean)} to false even if we don't draw anything. + * This ensures that the View system will call {@link #draw(Canvas)} on us instead + * of short-circuiting to {@link #dispatchDraw(Canvas)}. + * + * That would be a problem for us since we use {@link #draw(Canvas)} to understand if + * we are currently drawing on the preview or not. + * + * @param context a context + */ + public OverlayLayout(@NonNull Context context) { + super(context); + setWillNotDraw(false); + } + + /** + * Returns true if this {@link AttributeSet} belongs to an overlay. + * @param set an attribute set + * @return true if overlay + */ + public boolean isOverlay(@Nullable AttributeSet set) { + if (set == null) return false; + TypedArray a = getContext().obtainStyledAttributes(set, R.styleable.CameraView_Layout); + boolean isOverlay = + a.hasValue(R.styleable.CameraView_Layout_layout_drawOnPreview) + || a.hasValue(R.styleable.CameraView_Layout_layout_drawOnPictureSnapshot) + || a.hasValue(R.styleable.CameraView_Layout_layout_drawOnVideoSnapshot); + a.recycle(); + return isOverlay; + } + + /** + * Returns true if this {@link ViewGroup.LayoutParams} belongs to an overlay. + * @param params a layout params + * @return true if overlay + */ + public boolean isOverlay(@NonNull ViewGroup.LayoutParams params) { + return params instanceof LayoutParams; + } + + /** + * Generates our own overlay layout params. + * @param attrs input attrs + * @return our params + */ + @Override + public OverlayLayout.LayoutParams generateLayoutParams(AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + /** + * This is called by the View hierarchy, so at this point we are + * likely drawing on the preview. + * @param canvas View canvas + */ + @SuppressLint("MissingSuperCall") + @Override + public void draw(Canvas canvas) { + LOG.i("normal draw called."); + if (drawsOn(Target.PREVIEW)) { + drawOn(Target.PREVIEW, canvas); + } + } + + @Override + public boolean drawsOn(@NonNull Target target) { + for (int i = 0; i < getChildCount(); i++) { + LayoutParams params = (LayoutParams) getChildAt(i).getLayoutParams(); + if (params.drawsOn(target)) return true; + } + return false; + } + + /** + * For {@link Target#PREVIEW}, this method is called by the View hierarchy. We will + * just forward the call to super. + * + * For {@link Target#PICTURE_SNAPSHOT} and {@link Target#VIDEO_SNAPSHOT}, + * this method is called by the overlay drawer. We call {@link #dispatchDraw(Canvas)} + * to draw our children only. + * + * @param target the draw target + * @param canvas the canvas + */ + @Override + public void drawOn(@NonNull Target target, @NonNull Canvas canvas) { + synchronized (this) { + currentTarget = target; + switch (target) { + case PREVIEW: + super.draw(canvas); + break; + case VIDEO_SNAPSHOT: + case PICTURE_SNAPSHOT: + canvas.save(); + // The input canvas size is that of the preview stream, cropped to match + // the view aspect ratio (this op is done by picture & video recorder). + // So the aspect ratio is guaranteed to be the same, but we might have + // to apply some scale (typically > 1). + float widthScale = canvas.getWidth() / (float) getWidth(); + float heightScale = canvas.getHeight() / (float) getHeight(); + LOG.i("draw", + "target:", target, + "canvas:", canvas.getWidth() + "x" + canvas.getHeight(), + "view:", getWidth() + "x" + getHeight(), + "widthScale:", widthScale, + "heightScale:", heightScale + ); + canvas.scale(widthScale, heightScale); + dispatchDraw(canvas); + canvas.restore(); + break; + } + } + } + + /** + * We end up here in all three cases, and should filter out + * views that are not meant to be drawn on that specific surface. + */ + @Override + protected boolean drawChild(Canvas canvas, View child, long drawingTime) { + LayoutParams params = (LayoutParams) child.getLayoutParams(); + if (params.drawsOn(currentTarget)) { + LOG.v("Performing drawing for view:", child.getClass().getSimpleName(), + "target:", currentTarget, + "params:", params); + return doDrawChild(canvas, child, drawingTime); + } else { + LOG.v("Skipping drawing for view:", child.getClass().getSimpleName(), + "target:", currentTarget, + "params:", params); + return false; + } + } + + @VisibleForTesting + boolean doDrawChild(Canvas canvas, View child, long drawingTime) { + return super.drawChild(canvas, child, drawingTime); + } + + @SuppressWarnings("WeakerAccess") + public static class LayoutParams extends FrameLayout.LayoutParams { + + @SuppressWarnings("unused") + public boolean drawOnPreview = false; + public boolean drawOnPictureSnapshot = false; + public boolean drawOnVideoSnapshot = false; + + public LayoutParams(int width, int height) { + super(width, height); + } + + public LayoutParams(@NonNull Context context, @NonNull AttributeSet attrs) { + super(context, attrs); + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CameraView_Layout); + try { + drawOnPreview = a.getBoolean(R.styleable.CameraView_Layout_layout_drawOnPreview, false); + drawOnPictureSnapshot = a.getBoolean(R.styleable.CameraView_Layout_layout_drawOnPictureSnapshot, false); + drawOnVideoSnapshot = a.getBoolean(R.styleable.CameraView_Layout_layout_drawOnVideoSnapshot, false); + } finally { + a.recycle(); + } + } + + @VisibleForTesting + boolean drawsOn(@NonNull Target target) { + return ((target == Target.PREVIEW && drawOnPreview) + || (target == Target.VIDEO_SNAPSHOT && drawOnVideoSnapshot) + || (target == Target.PICTURE_SNAPSHOT && drawOnPictureSnapshot)); + } + + @NonNull + @Override + public String toString() { + return getClass().getName() + "[" + + "drawOnPreview:" + drawOnPreview + + ",drawOnPictureSnapshot:" + drawOnPictureSnapshot + + ",drawOnVideoSnapshot:" + drawOnVideoSnapshot + + "]"; + } + } +} diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/picture/SnapshotGlPictureRecorder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/picture/SnapshotGlPictureRecorder.java index e969a67a..d13ef3a9 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/picture/SnapshotGlPictureRecorder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/picture/SnapshotGlPictureRecorder.java @@ -2,6 +2,9 @@ package com.otaliastudios.cameraview.picture; import android.annotation.TargetApi; import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.SurfaceTexture; import android.opengl.EGL14; @@ -11,8 +14,10 @@ import android.os.Build; import com.otaliastudios.cameraview.CameraLogger; import com.otaliastudios.cameraview.PictureResult; +import com.otaliastudios.cameraview.overlay.Overlay; import com.otaliastudios.cameraview.controls.Facing; import com.otaliastudios.cameraview.engine.CameraEngine; +import com.otaliastudios.cameraview.engine.offset.Axis; import com.otaliastudios.cameraview.engine.offset.Reference; import com.otaliastudios.cameraview.internal.egl.EglCore; import com.otaliastudios.cameraview.internal.egl.EglViewport; @@ -26,7 +31,9 @@ import com.otaliastudios.cameraview.size.AspectRatio; import com.otaliastudios.cameraview.size.Size; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import android.view.Surface; public class SnapshotGlPictureRecorder extends PictureRecorder { @@ -37,15 +44,21 @@ public class SnapshotGlPictureRecorder extends PictureRecorder { private GlCameraPreview mPreview; private AspectRatio mOutputRatio; + private Overlay mOverlay; + private boolean mHasOverlay; + public SnapshotGlPictureRecorder( @NonNull PictureResult.Stub stub, @NonNull CameraEngine engine, @NonNull GlCameraPreview preview, - @NonNull AspectRatio outputRatio) { + @NonNull AspectRatio outputRatio, + @Nullable Overlay overlay) { super(stub, engine); mEngine = engine; mPreview = preview; mOutputRatio = outputRatio; + mOverlay = overlay; + mHasOverlay = overlay != null && overlay.drawsOn(Overlay.Target.PICTURE_SNAPSHOT); } @TargetApi(Build.VERSION_CODES.KITKAT) @@ -57,15 +70,31 @@ public class SnapshotGlPictureRecorder extends PictureRecorder { SurfaceTexture mSurfaceTexture; float[] mTransform; + int mOverlayTextureId = 0; + SurfaceTexture mOverlaySurfaceTexture; + Surface mOverlaySurface; + float[] mOverlayTransform; + + EglViewport mViewport; + @RendererThread public void onRendererTextureCreated(int textureId) { mTextureId = textureId; + mViewport = new EglViewport(); mSurfaceTexture = new SurfaceTexture(mTextureId, true); // Need to crop the size. Rect crop = CropHelper.computeCrop(mResult.size, mOutputRatio); mResult.size = new Size(crop.width(), crop.height()); mSurfaceTexture.setDefaultBufferSize(mResult.size.getWidth(), mResult.size.getHeight()); mTransform = new float[16]; + + if (mHasOverlay) { + mOverlayTextureId = mViewport.createTexture(); + mOverlaySurfaceTexture = new SurfaceTexture(mOverlayTextureId, true); + mOverlaySurfaceTexture.setDefaultBufferSize(mResult.size.getWidth(), mResult.size.getHeight()); + mOverlaySurface = new Surface(mOverlaySurfaceTexture); + mOverlayTransform = new float[16]; + } } @RendererThread @@ -97,14 +126,14 @@ public class SnapshotGlPictureRecorder extends PictureRecorder { WorkerHandler.execute(new Runnable() { @Override public void run() { + // 1. Get latest texture EglWindowSurface surface = new EglWindowSurface(core, mSurfaceTexture); surface.makeCurrent(); - EglViewport viewport = new EglViewport(); mSurfaceTexture.updateTexImage(); mSurfaceTexture.getTransformMatrix(mTransform); - // Apply scale and crop: - // NOTE: scaleX and scaleY are in REF_VIEW, while our input appears to be in REF_SENSOR. + // 2. Apply scale and crop: + // scaleX and scaleY are in REF_VIEW, while our input appears to be in REF_SENSOR. boolean flip = mEngine.getAngles().flip(Reference.VIEW, Reference.SENSOR); float realScaleX = flip ? scaleY : scaleX; float realScaleY = flip ? scaleX : scaleY; @@ -113,38 +142,62 @@ public class SnapshotGlPictureRecorder extends PictureRecorder { Matrix.translateM(mTransform, 0, scaleTranslX, scaleTranslY, 0); Matrix.scaleM(mTransform, 0, realScaleX, realScaleY, 1); - // Fix rotation: - // Not sure why we need the minus here... It makes no sense to me. - LOG.w("Recording frame. Rotation:", mResult.rotation, "Actual:", -mResult.rotation); - int rotation = -mResult.rotation; - mResult.rotation = 0; - - // Go back to 0,0 so that rotate and flip work well. + // 3. Go back to 0,0 so that rotate and flip work well. Matrix.translateM(mTransform, 0, 0.5F, 0.5F, 0); - // Apply rotation: - Matrix.rotateM(mTransform, 0, rotation, 0, 0, 1); + // 4. Apply rotation: + // Not sure why we need the minus here. + Matrix.rotateM(mTransform, 0, -mResult.rotation, 0, 0, 1); + mResult.rotation = 0; - // Flip horizontally for front camera: + // 5. Flip horizontally for front camera: if (mResult.facing == Facing.FRONT) { Matrix.scaleM(mTransform, 0, -1, 1, 1); } - // Go back to old position. + // 6. Go back to old position. Matrix.translateM(mTransform, 0, -0.5F, -0.5F, 0); - // Future note: passing scale values to the viewport? - // They are simply realScaleX and realScaleY. - viewport.drawFrame(mTextureId, mTransform); + // 7. Do pretty much the same for overlays, though with + // some differences. + if (mHasOverlay) { + // 1. First we must draw on the texture and get latest image. + try { + final Canvas surfaceCanvas = mOverlaySurface.lockCanvas(null); + surfaceCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); + mOverlay.drawOn(Overlay.Target.PICTURE_SNAPSHOT, surfaceCanvas); + mOverlaySurface.unlockCanvasAndPost(surfaceCanvas); + } catch (Surface.OutOfResourcesException e) { + LOG.w("Got Surface.OutOfResourcesException while drawing picture overlays", e); + } + mOverlaySurfaceTexture.updateTexImage(); + mOverlaySurfaceTexture.getTransformMatrix(mOverlayTransform); + + // 2. Then we can apply the transformations. + int rotation = mEngine.getAngles().offset(Reference.VIEW, Reference.OUTPUT, Axis.ABSOLUTE); + Matrix.translateM(mOverlayTransform, 0, 0.5F, 0.5F, 0); + Matrix.rotateM(mOverlayTransform, 0, rotation, 0, 0, 1); + // No need to flip the x axis for front camera, but need to flip the y axis always. + Matrix.scaleM(mOverlayTransform, 0, 1, -1, 1); + Matrix.translateM(mOverlayTransform, 0, -0.5F, -0.5F, 0); + } + + // 8. Draw and save + mViewport.drawFrame(mTextureId, mTransform); + if (mHasOverlay) mViewport.drawFrame(mOverlayTextureId, mOverlayTransform); // don't - surface.swapBuffers(); mResult.data = surface.saveFrameTo(Bitmap.CompressFormat.JPEG); mResult.format = PictureResult.FORMAT_JPEG; - mSurfaceTexture.releaseTexImage(); - // EGL14.eglMakeCurrent(oldDisplay, oldSurface, oldSurface, eglContext); + // 9. Cleanup + mSurfaceTexture.releaseTexImage(); surface.release(); - viewport.release(); + mViewport.release(); mSurfaceTexture.release(); + if (mHasOverlay) { + mOverlaySurface.release(); + mOverlaySurfaceTexture.release(); + } core.release(); dispatchResult(); } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java index a184731d..29470b7c 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/CameraPreview.java @@ -1,14 +1,21 @@ package com.otaliastudios.cameraview.preview; import android.content.Context; + +import androidx.annotation.CallSuper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.UiThread; import androidx.annotation.VisibleForTesting; +import android.os.Handler; +import android.os.Looper; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; +import com.google.android.gms.tasks.TaskCompletionSource; +import com.google.android.gms.tasks.Tasks; import com.otaliastudios.cameraview.CameraLogger; import com.otaliastudios.cameraview.engine.CameraEngine; import com.otaliastudios.cameraview.internal.utils.Op; @@ -240,7 +247,32 @@ public abstract class CameraPreview { * Called by the hosting {@link com.otaliastudios.cameraview.CameraView}, * this is a lifecycle event. */ + @CallSuper public void onDestroy() { + if (Thread.currentThread() == Looper.getMainLooper().getThread()) { + onDestroyView(); + } else { + // Do this on the UI thread and wait. + Handler ui = new Handler(Looper.getMainLooper()); + final TaskCompletionSource task = new TaskCompletionSource<>(); + ui.post(new Runnable() { + @Override + public void run() { + onDestroyView(); + task.setResult(null); + } + }); + try { Tasks.await(task.getTask()); } catch (Exception ignore) {} + } + } + + /** + * At this point we undo the work that was done during {@link #onCreateView(Context, ViewGroup)}, + * which basically means removing the root view from the hierarchy. + */ + @SuppressWarnings("WeakerAccess") + @UiThread + protected void onDestroyView() { View root = getRootView(); ViewParent parent = root.getParent(); if (parent instanceof ViewGroup) { diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java index 947114e0..54f62371 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/preview/GlCameraPreview.java @@ -5,7 +5,6 @@ import android.graphics.SurfaceTexture; import android.opengl.GLSurfaceView; import android.opengl.Matrix; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import android.view.LayoutInflater; diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/video/SnapshotVideoRecorder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/video/SnapshotVideoRecorder.java index 83eab2dd..92b43341 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/video/SnapshotVideoRecorder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/video/SnapshotVideoRecorder.java @@ -1,14 +1,19 @@ package com.otaliastudios.cameraview.video; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.PorterDuff; import android.graphics.SurfaceTexture; import android.opengl.EGL14; import android.os.Build; +import android.view.Surface; import com.otaliastudios.cameraview.CameraLogger; +import com.otaliastudios.cameraview.overlay.Overlay; import com.otaliastudios.cameraview.VideoResult; import com.otaliastudios.cameraview.controls.Audio; import com.otaliastudios.cameraview.engine.CameraEngine; -import com.otaliastudios.cameraview.engine.offset.Reference; +import com.otaliastudios.cameraview.internal.egl.EglViewport; import com.otaliastudios.cameraview.preview.GlCameraPreview; import com.otaliastudios.cameraview.preview.RendererFrameCallback; import com.otaliastudios.cameraview.preview.RendererThread; @@ -40,25 +45,33 @@ public class SnapshotVideoRecorder extends VideoRecorder implements RendererFram private static final int STATE_NOT_RECORDING = 1; private MediaEncoderEngine mEncoderEngine; - private CameraEngine mEngine; private GlCameraPreview mPreview; - private boolean mFlipped; private int mCurrentState = STATE_NOT_RECORDING; private int mDesiredState = STATE_NOT_RECORDING; private int mTextureId = 0; + private int mOverlayTextureId = 0; + private SurfaceTexture mOverlaySurfaceTexture; + private Surface mOverlaySurface; + private Overlay mOverlay; + private boolean mHasOverlay; + private int mOverlayRotation; + public SnapshotVideoRecorder(@NonNull CameraEngine engine, - @NonNull GlCameraPreview preview) { + @NonNull GlCameraPreview preview, + @Nullable Overlay overlay, + int overlayRotation) { super(engine); mPreview = preview; - mEngine = engine; + mOverlay = overlay; + mHasOverlay = overlay != null && overlay.drawsOn(Overlay.Target.VIDEO_SNAPSHOT); + mOverlayRotation = overlayRotation; } @Override protected void onStart() { mPreview.addRendererFrameCallback(this); - mFlipped = mEngine.getAngles().flip(Reference.SENSOR, Reference.VIEW); mDesiredState = STATE_RECORDING; dispatchVideoRecordingStart(); } @@ -72,6 +85,13 @@ public class SnapshotVideoRecorder extends VideoRecorder implements RendererFram @Override public void onRendererTextureCreated(int textureId) { mTextureId = textureId; + if (mHasOverlay) { + EglViewport temp = new EglViewport(); + mOverlayTextureId = temp.createTexture(); + mOverlaySurfaceTexture = new SurfaceTexture(mOverlayTextureId); + mOverlaySurfaceTexture.setDefaultBufferSize(mResult.size.getWidth(), mResult.size.getHeight()); + mOverlaySurface = new Surface(mOverlaySurfaceTexture); + } } @RendererThread @@ -104,8 +124,9 @@ public class SnapshotVideoRecorder extends VideoRecorder implements RendererFram mResult.rotation, type, mTextureId, scaleX, scaleY, - mFlipped, - EGL14.eglGetCurrentContext() + EGL14.eglGetCurrentContext(), + mHasOverlay ? mOverlayTextureId : TextureMediaEncoder.NO_TEXTURE, + mOverlayRotation ); TextureMediaEncoder videoEncoder = new TextureMediaEncoder(config); @@ -129,6 +150,21 @@ public class SnapshotVideoRecorder extends VideoRecorder implements RendererFram TextureMediaEncoder.TextureFrame textureFrame = textureEncoder.acquireFrame(); textureFrame.timestamp = surfaceTexture.getTimestamp(); surfaceTexture.getTransformMatrix(textureFrame.transform); + + // get overlay + if (mHasOverlay) { + try { + final Canvas surfaceCanvas = mOverlaySurface.lockCanvas(null); + surfaceCanvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); + mOverlay.drawOn(Overlay.Target.VIDEO_SNAPSHOT, surfaceCanvas); + mOverlaySurface.unlockCanvasAndPost(surfaceCanvas); + } catch (Surface.OutOfResourcesException e) { + LOG.w("Got Surface.OutOfResourcesException while drawing video overlays", e); + } + mOverlaySurfaceTexture.updateTexImage(); + mOverlaySurfaceTexture.getTransformMatrix(textureFrame.overlayTransform); + } + if (mEncoderEngine != null) { // can happen on teardown mEncoderEngine.notify(TextureMediaEncoder.FRAME_EVENT, textureFrame); @@ -142,28 +178,41 @@ public class SnapshotVideoRecorder extends VideoRecorder implements RendererFram mEncoderEngine = null; mPreview.removeRendererFrameCallback(SnapshotVideoRecorder.this); mPreview = null; + if (mOverlaySurfaceTexture != null) { + mOverlaySurfaceTexture.release(); + mOverlaySurfaceTexture = null; + } + if (mOverlaySurface != null) { + mOverlaySurface.release(); + mOverlaySurface = null; + } } } + @Override + public void onEncodingStart() { + // Do nothing. + } + @EncoderThread @Override - public void onEncoderStop(int stopReason, @Nullable Exception e) { + public void onEncodingEnd(int stopReason, @Nullable Exception e) { // If something failed, undo the result, since this is the mechanism // to notify Camera1Engine about this. if (e != null) { - LOG.e("Error onEncoderStop", e); + LOG.e("Error onEncodingEnd", e); mResult = null; mError = e; } else { - if (stopReason == MediaEncoderEngine.STOP_BY_MAX_DURATION) { - LOG.i("onEncoderStop because of max duration."); + if (stopReason == MediaEncoderEngine.END_BY_MAX_DURATION) { + LOG.i("onEncodingEnd because of max duration."); mResult.endReason = VideoResult.REASON_MAX_DURATION_REACHED; - } else if (stopReason == MediaEncoderEngine.STOP_BY_MAX_SIZE) { - LOG.i("onEncoderStop because of max size."); + } else if (stopReason == MediaEncoderEngine.END_BY_MAX_SIZE) { + LOG.i("onEncodingEnd because of max size."); mResult.endReason = VideoResult.REASON_MAX_SIZE_REACHED; } else { - LOG.i("onEncoderStop because of user."); + LOG.i("onEncodingEnd because of user."); } } // Cleanup diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/AudioMediaEncoder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/AudioMediaEncoder.java index c2e15d2e..5978629c 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/AudioMediaEncoder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/AudioMediaEncoder.java @@ -44,17 +44,27 @@ public class AudioMediaEncoder extends MediaEncoder { private static final int SAMPLE_SIZE = 2; // byte/sample/channel private static final int BYTE_RATE_PER_CHANNEL = SAMPLING_FREQUENCY * SAMPLE_SIZE; // byte/sec/channel private static final int BYTE_RATE = BYTE_RATE_PER_CHANNEL * CHANNELS_COUNT; // byte/sec - - static final int BIT_RATE = BYTE_RATE * 8; // bit/sec + @SuppressWarnings("unused") + private static final int BIT_RATE = BYTE_RATE * 8; // bit/sec // We call FRAME here the chunk of data that we want to read at each loop cycle private static final int FRAME_SIZE_PER_CHANNEL = 1024; // bytes/frame/channel [AAC constant] private static final int FRAME_SIZE = FRAME_SIZE_PER_CHANNEL * CHANNELS_COUNT; // bytes/frame - // We allocate buffers of 1KB each, which is not so much. I would say that allocating - // at most 200 of them is a reasonable value. With the current setup, in device tests, - // we manage to use 50 at most. - private static final int BUFFER_POOL_MAX_SIZE = 200; + // We allocate buffers of 1KB each, which is not so much. This value indicates the maximum + // number of these buffers that we can allocate at a given instant. + // This value is the number of runnables that the encoder thread is allowed to be 'behind' + // the recorder thread. It's not safe to have it very large or we can end encoding A LOT AFTER + // the actual recording. It's better to reduce this and skip recording at all. + private static final int BUFFER_POOL_MAX_SIZE = 60; + + private static long bytesToUs(int bytes) { + return (1000000L * bytes) / BYTE_RATE; + } + + private static long bytesToUs(long bytes) { + return (1000000L * bytes) / BYTE_RATE; + } private boolean mRequestStop = false; private AudioEncodingHandler mEncoder; @@ -157,7 +167,7 @@ public class AudioMediaEncoder extends MediaEncoder { while (!mRequestStop) { read(false); } - LOG.w("RECORDER: Stop was requested. We're out of the loop. Will post an endOfStream."); + LOG.w("Stop was requested. We're out of the loop. Will post an endOfStream."); // Last input with 0 length. This will signal the endOfStream. // Can't use drain(true); it is only available when writing to the codec InputSurface. read(true); @@ -169,20 +179,21 @@ public class AudioMediaEncoder extends MediaEncoder { private void read(boolean endOfStream) { mCurrentBuffer = mByteBufferPool.get(); if (mCurrentBuffer == null) { - LOG.e("Skipping audio frame, encoding is too slow."); - // TODO should fix the next presentation time here. However this is - // extremely unlikely based on my tests. The mByteBufferPool should be big enough. + LOG.e("read thread - eos:", endOfStream, "- Skipping audio frame, encoding is too slow."); + // Should fix the next presentation time here, but } else { mCurrentBuffer.clear(); mReadBytes = mAudioRecord.read(mCurrentBuffer, FRAME_SIZE); + LOG.i("read thread - eos:", endOfStream, "- Read new audio frame. Bytes:", mReadBytes); if (mReadBytes > 0) { // Good read: increase PTS. - increaseTime(mReadBytes); + mLastTimeUs = increaseTime(mReadBytes); + LOG.i("read thread - eos:", endOfStream, "- Frame PTS:", mLastTimeUs); mCurrentBuffer.limit(mReadBytes); onBuffer(endOfStream); } else if (mReadBytes == AudioRecord.ERROR_INVALID_OPERATION) { - LOG.e("Got AudioRecord.ERROR_INVALID_OPERATION"); + LOG.e("read thread - eos:", endOfStream, "- Got AudioRecord.ERROR_INVALID_OPERATION"); } else if (mReadBytes == AudioRecord.ERROR_BAD_VALUE) { - LOG.e("Got AudioRecord.ERROR_BAD_VALUE"); + LOG.e("read thread - eos:", endOfStream, "- Got AudioRecord.ERROR_BAD_VALUE"); } } } @@ -193,12 +204,12 @@ public class AudioMediaEncoder extends MediaEncoder { * to the consumer. */ private void onBuffer(boolean endOfStream) { + LOG.v("read thread - Sending buffer to encoder thread."); mEncoder.sendInputBuffer(mCurrentBuffer, mLastTimeUs, endOfStream); } - private void increaseTime(int readBytes) { - increaseTime3(readBytes); - LOG.v("Read", readBytes, "bytes, increasing PTS to", mLastTimeUs); + private long increaseTime(int readBytes) { + return increaseTime3(readBytes); } /** @@ -206,49 +217,61 @@ public class AudioMediaEncoder extends MediaEncoder { * It will use System.nanoTime() just once, as the starting point. * Of course we don't as there are things going on in this thread. */ - private void increaseTime1(int readBytes) { - mLastTimeUs += (1000000L * readBytes) / BYTE_RATE; + @SuppressWarnings("unused") + private long increaseTime1(int readBytes) { + return mLastTimeUs + bytesToUs(readBytes); } /** * Just for testing, this method will use Api 24 method to retrieve the timestamp. * This way we let the platform choose instead of making assumptions. */ + @SuppressWarnings("unused") @RequiresApi(24) - private void increaseTime2(int readBytes) { + private long increaseTime2(int readBytes) { if (mApi24Timestamp == null) { mApi24Timestamp = new AudioTimestamp(); } mAudioRecord.getTimestamp(mApi24Timestamp, AudioTimestamp.TIMEBASE_MONOTONIC); - mLastTimeUs = mApi24Timestamp.nanoTime / 1000; + return mApi24Timestamp.nanoTime / 1000; } private AudioTimestamp mApi24Timestamp; /** * This method looks like an improvement over {@link #increaseTime1(int)} as it * accounts for the current time as well. Adapted & improved. from Kickflip. + * + * This creates regular timestamps unless we accumulate a lot of delay (greater than + * twice the buffer duration), in which case it creates a gap and starts again trying + * to be regular from the new point. */ - private void increaseTime3(int readBytes) { - long currentTime = System.nanoTime() / 1000; - long correctedTime; - long bufferDuration = (1000000 * readBytes) / BYTE_RATE; - long bufferTime = currentTime - bufferDuration; // delay of acquiring the audio buffer - if (mTotalReadBytes == 0) { - mStartTimeUs = bufferTime; - } + private long increaseTime3(int readBytes) { + long bufferDurationUs = bytesToUs(readBytes); + long bufferEndTimeUs = System.nanoTime() / 1000; // now + long bufferStartTimeUs = bufferEndTimeUs - bufferDurationUs; + + // If this is the first time, the base time is the buffer start time. + if (mBytesSinceBaseTime == 0) mBaseTimeUs = bufferStartTimeUs; + // Recompute time assuming that we are respecting the sampling frequency. - // However, if the correction is too big (> 2*bufferDuration), reset to this point. - correctedTime = mStartTimeUs + (1000000 * mTotalReadBytes) / BYTE_RATE; - if(bufferTime - correctedTime >= 2 * bufferDuration) { - mStartTimeUs = bufferTime; - mTotalReadBytes = 0; - correctedTime = mStartTimeUs; + // This puts the time at the end of last read buffer, which means, where we + // should be if we had no delay / missed buffers. + long correctedTimeUs = mBaseTimeUs + bytesToUs(mBytesSinceBaseTime); + long correctionUs = bufferStartTimeUs - correctedTimeUs; + + // However, if the correction is too big (> 2*bufferDurationUs), reset to this point. + // This is triggered if we lose buffers and are recording/encoding at a slower rate. + if (correctionUs >= 2L * bufferDurationUs) { + mBaseTimeUs = bufferStartTimeUs; + mBytesSinceBaseTime = readBytes; + return mBaseTimeUs; + } else { + mBytesSinceBaseTime += readBytes; + return correctedTimeUs; } - mTotalReadBytes += readBytes; - mLastTimeUs = correctedTime; } - private long mStartTimeUs; - private long mTotalReadBytes; + private long mBaseTimeUs; + private long mBytesSinceBaseTime; } /** @@ -278,9 +301,11 @@ public class AudioMediaEncoder extends MediaEncoder { super.handleMessage(msg); boolean endOfStream = msg.what == 1; long timestamp = (((long) msg.arg1) << 32) | (((long) msg.arg2) & 0xffffffffL); + LOG.i("encoding thread - got buffer. timestamp:", timestamp, "eos:", endOfStream); ByteBuffer buffer = (ByteBuffer) msg.obj; int readBytes = buffer.remaining(); InputBuffer inputBuffer = mInputBufferPool.get(); + //noinspection ConstantConditions inputBuffer.source = buffer; inputBuffer.timestamp = timestamp; inputBuffer.length = readBytes; @@ -290,7 +315,7 @@ public class AudioMediaEncoder extends MediaEncoder { } private void performPendingOps(boolean force) { - LOG.v("Performing", mPendingOps.size(), "Pending operations."); + LOG.i("encoding thread - performing", mPendingOps.size(), "pending operations. force:", force); InputBuffer buffer; while ((buffer = mPendingOps.peek()) != null) { if (force) { @@ -305,17 +330,43 @@ public class AudioMediaEncoder extends MediaEncoder { } private void performPendingOp(InputBuffer buffer) { - buffer.data.put(buffer.source); + LOG.i("encoding thread - performing pending operation for timestamp:", buffer.timestamp, "- encoding."); + buffer.data.put(buffer.source); // TODO this copy is prob. the worst part here for performance mByteBufferPool.recycle(buffer.source); mPendingOps.remove(buffer); encodeInputBuffer(buffer); boolean eos = buffer.isEndOfStream; mInputBufferPool.recycle(buffer); - drainOutput(eos); - if (eos) { - mInputBufferPool.clear(); - WorkerHandler.get("AudioEncodingHandler").getThread().interrupt(); + if (eos) mInputBufferPool.clear(); + LOG.i("encoding thread - performing pending operation for timestamp:", buffer.timestamp, "- draining."); + // NOTE: can consider calling this drainOutput on yet another thread, which would let us + // use an even smaller BUFFER_POOL_MAX_SIZE without losing audio frames. But this way + // we can accumulate delay on this new thread without noticing (no pool getting empty). + if (true) { + drainOutput(eos); + if (eos) WorkerHandler.get("AudioEncodingHandler").getThread().interrupt(); + } else { + // Testing the option above. + WorkerHandler.get("AudioEncodingDrainer").remove(drainRunnable); + WorkerHandler.get("AudioEncodingDrainer").remove(drainRunnableEos); + WorkerHandler.get("AudioEncodingDrainer").post(eos ? drainRunnableEos : drainRunnable); } } + + private final Runnable drainRunnable = new Runnable() { + @Override + public void run() { + drainOutput(false); + } + }; + + private final Runnable drainRunnableEos = new Runnable() { + @Override + public void run() { + drainOutput(true); + WorkerHandler.get("AudioEncodingHandler").getThread().interrupt(); + WorkerHandler.get("AudioEncodingDrainer").getThread().interrupt(); + } + }; } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoder.java index 2fa7b0f0..b7881416 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoder.java @@ -88,11 +88,11 @@ abstract class MediaEncoder { * NOTE: it's important to call {@link WorkerHandler#post(Runnable)} instead of run()! */ final void start() { - LOG.i(getName(), "Start was called. Posting."); + LOG.w(getName(), "Start was called. Posting."); mWorker.post(new Runnable() { @Override public void run() { - LOG.i(getName(), "Start was called. Executing."); + LOG.w(getName(), "Start was called. Executing."); onStart(); } }); @@ -108,11 +108,11 @@ abstract class MediaEncoder { * @param data object */ final void notify(final @NonNull String event, final @Nullable Object data) { - LOG.i(getName(), "Notify was called. Posting."); + LOG.v(getName(), "Notify was called. Posting."); mWorker.post(new Runnable() { @Override public void run() { - LOG.i(getName(), "Notify was called. Executing."); + LOG.v(getName(), "Notify was called. Executing."); onEvent(event, data); } }); @@ -124,11 +124,11 @@ abstract class MediaEncoder { * NOTE: it's important to call {@link WorkerHandler#post(Runnable)} instead of run()! */ final void stop() { - LOG.i(getName(), "Stop was called. Posting."); + LOG.w(getName(), "Stop was called. Posting."); mWorker.post(new Runnable() { @Override public void run() { - LOG.i(getName(), "Stop was called. Executing."); + LOG.w(getName(), "Stop was called. Executing."); onStop(); } }); @@ -175,8 +175,9 @@ abstract class MediaEncoder { * parameters, might also be through an input buffer flag). */ private void release() { - LOG.w("Subclass", getName(), "Notified that it is released."); - mController.requestRelease(mTrackIndex); + LOG.w(getName(), "is being released. Notifying controller and releasing codecs."); + // TODO should we notify after this method? + mController.notifyReleased(mTrackIndex); mMediaCodec.stop(); mMediaCodec.release(); mMediaCodec = null; @@ -217,7 +218,7 @@ abstract class MediaEncoder { /** * Returns a new input buffer and index, waiting indefinitely if none is available. - * The buffer should be written into, then the index should be passed to {@link #encodeInputBuffer(InputBuffer)}. + * The buffer should be written into, then be passed to {@link #encodeInputBuffer(InputBuffer)}. * * @param holder the input buffer holder */ @@ -233,7 +234,7 @@ abstract class MediaEncoder { */ @SuppressWarnings("WeakerAccess") protected void encodeInputBuffer(InputBuffer buffer) { - LOG.w("ENCODING:", getName(), "Buffer:", buffer.index, "Bytes:", buffer.length, "Presentation:", buffer.timestamp); + LOG.v(getName(), "ENCODING - Buffer:", buffer.index, "Bytes:", buffer.length, "Presentation:", buffer.timestamp); if (buffer.isEndOfStream) { // send EOS mMediaCodec.queueInputBuffer(buffer.index, 0, 0, buffer.timestamp, MediaCodec.BUFFER_FLAG_END_OF_STREAM); @@ -266,7 +267,7 @@ abstract class MediaEncoder { @SuppressLint("LogNotTimber") @SuppressWarnings("WeakerAccess") protected void drainOutput(boolean drainAll) { - LOG.w("DRAINING:", getName(), "EOS:", drainAll); + LOG.v(getName(), "DRAINING - EOS:", drainAll); if (mMediaCodec == null) { LOG.e("drain() was called before prepare() or after releasing."); return; @@ -315,14 +316,12 @@ abstract class MediaEncoder { // and should be used for offsets only. // TODO find a better way, this causes sync issues. (+ note: this sends pts=0 at first) // mBufferInfo.presentationTimeUs = mLastPresentationTimeUs - mStartPresentationTimeUs; - LOG.i("DRAINING:", getName(), "Dispatching write(). Presentation:", mBufferInfo.presentationTimeUs); + LOG.v(getName(), "DRAINING - About to write(). Presentation:", mBufferInfo.presentationTimeUs); // TODO fix the mBufferInfo being the same, then implement delayed writing in Controller // and remove the isStarted() check here. OutputBuffer buffer = mOutputBufferPool.get(); - if (buffer == null) { - throw new IllegalStateException("buffer is null!"); - } + //noinspection ConstantConditions buffer.info = mBufferInfo; buffer.trackIndex = mTrackIndex; buffer.data = encodedData; @@ -336,17 +335,18 @@ abstract class MediaEncoder { && !mMaxLengthReached && mStartPresentationTimeUs != Long.MIN_VALUE && mLastPresentationTimeUs - mStartPresentationTimeUs > mMaxLengthMillis * 1000) { - LOG.w("DRAINING: Reached maxLength! mLastPresentationTimeUs:", mLastPresentationTimeUs, + LOG.w(getName(), "DRAINING - Reached maxLength! mLastPresentationTimeUs:", mLastPresentationTimeUs, "mStartPresentationTimeUs:", mStartPresentationTimeUs, "mMaxLengthUs:", mMaxLengthMillis * 1000); mMaxLengthReached = true; + LOG.w(getName(), "DRAINING - Requesting a stop."); mController.requestStop(mTrackIndex); break; } // Check for the EOS flag so we can release the encoder. if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { - LOG.w("DRAINING:", getName(), "Dispatching release()."); + LOG.w(getName(), "DRAINING - Got EOS. Releasing the codec."); release(); break; } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoderEngine.java b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoderEngine.java index 8ba29f56..cdc079c1 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoderEngine.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/MediaEncoderEngine.java @@ -26,34 +26,40 @@ public class MediaEncoderEngine { */ public interface Listener { + /** + * Called when encoding started. + */ + @EncoderThread + void onEncodingStart(); + /** * Called when encoding stopped for some reason. * If there's an exception, it failed. - * @param stopReason the reason + * @param reason the reason * @param e the error, if present */ @EncoderThread - void onEncoderStop(int stopReason, @Nullable Exception e); + void onEncodingEnd(int reason, @Nullable Exception e); } private final static String TAG = MediaEncoderEngine.class.getSimpleName(); private final static CameraLogger LOG = CameraLogger.create(TAG); @SuppressWarnings("WeakerAccess") - public final static int STOP_BY_USER = 0; - public final static int STOP_BY_MAX_DURATION = 1; - public final static int STOP_BY_MAX_SIZE = 2; + public final static int END_BY_USER = 0; + public final static int END_BY_MAX_DURATION = 1; + public final static int END_BY_MAX_SIZE = 2; private ArrayList mEncoders; private MediaMuxer mMediaMuxer; private int mStartedEncodersCount; - private int mStoppedEncodersCount; + private int mReleasedEncodersCount; private boolean mMediaMuxerStarted; @SuppressWarnings("FieldCanBeLocal") private Controller mController; private Listener mListener; - private int mStopReason = STOP_BY_USER; - private int mPossibleStopReason; + private int mEndReason = END_BY_USER; + private int mPossibleEndReason; private final Object mControllerLock = new Object(); /** @@ -87,7 +93,7 @@ public class MediaEncoderEngine { } mStartedEncodersCount = 0; mMediaMuxerStarted = false; - mStoppedEncodersCount = 0; + mReleasedEncodersCount = 0; // Trying to convert the size constraints to duration constraints, // because they are super easy to check. @@ -101,13 +107,13 @@ public class MediaEncoderEngine { long finalMaxDuration = Long.MAX_VALUE; if (maxSize > 0 && maxDuration > 0) { - mPossibleStopReason = sizeMaxDuration < maxDuration ? STOP_BY_MAX_SIZE : STOP_BY_MAX_DURATION; + mPossibleEndReason = sizeMaxDuration < maxDuration ? END_BY_MAX_SIZE : END_BY_MAX_DURATION; finalMaxDuration = Math.min(sizeMaxDuration, maxDuration); } else if (maxSize > 0) { - mPossibleStopReason = STOP_BY_MAX_SIZE; + mPossibleEndReason = END_BY_MAX_SIZE; finalMaxDuration = sizeMaxDuration; } else if (maxDuration > 0) { - mPossibleStopReason = STOP_BY_MAX_DURATION; + mPossibleEndReason = END_BY_MAX_DURATION; finalMaxDuration = maxDuration; } LOG.w("Computed a max duration of", (finalMaxDuration / 1000F)); @@ -120,6 +126,7 @@ public class MediaEncoderEngine { * Asks encoders to start (each one on its own track). */ public final void start() { + LOG.i("Passing event to encoders:", "START"); for (MediaEncoder encoder : mEncoders) { encoder.start(); } @@ -133,6 +140,7 @@ public class MediaEncoderEngine { */ @SuppressWarnings("SameParameterValue") public final void notify(final String event, final Object data) { + LOG.i("Passing event to encoders:", event); for (MediaEncoder encoder : mEncoders) { encoder.notify(event, data); } @@ -140,21 +148,23 @@ public class MediaEncoderEngine { /** * Asks encoders to stop. This is not sync, of course we will ask for encoders - * to call {@link Controller#requestRelease(int)} before actually stop the muxer. - * When all encoders request a release, {@link #release()} is called to do cleanup + * to call {@link Controller#notifyReleased(int)} before actually stop the muxer. + * When all encoders request a release, {@link #end()} is called to do cleanup * and notify the listener. */ public final void stop() { + LOG.i("Passing event to encoders:", "STOP"); for (MediaEncoder encoder : mEncoders) { encoder.stop(); } } /** - * Called after all encoders have requested a release using {@link Controller#requestRelease(int)}. + * Called after all encoders have requested a release using {@link Controller#notifyReleased(int)}. * At this point we will do cleanup and notify the listener. */ - private void release() { + private void end() { + LOG.i("end:", "Releasing muxer after all encoders have been released."); Exception error = null; if (mMediaMuxer != null) { // stop() throws an exception if you haven't fed it any data. @@ -168,14 +178,16 @@ public class MediaEncoderEngine { } mMediaMuxer = null; } + LOG.w("end:", "Dispatching end to listener - reason:", mEndReason, "error:", error); if (mListener != null) { - mListener.onEncoderStop(mStopReason, error); + mListener.onEncodingEnd(mEndReason, error); mListener = null; } - mStopReason = STOP_BY_USER; + mEndReason = END_BY_USER; mStartedEncodersCount = 0; - mStoppedEncodersCount = 0; + mReleasedEncodersCount = 0; mMediaMuxerStarted = false; + LOG.i("end:", "Completed."); } /** @@ -219,10 +231,14 @@ public class MediaEncoderEngine { throw new IllegalStateException("Trying to start but muxer started already"); } int track = mMediaMuxer.addTrack(format); - LOG.w("Controller:", "Assigned track", track, "to format", format.getString(MediaFormat.KEY_MIME)); + LOG.w("requestStart:", "Assigned track", track, "to format", format.getString(MediaFormat.KEY_MIME)); if (++mStartedEncodersCount == mEncoders.size()) { + LOG.w("requestStart:", "All encoders have started. Starting muxer and dispatching onEncodingStart()."); mMediaMuxer.start(); mMediaMuxerStarted = true; + if (mListener != null) { + mListener.onEncodingStart(); + } } return track; } @@ -251,7 +267,7 @@ public class MediaEncoderEngine { // This is a bad idea and causes crashes. // if (info.presentationTimeUs < mLastTimestampUs) info.presentationTimeUs = mLastTimestampUs; // mLastTimestampUs = info.presentationTimeUs; - LOG.v("Writing for track", buffer.trackIndex, ". Presentation:", buffer.info.presentationTimeUs); + LOG.v("write:", "Writing OutputBuffer - track:", buffer.trackIndex, "presentation:", buffer.info.presentationTimeUs); mMediaMuxer.writeSampleData(buffer.trackIndex, buffer.data, buffer.info); pool.recycle(buffer); } @@ -264,10 +280,11 @@ public class MediaEncoderEngine { * When this succeeds, {@link MediaEncoder#stop()} is called. */ void requestStop(int track) { - LOG.i("RequestStop was called for track", track); synchronized (mControllerLock) { + LOG.w("requestStop:", "Called for track", track); if (--mStartedEncodersCount == 0) { - mStopReason = mPossibleStopReason; + LOG.w("requestStop:", "All encoders have requested a stop. Stopping them."); + mEndReason = mPossibleEndReason; stop(); } } @@ -277,11 +294,12 @@ public class MediaEncoderEngine { * Notifies that the encoder was stopped. After this is called by all encoders, * we will actually stop the muxer. */ - void requestRelease(int track) { - LOG.i("requestRelease was called for track", track); + void notifyReleased(int track) { synchronized (mControllerLock) { - if (++mStoppedEncodersCount == mEncoders.size()) { - release(); + LOG.w("notifyReleased:", "Called for track", track); + if (++mReleasedEncodersCount == mEncoders.size()) { + LOG.w("requestStop:", "All encoders have been released. Stopping the muxer."); + end(); } } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/TextureMediaEncoder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/TextureMediaEncoder.java index 8d6652a4..2e9e275b 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/TextureMediaEncoder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/video/encoding/TextureMediaEncoder.java @@ -24,17 +24,24 @@ public class TextureMediaEncoder extends VideoMediaEncoder extends Med int rotation; String mimeType; - Config(int width, int height, int bitRate, int frameRate, int rotation, String mimeType) { + Config(int width, int height, int bitRate, int frameRate, int rotation, @NonNull String mimeType) { this.width = width; this.height = height; this.bitRate = bitRate; diff --git a/cameraview/src/main/res/values/attrs.xml b/cameraview/src/main/res/values/attrs.xml index e680f319..a6efb1ac 100644 --- a/cameraview/src/main/res/values/attrs.xml +++ b/cameraview/src/main/res/values/attrs.xml @@ -22,12 +22,12 @@ - - - + + + @@ -58,6 +58,17 @@ + + + + + + + + + + + @@ -83,15 +94,6 @@ - - - - - - - - - @@ -102,16 +104,14 @@ - - - - + + + + + - - - - + @@ -125,13 +125,20 @@ - - + + + + + + + + + \ No newline at end of file diff --git a/demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java b/demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java index 9b888ade..c9ce8220 100644 --- a/demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java +++ b/demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java @@ -1,16 +1,21 @@ package com.otaliastudios.cameraview.demo; +import android.animation.Animator; +import android.animation.ValueAnimator; +import android.annotation.SuppressLint; import android.content.Intent; import android.content.pm.PackageManager; import android.os.Bundle; import androidx.annotation.NonNull; import com.google.android.material.bottomsheet.BottomSheetBehavior; import androidx.appcompat.app.AppCompatActivity; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; +import android.widget.TextView; import android.widget.Toast; import com.otaliastudios.cameraview.CameraException; @@ -24,6 +29,7 @@ import com.otaliastudios.cameraview.VideoResult; import com.otaliastudios.cameraview.controls.Preview; import java.io.File; +import java.util.Arrays; import java.util.List; @@ -31,8 +37,6 @@ public class CameraActivity extends AppCompatActivity implements View.OnClickLis private CameraView camera; private ViewGroup controlPanel; - - // To show stuff in the callback private long mCaptureTime; @Override @@ -65,9 +69,41 @@ public class CameraActivity extends AppCompatActivity implements View.OnClickLis controlPanel = findViewById(R.id.controls); ViewGroup group = (ViewGroup) controlPanel.getChildAt(0); - List> options = Option.getAll(); - for (Option option : options) { - OptionView view = new OptionView(this, option, this); + final View watermark = findViewById(R.id.watermark); + + List> options = Arrays.asList( + // Layout + new Option.Width(), new Option.Height(), + // Engine and preview + new Option.Mode(), new Option.Engine(), new Option.Preview(), + // Some controls + new Option.Flash(), new Option.WhiteBalance(), new Option.Hdr(), + // Video recording + new Option.VideoCodec(), new Option.Audio(), + // Gestures + new Option.Pinch(), new Option.HorizontalScroll(), new Option.VerticalScroll(), + new Option.Tap(), new Option.LongTap(), + // Watermarks + new Option.OverlayInPreview(watermark), + new Option.OverlayInPictureSnapshot(watermark), + new Option.OverlayInVideoSnapshot(watermark), + // Other + new Option.Grid(), new Option.GridColor(), new Option.UseDeviceOrientation() + ); + List dividers = Arrays.asList( + false, true, + false, false, true, + false, false, true, + false, true, + false, false, false, false, true, + false, false, true, + false, false, true + ); + for (int i = 0; i < options.size(); i++) { + OptionView view = new OptionView(this); + //noinspection unchecked + view.setOption(options.get(i), this); + view.setHasDivider(dividers.get(i)); group.addView(view, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -80,6 +116,22 @@ public class CameraActivity extends AppCompatActivity implements View.OnClickLis b.setState(BottomSheetBehavior.STATE_HIDDEN); } }); + + // Animate the watermark just to show we record the animation in video snapshots + ValueAnimator animator = ValueAnimator.ofFloat(1F, 0.8F); + animator.setDuration(300); + animator.setRepeatCount(ValueAnimator.INFINITE); + animator.setRepeatMode(ValueAnimator.REVERSE); + animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + float scale = (float) animation.getAnimatedValue(); + watermark.setScaleX(scale); + watermark.setScaleY(scale); + watermark.setRotation(watermark.getRotation() + 2); + } + }); + animator.start(); } private void message(String content, boolean important) { diff --git a/demo/src/main/java/com/otaliastudios/cameraview/demo/Option.java b/demo/src/main/java/com/otaliastudios/cameraview/demo/Option.java index 3d4c6f73..89ba17ea 100644 --- a/demo/src/main/java/com/otaliastudios/cameraview/demo/Option.java +++ b/demo/src/main/java/com/otaliastudios/cameraview/demo/Option.java @@ -12,6 +12,8 @@ import com.otaliastudios.cameraview.CameraOptions; import com.otaliastudios.cameraview.CameraView; import com.otaliastudios.cameraview.gesture.Gesture; import com.otaliastudios.cameraview.gesture.GestureAction; +import com.otaliastudios.cameraview.overlay.Overlay; +import com.otaliastudios.cameraview.overlay.OverlayLayout; import java.util.ArrayList; import java.util.Arrays; @@ -21,51 +23,13 @@ import java.util.List; /** * Controls that we want to display in a ControlView. */ +@SuppressWarnings("WeakerAccess") public abstract class Option { - public static List> getAll() { - return Arrays.asList( - // Layout - new Width(false), - new Height(true), - - // Engine and preview - new Mode(false), - new Engine(false), - new Preview(true), - - // Some controls - new Flash(false), - new WhiteBalance(false), - new Hdr(true), - - // Video recording - new VideoCodec(false), - new Audio(true), - // TODO audio bitRate - // TODO video bitRate - // They are a bit annoying because it's not clear what the default should be. - - // Gestures - new Pinch(false), - new HorizontalScroll(false), - new VerticalScroll(false), - new Tap(false), - new LongTap(true), - - // Other - new Grid(false), - new GridColor(false), - new UseDeviceOrientation(true) - ); - } - private String name; - private boolean hasDividerBelow; - private Option(@NonNull String name, boolean hasDividerBelow) { + private Option(@NonNull String name) { this.name = name; - this.hasDividerBelow = hasDividerBelow; } @SuppressWarnings("WeakerAccess") @@ -74,11 +38,6 @@ public abstract class Option { return name; } - @SuppressWarnings("WeakerAccess") - public final boolean hasDividerBelow() { - return hasDividerBelow; - } - @NonNull public abstract T get(@NonNull CameraView view); @@ -93,8 +52,8 @@ public abstract class Option { } public static class Width extends Option { - Width(boolean hasDividerBelow) { - super("Width", hasDividerBelow); + public Width() { + super("Width"); } @NonNull @@ -135,8 +94,8 @@ public abstract class Option { } public static class Height extends Option { - Height(boolean hasDividerBelow) { - super("Height", hasDividerBelow); + public Height() { + super("Height"); } @NonNull @@ -179,8 +138,8 @@ public abstract class Option { private static abstract class ControlOption extends Option { private final Class controlClass; - ControlOption(@NonNull Class controlClass, String name, boolean hasDividerBelow) { - super(name, hasDividerBelow); + ControlOption(@NonNull Class controlClass, String name) { + super(name); this.controlClass = controlClass; } @@ -203,14 +162,14 @@ public abstract class Option { } public static class Mode extends ControlOption { - Mode(boolean hasDividerBelow) { - super(com.otaliastudios.cameraview.controls.Mode.class, "Mode", hasDividerBelow); + public Mode() { + super(com.otaliastudios.cameraview.controls.Mode.class, "Mode"); } } public static class Engine extends ControlOption { - Engine(boolean hasDividerBelow) { - super(com.otaliastudios.cameraview.controls.Engine.class, "Engine", hasDividerBelow); + public Engine() { + super(com.otaliastudios.cameraview.controls.Engine.class, "Engine"); } @Override @@ -234,8 +193,8 @@ public abstract class Option { } public static class Preview extends ControlOption { - Preview(boolean hasDividerBelow) { - super(com.otaliastudios.cameraview.controls.Preview.class, "Preview Surface", hasDividerBelow); + public Preview() { + super(com.otaliastudios.cameraview.controls.Preview.class, "Preview Surface"); } @Override @@ -276,32 +235,32 @@ public abstract class Option { } public static class Flash extends ControlOption { - Flash(boolean hasDividerBelow) { - super(com.otaliastudios.cameraview.controls.Flash.class, "Flash", hasDividerBelow); + public Flash() { + super(com.otaliastudios.cameraview.controls.Flash.class, "Flash"); } } public static class WhiteBalance extends ControlOption { - WhiteBalance(boolean hasDividerBelow) { - super(com.otaliastudios.cameraview.controls.WhiteBalance.class, "White Balance", hasDividerBelow); + public WhiteBalance() { + super(com.otaliastudios.cameraview.controls.WhiteBalance.class, "White Balance"); } } public static class Hdr extends ControlOption { - Hdr(boolean hasDividerBelow) { - super(com.otaliastudios.cameraview.controls.Hdr.class, "HDR", hasDividerBelow); + public Hdr() { + super(com.otaliastudios.cameraview.controls.Hdr.class, "HDR"); } } public static class VideoCodec extends ControlOption { - VideoCodec(boolean hasDividerBelow) { - super(com.otaliastudios.cameraview.controls.VideoCodec.class, "Video Codec", hasDividerBelow); + public VideoCodec() { + super(com.otaliastudios.cameraview.controls.VideoCodec.class, "Video Codec"); } } public static class Audio extends ControlOption { - Audio(boolean hasDividerBelow) { - super(com.otaliastudios.cameraview.controls.Audio.class, "Audio", hasDividerBelow); + public Audio() { + super(com.otaliastudios.cameraview.controls.Audio.class, "Audio"); } } @@ -309,8 +268,8 @@ public abstract class Option { private final Gesture gesture; private final GestureAction[] allActions = GestureAction.values(); - GestureOption(@NonNull Gesture gesture, String name, boolean hasDividerBelow) { - super(name, hasDividerBelow); + GestureOption(@NonNull Gesture gesture, String name) { + super(name); this.gesture = gesture; } @@ -339,45 +298,103 @@ public abstract class Option { } public static class Pinch extends GestureOption { - Pinch(boolean hasDividerBelow) { - super(Gesture.PINCH, "Pinch", hasDividerBelow); + public Pinch() { + super(Gesture.PINCH, "Pinch"); } } public static class HorizontalScroll extends GestureOption { - HorizontalScroll(boolean hasDividerBelow) { - super(Gesture.SCROLL_HORIZONTAL, "Horizontal Scroll", hasDividerBelow); + public HorizontalScroll() { + super(Gesture.SCROLL_HORIZONTAL, "Horizontal Scroll"); } } public static class VerticalScroll extends GestureOption { - VerticalScroll(boolean hasDividerBelow) { - super(Gesture.SCROLL_VERTICAL, "Vertical Scroll", hasDividerBelow); + public VerticalScroll() { + super(Gesture.SCROLL_VERTICAL, "Vertical Scroll"); } } public static class Tap extends GestureOption { - Tap(boolean hasDividerBelow) { - super(Gesture.TAP, "Tap", hasDividerBelow); + public Tap() { + super(Gesture.TAP, "Tap"); } } public static class LongTap extends GestureOption { - LongTap(boolean hasDividerBelow) { - super(Gesture.LONG_TAP, "Long Tap", hasDividerBelow); + public LongTap() { + super(Gesture.LONG_TAP, "Long Tap"); + } + } + + private static abstract class OverlayOption extends Option { + private View overlay; + private Overlay.Target target; + + OverlayOption(@NonNull Overlay.Target target, @NonNull String name, @NonNull View overlay) { + super(name); + this.overlay = overlay; + this.target = target; + } + + @NonNull + @Override + public Collection getAll(@NonNull CameraView view, @NonNull CameraOptions options) { + return Arrays.asList(true, false); + } + + @NonNull + @Override + public Boolean get(@NonNull CameraView view) { + OverlayLayout.LayoutParams params = (OverlayLayout.LayoutParams) overlay.getLayoutParams(); + switch (target) { + case PREVIEW: return params.drawOnPreview; + case PICTURE_SNAPSHOT: return params.drawOnPictureSnapshot; + case VIDEO_SNAPSHOT: return params.drawOnVideoSnapshot; + } + return false; + } + + @Override + public void set(@NonNull CameraView view, @NonNull Boolean value) { + OverlayLayout.LayoutParams params = (OverlayLayout.LayoutParams) overlay.getLayoutParams(); + switch (target) { + case PREVIEW: params.drawOnPreview = value; break; + case PICTURE_SNAPSHOT: params.drawOnPictureSnapshot = value; break; + case VIDEO_SNAPSHOT: params.drawOnVideoSnapshot = value; break; + } + overlay.setLayoutParams(params); + } + } + + public static class OverlayInPreview extends OverlayOption { + public OverlayInPreview(@NonNull View overlay) { + super(Overlay.Target.PREVIEW, "Overlay in Preview", overlay); + } + } + + public static class OverlayInPictureSnapshot extends OverlayOption { + public OverlayInPictureSnapshot(@NonNull View overlay) { + super(Overlay.Target.PICTURE_SNAPSHOT, "Overlay in Picture Snapshot", overlay); + } + } + + public static class OverlayInVideoSnapshot extends OverlayOption { + public OverlayInVideoSnapshot(@NonNull View overlay) { + super(Overlay.Target.VIDEO_SNAPSHOT, "Overlay in Video Snapshot", overlay); } } public static class Grid extends ControlOption { - Grid(boolean hasDividerBelow) { - super(com.otaliastudios.cameraview.controls.Grid.class, "Grid Lines", hasDividerBelow); + public Grid() { + super(com.otaliastudios.cameraview.controls.Grid.class, "Grid Lines"); } } public static class GridColor extends Option> { - GridColor(boolean hasDividerBelow) { - super("Grid Color", hasDividerBelow); + public GridColor() { + super("Grid Color"); } private static final List> ALL = Arrays.asList( @@ -420,8 +437,8 @@ public abstract class Option { } public static class UseDeviceOrientation extends Option { - UseDeviceOrientation(boolean hasDividerBelow) { - super("Use Device Orientation", hasDividerBelow); + public UseDeviceOrientation() { + super("Use Device Orientation"); } @NonNull diff --git a/demo/src/main/java/com/otaliastudios/cameraview/demo/OptionView.java b/demo/src/main/java/com/otaliastudios/cameraview/demo/OptionView.java index 48937343..ea4bbd04 100644 --- a/demo/src/main/java/com/otaliastudios/cameraview/demo/OptionView.java +++ b/demo/src/main/java/com/otaliastudios/cameraview/demo/OptionView.java @@ -33,23 +33,27 @@ public class OptionView extends LinearLayout implements Spinner.OnItemSel private Callback callback; private Spinner spinner; - public OptionView(Context context, Option option, Callback callback) { + public OptionView(@NonNull Context context) { super(context); - this.option = option; - this.callback = callback; setOrientation(VERTICAL); - inflate(context, R.layout.option_view, this); - TextView title = findViewById(R.id.title); - title.setText(option.getName()); - View divider = findViewById(R.id.divider); - divider.setVisibility(option.hasDividerBelow() ? View.VISIBLE : View.GONE); - ViewGroup content = findViewById(R.id.content); spinner = new Spinner(context, Spinner.MODE_DROPDOWN); content.addView(spinner); } + public void setHasDivider(boolean hasDivider) { + View divider = findViewById(R.id.divider); + divider.setVisibility(hasDivider ? View.VISIBLE : View.GONE); + } + + public void setOption(@NonNull Option option, @NonNull Callback callback) { + this.option = option; + this.callback = callback; + TextView title = findViewById(R.id.title); + title.setText(option.getName()); + } + @SuppressWarnings("all") public void onCameraOpened(CameraView view, CameraOptions options) { values = new ArrayList(option.getAll(view, options)); diff --git a/demo/src/main/res/layout/activity_camera.xml b/demo/src/main/res/layout/activity_camera.xml index 03b195b7..7168f803 100644 --- a/demo/src/main/res/layout/activity_camera.xml +++ b/demo/src/main/res/layout/activity_camera.xml @@ -17,7 +17,7 @@ android:layout_marginBottom="88dp" android:keepScreenOn="true" app:cameraExperimental="true" - app:cameraEngine="camera2" + app:cameraEngine="camera1" app:cameraPreview="glSurface" app:cameraPlaySounds="true" app:cameraGrid="off" @@ -29,8 +29,21 @@ app:cameraGestureScrollHorizontal="exposureCorrection" app:cameraGestureScrollVertical="none" app:cameraMode="picture" - app:cameraAutoFocusMarker="@string/cameraview_default_autofocus_marker"/> - + app:cameraAutoFocusMarker="@string/cameraview_default_autofocus_marker"> + + + + + + + + + + + + +``` + +As you can see, the overlay system is View-based - each overlay is just a real `View` attached +into the hierarchy. This is a powerful and creative tool. You can, for instance, retrieve the +overlay with `findViewById` and: + +- Animate it! +- Change its visibility +- Change its position or appearance +- Do so while video is being recorded + + Any changes in the overlay appearance will be recorded in real-time in the picture snapshot + or video snapshot that you are capturing. + + As you can see in the example, you can also selectively choose, for each overlay, whether it + will draw on the preview (`layout_drawOnPreview`), on picture snapshots (`layout_drawOnPreview`), + on video snapshots (`layout_drawOnPreview`). + +### Advanced Usage + +If you need to change these flags at runtime, you should cast the overlay `LayoutParams` as follows: + +```java +// Cast to OverlayLayout.LayoutParams +View overlay = findViewById(R.id.watermark); +OverlayLayout.LayoutParams params = (OverlayLayout.LayoutParams) overlay.getLayoutParams(); + +// Perform changes +params.drawOnPreview = true; // draw on preview +params.drawOnPreview = false; // do not draw on preview +params.drawOnPictureSnapshot = true; // draw on picture snapshots +params.drawOnPictureSnapshot = false; // do not draw on picture snapshots +params.drawOnVideoSnapshot = true; // draw on video snapshots +params.drawOnVideoSnapshot = false; // do not draw on video snapshots + +// When done, apply +overlay.setLayoutParams(params); +``` diff --git a/docs/index.md b/docs/index.md index 8df56286..eab56e4c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,14 +9,15 @@ CameraView is a well documented, high-level library that makes capturing picture addressing most of the common issues and needs, and still leaving you with flexibility where needed. - Fast & reliable -- Gestures support -- Camera1 or Camera2 powered engine -- Frame processing support -- OpenGL powered preview -- Take high-quality content with `takePicture` and `takeVideo` -- Take super-fast snapshots with `takePictureSnapshot` and `takeVideoSnapshot` -- Smart sizing: create a `CameraView` of any size -- Control HDR, flash, zoom, white balance, exposure, location, grid drawing & more +- Gestures support [[docs]](docs/gestures.html) +- Camera1 or Camera2 powered engine [[docs]](docs/previews.html) +- Frame processing support [[docs]](docs/frame-processing.html) +- Watermarks & animated overlays [[docs]](docs/watermarks-and-overlays.html) +- OpenGL powered preview [[docs]](docs/previews.html) +- Take high-quality content with `takePicture` and `takeVideo` [[docs]](docs/capturing-media.html) +- Take super-fast snapshots with `takePictureSnapshot` and `takeVideoSnapshot` [[docs]](docs/capturing-media.html) +- Smart sizing: create a `CameraView` of any size [[docs]](docs/preview-size.html) +- Control HDR, flash, zoom, white balance, exposure, location, grid drawing & more [[docs]](docs/controls.html) - Lightweight - Works down to API level 15 - Well tested @@ -28,7 +29,7 @@ addressing most of the common issues and needs, and still leaving you with flexi ### Get started Get started with [install info](about/install.html), [quick setup](about/getting-started.html), or -read the in-depth [documentation](docs/camera-events.html). +start reading the in-depth [documentation](docs/camera-events.html). ### Older versions