diff --git a/packages/camera/CHANGELOG.md b/packages/camera/CHANGELOG.md index 2d09af5b8953..196cd3e2c563 100644 --- a/packages/camera/CHANGELOG.md +++ b/packages/camera/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.5.3 + +* Added new quality presets. +* Now all quality presets can be used to control image capture quality. + ## 0.5.2+2 * Fix memory leak related to not unregistering stream handler in FlutterEventChannel when disposing camera. diff --git a/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java index bf99f8d561d5..763e3b516a62 100644 --- a/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -1,6 +1,7 @@ package io.flutter.plugins.camera; import static android.view.OrientationEventListener.ORIENTATION_UNKNOWN; +import static io.flutter.plugins.camera.CameraUtils.computeBestPreviewSize; import android.annotation.SuppressLint; import android.app.Activity; @@ -16,6 +17,7 @@ import android.hardware.camera2.CaptureFailure; import android.hardware.camera2.CaptureRequest; import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.CamcorderProfile; import android.media.Image; import android.media.ImageReader; import android.media.MediaRecorder; @@ -46,7 +48,6 @@ public class Camera { private final String cameraName; private final Size captureSize; private final Size previewSize; - private final Size videoSize; private final boolean enableAudio; private CameraDevice cameraDevice; @@ -57,8 +58,19 @@ public class Camera { private CaptureRequest.Builder captureRequestBuilder; private MediaRecorder mediaRecorder; private boolean recordingVideo; + private CamcorderProfile recordingProfile; private int currentOrientation = ORIENTATION_UNKNOWN; + // Mirrors camera.dart + public enum ResolutionPreset { + low, + medium, + high, + veryHigh, + ultraHigh, + max, + } + public Camera( final Activity activity, final FlutterView flutterView, @@ -87,21 +99,6 @@ public void onOrientationChanged(int i) { }; orientationEventListener.enable(); - int minHeight; - switch (resolutionPreset) { - case "high": - minHeight = 720; - break; - case "medium": - minHeight = 480; - break; - case "low": - minHeight = 240; - break; - default: - throw new IllegalArgumentException("Unknown preset: " + resolutionPreset); - } - CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraName); StreamConfigurationMap streamConfigurationMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); @@ -110,12 +107,11 @@ public void onOrientationChanged(int i) { //noinspection ConstantConditions isFrontFacing = characteristics.get(CameraCharacteristics.LENS_FACING) == CameraMetadata.LENS_FACING_FRONT; - captureSize = CameraUtils.computeBestCaptureSize(streamConfigurationMap); - Size[] sizes = - CameraUtils.computeBestPreviewAndRecordingSize( - activity, streamConfigurationMap, minHeight, getMediaOrientation(), captureSize); - videoSize = sizes[0]; - previewSize = sizes[1]; + ResolutionPreset preset = ResolutionPreset.valueOf(resolutionPreset); + recordingProfile = + CameraUtils.getBestAvailableCamcorderProfileForResolutionPreset(cameraName, preset); + captureSize = new Size(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight); + previewSize = computeBestPreviewSize(cameraName, preset); } public void setupCameraEventChannel(EventChannel cameraEventChannel) { @@ -143,13 +139,13 @@ private void prepareMediaRecorder(String outputFilePath) throws IOException { // of these function calls. if (enableAudio) mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); - mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); - if (enableAudio) mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); - mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264); - mediaRecorder.setVideoEncodingBitRate(1024 * 1000); - if (enableAudio) mediaRecorder.setAudioSamplingRate(16000); - mediaRecorder.setVideoFrameRate(27); - mediaRecorder.setVideoSize(videoSize.getWidth(), videoSize.getHeight()); + mediaRecorder.setOutputFormat(recordingProfile.fileFormat); + if (enableAudio) mediaRecorder.setAudioEncoder(recordingProfile.audioCodec); + mediaRecorder.setVideoEncoder(recordingProfile.videoCodec); + mediaRecorder.setVideoEncodingBitRate(recordingProfile.videoBitRate); + if (enableAudio) mediaRecorder.setAudioSamplingRate(recordingProfile.audioSampleRate); + mediaRecorder.setVideoFrameRate(recordingProfile.videoFrameRate); + mediaRecorder.setVideoSize(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight); mediaRecorder.setOutputFile(outputFilePath); mediaRecorder.setOrientationHint(getMediaOrientation()); diff --git a/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java b/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java index 517db1537041..e4613fb237c1 100644 --- a/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java +++ b/packages/camera/android/src/main/java/io/flutter/plugins/camera/CameraUtils.java @@ -3,15 +3,14 @@ import android.app.Activity; import android.content.Context; import android.graphics.ImageFormat; -import android.graphics.Point; -import android.graphics.SurfaceTexture; import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraCharacteristics; import android.hardware.camera2.CameraManager; import android.hardware.camera2.CameraMetadata; import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.CamcorderProfile; import android.util.Size; -import android.view.Display; +import io.flutter.plugins.camera.Camera.ResolutionPreset; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -25,61 +24,14 @@ public final class CameraUtils { private CameraUtils() {} - static Size[] computeBestPreviewAndRecordingSize( - Activity activity, - StreamConfigurationMap streamConfigurationMap, - int minHeight, - int orientation, - Size captureSize) { - Size previewSize, videoSize; - Size[] sizes = streamConfigurationMap.getOutputSizes(SurfaceTexture.class); - - // Preview size and video size should not be greater than screen resolution or 1080. - Point screenResolution = new Point(); - - Display display = activity.getWindowManager().getDefaultDisplay(); - display.getRealSize(screenResolution); - - final boolean swapWH = orientation % 180 == 90; - int screenWidth = swapWH ? screenResolution.y : screenResolution.x; - int screenHeight = swapWH ? screenResolution.x : screenResolution.y; - - List goodEnough = new ArrayList<>(); - for (Size s : sizes) { - if (minHeight <= s.getHeight() - && s.getWidth() <= screenWidth - && s.getHeight() <= screenHeight - && s.getHeight() <= 1080) { - goodEnough.add(s); - } + static Size computeBestPreviewSize(String cameraName, ResolutionPreset preset) { + if (preset.ordinal() > ResolutionPreset.high.ordinal()) { + preset = ResolutionPreset.high; } - Collections.sort(goodEnough, new CompareSizesByArea()); - - if (goodEnough.isEmpty()) { - previewSize = sizes[0]; - videoSize = sizes[0]; - } else { - float captureSizeRatio = (float) captureSize.getWidth() / captureSize.getHeight(); - - previewSize = goodEnough.get(0); - for (Size s : goodEnough) { - if ((float) s.getWidth() / s.getHeight() == captureSizeRatio) { - previewSize = s; - break; - } - } - - Collections.reverse(goodEnough); - videoSize = goodEnough.get(0); - for (Size s : goodEnough) { - if ((float) s.getWidth() / s.getHeight() == captureSizeRatio) { - videoSize = s; - break; - } - } - } - return new Size[] {videoSize, previewSize}; + CamcorderProfile profile = + getBestAvailableCamcorderProfileForResolutionPreset(cameraName, preset); + return new Size(profile.videoFrameWidth, profile.videoFrameHeight); } static Size computeBestCaptureSize(StreamConfigurationMap streamConfigurationMap) { @@ -118,6 +70,46 @@ public static List> getAvailableCameras(Activity activity) return cameras; } + static CamcorderProfile getBestAvailableCamcorderProfileForResolutionPreset( + String cameraName, ResolutionPreset preset) { + int cameraId = Integer.parseInt(cameraName); + switch (preset) { + // All of these cases deliberately fall through to get the best available profile. + case max: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_HIGH)) { + return CamcorderProfile.get(CamcorderProfile.QUALITY_HIGH); + } + case ultraHigh: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_2160P)) { + return CamcorderProfile.get(CamcorderProfile.QUALITY_2160P); + } + case veryHigh: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_1080P)) { + return CamcorderProfile.get(CamcorderProfile.QUALITY_1080P); + } + case high: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_720P)) { + return CamcorderProfile.get(CamcorderProfile.QUALITY_720P); + } + case medium: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_480P)) { + return CamcorderProfile.get(CamcorderProfile.QUALITY_480P); + } + case low: + if (CamcorderProfile.hasProfile(cameraId, CamcorderProfile.QUALITY_QVGA)) { + return CamcorderProfile.get(CamcorderProfile.QUALITY_QVGA); + } + default: + if (CamcorderProfile.hasProfile( + Integer.parseInt(cameraName), CamcorderProfile.QUALITY_LOW)) { + return CamcorderProfile.get(CamcorderProfile.QUALITY_LOW); + } else { + throw new IllegalArgumentException( + "No capture session available for current capture session."); + } + } + } + private static class CompareSizesByArea implements Comparator { @Override public int compare(Size lhs, Size rhs) { diff --git a/packages/camera/example/lib/main.dart b/packages/camera/example/lib/main.dart index 70b7ef285796..2e2aa4d0fe1f 100644 --- a/packages/camera/example/lib/main.dart +++ b/packages/camera/example/lib/main.dart @@ -258,7 +258,7 @@ class _CameraExampleHomeState extends State } controller = CameraController( cameraDescription, - ResolutionPreset.high, + ResolutionPreset.medium, enableAudio: enableAudio, ); diff --git a/packages/camera/example/pubspec.yaml b/packages/camera/example/pubspec.yaml index 834fe1b98cee..59f3821abe21 100644 --- a/packages/camera/example/pubspec.yaml +++ b/packages/camera/example/pubspec.yaml @@ -13,6 +13,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + flutter_driver: + sdk: flutter flutter: uses-material-design: true diff --git a/packages/camera/example/test_driver/camera.dart b/packages/camera/example/test_driver/camera.dart new file mode 100644 index 000000000000..7d59016ff0b1 --- /dev/null +++ b/packages/camera/example/test_driver/camera.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/painting.dart'; +import 'package:flutter_driver/driver_extension.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:camera/camera.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:video_player/video_player.dart'; + +void main() { + final Completer completer = Completer(); + Directory testDir; + enableFlutterDriverExtension(handler: (_) => completer.future); + + setUpAll(() async { + final Directory extDir = await getTemporaryDirectory(); + testDir = await Directory('${extDir.path}/test').create(recursive: true); + }); + + tearDownAll(() async { + await testDir.delete(recursive: true); + completer.complete(null); + }); + + final Map presetExpectedSizes = + { + ResolutionPreset.low: + Platform.isAndroid ? const Size(240, 320) : const Size(288, 352), + ResolutionPreset.medium: + Platform.isAndroid ? const Size(480, 720) : const Size(480, 640), + ResolutionPreset.high: const Size(720, 1280), + ResolutionPreset.veryHigh: const Size(1080, 1920), + ResolutionPreset.ultraHigh: const Size(2160, 3840), + // Don't bother checking for max here since it could be anything. + }; + + /// Verify that [actual] has dimensions that are at least as large as + /// [expectedSize]. Allows for a mismatch in portrait vs landscape. Returns + /// whether the dimensions exactly match. + bool assertExpectedDimensions(Size expectedSize, Size actual) { + expect(actual.shortestSide, lessThanOrEqualTo(expectedSize.shortestSide)); + expect(actual.longestSide, lessThanOrEqualTo(expectedSize.longestSide)); + return actual.shortestSide == expectedSize.shortestSide && + actual.longestSide == expectedSize.longestSide; + } + + // This tests that the capture is no bigger than the preset, since we have + // automatic code to fall back to smaller sizes when we need to. Returns + // whether the image is exactly the desired resolution. + Future testCaptureImageResolution( + CameraController controller, ResolutionPreset preset) async { + final Size expectedSize = presetExpectedSizes[preset]; + print( + 'Capturing photo at $preset (${expectedSize.width}x${expectedSize.height}) using camera ${controller.description.name}'); + + // Take Picture + final String filePath = + '${testDir.path}/${DateTime.now().millisecondsSinceEpoch}.jpg'; + await controller.takePicture(filePath); + + // Load picture + final File fileImage = File(filePath); + final Image image = await decodeImageFromList(fileImage.readAsBytesSync()); + + // Verify image dimensions are as expected + expect(image, isNotNull); + return assertExpectedDimensions( + expectedSize, Size(image.height.toDouble(), image.width.toDouble())); + } + + test('Capture specific image resolutions', () async { + final List cameras = await availableCameras(); + if (cameras.isEmpty) { + return; + } + for (CameraDescription cameraDescription in cameras) { + bool previousPresetExactlySupported = true; + for (MapEntry preset + in presetExpectedSizes.entries) { + final CameraController controller = + CameraController(cameraDescription, preset.key); + await controller.initialize(); + final bool presetExactlySupported = + await testCaptureImageResolution(controller, preset.key); + assert(!(!previousPresetExactlySupported && presetExactlySupported), + 'The camera took higher resolution pictures at a lower resolution.'); + previousPresetExactlySupported = presetExactlySupported; + await controller.dispose(); + } + } + }); + + // This tests that the capture is no bigger than the preset, since we have + // automatic code to fall back to smaller sizes when we need to. Returns + // whether the image is exactly the desired resolution. + Future testCaptureVideoResolution( + CameraController controller, ResolutionPreset preset) async { + final Size expectedSize = presetExpectedSizes[preset]; + print( + 'Capturing video at $preset (${expectedSize.width}x${expectedSize.height}) using camera ${controller.description.name}'); + + // Take Video + final String filePath = + '${testDir.path}/${DateTime.now().millisecondsSinceEpoch}.mp4'; + await controller.startVideoRecording(filePath); + sleep(const Duration(milliseconds: 300)); + await controller.stopVideoRecording(); + + // Load video metadata + final File videoFile = File(filePath); + final VideoPlayerController videoController = + VideoPlayerController.file(videoFile); + await videoController.initialize(); + final Size video = videoController.value.size; + + // Verify image dimensions are as expected + expect(video, isNotNull); + return assertExpectedDimensions( + expectedSize, Size(video.height, video.width)); + } + + test('Capture specific video resolutions', () async { + final List cameras = await availableCameras(); + if (cameras.isEmpty) { + return; + } + for (CameraDescription cameraDescription in cameras) { + bool previousPresetExactlySupported = true; + for (MapEntry preset + in presetExpectedSizes.entries) { + final CameraController controller = + CameraController(cameraDescription, preset.key); + await controller.initialize(); + await controller.prepareForVideoRecording(); + final bool presetExactlySupported = + await testCaptureVideoResolution(controller, preset.key); + assert(!(!previousPresetExactlySupported && presetExactlySupported), + 'The camera took higher resolution pictures at a lower resolution.'); + previousPresetExactlySupported = presetExactlySupported; + await controller.dispose(); + } + } + }); +} diff --git a/packages/camera/example/test_driver/camera_test.dart b/packages/camera/example/test_driver/camera_test.dart new file mode 100644 index 000000000000..38fe6c447e05 --- /dev/null +++ b/packages/camera/example/test_driver/camera_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_driver/flutter_driver.dart'; + +Future main() async { + final FlutterDriver driver = await FlutterDriver.connect(); + await driver.requestData(null, timeout: const Duration(minutes: 1)); + driver.close(); +} diff --git a/packages/camera/ios/Classes/CameraPlugin.m b/packages/camera/ios/Classes/CameraPlugin.m index 4c001926ae25..3aeaa4612a3e 100644 --- a/packages/camera/ios/Classes/CameraPlugin.m +++ b/packages/camera/ios/Classes/CameraPlugin.m @@ -6,8 +6,8 @@ static FlutterError *getFlutterError(NSError *error) { return [FlutterError errorWithCode:[NSString stringWithFormat:@"Error %d", (int)error.code] - message:error.domain - details:error.localizedDescription]; + message:error.localizedDescription + details:error.domain]; } @interface FLTSavePhotoDelegate : NSObject @@ -114,6 +114,43 @@ - (UIImageOrientation)getImageRotation { } @end +// Mirrors ResolutionPreset in camera.dart +typedef enum { + veryLow, + low, + medium, + high, + veryHigh, + ultraHigh, + max, +} ResolutionPreset; + +static ResolutionPreset getResolutionPresetForString(NSString *preset) { + if ([preset isEqualToString:@"veryLow"]) { + return veryLow; + } else if ([preset isEqualToString:@"low"]) { + return low; + } else if ([preset isEqualToString:@"medium"]) { + return medium; + } else if ([preset isEqualToString:@"high"]) { + return high; + } else if ([preset isEqualToString:@"veryHigh"]) { + return veryHigh; + } else if ([preset isEqualToString:@"ultraHigh"]) { + return ultraHigh; + } else if ([preset isEqualToString:@"max"]) { + return max; + } else { + NSError *error = [NSError errorWithDomain:NSCocoaErrorDomain + code:NSURLErrorUnknown + userInfo:@{ + NSLocalizedDescriptionKey : [NSString + stringWithFormat:@"Unknown resolution preset %@", preset] + }]; + @throw error; + } +} + @interface FLTCam : NSObject @@ -18,10 +18,12 @@ dependencies: sdk: flutter dev_dependencies: - flutter_test: - sdk: flutter path_provider: ^0.5.0 video_player: ^0.10.0 + flutter_test: + sdk: flutter + flutter_driver: + sdk: flutter flutter: plugin: