From a8a4e09900c864ccfba71903dbeca49ef349ac24 Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Fri, 11 Jan 2019 13:03:58 +0100 Subject: [PATCH 1/8] V2 bug fixes (#356) * Fix permissions error * Fix #355 * Fix #357 * Improve CameraOptions tests --- .../cameraview/CameraOptions1Test.java | 21 ++++++++++ .../cameraview/CameraViewCallbacksTest.java | 2 +- .../cameraview/CameraViewTest.java | 2 +- .../com/otaliastudios/cameraview/Camera1.java | 11 ++++- .../cameraview/CameraController.java | 9 ++++- .../cameraview/CameraOptions.java | 13 +++++- .../otaliastudios/cameraview/CameraView.java | 40 ++++++------------- docs/_posts/2018-12-20-runtime-permissions.md | 9 +++-- 8 files changed, 69 insertions(+), 38 deletions(-) diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraOptions1Test.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraOptions1Test.java index 00261196..7ab9e4da 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraOptions1Test.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraOptions1Test.java @@ -130,6 +130,27 @@ public class CameraOptions1Test extends BaseTest { } } + @Test + public void testVideoSizesNull() { + // When videoSizes is null, we take the preview sizes. + List sizes = Arrays.asList( + mockCameraSize(100, 200), + mockCameraSize(50, 50), + mockCameraSize(1600, 900), + mockCameraSize(1000, 2000) + ); + Camera.Parameters params = mock(Camera.Parameters.class); + when(params.getSupportedVideoSizes()).thenReturn(null); + when(params.getSupportedPreviewSizes()).thenReturn(sizes); + CameraOptions o = new CameraOptions(params, false); + Collection supportedSizes = o.getSupportedVideoSizes(); + assertEquals(supportedSizes.size(), sizes.size()); + for (Camera.Size size : sizes) { + Size internalSize = new Size(size.width, size.height); + assertTrue(supportedSizes.contains(internalSize)); + } + } + @Test public void testVideoSizesFlip() { List sizes = Arrays.asList( diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java index 44319ea1..f66b74fe 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewCallbacksTest.java @@ -62,7 +62,7 @@ public class CameraViewCallbacksTest extends BaseTest { } @Override - protected boolean checkPermissions(@NonNull Mode mode, @NonNull Audio audio) { + protected boolean checkPermissions(@NonNull Audio audio) { return true; } }; diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java index fcb8ae2e..aed9b4af 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java @@ -50,7 +50,7 @@ public class CameraViewTest extends BaseTest { } @Override - protected boolean checkPermissions(@NonNull Mode mode, @NonNull Audio audio) { + protected boolean checkPermissions(@NonNull Audio audio) { return hasPermissions; } }; diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java index 3e34236b..80dd92ba 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java @@ -173,7 +173,16 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera Camera.Parameters params = mCamera.getParameters(); mPreviewFormat = params.getPreviewFormat(); params.setPreviewSize(mPreviewSize.getWidth(), mPreviewSize.getHeight()); // <- not allowed during preview - params.setPictureSize(mCaptureSize.getWidth(), mCaptureSize.getHeight()); // <- allowed + if (mMode == Mode.PICTURE) { + params.setPictureSize(mCaptureSize.getWidth(), mCaptureSize.getHeight()); // <- allowed + } else { + // mCaptureSize in this case is a video size. The available video sizes are not necessarily + // a subset of the picture sizes, so we can't use the mCaptureSize value: it might crash. + // However, the setPictureSize() passed here is useless : we don't allow HQ pictures in video mode. + // While this might be lifted in the future, for now, just use a picture capture size. + Size pictureSize = computeCaptureSize(Mode.PICTURE); + params.setPictureSize(pictureSize.getWidth(), pictureSize.getHeight()); + } mCamera.setParameters(params); mCamera.setPreviewCallbackWithBuffer(null); // Release anything left diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java index f283e5af..d6063787 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java @@ -523,12 +523,17 @@ abstract class CameraController implements @NonNull @SuppressWarnings("WeakerAccess") protected final Size computeCaptureSize() { + return computeCaptureSize(mMode); + } + + @SuppressWarnings("WeakerAccess") + protected final Size computeCaptureSize(Mode mode) { // We want to pass stuff into the REF_VIEW reference, not the sensor one. // This is already managed by CameraOptions, so we just flip again at the end. boolean flip = flip(REF_SENSOR, REF_VIEW); SizeSelector selector; Collection sizes; - if (mMode == Mode.PICTURE) { + if (mode == Mode.PICTURE) { selector = mPictureSizeSelector; sizes = mCameraOptions.getSupportedPictureSizes(); } else { @@ -538,7 +543,7 @@ abstract class CameraController implements selector = SizeSelectors.or(selector, SizeSelectors.biggest()); List list = new ArrayList<>(sizes); Size result = selector.select(list).get(0); - LOG.i("computeCaptureSize:", "result:", result, "flip:", flip); + LOG.i("computeCaptureSize:", "result:", result, "flip:", flip, "mode:", mode); if (flip) result = result.flip(); // Go back to REF_SENSOR return result; } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraOptions.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraOptions.java index 8042c505..dc566960 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraOptions.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraOptions.java @@ -87,7 +87,7 @@ public class CameraOptions { exposureCorrectionSupported = params.getMinExposureCompensation() != 0 || params.getMaxExposureCompensation() != 0; - // Sizes + // Picture Sizes List sizes = params.getSupportedPictureSizes(); for (Camera.Size size : sizes) { int width = flipSizes ? size.height : size.width; @@ -95,6 +95,8 @@ public class CameraOptions { supportedPictureSizes.add(new Size(width, height)); supportedPictureAspectRatio.add(AspectRatio.of(width, height)); } + + // Video Sizes List vsizes = params.getSupportedVideoSizes(); if (vsizes != null) { for (Camera.Size size : vsizes) { @@ -103,6 +105,15 @@ public class CameraOptions { supportedVideoSizes.add(new Size(width, height)); supportedVideoAspectRatio.add(AspectRatio.of(width, height)); } + } else { + // StackOverflow threads seems to agree that if getSupportedVideoSizes is null, previews can be used. + List fallback = params.getSupportedPreviewSizes(); + for (Camera.Size size : fallback) { + int width = flipSizes ? size.height : size.width; + int height = flipSizes ? size.width : size.height; + supportedVideoSizes.add(new Size(width, height)); + supportedVideoAspectRatio.add(AspectRatio.of(width, height)); + } } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java index ea7a8429..f7c979ff 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java @@ -603,7 +603,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { if (!isEnabled()) return; if (mCameraPreview != null) mCameraPreview.onResume(); - if (checkPermissions(getMode(), getAudio())) { + if (checkPermissions(getAudio())) { // Update display orientation for current CameraController mOrientationHelper.enable(getContext()); mCameraController.setDisplayOffset(mOrientationHelper.getDisplayOffset()); @@ -613,15 +613,15 @@ public class CameraView extends FrameLayout implements LifecycleObserver { /** - * Checks that we have appropriate permissions for this session type. - * Throws if session = audio and manifest did not add the microphone permissions. - * @param mode the sessionType to be checked + * Checks that we have appropriate permissions. + * This means checking that we have audio permissions if audio = Audio.ON. * @param audio the audio setting to be checked * @return true if we can go on, false otherwise. */ + @SuppressWarnings("ConstantConditions") @SuppressLint("NewApi") - protected boolean checkPermissions(@NonNull Mode mode, @NonNull Audio audio) { - checkPermissionsManifestOrThrow(mode, audio); + protected boolean checkPermissions(@NonNull Audio audio) { + checkPermissionsManifestOrThrow(audio); // Manifest is OK at this point. Let's check runtime permissions. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true; @@ -641,12 +641,11 @@ public class CameraView extends FrameLayout implements LifecycleObserver { /** - * If mSessionType == SESSION_TYPE_VIDEO we will ask for RECORD_AUDIO permission. + * If audio is on we will ask for RECORD_AUDIO permission. * If the developer did not add this to its manifest, throw and fire warnings. - * (Hoping this is not caught elsewhere... we should test). */ - private void checkPermissionsManifestOrThrow(@NonNull Mode mode, @NonNull Audio audio) { - if (mode == Mode.VIDEO && audio == Audio.ON) { + private void checkPermissionsManifestOrThrow(@NonNull Audio audio) { + if (audio == Audio.ON) { try { PackageManager manager = getContext().getPackageManager(); PackageInfo info = manager.getPackageInfo(getContext().getPackageName(), PackageManager.GET_PERMISSIONS); @@ -655,7 +654,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { return; } } - LOG.e("Permission error:", "When the session type is set to video,", + LOG.e("Permission error:", "When audio is enabled (Audio.ON),", "the RECORD_AUDIO permission should be added to the app manifest file."); throw new IllegalStateException(CameraLogger.lastMessage); } catch (PackageManager.NameNotFoundException e) { @@ -1027,7 +1026,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { // Check did took place, or will happen on start(). mCameraController.setAudio(audio); - } else if (checkPermissions(getMode(), audio)) { + } else if (checkPermissions(audio)) { // Camera is running. Pass. mCameraController.setAudio(audio); @@ -1096,22 +1095,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { * @param mode desired session type. */ public void setMode(@NonNull Mode mode) { - - if (mode == getMode() || isClosed()) { - // Check did took place, or will happen on start(). - mCameraController.setMode(mode); - - } else if (checkPermissions(mode, getAudio())) { - // Camera is running. CameraImpl setMode will do the trick. - mCameraController.setMode(mode); - - } else { - // This means that the audio permission is being asked. - // Stop the camera so it can be restarted by the developer onPermissionResult. - // Developer must also set the session type again... - // Not ideal but good for now. - close(); - } + mCameraController.setMode(mode); } diff --git a/docs/_posts/2018-12-20-runtime-permissions.md b/docs/_posts/2018-12-20-runtime-permissions.md index 0c978a8b..0cbcd03c 100644 --- a/docs/_posts/2018-12-20-runtime-permissions.md +++ b/docs/_posts/2018-12-20-runtime-permissions.md @@ -41,8 +41,9 @@ device has cameras, and then start the camera view. On Marshmallow+, the user must explicitly approve our permissions. You can -- handle permissions yourself and then call `cameraView.start()` once they are acquired -- or call `cameraView.start()` anyway: `CameraView` will present a permission request to the user based on +- handle permissions yourself and then call `open()` or `setLifecycleOwner()` once they are acquired +- ignore this: `CameraView` will present a permission request to the user based on whether they are needed or not with the current configuration. - -In the second case, you should restart the camera if you have a successful response from `onRequestPermissionResults()`. + +Note however, that this is done at the activity level, so the permission request callback +`onRequestPermissionResults()` will be invoked on the parent activity, not the fragment. \ No newline at end of file From 894ac46bc5e92334df1abad0fa7732c00f0d862c Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Fri, 11 Jan 2019 15:04:42 +0100 Subject: [PATCH 2/8] Flip snapshots, add PictureResult.getFacing and VideoResult.getFacing (#360) * Flip snapshots, add PictureResult.getFacing and VideoResult.getFacing * Adapt codecov for v2 --- .../otaliastudios/cameraview/PictureResultTest.java | 3 +++ .../otaliastudios/cameraview/VideoResultTest.java | 4 ++++ .../java/com/otaliastudios/cameraview/Camera1.java | 4 ++++ .../com/otaliastudios/cameraview/PictureResult.java | 11 +++++++++++ .../cameraview/SnapshotPictureRecorder.java | 13 ++++++++++++- .../com/otaliastudios/cameraview/VideoResult.java | 11 +++++++++++ codecov.yml | 4 ++-- 7 files changed, 47 insertions(+), 3 deletions(-) diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/PictureResultTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/PictureResultTest.java index 950315b4..a99531a5 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/PictureResultTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/PictureResultTest.java @@ -27,12 +27,14 @@ public class PictureResultTest extends BaseTest { byte[] jpeg = new byte[]{2, 4, 1, 5, 2}; Location location = Mockito.mock(Location.class); boolean isSnapshot = true; + Facing facing = Facing.FRONT; result. format = format; result.rotation = rotation; result.size = size; result.data = jpeg; result.location = location; + result.facing = facing; //noinspection ConstantConditions result.isSnapshot = isSnapshot; @@ -43,5 +45,6 @@ public class PictureResultTest extends BaseTest { assertEquals(result.getLocation(), location); //noinspection ConstantConditions assertEquals(result.isSnapshot(), isSnapshot); + assertEquals(result.getFacing(), facing); } } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/VideoResultTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/VideoResultTest.java index ec174210..503523b1 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/VideoResultTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/VideoResultTest.java @@ -36,6 +36,7 @@ public class VideoResultTest extends BaseTest { int videoBitRate = 300000; int audioBitRate = 30000; Audio audio = Audio.ON; + Facing facing = Facing.FRONT; result.file = file; result.rotation = rotation; @@ -50,6 +51,7 @@ public class VideoResultTest extends BaseTest { result.videoBitRate = videoBitRate; result.audioBitRate = audioBitRate; result.audio = audio; + result.facing = facing; assertEquals(result.getFile(), file); assertEquals(result.getRotation(), rotation); @@ -64,5 +66,7 @@ public class VideoResultTest extends BaseTest { assertEquals(result.getVideoBitRate(), videoBitRate); assertEquals(result.getAudioBitRate(), audioBitRate); assertEquals(result.getAudio(), audio); + assertEquals(result.getFacing(), facing); + } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java index 80dd92ba..72bc6993 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java @@ -554,6 +554,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera result.location = mLocation; result.rotation = offset(REF_SENSOR, REF_OUTPUT); result.size = getPictureSize(REF_OUTPUT); + result.facing = mFacing; mPictureRecorder = new FullPictureRecorder(result, Camera1.this, mCamera); mPictureRecorder.take(); } @@ -573,6 +574,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera PictureResult result = new PictureResult(); result.location = mLocation; result.isSnapshot = true; + result.facing = mFacing; result.size = getPreviewSize(REF_OUTPUT); // Not the real size: it will be cropped to match the view ratio result.rotation = offset(REF_SENSOR, REF_OUTPUT); // Actually it will be rotated and set to 0. AspectRatio outputRatio = flip(REF_OUTPUT, REF_VIEW) ? viewAspectRatio.inverse() : viewAspectRatio; @@ -653,6 +655,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera videoResult.isSnapshot = false; videoResult.codec = mVideoCodec; videoResult.location = mLocation; + videoResult.facing = mFacing; videoResult.rotation = offset(REF_SENSOR, REF_OUTPUT); videoResult.size = flip(REF_SENSOR, REF_OUTPUT) ? mCaptureSize.flip() : mCaptureSize; videoResult.audio = mAudio; @@ -697,6 +700,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera videoResult.isSnapshot = true; videoResult.codec = mVideoCodec; videoResult.location = mLocation; + videoResult.facing = mFacing; videoResult.videoBitRate = mVideoBitRate; videoResult.audioBitRate = mAudioBitRate; videoResult.audio = mAudio; diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/PictureResult.java b/cameraview/src/main/java/com/otaliastudios/cameraview/PictureResult.java index 82a26326..282a2980 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/PictureResult.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/PictureResult.java @@ -21,6 +21,7 @@ public class PictureResult { Location location; int rotation; Size size; + Facing facing; byte[] data; int format; @@ -67,6 +68,16 @@ public class PictureResult { return size; } + /** + * Returns the facing value with which this video was recorded. + * + * @return the Facing of this video + */ + @NonNull + public Facing getFacing() { + return facing; + } + /** * Returns the raw compressed, ready to be saved to file, * in the given format. diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotPictureRecorder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotPictureRecorder.java index 8dd3e7c9..11fb97dc 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotPictureRecorder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotPictureRecorder.java @@ -113,13 +113,24 @@ class SnapshotPictureRecorder extends PictureRecorder { Matrix.translateM(mTransform, 0, scaleTranslX, scaleTranslY, 0); Matrix.scaleM(mTransform, 0, realScaleX, realScaleY, 1); - // Apply rotation: + // Fix rotation: // TODO 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. Matrix.translateM(mTransform, 0, 0.5F, 0.5F, 0); + + // Apply rotation: Matrix.rotateM(mTransform, 0, rotation, 0, 0, 1); + + // Flip horizontally for front camera: + if (mResult.facing == Facing.FRONT) { + Matrix.scaleM(mTransform, 0, -1, 1, 1); + } + + // Go back to old position. Matrix.translateM(mTransform, 0, -0.5F, -0.5F, 0); // Future note: passing scale values to the viewport? diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/VideoResult.java b/cameraview/src/main/java/com/otaliastudios/cameraview/VideoResult.java index 0c0219d8..806556b6 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/VideoResult.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/VideoResult.java @@ -25,6 +25,7 @@ public class VideoResult { int rotation; Size size; File file; + Facing facing; VideoCodec codec; Audio audio; long maxSize; @@ -87,6 +88,16 @@ public class VideoResult { return file; } + /** + * Returns the facing value with which this video was recorded. + * + * @return the Facing of this video + */ + @NonNull + public Facing getFacing() { + return facing; + } + /** * Returns the codec that was used to encode the video frames. * diff --git a/codecov.yml b/codecov.yml index 3c22eb79..2694c73f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,12 +1,12 @@ coverage: precision: 1 round: down - range: "40...100" + range: "30...70" status: project: default: - target: 45% + target: 40% patch: default: target: 70% From 68a3133282ee606bee1e3444315e19e68afc8144 Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Fri, 11 Jan 2019 15:23:16 +0100 Subject: [PATCH 3/8] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 0f788b70..5d7b8130 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ [![Build Status](https://travis-ci.org/natario1/CameraView.svg?branch=master)](https://travis-ci.org/natario1/CameraView) [![Code Coverage](https://codecov.io/gh/natario1/CameraView/branch/master/graph/badge.svg)](https://codecov.io/gh/natario1/CameraView) +[![Release](https://img.shields.io/github/release/natario1/CameraView.svg)](https://github.com/natario1/CameraView/releases) +[![Issues](https://img.shields.io/github/issues-raw/natario1/CameraView.svg)](https://github.com/natario1/CameraView/issues) +[![Funding](https://img.shields.io/opencollective/all/CameraView.svg?colorB=r)](https://natario1.github.io/CameraView/extra/donate) *This is a new major version (v2) of the library. It includes breaking changes, signature changes and new functionality. Keep reading if interested, or head to the legacy-v1 branch to read v1 documentation and info.* From 1f3a142a3da0d3fec66f99e6c8095500171b686b Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Fri, 11 Jan 2019 15:34:14 +0100 Subject: [PATCH 4/8] Bump version (#361) * Bump version * Rename v1 changelog --- CHANGELOG.md => CHANGELOG_V1.md | 2 ++ README.md | 2 +- cameraview/build.gradle | 2 +- docs/_posts/2018-12-20-changelog.md | 11 ++++++++++- docs/_posts/2018-12-20-install.md | 2 +- 5 files changed, 15 insertions(+), 4 deletions(-) rename CHANGELOG.md => CHANGELOG_V1.md (98%) diff --git a/CHANGELOG.md b/CHANGELOG_V1.md similarity index 98% rename from CHANGELOG.md rename to CHANGELOG_V1.md index aa691b89..99244fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG_V1.md @@ -1,3 +1,5 @@ +For v2 changelogs, please take a look at the [website](https://natario1.github.io/CameraView/about/changelog.html). + ## v1.6.1 This is the last release before v2. diff --git a/README.md b/README.md index 5d7b8130..566d0e9c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ 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. ```groovy -compile 'com.otaliastudios:cameraview:2.0.0-beta01' +compile 'com.otaliastudios:cameraview:2.0.0-beta02' ``` - Fast & reliable diff --git a/cameraview/build.gradle b/cameraview/build.gradle index 37eff082..aa0d5819 100644 --- a/cameraview/build.gradle +++ b/cameraview/build.gradle @@ -3,7 +3,7 @@ apply plugin: 'com.github.dcendents.android-maven' apply plugin: 'com.jfrog.bintray' // Required by bintray -version = '2.0.0-beta01' +version = '2.0.0-beta02' group = 'com.otaliastudios' //region android dependencies diff --git a/docs/_posts/2018-12-20-changelog.md b/docs/_posts/2018-12-20-changelog.md index 26a75f22..41a4495a 100644 --- a/docs/_posts/2018-12-20-changelog.md +++ b/docs/_posts/2018-12-20-changelog.md @@ -8,6 +8,15 @@ order: 3 New versions are released through GitHub, so the reference page is the [GitHub Releases](https://github.com/natario1/CameraView/releases) page. +### v2.0.0-beta02 + +- Fixed important bugs ([#356][356]) +- Picture snapshots are now flipped when front camera is used ([#360][360]) +- Added `PictureResult.getFacing()` and `VideoResult.getFacing()` ([#360][360]) + ### v2.0.0-beta01 -This is the first beta release. For changes with respect to v1, please take a look at the [migration guide](../extra/v1-migration-guide.html). \ No newline at end of file +This is the first beta release. For changes with respect to v1, please take a look at the [migration guide](../extra/v1-migration-guide.html). + +[356]: https://github.com/natario1/CameraView/pull/356 +[360]: https://github.com/natario1/CameraView/pull/360 \ No newline at end of file diff --git a/docs/_posts/2018-12-20-install.md b/docs/_posts/2018-12-20-install.md index cac23bf4..0683c4cc 100644 --- a/docs/_posts/2018-12-20-install.md +++ b/docs/_posts/2018-12-20-install.md @@ -24,7 +24,7 @@ allprojects { Then simply download the latest version: ```groovy -api 'com.otaliastudios:cameraview:2.0.0-beta01' +api 'com.otaliastudios:cameraview:2.0.0-beta02' ``` No other configuration steps are needed. \ No newline at end of file From aec17d3e492cf5923fe2c7d9e53c6a16001ebfe3 Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Tue, 15 Jan 2019 11:56:56 +0100 Subject: [PATCH 5/8] Update question.md --- .github/ISSUE_TEMPLATE/question.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index a56b5e59..91f41bc7 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -10,3 +10,6 @@ assignees: '' ### How do I? Describe your problem here. Please, read the docs first. Questions not strictly related to CameraView should be asked elsewhere. + +### Version used +CameraView exact version. From 74116144336ec382e7a9b5cf946a907f3a6d7a5b Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Sat, 23 Feb 2019 13:11:29 +0100 Subject: [PATCH 6/8] Improve snapshot recording (#374) * Timestamp changes * Revisit Audio encoding, create object pools * Use a Pool for float[] arrays * Remove unused audioBitRate from audio encoder * Fix demo app video duration * Correctly release pools * Restore output bitrate * Release textureFrame pool --- .../cameraview/AudioMediaEncoder.java | 283 +++++++++++++--- .../cameraview/ByteBufferPool.java | 15 + .../cameraview/EglBaseSurface.java | 1 + .../com/otaliastudios/cameraview/EglCore.java | 1 + .../otaliastudios/cameraview/InputBuffer.java | 12 + .../cameraview/InputBufferPool.java | 15 + .../cameraview/MediaCodecBuffers.java | 50 +++ .../cameraview/MediaEncoder.java | 311 +++++++++++++----- .../cameraview/MediaEncoderEngine.java | 233 +++++++------ .../cameraview/OutputBuffer.java | 11 + .../cameraview/OutputBufferPool.java | 18 + .../com/otaliastudios/cameraview/Pool.java | 89 +++++ .../cameraview/TextureMediaEncoder.java | 142 ++++---- .../cameraview/VideoMediaEncoder.java | 19 +- .../cameraview/SnapshotVideoRecorder.java | 25 +- .../cameraview/VideoRecorder.java | 1 + .../cameraview/WorkerHandler.java | 10 +- .../cameraview/demo/CameraActivity.java | 1 + 18 files changed, 922 insertions(+), 315 deletions(-) create mode 100644 cameraview/src/main/gles/com/otaliastudios/cameraview/ByteBufferPool.java create mode 100644 cameraview/src/main/gles/com/otaliastudios/cameraview/InputBuffer.java create mode 100644 cameraview/src/main/gles/com/otaliastudios/cameraview/InputBufferPool.java create mode 100644 cameraview/src/main/gles/com/otaliastudios/cameraview/MediaCodecBuffers.java create mode 100644 cameraview/src/main/gles/com/otaliastudios/cameraview/OutputBuffer.java create mode 100644 cameraview/src/main/gles/com/otaliastudios/cameraview/OutputBufferPool.java create mode 100644 cameraview/src/main/gles/com/otaliastudios/cameraview/Pool.java diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/AudioMediaEncoder.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/AudioMediaEncoder.java index 58cae799..32dce9c8 100644 --- a/cameraview/src/main/gles/com/otaliastudios/cameraview/AudioMediaEncoder.java +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/AudioMediaEncoder.java @@ -1,34 +1,64 @@ package com.otaliastudios.cameraview; +import android.annotation.SuppressLint; import android.media.AudioFormat; import android.media.AudioRecord; +import android.media.AudioTimestamp; import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaFormat; import android.media.MediaRecorder; import android.os.Build; +import android.os.Handler; +import android.os.Message; +import android.util.Log; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import java.io.IOException; import java.nio.ByteBuffer; +import java.util.concurrent.LinkedBlockingQueue; +// TODO create onVideoRecordingStart/onVideoRecordingEnd callbacks @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) class AudioMediaEncoder extends MediaEncoder { + private static final String TAG = AudioMediaEncoder.class.getSimpleName(); + private static final CameraLogger LOG = CameraLogger.create(TAG); + private static final String MIME_TYPE = "audio/mp4a-latm"; - private static final int SAMPLE_RATE = 44100; // 44.1[KHz] is only setting guaranteed to be available on all devices. - public static final int SAMPLES_PER_FRAME = 1024; // AAC, bytes/frame/channel - public static final int FRAMES_PER_BUFFER = 25; // AAC, frame/buffer/sec + private static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT; // Determines the SAMPLE_SIZE + private static final int CHANNELS = AudioFormat.CHANNEL_IN_MONO; // AudioFormat.CHANNEL_IN_STEREO; + + // The 44.1KHz frequency is the only setting guaranteed to be available on all devices. + private static final int SAMPLING_FREQUENCY = 44100; // samples/sec + private static final int CHANNELS_COUNT = 1; // 2; + + 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 + + // 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; - private final Object mLock = new Object(); private boolean mRequestStop = false; + private AudioEncodingHandler mEncoder; + private AudioRecordingThread mRecorder; + private ByteBufferPool mByteBufferPool; private Config mConfig; static class Config { int bitRate; - Config(int bitRate) { this.bitRate = bitRate; } @@ -38,15 +68,20 @@ class AudioMediaEncoder extends MediaEncoder { mConfig = config; } + @NonNull + @Override + String getName() { + return "AudioEncoder"; + } + @EncoderThread @Override - void prepare(@NonNull MediaEncoderEngine.Controller controller, long maxLengthMillis) { - super.prepare(controller, maxLengthMillis); - final MediaFormat audioFormat = MediaFormat.createAudioFormat(MIME_TYPE, SAMPLE_RATE, 1); + void onPrepare(@NonNull MediaEncoderEngine.Controller controller, long maxLengthMillis) { + final MediaFormat audioFormat = MediaFormat.createAudioFormat(MIME_TYPE, SAMPLING_FREQUENCY, CHANNELS_COUNT); audioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); - audioFormat.setInteger(MediaFormat.KEY_CHANNEL_MASK, AudioFormat.CHANNEL_IN_MONO); + audioFormat.setInteger(MediaFormat.KEY_CHANNEL_MASK, CHANNELS); audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, mConfig.bitRate); - audioFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 1); + audioFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS_COUNT); try { mMediaCodec = MediaCodec.createEncoderByType(MIME_TYPE); } catch (IOException e) { @@ -54,86 +89,228 @@ class AudioMediaEncoder extends MediaEncoder { } mMediaCodec.configure(audioFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); mMediaCodec.start(); + mByteBufferPool = new ByteBufferPool(FRAME_SIZE, BUFFER_POOL_MAX_SIZE); + mEncoder = new AudioEncodingHandler(); + mRecorder = new AudioRecordingThread(); } @EncoderThread @Override - void start() { + void onStart() { mRequestStop = false; - new AudioThread().start(); + mRecorder.start(); } @EncoderThread @Override - void notify(@NonNull String event, @Nullable Object data) { } + void onEvent(@NonNull String event, @Nullable Object data) { } @EncoderThread @Override - void stop() { + void onStop() { mRequestStop = true; - synchronized (mLock) { - try { - mLock.wait(); - } catch (InterruptedException e) { - // do nothing - } - } } @Override - void release() { - super.release(); + void onRelease() { mRequestStop = false; + mEncoder = null; + mRecorder = null; + if (mByteBufferPool != null) { + mByteBufferPool.clear(); + mByteBufferPool = null; + } } - class AudioThread extends Thread { + @Override + int getEncodedBitRate() { + return mConfig.bitRate; + } + + class AudioRecordingThread extends Thread { private AudioRecord mAudioRecord; + private ByteBuffer mCurrentBuffer; + private int mReadBytes; + private long mLastTimeUs; - AudioThread() { - final int minBufferSize = AudioRecord.getMinBufferSize( - SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT); - int bufferSize = SAMPLES_PER_FRAME * FRAMES_PER_BUFFER; - if (bufferSize < minBufferSize) { - bufferSize = ((minBufferSize / SAMPLES_PER_FRAME) + 1) * SAMPLES_PER_FRAME * 2; + AudioRecordingThread() { + final int minBufferSize = AudioRecord.getMinBufferSize(SAMPLING_FREQUENCY, CHANNELS, ENCODING); + int bufferSize = FRAME_SIZE * 25; // Make this bigger so we don't skip frames. + while (bufferSize < minBufferSize) { + bufferSize += FRAME_SIZE; // Unlikely I think. } - mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.CAMCORDER, SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize); + mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.CAMCORDER, + SAMPLING_FREQUENCY, CHANNELS, ENCODING, bufferSize); + setPriority(Thread.MAX_PRIORITY); } @Override public void run() { - super.run(); + mLastTimeUs = System.nanoTime() / 1000L; mAudioRecord.startRecording(); - final ByteBuffer buffer = ByteBuffer.allocateDirect(SAMPLES_PER_FRAME); - int readBytes; while (!mRequestStop) { - buffer.clear(); - readBytes = mAudioRecord.read(buffer, SAMPLES_PER_FRAME); - if (readBytes > 0) { - // set audio data to encoder - buffer.position(readBytes); - buffer.flip(); - encode(buffer, readBytes, getPresentationTime()); - drain(false); - } + read(false); } - // This will signal the endOfStream. + LOG.w("RECORDER: 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. - encode(null, 0, getPresentationTime()); - drain(false); + read(true); mAudioRecord.stop(); mAudioRecord.release(); mAudioRecord = null; - synchronized (mLock) { - mLock.notify(); + } + + 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. + } else { + mCurrentBuffer.clear(); + mReadBytes = mAudioRecord.read(mCurrentBuffer, FRAME_SIZE); + if (mReadBytes > 0) { // Good read: increase PTS. + increaseTime(mReadBytes); + mCurrentBuffer.limit(mReadBytes); + onBuffer(endOfStream); + } else if (mReadBytes == AudioRecord.ERROR_INVALID_OPERATION) { + LOG.e("Got AudioRecord.ERROR_INVALID_OPERATION"); + } else if (mReadBytes == AudioRecord.ERROR_BAD_VALUE) { + LOG.e("Got AudioRecord.ERROR_BAD_VALUE"); + } + } + } + + /** + * New data at position buffer.position() of size buffer.remaining() + * has been written into this buffer. This method should pass the data + * to the consumer. + */ + private void onBuffer(boolean endOfStream) { + mEncoder.sendInputBuffer(mCurrentBuffer, mLastTimeUs, endOfStream); + } + + private void increaseTime(int readBytes) { + increaseTime3(readBytes); + LOG.v("Read", readBytes, "bytes, increasing PTS to", mLastTimeUs); + } + + /** + * This method simply assumes that we read everything without losing a single US. + * 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; + } + + /** + * 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. + */ + @RequiresApi(24) + private void increaseTime2(int readBytes) { + if (mApi24Timestamp == null) { + mApi24Timestamp = new AudioTimestamp(); } + mAudioRecord.getTimestamp(mApi24Timestamp, AudioTimestamp.TIMEBASE_MONOTONIC); + mLastTimeUs = 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. + */ + 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; + } + // 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; + } + mTotalReadBytes += readBytes; + mLastTimeUs = correctedTime; + } + private long mStartTimeUs; + private long mTotalReadBytes; } - @Override - int getBitRate() { - return mConfig.bitRate; + /** + * This will be a super busy thread. It's important for it to be: + * - different than the recording thread: or we would miss a lot of audio + * - different than the 'encoder' thread: we want that to be reactive. + * For example, a stop() must become onStop() soon, can't wait for all this draining. + */ + @SuppressLint("HandlerLeak") + class AudioEncodingHandler extends Handler { + + InputBufferPool mInputBufferPool = new InputBufferPool(); + LinkedBlockingQueue mPendingOps = new LinkedBlockingQueue<>(); + + AudioEncodingHandler() { + super(WorkerHandler.get("AudioEncodingHandler").getLooper()); + } + + void sendInputBuffer(ByteBuffer buffer, long presentationTimeUs, boolean endOfStream) { + int presentation1 = (int) (presentationTimeUs >> 32); + int presentation2 = (int) (presentationTimeUs); + sendMessage(obtainMessage(endOfStream ? 1 : 0, presentation1, presentation2, buffer)); + } + + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + boolean endOfStream = msg.what == 1; + long timestamp = (((long) msg.arg1) << 32) | (((long) msg.arg2) & 0xffffffffL); + ByteBuffer buffer = (ByteBuffer) msg.obj; + int readBytes = buffer.remaining(); + InputBuffer inputBuffer = mInputBufferPool.get(); + inputBuffer.source = buffer; + inputBuffer.timestamp = timestamp; + inputBuffer.length = readBytes; + inputBuffer.isEndOfStream = endOfStream; + mPendingOps.add(inputBuffer); + performPendingOps(endOfStream); + } + + private void performPendingOps(boolean force) { + LOG.v("Performing", mPendingOps.size(), "Pending operations."); + InputBuffer buffer; + while ((buffer = mPendingOps.peek()) != null) { + if (force) { + acquireInputBuffer(buffer); + performPendingOp(buffer); + } else if (tryAcquireInputBuffer(buffer)) { + performPendingOp(buffer); + } else { + break; // Will try later. + } + } + } + + private void performPendingOp(InputBuffer buffer) { + buffer.data.put(buffer.source); + 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(); + } + } } } diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/ByteBufferPool.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/ByteBufferPool.java new file mode 100644 index 00000000..8e2a2aac --- /dev/null +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/ByteBufferPool.java @@ -0,0 +1,15 @@ +package com.otaliastudios.cameraview; + +import java.nio.ByteBuffer; + +class ByteBufferPool extends Pool { + + ByteBufferPool(final int bufferSize, int maxPoolSize) { + super(maxPoolSize, new Factory() { + @Override + public ByteBuffer create() { + return ByteBuffer.allocateDirect(bufferSize); + } + }); + } +} diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/EglBaseSurface.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/EglBaseSurface.java index ea842618..4e3393e2 100644 --- a/cameraview/src/main/gles/com/otaliastudios/cameraview/EglBaseSurface.java +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/EglBaseSurface.java @@ -151,6 +151,7 @@ class EglBaseSurface extends EglElement { /** * Sends the presentation time stamp to EGL. + * https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_presentation_time.txt * * @param nsecs Timestamp, in nanoseconds. */ diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/EglCore.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/EglCore.java index e0691b19..2483978d 100644 --- a/cameraview/src/main/gles/com/otaliastudios/cameraview/EglCore.java +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/EglCore.java @@ -314,6 +314,7 @@ final class EglCore { /** * Sends the presentation time stamp to EGL. Time is expressed in nanoseconds. + * https://www.khronos.org/registry/EGL/extensions/ANDROID/EGL_ANDROID_presentation_time.txt */ public void setPresentationTime(EGLSurface eglSurface, long nsecs) { EGLExt.eglPresentationTimeANDROID(mEGLDisplay, eglSurface, nsecs); diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/InputBuffer.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/InputBuffer.java new file mode 100644 index 00000000..9cc1369d --- /dev/null +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/InputBuffer.java @@ -0,0 +1,12 @@ +package com.otaliastudios.cameraview; + +import java.nio.ByteBuffer; + +class InputBuffer { + ByteBuffer data; + ByteBuffer source; + int index; + int length; + long timestamp; + boolean isEndOfStream; +} diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/InputBufferPool.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/InputBufferPool.java new file mode 100644 index 00000000..c99d0780 --- /dev/null +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/InputBufferPool.java @@ -0,0 +1,15 @@ +package com.otaliastudios.cameraview; + +import java.nio.ByteBuffer; + +class InputBufferPool extends Pool { + + InputBufferPool() { + super(Integer.MAX_VALUE, new Factory() { + @Override + public InputBuffer create() { + return new InputBuffer(); + } + }); + } +} diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaCodecBuffers.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaCodecBuffers.java new file mode 100644 index 00000000..4f134b08 --- /dev/null +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaCodecBuffers.java @@ -0,0 +1,50 @@ +package com.otaliastudios.cameraview; + +import android.media.MediaCodec; +import android.os.Build; + +import java.nio.ByteBuffer; + +/** + * A Wrapper to MediaCodec that facilitates the use of API-dependent get{Input/Output}Buffer methods, + * in order to prevent: http://stackoverflow.com/q/30646885 + */ +class MediaCodecBuffers { + + private final MediaCodec mMediaCodec; + private final ByteBuffer[] mInputBuffers; + private ByteBuffer[] mOutputBuffers; + + MediaCodecBuffers(MediaCodec mediaCodec) { + mMediaCodec = mediaCodec; + + if (Build.VERSION.SDK_INT < 21) { + mInputBuffers = mediaCodec.getInputBuffers(); + mOutputBuffers = mediaCodec.getOutputBuffers(); + } else { + mInputBuffers = mOutputBuffers = null; + } + } + + public ByteBuffer getInputBuffer(final int index) { + if (Build.VERSION.SDK_INT >= 21) { + return mMediaCodec.getInputBuffer(index); + } + ByteBuffer buffer = mInputBuffers[index]; + buffer.clear(); + return buffer; + } + + public ByteBuffer getOutputBuffer(final int index) { + if (Build.VERSION.SDK_INT >= 21) { + return mMediaCodec.getOutputBuffer(index); + } + return mOutputBuffers[index]; + } + + public void onOutputBuffersChanged() { + if (Build.VERSION.SDK_INT < 21) { + mOutputBuffers = mMediaCodec.getOutputBuffers(); + } + } +} diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaEncoder.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaEncoder.java index b1b0e8bc..e82e8c1e 100644 --- a/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaEncoder.java +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaEncoder.java @@ -1,8 +1,10 @@ package com.otaliastudios.cameraview; +import android.annotation.SuppressLint; import android.media.MediaCodec; import android.media.MediaFormat; import android.os.Build; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -14,17 +16,107 @@ import java.nio.ByteBuffer; @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) abstract class MediaEncoder { - private final static int TIMEOUT_USEC = 10000; // 10 msec + private final static String TAG = MediaEncoder.class.getSimpleName(); + private final static CameraLogger LOG = CameraLogger.create(TAG); + + // Did some test to see which value would maximize our performance in the current setup (infinite audio pool). + // Measured the time it would take to write a 30 seconds video. Based on this, we'll go with TIMEOUT=0 for now. + // INPUT_TIMEOUT_US 10000: 46 seconds + // INPUT_TIMEOUT_US 1000: 37 seconds + // INPUT_TIMEOUT_US 100: 33 seconds + // INPUT_TIMEOUT_US 0: 32 seconds + private final static int INPUT_TIMEOUT_US = 0; + + // 0 also seems to be the best, although it does not change so much. + // Can't go too high or this is a bottleneck for the audio encoder. + private final static int OUTPUT_TIMEOUT_US = 0; @SuppressWarnings("WeakerAccess") protected MediaCodec mMediaCodec; - private MediaCodec.BufferInfo mBufferInfo; + @SuppressWarnings("WeakerAccess") + protected WorkerHandler mWorker; + private MediaEncoderEngine.Controller mController; private int mTrackIndex; + private OutputBufferPool mOutputBufferPool; + private MediaCodec.BufferInfo mBufferInfo; + private MediaCodecBuffers mBuffers; private long mMaxLengthMillis; private boolean mMaxLengthReached; + /** + * A readable name for the thread. + */ + @NonNull + abstract String getName(); + + /** + * This encoder was attached to the engine. Keep the controller + * and run the internal thread. + */ + final void prepare(@NonNull final MediaEncoderEngine.Controller controller, final long maxLengthMillis) { + mController = controller; + mBufferInfo = new MediaCodec.BufferInfo(); + mMaxLengthMillis = maxLengthMillis; + mWorker = WorkerHandler.get(getName()); + LOG.i(getName(), "Prepare was called. Posting."); + mWorker.post(new Runnable() { + @Override + public void run() { + LOG.i(getName(), "Prepare was called. Executing."); + onPrepare(controller, maxLengthMillis); + } + }); + } + + /** + * Start recording. This might be a lightweight operation + * in case the encoder needs to wait for a certain event + * like a "frame available". + */ + final void start() { + LOG.i(getName(), "Start was called. Posting."); + mWorker.post(new Runnable() { + @Override + public void run() { + LOG.i(getName(), "Start was called. Executing."); + onStart(); + } + }); + } + + /** + * The caller notifying of a certain event occurring. + * Should analyze the string and see if the event is important. + * @param event what happened + * @param data object + */ + final void notify(final @NonNull String event, final @Nullable Object data) { + LOG.i(getName(), "Notify was called. Posting."); + mWorker.post(new Runnable() { + @Override + public void run() { + LOG.i(getName(), "Notify was called. Executing."); + onEvent(event, data); + } + }); + } + + /** + * Stop recording. + */ + final void stop() { + LOG.i(getName(), "Stop was called. Posting."); + mWorker.post(new Runnable() { + @Override + public void run() { + LOG.i(getName(), "Stop was called. Executing."); + onStop(); + } + }); + } + /** * Called to prepare this encoder before starting. * Any initialization should be done here as it does not interfere with the original @@ -33,13 +125,10 @@ abstract class MediaEncoder { * At this point subclasses MUST create the {@link #mMediaCodec} object. * * @param controller the muxer controller + * @param maxLengthMillis the maxLength in millis */ @EncoderThread - void prepare(@NonNull MediaEncoderEngine.Controller controller, long maxLengthMillis) { - mController = controller; - mBufferInfo = new MediaCodec.BufferInfo(); - mMaxLengthMillis = maxLengthMillis; - } + abstract void onPrepare(@NonNull final MediaEncoderEngine.Controller controller, final long maxLengthMillis); /** * Start recording. This might be a lightweight operation @@ -47,7 +136,7 @@ abstract class MediaEncoder { * like a "frame available". */ @EncoderThread - abstract void start(); + abstract void onStart(); /** * The caller notifying of a certain event occurring. @@ -56,97 +145,130 @@ abstract class MediaEncoder { * @param data object */ @EncoderThread - abstract void notify(@NonNull String event, @Nullable Object data); + abstract void onEvent(@NonNull String event, @Nullable Object data); /** * Stop recording. - * This MUST happen SYNCHRONOUSLY! */ @EncoderThread - abstract void stop(); + abstract void onStop(); /** - * Release resources here. + * Called by {@link #drainOutput(boolean)} when we get an EOS signal (not necessarily in the + * parameters, might also be through an input buffer flag). */ - @EncoderThread - void release() { - if (mMediaCodec != null) { - mMediaCodec.stop(); - mMediaCodec.release(); - mMediaCodec = null; + private void release() { + LOG.w("Subclass", getName(), "Notified that it is released."); + mController.requestRelease(mTrackIndex); + mMediaCodec.stop(); + mMediaCodec.release(); + mMediaCodec = null; + mOutputBufferPool.clear(); + mOutputBufferPool = null; + mBuffers = null; + onRelease(); + } + + /** + * This is called when we are stopped. + * It is a good moment to release all resources, although the muxer + * might still be alive (we wait for the other Encoder, see Controller). + */ + abstract void onRelease(); + + /** + * Returns a new input buffer and index, waiting at most {@link #INPUT_TIMEOUT_US} if none is available. + * Callers should check the boolean result - true if the buffer was filled. + */ + @SuppressWarnings("WeakerAccess") + protected boolean tryAcquireInputBuffer(@NonNull InputBuffer holder) { + if (mBuffers == null) { + mBuffers = new MediaCodecBuffers(mMediaCodec); + } + int inputBufferIndex = mMediaCodec.dequeueInputBuffer(INPUT_TIMEOUT_US); + if (inputBufferIndex < 0) { + return false; + } else { + holder.index = inputBufferIndex; + holder.data = mBuffers.getInputBuffer(inputBufferIndex); + return true; } } + /** + * 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)}. + */ + @SuppressWarnings({"StatementWithEmptyBody", "WeakerAccess"}) + protected void acquireInputBuffer(@NonNull InputBuffer holder) { + while (!tryAcquireInputBuffer(holder)) {} + } + /** * Encode data into the {@link #mMediaCodec}. */ @SuppressWarnings("WeakerAccess") - protected void encode(@Nullable final ByteBuffer buffer, final int length, final long presentationTimeUs) { - final ByteBuffer[] inputBuffers = mMediaCodec.getInputBuffers(); - while (true) { - final int inputBufferIndex = mMediaCodec.dequeueInputBuffer(TIMEOUT_USEC); - if (inputBufferIndex >= 0) { - final ByteBuffer inputBuffer = inputBuffers[inputBufferIndex]; - inputBuffer.clear(); - if (buffer != null) { - inputBuffer.put(buffer); - } - if (length <= 0) { // send EOS - mMediaCodec.queueInputBuffer(inputBufferIndex, 0, 0, - presentationTimeUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM); - } else { - mMediaCodec.queueInputBuffer(inputBufferIndex, 0, length, - presentationTimeUs, 0); - } - break; - } else if (inputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { - // wait for MediaCodec encoder is ready to encode - // nothing to do here because MediaCodec#dequeueInputBuffer(TIMEOUT_USEC) - // will wait for maximum TIMEOUT_USEC(10msec) on each call - } + protected void encodeInputBuffer(InputBuffer buffer) { + LOG.w("ENCODING:", getName(), "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); + } else { + mMediaCodec.queueInputBuffer(buffer.index, 0, buffer.length, + buffer.timestamp, 0); } } + /** + * Signals the end of input stream. This is a Video only API, as in the normal case, + * we use input buffers to signal the end. In the video case, we don't have input buffers + * because we use an input surface instead. + */ + @SuppressWarnings("WeakerAccess") + protected void signalEndOfInputStream() { + mMediaCodec.signalEndOfInputStream(); + } + /** * Extracts all pending data that was written and encoded into {@link #mMediaCodec}, * and forwards it to the muxer. - *

- * If endOfStream is not set, this returns when there is no more data to drain. If it - * is set, we send EOS to the encoder, and then iterate until we see EOS on the output. - * Calling this with endOfStream set should be done once, right before stopping the muxer. + * + * If drainAll is not set, this returns after TIMEOUT_USEC if there is no more data to drain. + * If drainAll is set, we wait until we see EOS on the output. + * Calling this with drainAll set should be done once, right before stopping the muxer. */ + @SuppressLint("LogNotTimber") @SuppressWarnings("WeakerAccess") - protected void drain(boolean endOfStream) { - if (endOfStream) { - mMediaCodec.signalEndOfInputStream(); + protected void drainOutput(boolean drainAll) { + LOG.w("DRAINING:", getName(), "EOS:", drainAll); + if (mMediaCodec == null) { + LOG.e("drain() was called before prepare() or after releasing."); + return; + } + if (mBuffers == null) { + mBuffers = new MediaCodecBuffers(mMediaCodec); } - - ByteBuffer[] encoderOutputBuffers = mMediaCodec.getOutputBuffers(); while (true) { - int encoderStatus = mMediaCodec.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC); + int encoderStatus = mMediaCodec.dequeueOutputBuffer(mBufferInfo, OUTPUT_TIMEOUT_US); if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) { // no output available yet - if (!endOfStream) break; // out of while + if (!drainAll) break; // out of while } else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { // not expected for an encoder - encoderOutputBuffers = mMediaCodec.getOutputBuffers(); + mBuffers.onOutputBuffersChanged(); } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { // should happen before receiving buffers, and should only happen once - if (mController.isStarted()) throw new RuntimeException("format changed twice"); + if (mController.isStarted()) throw new RuntimeException("MediaFormat changed twice."); MediaFormat newFormat = mMediaCodec.getOutputFormat(); - - // now that we have the Magic Goodies, start the muxer - mTrackIndex = mController.start(newFormat); + mTrackIndex = mController.requestStart(newFormat); + mOutputBufferPool = new OutputBufferPool(mTrackIndex); } else if (encoderStatus < 0) { - Log.w("VideoMediaEncoder", "unexpected result from encoder.dequeueOutputBuffer: " + encoderStatus); + LOG.e("Unexpected result from dequeueOutputBuffer: " + encoderStatus); // let's ignore it } else { - ByteBuffer encodedData = encoderOutputBuffers[encoderStatus]; - if (encodedData == null) { - throw new RuntimeException("encoderOutputBuffer " + encoderStatus + " was null"); - } + ByteBuffer encodedData = mBuffers.getOutputBuffer(encoderStatus); // Codec config means that config data was pulled out and fed to the muxer when we got // the INFO_OUTPUT_FORMAT_CHANGED status. Ignore it. @@ -155,41 +277,56 @@ abstract class MediaEncoder { // adjust the ByteBuffer values to match BufferInfo (not needed?) encodedData.position(mBufferInfo.offset); encodedData.limit(mBufferInfo.offset + mBufferInfo.size); - mController.write(mTrackIndex, encodedData, mBufferInfo); - mLastPresentationTime = mBufferInfo.presentationTimeUs; - if (mStartPresentationTime == 0) { - mStartPresentationTime = mLastPresentationTime; + // Store startPresentationTime and lastPresentationTime, useful for example to + // detect the mMaxLengthReached and stop recording. + if (mStartPresentationTimeUs == Long.MIN_VALUE) { + mStartPresentationTimeUs = mBufferInfo.presentationTimeUs; } + mLastPresentationTimeUs = mBufferInfo.presentationTimeUs; + // Pass presentation times as offets with respect to the mStartPresentationTimeUs. + // This ensures consistency between audio pts (coming from System.nanoTime()) and + // video pts (coming from SurfaceTexture) both of which have no meaningful time-base + // 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); + + // TODO fix the mBufferInfo being the same, then implement delayed writing in Controller + // and remove the isStarted() check here. + OutputBuffer buffer = mOutputBufferPool.get(); + buffer.info = mBufferInfo; + buffer.trackIndex = mTrackIndex; + buffer.data = encodedData; + mController.write(mOutputBufferPool, buffer); } mMediaCodec.releaseOutputBuffer(encoderStatus, false); - if (!mMaxLengthReached) { - if (mLastPresentationTime / 1000 - mStartPresentationTime / 1000 > mMaxLengthMillis) { - mMaxLengthReached = true; - // Log.e("MediaEncoder", this.getClass().getSimpleName() + " requested stop at " + (mLastPresentationTime * 1000 * 1000)); - mController.requestStop(); - break; - } + + // Check for the maxLength constraint (with appropriate conditions) + // Not needed if drainAll because we already were asked to stop + if (!drainAll + && !mMaxLengthReached + && mStartPresentationTimeUs != Long.MIN_VALUE + && mLastPresentationTimeUs - mStartPresentationTimeUs > mMaxLengthMillis * 1000) { + LOG.w("DRAINING: Reached maxLength! mLastPresentationTimeUs:", mLastPresentationTimeUs, + "mStartPresentationTimeUs:", mStartPresentationTimeUs, + "mMaxLengthUs:", mMaxLengthMillis * 1000); + mMaxLengthReached = true; + 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) { - break; // out of while + LOG.w("DRAINING:", getName(), "Dispatching release()."); + release(); + break; } } } } - private long mStartPresentationTime = 0; - private long mLastPresentationTime = 0; - - long getPresentationTime() { - long result = System.nanoTime() / 1000L; - // presentationTimeUs should be monotonic - // otherwise muxer fail to write - if (result < mLastPresentationTime) { - result = (mLastPresentationTime - result) + result; - } - return result; - } + private long mStartPresentationTimeUs = Long.MIN_VALUE; + private long mLastPresentationTimeUs = 0; - abstract int getBitRate(); + abstract int getEncodedBitRate(); } diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaEncoderEngine.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaEncoderEngine.java index efb97e4c..0b9fa771 100644 --- a/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaEncoderEngine.java +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/MediaEncoderEngine.java @@ -1,6 +1,5 @@ package com.otaliastudios.cameraview; -import android.media.MediaCodec; import android.media.MediaFormat; import android.media.MediaMuxer; import android.os.Build; @@ -10,13 +9,12 @@ import androidx.annotation.RequiresApi; import java.io.File; import java.io.IOException; -import java.nio.ByteBuffer; import java.util.ArrayList; @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) class MediaEncoderEngine { - private final static String TAG = MediaEncoder.class.getSimpleName(); + private final static String TAG = MediaEncoderEngine.class.getSimpleName(); private final static CameraLogger LOG = CameraLogger.create(TAG); @SuppressWarnings("WeakerAccess") @@ -24,20 +22,19 @@ class MediaEncoderEngine { final static int STOP_BY_MAX_DURATION = 1; final static int STOP_BY_MAX_SIZE = 2; - private WorkerHandler mWorker; private ArrayList mEncoders; private MediaMuxer mMediaMuxer; - private int mMediaMuxerStartCount; + private int mStartedEncodersCount; + private int mStoppedEncodersCount; private boolean mMediaMuxerStarted; private Controller mController; private Listener mListener; private int mStopReason = STOP_BY_USER; private int mPossibleStopReason; - private final Object mLock = new Object(); + private final Object mControllerLock = new Object(); MediaEncoderEngine(@NonNull File file, @NonNull VideoMediaEncoder videoEncoder, @Nullable AudioMediaEncoder audioEncoder, final int maxDuration, final long maxSize, @Nullable Listener listener) { - mWorker = WorkerHandler.get("EncoderEngine"); mListener = listener; mController = new Controller(); mEncoders = new ArrayList<>(); @@ -50,51 +47,54 @@ class MediaEncoderEngine { } catch (IOException e) { throw new RuntimeException(e); } - mMediaMuxerStartCount = 0; + mStartedEncodersCount = 0; mMediaMuxerStarted = false; - mWorker.post(new Runnable() { - @Override - public void run() { - // Trying to convert the size constraints to duration constraints, - // because they are super easy to check. - // This is really naive & probably not accurate, but... - int bitRate = 0; - for (MediaEncoder encoder : mEncoders) { - bitRate += encoder.getBitRate(); - } - int bytePerSecond = bitRate / 8; - long sizeMaxDuration = (maxSize / bytePerSecond) * 1000L; - - long finalMaxDuration = Long.MAX_VALUE; - if (maxSize > 0 && maxDuration > 0) { - mPossibleStopReason = sizeMaxDuration < maxDuration ? STOP_BY_MAX_SIZE : STOP_BY_MAX_DURATION; - finalMaxDuration = Math.min(sizeMaxDuration, maxDuration); - } else if (maxSize > 0) { - mPossibleStopReason = STOP_BY_MAX_SIZE; - finalMaxDuration = sizeMaxDuration; - } else if (maxDuration > 0) { - mPossibleStopReason = STOP_BY_MAX_DURATION; - finalMaxDuration = maxDuration; - } - LOG.i("Computed a max duration of", (finalMaxDuration / 1000F)); - for (MediaEncoder encoder : mEncoders) { - encoder.prepare(mController, finalMaxDuration); - } - } - }); + mStoppedEncodersCount = 0; + + // Trying to convert the size constraints to duration constraints, + // because they are super easy to check. + // This is really naive & probably not accurate, but... + int bitRate = 0; + for (MediaEncoder encoder : mEncoders) { + bitRate += encoder.getEncodedBitRate(); + } + int bytePerSecond = bitRate / 8; + long sizeMaxDuration = (maxSize / bytePerSecond) * 1000L; + + long finalMaxDuration = Long.MAX_VALUE; + if (maxSize > 0 && maxDuration > 0) { + mPossibleStopReason = sizeMaxDuration < maxDuration ? STOP_BY_MAX_SIZE : STOP_BY_MAX_DURATION; + finalMaxDuration = Math.min(sizeMaxDuration, maxDuration); + } else if (maxSize > 0) { + mPossibleStopReason = STOP_BY_MAX_SIZE; + finalMaxDuration = sizeMaxDuration; + } else if (maxDuration > 0) { + mPossibleStopReason = STOP_BY_MAX_DURATION; + finalMaxDuration = maxDuration; + } + LOG.w("Computed a max duration of", (finalMaxDuration / 1000F)); + for (MediaEncoder encoder : mEncoders) { + encoder.prepare(mController, finalMaxDuration); + } } // Stuff here might be called from multiple threads. class Controller { - int start(MediaFormat format) { - synchronized (mLock) { + /** + * Request that the muxer should start. This is not guaranteed to be executed: + * we wait for all encoders to call this method, and only then, start the muxer. + * @param format the media format + * @return the encoder track index + */ + int requestStart(MediaFormat format) { + synchronized (mControllerLock) { if (mMediaMuxerStarted) { throw new IllegalStateException("Trying to start but muxer started already"); } int track = mMediaMuxer.addTrack(format); - mMediaMuxerStartCount++; - if (mMediaMuxerStartCount == mEncoders.size()) { + LOG.w("Controller:", "Assigned track", track, "to format", format.getString(MediaFormat.KEY_MIME)); + if (++mStartedEncodersCount == mEncoders.size()) { mMediaMuxer.start(); mMediaMuxerStarted = true; } @@ -102,83 +102,124 @@ class MediaEncoderEngine { } } + /** + * Whether the muxer is started. + * @return true if muxer was started + */ boolean isStarted() { - synchronized (mLock) { + synchronized (mControllerLock) { return mMediaMuxerStarted; } } - // Synchronization does not seem needed here. - void write(int track, ByteBuffer encodedData, MediaCodec.BufferInfo info) { + /** + * Writes the given data to the muxer. Should be called after {@link #isStarted()} + * returns true. Note: this seems to be thread safe, no lock. + * TODO cache values if not started yet, then apply later. Read comments in drain(). + * Currently they are recycled instantly. + */ + void write(OutputBufferPool pool, OutputBuffer buffer) { if (!mMediaMuxerStarted) { throw new IllegalStateException("Trying to write before muxer started"); } - mMediaMuxer.writeSampleData(track, encodedData, info); + // 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); + mMediaMuxer.writeSampleData(buffer.trackIndex, buffer.data, buffer.info); + pool.recycle(buffer); } - void requestStop() { - synchronized (mLock) { - mMediaMuxerStartCount--; - if (mMediaMuxerStartCount == 0) { + /** + * Requests that the engine stops. This is not executed until all encoders call + * this method, so it is a kind of soft request, just like {@link #requestStart(MediaFormat)}. + * To be used when maxLength / maxSize constraints are reached, for example. + * + * When this succeeds, {@link MediaEncoder#stop()} is called. + */ + void requestStop(int track) { + LOG.i("RequestStop was called for track", track); + synchronized (mControllerLock) { + if (--mStartedEncodersCount == 0) { mStopReason = mPossibleStopReason; stop(); } } } - } - void start() { - mWorker.post(new Runnable() { - @Override - public void run() { - for (MediaEncoder encoder : mEncoders) { - encoder.start(); + /** + * 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); + synchronized (mControllerLock) { + if (++mStoppedEncodersCount == mEncoders.size()) { + release(); } } - }); + } } - void notify(final String event, final Object data) { - mWorker.post(new Runnable() { - @Override - public void run() { - for (MediaEncoder encoder : mEncoders) { - encoder.notify(event, data); - } - } - }); + final void start() { + for (MediaEncoder encoder : mEncoders) { + encoder.start(); + } } - void stop() { - mWorker.post(new Runnable() { - @Override - public void run() { - for (MediaEncoder encoder : mEncoders) { - encoder.stop(); - } - for (MediaEncoder encoder : mEncoders) { - encoder.release(); - } - Exception error = null; - if (mMediaMuxer != null) { - // stop() throws an exception if you haven't fed it any data. - // But also in other occasions. So this is a signal that something - // went wrong, and we propagate that to the listener. - try { - mMediaMuxer.stop(); - mMediaMuxer.release(); - } catch (Exception e) { - error = e; - } - mMediaMuxer = null; - } - if (mListener != null) mListener.onEncoderStop(mStopReason, error); - mStopReason = STOP_BY_USER; - mListener = null; - mMediaMuxerStartCount = 0; - mMediaMuxerStarted = false; + @SuppressWarnings("SameParameterValue") + final void notify(final String event, final Object data) { + for (MediaEncoder encoder : mEncoders) { + encoder.notify(event, data); + } + } + + /** + * This just asks the encoder to stop. We will wait for them to call {@link Controller#requestRelease(int)} + * to actually stop the muxer, as there might be async stuff going on. + */ + final void stop() { + for (MediaEncoder encoder : mEncoders) { + encoder.stop(); + } + } + + private void release() { + Exception error = null; + if (mMediaMuxer != null) { + // stop() throws an exception if you haven't fed it any data. + // But also in other occasions. So this is a signal that something + // went wrong, and we propagate that to the listener. + try { + mMediaMuxer.stop(); + mMediaMuxer.release(); + } catch (Exception e) { + error = e; } - }); + mMediaMuxer = null; + } + if (mListener != null) { + mListener.onEncoderStop(mStopReason, error); + mListener = null; + } + mStopReason = STOP_BY_USER; + mStartedEncodersCount = 0; + mStoppedEncodersCount = 0; + mMediaMuxerStarted = false; + } + + @NonNull + VideoMediaEncoder getVideoEncoder() { + return (VideoMediaEncoder) mEncoders.get(0); + } + + @Nullable + AudioMediaEncoder getAudioEncoder() { + if (mEncoders.size() > 1) { + return (AudioMediaEncoder) mEncoders.get(1); + } else { + return null; + } } interface Listener { diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/OutputBuffer.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/OutputBuffer.java new file mode 100644 index 00000000..b3f72a97 --- /dev/null +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/OutputBuffer.java @@ -0,0 +1,11 @@ +package com.otaliastudios.cameraview; + +import android.media.MediaCodec; + +import java.nio.ByteBuffer; + +class OutputBuffer { + MediaCodec.BufferInfo info; + int trackIndex; + ByteBuffer data; +} diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/OutputBufferPool.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/OutputBufferPool.java new file mode 100644 index 00000000..9e77f862 --- /dev/null +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/OutputBufferPool.java @@ -0,0 +1,18 @@ +package com.otaliastudios.cameraview; + +import android.media.MediaCodec; + +class OutputBufferPool extends Pool { + + OutputBufferPool(final int trackIndex) { + super(Integer.MAX_VALUE, new Factory() { + @Override + public OutputBuffer create() { + OutputBuffer buffer = new OutputBuffer(); + buffer.trackIndex = trackIndex; + buffer.info = new MediaCodec.BufferInfo(); + return buffer; + } + }); + } +} diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/Pool.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/Pool.java new file mode 100644 index 00000000..4429d55c --- /dev/null +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/Pool.java @@ -0,0 +1,89 @@ +package com.otaliastudios.cameraview; + +import java.util.concurrent.LinkedBlockingQueue; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +class Pool { + + private static final String TAG = Pool.class.getSimpleName(); + private static final CameraLogger LOG = CameraLogger.create(TAG); + + private int maxPoolSize; + private int activeCount; + private LinkedBlockingQueue mQueue; + private Factory factory; + + interface Factory { + T create(); + } + + Pool(int maxPoolSize, Factory factory) { + this.maxPoolSize = maxPoolSize; + this.mQueue = new LinkedBlockingQueue<>(maxPoolSize); + this.factory = factory; + } + + boolean canGet() { + return count() < maxPoolSize; + } + + @Nullable + T get() { + T buffer = mQueue.poll(); + if (buffer != null) { + activeCount++; // poll decreases, this fixes + LOG.v("GET: Reusing recycled item.", this); + return buffer; + } + + if (!canGet()) { + LOG.v("GET: Returning null. Too much items requested.", this); + return null; + } + + activeCount++; + LOG.v("GET: Creating a new item.", this); + return factory.create(); + } + + + void recycle(@NonNull T item) { + 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 " + + "this pool, or some item was recycled more than once. " + this); + } + if (!mQueue.offer(item)) { + throw new IllegalStateException("Trying to recycle an item while the queue is full. " + + "This means that this or some previous items being recycled were not coming from " + + "this pool, or some item was recycled more than once. " + this); + } + } + + @NonNull + @Override + public String toString() { + return getClass().getSimpleName() + " -- count:" + count() + ", active:" + activeCount() + ", cached:" + cachedCount(); + } + + final int count() { + return activeCount() + cachedCount(); + } + + final int activeCount() { + return activeCount; + } + + final int cachedCount() { + return mQueue.size(); + } + + @CallSuper + void clear() { + mQueue.clear(); + } +} diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/TextureMediaEncoder.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/TextureMediaEncoder.java index 5e72a878..6c40b58a 100644 --- a/cameraview/src/main/gles/com/otaliastudios/cameraview/TextureMediaEncoder.java +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/TextureMediaEncoder.java @@ -3,6 +3,8 @@ package com.otaliastudios.cameraview; import android.opengl.EGLContext; import android.opengl.Matrix; import android.os.Build; +import android.widget.TextView; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; @@ -15,10 +17,6 @@ class TextureMediaEncoder extends VideoMediaEncoder final static String FRAME_EVENT = "frame"; - static class Frame { - float[] transform; - long timestamp; - } static class Config extends VideoMediaEncoder.Config { int textureId; float scaleX; @@ -44,15 +42,40 @@ class TextureMediaEncoder extends VideoMediaEncoder private EglCore mEglCore; private EglWindowSurface mWindow; private EglViewport mViewport; + private Pool mFramePool = new Pool<>(100, new Pool.Factory() { + @Override + public TextureFrame create() { + return new TextureFrame(); + } + }); TextureMediaEncoder(@NonNull Config config) { super(config); } + static class TextureFrame { + private TextureFrame() {} + // Nanoseconds, in no meaningful time-base. Should be for offsets only. + // Typically coming from SurfaceTexture.getTimestamp(). + long timestamp; + float[] transform = new float[16]; + } + + @NonNull + TextureFrame acquireFrame() { + if (!mFramePool.canGet()) { + throw new RuntimeException("Need more frames than this! Please increase the pool size."); + } else { + //noinspection ConstantConditions + return mFramePool.get(); + } + } + + @EncoderThread @Override - void prepare(@NonNull MediaEncoderEngine.Controller controller, long maxLengthMillis) { - super.prepare(controller, maxLengthMillis); + void onPrepare(@NonNull MediaEncoderEngine.Controller controller, long maxLengthMillis) { + super.onPrepare(controller, maxLengthMillis); mEglCore = new EglCore(mConfig.eglContext, EglCore.FLAG_RECORDABLE); mWindow = new EglWindowSurface(mEglCore, mSurface, true); mWindow.makeCurrent(); // drawing will happen on the InputWindowSurface, which @@ -62,8 +85,57 @@ class TextureMediaEncoder extends VideoMediaEncoder @EncoderThread @Override - void release() { - super.release(); + void onStart() { + super.onStart(); + // Nothing to do here. Waiting for the first frame. + } + + @EncoderThread + @Override + void onEvent(@NonNull String event, @Nullable Object data) { + if (!event.equals(FRAME_EVENT)) return; + TextureFrame frame = (TextureFrame) data; + if (frame == null) return; // Should not happen + if (frame.timestamp == 0 || mFrameNum < 0) { + // The first condition comes from grafika. + // The second condition means we were asked to stop. + mFramePool.recycle(frame); + return; + } + + mFrameNum++; + LOG.v("Incoming frame timestamp:", frame.timestamp); + // We must scale this matrix like GlCameraPreview does, because it might have some cropping. + // Scaling takes place with respect to the (0, 0, 0) point, so we must apply a Translation to compensate. + float[] transform = frame.transform; + float scaleX = mConfig.scaleX; + float scaleY = mConfig.scaleY; + float scaleTranslX = (1F - scaleX) / 2F; + float scaleTranslY = (1F - scaleY) / 2F; + Matrix.translateM(transform, 0, scaleTranslX, scaleTranslY, 0); + Matrix.scaleM(transform, 0, scaleX, scaleY, 1); + + // We also must rotate this matrix. In GlCameraPreview it is not needed because it is a live + // stream, but the output video, must be correctly rotated based on the device rotation at the moment. + // Rotation also takes place with respect to the origin (the Z axis), so we must + // translate to origin, rotate, then back to where we were. + + Matrix.translateM(transform, 0, 0.5F, 0.5F, 0); + Matrix.rotateM(transform, 0, mConfig.transformRotation, 0, 0, 1); + Matrix.translateM(transform, 0, -0.5F, -0.5F, 0); + + drainOutput(false); + // Future note: passing scale values to the viewport? They are scaleX and scaleY, + // but flipped based on the mConfig.scaleFlipped boolean. + mViewport.drawFrame(mConfig.textureId, transform); + mWindow.setPresentationTime(frame.timestamp); + mWindow.swapBuffers(); + mFramePool.recycle(frame); + } + + @Override + void onRelease() { + mFramePool.clear(); if (mWindow != null) { mWindow.release(); mWindow = null; @@ -77,58 +149,4 @@ class TextureMediaEncoder extends VideoMediaEncoder mEglCore = null; } } - - @EncoderThread - @Override - void start() { - super.start(); - // Nothing to do here. Waiting for the first frame. - } - - @EncoderThread - @Override - void notify(@NonNull String event, @Nullable Object data) { - if (event.equals(FRAME_EVENT)) { - Frame frame = (Frame) data; - - // Seeing this after device is toggled off/on with power button. The - // first frame back has a zero timestamp. - // MPEG4Writer thinks this is cause to abort() in native code, so it's very - // important that we just ignore the frame. - if (frame.timestamp == 0) return; - if (mFrameNum < 0) return; - mFrameNum++; - - int arg1 = (int) (frame.timestamp >> 32); - int arg2 = (int) frame.timestamp; - long timestamp = (((long) arg1) << 32) | (((long) arg2) & 0xffffffffL); - float[] transform = frame.transform; - - // We must scale this matrix like GlCameraPreview does, because it might have some cropping. - // Scaling takes place with respect to the (0, 0, 0) point, so we must apply a Translation to compensate. - - float scaleX = mConfig.scaleX; - float scaleY = mConfig.scaleY; - float scaleTranslX = (1F - scaleX) / 2F; - float scaleTranslY = (1F - scaleY) / 2F; - Matrix.translateM(transform, 0, scaleTranslX, scaleTranslY, 0); - Matrix.scaleM(transform, 0, scaleX, scaleY, 1); - - // We also must rotate this matrix. In GlCameraPreview it is not needed because it is a live - // stream, but the output video, must be correctly rotated based on the device rotation at the moment. - // Rotation also takes place with respect to the origin (the Z axis), so we must - // translate to origin, rotate, then back to where we were. - - Matrix.translateM(transform, 0, 0.5F, 0.5F, 0); - Matrix.rotateM(transform, 0, mConfig.transformRotation, 0, 0, 1); - Matrix.translateM(transform, 0, -0.5F, -0.5F, 0); - - drain(false); - // Future note: passing scale values to the viewport? They are scaleX and scaleY, - // but flipped based on the mConfig.scaleFlipped boolean. - mViewport.drawFrame(mConfig.textureId, transform); - mWindow.setPresentationTime(timestamp); - mWindow.swapBuffers(); - } - } } diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/VideoMediaEncoder.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/VideoMediaEncoder.java index 5c3c58db..13c4599a 100644 --- a/cameraview/src/main/gles/com/otaliastudios/cameraview/VideoMediaEncoder.java +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/VideoMediaEncoder.java @@ -51,10 +51,15 @@ abstract class VideoMediaEncoder extends Med mConfig = config; } + @NonNull + @Override + String getName() { + return "VideoEncoder"; + } + @EncoderThread @Override - void prepare(@NonNull MediaEncoderEngine.Controller controller, long maxLengthMillis) { - super.prepare(controller, maxLengthMillis); + void onPrepare(@NonNull MediaEncoderEngine.Controller controller, long maxLengthMillis) { MediaFormat format = MediaFormat.createVideoFormat(mConfig.mimeType, mConfig.width, mConfig.height); // Set some properties. Failing to specify some of these can cause the MediaCodec @@ -62,6 +67,7 @@ abstract class VideoMediaEncoder extends Med format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); format.setInteger(MediaFormat.KEY_BIT_RATE, mConfig.bitRate); format.setInteger(MediaFormat.KEY_FRAME_RATE, mConfig.frameRate); + format.setInteger(MediaFormat.KEY_FRAME_RATE, 6); // TODO format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2); format.setInteger("rotation-degrees", mConfig.rotation); @@ -79,20 +85,21 @@ abstract class VideoMediaEncoder extends Med @EncoderThread @Override - void start() { + void onStart() { // Nothing to do here. Waiting for the first frame. mFrameNum = 0; } @EncoderThread @Override - void stop() { + void onStop() { mFrameNum = -1; - drain(true); + signalEndOfInputStream(); + drainOutput(true); } @Override - int getBitRate() { + int getEncodedBitRate() { return mConfig.bitRate; } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotVideoRecorder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotVideoRecorder.java index c3003f18..e707dec9 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotVideoRecorder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotVideoRecorder.java @@ -58,8 +58,13 @@ class SnapshotVideoRecorder extends VideoRecorder implements GlCameraPreview.Ren @Override public void onRendererFrame(@NonNull SurfaceTexture surfaceTexture, float scaleX, float scaleY) { if (mCurrentState == STATE_NOT_RECORDING && mDesiredState == STATE_RECORDING) { + // Set default options + if (mResult.videoBitRate <= 0) mResult.videoBitRate = DEFAULT_VIDEO_BITRATE; + if (mResult.videoFrameRate <= 0) mResult.videoFrameRate = DEFAULT_VIDEO_FRAMERATE; + if (mResult.audioBitRate <= 0) mResult.audioBitRate = DEFAULT_AUDIO_BITRATE; + + // Video. Ensure width and height are divisible by 2, as I have read somewhere. Size size = mResult.getSize(); - // Ensure width and height are divisible by 2, as I have read somewhere. int width = size.getWidth(); int height = size.getHeight(); width = width % 2 == 0 ? width : width + 1; @@ -70,9 +75,6 @@ class SnapshotVideoRecorder extends VideoRecorder implements GlCameraPreview.Ren case H_264: type = "video/avc"; break; // MediaFormat.MIMETYPE_VIDEO_AVC: case DEVICE_DEFAULT: type = "video/avc"; break; } - if (mResult.videoBitRate <= 0) mResult.videoBitRate = DEFAULT_VIDEO_BITRATE; - if (mResult.audioBitRate <= 0) mResult.audioBitRate = DEFAULT_AUDIO_BITRATE; - if (mResult.videoFrameRate <= 0) mResult.videoFrameRate = DEFAULT_VIDEO_FRAMERATE; LOG.w("Creating frame encoder. Rotation:", mResult.rotation); TextureMediaEncoder.Config config = new TextureMediaEncoder.Config(width, height, mResult.videoBitRate, @@ -84,10 +86,14 @@ class SnapshotVideoRecorder extends VideoRecorder implements GlCameraPreview.Ren EGL14.eglGetCurrentContext() ); TextureMediaEncoder videoEncoder = new TextureMediaEncoder(config); + + // Audio AudioMediaEncoder audioEncoder = null; if (mResult.audio == Audio.ON) { audioEncoder = new AudioMediaEncoder(new AudioMediaEncoder.Config(mResult.audioBitRate)); } + + // Engine mEncoderEngine = new MediaEncoderEngine(mResult.file, videoEncoder, audioEncoder, mResult.maxDuration, mResult.maxSize, SnapshotVideoRecorder.this); mEncoderEngine.start(); @@ -96,11 +102,11 @@ class SnapshotVideoRecorder extends VideoRecorder implements GlCameraPreview.Ren } if (mCurrentState == STATE_RECORDING) { - TextureMediaEncoder.Frame frame = new TextureMediaEncoder.Frame(); - frame.timestamp = surfaceTexture.getTimestamp(); - frame.transform = new float[16]; // TODO would be cool to avoid this at every frame. But it's not easy. - surfaceTexture.getTransformMatrix(frame.transform); - mEncoderEngine.notify(TextureMediaEncoder.FRAME_EVENT, frame); + TextureMediaEncoder textureEncoder = (TextureMediaEncoder) mEncoderEngine.getVideoEncoder(); + TextureMediaEncoder.TextureFrame textureFrame = textureEncoder.acquireFrame(); + textureFrame.timestamp = surfaceTexture.getTimestamp(); + surfaceTexture.getTransformMatrix(textureFrame.transform); + mEncoderEngine.notify(TextureMediaEncoder.FRAME_EVENT, textureFrame); } if (mCurrentState == STATE_RECORDING && mDesiredState == STATE_NOT_RECORDING) { @@ -113,7 +119,6 @@ class SnapshotVideoRecorder extends VideoRecorder implements GlCameraPreview.Ren } - @EncoderThread @Override public void onEncoderStop(int stopReason, @Nullable Exception e) { // If something failed, undo the result, since this is the mechanism diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/VideoRecorder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/VideoRecorder.java index 5b2c4d1b..22d512b0 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/VideoRecorder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/VideoRecorder.java @@ -22,6 +22,7 @@ abstract class VideoRecorder { abstract void stop(); + @SuppressWarnings("WeakerAccess") protected void dispatchResult() { if (mListener != null) { mListener.onVideoResult(mResult); diff --git a/cameraview/src/main/utils/com/otaliastudios/cameraview/WorkerHandler.java b/cameraview/src/main/utils/com/otaliastudios/cameraview/WorkerHandler.java index db2142b2..aeb1e040 100644 --- a/cameraview/src/main/utils/com/otaliastudios/cameraview/WorkerHandler.java +++ b/cameraview/src/main/utils/com/otaliastudios/cameraview/WorkerHandler.java @@ -2,6 +2,8 @@ package com.otaliastudios.cameraview; import android.os.Handler; import android.os.HandlerThread; +import android.os.Looper; + import androidx.annotation.NonNull; import java.lang.ref.WeakReference; @@ -63,16 +65,22 @@ class WorkerHandler { } @NonNull - public Thread getThread() { + public HandlerThread getThread() { return mThread; } + @NonNull + public Looper getLooper() { + return mThread.getLooper(); + } + static void destroy() { for (String key : sCache.keySet()) { WeakReference ref = sCache.get(key); WorkerHandler handler = ref.get(); if (handler != null && handler.getThread().isAlive()) { handler.getThread().interrupt(); + // handler.getThread().quit(); } ref.clear(); } 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 125e1315..0b0d348a 100644 --- a/demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java +++ b/demo/src/main/java/com/otaliastudios/cameraview/demo/CameraActivity.java @@ -2,6 +2,7 @@ package com.otaliastudios.cameraview.demo; import android.content.Intent; import android.content.pm.PackageManager; +import android.net.Uri; import android.os.Bundle; import androidx.annotation.NonNull; import com.google.android.material.bottomsheet.BottomSheetBehavior; From d462b8304827a55205e0e54c9a78361fa5107ce9 Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Sat, 23 Feb 2019 15:52:41 +0100 Subject: [PATCH 7/8] v2.0.0-beta03 (#392) * Fix #377 and update dependencies * Fix #384 * Fix unbindFromSurface bug * Bump version to v2.0.0-beta03 * Update build.gradle --- README.md | 2 +- cameraview/build.gradle | 2 +- .../com/otaliastudios/cameraview/EglCore.java | 19 ++---------- .../com/otaliastudios/cameraview/Camera1.java | 29 ++++++++++++++----- .../cameraview/CameraController.java | 2 +- .../otaliastudios/cameraview/CameraView.java | 1 - demo/build.gradle | 4 +-- demo/src/main/AndroidManifest.xml | 4 +-- docs/_posts/2018-12-20-changelog.md | 9 +++++- docs/_posts/2018-12-20-install.md | 2 +- 10 files changed, 39 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 566d0e9c..01f3ac6b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ 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. ```groovy -compile 'com.otaliastudios:cameraview:2.0.0-beta02' +compile 'com.otaliastudios:cameraview:2.0.0-beta03' ``` - Fast & reliable diff --git a/cameraview/build.gradle b/cameraview/build.gradle index aa0d5819..07e871c7 100644 --- a/cameraview/build.gradle +++ b/cameraview/build.gradle @@ -3,7 +3,7 @@ apply plugin: 'com.github.dcendents.android-maven' apply plugin: 'com.jfrog.bintray' // Required by bintray -version = '2.0.0-beta02' +version = '2.0.0-beta03' group = 'com.otaliastudios' //region android dependencies diff --git a/cameraview/src/main/gles/com/otaliastudios/cameraview/EglCore.java b/cameraview/src/main/gles/com/otaliastudios/cameraview/EglCore.java index 2483978d..62f1f6d9 100644 --- a/cameraview/src/main/gles/com/otaliastudios/cameraview/EglCore.java +++ b/cameraview/src/main/gles/com/otaliastudios/cameraview/EglCore.java @@ -137,7 +137,7 @@ final class EglCore { int[] values = new int[1]; EGL14.eglQueryContext(mEGLDisplay, mEGLContext, EGL14.EGL_CONTEXT_CLIENT_VERSION, values, 0); - Log.d(TAG, "EGLContext created, client version " + values[0]); + // Log.d(TAG, "EGLContext created, client version " + values[0]); } /** @@ -273,7 +273,7 @@ final class EglCore { public void makeCurrent(EGLSurface eglSurface) { if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) { // called makeCurrent() before create? - Log.d(TAG, "NOTE: makeCurrent w/o display"); + // Log.d(TAG, "NOTE: makeCurrent w/o display"); } if (!EGL14.eglMakeCurrent(mEGLDisplay, eglSurface, eglSurface, mEGLContext)) { throw new RuntimeException("eglMakeCurrent failed"); @@ -351,21 +351,6 @@ final class EglCore { return mGlVersion; } - /** - * Writes the current display, context, and surface to the log. - */ - public static void logCurrent(String msg) { - EGLDisplay display; - EGLContext context; - EGLSurface surface; - - display = EGL14.eglGetCurrentDisplay(); - context = EGL14.eglGetCurrentContext(); - surface = EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW); - Log.i(TAG, "Current EGL (" + msg + "): display=" + display + ", context=" + context + - ", surface=" + surface); - } - /** * Checks for EGL errors. Throws an exception if an error has been raised. */ diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java index 72bc6993..7c6f9b52 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java @@ -66,7 +66,10 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera }); } - // Preview surface is now available. If camera is open, set up. + /** + * Preview surface is now available. If camera is open, set up. + * At this point we are sure that mPreview is not null. + */ @Override public void onSurfaceAvailable() { LOG.i("onSurfaceAvailable:", "Size is", mPreview.getOutputSurfaceSize()); @@ -80,8 +83,11 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera }); } - // Preview surface did change its size. Compute a new preview size. - // This requires stopping and restarting the preview. + /** + * Preview surface did change its size. Compute a new preview size. + * This requires stopping and restarting the preview. + * At this point we are sure that mPreview is not null. + */ @Override public void onSurfaceChanged() { LOG.i("onSurfaceChanged, size is", mPreview.getOutputSurfaceSize()); @@ -119,8 +125,11 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera return isCameraAvailable() && mPreview != null && mPreview.hasSurface() && !mIsBound; } - // The act of binding an "open" camera to a "ready" preview. - // These can happen at different times but we want to end up here. + /** + * The act of binding an "open" camera to a "ready" preview. + * These can happen at different times but we want to end up here. + * At this point we are sure that mPreview is not null. + */ @WorkerThread private void bindToSurface() { LOG.i("bindToSurface:", "Started"); @@ -275,7 +284,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera } if (mCamera != null) { stopPreview(); - unbindFromSurface(); + if (mIsBound) unbindFromSurface(); destroyCamera(); } mCameraOptions = null; @@ -440,8 +449,12 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera Camera.CameraInfo info = new Camera.CameraInfo(); Camera.getCameraInfo(mCameraId, info); if (info.canDisableShutterSound) { - mCamera.enableShutterSound(mPlaySounds); - return true; + try { + // this method is documented to throw on some occasions. #377 + return mCamera.enableShutterSound(mPlaySounds); + } catch (RuntimeException exception) { + return false; + } } } if (mPlaySounds) { diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java index d6063787..cfedbb9c 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java @@ -91,7 +91,7 @@ abstract class CameraController implements mFrameManager = new FrameManager(2, this); } - void setPreview(CameraPreview cameraPreview) { + void setPreview(@NonNull CameraPreview cameraPreview) { mPreview = cameraPreview; mPreview.setSurfaceCallback(this); } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java index f7c979ff..7f2f6213 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java @@ -602,7 +602,6 @@ public class CameraView extends FrameLayout implements LifecycleObserver { public void open() { if (!isEnabled()) return; if (mCameraPreview != null) mCameraPreview.onResume(); - if (checkPermissions(getAudio())) { // Update display orientation for current CameraController mOrientationHelper.enable(getContext()); diff --git a/demo/build.gradle b/demo/build.gradle index 51bcb2f1..be25003a 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -23,6 +23,6 @@ android { dependencies { implementation project(':cameraview') - implementation 'androidx.appcompat:appcompat:1.1.0-alpha01' - implementation 'com.google.android.material:material:1.1.0-alpha02' + implementation 'androidx.appcompat:appcompat:1.1.0-alpha02' + implementation 'com.google.android.material:material:1.1.0-alpha03' } diff --git a/demo/src/main/AndroidManifest.xml b/demo/src/main/AndroidManifest.xml index 91853314..15d6c9ea 100644 --- a/demo/src/main/AndroidManifest.xml +++ b/demo/src/main/AndroidManifest.xml @@ -15,9 +15,9 @@ + android:screenOrientation="portrait" + android:hardwareAccelerated="true"> diff --git a/docs/_posts/2018-12-20-changelog.md b/docs/_posts/2018-12-20-changelog.md index 41a4495a..1a924184 100644 --- a/docs/_posts/2018-12-20-changelog.md +++ b/docs/_posts/2018-12-20-changelog.md @@ -8,6 +8,11 @@ order: 3 New versions are released through GitHub, so the reference page is the [GitHub Releases](https://github.com/natario1/CameraView/releases) page. +### v2.0.0-beta03 + +- Fixed a few bugs ([#392][392]) +- Important fixes to video snapshot recording ([#374][374]) + ### v2.0.0-beta02 - Fixed important bugs ([#356][356]) @@ -19,4 +24,6 @@ New versions are released through GitHub, so the reference page is the [GitHub R This is the first beta release. For changes with respect to v1, please take a look at the [migration guide](../extra/v1-migration-guide.html). [356]: https://github.com/natario1/CameraView/pull/356 -[360]: https://github.com/natario1/CameraView/pull/360 \ No newline at end of file +[360]: https://github.com/natario1/CameraView/pull/360 +[374]: https://github.com/natario1/CameraView/pull/374 +[392]: https://github.com/natario1/CameraView/pull/392 \ No newline at end of file diff --git a/docs/_posts/2018-12-20-install.md b/docs/_posts/2018-12-20-install.md index 0683c4cc..917a56e9 100644 --- a/docs/_posts/2018-12-20-install.md +++ b/docs/_posts/2018-12-20-install.md @@ -24,7 +24,7 @@ allprojects { Then simply download the latest version: ```groovy -api 'com.otaliastudios:cameraview:2.0.0-beta02' +api 'com.otaliastudios:cameraview:2.0.0-beta03' ``` No other configuration steps are needed. \ No newline at end of file From 5e5af877e407d5a60fd3cc2f9fc926f9db9e596d Mon Sep 17 00:00:00 2001 From: Mattia Iavarone Date: Sun, 24 Feb 2019 20:25:47 +0100 Subject: [PATCH 8/8] New snapshot max size APIs (#393) * New getSurfaceSize internal method * Rename setPreviewSize and all internals to setPreviewStreamSize * Rename getSurfaceSize to getPreviewSurfaceSize * New snapshotMaxWidth and snapshotMaxHeight APIs * Add docs * Improve rescaling logic * Add tests --- .../cameraview/CameraPreviewTest.java | 22 ++--- .../cameraview/CameraViewTest.java | 32 ++++-- .../cameraview/IntegrationTest.java | 2 +- .../cameraview/MockCameraController.java | 4 +- .../cameraview/TextureCameraPreviewTest.java | 4 +- .../com/otaliastudios/cameraview/Camera1.java | 53 ++++++---- .../cameraview/CameraController.java | 98 ++++++++++++++++--- .../otaliastudios/cameraview/CameraView.java | 37 +++++-- .../cameraview/SnapshotPictureRecorder.java | 4 +- cameraview/src/main/res/values/attrs.xml | 3 + .../cameraview/CameraPreview.java | 25 +++-- .../cameraview/GlCameraPreview.java | 8 +- .../cameraview/SurfaceCameraPreview.java | 6 +- .../cameraview/TextureCameraPreview.java | 10 +- .../demo/PicturePreviewActivity.java | 15 +++ .../cameraview/demo/VideoPreviewActivity.java | 7 ++ docs/_posts/2018-12-20-capture-size.md | 8 +- docs/_posts/2018-12-20-capturing-media.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-preview-size.md | 12 +-- docs/_posts/2018-12-20-runtime-permissions.md | 2 +- docs/_posts/2019-02-24-snapshot-size.md | 58 +++++++++++ 24 files changed, 311 insertions(+), 109 deletions(-) create mode 100644 docs/_posts/2019-02-24-snapshot-size.md diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraPreviewTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraPreviewTest.java index 05f5e462..85c2374d 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraPreviewTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraPreviewTest.java @@ -102,17 +102,17 @@ public abstract class CameraPreviewTest extends BaseTest { @Test public void testDesiredSize() { - preview.setInputStreamSize(160, 90, false); - assertEquals(160, preview.getInputStreamSize().getWidth()); - assertEquals(90, preview.getInputStreamSize().getHeight()); + preview.setStreamSize(160, 90, false); + assertEquals(160, preview.getStreamSize().getWidth()); + assertEquals(90, preview.getStreamSize().getHeight()); } @Test public void testSurfaceAvailable() { ensureAvailable(); verify(callback, times(1)).onSurfaceAvailable(); - assertEquals(surfaceSize.getWidth(), preview.getOutputSurfaceSize().getWidth()); - assertEquals(surfaceSize.getHeight(), preview.getOutputSurfaceSize().getHeight()); + assertEquals(surfaceSize.getWidth(), preview.getSurfaceSize().getWidth()); + assertEquals(surfaceSize.getHeight(), preview.getSurfaceSize().getHeight()); } @Test @@ -121,8 +121,8 @@ public abstract class CameraPreviewTest extends BaseTest { ensureDestroyed(); // This might be called twice in Texture because it overrides ensureDestroyed method verify(callback, atLeastOnce()).onSurfaceDestroyed(); - assertEquals(0, preview.getOutputSurfaceSize().getWidth()); - assertEquals(0, preview.getOutputSurfaceSize().getHeight()); + assertEquals(0, preview.getSurfaceSize().getWidth()); + assertEquals(0, preview.getSurfaceSize().getHeight()); } @Test @@ -146,7 +146,7 @@ public abstract class CameraPreviewTest extends BaseTest { // Since desired is 'desired', let's fake a new view size that is consistent with it. // Ensure crop is not happening anymore. preview.mCropTask.listen(); - preview.dispatchOnOutputSurfaceSizeChanged((int) (50f * desired), 50); // Wait... + preview.dispatchOnSurfaceSizeChanged((int) (50f * desired), 50); // Wait... preview.mCropTask.await(); assertEquals(desired, getViewAspectRatioWithScale(), 0.01f); assertFalse(preview.isCropping()); @@ -154,19 +154,19 @@ public abstract class CameraPreviewTest extends BaseTest { private void setDesiredAspectRatio(float desiredAspectRatio) { preview.mCropTask.listen(); - preview.setInputStreamSize((int) (10f * desiredAspectRatio), 10, false); // Wait... + preview.setStreamSize((int) (10f * desiredAspectRatio), 10, false); // Wait... preview.mCropTask.await(); assertEquals(desiredAspectRatio, getViewAspectRatioWithScale(), 0.01f); } private float getViewAspectRatio() { - Size size = preview.getOutputSurfaceSize(); + Size size = preview.getSurfaceSize(); return AspectRatio.of(size.getWidth(), size.getHeight()).toFloat(); } private float getViewAspectRatioWithScale() { - Size size = preview.getOutputSurfaceSize(); + Size size = preview.getSurfaceSize(); int newWidth = (int) (((float) size.getWidth()) * getCropScaleX()); int newHeight = (int) (((float) size.getHeight()) * getCropScaleY()); return AspectRatio.of(newWidth, newHeight).toFloat(); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java index aed9b4af..04a42fad 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/CameraViewTest.java @@ -288,14 +288,14 @@ public class CameraViewTest extends BaseTest { //region testMeasure - private void mockPreviewSize() { + private void mockPreviewStreamSize() { Size size = new Size(900, 1600); - mockController.setMockPreviewSize(size); + mockController.setMockPreviewStreamSize(size); } @Test public void testMeasure_early() { - mockController.setMockPreviewSize(null); + mockController.setMockPreviewStreamSize(null); cameraView.measure( makeMeasureSpec(500, EXACTLY), makeMeasureSpec(500, EXACTLY)); @@ -305,7 +305,7 @@ public class CameraViewTest extends BaseTest { @Test public void testMeasure_matchParentBoth() { - mockPreviewSize(); + mockPreviewStreamSize(); // Respect parent/layout constraints on both dimensions. cameraView.setLayoutParams(new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)); @@ -331,7 +331,7 @@ public class CameraViewTest extends BaseTest { @Test public void testMeasure_wrapContentBoth() { - mockPreviewSize(); + mockPreviewStreamSize(); // Respect parent constraints, but fit aspect ratio. // Fit into a 160x160 parent so we espect final width to be 90. @@ -345,7 +345,7 @@ public class CameraViewTest extends BaseTest { @Test public void testMeasure_wrapContentSingle() { - mockPreviewSize(); + mockPreviewStreamSize(); // Respect MATCH_PARENT on height, change width to fit the aspect ratio. cameraView.setLayoutParams(new ViewGroup.LayoutParams(WRAP_CONTENT, MATCH_PARENT)); @@ -366,7 +366,7 @@ public class CameraViewTest extends BaseTest { @Test public void testMeasure_scrollableContainer() { - mockPreviewSize(); + mockPreviewStreamSize(); // Assume a vertical scroll view. It will pass UNSPECIFIED as height. // We respect MATCH_PARENT on width (160), and enlarge height to match the aspect ratio. @@ -559,10 +559,10 @@ public class CameraViewTest extends BaseTest { } @Test - public void testPreviewSizeSelector() { + public void testPreviewStreamSizeSelector() { SizeSelector source = SizeSelectors.minHeight(50); - cameraView.setPreviewSize(source); - SizeSelector result = mockController.getPreviewSizeSelector(); + cameraView.setPreviewStreamSize(source); + SizeSelector result = mockController.getPreviewStreamSizeSelector(); assertNotNull(result); assertEquals(result, source); } @@ -661,5 +661,17 @@ public class CameraViewTest extends BaseTest { //endregion + //region Snapshots + + @Test + public void testSetSnapshotMaxSize() { + cameraView.setSnapshotMaxWidth(500); + cameraView.setSnapshotMaxHeight(1000); + assertEquals(mockController.mSnapshotMaxWidth, 500); + assertEquals(mockController.mSnapshotMaxHeight, 1000); + } + + //endregion + // TODO: test permissions } diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java index 98760646..d7d47eb4 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/IntegrationTest.java @@ -89,7 +89,7 @@ public class IntegrationTest extends BaseTest { } @After - public void tearDown() throws Exception { + public void tearDown() { camera.stopVideo(); camera.destroy(); WorkerHandler.destroy(); diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/MockCameraController.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/MockCameraController.java index 7be7053d..1026b215 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/MockCameraController.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/MockCameraController.java @@ -23,8 +23,8 @@ public class MockCameraController extends CameraController { mCameraOptions = options; } - void setMockPreviewSize(Size size) { - mPreviewSize = size; + void setMockPreviewStreamSize(Size size) { + mPreviewStreamSize = size; } void mockStarted(boolean started) { diff --git a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/TextureCameraPreviewTest.java b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/TextureCameraPreviewTest.java index 0f604fc7..4e5e3251 100644 --- a/cameraview/src/androidTest/java/com/otaliastudios/cameraview/TextureCameraPreviewTest.java +++ b/cameraview/src/androidTest/java/com/otaliastudios/cameraview/TextureCameraPreviewTest.java @@ -23,7 +23,7 @@ public class TextureCameraPreviewTest extends CameraPreviewTest { if (isHardwareAccelerated()) { super.ensureAvailable(); } else { - preview.dispatchOnOutputSurfaceAvailable( + preview.dispatchOnSurfaceAvailable( surfaceSize.getWidth(), surfaceSize.getHeight()); } @@ -34,7 +34,7 @@ public class TextureCameraPreviewTest extends CameraPreviewTest { super.ensureDestroyed(); if (!isHardwareAccelerated()) { // Ensure it is called. - preview.dispatchOnOutputSurfaceDestroyed(); + preview.dispatchOnSurfaceDestroyed(); } } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java index 7c6f9b52..758bb895 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/Camera1.java @@ -72,7 +72,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera */ @Override public void onSurfaceAvailable() { - LOG.i("onSurfaceAvailable:", "Size is", mPreview.getOutputSurfaceSize()); + LOG.i("onSurfaceAvailable:", "Size is", getPreviewSurfaceSize(REF_VIEW)); schedule(null, false, new Runnable() { @Override public void run() { @@ -90,19 +90,19 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera */ @Override public void onSurfaceChanged() { - LOG.i("onSurfaceChanged, size is", mPreview.getOutputSurfaceSize()); + LOG.i("onSurfaceChanged, size is", getPreviewSurfaceSize(REF_VIEW)); schedule(null, true, new Runnable() { @Override public void run() { if (!mIsBound) return; // Compute a new camera preview size. - Size newSize = computePreviewSize(sizesFromList(mCamera.getParameters().getSupportedPreviewSizes())); - if (newSize.equals(mPreviewSize)) return; + Size newSize = computePreviewStreamSize(sizesFromList(mCamera.getParameters().getSupportedPreviewSizes())); + if (newSize.equals(mPreviewStreamSize)) return; // Apply. LOG.i("onSurfaceChanged:", "Computed a new preview size. Going on."); - mPreviewSize = newSize; + mPreviewStreamSize = newSize; stopPreview(); startPreview("onSurfaceChanged:"); } @@ -135,10 +135,12 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera LOG.i("bindToSurface:", "Started"); Object output = mPreview.getOutput(); try { - if (mPreview.getOutputClass() == SurfaceHolder.class) { + if (output instanceof SurfaceHolder) { mCamera.setPreviewDisplay((SurfaceHolder) output); - } else { + } else if (output instanceof SurfaceTexture) { mCamera.setPreviewTexture((SurfaceTexture) output); + } else { + throw new RuntimeException("Unknown CameraPreview output class."); } } catch (IOException e) { LOG.e("bindToSurface:", "Failed to bind.", e); @@ -146,20 +148,22 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera } mCaptureSize = computeCaptureSize(); - mPreviewSize = computePreviewSize(sizesFromList(mCamera.getParameters().getSupportedPreviewSizes())); + mPreviewStreamSize = computePreviewStreamSize(sizesFromList(mCamera.getParameters().getSupportedPreviewSizes())); mIsBound = true; } @WorkerThread private void unbindFromSurface() { mIsBound = false; - mPreviewSize = null; + mPreviewStreamSize = null; mCaptureSize = null; try { if (mPreview.getOutputClass() == SurfaceHolder.class) { mCamera.setPreviewDisplay(null); - } else { + } else if (mPreview.getOutputClass() == SurfaceTexture.class) { mCamera.setPreviewTexture(null); + } else { + throw new RuntimeException("Unknown CameraPreview output class."); } } catch (IOException e) { LOG.e("unbindFromSurface", "Could not release surface", e); @@ -172,16 +176,16 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera // To be called when the preview size is setup or changed. private void startPreview(String log) { - LOG.i(log, "Dispatching onCameraPreviewSizeChanged."); - mCameraCallbacks.onCameraPreviewSizeChanged(); + LOG.i(log, "Dispatching onCameraPreviewStreamSizeChanged."); + mCameraCallbacks.onCameraPreviewStreamSizeChanged(); - Size previewSize = getPreviewSize(REF_VIEW); + Size previewSize = getPreviewStreamSize(REF_VIEW); boolean wasFlipped = flip(REF_SENSOR, REF_VIEW); - mPreview.setInputStreamSize(previewSize.getWidth(), previewSize.getHeight(), wasFlipped); + mPreview.setStreamSize(previewSize.getWidth(), previewSize.getHeight(), wasFlipped); Camera.Parameters params = mCamera.getParameters(); mPreviewFormat = params.getPreviewFormat(); - params.setPreviewSize(mPreviewSize.getWidth(), mPreviewSize.getHeight()); // <- not allowed during preview + params.setPreviewSize(mPreviewStreamSize.getWidth(), mPreviewStreamSize.getHeight()); // <- not allowed during preview if (mMode == Mode.PICTURE) { params.setPictureSize(mCaptureSize.getWidth(), mCaptureSize.getHeight()); // <- allowed } else { @@ -196,7 +200,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera mCamera.setPreviewCallbackWithBuffer(null); // Release anything left mCamera.setPreviewCallbackWithBuffer(this); // Add ourselves - mFrameManager.allocate(ImageFormat.getBitsPerPixel(mPreviewFormat), mPreviewSize); + mFrameManager.allocate(ImageFormat.getBitsPerPixel(mPreviewFormat), mPreviewStreamSize); LOG.i(log, "Starting preview with startPreview()."); try { @@ -289,7 +293,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera } mCameraOptions = null; mCamera = null; - mPreviewSize = null; + mPreviewStreamSize = null; mCaptureSize = null; mIsBound = false; LOG.w("onStop:", "Clean up.", "Returning."); @@ -574,7 +578,10 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera }); } - + /** + * Just a note about the snapshot size - it is the PreviewStreamSize, cropped with the view ratio. + * @param viewAspectRatio the view aspect ratio + */ @Override void takePictureSnapshot(@NonNull final AspectRatio viewAspectRatio) { LOG.v("takePictureSnapshot: scheduling"); @@ -588,7 +595,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera result.location = mLocation; result.isSnapshot = true; result.facing = mFacing; - result.size = getPreviewSize(REF_OUTPUT); // Not the real size: it will be cropped to match the view ratio + result.size = getUncroppedSnapshotSize(REF_OUTPUT); // Not the real size: it will be cropped to match the view ratio result.rotation = offset(REF_SENSOR, REF_OUTPUT); // Actually it will be rotated and set to 0. AspectRatio outputRatio = flip(REF_OUTPUT, REF_VIEW) ? viewAspectRatio.inverse() : viewAspectRatio; // LOG.e("ROTBUG_pic", "aspectRatio (REF_VIEW):", viewAspectRatio); @@ -611,7 +618,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera Frame frame = mFrameManager.getFrame(data, System.currentTimeMillis(), offset(REF_SENSOR, REF_OUTPUT), - mPreviewSize, + mPreviewStreamSize, mPreviewFormat); mCameraCallbacks.dispatchFrame(frame); } @@ -693,6 +700,10 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera }); } + /** + * @param file the output file + * @param viewAspectRatio the view aspect ratio + */ @SuppressLint("NewApi") @Override void takeVideoSnapshot(@NonNull final File file, @NonNull final AspectRatio viewAspectRatio) { @@ -755,7 +766,7 @@ class Camera1 extends CameraController implements Camera.PreviewCallback, Camera // Based on this we will use VO for everything. See if we get issues about distortion // and maybe we can improve. The reason why this happen is beyond my understanding. - Size outputSize = getPreviewSize(REF_OUTPUT); + Size outputSize = getUncroppedSnapshotSize(REF_OUTPUT); AspectRatio outputRatio = flip(REF_OUTPUT, REF_VIEW) ? viewAspectRatio.inverse() : viewAspectRatio; Rect outputCrop = CropHelper.computeCrop(outputSize, outputRatio); outputSize = new Size(outputCrop.width(), outputCrop.height()); diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java index cfedbb9c..4b28bb31 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraController.java @@ -8,6 +8,7 @@ import android.os.Handler; import android.os.Looper; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; import java.io.File; @@ -49,10 +50,15 @@ abstract class CameraController implements protected float mExposureCorrectionValue; protected boolean mPlaySounds; - @Nullable private SizeSelector mPreviewSizeSelector; + @Nullable private SizeSelector mPreviewStreamSizeSelector; private SizeSelector mPictureSizeSelector; private SizeSelector mVideoSizeSelector; + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + int mSnapshotMaxWidth = Integer.MAX_VALUE; // in REF_VIEW for consistency with SizeSelectors + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + int mSnapshotMaxHeight = Integer.MAX_VALUE; // in REF_VIEW for consistency with SizeSelectors + protected int mCameraId; protected CameraOptions mCameraOptions; protected Mapper mMapper; @@ -64,7 +70,7 @@ abstract class CameraController implements protected int mVideoBitRate; protected int mAudioBitRate; protected Size mCaptureSize; - protected Size mPreviewSize; + protected Size mPreviewStreamSize; protected int mPreviewFormat; protected int mSensorOffset; @@ -279,8 +285,8 @@ abstract class CameraController implements mDeviceOrientation = deviceOrientation; } - final void setPreviewSizeSelector(@Nullable SizeSelector selector) { - mPreviewSizeSelector = selector; + final void setPreviewStreamSizeSelector(@Nullable SizeSelector selector) { + mPreviewStreamSizeSelector = selector; } final void setPictureSizeSelector(@NonNull SizeSelector selector) { @@ -311,6 +317,14 @@ abstract class CameraController implements mAudioBitRate = audioBitRate; } + final void setSnapshotMaxWidth(int maxWidth) { + mSnapshotMaxWidth = maxWidth; + } + + final void setSnapshotMaxHeight(int maxHeight) { + mSnapshotMaxHeight = maxHeight; + } + //endregion //region Abstract setters and APIs @@ -421,8 +435,8 @@ abstract class CameraController implements } @Nullable - /* for tests */ final SizeSelector getPreviewSizeSelector() { - return mPreviewSizeSelector; + /* for tests */ final SizeSelector getPreviewStreamSizeSelector() { + return mPreviewStreamSizeSelector; } @NonNull @@ -493,19 +507,71 @@ abstract class CameraController implements return offset(reference1, reference2) % 180 != 0; } + @Nullable final Size getPictureSize(@SuppressWarnings("SameParameterValue") int reference) { if (mCaptureSize == null || mMode == Mode.VIDEO) return null; return flip(REF_SENSOR, reference) ? mCaptureSize.flip() : mCaptureSize; } + @Nullable final Size getVideoSize(@SuppressWarnings("SameParameterValue") int reference) { if (mCaptureSize == null || mMode == Mode.PICTURE) return null; return flip(REF_SENSOR, reference) ? mCaptureSize.flip() : mCaptureSize; } - final Size getPreviewSize(int reference) { - if (mPreviewSize == null) return null; - return flip(REF_SENSOR, reference) ? mPreviewSize.flip() : mPreviewSize; + @Nullable + final Size getPreviewStreamSize(int reference) { + if (mPreviewStreamSize == null) return null; + return flip(REF_SENSOR, reference) ? mPreviewStreamSize.flip() : mPreviewStreamSize; + } + + @SuppressWarnings("SameParameterValue") + @Nullable + final Size getPreviewSurfaceSize(int reference) { + if (mPreview == null) return null; + return flip(REF_VIEW, reference) ? mPreview.getSurfaceSize().flip() : mPreview.getSurfaceSize(); + } + + /** + * Returns the snapshot size, but not cropped with the view dimensions, which + * is what we will do before creating the snapshot. However, cropping is done at various + * levels so we don't want to perform the op here. + * + * The base snapshot size is based on PreviewStreamSize (later cropped with view ratio). Why? + * One might be tempted to say that it is the SurfaceSize (which already matches the view ratio). + * + * The camera sensor will capture preview frames with PreviewStreamSize and that's it. Then they + * are hardware-scaled by the preview surface, but this does not affect the snapshot, as the + * snapshot recorder simply creates another surface. + * + * Done tests to ensure that this is true, by using + * 1. small SurfaceSize and biggest() PreviewStreamSize: output is not low quality + * 2. big SurfaceSize and smallest() PreviewStreamSize: output is low quality + * In both cases the result.size here was set to the biggest of the two. + * + * I could not find the same evidence for videos, but I would say that the same things should + * apply, despite the capturing mechanism being different. + */ + @Nullable + final Size getUncroppedSnapshotSize(int reference) { + Size baseSize = getPreviewStreamSize(reference); + if (baseSize == null) return null; + boolean flip = flip(reference, REF_VIEW); + int maxWidth = flip ? mSnapshotMaxHeight : mSnapshotMaxWidth; + int maxHeight = flip ? mSnapshotMaxWidth : mSnapshotMaxHeight; + float baseRatio = AspectRatio.of(baseSize).toFloat(); + float maxValuesRatio = AspectRatio.of(maxWidth, maxHeight).toFloat(); + if (maxValuesRatio >= baseRatio) { + // Height is the real constraint. + int outHeight = Math.min(baseSize.getHeight(), maxHeight); + int outWidth = (int) Math.floor((float) outHeight * baseRatio); + return new Size(outWidth, outHeight); + } else { + // Width is the real constraint. + int outWidth = Math.min(baseSize.getWidth(), maxWidth); + int outHeight = (int) Math.floor((float) outWidth / baseRatio); + return new Size(outWidth, outHeight); + } } @@ -550,7 +616,7 @@ abstract class CameraController implements @NonNull @SuppressWarnings("WeakerAccess") - protected final Size computePreviewSize(@NonNull List previewSizes) { + protected final Size computePreviewStreamSize(@NonNull List previewSizes) { // These sizes come in REF_SENSOR. Since there is an external selector involved, // we must convert all of them to REF_VIEW, then flip back when returning. boolean flip = flip(REF_SENSOR, REF_VIEW); @@ -559,12 +625,12 @@ abstract class CameraController implements sizes.add(flip ? size.flip() : size); } - // Create our own default selector, which will be used if the external mPreviewSizeSelector + // Create our own default selector, which will be used if the external mPreviewStreamSizeSelector // is null, or if it fails in finding a size. - Size targetMinSize = mPreview.getOutputSurfaceSize(); + Size targetMinSize = getPreviewSurfaceSize(REF_VIEW); AspectRatio targetRatio = AspectRatio.of(mCaptureSize.getWidth(), mCaptureSize.getHeight()); if (flip) targetRatio = targetRatio.inverse(); - LOG.i("size:", "computePreviewSize:", "targetRatio:", targetRatio, "targetMinSize:", targetMinSize); + LOG.i("size:", "computePreviewStreamSize:", "targetRatio:", targetRatio, "targetMinSize:", targetMinSize); SizeSelector matchRatio = SizeSelectors.and( // Match this aspect ratio and sort by biggest SizeSelectors.aspectRatio(targetRatio, 0), SizeSelectors.biggest()); @@ -582,14 +648,14 @@ abstract class CameraController implements // Apply the external selector with this as a fallback, // and return a size in REF_SENSOR reference. SizeSelector selector; - if (mPreviewSizeSelector != null) { - selector = SizeSelectors.or(mPreviewSizeSelector, matchAll); + if (mPreviewStreamSizeSelector != null) { + selector = SizeSelectors.or(mPreviewStreamSizeSelector, matchAll); } else { selector = matchAll; } Size result = selector.select(sizes).get(0); if (flip) result = result.flip(); - LOG.i("computePreviewSize:", "result:", result, "flip:", flip); + LOG.i("computePreviewStreamSize:", "result:", result, "flip:", flip); return result; } diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java index 7f2f6213..deafbe50 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/CameraView.java @@ -311,7 +311,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - Size previewSize = mCameraController.getPreviewSize(CameraController.REF_VIEW); + Size previewSize = mCameraController.getPreviewStreamSize(CameraController.REF_VIEW); if (previewSize == null) { LOG.w("onMeasure:", "surface is not ready. Calling default behavior."); super.onMeasure(widthMeasureSpec, heightMeasureSpec); @@ -1073,15 +1073,15 @@ public class CameraView extends FrameLayout implements LifecycleObserver { * upscaling. If all you want is set an aspect ratio, use {@link #setPictureSize(SizeSelector)} * and {@link #setVideoSize(SizeSelector)}. * - * When size changes, the {@link CameraView} is remeasured so any WRAP_CONTENT dimension + * When stream size changes, the {@link CameraView} is remeasured so any WRAP_CONTENT dimension * is recomputed accordingly. * * See the {@link SizeSelectors} class for handy utilities for creating selectors. * * @param selector a size selector */ - public void setPreviewSize(@NonNull SizeSelector selector) { - mCameraController.setPreviewSizeSelector(selector); + public void setPreviewStreamSize(@NonNull SizeSelector selector) { + mCameraController.setPreviewStreamSizeSelector(selector); } @@ -1388,6 +1388,27 @@ public class CameraView extends FrameLayout implements LifecycleObserver { }); } + /** + * Sets the max width for snapshots taken with {@link #takePictureSnapshot()} or + * {@link #takeVideoSnapshot(File)}. If the snapshot width exceeds this value, the snapshot + * will be scaled down to match this constraint. + * + * @param maxWidth max width for snapshots + */ + public void setSnapshotMaxWidth(int maxWidth) { + mCameraController.setSnapshotMaxWidth(maxWidth); + } + + /** + * Sets the max height for snapshots taken with {@link #takePictureSnapshot()} or + * {@link #takeVideoSnapshot(File)}. If the snapshot height exceeds this value, the snapshot + * will be scaled down to match this constraint. + * + * @param maxHeight max height for snapshots + */ + public void setSnapshotMaxHeight(int maxHeight) { + mCameraController.setSnapshotMaxHeight(maxHeight); + } /** * Returns the size used for snapshots, or null if it hasn't been computed @@ -1402,7 +1423,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { // Get the preview size and crop according to the current view size. // It's better to do calculations in the REF_VIEW reference, and then flip if needed. - Size preview = mCameraController.getPreviewSize(CameraController.REF_VIEW); + Size preview = mCameraController.getUncroppedSnapshotSize(CameraController.REF_VIEW); AspectRatio viewRatio = AspectRatio.of(getWidth(), getHeight()); Rect crop = CropHelper.computeCrop(preview, viewRatio); Size cropSize = new Size(crop.width(), crop.height()); @@ -1597,7 +1618,7 @@ public class CameraView extends FrameLayout implements LifecycleObserver { interface CameraCallbacks extends OrientationHelper.Callback { void dispatchOnCameraOpened(CameraOptions options); void dispatchOnCameraClosed(); - void onCameraPreviewSizeChanged(); + void onCameraPreviewStreamSizeChanged(); void onShutter(boolean shouldPlaySound); void dispatchOnVideoTaken(VideoResult result); void dispatchOnPictureTaken(PictureResult result); @@ -1642,8 +1663,8 @@ public class CameraView extends FrameLayout implements LifecycleObserver { } @Override - public void onCameraPreviewSizeChanged() { - mLogger.i("onCameraPreviewSizeChanged"); + public void onCameraPreviewStreamSizeChanged() { + mLogger.i("onCameraPreviewStreamSizeChanged"); // Camera preview size has changed. // Request a layout pass for onMeasure() to do its stuff. // Potentially this will change CameraView size, which changes Surface size, diff --git a/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotPictureRecorder.java b/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotPictureRecorder.java index 11fb97dc..444d5a70 100644 --- a/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotPictureRecorder.java +++ b/cameraview/src/main/java/com/otaliastudios/cameraview/SnapshotPictureRecorder.java @@ -37,7 +37,7 @@ class SnapshotPictureRecorder extends PictureRecorder { mCamera = camera; mOutputRatio = outputRatio; mFormat = mController.mPreviewFormat; - mSensorPreviewSize = mController.mPreviewSize; + mSensorPreviewSize = mController.mPreviewStreamSize; } @Override @@ -189,7 +189,7 @@ class SnapshotPictureRecorder extends PictureRecorder { // It seems that the buffers are already cleared here, so we need to allocate again. camera.setPreviewCallbackWithBuffer(null); // Release anything left camera.setPreviewCallbackWithBuffer(mController); // Add ourselves - mController.mFrameManager.allocate(ImageFormat.getBitsPerPixel(mFormat), mController.mPreviewSize); + mController.mFrameManager.allocate(ImageFormat.getBitsPerPixel(mFormat), mController.mPreviewStreamSize); } }); } diff --git a/cameraview/src/main/res/values/attrs.xml b/cameraview/src/main/res/values/attrs.xml index f25628fe..5ea86273 100644 --- a/cameraview/src/main/res/values/attrs.xml +++ b/cameraview/src/main/res/values/attrs.xml @@ -25,6 +25,9 @@ + + + diff --git a/cameraview/src/main/views/com/otaliastudios/cameraview/CameraPreview.java b/cameraview/src/main/views/com/otaliastudios/cameraview/CameraPreview.java index 43bb0ec2..9469ef61 100644 --- a/cameraview/src/main/views/com/otaliastudios/cameraview/CameraPreview.java +++ b/cameraview/src/main/views/com/otaliastudios/cameraview/CameraPreview.java @@ -6,6 +6,13 @@ import androidx.annotation.Nullable; import android.view.View; import android.view.ViewGroup; +/** + * A CameraPreview takes in input stream from the {@link CameraController}, and streams it + * into an output surface that belongs to the view hierarchy. + * + * @param the type of view which hosts the content surface + * @param the type of output, either {@link android.view.SurfaceHolder} or {@link android.graphics.SurfaceTexture} + */ abstract class CameraPreview { protected final static CameraLogger LOG = CameraLogger.create(CameraPreview.class.getSimpleName()); @@ -60,8 +67,8 @@ abstract class CameraPreview { // As far as I can see, these are the actual preview dimensions, as set in CameraParameters. // This is called by the CameraImpl. // These must be alredy rotated, if needed, to be consistent with surface/view sizes. - void setInputStreamSize(int width, int height, boolean wasFlipped) { - LOG.i("setInputStreamSize:", "desiredW=", width, "desiredH=", height); + void setStreamSize(int width, int height, boolean wasFlipped) { + LOG.i("setStreamSize:", "desiredW=", width, "desiredH=", height); mInputStreamWidth = width; mInputStreamHeight = height; mInputFlipped = wasFlipped; @@ -71,12 +78,12 @@ abstract class CameraPreview { } @NonNull - final Size getInputStreamSize() { + final Size getStreamSize() { return new Size(mInputStreamWidth, mInputStreamHeight); } @NonNull - final Size getOutputSurfaceSize() { + final Size getSurfaceSize() { return new Size(mOutputSurfaceWidth, mOutputSurfaceHeight); } @@ -90,8 +97,8 @@ abstract class CameraPreview { @SuppressWarnings("WeakerAccess") - protected final void dispatchOnOutputSurfaceAvailable(int width, int height) { - LOG.i("dispatchOnOutputSurfaceAvailable:", "w=", width, "h=", height); + protected final void dispatchOnSurfaceAvailable(int width, int height) { + LOG.i("dispatchOnSurfaceAvailable:", "w=", width, "h=", height); mOutputSurfaceWidth = width; mOutputSurfaceHeight = height; if (mOutputSurfaceWidth > 0 && mOutputSurfaceHeight > 0) { @@ -104,8 +111,8 @@ abstract class CameraPreview { // As far as I can see, these are the view/surface dimensions. // This is called by subclasses. @SuppressWarnings("WeakerAccess") - protected final void dispatchOnOutputSurfaceSizeChanged(int width, int height) { - LOG.i("dispatchOnOutputSurfaceSizeChanged:", "w=", width, "h=", height); + protected final void dispatchOnSurfaceSizeChanged(int width, int height) { + LOG.i("dispatchOnSurfaceSizeChanged:", "w=", width, "h=", height); if (width != mOutputSurfaceWidth || height != mOutputSurfaceHeight) { mOutputSurfaceWidth = width; mOutputSurfaceHeight = height; @@ -117,7 +124,7 @@ abstract class CameraPreview { } @SuppressWarnings("WeakerAccess") - protected final void dispatchOnOutputSurfaceDestroyed() { + protected final void dispatchOnSurfaceDestroyed() { mOutputSurfaceWidth = 0; mOutputSurfaceHeight = 0; mSurfaceCallback.onSurfaceDestroyed(); diff --git a/cameraview/src/main/views/com/otaliastudios/cameraview/GlCameraPreview.java b/cameraview/src/main/views/com/otaliastudios/cameraview/GlCameraPreview.java index 44ece2a9..d29463f3 100644 --- a/cameraview/src/main/views/com/otaliastudios/cameraview/GlCameraPreview.java +++ b/cameraview/src/main/views/com/otaliastudios/cameraview/GlCameraPreview.java @@ -85,7 +85,7 @@ class GlCameraPreview extends CameraPreview imple @Override public void surfaceDestroyed(SurfaceHolder holder) { - dispatchOnOutputSurfaceDestroyed(); + dispatchOnSurfaceDestroyed(); mDispatched = false; } }); @@ -159,7 +159,7 @@ class GlCameraPreview extends CameraPreview imple @Override public void onSurfaceChanged(GL10 gl, final int width, final int height) { if (!mDispatched) { - dispatchOnOutputSurfaceAvailable(width, height); + dispatchOnSurfaceAvailable(width, height); mDispatched = true; } else if (mOutputSurfaceWidth == width && mOutputSurfaceHeight == height) { // I was experimenting and this was happening. @@ -168,13 +168,13 @@ class GlCameraPreview extends CameraPreview imple // With other CameraPreview implementation we could just dispatch the 'size changed' event // to the controller and everything would go straight. In case of GL, apparently we have to // force recreate the EGLContext by calling onPause and onResume in the UI thread. - dispatchOnOutputSurfaceDestroyed(); + dispatchOnSurfaceDestroyed(); getView().post(new Runnable() { @Override public void run() { getView().onPause(); getView().onResume(); - dispatchOnOutputSurfaceAvailable(width, height); + dispatchOnSurfaceAvailable(width, height); } }); } diff --git a/cameraview/src/main/views/com/otaliastudios/cameraview/SurfaceCameraPreview.java b/cameraview/src/main/views/com/otaliastudios/cameraview/SurfaceCameraPreview.java index 7defa65b..ac63767d 100644 --- a/cameraview/src/main/views/com/otaliastudios/cameraview/SurfaceCameraPreview.java +++ b/cameraview/src/main/views/com/otaliastudios/cameraview/SurfaceCameraPreview.java @@ -44,17 +44,17 @@ class SurfaceCameraPreview extends CameraPreview { public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { LOG.i("callback:", "surfaceChanged", "w:", width, "h:", height, "dispatched:", mDispatched); if (!mDispatched) { - dispatchOnOutputSurfaceAvailable(width, height); + dispatchOnSurfaceAvailable(width, height); mDispatched = true; } else { - dispatchOnOutputSurfaceSizeChanged(width, height); + dispatchOnSurfaceSizeChanged(width, height); } } @Override public void surfaceDestroyed(SurfaceHolder holder) { LOG.i("callback:", "surfaceDestroyed"); - dispatchOnOutputSurfaceDestroyed(); + dispatchOnSurfaceDestroyed(); mDispatched = false; } }); diff --git a/cameraview/src/main/views/com/otaliastudios/cameraview/TextureCameraPreview.java b/cameraview/src/main/views/com/otaliastudios/cameraview/TextureCameraPreview.java index c5664013..05b641fc 100644 --- a/cameraview/src/main/views/com/otaliastudios/cameraview/TextureCameraPreview.java +++ b/cameraview/src/main/views/com/otaliastudios/cameraview/TextureCameraPreview.java @@ -28,17 +28,17 @@ class TextureCameraPreview extends CameraPreview { @Override public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { - dispatchOnOutputSurfaceAvailable(width, height); + dispatchOnSurfaceAvailable(width, height); } @Override public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { - dispatchOnOutputSurfaceSizeChanged(width, height); + dispatchOnSurfaceSizeChanged(width, height); } @Override public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { - dispatchOnOutputSurfaceDestroyed(); + dispatchOnSurfaceDestroyed(); return true; } @@ -70,8 +70,8 @@ class TextureCameraPreview extends CameraPreview { @TargetApi(15) @Override - void setInputStreamSize(int width, int height, boolean wasFlipped) { - super.setInputStreamSize(width, height, wasFlipped); + void setStreamSize(int width, int height, boolean wasFlipped) { + super.setStreamSize(width, height, wasFlipped); if (getView().getSurfaceTexture() != null) { getView().getSurfaceTexture().setDefaultBufferSize(width, height); } diff --git a/demo/src/main/java/com/otaliastudios/cameraview/demo/PicturePreviewActivity.java b/demo/src/main/java/com/otaliastudios/cameraview/demo/PicturePreviewActivity.java index 2805c027..97fba873 100644 --- a/demo/src/main/java/com/otaliastudios/cameraview/demo/PicturePreviewActivity.java +++ b/demo/src/main/java/com/otaliastudios/cameraview/demo/PicturePreviewActivity.java @@ -2,8 +2,11 @@ package com.otaliastudios.cameraview.demo; import android.app.Activity; import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.os.Bundle; import androidx.annotation.Nullable; + +import android.util.Log; import android.widget.ImageView; import com.otaliastudios.cameraview.AspectRatio; @@ -45,6 +48,18 @@ public class PicturePreviewActivity extends Activity { imageView.setImageBitmap(bitmap); } }); + + if (result.isSnapshot()) { + // Log the real size for debugging reason. + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(result.getData(), 0, result.getData().length, options); + if (result.getRotation() % 180 != 0) { + Log.e("PicturePreview", "The picture full size is " + result.getSize().getHeight() + "x" + result.getSize().getWidth()); + } else { + Log.e("PicturePreview", "The picture full size is " + result.getSize().getWidth() + "x" + result.getSize().getHeight()); + } + } } @Override diff --git a/demo/src/main/java/com/otaliastudios/cameraview/demo/VideoPreviewActivity.java b/demo/src/main/java/com/otaliastudios/cameraview/demo/VideoPreviewActivity.java index cee2d78f..d7921671 100644 --- a/demo/src/main/java/com/otaliastudios/cameraview/demo/VideoPreviewActivity.java +++ b/demo/src/main/java/com/otaliastudios/cameraview/demo/VideoPreviewActivity.java @@ -5,6 +5,8 @@ import android.media.MediaPlayer; import android.net.Uri; import android.os.Bundle; import androidx.annotation.Nullable; + +import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.MediaController; @@ -73,6 +75,11 @@ public class VideoPreviewActivity extends Activity { lp.height = (int) (viewWidth * (videoHeight / videoWidth)); videoView.setLayoutParams(lp); playVideo(); + + if (result.isSnapshot()) { + // Log the real size for debugging reason. + Log.e("VideoPreview", "The video full size is " + videoWidth + "x" + videoHeight); + } } }); } diff --git a/docs/_posts/2018-12-20-capture-size.md b/docs/_posts/2018-12-20-capture-size.md index 5e34b529..b4a76b34 100644 --- a/docs/_posts/2018-12-20-capture-size.md +++ b/docs/_posts/2018-12-20-capture-size.md @@ -9,9 +9,11 @@ date: 2018-12-20 22:07:22 disqus: 1 --- -If you are planning to use the snapshot APIs, the size of the media output is that of the preview, -accounting for any cropping made when [measuring the view](preview-size.html). -If you are planning to use the standard APIs for capturing, then what follows applies. +If you are planning to use the snapshot APIs, the size of the media output is that of the preview stream, +accounting for any cropping made when [measuring the view](preview-size.html) and other constraints. +Please read the [Snapshot Size](snapshot-size.html) document. + +If you are planning to use the standard APIs, then what follows applies. ### Controlling Size diff --git a/docs/_posts/2018-12-20-capturing-media.md b/docs/_posts/2018-12-20-capturing-media.md index 1538f7ae..2138b383 100644 --- a/docs/_posts/2018-12-20-capturing-media.md +++ b/docs/_posts/2018-12-20-capturing-media.md @@ -46,8 +46,8 @@ resulting snapshots are square as well, no matter what the sensor available size |------|-----|-------|--------------------------|------------------------|---------|-----------| |`takePicture()`|Pictures|Standard|`yes`|`no`|`no`|That of `setPictureSize`| |`takeVideo(File)`|Videos|Standard|`no`|`yes`|`no`|That of `setVideoSize`| -|`takePictureSnapshot()`|Pictures|Snapshot|`yes`|`yes`|`yes`|That of the view| -|`takeVideoSnapshot(File)`|Videos|Snapshot|`yes`|`yes`|`yes`|That of the view| +|`takePictureSnapshot()`|Pictures|Snapshot|`yes`|`yes`|`yes`|That of the preview stream, [or less](snapshot-size.html)| +|`takeVideoSnapshot(File)`|Videos|Snapshot|`yes`|`yes`|`yes`|That of the preview stream, [or less](snapshot-size.html)| Please note that the video snaphot features requires: diff --git a/docs/_posts/2018-12-20-debugging.md b/docs/_posts/2018-12-20-debugging.md index dc7e1ab4..2e2d302d 100644 --- a/docs/_posts/2018-12-20-debugging.md +++ b/docs/_posts/2018-12-20-debugging.md @@ -2,7 +2,7 @@ layout: page title: "Debugging" category: docs -order: 10 +order: 12 date: 2018-12-20 20:02:38 disqus: 1 --- diff --git a/docs/_posts/2018-12-20-error-handling.md b/docs/_posts/2018-12-20-error-handling.md index 72c98cb1..1a24041e 100644 --- a/docs/_posts/2018-12-20-error-handling.md +++ b/docs/_posts/2018-12-20-error-handling.md @@ -2,7 +2,7 @@ layout: page title: "Error Handling" category: docs -order: 9 +order: 11 date: 2018-12-20 20:02:31 disqus: 1 --- diff --git a/docs/_posts/2018-12-20-more-features.md b/docs/_posts/2018-12-20-more-features.md index 984dd940..a971f84a 100644 --- a/docs/_posts/2018-12-20-more-features.md +++ b/docs/_posts/2018-12-20-more-features.md @@ -4,7 +4,7 @@ title: "More features" subtitle: "Undocumented features & more" description: "Undocumented features & more" category: docs -order: 11 +order: 13 date: 2018-12-20 20:41:20 disqus: 1 --- diff --git a/docs/_posts/2018-12-20-preview-size.md b/docs/_posts/2018-12-20-preview-size.md index 4573f591..5914b5c6 100644 --- a/docs/_posts/2018-12-20-preview-size.md +++ b/docs/_posts/2018-12-20-preview-size.md @@ -55,13 +55,13 @@ This means that part of the preview might be hidden, and the output might contai that were not visible during the capture, **unless it is taken as a snapshot, since snapshots account for cropping**. -## Advanced feature: Preview Size Selection +## Advanced feature: Preview Stream Size Selection **Only do this if you know what you are doing. This is typically not needed - prefer picture/video size selectors, -as they will drive the preview size selection and, eventually, the view size. If what you want is just +as they will drive the preview stream size selection and, eventually, the view size. If what you want is just choose an aspect ratio, do so with [Capture Size](capture-size.html) selection.** -As said, `WRAP_CONTENT` adapts the view boundaries to the preview size. The preview size must be determined +As said, `WRAP_CONTENT` adapts the view boundaries to the preview stream size. The preview stream size must be determined based on the sizes that the device sensor & hardware actually support. This operation is done automatically by the engine. The default selector will do the following: @@ -70,10 +70,10 @@ by the engine. The default selector will do the following: - Try to match both, or just one, or fallback to the biggest available size There are not so many reason why you would replace this, other than control the frame processor size -or, indirectly, the snapshot size. You can, however, hook into the process using `setPreviewSize(SizeSelector)`: +or, indirectly, the snapshot size. You can, however, hook into the process using `setPreviewStreamSize(SizeSelector)`: ```java -cameraView.setPreviewSize(new SizeSelector() { +cameraView.setPreviewStreamSize(new SizeSelector() { @Override public List select(List source) { // Receives a list of available sizes. @@ -82,7 +82,7 @@ cameraView.setPreviewSize(new SizeSelector() { }); ``` -After the preview size is determined, if it has changed since list time, the `CameraView` will receive +After the preview stream size is determined, if it has changed since list time, the `CameraView` will receive another call to `onMeasure` so the `WRAP_CONTENT` magic can take place. To understand how SizeSelectors work and the available utilities, please read the [Capture Size](capture-size.html) document. diff --git a/docs/_posts/2018-12-20-runtime-permissions.md b/docs/_posts/2018-12-20-runtime-permissions.md index 0cbcd03c..5027a65f 100644 --- a/docs/_posts/2018-12-20-runtime-permissions.md +++ b/docs/_posts/2018-12-20-runtime-permissions.md @@ -4,7 +4,7 @@ title: "Runtime Permissions" subtitle: "Permissions and Manifest setup" description: "Permissions and Manifest setup" category: docs -order: 8 +order: 10 date: 2018-12-20 20:03:03 disqus: 1 --- diff --git a/docs/_posts/2019-02-24-snapshot-size.md b/docs/_posts/2019-02-24-snapshot-size.md new file mode 100644 index 00000000..8703a232 --- /dev/null +++ b/docs/_posts/2019-02-24-snapshot-size.md @@ -0,0 +1,58 @@ +--- +layout: page +title: "Snapshot Size" +subtitle: "Sizing the snapshots output" +description: "Sizing the snapshots output" +category: docs +order: 9 +date: 2019-02-24 17:36:39 +disqus: 1 +--- + +Snapshots are captured from the preview stream instead of using a separate capture channel. +They are extremely fast, small in size, and give you a low-quality output that can be easily +uploaded or processed. + +The snapshot size is based on the size of the preview stream, which is described in the [Preview Size](preview-size.html) document. +Although the preview stream size is customizable, note that this is considered an advanced feature, +as the best preview stream size selector already does a good job for the vast majority of use cases. + +When taking snapshots, the preview stream size is then changed to match some constraints. + +### Matching the preview ratio + +Snapshots will automatically be cropped to match the preview aspect ratio. This means that if your +preview is square, you can finally take a square picture or video, regardless of the available sensor sizes. + +Take a look at the [Preview Size](preview-size.html) document to learn about preview sizing. + +### Other constraints + +You can refine the size further by applying `maxWidth` and a `maxHeight` constraints: + +```java +cameraView.setSnapshotMaxWidth(500); +cameraView.setSnapshotMaxHeight(500); +``` + +These values apply to both picture and video snapshots. If the snapshot dimensions exceed these values +(both default `Integer.MAX_VALUE`), the snapshot will be scaled down to match the constraints. + +This is very useful as it decouples the snapshot size logic from the preview. By using small constraints, +you can have a pleasant, good looking preview stream, while still capturing fast, low-res snapshots +with no issues. + +### XML Attributes + +```xml + +``` + +### Related APIs + +|Method|Description| +|------|-----------| +|`setSnapshotMaxWidth(int)`|Sets the max width for snapshots. If out of bounds, the output will be scaled down.| +|`setSnapshotMaxHeight(int)`|Sets the max height for snapshots. If out of bounds, the output will be scaled down.| \ No newline at end of file