From 50b843105431d43e4ac1f059410839605b859213 Mon Sep 17 00:00:00 2001 From: "Vladimir E. Koltunov" Date: Fri, 5 Apr 2024 18:39:00 +0300 Subject: [PATCH] [camera] Camera with MediaSettings: platform implementations (federated) (#5223) ## Platform implementations of federated plugin This is the `platform implementations` part of `camera` PR #3586. `camera_platform_interface: 2.6.0` merged and published in PR #3615. Now repeating steps 3,4 (see [Changing federated plugins](https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages#changing-federated-plugins)), because `camera/camera` depends on implementations `camera/camera_android`, `camera/camera_web` etc. --- packages/camera/camera_android/CHANGELOG.md | 4 + .../io/flutter/plugins/camera/Camera.java | 93 ++++- .../plugins/camera/MethodCallHandlerImpl.java | 7 +- .../camera/media/MediaRecorderBuilder.java | 91 ++++- .../io/flutter/plugins/camera/CameraTest.java | 266 +++++++++++-- .../CameraTest_getRecordingProfileTest.java | 10 +- .../media/MediaRecorderBuilderTest.java | 85 ++++- .../example/integration_test/camera_test.dart | 175 +++++++-- .../example/lib/camera_controller.dart | 28 +- .../camera_android/example/lib/main.dart | 7 +- .../camera_android/example/pubspec.yaml | 2 +- .../lib/src/android_camera.dart | 22 +- packages/camera/camera_android/pubspec.yaml | 4 +- .../test/android_camera_test.dart | 48 +++ .../camera_android_camerax/CHANGELOG.md | 4 + .../integration_test/integration_test.dart | 18 +- .../example/lib/camera_controller.dart | 21 +- .../example/lib/main.dart | 8 +- .../example/pubspec.yaml | 3 +- .../lib/src/android_camera_camerax.dart | 27 +- .../camera_android_camerax/pubspec.yaml | 4 +- .../test/android_camera_camerax_test.dart | 50 ++- .../camera/camera_avfoundation/CHANGELOG.md | 4 + .../example/integration_test/camera_test.dart | 123 ++++++ .../ios/Runner.xcodeproj/project.pbxproj | 4 + .../ios/RunnerTests/CameraSettingsTests.m | 350 ++++++++++++++++++ .../example/ios/RunnerTests/CameraTestUtils.h | 7 + .../example/ios/RunnerTests/CameraTestUtils.m | 117 ++++-- .../example/lib/camera_controller.dart | 46 ++- .../camera_avfoundation/example/lib/main.dart | 4 +- .../ios/Classes/CameraPlugin.m | 92 ++++- .../ios/Classes/CameraPlugin_Test.h | 1 - .../camera_avfoundation/ios/Classes/FLTCam.h | 9 +- .../camera_avfoundation/ios/Classes/FLTCam.m | 134 +++++-- .../ios/Classes/FLTCamMediaSettings.h | 54 +++ .../ios/Classes/FLTCamMediaSettings.m | 36 ++ .../Classes/FLTCamMediaSettingsAVWrapper.h | 112 ++++++ .../Classes/FLTCamMediaSettingsAVWrapper.m | 55 +++ .../ios/Classes/FLTCam_Test.h | 6 +- .../lib/src/avfoundation_camera.dart | 23 +- .../camera/camera_avfoundation/pubspec.yaml | 3 +- .../test/avfoundation_camera_test.dart | 48 +++ packages/camera/camera_web/CHANGELOG.md | 3 +- .../integration_test/camera_bitrate_test.dart | 146 ++++++++ .../integration_test/camera_web_test.dart | 163 +++++--- .../camera/camera_web/example/pubspec.yaml | 8 +- .../camera/camera_web/lib/src/camera.dart | 9 + .../camera_web/lib/src/camera_service.dart | 59 ++- .../camera/camera_web/lib/src/camera_web.dart | 24 +- packages/camera/camera_web/pubspec.yaml | 4 +- packages/camera/camera_windows/CHANGELOG.md | 3 +- .../camera_windows/example/lib/main.dart | 34 +- .../camera_windows/example/pubspec.yaml | 2 +- .../camera_windows/lib/camera_windows.dart | 23 +- packages/camera/camera_windows/pubspec.yaml | 4 +- .../test/camera_windows_test.dart | 65 +++- .../camera/camera_windows/windows/camera.cpp | 12 +- .../camera/camera_windows/windows/camera.h | 16 +- .../camera_windows/windows/camera_plugin.cpp | 22 +- .../windows/capture_controller.cpp | 10 +- .../windows/capture_controller.h | 13 +- .../camera_windows/windows/record_handler.cpp | 35 +- .../camera_windows/windows/record_handler.h | 42 ++- .../windows/test/camera_plugin_test.cpp | 4 +- .../windows/test/camera_test.cpp | 27 +- .../windows/test/capture_controller_test.cpp | 125 ++++++- .../camera_windows/windows/test/mocks.h | 61 ++- 67 files changed, 2703 insertions(+), 416 deletions(-) create mode 100644 packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraSettingsTests.m create mode 100644 packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettings.h create mode 100644 packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettings.m create mode 100644 packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettingsAVWrapper.h create mode 100644 packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettingsAVWrapper.m create mode 100644 packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart diff --git a/packages/camera/camera_android/CHANGELOG.md b/packages/camera/camera_android/CHANGELOG.md index 82d27a401e85..01dab3badc0c 100644 --- a/packages/camera/camera_android/CHANGELOG.md +++ b/packages/camera/camera_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.10.9 + +* Adds support to control video FPS and bitrate. See `CameraController.withSettings`. + ## 0.10.8+18 * Updates annotations lib to 1.7.1. diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java index 7c28505eab9c..ae9d158fccb4 100644 --- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java +++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/Camera.java @@ -29,12 +29,14 @@ import android.os.HandlerThread; import android.os.Looper; import android.util.Log; +import android.util.Range; import android.util.Size; import android.view.Display; import android.view.Surface; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import io.flutter.BuildConfig; import io.flutter.embedding.engine.systemchannels.PlatformChannel; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodChannel; @@ -52,6 +54,7 @@ import io.flutter.plugins.camera.features.flash.FlashFeature; import io.flutter.plugins.camera.features.flash.FlashMode; import io.flutter.plugins.camera.features.focuspoint.FocusPointFeature; +import io.flutter.plugins.camera.features.fpsrange.FpsRangeFeature; import io.flutter.plugins.camera.features.resolution.ResolutionFeature; import io.flutter.plugins.camera.features.resolution.ResolutionPreset; import io.flutter.plugins.camera.features.sensororientation.DeviceOrientationManager; @@ -111,8 +114,7 @@ class Camera private int initialCameraFacing; private final SurfaceTextureEntry flutterTexture; - private final ResolutionPreset resolutionPreset; - private final boolean enableAudio; + private final VideoCaptureSettings videoCaptureSettings; private final Context applicationContext; final DartMessenger dartMessenger; private CameraProperties cameraProperties; @@ -185,29 +187,80 @@ public void close() { } } + public static class VideoCaptureSettings { + @NonNull public final ResolutionPreset resolutionPreset; + public final boolean enableAudio; + @Nullable public final Integer fps; + @Nullable public final Integer videoBitrate; + @Nullable public final Integer audioBitrate; + + public VideoCaptureSettings( + @NonNull ResolutionPreset resolutionPreset, + boolean enableAudio, + @Nullable Integer fps, + @Nullable Integer videoBitrate, + @Nullable Integer audioBitrate) { + this.resolutionPreset = resolutionPreset; + this.enableAudio = enableAudio; + this.fps = fps; + this.videoBitrate = videoBitrate; + this.audioBitrate = audioBitrate; + } + + public VideoCaptureSettings(@NonNull ResolutionPreset resolutionPreset, boolean enableAudio) { + this(resolutionPreset, enableAudio, null, null, null); + } + } + public Camera( final Activity activity, final SurfaceTextureEntry flutterTexture, final CameraFeatureFactory cameraFeatureFactory, final DartMessenger dartMessenger, final CameraProperties cameraProperties, - final ResolutionPreset resolutionPreset, - final boolean enableAudio) { + final VideoCaptureSettings videoCaptureSettings) { if (activity == null) { throw new IllegalStateException("No activity available!"); } this.activity = activity; - this.enableAudio = enableAudio; this.flutterTexture = flutterTexture; this.dartMessenger = dartMessenger; this.applicationContext = activity.getApplicationContext(); this.cameraProperties = cameraProperties; this.cameraFeatureFactory = cameraFeatureFactory; - this.resolutionPreset = resolutionPreset; + this.videoCaptureSettings = videoCaptureSettings; this.cameraFeatures = CameraFeatures.init( - cameraFeatureFactory, cameraProperties, activity, dartMessenger, resolutionPreset); + cameraFeatureFactory, + cameraProperties, + activity, + dartMessenger, + videoCaptureSettings.resolutionPreset); + + Integer recordingFps = null; + + if (videoCaptureSettings.fps != null && videoCaptureSettings.fps.intValue() > 0) { + recordingFps = videoCaptureSettings.fps; + } else { + + if (SdkCapabilityChecker.supportsEncoderProfiles()) { + EncoderProfiles encoderProfiles = getRecordingProfile(); + if (encoderProfiles != null && encoderProfiles.getVideoProfiles().size() > 0) { + recordingFps = encoderProfiles.getVideoProfiles().get(0).getFrameRate(); + } + } else { + CamcorderProfile camcorderProfile = getRecordingProfileLegacy(); + recordingFps = null != camcorderProfile ? camcorderProfile.videoFrameRate : null; + } + } + + if (recordingFps != null && recordingFps.intValue() > 0) { + + final FpsRangeFeature fpsRange = new FpsRangeFeature(cameraProperties); + fpsRange.setValue(new Range(recordingFps, recordingFps)); + this.cameraFeatures.setFpsRange(fpsRange); + } // Create capture callback. captureTimeouts = new CaptureTimeoutsWrapper(3000, 3000); @@ -257,14 +310,28 @@ private void prepareMediaRecorder(String outputFilePath) throws IOException { // TODO(camsim99): Revert changes that allow legacy code to be used when recordingProfile is null // once this has largely been fixed on the Android side. https://github.com/flutter/flutter/issues/119668 if (SdkCapabilityChecker.supportsEncoderProfiles() && getRecordingProfile() != null) { - mediaRecorderBuilder = new MediaRecorderBuilder(getRecordingProfile(), outputFilePath); + mediaRecorderBuilder = + new MediaRecorderBuilder( + getRecordingProfile(), + new MediaRecorderBuilder.RecordingParameters( + outputFilePath, + videoCaptureSettings.fps, + videoCaptureSettings.videoBitrate, + videoCaptureSettings.audioBitrate)); } else { - mediaRecorderBuilder = new MediaRecorderBuilder(getRecordingProfileLegacy(), outputFilePath); + mediaRecorderBuilder = + new MediaRecorderBuilder( + getRecordingProfileLegacy(), + new MediaRecorderBuilder.RecordingParameters( + outputFilePath, + videoCaptureSettings.fps, + videoCaptureSettings.videoBitrate, + videoCaptureSettings.audioBitrate)); } mediaRecorder = mediaRecorderBuilder - .setEnableAudio(enableAudio) + .setEnableAudio(videoCaptureSettings.enableAudio) .setMediaOrientation( lockedOrientation == null ? getDeviceOrientationManager().getVideoOrientation() @@ -1316,7 +1383,11 @@ public void setDescriptionWhileRecording( cameraProperties = properties; cameraFeatures = CameraFeatures.init( - cameraFeatureFactory, cameraProperties, activity, dartMessenger, resolutionPreset); + cameraFeatureFactory, + cameraProperties, + activity, + dartMessenger, + videoCaptureSettings.resolutionPreset); cameraFeatures.setAutoFocus( cameraFeatureFactory.createAutoFocusFeature(cameraProperties, true)); try { diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java index aad62bbaba85..19b16e8f7bfb 100644 --- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java +++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/MethodCallHandlerImpl.java @@ -388,6 +388,9 @@ private void instantiateCamera(MethodCall call, Result result) throws CameraAcce String cameraName = call.argument("cameraName"); String preset = call.argument("resolutionPreset"); boolean enableAudio = call.argument("enableAudio"); + Integer fps = call.argument("fps"); + Integer videoBitrate = call.argument("videoBitrate"); + Integer audioBitrate = call.argument("audioBitrate"); TextureRegistry.SurfaceTextureEntry flutterSurfaceTexture = textureRegistry.createSurfaceTexture(); @@ -405,8 +408,8 @@ private void instantiateCamera(MethodCall call, Result result) throws CameraAcce new CameraFeatureFactoryImpl(), dartMessenger, cameraProperties, - resolutionPreset, - enableAudio); + new Camera.VideoCaptureSettings( + resolutionPreset, enableAudio, fps, videoBitrate, audioBitrate)); Map reply = new HashMap<>(); reply.put("cameraId", flutterSurfaceTexture.id()); diff --git a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java index d41c3335f221..15cc38465b62 100644 --- a/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java +++ b/packages/camera/camera_android/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java @@ -8,6 +8,7 @@ import android.media.EncoderProfiles; import android.media.MediaRecorder; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import io.flutter.plugins.camera.SdkCapabilityChecker; import java.io.IOException; @@ -19,42 +20,64 @@ MediaRecorder makeMediaRecorder() { } } - private final String outputFilePath; + public static class RecordingParameters { + @NonNull public final String outputFilePath; + @Nullable public final Integer fps; + @Nullable public final Integer videoBitrate; + @Nullable public final Integer audioBitrate; + + public RecordingParameters(@NonNull String outputFilePath) { + this(outputFilePath, null, null, null); + } + + public RecordingParameters( + @NonNull String outputFilePath, + @Nullable Integer fps, + @Nullable Integer videoBitrate, + @Nullable Integer audioBitrate) { + this.outputFilePath = outputFilePath; + this.fps = fps; + this.videoBitrate = videoBitrate; + this.audioBitrate = audioBitrate; + } + } + private final CamcorderProfile camcorderProfile; private final EncoderProfiles encoderProfiles; private final MediaRecorderFactory recorderFactory; + @NonNull private final RecordingParameters parameters; private boolean enableAudio; private int mediaOrientation; public MediaRecorderBuilder( - @NonNull CamcorderProfile camcorderProfile, @NonNull String outputFilePath) { - this(camcorderProfile, outputFilePath, new MediaRecorderFactory()); + @NonNull CamcorderProfile camcorderProfile, @NonNull RecordingParameters parameters) { + this(camcorderProfile, new MediaRecorderFactory(), parameters); } public MediaRecorderBuilder( - @NonNull EncoderProfiles encoderProfiles, @NonNull String outputFilePath) { - this(encoderProfiles, outputFilePath, new MediaRecorderFactory()); + @NonNull EncoderProfiles encoderProfiles, @NonNull RecordingParameters parameters) { + this(encoderProfiles, new MediaRecorderFactory(), parameters); } MediaRecorderBuilder( @NonNull CamcorderProfile camcorderProfile, - @NonNull String outputFilePath, - MediaRecorderFactory helper) { - this.outputFilePath = outputFilePath; + MediaRecorderFactory helper, + @NonNull RecordingParameters parameters) { this.camcorderProfile = camcorderProfile; this.encoderProfiles = null; this.recorderFactory = helper; + this.parameters = parameters; } MediaRecorderBuilder( @NonNull EncoderProfiles encoderProfiles, - @NonNull String outputFilePath, - MediaRecorderFactory helper) { - this.outputFilePath = outputFilePath; + MediaRecorderFactory helper, + @NonNull RecordingParameters parameters) { this.encoderProfiles = encoderProfiles; this.camcorderProfile = null; this.recorderFactory = helper; + this.parameters = parameters; } @NonNull @@ -79,34 +102,62 @@ public MediaRecorder build() throws IOException, NullPointerException, IndexOutO mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); if (SdkCapabilityChecker.supportsEncoderProfiles() && encoderProfiles != null) { + mediaRecorder.setOutputFormat(encoderProfiles.getRecommendedFileFormat()); + EncoderProfiles.VideoProfile videoProfile = encoderProfiles.getVideoProfiles().get(0); - EncoderProfiles.AudioProfile audioProfile = encoderProfiles.getAudioProfiles().get(0); - mediaRecorder.setOutputFormat(encoderProfiles.getRecommendedFileFormat()); if (enableAudio) { + EncoderProfiles.AudioProfile audioProfile = encoderProfiles.getAudioProfiles().get(0); + mediaRecorder.setAudioEncoder(audioProfile.getCodec()); - mediaRecorder.setAudioEncodingBitRate(audioProfile.getBitrate()); + mediaRecorder.setAudioEncodingBitRate( + (parameters.audioBitrate != null && parameters.audioBitrate.intValue() > 0) + ? parameters.audioBitrate + : audioProfile.getBitrate()); mediaRecorder.setAudioSamplingRate(audioProfile.getSampleRate()); } + mediaRecorder.setVideoEncoder(videoProfile.getCodec()); - mediaRecorder.setVideoEncodingBitRate(videoProfile.getBitrate()); - mediaRecorder.setVideoFrameRate(videoProfile.getFrameRate()); + + int videoBitrate = + (parameters.videoBitrate != null && parameters.videoBitrate.intValue() > 0) + ? parameters.videoBitrate + : videoProfile.getBitrate(); + + mediaRecorder.setVideoEncodingBitRate(videoBitrate); + + int fps = + (parameters.fps != null && parameters.fps.intValue() > 0) + ? parameters.fps + : videoProfile.getFrameRate(); + + mediaRecorder.setVideoFrameRate(fps); + mediaRecorder.setVideoSize(videoProfile.getWidth(), videoProfile.getHeight()); } else if (camcorderProfile != null) { mediaRecorder.setOutputFormat(camcorderProfile.fileFormat); if (enableAudio) { mediaRecorder.setAudioEncoder(camcorderProfile.audioCodec); - mediaRecorder.setAudioEncodingBitRate(camcorderProfile.audioBitRate); + mediaRecorder.setAudioEncodingBitRate( + (parameters.audioBitrate != null && parameters.audioBitrate.intValue() > 0) + ? parameters.audioBitrate + : camcorderProfile.audioBitRate); mediaRecorder.setAudioSamplingRate(camcorderProfile.audioSampleRate); } mediaRecorder.setVideoEncoder(camcorderProfile.videoCodec); - mediaRecorder.setVideoEncodingBitRate(camcorderProfile.videoBitRate); - mediaRecorder.setVideoFrameRate(camcorderProfile.videoFrameRate); + mediaRecorder.setVideoEncodingBitRate( + (parameters.videoBitrate != null && parameters.videoBitrate.intValue() > 0) + ? parameters.videoBitrate + : camcorderProfile.videoBitRate); + mediaRecorder.setVideoFrameRate( + (parameters.fps != null && parameters.fps.intValue() > 0) + ? parameters.fps + : camcorderProfile.videoFrameRate); mediaRecorder.setVideoSize( camcorderProfile.videoFrameWidth, camcorderProfile.videoFrameHeight); } - mediaRecorder.setOutputFile(outputFilePath); + mediaRecorder.setOutputFile(parameters.outputFilePath); mediaRecorder.setOrientationHint(this.mediaOrientation); mediaRecorder.prepare(); diff --git a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest.java b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest.java index 7fdfa24d99fe..507f2aefe1a1 100644 --- a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest.java +++ b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest.java @@ -8,31 +8,21 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import android.app.Activity; +import android.content.Context; import android.graphics.SurfaceTexture; -import android.hardware.camera2.CameraAccessException; -import android.hardware.camera2.CameraCaptureSession; -import android.hardware.camera2.CameraDevice; -import android.hardware.camera2.CameraMetadata; -import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.*; import android.hardware.camera2.params.SessionConfiguration; +import android.media.CamcorderProfile; import android.media.ImageReader; import android.media.MediaRecorder; import android.os.Build; import android.os.Handler; import android.os.HandlerThread; +import android.util.Range; import android.util.Size; import android.view.Surface; import androidx.annotation.NonNull; @@ -63,19 +53,32 @@ import io.flutter.plugins.camera.media.ImageStreamReader; import io.flutter.plugins.camera.utils.TestUtils; import io.flutter.view.TextureRegistry; +import java.io.Closeable; +import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedConstruction; import org.mockito.MockedStatic; +import org.mockito.Mockito; class FakeCameraDeviceWrapper implements CameraDeviceWrapper { final List captureRequests; + @Nullable final CameraCaptureSession session; FakeCameraDeviceWrapper(List captureRequests) { + this(captureRequests, null); + } + + FakeCameraDeviceWrapper( + List captureRequests, CameraCaptureSession session) { this.captureRequests = captureRequests; + this.session = session; } @NonNull @@ -85,13 +88,21 @@ public CaptureRequest.Builder createCaptureRequest(int var1) { } @Override - public void createCaptureSession(SessionConfiguration config) {} + public void createCaptureSession(SessionConfiguration config) { + if (session != null) { + config.getStateCallback().onConfigured(session); + } + } @Override public void createCaptureSession( @NonNull List outputs, @NonNull CameraCaptureSession.StateCallback callback, - @Nullable Handler handler) {} + @Nullable Handler handler) { + if (session != null) { + callback.onConfigured(session); + } + } @Override public void close() {} @@ -109,10 +120,13 @@ public class CameraTest { private MockedStatic mockHandlerFactory; private Handler mockHandler; + private RangeConstruction mockRangeConstruction; + @Before public void before() { + + mockRangeConstruction = new RangeConstruction(); mockCameraProperties = mock(CameraProperties.class); - mockCameraFeatureFactory = new TestCameraFeatureFactory(); mockDartMessenger = mock(DartMessenger.class); mockCaptureSession = mock(CameraCaptureSession.class); mockPreviewRequestBuilder = mock(CaptureRequest.Builder.class); @@ -134,6 +148,19 @@ public void before() { .when(() -> Camera.HandlerThreadFactory.create(any())) .thenReturn(mockHandlerThread); + // Use a wildcard, since `new Range[] {...}` + // results in a 'Generic array creation' error. + @SuppressWarnings("unchecked") + final Range[] mockRanges = + (Range[]) new Range[] {new Range(10, 20)}; + + when(mockCameraProperties.getControlAutoExposureAvailableTargetFpsRanges()) + .thenReturn(mockRanges); + + final FpsRangeFeature fpsRangeFeature = new FpsRangeFeature(mockCameraProperties); + + mockCameraFeatureFactory = new TestCameraFeatureFactory(fpsRangeFeature); + camera = new Camera( mockActivity, @@ -141,18 +168,22 @@ public void before() { mockCameraFeatureFactory, mockDartMessenger, mockCameraProperties, - resolutionPreset, - enableAudio); + new Camera.VideoCaptureSettings(resolutionPreset, enableAudio)); + + final CamcorderProfile mockProfileLegacy = mock(CamcorderProfile.class); + mockProfileLegacy.videoFrameRate = 15; + when(camera.getRecordingProfileLegacy()).thenReturn(mockProfileLegacy); TestUtils.setPrivateField(camera, "captureSession", mockCaptureSession); TestUtils.setPrivateField(camera, "previewRequestBuilder", mockPreviewRequestBuilder); } @After - public void after() { + public void after() throws IOException { SdkCapabilityChecker.SDK_VERSION = 0; mockHandlerThreadFactory.close(); mockHandlerFactory.close(); + mockRangeConstruction.close(); } @Test @@ -167,41 +198,41 @@ public void shouldCreateCameraPluginAndSetAllFeatures() { final Activity mockActivity = mock(Activity.class); final TextureRegistry.SurfaceTextureEntry mockFlutterTexture = mock(TextureRegistry.SurfaceTextureEntry.class); - final CameraFeatureFactory mockCameraFeatureFactory = mock(CameraFeatureFactory.class); + final CameraFeatureFactory spyMockCameraFeatureFactory = spy(mockCameraFeatureFactory); final String cameraName = "1"; final ResolutionPreset resolutionPreset = ResolutionPreset.high; final boolean enableAudio = false; when(mockCameraProperties.getCameraName()).thenReturn(cameraName); SensorOrientationFeature mockSensorOrientationFeature = mock(SensorOrientationFeature.class); - when(mockCameraFeatureFactory.createSensorOrientationFeature(any(), any(), any())) + when(spyMockCameraFeatureFactory.createSensorOrientationFeature(any(), any(), any())) .thenReturn(mockSensorOrientationFeature); Camera camera = new Camera( mockActivity, mockFlutterTexture, - mockCameraFeatureFactory, + spyMockCameraFeatureFactory, mockDartMessenger, mockCameraProperties, - resolutionPreset, - enableAudio); + new Camera.VideoCaptureSettings(resolutionPreset, enableAudio)); - verify(mockCameraFeatureFactory, times(1)) + verify(spyMockCameraFeatureFactory, times(1)) .createSensorOrientationFeature(mockCameraProperties, mockActivity, mockDartMessenger); - verify(mockCameraFeatureFactory, times(1)).createAutoFocusFeature(mockCameraProperties, false); - verify(mockCameraFeatureFactory, times(1)).createExposureLockFeature(mockCameraProperties); - verify(mockCameraFeatureFactory, times(1)) + verify(spyMockCameraFeatureFactory, times(1)) + .createAutoFocusFeature(mockCameraProperties, false); + verify(spyMockCameraFeatureFactory, times(1)).createExposureLockFeature(mockCameraProperties); + verify(spyMockCameraFeatureFactory, times(1)) .createExposurePointFeature(eq(mockCameraProperties), eq(mockSensorOrientationFeature)); - verify(mockCameraFeatureFactory, times(1)).createExposureOffsetFeature(mockCameraProperties); - verify(mockCameraFeatureFactory, times(1)).createFlashFeature(mockCameraProperties); - verify(mockCameraFeatureFactory, times(1)) + verify(spyMockCameraFeatureFactory, times(1)).createExposureOffsetFeature(mockCameraProperties); + verify(spyMockCameraFeatureFactory, times(1)).createFlashFeature(mockCameraProperties); + verify(spyMockCameraFeatureFactory, times(1)) .createFocusPointFeature(eq(mockCameraProperties), eq(mockSensorOrientationFeature)); - verify(mockCameraFeatureFactory, times(1)).createFpsRangeFeature(mockCameraProperties); - verify(mockCameraFeatureFactory, times(1)).createNoiseReductionFeature(mockCameraProperties); - verify(mockCameraFeatureFactory, times(1)) + verify(spyMockCameraFeatureFactory, times(1)).createFpsRangeFeature(mockCameraProperties); + verify(spyMockCameraFeatureFactory, times(1)).createNoiseReductionFeature(mockCameraProperties); + verify(spyMockCameraFeatureFactory, times(1)) .createResolutionFeature(mockCameraProperties, resolutionPreset, cameraName); - verify(mockCameraFeatureFactory, times(1)).createZoomLevelFeature(mockCameraProperties); + verify(spyMockCameraFeatureFactory, times(1)).createZoomLevelFeature(mockCameraProperties); assertNotNull("should create a camera", camera); } @@ -1165,6 +1196,127 @@ public void close_doesNotCloseCaptureSessionWhenCameraDeviceNonNull() { verify(mockCaptureSession, never()).close(); } + @Test + public void startVideoRecording_shouldApplySettingsToMediaRecorder() + throws InterruptedException, IOException, CameraAccessException { + + final Activity mockActivity = mock(Activity.class); + final TextureRegistry.SurfaceTextureEntry mockFlutterTexture = + mock(TextureRegistry.SurfaceTextureEntry.class); + final String cameraName = "1"; + final ResolutionPreset resolutionPreset = ResolutionPreset.high; + final boolean enableAudio = true; + + //region These parameters should be set in android MediaRecorder. + final int fps = 15; + final int videoBitrate = 200000; + final int audioBitrate = 32000; + //endregion + + when(mockCameraProperties.getCameraName()).thenReturn(cameraName); + + final Camera.VideoCaptureSettings parameters = + new Camera.VideoCaptureSettings( + resolutionPreset, enableAudio, fps, videoBitrate, audioBitrate); + + // Use a wildcard, since `new Range[] {...}` + // results in a 'Generic array creation' error. + @SuppressWarnings("unchecked") + final Range[] mockRanges = + (Range[]) new Range[] {new Range(10, 20)}; + + when(mockCameraProperties.getControlAutoExposureAvailableTargetFpsRanges()) + .thenReturn(mockRanges); + + final Context mockApplicationContext = mock(Context.class); + when(mockActivity.getApplicationContext()).thenReturn(mockApplicationContext); + + try (final MockedStatic mockFile = mockStatic(File.class); + final MockedConstruction mockMediaRecorder = + Mockito.mockConstruction(MediaRecorder.class)) { + + assertNotNull(mockMediaRecorder); + + mockFile + .when(() -> File.createTempFile(any(), any(), any())) + .thenReturn(new File("/tmp/file.mp4")); + + final FpsRangeFeature fpsRangeFeature = new FpsRangeFeature(mockCameraProperties); + + final Camera camera = + spy( + new Camera( + mockActivity, + mockFlutterTexture, + mockCameraFeatureFactory, + mockDartMessenger, + mockCameraProperties, + parameters)); + + final CamcorderProfile mockProfileLegacy = mock(CamcorderProfile.class); + mockProfileLegacy.videoFrameRate = fps; + when(camera.getRecordingProfileLegacy()).thenReturn(mockProfileLegacy); + + final SensorOrientationFeature mockSensorOrientationFeature = + mockCameraFeatureFactory.createSensorOrientationFeature(mockCameraProperties, null, null); + DeviceOrientationManager mockDeviceOrientationManager = mock(DeviceOrientationManager.class); + + when(mockSensorOrientationFeature.getDeviceOrientationManager()) + .thenReturn(mockDeviceOrientationManager); + + TestUtils.setPrivateField(camera, "captureSession", mockCaptureSession); + TestUtils.setPrivateField(camera, "previewRequestBuilder", mockPreviewRequestBuilder); + + final ArrayList mockRequestBuilders = new ArrayList<>(); + CaptureRequest.Builder mockRequestBuilder = mock(CaptureRequest.Builder.class); + mockRequestBuilders.add(mockRequestBuilder); + final SurfaceTexture mockSurfaceTexture = mock(SurfaceTexture.class); + final Size mockSize = mock(Size.class); + final ImageReader mockPictureImageReader = mock(ImageReader.class); + TestUtils.setPrivateField(camera, "pictureImageReader", mockPictureImageReader); + final CameraDeviceWrapper fakeCamera = + new FakeCameraDeviceWrapper(mockRequestBuilders, mockCaptureSession); + + TestUtils.setPrivateField(camera, "cameraDevice", fakeCamera); + MethodChannel.Result mockResult = mock(MethodChannel.Result.class); + + TextureRegistry.SurfaceTextureEntry cameraFlutterTexture = + (TextureRegistry.SurfaceTextureEntry) TestUtils.getPrivateField(camera, "flutterTexture"); + + ResolutionFeature resolutionFeature = + (ResolutionFeature) + TestUtils.getPrivateField(mockCameraFeatureFactory, "mockResolutionFeature"); + + assertNotNull(cameraFlutterTexture); + when(cameraFlutterTexture.surfaceTexture()).thenReturn(mockSurfaceTexture); + + assertNotNull(resolutionFeature); + when(resolutionFeature.getPreviewSize()).thenReturn(mockSize); + + camera.startVideoRecording(mockResult, null); + + //region Check that FPS parameter affects AE range at which the camera captures frames. + assertEquals(camera.cameraFeatures.getFpsRange().getValue().getLower(), Integer.valueOf(fps)); + assertEquals(camera.cameraFeatures.getFpsRange().getValue().getUpper(), Integer.valueOf(fps)); + + verify(mockRequestBuilder) + .set( + eq(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE), + argThat( + (Range range) -> range.getLower() == fps && range.getUpper() == fps)); + //endregion + + final MediaRecorder recorder = + (MediaRecorder) TestUtils.getPrivateField(camera, "mediaRecorder"); + + //region Check that parameters affects movies, written by MediaRecorder. + verify(recorder).setVideoFrameRate(fps); + verify(recorder).setAudioEncodingBitRate(audioBitrate); + verify(recorder).setVideoEncodingBitRate(videoBitrate); + //endregion + } + } + @Test public void pausePreview_doesNotCallStopRepeatingWhenCameraClosed() throws CameraAccessException { ArrayList mockRequestBuilders = new ArrayList<>(); @@ -1178,6 +1330,42 @@ public void pausePreview_doesNotCallStopRepeatingWhenCameraClosed() throws Camer verify(mockCaptureSession, never()).stopRepeating(); } + /// Allow to use `new android.util.Range(Integer, Integer)` + private static class RangeConstruction implements Closeable { + final Map, Integer> lowers = new HashMap<>(); + final Map, Integer> uppers = new HashMap<>(); + + @SuppressWarnings({"rawtypes"}) + final MockedConstruction rangeMockedConstruction; + + @SuppressWarnings({"unchecked"}) + public RangeConstruction() { + this.rangeMockedConstruction = + Mockito.mockConstruction( + Range.class, + (mock, context) -> { + int lower = (int) context.arguments().get(0); + int upper = (int) context.arguments().get(1); + lowers.put((Range) mock, lower); + uppers.put((Range) mock, upper); + when(((Range) mock).getUpper()) + .thenReturn(lowers.getOrDefault((Range) mock, 15)); + when(((Range) mock).getLower()) + .thenReturn(uppers.getOrDefault((Range) mock, 15)); + when(mock.toString()) + .thenReturn( + String.format( + "mocked [%s, %s]", + lowers.getOrDefault(mock, 15), uppers.getOrDefault(mock, 15))); + }); + } + + @Override + public void close() throws IOException { + rangeMockedConstruction.close(); + } + } + private static class TestCameraFeatureFactory implements CameraFeatureFactory { private final AutoFocusFeature mockAutoFocusFeature; private final ExposureLockFeature mockExposureLockFeature; @@ -1191,14 +1379,14 @@ private static class TestCameraFeatureFactory implements CameraFeatureFactory { private final SensorOrientationFeature mockSensorOrientationFeature; private final ZoomLevelFeature mockZoomLevelFeature; - public TestCameraFeatureFactory() { + public TestCameraFeatureFactory(FpsRangeFeature fpsRangeFeature) { this.mockAutoFocusFeature = mock(AutoFocusFeature.class); this.mockExposureLockFeature = mock(ExposureLockFeature.class); this.mockExposureOffsetFeature = mock(ExposureOffsetFeature.class); this.mockExposurePointFeature = mock(ExposurePointFeature.class); this.mockFlashFeature = mock(FlashFeature.class); this.mockFocusPointFeature = mock(FocusPointFeature.class); - this.mockFpsRangeFeature = mock(FpsRangeFeature.class); + this.mockFpsRangeFeature = fpsRangeFeature; this.mockNoiseReductionFeature = mock(NoiseReductionFeature.class); this.mockResolutionFeature = mock(ResolutionFeature.class); this.mockSensorOrientationFeature = mock(SensorOrientationFeature.class); diff --git a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest_getRecordingProfileTest.java b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest_getRecordingProfileTest.java index 04bab14f26ac..c5a97a19aca7 100644 --- a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest_getRecordingProfileTest.java +++ b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/CameraTest_getRecordingProfileTest.java @@ -72,8 +72,7 @@ public void before() { mockCameraFeatureFactory, mockDartMessenger, mockCameraProperties, - resolutionPreset, - enableAudio); + new Camera.VideoCaptureSettings(resolutionPreset, enableAudio)); } @Config(maxSdk = 30) @@ -87,7 +86,10 @@ public void getRecordingProfileLegacy() { CamcorderProfile actualRecordingProfile = camera.getRecordingProfileLegacy(); - verify(mockResolutionFeature, times(1)).getRecordingProfileLegacy(); + // First time: getRecordingProfileLegacy() is called in `before()` when + // camera constructor tries to determine default recording Fps. + // Second time: in this test case. + verify(mockResolutionFeature, times(2)).getRecordingProfileLegacy(); assertEquals(mockCamcorderProfile, actualRecordingProfile); } @@ -102,7 +104,7 @@ public void getRecordingProfile() { EncoderProfiles actualRecordingProfile = camera.getRecordingProfile(); - verify(mockResolutionFeature, times(1)).getRecordingProfile(); + verify(mockResolutionFeature, times(2)).getRecordingProfile(); assertEquals(mockRecordingProfile, actualRecordingProfile); } diff --git a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java index f37de01f5e7c..963e8b4f0af3 100644 --- a/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java +++ b/packages/camera/camera_android/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java @@ -21,12 +21,19 @@ @RunWith(RobolectricTestRunner.class) public class MediaRecorderBuilderTest { + + private static final int testVideoBitrate = 200000; + private static final int testFps = 15; + private static final int testAudioBitrate = 32000; + @Config(maxSdk = 30) @SuppressWarnings("deprecation") @Test public void ctor_testLegacy() { MediaRecorderBuilder builder = - new MediaRecorderBuilder(CamcorderProfile.get(CamcorderProfile.QUALITY_1080P), ""); + new MediaRecorderBuilder( + CamcorderProfile.get(CamcorderProfile.QUALITY_1080P), + new MediaRecorderBuilder.RecordingParameters("")); assertNotNull(builder); } @@ -35,7 +42,32 @@ public void ctor_testLegacy() { @Test public void ctor_test() { MediaRecorderBuilder builder = - new MediaRecorderBuilder(CamcorderProfile.getAll("0", CamcorderProfile.QUALITY_1080P), ""); + new MediaRecorderBuilder( + CamcorderProfile.getAll("0", CamcorderProfile.QUALITY_1080P), + new MediaRecorderBuilder.RecordingParameters("")); + + assertNotNull(builder); + } + + @Config(maxSdk = 30) + @SuppressWarnings("deprecation") + @Test + public void ctor_testDefaultsLegacy() { + MediaRecorderBuilder builder = + new MediaRecorderBuilder( + CamcorderProfile.get(CamcorderProfile.QUALITY_1080P), + new MediaRecorderBuilder.RecordingParameters("")); + + assertNotNull(builder); + } + + @Config(minSdk = 31) + @Test + public void ctor_testDefaults() { + MediaRecorderBuilder builder = + new MediaRecorderBuilder( + CamcorderProfile.getAll("0", CamcorderProfile.QUALITY_1080P), + new MediaRecorderBuilder.RecordingParameters("")); assertNotNull(builder); } @@ -51,7 +83,11 @@ public void build_shouldSetValuesInCorrectOrderWhenAudioIsDisabledLegacy() throw String outputFilePath = "mock_video_file_path"; int mediaOrientation = 1; MediaRecorderBuilder builder = - new MediaRecorderBuilder(recorderProfile, outputFilePath, mockFactory) + new MediaRecorderBuilder( + recorderProfile, + mockFactory, + new MediaRecorderBuilder.RecordingParameters( + outputFilePath, testFps, testVideoBitrate, null)) .setEnableAudio(false) .setMediaOrientation(mediaOrientation); @@ -63,8 +99,8 @@ public void build_shouldSetValuesInCorrectOrderWhenAudioIsDisabledLegacy() throw inOrder.verify(recorder).setVideoSource(MediaRecorder.VideoSource.SURFACE); inOrder.verify(recorder).setOutputFormat(recorderProfile.fileFormat); inOrder.verify(recorder).setVideoEncoder(recorderProfile.videoCodec); - inOrder.verify(recorder).setVideoEncodingBitRate(recorderProfile.videoBitRate); - inOrder.verify(recorder).setVideoFrameRate(recorderProfile.videoFrameRate); + inOrder.verify(recorder).setVideoEncodingBitRate(testVideoBitrate); + inOrder.verify(recorder).setVideoFrameRate(testFps); inOrder .verify(recorder) .setVideoSize(recorderProfile.videoFrameWidth, recorderProfile.videoFrameHeight); @@ -87,7 +123,11 @@ public void build_shouldSetValuesInCorrectOrderWhenAudioIsDisabled() throws IOEx String outputFilePath = "mock_video_file_path"; int mediaOrientation = 1; MediaRecorderBuilder builder = - new MediaRecorderBuilder(recorderProfile, outputFilePath, mockFactory) + new MediaRecorderBuilder( + recorderProfile, + mockFactory, + new MediaRecorderBuilder.RecordingParameters( + outputFilePath, testFps, testVideoBitrate, null)) .setEnableAudio(false) .setMediaOrientation(mediaOrientation); @@ -103,8 +143,8 @@ public void build_shouldSetValuesInCorrectOrderWhenAudioIsDisabled() throws IOEx inOrder.verify(recorder).setVideoSource(MediaRecorder.VideoSource.SURFACE); inOrder.verify(recorder).setOutputFormat(recorderProfile.getRecommendedFileFormat()); inOrder.verify(recorder).setVideoEncoder(videoProfile.getCodec()); - inOrder.verify(recorder).setVideoEncodingBitRate(videoProfile.getBitrate()); - inOrder.verify(recorder).setVideoFrameRate(videoProfile.getFrameRate()); + inOrder.verify(recorder).setVideoEncodingBitRate(testVideoBitrate); + inOrder.verify(recorder).setVideoFrameRate(testFps); inOrder.verify(recorder).setVideoSize(videoProfile.getWidth(), videoProfile.getHeight()); inOrder.verify(recorder).setOutputFile(outputFilePath); inOrder.verify(recorder).setOrientationHint(mediaOrientation); @@ -121,7 +161,10 @@ public void build_shouldThrowExceptionWithoutVideoOrAudioProfiles() throws IOExc String outputFilePath = "mock_video_file_path"; int mediaOrientation = 1; MediaRecorderBuilder builder = - new MediaRecorderBuilder(recorderProfile, outputFilePath, mockFactory) + new MediaRecorderBuilder( + recorderProfile, + mockFactory, + new MediaRecorderBuilder.RecordingParameters(outputFilePath)) .setEnableAudio(false) .setMediaOrientation(mediaOrientation); @@ -141,7 +184,11 @@ public void build_shouldSetValuesInCorrectOrderWhenAudioIsEnabledLegacy() throws String outputFilePath = "mock_video_file_path"; int mediaOrientation = 1; MediaRecorderBuilder builder = - new MediaRecorderBuilder(recorderProfile, outputFilePath, mockFactory) + new MediaRecorderBuilder( + recorderProfile, + mockFactory, + new MediaRecorderBuilder.RecordingParameters( + outputFilePath, testFps, testVideoBitrate, testAudioBitrate)) .setEnableAudio(true) .setMediaOrientation(mediaOrientation); @@ -154,11 +201,11 @@ public void build_shouldSetValuesInCorrectOrderWhenAudioIsEnabledLegacy() throws inOrder.verify(recorder).setVideoSource(MediaRecorder.VideoSource.SURFACE); inOrder.verify(recorder).setOutputFormat(recorderProfile.fileFormat); inOrder.verify(recorder).setAudioEncoder(recorderProfile.audioCodec); - inOrder.verify(recorder).setAudioEncodingBitRate(recorderProfile.audioBitRate); + inOrder.verify(recorder).setAudioEncodingBitRate(testAudioBitrate); inOrder.verify(recorder).setAudioSamplingRate(recorderProfile.audioSampleRate); inOrder.verify(recorder).setVideoEncoder(recorderProfile.videoCodec); - inOrder.verify(recorder).setVideoEncodingBitRate(recorderProfile.videoBitRate); - inOrder.verify(recorder).setVideoFrameRate(recorderProfile.videoFrameRate); + inOrder.verify(recorder).setVideoEncodingBitRate(testVideoBitrate); + inOrder.verify(recorder).setVideoFrameRate(testFps); inOrder .verify(recorder) .setVideoSize(recorderProfile.videoFrameWidth, recorderProfile.videoFrameHeight); @@ -181,7 +228,11 @@ public void build_shouldSetValuesInCorrectOrderWhenAudioIsEnabled() throws IOExc String outputFilePath = "mock_video_file_path"; int mediaOrientation = 1; MediaRecorderBuilder builder = - new MediaRecorderBuilder(recorderProfile, outputFilePath, mockFactory) + new MediaRecorderBuilder( + recorderProfile, + mockFactory, + new MediaRecorderBuilder.RecordingParameters( + outputFilePath, testFps, testVideoBitrate, testAudioBitrate)) .setEnableAudio(true) .setMediaOrientation(mediaOrientation); @@ -199,11 +250,11 @@ public void build_shouldSetValuesInCorrectOrderWhenAudioIsEnabled() throws IOExc inOrder.verify(recorder).setVideoSource(MediaRecorder.VideoSource.SURFACE); inOrder.verify(recorder).setOutputFormat(recorderProfile.getRecommendedFileFormat()); inOrder.verify(recorder).setAudioEncoder(audioProfile.getCodec()); - inOrder.verify(recorder).setAudioEncodingBitRate(audioProfile.getBitrate()); + inOrder.verify(recorder).setAudioEncodingBitRate(testAudioBitrate); inOrder.verify(recorder).setAudioSamplingRate(audioProfile.getSampleRate()); inOrder.verify(recorder).setVideoEncoder(videoProfile.getCodec()); - inOrder.verify(recorder).setVideoEncodingBitRate(videoProfile.getBitrate()); - inOrder.verify(recorder).setVideoFrameRate(videoProfile.getFrameRate()); + inOrder.verify(recorder).setVideoEncodingBitRate(testVideoBitrate); + inOrder.verify(recorder).setVideoFrameRate(testFps); inOrder.verify(recorder).setVideoSize(videoProfile.getWidth(), videoProfile.getHeight()); inOrder.verify(recorder).setOutputFile(outputFilePath); inOrder.verify(recorder).setOrientationHint(mediaOrientation); diff --git a/packages/camera/camera_android/example/integration_test/camera_test.dart b/packages/camera/camera_android/example/integration_test/camera_test.dart index 1390de6c6da4..8e1a6ce7f791 100644 --- a/packages/camera/camera_android/example/integration_test/camera_test.dart +++ b/packages/camera/camera_android/example/integration_test/camera_test.dart @@ -81,8 +81,8 @@ void main() { bool previousPresetExactlySupported = true; for (final MapEntry preset in presetExpectedSizes.entries) { - final CameraController controller = - CameraController(cameraDescription, preset.key); + final CameraController controller = CameraController(cameraDescription, + mediaSettings: MediaSettings(resolutionPreset: preset.key)); await controller.initialize(); final bool presetExactlySupported = await testCaptureImageResolution(controller, preset.key); @@ -131,8 +131,9 @@ void main() { bool previousPresetExactlySupported = true; for (final MapEntry preset in presetExpectedSizes.entries) { - final CameraController controller = - CameraController(cameraDescription, preset.key); + final CameraController controller = CameraController( + cameraDescription, + mediaSettings: MediaSettings(resolutionPreset: preset.key)); await controller.initialize(); await controller.prepareForVideoRecording(); final bool presetExactlySupported = @@ -155,11 +156,7 @@ void main() { return; } - final CameraController controller = CameraController( - cameras[0], - ResolutionPreset.low, - enableAudio: false, - ); + final CameraController controller = CameraController(cameras[0]); await controller.initialize(); await controller.prepareForVideoRecording(); @@ -209,11 +206,7 @@ void main() { return; } - final CameraController controller = CameraController( - cameras[0], - ResolutionPreset.low, - enableAudio: false, - ); + final CameraController controller = CameraController(cameras[0]); await controller.initialize(); await controller.prepareForVideoRecording(); @@ -250,11 +243,7 @@ void main() { return; } - final CameraController controller = CameraController( - cameras[0], - ResolutionPreset.low, - enableAudio: false, - ); + final CameraController controller = CameraController(cameras[0]); await controller.initialize(); await controller.setDescription(cameras[1]); @@ -271,11 +260,7 @@ void main() { return; } - final CameraController controller = CameraController( - cameras[0], - ResolutionPreset.low, - enableAudio: false, - ); + final CameraController controller = CameraController(cameras[0]); await controller.initialize(); bool isDetecting = false; @@ -308,11 +293,7 @@ void main() { return; } - final CameraController controller = CameraController( - cameras[0], - ResolutionPreset.low, - enableAudio: false, - ); + final CameraController controller = CameraController(cameras[0]); await controller.initialize(); bool isDetecting = false; @@ -341,4 +322,140 @@ void main() { expect(controller.value.isStreamingImages, false); }, ); + + group('Camera settings', () { + Future getCamera() async { + final List cameras = + await CameraPlatform.instance.availableCameras(); + expect(cameras.isNotEmpty, equals(true)); + + // Prefer back camera, as it allows more customizations. + final CameraDescription cameraDescription = cameras.firstWhere( + (CameraDescription description) => + description.lensDirection == CameraLensDirection.back, + orElse: () => cameras.first, + ); + return cameraDescription; + } + + Future startRecording(CameraController controller) async { + await controller.initialize(); + + //region Lock video as much as possible to avoid video bitrate fluctuations + await controller.lockCaptureOrientation(); + await controller.setExposureMode(ExposureMode.locked); + await controller.setFocusMode(FocusMode.locked); + await controller.setFlashMode(FlashMode.off); + await controller.setExposureOffset(0); + //endregion + + await controller.prepareForVideoRecording(); + await controller.startVideoRecording(); + } + + testWidgets('Control FPS', (WidgetTester tester) async { + final CameraDescription cameraDescription = await getCamera(); + + final List lengths = []; + + for (final int fps in [10, 30]) { + final CameraController controller = CameraController( + cameraDescription, + mediaSettings: MediaSettings( + resolutionPreset: ResolutionPreset.medium, fps: fps), + ); + + await startRecording(controller); + + sleep(const Duration(milliseconds: 1000)); + + final XFile file = await controller.stopVideoRecording(); + + // Load video size + final File videoFile = File(file.path); + + lengths.add(await videoFile.length()); + + await controller.dispose(); + } + + for (int n = 0; n < lengths.length - 1; n++) { + expect(lengths[n], greaterThan(0)); + } + }); + + testWidgets('Control video bitrate', (WidgetTester tester) async { + final CameraDescription cameraDescription = await getCamera(); + + const int kiloBits = 1000; + final List lengths = []; + for (final int videoBitrate in [200 * kiloBits, 2000 * kiloBits]) { + final CameraController controller = CameraController( + cameraDescription, + mediaSettings: MediaSettings( + resolutionPreset: ResolutionPreset.medium, + videoBitrate: videoBitrate), + ); + + await startRecording(controller); + + sleep(const Duration(milliseconds: 1000)); + + final XFile file = await controller.stopVideoRecording(); + + // Load video size + final File videoFile = File(file.path); + + lengths.add(await videoFile.length()); + + await controller.dispose(); + } + + for (int n = 0; n < lengths.length - 1; n++) { + expect(lengths[n], greaterThan(0)); + } + }); + + testWidgets('Control audio bitrate', (WidgetTester tester) async { + final List lengths = []; + + final CameraDescription cameraDescription = await getCamera(); + + const int kiloBits = 1000; + + for (final int audioBitrate in [32 * kiloBits, 256 * kiloBits]) { + final CameraController controller = CameraController( + cameraDescription, + mediaSettings: MediaSettings( + //region Use lowest video settings for minimize video impact on bitrate + resolutionPreset: ResolutionPreset.low, + fps: 10, + videoBitrate: 64 * kiloBits, + //endregion + audioBitrate: audioBitrate, + enableAudio: true, + ), + ); + + await startRecording(controller); + + sleep(const Duration(milliseconds: 1000)); + + final XFile file = await controller.stopVideoRecording(); + + // Load video metadata + final File videoFile = File(file.path); + + final int length = await videoFile.length(); + + lengths.add(length); + + await controller.dispose(); + } + + for (int n = 0; n < lengths.length - 1; n++) { + expect(lengths[n], greaterThan(0)); + } + }); + }); } diff --git a/packages/camera/camera_android/example/lib/camera_controller.dart b/packages/camera/camera_android/example/lib/camera_controller.dart index 3c4ce1871345..2ebd975531eb 100644 --- a/packages/camera/camera_android/example/lib/camera_controller.dart +++ b/packages/camera/camera_android/example/lib/camera_controller.dart @@ -172,25 +172,18 @@ class CameraValue { class CameraController extends ValueNotifier { /// Creates a new camera controller in an uninitialized state. CameraController( - CameraDescription cameraDescription, - this.resolutionPreset, { - this.enableAudio = true, + CameraDescription cameraDescription, { + MediaSettings mediaSettings = + const MediaSettings(resolutionPreset: ResolutionPreset.medium), this.imageFormatGroup, - }) : super(CameraValue.uninitialized(cameraDescription)); + }) : _mediaSettings = mediaSettings, + super(CameraValue.uninitialized(cameraDescription)); - /// The properties of the camera device controlled by this controller. - CameraDescription get description => value.description; - - /// The resolution this controller is targeting. /// - /// This resolution preset is not guaranteed to be available on the device, - /// if unavailable a lower resolution will be used. - /// - /// See also: [ResolutionPreset]. - final ResolutionPreset resolutionPreset; + final MediaSettings _mediaSettings; - /// Whether to include audio when recording a video. - final bool enableAudio; + /// The properties of the camera device controlled by this controller. + CameraDescription get description => value.description; /// The [ImageFormatGroup] describes the output of the raw image format. /// @@ -223,10 +216,9 @@ class CameraController extends ValueNotifier { ); }); - _cameraId = await CameraPlatform.instance.createCamera( + _cameraId = await CameraPlatform.instance.createCameraWithSettings( description, - resolutionPreset, - enableAudio: enableAudio, + _mediaSettings, ); unawaited(CameraPlatform.instance diff --git a/packages/camera/camera_android/example/lib/main.dart b/packages/camera/camera_android/example/lib/main.dart index 26d2527eeddb..830052e351f4 100644 --- a/packages/camera/camera_android/example/lib/main.dart +++ b/packages/camera/camera_android/example/lib/main.dart @@ -637,8 +637,11 @@ class _CameraExampleHomeState extends State CameraDescription cameraDescription) async { final CameraController cameraController = CameraController( cameraDescription, - kIsWeb ? ResolutionPreset.max : ResolutionPreset.medium, - enableAudio: enableAudio, + mediaSettings: MediaSettings( + resolutionPreset: + kIsWeb ? ResolutionPreset.max : ResolutionPreset.medium, + enableAudio: enableAudio, + ), imageFormatGroup: ImageFormatGroup.jpeg, ); diff --git a/packages/camera/camera_android/example/pubspec.yaml b/packages/camera/camera_android/example/pubspec.yaml index 7cf7ef8e62d4..24e3d63767ef 100644 --- a/packages/camera/camera_android/example/pubspec.yaml +++ b/packages/camera/camera_android/example/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ - camera_platform_interface: ^2.5.0 + camera_platform_interface: ^2.6.0 flutter: sdk: flutter path_provider: ^2.0.0 diff --git a/packages/camera/camera_android/lib/src/android_camera.dart b/packages/camera/camera_android/lib/src/android_camera.dart index 98e1edd1280a..ce0af1715626 100644 --- a/packages/camera/camera_android/lib/src/android_camera.dart +++ b/packages/camera/camera_android/lib/src/android_camera.dart @@ -96,15 +96,33 @@ class AndroidCamera extends CameraPlatform { CameraDescription cameraDescription, ResolutionPreset? resolutionPreset, { bool enableAudio = false, - }) async { + }) => + createCameraWithSettings( + cameraDescription, + MediaSettings( + resolutionPreset: resolutionPreset, + enableAudio: enableAudio, + )); + + @override + Future createCameraWithSettings( + CameraDescription cameraDescription, + MediaSettings? mediaSettings, + ) async { try { + final ResolutionPreset? resolutionPreset = + mediaSettings?.resolutionPreset; + final Map? reply = await _channel .invokeMapMethod('create', { 'cameraName': cameraDescription.name, 'resolutionPreset': resolutionPreset != null ? _serializeResolutionPreset(resolutionPreset) : null, - 'enableAudio': enableAudio, + 'fps': mediaSettings?.fps, + 'videoBitrate': mediaSettings?.videoBitrate, + 'audioBitrate': mediaSettings?.audioBitrate, + 'enableAudio': mediaSettings?.enableAudio ?? false, }); return reply!['cameraId']! as int; diff --git a/packages/camera/camera_android/pubspec.yaml b/packages/camera/camera_android/pubspec.yaml index e45547bc0a92..6446f3c7e8d3 100644 --- a/packages/camera/camera_android/pubspec.yaml +++ b/packages/camera/camera_android/pubspec.yaml @@ -3,7 +3,7 @@ description: Android implementation of the camera plugin. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.10.8+18 +version: 0.10.9 environment: sdk: ^3.1.0 @@ -19,7 +19,7 @@ flutter: dartPluginClass: AndroidCamera dependencies: - camera_platform_interface: ^2.5.0 + camera_platform_interface: ^2.6.0 flutter: sdk: flutter flutter_plugin_android_lifecycle: ^2.0.2 diff --git a/packages/camera/camera_android/test/android_camera_test.dart b/packages/camera/camera_android/test/android_camera_test.dart index 9cddd414b7bd..87acd2c0c721 100644 --- a/packages/camera/camera_android/test/android_camera_test.dart +++ b/packages/camera/camera_android/test/android_camera_test.dart @@ -72,6 +72,54 @@ void main() { arguments: { 'cameraName': 'Test', 'resolutionPreset': 'high', + 'enableAudio': false, + 'fps': null, + 'videoBitrate': null, + 'audioBitrate': null, + }, + ), + ]); + expect(cameraId, 1); + }); + + test( + 'Should send creation data and receive back a camera id using createCameraWithSettings', + () async { + // Arrange + final MethodChannelMock cameraMockChannel = MethodChannelMock( + channelName: _channelName, + methods: { + 'create': { + 'cameraId': 1, + 'imageFormatGroup': 'unknown', + } + }); + final AndroidCamera camera = AndroidCamera(); + + // Act + final int cameraId = await camera.createCameraWithSettings( + const CameraDescription( + name: 'Test', + lensDirection: CameraLensDirection.back, + sensorOrientation: 0), + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + ), + ); + + // Assert + expect(cameraMockChannel.log, [ + isMethodCall( + 'create', + arguments: { + 'cameraName': 'Test', + 'resolutionPreset': 'low', + 'fps': 15, + 'videoBitrate': 200000, + 'audioBitrate': 32000, 'enableAudio': false }, ), diff --git a/packages/camera/camera_android_camerax/CHANGELOG.md b/packages/camera/camera_android_camerax/CHANGELOG.md index c2ca340a3658..f271e9061580 100644 --- a/packages/camera/camera_android_camerax/CHANGELOG.md +++ b/packages/camera/camera_android_camerax/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.6.2 + +* Adds support to control video FPS and bitrate. See `CameraController.withSettings`. + ## 0.6.1+1 * Moves integration_test dependency to dev_dependencies. diff --git a/packages/camera/camera_android_camerax/example/integration_test/integration_test.dart b/packages/camera/camera_android_camerax/example/integration_test/integration_test.dart index b016cf080dad..83d20b3585b4 100644 --- a/packages/camera/camera_android_camerax/example/integration_test/integration_test.dart +++ b/packages/camera/camera_android_camerax/example/integration_test/integration_test.dart @@ -84,8 +84,10 @@ void main() { bool previousPresetExactlySupported = true; for (final MapEntry preset in presetExpectedSizes.entries) { - final CameraController controller = - CameraController(cameraDescription, preset.key); + final CameraController controller = CameraController( + cameraDescription, + mediaSettings: MediaSettings(resolutionPreset: preset.key), + ); await controller.initialize(); final bool presetExactlySupported = await testCaptureImageResolution(controller, preset.key); @@ -113,8 +115,10 @@ void main() { bool previousPresetExactlySupported = true; for (final MapEntry preset in presetExpectedSizes.entries) { - final CameraController controller = - CameraController(cameraDescription, preset.key); + final CameraController controller = CameraController( + cameraDescription, + mediaSettings: MediaSettings(resolutionPreset: preset.key), + ); await controller.initialize(); @@ -147,8 +151,10 @@ void main() { bool previousPresetExactlySupported = true; for (final MapEntry preset in presetExpectedSizes.entries) { - final CameraController controller = - CameraController(cameraDescription, preset.key); + final CameraController controller = CameraController( + cameraDescription, + mediaSettings: MediaSettings(resolutionPreset: preset.key), + ); final Completer imageCompleter = Completer(); await controller.initialize(); await controller.startImageStream((CameraImage image) { diff --git a/packages/camera/camera_android_camerax/example/lib/camera_controller.dart b/packages/camera/camera_android_camerax/example/lib/camera_controller.dart index e3b4ee9b496c..92aff480e738 100644 --- a/packages/camera/camera_android_camerax/example/lib/camera_controller.dart +++ b/packages/camera/camera_android_camerax/example/lib/camera_controller.dart @@ -225,25 +225,21 @@ class CameraValue { class CameraController extends ValueNotifier { /// Creates a new camera controller in an uninitialized state. CameraController( - this.description, - this.resolutionPreset, { - this.enableAudio = true, + this.description, { + this.mediaSettings, this.imageFormatGroup, }) : super(const CameraValue.uninitialized()); /// The properties of the camera device controlled by this controller. final CameraDescription description; - /// The resolution this controller is targeting. + /// The media settings this controller is targeting. /// - /// This resolution preset is not guaranteed to be available on the device, + /// This media settings are not guaranteed to be available on the device, /// if unavailable a lower resolution will be used. /// - /// See also: [ResolutionPreset]. - final ResolutionPreset resolutionPreset; - - /// Whether to include audio when recording a video. - final bool enableAudio; + /// See also: [MediaSettings]. + final MediaSettings? mediaSettings; /// The [ImageFormatGroup] describes the output of the raw image format. /// @@ -293,10 +289,9 @@ class CameraController extends ValueNotifier { ); }); - _cameraId = await CameraPlatform.instance.createCamera( + _cameraId = await CameraPlatform.instance.createCameraWithSettings( description, - resolutionPreset, - enableAudio: enableAudio, + mediaSettings ?? const MediaSettings(), ); _unawaited(CameraPlatform.instance diff --git a/packages/camera/camera_android_camerax/example/lib/main.dart b/packages/camera/camera_android_camerax/example/lib/main.dart index 22221fd1de96..567d400d992c 100644 --- a/packages/camera/camera_android_camerax/example/lib/main.dart +++ b/packages/camera/camera_android_camerax/example/lib/main.dart @@ -636,8 +636,12 @@ class _CameraExampleHomeState extends State final CameraController cameraController = CameraController( cameraDescription, - kIsWeb ? ResolutionPreset.max : ResolutionPreset.medium, - enableAudio: enableAudio, + mediaSettings: const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + ), imageFormatGroup: ImageFormatGroup.jpeg, ); diff --git a/packages/camera/camera_android_camerax/example/pubspec.yaml b/packages/camera/camera_android_camerax/example/pubspec.yaml index 54b84cc0b696..4739dccbae88 100644 --- a/packages/camera/camera_android_camerax/example/pubspec.yaml +++ b/packages/camera/camera_android_camerax/example/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ - camera_platform_interface: ^2.2.0 + camera_platform_interface: ^2.6.0 flutter: sdk: flutter video_player: ^2.7.0 @@ -28,3 +28,4 @@ dev_dependencies: flutter: uses-material-design: true + diff --git a/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart b/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart index 19aaea83fa3d..e6ef399b6570 100644 --- a/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart +++ b/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart @@ -270,6 +270,20 @@ class AndroidCameraCameraX extends CameraPlatform { return cameraDescriptions; } + /// Creates an uninitialized camera instance with default settings and returns the camera ID. + /// + /// See [createCameraWithSettings] + @override + Future createCamera( + CameraDescription description, + ResolutionPreset? resolutionPreset, { + bool enableAudio = false, + }) => + createCameraWithSettings( + description, + MediaSettings( + resolutionPreset: resolutionPreset, enableAudio: enableAudio)); + /// Creates an uninitialized camera instance and returns the camera ID. /// /// In the CameraX library, cameras are accessed by combining [UseCase]s @@ -287,13 +301,12 @@ class AndroidCameraCameraX extends CameraPlatform { /// that a camera preview can be drawn to, a [Preview] instance is configured /// and bound to the [ProcessCameraProvider] instance. @override - Future createCamera( + Future createCameraWithSettings( CameraDescription cameraDescription, - ResolutionPreset? resolutionPreset, { - bool enableAudio = false, - }) async { + MediaSettings? mediaSettings, + ) async { // Must obtain proper permissions before attempting to access a camera. - await proxy.requestCameraPermissions(enableAudio); + await proxy.requestCameraPermissions(mediaSettings?.enableAudio ?? false); // Save CameraSelector that matches cameraDescription. final int cameraSelectorLensDirection = @@ -307,9 +320,9 @@ class AndroidCameraCameraX extends CameraPlatform { // Determine ResolutionSelector and QualitySelector based on // resolutionPreset for camera UseCases. final ResolutionSelector? presetResolutionSelector = - _getResolutionSelectorFromPreset(resolutionPreset); + _getResolutionSelectorFromPreset(mediaSettings?.resolutionPreset); final QualitySelector? presetQualitySelector = - _getQualitySelectorFromPreset(resolutionPreset); + _getQualitySelectorFromPreset(mediaSettings?.resolutionPreset); // Retrieve a fresh ProcessCameraProvider instance. processCameraProvider ??= await proxy.getProcessCameraProvider(); diff --git a/packages/camera/camera_android_camerax/pubspec.yaml b/packages/camera/camera_android_camerax/pubspec.yaml index 1d82d5f51681..dfc6f93a4966 100644 --- a/packages/camera/camera_android_camerax/pubspec.yaml +++ b/packages/camera/camera_android_camerax/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_android_camerax description: Android implementation of the camera plugin using the CameraX library. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_android_camerax issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.6.1+1 +version: 0.6.2 environment: sdk: ^3.1.0 @@ -19,7 +19,7 @@ flutter: dependencies: async: ^2.5.0 - camera_platform_interface: ^2.3.2 + camera_platform_interface: ^2.6.0 flutter: sdk: flutter meta: ^1.7.0 diff --git a/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart b/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart index f08a2e02ad49..da1bd76b03d8 100644 --- a/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart +++ b/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart @@ -319,8 +319,7 @@ void main() { name: 'cameraName', lensDirection: testLensDirection, sensorOrientation: testSensorOrientation); - const ResolutionPreset testResolutionPreset = ResolutionPreset.veryHigh; - const bool enableAudio = true; + const int testSurfaceTextureId = 6; // Mock/Detached objects for (typically attached) objects created by @@ -396,8 +395,16 @@ void main() { camera.processCameraProvider = mockProcessCameraProvider; expect( - await camera.createCamera(testCameraDescription, testResolutionPreset, - enableAudio: enableAudio), + await camera.createCameraWithSettings( + testCameraDescription, + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + enableAudio: true, + ), + ), equals(testSurfaceTextureId)); // Verify permissions are requested and the camera starts listening for device orientation changes. @@ -508,8 +515,15 @@ void main() { .thenAnswer((_) async => mockCameraControl); camera.processCameraProvider = mockProcessCameraProvider; - await camera.createCamera(testCameraDescription, testResolutionPreset, - enableAudio: enableAudio); + await camera.createCameraWithSettings( + testCameraDescription, + const MediaSettings( + resolutionPreset: testResolutionPreset, + fps: 15, + videoBitrate: 2000000, + audioBitrate: 64000, + enableAudio: enableAudio, + )); // Verify expected UseCases were bound. verify(camera.processCameraProvider!.bindToLifecycle(camera.cameraSelector!, @@ -559,8 +573,11 @@ void main() { // Test non-null resolution presets. for (final ResolutionPreset resolutionPreset in ResolutionPreset.values) { - await camera.createCamera(testCameraDescription, resolutionPreset, - enableAudio: enableAudio); + await camera.createCamera( + testCameraDescription, + resolutionPreset, + enableAudio: enableAudio, + ); Size? expectedBoundSize; ResolutionStrategy? expectedResolutionStrategy; @@ -891,8 +908,6 @@ void main() { name: 'cameraName', lensDirection: testLensDirection, sensorOrientation: testSensorOrientation); - const ResolutionPreset testResolutionPreset = ResolutionPreset.veryHigh; - const bool enableAudio = true; const int resolutionWidth = 350; const int resolutionHeight = 750; final Camera mockCamera = MockCamera(); @@ -972,8 +987,16 @@ void main() { when(mockPreview.getResolutionInfo()) .thenAnswer((_) async => testResolutionInfo); - await camera.createCamera(testCameraDescription, testResolutionPreset, - enableAudio: enableAudio); + await camera.createCameraWithSettings( + testCameraDescription, + const MediaSettings( + resolutionPreset: ResolutionPreset.medium, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + enableAudio: true, + ), + ); // Start listening to camera events stream to verify the proper CameraInitializedEvent is sent. camera.cameraEventStreamController.stream.listen((CameraEvent event) { @@ -1062,7 +1085,7 @@ void main() { }); test( - 'onDeviceOrientationChanged stream emits changes in device oreintation detected by system services', + 'onDeviceOrientationChanged stream emits changes in device orientation detected by system services', () async { final AndroidCameraCameraX camera = AndroidCameraCameraX(); final Stream eventStream = @@ -2053,6 +2076,7 @@ void main() { as Analyzer; await capturedAnalyzer.analyze(mockImageProxy); + final CameraImageData imageData = await imageDataCompleter.future; // Test Analyzer correctly process ImageProxy instances. diff --git a/packages/camera/camera_avfoundation/CHANGELOG.md b/packages/camera/camera_avfoundation/CHANGELOG.md index c63f7412673e..10cb652e121f 100644 --- a/packages/camera/camera_avfoundation/CHANGELOG.md +++ b/packages/camera/camera_avfoundation/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.9.15 + +* Adds support to control video FPS and bitrate. See `CameraController.withSettings`. + ## 0.9.14+2 * Removes `_ambiguate` methods from example code. diff --git a/packages/camera/camera_avfoundation/example/integration_test/camera_test.dart b/packages/camera/camera_avfoundation/example/integration_test/camera_test.dart index b91fcec6a183..c8fded8ad233 100644 --- a/packages/camera/camera_avfoundation/example/integration_test/camera_test.dart +++ b/packages/camera/camera_avfoundation/example/integration_test/camera_test.dart @@ -340,4 +340,127 @@ void main() { } } }); + + group('Camera settings', () { + testWidgets('Control FPS', (WidgetTester tester) async { + final List cameras = + await CameraPlatform.instance.availableCameras(); + if (cameras.isEmpty) { + return; + } + + final List lengths = []; + for (final int fps in [10, 30]) { + final CameraController controller = CameraController.withSettings( + cameras.first, + mediaSettings: MediaSettings( + resolutionPreset: ResolutionPreset.medium, + fps: fps, + ), + ); + await controller.initialize(); + await controller.prepareForVideoRecording(); + + // Take Video + await controller.startVideoRecording(); + sleep(const Duration(milliseconds: 500)); + final XFile file = await controller.stopVideoRecording(); + + // Load video size + final File videoFile = File(file.path); + + lengths.add(await videoFile.length()); + + await controller.dispose(); + } + + for (int n = 0; n < lengths.length - 1; n++) { + expect(lengths[n], lessThan(lengths[n + 1]), + reason: 'incrementing fps should increment file size'); + } + }); + + testWidgets('Control video bitrate', (WidgetTester tester) async { + final List cameras = + await CameraPlatform.instance.availableCameras(); + if (cameras.isEmpty) { + return; + } + + const int kiloBits = 1024; + final List lengths = []; + for (final int videoBitrate in [100 * kiloBits, 1000 * kiloBits]) { + final CameraController controller = CameraController.withSettings( + cameras.first, + mediaSettings: MediaSettings( + resolutionPreset: ResolutionPreset.medium, + videoBitrate: videoBitrate, + ), + ); + await controller.initialize(); + await controller.prepareForVideoRecording(); + + // Take Video + await controller.startVideoRecording(); + sleep(const Duration(milliseconds: 500)); + final XFile file = await controller.stopVideoRecording(); + + // Load video size + final File videoFile = File(file.path); + + lengths.add(await videoFile.length()); + + await controller.dispose(); + } + + for (int n = 0; n < lengths.length - 1; n++) { + expect(lengths[n], lessThan(lengths[n + 1]), + reason: 'incrementing video bitrate should increment file size'); + } + }); + + testWidgets('Control audio bitrate', (WidgetTester tester) async { + final List cameras = + await CameraPlatform.instance.availableCameras(); + if (cameras.isEmpty) { + return; + } + + final List lengths = []; + + const int kiloBits = 1024; + for (final int audioBitrate in [32 * kiloBits, 64 * kiloBits]) { + final CameraController controller = CameraController.withSettings( + cameras.first, + mediaSettings: MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 5, + videoBitrate: 32000, + audioBitrate: audioBitrate, + enableAudio: true), + ); + await controller.initialize(); + await controller.prepareForVideoRecording(); + + // Take Video + await controller.startVideoRecording(); + sleep(const Duration(milliseconds: 1000)); + final XFile file = await controller.stopVideoRecording(); + + // Load video metadata + final File videoFile = File(file.path); + + final int length = await videoFile.length(); + + lengths.add(length); + + await controller.dispose(); + } + + for (int n = 0; n < lengths.length - 1; n++) { + expect(lengths[n], lessThan(lengths[n + 1]), + reason: 'incrementing audio bitrate should increment file size'); + } + }); + }); } diff --git a/packages/camera/camera_avfoundation/example/ios/Runner.xcodeproj/project.pbxproj b/packages/camera/camera_avfoundation/example/ios/Runner.xcodeproj/project.pbxproj index ac2e7ffb6ec4..dad7730b1b59 100644 --- a/packages/camera/camera_avfoundation/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/camera/camera_avfoundation/example/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 43ED1537282570DE00EB00DE /* AvailableCamerasTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 43ED1536282570DE00EB00DE /* AvailableCamerasTest.m */; }; 788A065A27B0E02900533D74 /* StreamingTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 788A065927B0E02900533D74 /* StreamingTest.m */; }; + 7D5FCCD42AEF9D0200FB7108 /* CameraSettingsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7D5FCCD32AEF9D0200FB7108 /* CameraSettingsTests.m */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -79,6 +80,7 @@ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 7D5FCCD32AEF9D0200FB7108 /* CameraSettingsTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CameraSettingsTests.m; sourceTree = ""; }; 89D82918721FABF772705DB0 /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -131,6 +133,7 @@ 03BB76692665316900CE5A93 /* RunnerTests */ = { isa = PBXGroup; children = ( + 7D5FCCD32AEF9D0200FB7108 /* CameraSettingsTests.m */, 03BB766A2665316900CE5A93 /* CameraFocusTests.m */, 03BB767226653ABE00CE5A93 /* CameraOrientationTests.m */, 03BB766C2665316900CE5A93 /* Info.plist */, @@ -447,6 +450,7 @@ E071CF7227B3061B006EF3BA /* FLTCamPhotoCaptureTests.m in Sources */, E0F95E3D27A32AB900699390 /* CameraPropertiesTests.m in Sources */, 03BB766B2665316900CE5A93 /* CameraFocusTests.m in Sources */, + 7D5FCCD42AEF9D0200FB7108 /* CameraSettingsTests.m in Sources */, E487C86026D686A10034AC92 /* CameraPreviewPauseTests.m in Sources */, E071CF7427B31DE4006EF3BA /* FLTCamSampleBufferTests.m in Sources */, E04F108627A87CA600573D0C /* FLTSavePhotoDelegateTests.m in Sources */, diff --git a/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraSettingsTests.m b/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraSettingsTests.m new file mode 100644 index 000000000000..ecf2b17e04e4 --- /dev/null +++ b/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraSettingsTests.m @@ -0,0 +1,350 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import camera_avfoundation; +@import camera_avfoundation.Test; +@import XCTest; +@import AVFoundation; +#import +#import "CameraTestUtils.h" +#import "MockFLTThreadSafeFlutterResult.h" + +static const char *gTestResolutionPreset = "medium"; +static const int gTestFramesPerSecond = 15; +static const int gTestVideoBitrate = 200000; +static const int gTestAudioBitrate = 32000; +static const bool gTestEnableAudio = YES; + +@interface CameraCreateWithMediaSettingsParseTests : XCTestCase +@end + +@interface MockErrorFlutterResult : MockFLTThreadSafeFlutterResult +@property(nonatomic, nullable) NSError *receivedError; +@end + +@implementation MockErrorFlutterResult + +- (void)sendError:(NSError *)error { + _receivedError = error; + [self.expectation fulfill]; +} + +@end + +/// Expect that optional positive numbers can be parsed +@implementation CameraCreateWithMediaSettingsParseTests + +- (NSError *)failingTestWithArguments:(NSDictionary *)arguments { + CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Result finished"]; + + MockErrorFlutterResult *resultObject = + [[MockErrorFlutterResult alloc] initWithExpectation:expectation]; + + // Set up method call + FlutterMethodCall *call = [FlutterMethodCall methodCallWithMethodName:@"create" + arguments:arguments]; + + [camera createCameraOnSessionQueueWithCreateMethodCall:call result:resultObject]; + [self waitForExpectationsWithTimeout:1 handler:nil]; + + // Verify the result + NSError *receivedError = resultObject.receivedError; + XCTAssertNotNil(receivedError); + return receivedError; +} + +- (NSError *)goodTestWithArguments:(NSDictionary *)arguments { + CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Result finished"]; + + // Set up mocks for initWithCameraName method + id avCaptureDeviceInputMock = OCMClassMock([AVCaptureDeviceInput class]); + OCMStub([avCaptureDeviceInputMock deviceInputWithDevice:[OCMArg any] error:[OCMArg anyObjectRef]]) + .andReturn([AVCaptureInput alloc]); + + id avCaptureSessionMock = OCMClassMock([AVCaptureSession class]); + OCMStub([avCaptureSessionMock alloc]).andReturn(avCaptureSessionMock); + OCMStub([avCaptureSessionMock canSetSessionPreset:[OCMArg any]]).andReturn(YES); + + MockErrorFlutterResult *resultObject = + [[MockErrorFlutterResult alloc] initWithExpectation:expectation]; + + // Set up method call + FlutterMethodCall *call = [FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"resolutionPreset" : @"medium", @"enableAudio" : @(1)}]; + + [camera createCameraOnSessionQueueWithCreateMethodCall:call result:resultObject]; + [self waitForExpectationsWithTimeout:1 handler:nil]; + + // Verify the result + NSDictionary *dictionaryResult = (NSDictionary *)resultObject.receivedResult; + XCTAssertNotNil(dictionaryResult); + XCTAssert([[dictionaryResult allKeys] containsObject:@"cameraId"]); + + return resultObject.receivedError; +} + +- (void)testCameraCreateWithMediaSettings_shouldRejectNegativeIntNumbers { + id errorOrNil = + [self failingTestWithArguments:@{@"fps" : @(-1), @"resolutionPreset" : @"medium"}]; + XCTAssertEqualObjects([errorOrNil localizedDescription], @"fps should be a positive number", + "should reject negative int number"); +} + +- (void)testCameraCreateWithMediaSettings_shouldRejectNegativeFloatingPointNumbers { + id errorOrNil = + [self failingTestWithArguments:@{@"fps" : @(-3.7), @"resolutionPreset" : @"medium"}]; + XCTAssertEqualObjects([errorOrNil localizedDescription], @"fps should be a positive number", + "should reject negative floating point number"); +} + +- (void)testCameraCreateWithMediaSettings_nanShouldBeParsedAsNil { + id errorOrNil = + [self failingTestWithArguments:@{@"fps" : @(NAN), @"resolutionPreset" : @"medium"}]; + XCTAssertEqualObjects([errorOrNil localizedDescription], @"fps should not be a nan", + "should reject NAN"); +} + +- (void)testCameraCreateWithMediaSettings_shouldNotRejectNilArguments { + id errorOrNil = [self goodTestWithArguments:@{@"resolutionPreset" : @"medium"}]; + XCTAssertNil(errorOrNil, "should accept nil"); +} + +- (void)testCameraCreateWithMediaSettings_shouldAcceptNull { + id errorOrNil = + [self goodTestWithArguments:@{@"fps" : [NSNull null], @"resolutionPreset" : @"medium"}]; + XCTAssertNil(errorOrNil, "should accept [NSNull null]"); +} + +- (void)testCameraCreateWithMediaSettings_shouldAcceptPositiveDecimalNumbers { + id errorOrNil = [self goodTestWithArguments:@{@"fps" : @(5), @"resolutionPreset" : @"medium"}]; + XCTAssertNil(errorOrNil, "should parse positive int number"); +} + +- (void)testCameraCreateWithMediaSettings_shouldAcceptPositiveFloatingPointNumbers { + id errorOrNil = [self goodTestWithArguments:@{@"fps" : @(3.7), @"resolutionPreset" : @"medium"}]; + XCTAssertNil(errorOrNil, "should accept positive floating point number"); +} + +- (void)testCameraCreateWithMediaSettings_shouldRejectWrongVideoBitrate { + id errorOrNil = + [self failingTestWithArguments:@{@"videoBitrate" : @(-1), @"resolutionPreset" : @"medium"}]; + XCTAssertEqualObjects([errorOrNil localizedDescription], + @"videoBitrate should be a positive number", + "should reject wrong video bitrate"); +} + +- (void)testCameraCreateWithMediaSettings_shouldRejectWrongAudioBitrate { + id errorOrNil = + [self failingTestWithArguments:@{@"audioBitrate" : @(-1), @"resolutionPreset" : @"medium"}]; + XCTAssertEqualObjects([errorOrNil localizedDescription], + @"audioBitrate should be a positive number", + "should reject wrong audio bitrate"); +} + +- (void)testCameraCreateWithMediaSettings_shouldAcceptGoodVideoBitrate { + id errorOrNil = + [self goodTestWithArguments:@{@"videoBitrate" : @(200000), @"resolutionPreset" : @"medium"}]; + XCTAssertNil(errorOrNil, "should accept good video bitrate"); +} + +- (void)testCameraCreateWithMediaSettings_shouldAcceptGoodAudioBitrate { + id errorOrNil = + [self goodTestWithArguments:@{@"audioBitrate" : @(32000), @"resolutionPreset" : @"medium"}]; + XCTAssertNil(errorOrNil, "should accept good audio bitrate"); +} + +@end + +@interface CameraSettingsTests : XCTestCase +@end + +/** + * A test implemetation of `FLTCamMediaSettingsAVWrapper` + * + * This xctest-expectation-checking implementation of `FLTCamMediaSettingsAVWrapper` is injected + * into `camera-avfoundation` plugin instead of real AVFoundation-based realization. + * Such kind of Dependency Injection (DI) allows to run media-settings tests without + * any additional mocking of AVFoundation classes. + */ +@interface TestMediaSettingsAVWrapper : FLTCamMediaSettingsAVWrapper +@property(nonatomic, readonly) XCTestExpectation *lockExpectation; +@property(nonatomic, readonly) XCTestExpectation *unlockExpectation; +@property(nonatomic, readonly) XCTestExpectation *minFrameDurationExpectation; +@property(nonatomic, readonly) XCTestExpectation *maxFrameDurationExpectation; +@property(nonatomic, readonly) XCTestExpectation *beginConfigurationExpectation; +@property(nonatomic, readonly) XCTestExpectation *commitConfigurationExpectation; +@property(nonatomic, readonly) XCTestExpectation *audioSettingsExpectation; +@property(nonatomic, readonly) XCTestExpectation *videoSettingsExpectation; +@end + +@implementation TestMediaSettingsAVWrapper + +- (instancetype)initWithTestCase:(XCTestCase *)test { + _lockExpectation = [test expectationWithDescription:@"lockExpectation"]; + _unlockExpectation = [test expectationWithDescription:@"unlockExpectation"]; + _minFrameDurationExpectation = [test expectationWithDescription:@"minFrameDurationExpectation"]; + _maxFrameDurationExpectation = [test expectationWithDescription:@"maxFrameDurationExpectation"]; + _beginConfigurationExpectation = + [test expectationWithDescription:@"beginConfigurationExpectation"]; + _commitConfigurationExpectation = + [test expectationWithDescription:@"commitConfigurationExpectation"]; + _audioSettingsExpectation = [test expectationWithDescription:@"audioSettingsExpectation"]; + _videoSettingsExpectation = [test expectationWithDescription:@"videoSettingsExpectation"]; + + return self; +} + +- (BOOL)lockDevice:(AVCaptureDevice *)captureDevice error:(NSError **)outError { + [_lockExpectation fulfill]; + return YES; +} + +- (void)unlockDevice:(AVCaptureDevice *)captureDevice { + [_unlockExpectation fulfill]; +} + +- (void)beginConfigurationForSession:(AVCaptureSession *)videoCaptureSession { + [_beginConfigurationExpectation fulfill]; +} + +- (void)commitConfigurationForSession:(AVCaptureSession *)videoCaptureSession { + [_commitConfigurationExpectation fulfill]; +} + +- (void)setMinFrameDuration:(CMTime)duration onDevice:(AVCaptureDevice *)captureDevice { + // FLTCam allows to set frame rate with 1/10 precision. + CMTime expectedDuration = CMTimeMake(10, gTestFramesPerSecond * 10); + + if (duration.value == expectedDuration.value && + duration.timescale == expectedDuration.timescale) { + [_minFrameDurationExpectation fulfill]; + } +} + +- (void)setMaxFrameDuration:(CMTime)duration onDevice:(AVCaptureDevice *)captureDevice { + // FLTCam allows to set frame rate with 1/10 precision. + CMTime expectedDuration = CMTimeMake(10, gTestFramesPerSecond * 10); + + if (duration.value == expectedDuration.value && + duration.timescale == expectedDuration.timescale) { + [_maxFrameDurationExpectation fulfill]; + } +} + +- (AVAssetWriterInput *)assetWriterAudioInputWithOutputSettings: + (nullable NSDictionary *)outputSettings { + if ([outputSettings[AVEncoderBitRateKey] isEqual:@(gTestAudioBitrate)]) { + [_audioSettingsExpectation fulfill]; + } + + return [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio + outputSettings:outputSettings]; +} + +- (AVAssetWriterInput *)assetWriterVideoInputWithOutputSettings: + (nullable NSDictionary *)outputSettings { + if ([outputSettings[AVVideoCompressionPropertiesKey] isKindOfClass:[NSMutableDictionary class]]) { + NSDictionary *compressionProperties = outputSettings[AVVideoCompressionPropertiesKey]; + + if ([compressionProperties[AVVideoAverageBitRateKey] isEqual:@(gTestVideoBitrate)] && + [compressionProperties[AVVideoExpectedSourceFrameRateKey] + isEqual:@(gTestFramesPerSecond)]) { + [_videoSettingsExpectation fulfill]; + } + } + + return [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo + outputSettings:outputSettings]; +} + +- (void)addInput:(AVAssetWriterInput *)writerInput toAssetWriter:(AVAssetWriter *)writer { +} + +- (NSDictionary *) + recommendedVideoSettingsForAssetWriterWithFileType:(AVFileType)fileType + forOutput:(AVCaptureVideoDataOutput *)output { + return @{}; +} + +@end + +@implementation CameraSettingsTests + +/// Expect that FPS, video and audio bitrate are passed to camera device and asset writer. +- (void)testSettings_shouldPassConfigurationToCameraDeviceAndWriter { + FLTCamMediaSettings *settings = + [[FLTCamMediaSettings alloc] initWithFramesPerSecond:@(gTestFramesPerSecond) + videoBitrate:@(gTestVideoBitrate) + audioBitrate:@(gTestAudioBitrate) + enableAudio:gTestEnableAudio]; + TestMediaSettingsAVWrapper *injectedWrapper = + [[TestMediaSettingsAVWrapper alloc] initWithTestCase:self]; + + FLTCam *camera = FLTCreateCamWithCaptureSessionQueueAndMediaSettings( + dispatch_queue_create("test", NULL), settings, injectedWrapper); + + // Expect FPS configuration is passed to camera device. + [self waitForExpectations:@[ + injectedWrapper.lockExpectation, injectedWrapper.beginConfigurationExpectation, + injectedWrapper.minFrameDurationExpectation, injectedWrapper.maxFrameDurationExpectation, + injectedWrapper.commitConfigurationExpectation, injectedWrapper.unlockExpectation + ] + timeout:1 + enforceOrder:YES]; + + FLTThreadSafeFlutterResult *result = + [[FLTThreadSafeFlutterResult alloc] initWithResult:^(id result){ + }]; + + [camera startVideoRecordingWithResult:result]; + + [self waitForExpectations:@[ + injectedWrapper.audioSettingsExpectation, injectedWrapper.videoSettingsExpectation + ] + timeout:1]; +} + +- (void)testSettings_ShouldBeSupportedByMethodCall { + CameraPlugin *camera = [[CameraPlugin alloc] initWithRegistry:nil messenger:nil]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Result finished"]; + + // Set up mocks for initWithCameraName method + id avCaptureDeviceInputMock = OCMClassMock([AVCaptureDeviceInput class]); + OCMStub([avCaptureDeviceInputMock deviceInputWithDevice:[OCMArg any] error:[OCMArg anyObjectRef]]) + .andReturn([AVCaptureInput alloc]); + + id avCaptureSessionMock = OCMClassMock([AVCaptureSession class]); + OCMStub([avCaptureSessionMock alloc]).andReturn(avCaptureSessionMock); + OCMStub([avCaptureSessionMock canSetSessionPreset:[OCMArg any]]).andReturn(YES); + + MockFLTThreadSafeFlutterResult *resultObject = + [[MockFLTThreadSafeFlutterResult alloc] initWithExpectation:expectation]; + + // Set up method call + FlutterMethodCall *call = + [FlutterMethodCall methodCallWithMethodName:@"create" + arguments:@{ + @"resolutionPreset" : @(gTestResolutionPreset), + @"enableAudio" : @(gTestEnableAudio), + @"fps" : @(gTestFramesPerSecond), + @"videoBitrate" : @(gTestVideoBitrate), + @"audioBitrate" : @(gTestAudioBitrate) + }]; + + [camera createCameraOnSessionQueueWithCreateMethodCall:call result:resultObject]; + [self waitForExpectationsWithTimeout:1 handler:nil]; + + // Verify the result + NSDictionary *dictionaryResult = (NSDictionary *)resultObject.receivedResult; + XCTAssertNotNil(dictionaryResult); + XCTAssert([[dictionaryResult allKeys] containsObject:@"cameraId"]); +} + +@end diff --git a/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraTestUtils.h b/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraTestUtils.h index cdc11bff6c82..1982be72f117 100644 --- a/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraTestUtils.h +++ b/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraTestUtils.h @@ -8,7 +8,14 @@ NS_ASSUME_NONNULL_BEGIN /// Creates an `FLTCam` that runs its capture session operations on a given queue. /// @param captureSessionQueue the capture session queue +/// @param mediaSettings media settings configuration parameters +/// @param mediaSettingsAVWrapper provider to perform media settings operations (for unit test +/// dependency injection). /// @return an FLTCam object. +extern FLTCam *_Nullable FLTCreateCamWithCaptureSessionQueueAndMediaSettings( + dispatch_queue_t _Nullable captureSessionQueue, FLTCamMediaSettings *_Nullable mediaSettings, + FLTCamMediaSettingsAVWrapper *_Nullable mediaSettingsAVWrapper); + extern FLTCam *FLTCreateCamWithCaptureSessionQueue(dispatch_queue_t captureSessionQueue); /// Creates an `FLTCam` with a given captureSession and resolutionPreset diff --git a/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraTestUtils.m b/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraTestUtils.m index d0456f7aa544..d334576e212d 100644 --- a/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraTestUtils.m +++ b/packages/camera/camera_avfoundation/example/ios/RunnerTests/CameraTestUtils.m @@ -7,11 +7,35 @@ @import AVFoundation; FLTCam *FLTCreateCamWithCaptureSessionQueue(dispatch_queue_t captureSessionQueue) { + return FLTCreateCamWithCaptureSessionQueueAndMediaSettings(captureSessionQueue, nil, nil); +} + +FLTCam *FLTCreateCamWithCaptureSessionQueueAndMediaSettings( + dispatch_queue_t captureSessionQueue, FLTCamMediaSettings *mediaSettings, + FLTCamMediaSettingsAVWrapper *mediaSettingsAVWrapper) { + if (!mediaSettings) { + mediaSettings = [[FLTCamMediaSettings alloc] initWithFramesPerSecond:nil + videoBitrate:nil + audioBitrate:nil + enableAudio:true]; + } + + if (!mediaSettingsAVWrapper) { + mediaSettingsAVWrapper = [[FLTCamMediaSettingsAVWrapper alloc] init]; + } + id inputMock = OCMClassMock([AVCaptureDeviceInput class]); OCMStub([inputMock deviceInputWithDevice:[OCMArg any] error:[OCMArg setTo:nil]]) .andReturn(inputMock); id videoSessionMock = OCMClassMock([AVCaptureSession class]); + OCMStub([videoSessionMock beginConfiguration]) + .andDo(^(NSInvocation *invocation){ + }); + OCMStub([videoSessionMock commitConfiguration]) + .andDo(^(NSInvocation *invocation){ + }); + OCMStub([videoSessionMock addInputWithNoConnections:[OCMArg any]]); OCMStub([videoSessionMock canSetSessionPreset:[OCMArg any]]).andReturn(YES); @@ -19,14 +43,42 @@ OCMStub([audioSessionMock addInputWithNoConnections:[OCMArg any]]); OCMStub([audioSessionMock canSetSessionPreset:[OCMArg any]]).andReturn(YES); - return [[FLTCam alloc] initWithCameraName:@"camera" - resolutionPreset:@"medium" - enableAudio:true - orientation:UIDeviceOrientationPortrait - videoCaptureSession:videoSessionMock - audioCaptureSession:audioSessionMock - captureSessionQueue:captureSessionQueue - error:nil]; + id fltCam = [[FLTCam alloc] initWithCameraName:@"camera" + resolutionPreset:@"medium" + mediaSettings:mediaSettings + mediaSettingsAVWrapper:mediaSettingsAVWrapper + orientation:UIDeviceOrientationPortrait + videoCaptureSession:videoSessionMock + audioCaptureSession:audioSessionMock + captureSessionQueue:captureSessionQueue + error:nil]; + + id captureVideoDataOutputMock = [OCMockObject niceMockForClass:[AVCaptureVideoDataOutput class]]; + + OCMStub([captureVideoDataOutputMock new]).andReturn(captureVideoDataOutputMock); + + OCMStub([captureVideoDataOutputMock + recommendedVideoSettingsForAssetWriterWithOutputFileType:AVFileTypeMPEG4]) + .andReturn(@{}); + + OCMStub([captureVideoDataOutputMock sampleBufferCallbackQueue]).andReturn(captureSessionQueue); + + id videoMock = OCMClassMock([AVAssetWriterInputPixelBufferAdaptor class]); + OCMStub([videoMock assetWriterInputPixelBufferAdaptorWithAssetWriterInput:OCMOCK_ANY + sourcePixelBufferAttributes:OCMOCK_ANY]) + .andReturn(videoMock); + + id writerInputMock = [OCMockObject niceMockForClass:[AVAssetWriterInput class]]; + + OCMStub([writerInputMock assetWriterInputWithMediaType:AVMediaTypeAudio + outputSettings:[OCMArg any]]) + .andReturn(writerInputMock); + + OCMStub([writerInputMock assetWriterInputWithMediaType:AVMediaTypeVideo + outputSettings:[OCMArg any]]) + .andReturn(writerInputMock); + + return fltCam; } FLTCam *FLTCreateCamWithVideoCaptureSession(AVCaptureSession *captureSession, @@ -39,14 +91,19 @@ OCMStub([audioSessionMock addInputWithNoConnections:[OCMArg any]]); OCMStub([audioSessionMock canSetSessionPreset:[OCMArg any]]).andReturn(YES); - return [[FLTCam alloc] initWithCameraName:@"camera" - resolutionPreset:resolutionPreset - enableAudio:true - orientation:UIDeviceOrientationPortrait - videoCaptureSession:captureSession - audioCaptureSession:audioSessionMock - captureSessionQueue:dispatch_queue_create("capture_session_queue", NULL) - error:nil]; + return + [[FLTCam alloc] initWithCameraName:@"camera" + resolutionPreset:resolutionPreset + mediaSettings:[[FLTCamMediaSettings alloc] initWithFramesPerSecond:nil + videoBitrate:nil + audioBitrate:nil + enableAudio:true] + mediaSettingsAVWrapper:[[FLTCamMediaSettingsAVWrapper alloc] init] + orientation:UIDeviceOrientationPortrait + videoCaptureSession:captureSession + audioCaptureSession:audioSessionMock + captureSessionQueue:dispatch_queue_create("capture_session_queue", NULL) + error:nil]; } FLTCam *FLTCreateCamWithVideoDimensionsForFormat( @@ -60,18 +117,22 @@ OCMStub([audioSessionMock addInputWithNoConnections:[OCMArg any]]); OCMStub([audioSessionMock canSetSessionPreset:[OCMArg any]]).andReturn(YES); - return - [[FLTCam alloc] initWithResolutionPreset:resolutionPreset - enableAudio:true - orientation:UIDeviceOrientationPortrait - videoCaptureSession:captureSession - audioCaptureSession:audioSessionMock - captureSessionQueue:dispatch_queue_create("capture_session_queue", NULL) - captureDeviceFactory:^AVCaptureDevice *(void) { - return captureDevice; - } - videoDimensionsForFormat:videoDimensionsForFormat - error:nil]; + return [[FLTCam alloc] + initWithResolutionPreset:resolutionPreset + mediaSettings:[[FLTCamMediaSettings alloc] initWithFramesPerSecond:nil + videoBitrate:nil + audioBitrate:nil + enableAudio:true] + mediaSettingsAVWrapper:[[FLTCamMediaSettingsAVWrapper alloc] init] + orientation:UIDeviceOrientationPortrait + videoCaptureSession:captureSession + audioCaptureSession:audioSessionMock + captureSessionQueue:dispatch_queue_create("capture_session_queue", NULL) + captureDeviceFactory:^AVCaptureDevice *(void) { + return captureDevice; + } + videoDimensionsForFormat:videoDimensionsForFormat + error:nil]; } CMSampleBufferRef FLTCreateTestSampleBuffer(void) { diff --git a/packages/camera/camera_avfoundation/example/lib/camera_controller.dart b/packages/camera/camera_avfoundation/example/lib/camera_controller.dart index 5dfed52ce994..05867cf6dd08 100644 --- a/packages/camera/camera_avfoundation/example/lib/camera_controller.dart +++ b/packages/camera/camera_avfoundation/example/lib/camera_controller.dart @@ -171,26 +171,35 @@ class CameraValue { /// outside of the overall example code. class CameraController extends ValueNotifier { /// Creates a new camera controller in an uninitialized state. - CameraController( + factory CameraController( CameraDescription cameraDescription, - this.resolutionPreset, { - this.enableAudio = true, + ResolutionPreset resolutionPreset, { + bool enableAudio = true, + ImageFormatGroup? imageFormatGroup, + }) => + CameraController.withSettings( + cameraDescription, + mediaSettings: MediaSettings( + resolutionPreset: resolutionPreset, + enableAudio: enableAudio, + ), + imageFormatGroup: imageFormatGroup, + ); + + /// Creates a new camera controller in an uninitialized state, using specified media settings like FPS and bitrate. + CameraController.withSettings( + CameraDescription cameraDescription, { + required this.mediaSettings, this.imageFormatGroup, - }) : super(CameraValue.uninitialized(cameraDescription)); + }) : assert(mediaSettings.resolutionPreset != null, + 'resolutionPreset should be provided in CameraController.withSettings'), + super(CameraValue.uninitialized(cameraDescription)); /// The properties of the camera device controlled by this controller. CameraDescription get description => value.description; - /// The resolution this controller is targeting. - /// - /// This resolution preset is not guaranteed to be available on the device, - /// if unavailable a lower resolution will be used. - /// - /// See also: [ResolutionPreset]. - final ResolutionPreset resolutionPreset; - - /// Whether to include audio when recording a video. - final bool enableAudio; + /// Media settings for video recording. + final MediaSettings mediaSettings; /// The [ImageFormatGroup] describes the output of the raw image format. /// @@ -223,10 +232,9 @@ class CameraController extends ValueNotifier { ); }); - _cameraId = await CameraPlatform.instance.createCamera( + _cameraId = await CameraPlatform.instance.createCameraWithSettings( description, - resolutionPreset, - enableAudio: enableAudio, + mediaSettings, ); unawaited(CameraPlatform.instance @@ -540,7 +548,7 @@ class Optional extends IterableBase { /// If the Optional is [absent()], returns [absent()] without applying the transformer. /// /// The transformer must not return `null`. If it does, an [ArgumentError] is thrown. - Optional transform(S Function(T value) transformer) { + Optional transform(S Function(T? value) transformer) { return _value == null ? Optional.absent() : Optional.of(transformer(_value)); @@ -551,7 +559,7 @@ class Optional extends IterableBase { /// If the Optional is [absent()], returns [absent()] without applying the transformer. /// /// Returns [absent()] if the transformer returns `null`. - Optional transformNullable(S? Function(T value) transformer) { + Optional transformNullable(S? Function(T? value) transformer) { return _value == null ? Optional.absent() : Optional.fromNullable(transformer(_value)); diff --git a/packages/camera/camera_avfoundation/example/lib/main.dart b/packages/camera/camera_avfoundation/example/lib/main.dart index 26d2527eeddb..9b8b9759f320 100644 --- a/packages/camera/camera_avfoundation/example/lib/main.dart +++ b/packages/camera/camera_avfoundation/example/lib/main.dart @@ -593,7 +593,9 @@ class _CameraExampleHomeState extends State title: Icon(getCameraLensIcon(cameraDescription.lensDirection)), groupValue: controller?.description, value: cameraDescription, - onChanged: onChanged, + onChanged: (controller?.value.isRecordingVideo ?? false) + ? null + : onChanged, ), ), ); diff --git a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m index b30557a36802..243d7271869a 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m +++ b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin.m @@ -299,6 +299,59 @@ - (void)handleCreateMethodCall:(FlutterMethodCall *)call }); } +// Returns number value if provided and positive, or nil. +// Used to parse values like framerates and bitrates, that are positive by nature. +// nil allows to ignore unsupported values. ++ (NSNumber *)positiveNumberValueOrNilForArgument:(NSString *)argument + fromMethod:(FlutterMethodCall *)flutterMethodCall + error:(NSError **)error { + id value = flutterMethodCall.arguments[argument]; + + if (!value || [value isEqual:[NSNull null]]) { + return nil; + } + + if (![value isKindOfClass:[NSNumber class]]) { + if (error) { + *error = [NSError errorWithDomain:@"ArgumentError" + code:0 + userInfo:@{ + NSLocalizedDescriptionKey : + [NSString stringWithFormat:@"%@ should be a number", argument] + }]; + } + return nil; + } + + NSNumber *number = (NSNumber *)value; + + if (isnan([number doubleValue])) { + if (error) { + *error = [NSError errorWithDomain:@"ArgumentError" + code:0 + userInfo:@{ + NSLocalizedDescriptionKey : + [NSString stringWithFormat:@"%@ should not be a nan", argument] + }]; + } + return nil; + } + + if ([number doubleValue] <= 0.0) { + if (error) { + *error = [NSError errorWithDomain:@"ArgumentError" + code:0 + userInfo:@{ + NSLocalizedDescriptionKey : [NSString + stringWithFormat:@"%@ should be a positive number", argument] + }]; + } + return nil; + } + + return number; +} + - (void)createCameraOnSessionQueueWithCreateMethodCall:(FlutterMethodCall *)createMethodCall result:(FLTThreadSafeFlutterResult *)result { __weak typeof(self) weakSelf = self; @@ -307,12 +360,47 @@ - (void)createCameraOnSessionQueueWithCreateMethodCall:(FlutterMethodCall *)crea if (!strongSelf) return; NSString *cameraName = createMethodCall.arguments[@"cameraName"]; + + NSError *error; + + NSNumber *framesPerSecond = [CameraPlugin positiveNumberValueOrNilForArgument:@"fps" + fromMethod:createMethodCall + error:&error]; + if (error) { + [result sendError:error]; + return; + } + + NSNumber *videoBitrate = [CameraPlugin positiveNumberValueOrNilForArgument:@"videoBitrate" + fromMethod:createMethodCall + error:&error]; + if (error) { + [result sendError:error]; + return; + } + + NSNumber *audioBitrate = [CameraPlugin positiveNumberValueOrNilForArgument:@"audioBitrate" + fromMethod:createMethodCall + error:&error]; + if (error) { + [result sendError:error]; + return; + } + NSString *resolutionPreset = createMethodCall.arguments[@"resolutionPreset"]; NSNumber *enableAudio = createMethodCall.arguments[@"enableAudio"]; - NSError *error; + FLTCamMediaSettings *mediaSettings = + [[FLTCamMediaSettings alloc] initWithFramesPerSecond:framesPerSecond + videoBitrate:videoBitrate + audioBitrate:audioBitrate + enableAudio:[enableAudio boolValue]]; + FLTCamMediaSettingsAVWrapper *mediaSettingsAVWrapper = + [[FLTCamMediaSettingsAVWrapper alloc] init]; + FLTCam *cam = [[FLTCam alloc] initWithCameraName:cameraName resolutionPreset:resolutionPreset - enableAudio:[enableAudio boolValue] + mediaSettings:mediaSettings + mediaSettingsAVWrapper:mediaSettingsAVWrapper orientation:[[UIDevice currentDevice] orientation] captureSessionQueue:strongSelf.captureSessionQueue error:&error]; diff --git a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin_Test.h b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin_Test.h index f6c97da4ad84..230648ff550c 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin_Test.h +++ b/packages/camera/camera_avfoundation/ios/Classes/CameraPlugin_Test.h @@ -47,5 +47,4 @@ /// @param result a thread safe flutter result wrapper object to report creation result. - (void)createCameraOnSessionQueueWithCreateMethodCall:(FlutterMethodCall *)createMethodCall result:(FLTThreadSafeFlutterResult *)result; - @end diff --git a/packages/camera/camera_avfoundation/ios/Classes/FLTCam.h b/packages/camera/camera_avfoundation/ios/Classes/FLTCam.h index 5234233f4c2e..e1216fd7dc99 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/FLTCam.h +++ b/packages/camera/camera_avfoundation/ios/Classes/FLTCam.h @@ -7,6 +7,8 @@ @import Flutter; #import "CameraProperties.h" +#import "FLTCamMediaSettings.h" +#import "FLTCamMediaSettingsAVWrapper.h" #import "FLTThreadSafeEventChannel.h" #import "FLTThreadSafeFlutterResult.h" #import "FLTThreadSafeMethodChannel.h" @@ -33,13 +35,16 @@ NS_ASSUME_NONNULL_BEGIN /// Initializes an `FLTCam` instance. /// @param cameraName a name used to uniquely identify the camera. /// @param resolutionPreset the resolution preset -/// @param enableAudio YES if audio should be enabled for video capturing; NO otherwise. +/// @param mediaSettings the media settings configuration parameters +/// @param mediaSettingsAVWrapper AVFoundation wrapper to perform media settings related operations +/// (for dependency injection in unit tests). /// @param orientation the orientation of camera /// @param captureSessionQueue the queue on which camera's capture session operations happen. /// @param error report to the caller if any error happened creating the camera. - (instancetype)initWithCameraName:(NSString *)cameraName resolutionPreset:(NSString *)resolutionPreset - enableAudio:(BOOL)enableAudio + mediaSettings:(FLTCamMediaSettings *)mediaSettings + mediaSettingsAVWrapper:(FLTCamMediaSettingsAVWrapper *)mediaSettingsAVWrapper orientation:(UIDeviceOrientation)orientation captureSessionQueue:(dispatch_queue_t)captureSessionQueue error:(NSError **)error; diff --git a/packages/camera/camera_avfoundation/ios/Classes/FLTCam.m b/packages/camera/camera_avfoundation/ios/Classes/FLTCam.m index b16d65fe40e3..b2f09e9fed76 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/FLTCam.m +++ b/packages/camera/camera_avfoundation/ios/Classes/FLTCam.m @@ -41,7 +41,8 @@ @interface FLTCam () @property(readonly, nonatomic) int64_t textureId; -@property BOOL enableAudio; +@property(readonly, nonatomic) FLTCamMediaSettings *mediaSettings; +@property(readonly, nonatomic) FLTCamMediaSettingsAVWrapper *mediaSettingsAVWrapper; @property(nonatomic) FLTImageStreamHandler *imageStreamHandler; @property(readonly, nonatomic) AVCaptureSession *videoCaptureSession; @property(readonly, nonatomic) AVCaptureSession *audioCaptureSession; @@ -99,13 +100,15 @@ @implementation FLTCam - (instancetype)initWithCameraName:(NSString *)cameraName resolutionPreset:(NSString *)resolutionPreset - enableAudio:(BOOL)enableAudio + mediaSettings:(FLTCamMediaSettings *)mediaSettings + mediaSettingsAVWrapper:(FLTCamMediaSettingsAVWrapper *)mediaSettingsAVWrapper orientation:(UIDeviceOrientation)orientation captureSessionQueue:(dispatch_queue_t)captureSessionQueue error:(NSError **)error { return [self initWithCameraName:cameraName resolutionPreset:resolutionPreset - enableAudio:enableAudio + mediaSettings:mediaSettings + mediaSettingsAVWrapper:mediaSettingsAVWrapper orientation:orientation videoCaptureSession:[[AVCaptureSession alloc] init] audioCaptureSession:[[AVCaptureSession alloc] init] @@ -115,14 +118,16 @@ - (instancetype)initWithCameraName:(NSString *)cameraName - (instancetype)initWithCameraName:(NSString *)cameraName resolutionPreset:(NSString *)resolutionPreset - enableAudio:(BOOL)enableAudio + mediaSettings:(FLTCamMediaSettings *)mediaSettings + mediaSettingsAVWrapper:(FLTCamMediaSettingsAVWrapper *)mediaSettingsAVWrapper orientation:(UIDeviceOrientation)orientation videoCaptureSession:(AVCaptureSession *)videoCaptureSession audioCaptureSession:(AVCaptureSession *)audioCaptureSession captureSessionQueue:(dispatch_queue_t)captureSessionQueue error:(NSError **)error { return [self initWithResolutionPreset:resolutionPreset - enableAudio:enableAudio + mediaSettings:mediaSettings + mediaSettingsAVWrapper:mediaSettingsAVWrapper orientation:orientation videoCaptureSession:videoCaptureSession audioCaptureSession:videoCaptureSession @@ -137,7 +142,8 @@ - (instancetype)initWithCameraName:(NSString *)cameraName } - (instancetype)initWithResolutionPreset:(NSString *)resolutionPreset - enableAudio:(BOOL)enableAudio + mediaSettings:(FLTCamMediaSettings *)mediaSettings + mediaSettingsAVWrapper:(FLTCamMediaSettingsAVWrapper *)mediaSettingsAVWrapper orientation:(UIDeviceOrientation)orientation videoCaptureSession:(AVCaptureSession *)videoCaptureSession audioCaptureSession:(AVCaptureSession *)audioCaptureSession @@ -158,7 +164,10 @@ - (instancetype)initWithResolutionPreset:(NSString *)resolutionPreset }]; return nil; } - _enableAudio = enableAudio; + + _mediaSettings = mediaSettings; + _mediaSettingsAVWrapper = mediaSettingsAVWrapper; + _captureSessionQueue = captureSessionQueue; _pixelBufferSynchronizationQueue = dispatch_queue_create("io.flutter.camera.pixelBufferSynchronizationQueue", NULL); @@ -185,7 +194,9 @@ - (instancetype)initWithResolutionPreset:(NSString *)resolutionPreset NSError *localError = nil; AVCaptureConnection *connection = [self createConnection:&localError]; if (localError) { - *error = localError; + if (error != nil) { + *error = localError; + } return nil; } @@ -200,9 +211,42 @@ - (instancetype)initWithResolutionPreset:(NSString *)resolutionPreset _motionManager = [[CMMotionManager alloc] init]; [_motionManager startAccelerometerUpdates]; - if (![self setCaptureSessionPreset:_resolutionPreset withError:error]) { - return nil; + if (_mediaSettings.framesPerSecond) { + // The frame rate can be changed only on a locked for configuration device. + if ([mediaSettingsAVWrapper lockDevice:_captureDevice error:error]) { + [_mediaSettingsAVWrapper beginConfigurationForSession:_videoCaptureSession]; + + // Possible values for presets are hard-coded in FLT interface having + // corresponding AVCaptureSessionPreset counterparts. + // If _resolutionPreset is not supported by camera there is + // fallback to lower resolution presets. + // If none can be selected there is error condition. + if (![self setCaptureSessionPreset:_resolutionPreset withError:error]) { + [_videoCaptureSession commitConfiguration]; + [_captureDevice unlockForConfiguration]; + return nil; + } + + // Set frame rate with 1/10 precision allowing not integral values. + int fpsNominator = floor([_mediaSettings.framesPerSecond doubleValue] * 10.0); + CMTime duration = CMTimeMake(10, fpsNominator); + + [mediaSettingsAVWrapper setMinFrameDuration:duration onDevice:_captureDevice]; + [mediaSettingsAVWrapper setMaxFrameDuration:duration onDevice:_captureDevice]; + + [_mediaSettingsAVWrapper commitConfigurationForSession:_videoCaptureSession]; + [_mediaSettingsAVWrapper unlockDevice:_captureDevice]; + } else { + return nil; + } + } else { + // If the frame rate is not important fall to a less restrictive + // behavior (no configuration locking). + if (![self setCaptureSessionPreset:_resolutionPreset withError:error]) { + return nil; + } } + [self updateOrientation]; return self; @@ -212,7 +256,11 @@ - (AVCaptureConnection *)createConnection:(NSError **)error { // Setup video capture input. _captureVideoInput = [AVCaptureDeviceInput deviceInputWithDevice:_captureDevice error:error]; - if (*error) { + // Test the return value of the `deviceInputWithDevice` method to see whether an error occurred. + // Don’t just test to see whether the error pointer was set to point to an error. + // See: + // https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ProgrammingWithObjectiveC/ErrorHandling/ErrorHandling.html + if (!_captureVideoInput) { return nil; } @@ -457,12 +505,15 @@ - (BOOL)setCaptureSessionPreset:(FLTResolutionPreset)resolutionPreset withError: _videoCaptureSession.sessionPreset = AVCaptureSessionPresetLow; _previewSize = CGSizeMake(352, 288); } else { - *error = [NSError errorWithDomain:NSCocoaErrorDomain - code:NSURLErrorUnknown - userInfo:@{ - NSLocalizedDescriptionKey : - @"No capture session available for current capture session." - }]; + if (error != nil) { + *error = + [NSError errorWithDomain:NSCocoaErrorDomain + code:NSURLErrorUnknown + userInfo:@{ + NSLocalizedDescriptionKey : + @"No capture session available for current capture session." + }]; + } return NO; } } @@ -1211,7 +1262,8 @@ - (BOOL)setupWriterForPath:(NSString *)path { } else { return NO; } - if (_enableAudio && !_isAudioSetup) { + + if (_mediaSettings.enableAudio && !_isAudioSetup) { [self setUpCaptureSessionForAudio]; } @@ -1224,10 +1276,26 @@ - (BOOL)setupWriterForPath:(NSString *)path { return NO; } - NSDictionary *videoSettings = [_captureVideoOutput - recommendedVideoSettingsForAssetWriterWithOutputFileType:AVFileTypeMPEG4]; - _videoWriterInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo - outputSettings:videoSettings]; + NSMutableDictionary *videoSettings = [[_mediaSettingsAVWrapper + recommendedVideoSettingsForAssetWriterWithFileType:AVFileTypeMPEG4 + forOutput:_captureVideoOutput] mutableCopy]; + + if (_mediaSettings.videoBitrate || _mediaSettings.framesPerSecond) { + NSMutableDictionary *compressionProperties = [[NSMutableDictionary alloc] init]; + + if (_mediaSettings.videoBitrate) { + compressionProperties[AVVideoAverageBitRateKey] = _mediaSettings.videoBitrate; + } + + if (_mediaSettings.framesPerSecond) { + compressionProperties[AVVideoExpectedSourceFrameRateKey] = _mediaSettings.framesPerSecond; + } + + videoSettings[AVVideoCompressionPropertiesKey] = compressionProperties; + } + + _videoWriterInput = + [_mediaSettingsAVWrapper assetWriterVideoInputWithOutputSettings:videoSettings]; _videoAdaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:_videoWriterInput @@ -1240,23 +1308,27 @@ - (BOOL)setupWriterForPath:(NSString *)path { _videoWriterInput.expectsMediaDataInRealTime = YES; // Add the audio input - if (_enableAudio) { + if (_mediaSettings.enableAudio) { AudioChannelLayout acl; bzero(&acl, sizeof(acl)); acl.mChannelLayoutTag = kAudioChannelLayoutTag_Mono; - NSDictionary *audioOutputSettings = nil; - // Both type of audio inputs causes output video file to be corrupted. - audioOutputSettings = @{ + NSMutableDictionary *audioOutputSettings = [@{ AVFormatIDKey : [NSNumber numberWithInt:kAudioFormatMPEG4AAC], AVSampleRateKey : [NSNumber numberWithFloat:44100.0], AVNumberOfChannelsKey : [NSNumber numberWithInt:1], AVChannelLayoutKey : [NSData dataWithBytes:&acl length:sizeof(acl)], - }; - _audioWriterInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio - outputSettings:audioOutputSettings]; + } mutableCopy]; + + if (_mediaSettings.audioBitrate) { + audioOutputSettings[AVEncoderBitRateKey] = _mediaSettings.audioBitrate; + } + + _audioWriterInput = + [_mediaSettingsAVWrapper assetWriterAudioInputWithOutputSettings:audioOutputSettings]; + _audioWriterInput.expectsMediaDataInRealTime = YES; - [_videoWriter addInput:_audioWriterInput]; + [_mediaSettingsAVWrapper addInput:_audioWriterInput toAssetWriter:_videoWriter]; [_audioOutput setSampleBufferDelegate:self queue:_captureSessionQueue]; } @@ -1266,7 +1338,7 @@ - (BOOL)setupWriterForPath:(NSString *)path { [self.captureDevice unlockForConfiguration]; } - [_videoWriter addInput:_videoWriterInput]; + [_mediaSettingsAVWrapper addInput:_videoWriterInput toAssetWriter:_videoWriter]; [_captureVideoOutput setSampleBufferDelegate:self queue:_captureSessionQueue]; diff --git a/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettings.h b/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettings.h new file mode 100644 index 000000000000..004accfceb7c --- /dev/null +++ b/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettings.h @@ -0,0 +1,54 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/** + * Media settings configuration parameters. + */ +@interface FLTCamMediaSettings : NSObject + +/** + * @property framesPerSecond optional frame rate of video being recorded. + */ +@property(atomic, readonly, strong, nullable) NSNumber *framesPerSecond; + +/** + * @property videoBitrate optional bitrate of video being recorded. + */ +@property(atomic, readonly, strong, nullable) NSNumber *videoBitrate; + +/** + * @property audioBitrate optional bitrate of audio being recorded. + */ +@property(atomic, readonly, strong, nullable) NSNumber *audioBitrate; + +/** + * @property enableAudio whether audio should be recorded. + */ +@property(atomic, readonly) BOOL enableAudio; + +/** + * @method initWithFramesPerSecond:videoBitrate:audioBitrate:enableAudio: + * + * @abstract Initialize `FLTCamMediaSettings`. + * + * @param framesPerSecond optional frame rate of video being recorded. + * @param videoBitrate optional bitrate of video being recorded. + * @param audioBitrate optional bitrate of audio being recorded. + * @param enableAudio whether audio should be recorded. + * + * @result FLTCamMediaSettings instance + */ +- (instancetype)initWithFramesPerSecond:(nullable NSNumber *)framesPerSecond + videoBitrate:(nullable NSNumber *)videoBitrate + audioBitrate:(nullable NSNumber *)audioBitrate + enableAudio:(BOOL)enableAudio NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettings.m b/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettings.m new file mode 100644 index 000000000000..5c2ca5ae9958 --- /dev/null +++ b/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettings.m @@ -0,0 +1,36 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FLTCamMediaSettings.h" + +static void AssertPositiveNumberOrNil(NSNumber *_Nullable param, const char *_Nonnull paramName) { + if (param != nil) { + NSCAssert(!isnan([param doubleValue]), @"%s is NaN", paramName); + NSCAssert([param doubleValue] > 0, @"%s is not positive: %@", paramName, param); + } +} + +@implementation FLTCamMediaSettings + +- (instancetype)initWithFramesPerSecond:(nullable NSNumber *)framesPerSecond + videoBitrate:(nullable NSNumber *)videoBitrate + audioBitrate:(nullable NSNumber *)audioBitrate + enableAudio:(BOOL)enableAudio { + self = [super init]; + + if (self != nil) { + AssertPositiveNumberOrNil(framesPerSecond, "framesPerSecond"); + AssertPositiveNumberOrNil(videoBitrate, "videoBitrate"); + AssertPositiveNumberOrNil(audioBitrate, "audioBitrate"); + + _framesPerSecond = framesPerSecond; + _videoBitrate = videoBitrate; + _audioBitrate = audioBitrate; + _enableAudio = enableAudio; + } + + return self; +} + +@end diff --git a/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettingsAVWrapper.h b/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettingsAVWrapper.h new file mode 100644 index 000000000000..144a84eac13f --- /dev/null +++ b/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettingsAVWrapper.h @@ -0,0 +1,112 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import AVFoundation; +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/** + * @interface FLTCamMediaSettingsAVWrapper + * @abstract An interface for performing media settings operations. + * + * @discussion + * xctest-expectation-checking implementation (`TestMediaSettingsAVWrapper`) of this interface can + * be injected into `camera-avfoundation` plugin allowing to run media-settings tests without any + * additional mocking of AVFoundation classes. + */ +@interface FLTCamMediaSettingsAVWrapper : NSObject + +/** + * @method lockDevice:error: + * @abstract Requests exclusive access to configure device hardware properties. + * @param captureDevice The capture device. + * @param outError The optional error. + * @result A BOOL indicating whether the device was successfully locked for configuration. + */ +- (BOOL)lockDevice:(AVCaptureDevice *)captureDevice error:(NSError *_Nullable *_Nullable)outError; + +/** + * @method unlockDevice: + * @abstract Release exclusive control over device hardware properties. + * @param captureDevice The capture device. + */ +- (void)unlockDevice:(AVCaptureDevice *)captureDevice; + +/** + * @method beginConfigurationForSession: + * @abstract When paired with commitConfiguration, allows a client to batch multiple configuration + * operations on a running session into atomic updates. + * @param videoCaptureSession The video capture session. + */ +- (void)beginConfigurationForSession:(AVCaptureSession *)videoCaptureSession; + +/** + * @method commitConfigurationForSession: + * @abstract When preceded by beginConfiguration, allows a client to batch multiple configuration + * operations on a running session into atomic updates. + * @param videoCaptureSession The video capture session. + */ +- (void)commitConfigurationForSession:(AVCaptureSession *)videoCaptureSession; + +/** + * @method setMinFrameDuration:onDevice: + * @abstract Set receiver's current active minimum frame duration (the reciprocal of its max frame + * rate). + * @param duration The frame duration. + * @param captureDevice The capture device + */ +- (void)setMinFrameDuration:(CMTime)duration onDevice:(AVCaptureDevice *)captureDevice; + +/** + * @method setMaxFrameDuration:onDevice: + * @abstract Set receiver's current active maximum frame duration (the reciprocal of its min frame + * rate). + * @param duration The frame duration. + * @param captureDevice The capture device + */ +- (void)setMaxFrameDuration:(CMTime)duration onDevice:(AVCaptureDevice *)captureDevice; + +/** + * @method assetWriterAudioInputWithOutputSettings: + * @abstract Creates a new input of the audio media type to receive sample buffers for writing to + * the output file. + * @param outputSettings The settings used for encoding the audio appended to the output. + * @result An instance of `AVAssetWriterInput`. + */ +- (AVAssetWriterInput *)assetWriterAudioInputWithOutputSettings: + (nullable NSDictionary *)outputSettings; + +/** + * @method assetWriterVideoInputWithOutputSettings: + * @abstract Creates a new input of the video media type to receive sample buffers for writing to + * the output file. + * @param outputSettings The settings used for encoding the video appended to the output. + * @result An instance of `AVAssetWriterInput`. + */ +- (AVAssetWriterInput *)assetWriterVideoInputWithOutputSettings: + (nullable NSDictionary *)outputSettings; + +/** + * @method addInput:toAssetWriter: + * @abstract Adds an input to the asset writer. + * @param writerInput The `AVAssetWriterInput` object to be added. + * @param writer The `AVAssetWriter` object. + */ +- (void)addInput:(AVAssetWriterInput *)writerInput toAssetWriter:(AVAssetWriter *)writer; + +/** + * @method recommendedVideoSettingsForAssetWriterWithFileType:forOutput: + * @abstract Specifies the recommended video settings for `AVCaptureVideoDataOutput`. + * @param fileType Specifies the UTI of the file type to be written (see AVMediaFormat.h for a list + * of file format UTIs). + * @param output The `AVCaptureVideoDataOutput` instance. + * @result A fully populated dictionary of keys and values that are compatible with AVAssetWriter. + */ +- (nullable NSDictionary *) + recommendedVideoSettingsForAssetWriterWithFileType:(AVFileType)fileType + forOutput:(AVCaptureVideoDataOutput *)output; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettingsAVWrapper.m b/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettingsAVWrapper.m new file mode 100644 index 000000000000..636b5c7bf4c3 --- /dev/null +++ b/packages/camera/camera_avfoundation/ios/Classes/FLTCamMediaSettingsAVWrapper.m @@ -0,0 +1,55 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FLTCamMediaSettingsAVWrapper.h" + +@implementation FLTCamMediaSettingsAVWrapper + +- (BOOL)lockDevice:(AVCaptureDevice *)captureDevice error:(NSError *_Nullable *_Nullable)outError { + return [captureDevice lockForConfiguration:outError]; +} + +- (void)unlockDevice:(AVCaptureDevice *)captureDevice { + return [captureDevice unlockForConfiguration]; +} + +- (void)beginConfigurationForSession:(AVCaptureSession *)videoCaptureSession { + [videoCaptureSession beginConfiguration]; +} + +- (void)commitConfigurationForSession:(AVCaptureSession *)videoCaptureSession { + [videoCaptureSession commitConfiguration]; +} + +- (void)setMinFrameDuration:(CMTime)duration onDevice:(AVCaptureDevice *)captureDevice { + captureDevice.activeVideoMinFrameDuration = duration; +} + +- (void)setMaxFrameDuration:(CMTime)duration onDevice:(AVCaptureDevice *)captureDevice { + captureDevice.activeVideoMaxFrameDuration = duration; +} + +- (AVAssetWriterInput *)assetWriterAudioInputWithOutputSettings: + (nullable NSDictionary *)outputSettings { + return [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio + outputSettings:outputSettings]; +} + +- (AVAssetWriterInput *)assetWriterVideoInputWithOutputSettings: + (nullable NSDictionary *)outputSettings { + return [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo + outputSettings:outputSettings]; +} + +- (void)addInput:(AVAssetWriterInput *)writerInput toAssetWriter:(AVAssetWriter *)writer { + [writer addInput:writerInput]; +} + +- (nullable NSDictionary *) + recommendedVideoSettingsForAssetWriterWithFileType:(AVFileType)fileType + forOutput:(AVCaptureVideoDataOutput *)output { + return [output recommendedVideoSettingsForAssetWriterWithOutputFileType:fileType]; +} + +@end diff --git a/packages/camera/camera_avfoundation/ios/Classes/FLTCam_Test.h b/packages/camera/camera_avfoundation/ios/Classes/FLTCam_Test.h index 94993feaa74e..ed9fad64d3e2 100644 --- a/packages/camera/camera_avfoundation/ios/Classes/FLTCam_Test.h +++ b/packages/camera/camera_avfoundation/ios/Classes/FLTCam_Test.h @@ -56,7 +56,8 @@ typedef AVCaptureDevice * (^CaptureDeviceFactory)(void); /// Allows for injecting dependencies that are usually internal. - (instancetype)initWithCameraName:(NSString *)cameraName resolutionPreset:(NSString *)resolutionPreset - enableAudio:(BOOL)enableAudio + mediaSettings:(FLTCamMediaSettings *)mediaSettings + mediaSettingsAVWrapper:(FLTCamMediaSettingsAVWrapper *)mediaSettingsAVWrapper orientation:(UIDeviceOrientation)orientation videoCaptureSession:(AVCaptureSession *)videoCaptureSession audioCaptureSession:(AVCaptureSession *)audioCaptureSession @@ -67,7 +68,8 @@ typedef AVCaptureDevice * (^CaptureDeviceFactory)(void); /// Allows for testing with specified resolution, audio preference, orientation, /// and direct access to capture sessions and blocks. - (instancetype)initWithResolutionPreset:(NSString *)resolutionPreset - enableAudio:(BOOL)enableAudio + mediaSettings:(FLTCamMediaSettings *)mediaSettings + mediaSettingsAVWrapper:(FLTCamMediaSettingsAVWrapper *)mediaSettingsAVWrapper orientation:(UIDeviceOrientation)orientation videoCaptureSession:(AVCaptureSession *)videoCaptureSession audioCaptureSession:(AVCaptureSession *)audioCaptureSession diff --git a/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart b/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart index 3af783f6072d..af56e64f5bef 100644 --- a/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart +++ b/packages/camera/camera_avfoundation/lib/src/avfoundation_camera.dart @@ -96,15 +96,30 @@ class AVFoundationCamera extends CameraPlatform { CameraDescription cameraDescription, ResolutionPreset? resolutionPreset, { bool enableAudio = false, - }) async { + }) => + createCameraWithSettings( + cameraDescription, + MediaSettings( + resolutionPreset: resolutionPreset, + enableAudio: enableAudio, + )); + + @override + Future createCameraWithSettings( + CameraDescription cameraDescription, + MediaSettings? mediaSettings, + ) async { try { final Map? reply = await _channel .invokeMapMethod('create', { 'cameraName': cameraDescription.name, - 'resolutionPreset': resolutionPreset != null - ? _serializeResolutionPreset(resolutionPreset) + 'resolutionPreset': null != mediaSettings?.resolutionPreset + ? _serializeResolutionPreset(mediaSettings!.resolutionPreset!) : null, - 'enableAudio': enableAudio, + 'fps': mediaSettings?.fps, + 'videoBitrate': mediaSettings?.videoBitrate, + 'audioBitrate': mediaSettings?.audioBitrate, + 'enableAudio': mediaSettings?.enableAudio ?? true, }); return reply!['cameraId']! as int; diff --git a/packages/camera/camera_avfoundation/pubspec.yaml b/packages/camera/camera_avfoundation/pubspec.yaml index f3af10dc7090..1757aafdc2d1 100644 --- a/packages/camera/camera_avfoundation/pubspec.yaml +++ b/packages/camera/camera_avfoundation/pubspec.yaml @@ -2,8 +2,7 @@ name: camera_avfoundation description: iOS implementation of the camera plugin. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_avfoundation issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 - -version: 0.9.14+2 +version: 0.9.15 environment: sdk: ^3.2.3 diff --git a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart index 5f8aa22f6380..84e28287131a 100644 --- a/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart +++ b/packages/camera/camera_avfoundation/test/avfoundation_camera_test.dart @@ -72,6 +72,54 @@ void main() { arguments: { 'cameraName': 'Test', 'resolutionPreset': 'high', + 'fps': null, + 'videoBitrate': null, + 'audioBitrate': null, + 'enableAudio': false + }, + ), + ]); + expect(cameraId, 1); + }); + + test( + 'Should send creation data and receive back a camera id using createCameraWithSettings', + () async { + // Arrange + final MethodChannelMock cameraMockChannel = MethodChannelMock( + channelName: _channelName, + methods: { + 'create': { + 'cameraId': 1, + 'imageFormatGroup': 'unknown', + } + }); + final AVFoundationCamera camera = AVFoundationCamera(); + + // Act + final int cameraId = await camera.createCameraWithSettings( + const CameraDescription( + name: 'Test', + lensDirection: CameraLensDirection.back, + sensorOrientation: 0), + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + ), + ); + + // Assert + expect(cameraMockChannel.log, [ + isMethodCall( + 'create', + arguments: { + 'cameraName': 'Test', + 'resolutionPreset': 'low', + 'fps': 15, + 'videoBitrate': 200000, + 'audioBitrate': 32000, 'enableAudio': false }, ), diff --git a/packages/camera/camera_web/CHANGELOG.md b/packages/camera/camera_web/CHANGELOG.md index 4303457de66b..0b6202b05b93 100644 --- a/packages/camera/camera_web/CHANGELOG.md +++ b/packages/camera/camera_web/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.3.3 +* Adds support to control video FPS and bitrate. See `CameraController.withSettings`. * Updates minimum supported SDK version to Flutter 3.13/Dart 3.1. ## 0.3.2+4 diff --git a/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart b/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart new file mode 100644 index 000000000000..3bf946029c27 --- /dev/null +++ b/packages/camera/camera_web/example/integration_test/camera_bitrate_test.dart @@ -0,0 +1,146 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:html'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:camera_web/camera_web.dart'; +import 'package:camera_web/src/camera.dart'; +import 'package:camera_web/src/types/types.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'helpers/helpers.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + const Size videoSize = Size(320, 240); + + /// Draw some seconds of random video frames on canvas in realtime. + Future simulateCamera(CanvasElement canvasElement) async { + const int fps = 15; + const int seconds = 3; + const int frameDuration = 1000 ~/ fps; + final Random random = Random(0); + + for (int n = 0; n < fps * seconds; n++) { + await Future.delayed(const Duration(milliseconds: frameDuration)); + final int w = videoSize.width ~/ 20; + final int h = videoSize.height ~/ 20; + for (int y = 0; y < videoSize.height; y += h) { + for (int x = 0; x < videoSize.width; x += w) { + canvasElement.context2D.setFillColorRgb( + random.nextInt(255), random.nextInt(255), random.nextInt(255)); + canvasElement.context2D.fillRect(x, y, w, h); + } + } + } + } + + setUpAll(() { + registerFallbackValue(MockCameraOptions()); + }); + + testWidgets('Camera allows to control video bitrate', + (WidgetTester tester) async { + //const String supportedVideoType = 'video/webm'; + const String supportedVideoType = 'video/webm;codecs="vp9,opus"'; + bool isVideoTypeSupported(String type) => type == supportedVideoType; + + Future recordVideo(int videoBitrate) async { + final Window window = MockWindow(); + final Navigator navigator = MockNavigator(); + final MediaDevices mediaDevices = MockMediaDevices(); + + when(() => window.navigator).thenReturn(navigator); + when(() => navigator.mediaDevices).thenReturn(mediaDevices); + + final CanvasElement canvasElement = CanvasElement( + width: videoSize.width.toInt(), + height: videoSize.height.toInt(), + )..context2D.clearRect(0, 0, videoSize.width, videoSize.height); + + final VideoElement videoElement = VideoElement(); + + final MockCameraService cameraService = MockCameraService(); + + CameraPlatform.instance = CameraPlugin( + cameraService: cameraService, + )..window = window; + + final CameraOptions options = CameraOptions( + audio: const AudioConstraints(), + video: VideoConstraints( + width: VideoSizeConstraint( + ideal: videoSize.width.toInt(), + ), + height: VideoSizeConstraint( + ideal: videoSize.height.toInt(), + ), + ), + ); + + final int cameraId = videoBitrate; + + when( + () { + return cameraService.getMediaStreamForOptions( + options, + cameraId: cameraId, + ); + }, + ).thenAnswer( + (_) => Future.value(canvasElement.captureStream())); + + final Camera camera = Camera( + textureId: cameraId, + cameraService: cameraService, + options: options, + recorderOptions: ( + audioBitrate: null, + videoBitrate: videoBitrate, + )) + ..isVideoTypeSupported = isVideoTypeSupported; + + await camera.initialize(); + await camera.play(); + + await camera.startVideoRecording(); + + await simulateCamera(canvasElement); + + final XFile file = await camera.stopVideoRecording(); + + // Real movie can be saved locally during manual test invocation. + // First: add '--no-headless' to _targetDeviceFlags in + // `script/tool/lib/src/drive_examples_command.dart`, then uncomment: + // Second: uncomment next line + // await file.saveTo('movie.$videoBitrate.webm'); + + await camera.dispose(); + + final int length = await file.length(); + + videoElement.remove(); + + canvasElement.remove(); + + return length; + } + + const int kilobits = 1024; + const int megabits = kilobits * kilobits; + + final int lengthSmall = await recordVideo(500 * kilobits); + final int lengthLarge = await recordVideo(2 * megabits); + final int lengthMedium = await recordVideo(1 * megabits); + + expect(lengthSmall, lessThan(lengthMedium)); + expect(lengthMedium, lessThan(lengthLarge)); + }); +} diff --git a/packages/camera/camera_web/example/integration_test/camera_web_test.dart b/packages/camera/camera_web/example/integration_test/camera_web_test.dart index 56fb16cfab9a..d31855f087eb 100644 --- a/packages/camera/camera_web/example/integration_test/camera_web_test.dart +++ b/packages/camera/camera_web/example/integration_test/camera_web_test.dart @@ -533,32 +533,52 @@ void main() { enableAudio: true, ); - expect( - (CameraPlatform.instance as CameraPlugin).cameras[cameraId], - isA() - .having( - (Camera camera) => camera.textureId, - 'textureId', - cameraId, - ) - .having( - (Camera camera) => camera.options, - 'options', - CameraOptions( - audio: const AudioConstraints(enabled: true), - video: VideoConstraints( - facingMode: FacingModeConstraint(CameraType.user), - width: VideoSizeConstraint( - ideal: ultraHighResolutionSize.width.toInt(), - ), - height: VideoSizeConstraint( - ideal: ultraHighResolutionSize.height.toInt(), - ), - deviceId: cameraMetadata.deviceId, - ), - ), - ), + final Camera? camera = + (CameraPlatform.instance as CameraPlugin).cameras[cameraId]; + + expect(camera, isA()); + expect(camera!.textureId, cameraId); + expect(camera.options.audio.enabled, isTrue); + expect(camera.options.video.facingMode, + equals(FacingModeConstraint(CameraType.user))); + expect(camera.options.video.width!.ideal, + ultraHighResolutionSize.width.toInt()); + expect(camera.options.video.height!.ideal, + ultraHighResolutionSize.height.toInt()); + expect(camera.options.video.deviceId, cameraMetadata.deviceId); + }); + + testWidgets('with appropriate createCameraWithSettings options', + (WidgetTester tester) async { + when( + () => cameraService + .mapResolutionPresetToSize(ResolutionPreset.ultraHigh), + ).thenReturn(ultraHighResolutionSize); + + final int cameraId = + await CameraPlatform.instance.createCameraWithSettings( + cameraDescription, + const MediaSettings( + resolutionPreset: ResolutionPreset.ultraHigh, + videoBitrate: 200000, + audioBitrate: 32000, + enableAudio: true, + ), ); + + final Camera? camera = + (CameraPlatform.instance as CameraPlugin).cameras[cameraId]; + + expect(camera, isA()); + expect(camera!.textureId, cameraId); + expect(camera.options.audio.enabled, isTrue); + expect(camera.options.video.facingMode, + equals(FacingModeConstraint(CameraType.user))); + expect(camera.options.video.width!.ideal, + ultraHighResolutionSize.width.toInt()); + expect(camera.options.video.height!.ideal, + ultraHighResolutionSize.height.toInt()); + expect(camera.options.video.deviceId, cameraMetadata.deviceId); }); testWidgets( @@ -574,26 +594,50 @@ void main() { null, ); - expect( - (CameraPlatform.instance as CameraPlugin).cameras[cameraId], - isA().having( - (Camera camera) => camera.options, - 'options', - CameraOptions( - audio: const AudioConstraints(), - video: VideoConstraints( - facingMode: FacingModeConstraint(CameraType.user), - width: VideoSizeConstraint( - ideal: maxResolutionSize.width.toInt(), - ), - height: VideoSizeConstraint( - ideal: maxResolutionSize.height.toInt(), - ), - deviceId: cameraMetadata.deviceId, - ), - ), + final Camera? camera = + (CameraPlatform.instance as CameraPlugin).cameras[cameraId]; + + expect(camera, isA()); + expect(camera!.textureId, cameraId); + expect(camera.options.audio.enabled, isFalse); + expect(camera.options.video.facingMode, + equals(FacingModeConstraint(CameraType.user))); + expect(camera.options.video.width!.ideal, + maxResolutionSize.width.toInt()); + expect(camera.options.video.height!.ideal, + maxResolutionSize.height.toInt()); + expect(camera.options.video.deviceId, cameraMetadata.deviceId); + }); + + testWidgets( + 'with a max resolution preset ' + 'and enabled audio set to false ' + 'when no options are specified ' + 'using createCameraWithSettings', (WidgetTester tester) async { + when( + () => cameraService.mapResolutionPresetToSize(ResolutionPreset.max), + ).thenReturn(maxResolutionSize); + + final int cameraId = + await CameraPlatform.instance.createCameraWithSettings( + cameraDescription, + const MediaSettings( + resolutionPreset: ResolutionPreset.max, ), ); + + final Camera? camera = + (CameraPlatform.instance as CameraPlugin).cameras[cameraId]; + + expect(camera, isA()); + expect(camera!.options.audio.enabled, isFalse); + expect(camera.options.video.facingMode, + equals(FacingModeConstraint(CameraType.user))); + expect(camera.options.video.width!.ideal, + maxResolutionSize.width.toInt()); + expect(camera.options.video.height!.ideal, + maxResolutionSize.height.toInt()); + expect(camera.options.video.deviceId, cameraMetadata.deviceId); }); }); @@ -620,6 +664,37 @@ void main() { ), ); }); + + testWidgets( + 'throws CameraException ' + 'with missingMetadata error ' + 'if there is no metadata ' + 'for the given camera description ' + 'using createCameraWithSettings', (WidgetTester tester) async { + expect( + () => CameraPlatform.instance.createCameraWithSettings( + const CameraDescription( + name: 'name', + lensDirection: CameraLensDirection.back, + sensorOrientation: 0, + ), + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + enableAudio: true, + ), + ), + throwsA( + isA().having( + (CameraException e) => e.code, + 'code', + CameraErrorCode.missingMetadata.toString(), + ), + ), + ); + }); }); group('initializeCamera', () { @@ -2435,7 +2510,7 @@ void main() { final FakeMediaError error = FakeMediaError( MediaError.MEDIA_ERR_NETWORK, - 'A network error occured.', + 'A network error occurred.', ); final CameraErrorCode errorCode = diff --git a/packages/camera/camera_web/example/pubspec.yaml b/packages/camera/camera_web/example/pubspec.yaml index 18b7681f00ac..e204c437fb4c 100644 --- a/packages/camera/camera_web/example/pubspec.yaml +++ b/packages/camera/camera_web/example/pubspec.yaml @@ -6,8 +6,13 @@ environment: flutter: ">=3.13.0" dependencies: - camera_platform_interface: ^2.1.0 + camera_platform_interface: ^2.6.0 camera_web: + # When depending on this package from a real application you should use: + # camera_web: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. path: ../ flutter: sdk: flutter @@ -20,3 +25,4 @@ dev_dependencies: integration_test: sdk: flutter mocktail: 0.3.0 + diff --git a/packages/camera/camera_web/lib/src/camera.dart b/packages/camera/camera_web/lib/src/camera.dart index b256ca2b428e..acf5ceef888b 100644 --- a/packages/camera/camera_web/lib/src/camera.dart +++ b/packages/camera/camera_web/lib/src/camera.dart @@ -45,6 +45,7 @@ class Camera { required this.textureId, required CameraService cameraService, this.options = const CameraOptions(), + this.recorderOptions = const (audioBitrate: null, videoBitrate: null), }) : _cameraService = cameraService; // A torch mode constraint name. @@ -57,6 +58,9 @@ class Camera { /// The camera options used to initialize a camera, empty by default. final CameraOptions options; + /// The options used to initialize a MediaRecorder. + final ({int? audioBitrate, int? videoBitrate}) recorderOptions; + /// The video element that displays the camera stream. /// Initialized in [initialize]. late final html.VideoElement videoElement; @@ -448,6 +452,10 @@ class Camera { mediaRecorder ??= html.MediaRecorder(videoElement.srcObject!, { 'mimeType': _videoMimeType, + if (recorderOptions.audioBitrate != null) + 'audioBitsPerSecond': recorderOptions.audioBitrate!, + if (recorderOptions.videoBitrate != null) + 'videoBitsPerSecond': recorderOptions.videoBitrate!, }); _videoAvailableCompleter = Completer(); @@ -607,6 +615,7 @@ class Camera { /// any of the available video mime types. String get _videoMimeType { const List types = [ + 'video/webm;codecs="vp9,opus"', 'video/mp4', 'video/webm', ]; diff --git a/packages/camera/camera_web/lib/src/camera_service.dart b/packages/camera/camera_web/lib/src/camera_service.dart index 85270e418067..8ac40ff33ee6 100644 --- a/packages/camera/camera_web/lib/src/camera_service.dart +++ b/packages/camera/camera_web/lib/src/camera_service.dart @@ -101,14 +101,14 @@ class CameraService { throw CameraWebException( cameraId, CameraErrorCode.unknown, - 'An unknown error occured when fetching the camera stream.', + 'An unknown error occurred when fetching the camera stream.', ); } } catch (_) { throw CameraWebException( cameraId, CameraErrorCode.unknown, - 'An unknown error occured when fetching the camera stream.', + 'An unknown error occurred when fetching the camera stream.', ); } } @@ -224,7 +224,7 @@ class CameraService { // The method may not be supported on Firefox. // See: https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/getCapabilities#browser_compatibility if (!jsUtil.hasProperty(videoTrack, 'getCapabilities')) { - // Return null if the video track capabilites are not supported. + // Return null if the video track capabilities are not supported. return null; } @@ -307,6 +307,59 @@ class CameraService { return const Size(320, 240); } + static const int _kiloBits = 1000; + static const int _megaBits = _kiloBits * _kiloBits; + + /// Maps the given [resolutionPreset] to video bitrate. + int mapResolutionPresetToVideoBitrate(ResolutionPreset resolutionPreset) { + switch (resolutionPreset) { + case ResolutionPreset.max: + case ResolutionPreset.ultraHigh: + return 8 * _megaBits; + case ResolutionPreset.veryHigh: + return 4 * _megaBits; + case ResolutionPreset.high: + return 1 * _megaBits; + case ResolutionPreset.medium: + return 400 * _kiloBits; + case ResolutionPreset.low: + return 200 * _kiloBits; + } + + // The enum comes from a different package, which could get a new value at + // any time, so provide a fallback that ensures this won't break when used + // with a version that contains new values. This is deliberately outside + // the switch rather than a `default` so that the linter will flag the + // switch as needing an update. + // ignore: dead_code + return 1 * _megaBits; + } + + /// Maps the given [resolutionPreset] to audio bitrate. + int mapResolutionPresetToAudioBitrate(ResolutionPreset resolutionPreset) { + switch (resolutionPreset) { + case ResolutionPreset.max: + case ResolutionPreset.ultraHigh: + return 128 * _kiloBits; + case ResolutionPreset.veryHigh: + return 128 * _kiloBits; + case ResolutionPreset.high: + return 64 * _kiloBits; + case ResolutionPreset.medium: + return 48 * _kiloBits; + case ResolutionPreset.low: + return 32 * _kiloBits; + } + + // The enum comes from a different package, which could get a new value at + // any time, so provide a fallback that ensures this won't break when used + // with a version that contains new values. This is deliberately outside + // the switch rather than a `default` so that the linter will flag the + // switch as needing an update. + // ignore: dead_code + return 64 * _kiloBits; + } + /// Maps the given [deviceOrientation] to [OrientationType]. String mapDeviceOrientationToOrientationType( DeviceOrientation deviceOrientation, diff --git a/packages/camera/camera_web/lib/src/camera_web.dart b/packages/camera/camera_web/lib/src/camera_web.dart index be41e7b9a874..900d1c706fef 100644 --- a/packages/camera/camera_web/lib/src/camera_web.dart +++ b/packages/camera/camera_web/lib/src/camera_web.dart @@ -197,7 +197,19 @@ class CameraPlugin extends CameraPlatform { CameraDescription cameraDescription, ResolutionPreset? resolutionPreset, { bool enableAudio = false, - }) async { + }) => + createCameraWithSettings( + cameraDescription, + MediaSettings( + resolutionPreset: resolutionPreset, + enableAudio: enableAudio, + )); + + @override + Future createCameraWithSettings( + CameraDescription cameraDescription, + MediaSettings? mediaSettings, + ) async { try { if (!camerasMetadata.containsKey(cameraDescription)) { throw PlatformException( @@ -217,8 +229,8 @@ class CameraPlugin extends CameraPlatform { // Use the highest resolution possible // if the resolution preset is not specified. - final Size videoSize = _cameraService - .mapResolutionPresetToSize(resolutionPreset ?? ResolutionPreset.max); + final Size videoSize = _cameraService.mapResolutionPresetToSize( + mediaSettings?.resolutionPreset ?? ResolutionPreset.max); // Create a camera with the given audio and video constraints. // Sensor orientation is currently not supported. @@ -226,7 +238,7 @@ class CameraPlugin extends CameraPlatform { textureId: textureId, cameraService: _cameraService, options: CameraOptions( - audio: AudioConstraints(enabled: enableAudio), + audio: AudioConstraints(enabled: mediaSettings?.enableAudio ?? true), video: VideoConstraints( facingMode: cameraType != null ? FacingModeConstraint(cameraType) : null, @@ -239,6 +251,10 @@ class CameraPlugin extends CameraPlatform { deviceId: cameraMetadata.deviceId, ), ), + recorderOptions: ( + audioBitrate: mediaSettings?.audioBitrate, + videoBitrate: mediaSettings?.videoBitrate, + ), ); cameras[textureId] = camera; diff --git a/packages/camera/camera_web/pubspec.yaml b/packages/camera/camera_web/pubspec.yaml index fab01e5ecc59..2cd0f4f6b26a 100644 --- a/packages/camera/camera_web/pubspec.yaml +++ b/packages/camera/camera_web/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_web description: A Flutter plugin for getting information about and controlling the camera on Web. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_web issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.3.2+4 +version: 0.3.3 environment: sdk: ">=3.1.0 <4.0.0" @@ -17,7 +17,7 @@ flutter: fileName: camera_web.dart dependencies: - camera_platform_interface: ^2.3.1 + camera_platform_interface: ^2.6.0 flutter: sdk: flutter flutter_web_plugins: diff --git a/packages/camera/camera_windows/CHANGELOG.md b/packages/camera/camera_windows/CHANGELOG.md index a0e5c3fcc626..01e8358fd187 100644 --- a/packages/camera/camera_windows/CHANGELOG.md +++ b/packages/camera/camera_windows/CHANGELOG.md @@ -1,5 +1,6 @@ -## NEXT +## 0.2.2 +* Adds support to control video FPS and bitrate. See `CameraController.withSettings`. * Updates minimum supported SDK version to Flutter 3.13/Dart 3.1. ## 0.2.1+9 diff --git a/packages/camera/camera_windows/example/lib/main.dart b/packages/camera/camera_windows/example/lib/main.dart index 5d5deac5e85f..5523a13a7976 100644 --- a/packages/camera/camera_windows/example/lib/main.dart +++ b/packages/camera/camera_windows/example/lib/main.dart @@ -29,10 +29,15 @@ class _MyAppState extends State { bool _initialized = false; bool _recording = false; bool _recordingTimed = false; - bool _recordAudio = true; bool _previewPaused = false; Size? _previewSize; - ResolutionPreset _resolutionPreset = ResolutionPreset.veryHigh; + MediaSettings _mediaSettings = const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + enableAudio: true, + ); StreamSubscription? _errorStreamSubscription; StreamSubscription? _cameraClosingStreamSubscription; @@ -93,10 +98,9 @@ class _MyAppState extends State { final int cameraIndex = _cameraIndex % _cameras.length; final CameraDescription camera = _cameras[cameraIndex]; - cameraId = await CameraPlatform.instance.createCamera( + cameraId = await CameraPlatform.instance.createCameraWithSettings( camera, - _resolutionPreset, - enableAudio: _recordAudio, + _mediaSettings, ); unawaited(_errorStreamSubscription?.cancel()); @@ -276,7 +280,13 @@ class _MyAppState extends State { Future _onResolutionChange(ResolutionPreset newValue) async { setState(() { - _resolutionPreset = newValue; + _mediaSettings = MediaSettings( + resolutionPreset: newValue, + fps: _mediaSettings.fps, + videoBitrate: _mediaSettings.videoBitrate, + audioBitrate: _mediaSettings.audioBitrate, + enableAudio: _mediaSettings.enableAudio, + ); }); if (_initialized && _cameraId >= 0) { // Re-inits camera with new resolution preset. @@ -287,7 +297,13 @@ class _MyAppState extends State { Future _onAudioChange(bool recordAudio) async { setState(() { - _recordAudio = recordAudio; + _mediaSettings = MediaSettings( + resolutionPreset: _mediaSettings.resolutionPreset, + fps: _mediaSettings.fps, + videoBitrate: _mediaSettings.videoBitrate, + audioBitrate: _mediaSettings.audioBitrate, + enableAudio: recordAudio, + ); }); if (_initialized && _cameraId >= 0) { // Re-inits camera with new record audio setting. @@ -359,7 +375,7 @@ class _MyAppState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ DropdownButton( - value: _resolutionPreset, + value: _mediaSettings.resolutionPreset, onChanged: (ResolutionPreset? value) { if (value != null) { _onResolutionChange(value); @@ -370,7 +386,7 @@ class _MyAppState extends State { const SizedBox(width: 20), const Text('Audio:'), Switch( - value: _recordAudio, + value: _mediaSettings.enableAudio, onChanged: (bool state) => _onAudioChange(state)), const SizedBox(width: 20), ElevatedButton( diff --git a/packages/camera/camera_windows/example/pubspec.yaml b/packages/camera/camera_windows/example/pubspec.yaml index 9061be968f99..7a0853acce9d 100644 --- a/packages/camera/camera_windows/example/pubspec.yaml +++ b/packages/camera/camera_windows/example/pubspec.yaml @@ -7,7 +7,7 @@ environment: flutter: ">=3.13.0" dependencies: - camera_platform_interface: ^2.1.2 + camera_platform_interface: ^2.6.0 camera_windows: # When depending on this package from a real application you should use: # camera_windows: ^x.y.z diff --git a/packages/camera/camera_windows/lib/camera_windows.dart b/packages/camera/camera_windows/lib/camera_windows.dart index 920f53f77f3a..fc4ce3b09c81 100644 --- a/packages/camera/camera_windows/lib/camera_windows.dart +++ b/packages/camera/camera_windows/lib/camera_windows.dart @@ -68,14 +68,31 @@ class CameraWindows extends CameraPlatform { CameraDescription cameraDescription, ResolutionPreset? resolutionPreset, { bool enableAudio = false, - }) async { + }) => + createCameraWithSettings( + cameraDescription, + MediaSettings( + resolutionPreset: resolutionPreset, + enableAudio: enableAudio, + )); + + @override + Future createCameraWithSettings( + CameraDescription cameraDescription, + MediaSettings? mediaSettings, + ) async { try { // If resolutionPreset is not specified, plugin selects the highest resolution possible. final Map? reply = await pluginChannel .invokeMapMethod('create', { 'cameraName': cameraDescription.name, - 'resolutionPreset': _serializeResolutionPreset(resolutionPreset), - 'enableAudio': enableAudio, + 'resolutionPreset': null != mediaSettings?.resolutionPreset + ? _serializeResolutionPreset(mediaSettings!.resolutionPreset) + : null, + 'fps': mediaSettings?.fps, + 'videoBitrate': mediaSettings?.videoBitrate, + 'audioBitrate': mediaSettings?.audioBitrate, + 'enableAudio': mediaSettings?.enableAudio ?? true, }); if (reply == null) { diff --git a/packages/camera/camera_windows/pubspec.yaml b/packages/camera/camera_windows/pubspec.yaml index 1dd1413b5531..de2f695c7541 100644 --- a/packages/camera/camera_windows/pubspec.yaml +++ b/packages/camera/camera_windows/pubspec.yaml @@ -2,7 +2,7 @@ name: camera_windows description: A Flutter plugin for getting information about and controlling the camera on Windows. repository: https://github.com/flutter/packages/tree/main/packages/camera/camera_windows issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22 -version: 0.2.1+9 +version: 0.2.2 environment: sdk: ^3.1.0 @@ -17,7 +17,7 @@ flutter: dartPluginClass: CameraWindows dependencies: - camera_platform_interface: ^2.3.1 + camera_platform_interface: ^2.6.0 cross_file: ^0.3.1 flutter: sdk: flutter diff --git a/packages/camera/camera_windows/test/camera_windows_test.dart b/packages/camera/camera_windows/test/camera_windows_test.dart index 8d7b5d3d7185..228cae2c952b 100644 --- a/packages/camera/camera_windows/test/camera_windows_test.dart +++ b/packages/camera/camera_windows/test/camera_windows_test.dart @@ -8,6 +8,7 @@ import 'package:camera_windows/camera_windows.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; + import './utils/method_channel_mock.dart'; void main() { @@ -34,12 +35,17 @@ void main() { final CameraWindows plugin = CameraWindows(); // Act - final int cameraId = await plugin.createCamera( + final int cameraId = await plugin.createCameraWithSettings( const CameraDescription( name: 'Test', lensDirection: CameraLensDirection.front, sensorOrientation: 0), - ResolutionPreset.high, + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + ), ); // Assert @@ -48,7 +54,10 @@ void main() { 'create', arguments: { 'cameraName': 'Test', - 'resolutionPreset': 'high', + 'resolutionPreset': 'low', + 'fps': 15, + 'videoBitrate': 200000, + 'audioBitrate': 32000, 'enableAudio': false }, ), @@ -72,13 +81,19 @@ void main() { // Act expect( - () => plugin.createCamera( + () => plugin.createCameraWithSettings( const CameraDescription( name: 'Test', lensDirection: CameraLensDirection.back, sensorOrientation: 0, ), - ResolutionPreset.high, + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + enableAudio: true, + ), ), throwsA( isA() @@ -137,13 +152,19 @@ void main() { }, }); final CameraWindows plugin = CameraWindows(); - final int cameraId = await plugin.createCamera( + final int cameraId = await plugin.createCameraWithSettings( const CameraDescription( name: 'Test', lensDirection: CameraLensDirection.back, sensorOrientation: 0, ), - ResolutionPreset.high, + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + enableAudio: true, + ), ); // Act @@ -174,13 +195,19 @@ void main() { }); final CameraWindows plugin = CameraWindows(); - final int cameraId = await plugin.createCamera( + final int cameraId = await plugin.createCameraWithSettings( const CameraDescription( name: 'Test', lensDirection: CameraLensDirection.back, sensorOrientation: 0, ), - ResolutionPreset.high, + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + enableAudio: true, + ), ); await plugin.initializeCamera(cameraId); @@ -216,13 +243,19 @@ void main() { ); plugin = CameraWindows(); - cameraId = await plugin.createCamera( + cameraId = await plugin.createCameraWithSettings( const CameraDescription( name: 'Test', lensDirection: CameraLensDirection.back, sensorOrientation: 0, ), - ResolutionPreset.high, + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + enableAudio: true, + ), ); await plugin.initializeCamera(cameraId); }); @@ -295,13 +328,19 @@ void main() { }, ); plugin = CameraWindows(); - cameraId = await plugin.createCamera( + cameraId = await plugin.createCameraWithSettings( const CameraDescription( name: 'Test', lensDirection: CameraLensDirection.back, sensorOrientation: 0, ), - ResolutionPreset.high, + const MediaSettings( + resolutionPreset: ResolutionPreset.low, + fps: 15, + videoBitrate: 200000, + audioBitrate: 32000, + enableAudio: true, + ), ); await plugin.initializeCamera(cameraId); }); diff --git a/packages/camera/camera_windows/windows/camera.cpp b/packages/camera/camera_windows/windows/camera.cpp index 6a0944747908..94a0ed89ac46 100644 --- a/packages/camera/camera_windows/windows/camera.cpp +++ b/packages/camera/camera_windows/windows/camera.cpp @@ -49,25 +49,25 @@ CameraImpl::~CameraImpl() { bool CameraImpl::InitCamera(flutter::TextureRegistrar* texture_registrar, flutter::BinaryMessenger* messenger, - bool record_audio, - ResolutionPreset resolution_preset) { + ResolutionPreset resolution_preset, + const RecordSettings& record_settings) { auto capture_controller_factory = std::make_unique(); return InitCamera(std::move(capture_controller_factory), texture_registrar, - messenger, record_audio, resolution_preset); + messenger, resolution_preset, record_settings); } bool CameraImpl::InitCamera( std::unique_ptr capture_controller_factory, flutter::TextureRegistrar* texture_registrar, - flutter::BinaryMessenger* messenger, bool record_audio, - ResolutionPreset resolution_preset) { + flutter::BinaryMessenger* messenger, ResolutionPreset resolution_preset, + const RecordSettings& record_settings) { assert(!device_id_.empty()); messenger_ = messenger; capture_controller_ = capture_controller_factory->CreateCaptureController(this); return capture_controller_->InitCaptureDevice( - texture_registrar, device_id_, record_audio, resolution_preset); + texture_registrar, device_id_, resolution_preset, record_settings); } bool CameraImpl::AddPendingResult( diff --git a/packages/camera/camera_windows/windows/camera.h b/packages/camera/camera_windows/windows/camera.h index 8508da1924d0..50ec6a75e74b 100644 --- a/packages/camera/camera_windows/windows/camera.h +++ b/packages/camera/camera_windows/windows/camera.h @@ -9,6 +9,7 @@ #include #include +#include #include "capture_controller.h" @@ -36,7 +37,7 @@ enum class PendingResultType { // to capture video or photo from the camera. class Camera : public CaptureControllerListener { public: - explicit Camera(const std::string& device_id) {} + explicit Camera([[maybe_unused]] const std::string& device_id) {} virtual ~Camera() = default; // Disallow copy and move. @@ -67,8 +68,8 @@ class Camera : public CaptureControllerListener { // Returns false if initialization fails. virtual bool InitCamera(flutter::TextureRegistrar* texture_registrar, flutter::BinaryMessenger* messenger, - bool record_audio, - ResolutionPreset resolution_preset) = 0; + ResolutionPreset resolution_preset, + const RecordSettings& record_settings) = 0; }; // Concrete implementation of the |Camera| interface. @@ -127,8 +128,9 @@ class CameraImpl : public Camera { return capture_controller_.get(); } bool InitCamera(flutter::TextureRegistrar* texture_registrar, - flutter::BinaryMessenger* messenger, bool record_audio, - ResolutionPreset resolution_preset) override; + flutter::BinaryMessenger* messenger, + ResolutionPreset resolution_preset, + const RecordSettings& record_settings) override; // Initializes the camera and its associated capture controller. // @@ -139,8 +141,8 @@ class CameraImpl : public Camera { bool InitCamera( std::unique_ptr capture_controller_factory, flutter::TextureRegistrar* texture_registrar, - flutter::BinaryMessenger* messenger, bool record_audio, - ResolutionPreset resolution_preset); + flutter::BinaryMessenger* messenger, ResolutionPreset resolution_preset, + const RecordSettings& record_settings); private: // Loops through all pending results and calls their error handler with given diff --git a/packages/camera/camera_windows/windows/camera_plugin.cpp b/packages/camera/camera_windows/windows/camera_plugin.cpp index 5503d17e702b..c99565dd49c8 100644 --- a/packages/camera/camera_windows/windows/camera_plugin.cpp +++ b/packages/camera/camera_windows/windows/camera_plugin.cpp @@ -44,6 +44,9 @@ constexpr char kDisposeMethod[] = "dispose"; constexpr char kCameraNameKey[] = "cameraName"; constexpr char kResolutionPresetKey[] = "resolutionPreset"; +constexpr char kFpsKey[] = "fps"; +constexpr char kVideoBitrateKey[] = "videoBitrate"; +constexpr char kAudioBitrateKey[] = "audioBitrate"; constexpr char kEnableAudioKey[] = "enableAudio"; constexpr char kCameraIdKey[] = "cameraId"; @@ -398,8 +401,25 @@ void CameraPlugin::CreateMethodHandler( resolution_preset = ResolutionPreset::kAuto; } + const auto* fps_argument = std::get_if(ValueOrNull(args, kFpsKey)); + const auto* video_bitrate_argument = + std::get_if(ValueOrNull(args, kVideoBitrateKey)); + const auto* audio_bitrate_argument = + std::get_if(ValueOrNull(args, kAudioBitrateKey)); + + RecordSettings record_settings; + record_settings.record_audio = *record_audio; + record_settings.fps = + fps_argument ? std::make_optional(*fps_argument) : std::nullopt; + record_settings.video_bitrate = + video_bitrate_argument ? std::make_optional(*video_bitrate_argument) + : std::nullopt; + record_settings.audio_bitrate = + audio_bitrate_argument ? std::make_optional(*audio_bitrate_argument) + : std::nullopt; + bool initialized = camera->InitCamera(texture_registrar_, messenger_, - *record_audio, resolution_preset); + resolution_preset, record_settings); if (initialized) { cameras_.push_back(std::move(camera)); } diff --git a/packages/camera/camera_windows/windows/capture_controller.cpp b/packages/camera/camera_windows/windows/capture_controller.cpp index 384c86ac109b..0c06f69a4a4a 100644 --- a/packages/camera/camera_windows/windows/capture_controller.cpp +++ b/packages/camera/camera_windows/windows/capture_controller.cpp @@ -217,7 +217,7 @@ HRESULT CaptureControllerImpl::CreateCaptureEngine() { } // Creates audio source only if not already initialized by test framework - if (record_audio_ && !audio_source_) { + if (media_settings_.record_audio && !audio_source_) { hr = CreateDefaultAudioCaptureSource(); if (FAILED(hr)) { return hr; @@ -241,7 +241,7 @@ HRESULT CaptureControllerImpl::CreateCaptureEngine() { } hr = attributes->SetUINT32(MF_CAPTURE_ENGINE_USE_VIDEO_DEVICE_ONLY, - !record_audio_); + !media_settings_.record_audio); if (FAILED(hr)) { return hr; } @@ -301,7 +301,7 @@ void CaptureControllerImpl::ResetCaptureController() { bool CaptureControllerImpl::InitCaptureDevice( flutter::TextureRegistrar* texture_registrar, const std::string& device_id, - bool record_audio, ResolutionPreset resolution_preset) { + ResolutionPreset resolution_preset, const RecordSettings& record_settings) { assert(capture_controller_listener_); if (IsInitialized()) { @@ -316,7 +316,7 @@ bool CaptureControllerImpl::InitCaptureDevice( capture_engine_state_ = CaptureEngineState::kInitializing; resolution_preset_ = resolution_preset; - record_audio_ = record_audio; + media_settings_ = record_settings; texture_registrar_ = texture_registrar; video_device_id_ = device_id; @@ -518,7 +518,7 @@ void CaptureControllerImpl::StartRecord(const std::string& file_path, } if (!record_handler_) { - record_handler_ = std::make_unique(record_audio_); + record_handler_ = std::make_unique(media_settings_); } else if (!record_handler_->CanStart()) { return OnRecordStarted( CameraResult::kError, diff --git a/packages/camera/camera_windows/windows/capture_controller.h b/packages/camera/camera_windows/windows/capture_controller.h index 9536be70c50a..c6807002e589 100644 --- a/packages/camera/camera_windows/windows/capture_controller.h +++ b/packages/camera/camera_windows/windows/capture_controller.h @@ -15,6 +15,7 @@ #include #include +#include #include #include "capture_controller_listener.h" @@ -87,8 +88,8 @@ class CaptureController { // resolution_preset: Maximum capture resolution height. virtual bool InitCaptureDevice(TextureRegistrar* texture_registrar, const std::string& device_id, - bool record_audio, - ResolutionPreset resolution_preset) = 0; + ResolutionPreset resolution_preset, + const RecordSettings& record_settings) = 0; // Returns preview frame width virtual uint32_t GetPreviewWidth() const = 0; @@ -136,8 +137,9 @@ class CaptureControllerImpl : public CaptureController, // CaptureController bool InitCaptureDevice(TextureRegistrar* texture_registrar, - const std::string& device_id, bool record_audio, - ResolutionPreset resolution_preset) override; + const std::string& device_id, + ResolutionPreset resolution_preset, + const RecordSettings& record_settings) override; uint32_t GetPreviewWidth() const override { return preview_frame_width_; } uint32_t GetPreviewHeight() const override { return preview_frame_height_; } void StartPreview() override; @@ -232,7 +234,7 @@ class CaptureControllerImpl : public CaptureController, void OnRecordStopped(CameraResult result, const std::string& error); bool media_foundation_started_ = false; - bool record_audio_ = false; + uint32_t preview_frame_width_ = 0; uint32_t preview_frame_height_ = 0; UINT dx_device_reset_token_ = 0; @@ -246,6 +248,7 @@ class CaptureControllerImpl : public CaptureController, CaptureEngineState capture_engine_state_ = CaptureEngineState::kNotInitialized; ResolutionPreset resolution_preset_ = ResolutionPreset::kMedium; + RecordSettings media_settings_; ComPtr capture_engine_; ComPtr capture_engine_callback_handler_; ComPtr dxgi_device_manager_; diff --git a/packages/camera/camera_windows/windows/record_handler.cpp b/packages/camera/camera_windows/windows/record_handler.cpp index 0f7192533fdd..6e1cb78d94f1 100644 --- a/packages/camera/camera_windows/windows/record_handler.cpp +++ b/packages/camera/camera_windows/windows/record_handler.cpp @@ -114,6 +114,22 @@ HRESULT BuildMediaTypeForAudioCapture(IMFMediaType** audio_record_media_type) { return hr; } +// Helper function to set the frame rate on a video media type. +inline HRESULT SetFrameRate(IMFMediaType* pType, UINT32 numerator, + UINT32 denominator) { + return MFSetAttributeRatio(pType, MF_MT_FRAME_RATE, numerator, denominator); +} + +// Helper function to set the video bitrate on a video media type. +inline HRESULT SetVideoBitrate(IMFMediaType* pType, UINT32 bitrate) { + return pType->SetUINT32(MF_MT_AVG_BITRATE, bitrate); +} + +// Helper function to set the audio bitrate on an audio media type. +inline HRESULT SetAudioBitrate(IMFMediaType* pType, UINT32 bitrate) { + return pType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, bitrate); +} + HRESULT RecordHandler::InitRecordSink(IMFCaptureEngine* capture_engine, IMFMediaType* base_media_type) { assert(!file_path_.empty()); @@ -160,6 +176,17 @@ HRESULT RecordHandler::InitRecordSink(IMFCaptureEngine* capture_engine, return hr; } + if (media_settings_.fps.has_value()) { + assert(media_settings_.fps.value() > 0); + SetFrameRate(video_record_media_type.Get(), media_settings_.fps.value(), 1); + } + + if (media_settings_.video_bitrate.has_value()) { + assert(media_settings_.video_bitrate.value() > 0); + SetVideoBitrate(video_record_media_type.Get(), + media_settings_.video_bitrate.value()); + } + DWORD video_record_sink_stream_index; hr = record_sink_->AddStream( (DWORD)MF_CAPTURE_ENGINE_PREFERRED_SOURCE_STREAM_FOR_VIDEO_RECORD, @@ -168,13 +195,19 @@ HRESULT RecordHandler::InitRecordSink(IMFCaptureEngine* capture_engine, return hr; } - if (record_audio_) { + if (media_settings_.record_audio) { ComPtr audio_record_media_type; HRESULT audio_capture_hr = S_OK; audio_capture_hr = BuildMediaTypeForAudioCapture(audio_record_media_type.GetAddressOf()); if (SUCCEEDED(audio_capture_hr)) { + if (media_settings_.audio_bitrate.has_value()) { + assert(media_settings_.audio_bitrate.value() > 0); + SetAudioBitrate(audio_record_media_type.Get(), + media_settings_.audio_bitrate.value()); + } + DWORD audio_record_sink_stream_index; hr = record_sink_->AddStream( (DWORD)MF_CAPTURE_ENGINE_PREFERRED_SOURCE_STREAM_FOR_AUDIO, diff --git a/packages/camera/camera_windows/windows/record_handler.h b/packages/camera/camera_windows/windows/record_handler.h index 0c87bf9cec64..612e14a4eee9 100644 --- a/packages/camera/camera_windows/windows/record_handler.h +++ b/packages/camera/camera_windows/windows/record_handler.h @@ -9,7 +9,9 @@ #include #include +#include #include +#include #include namespace camera_windows { @@ -30,12 +32,48 @@ enum class RecordingType { // sequential order through the states. enum class RecordState { kNotStarted, kStarting, kRunning, kStopping }; +// Recording media settings. +// +// Used in [Camera::InitCamera]. +// Allows to tune recorded video parameters, such as resolution, frame rate, +// bitrate. If [fps], [video_bitrate] or [audio_bitrate] are passed, they must +// be greater than zero. +struct RecordSettings { + explicit RecordSettings( + const bool record_audio = false, + const std::optional& fps = std::nullopt, + const std::optional& video_bitrate = std::nullopt, + const std::optional& audio_bitrate = std::nullopt) + : record_audio(record_audio), + fps(fps), + video_bitrate(video_bitrate), + audio_bitrate(audio_bitrate) { + assert(!fps.has_value() || fps.value() > 0); + assert(!video_bitrate.has_value() || video_bitrate.value() > 0); + assert(!audio_bitrate.has_value() || audio_bitrate.value() > 0); + } + + // Controls audio presence in recorded video. + bool record_audio{false}; + + // Rate at which frames should be captured by the camera in frames per second. + std::optional fps{std::nullopt}; + + // The video encoding bit rate for recording. + std::optional video_bitrate{std::nullopt}; + + // The audio encoding bit rate for recording. + std::optional audio_bitrate{std::nullopt}; +}; + // Handler for video recording via the camera. // // Handles record sink initialization and manages the state of video recording. class RecordHandler { public: - RecordHandler(bool record_audio) : record_audio_(record_audio) {} + explicit RecordHandler(const RecordSettings& record_settings) + : media_settings_(record_settings) {} + virtual ~RecordHandler() = default; // Prevent copying. @@ -103,7 +141,7 @@ class RecordHandler { HRESULT InitRecordSink(IMFCaptureEngine* capture_engine, IMFMediaType* base_media_type); - bool record_audio_ = false; + const RecordSettings media_settings_; int64_t max_video_duration_ms_ = -1; int64_t recording_start_timestamp_us_ = -1; uint64_t recording_duration_us_ = 0; diff --git a/packages/camera/camera_windows/windows/test/camera_plugin_test.cpp b/packages/camera/camera_windows/windows/test/camera_plugin_test.cpp index 9cab069bbb97..7a9a3cdbde7b 100644 --- a/packages/camera/camera_windows/windows/test/camera_plugin_test.cpp +++ b/packages/camera/camera_windows/windows/test/camera_plugin_test.cpp @@ -52,8 +52,8 @@ void MockInitCamera(MockCamera* camera, bool success) { .Times(1) .WillOnce([camera, success](flutter::TextureRegistrar* texture_registrar, flutter::BinaryMessenger* messenger, - bool record_audio, - ResolutionPreset resolution_preset) { + ResolutionPreset resolution_preset, + const RecordSettings& record_settings) { assert(camera->pending_result_); if (success) { camera->pending_result_->Success(EncodableValue(1)); diff --git a/packages/camera/camera_windows/windows/test/camera_test.cpp b/packages/camera/camera_windows/windows/test/camera_test.cpp index 158a2c26c027..9ab81897254d 100644 --- a/packages/camera/camera_windows/windows/test/camera_test.cpp +++ b/packages/camera/camera_windows/windows/test/camera_test.cpp @@ -48,12 +48,17 @@ TEST(Camera, InitCameraCreatesCaptureController) { EXPECT_TRUE(camera->GetCaptureController() == nullptr); + RecordSettings record_settings(false); + record_settings.fps = 5; + record_settings.video_bitrate = 200000; + record_settings.audio_bitrate = 32000; + // Init camera with mock capture controller factory bool result = camera->InitCamera(std::move(capture_controller_factory), std::make_unique().get(), - std::make_unique().get(), false, - ResolutionPreset::kAuto); + std::make_unique().get(), + ResolutionPreset::kAuto, record_settings); EXPECT_TRUE(result); EXPECT_TRUE(camera->GetCaptureController() != nullptr); } @@ -79,12 +84,17 @@ TEST(Camera, InitCameraReportsFailure) { EXPECT_TRUE(camera->GetCaptureController() == nullptr); + RecordSettings record_settings(false); + record_settings.fps = 5; + record_settings.video_bitrate = 200000; + record_settings.audio_bitrate = 32000; + // Init camera with mock capture controller factory bool result = camera->InitCamera(std::move(capture_controller_factory), std::make_unique().get(), - std::make_unique().get(), false, - ResolutionPreset::kAuto); + std::make_unique().get(), + ResolutionPreset::kAuto, record_settings); EXPECT_FALSE(result); EXPECT_TRUE(camera->GetCaptureController() != nullptr); } @@ -487,10 +497,17 @@ TEST(Camera, OnVideoRecordSucceededInvokesCameraChannelEvent) { // and second is camera closing message. EXPECT_CALL(*binary_messenger, Send(Eq(camera_channel), _, _, _)).Times(2); + RecordSettings record_settings; + record_settings.record_audio = false; + record_settings.fps = 5; + record_settings.video_bitrate = 200000; + record_settings.audio_bitrate = 32000; + // Init camera with mock capture controller factory camera->InitCamera(std::move(capture_controller_factory), std::make_unique().get(), - binary_messenger.get(), false, ResolutionPreset::kAuto); + binary_messenger.get(), ResolutionPreset::kAuto, + record_settings); // Pass camera id for camera camera->OnCreateCaptureEngineSucceeded(camera_id); diff --git a/packages/camera/camera_windows/windows/test/capture_controller_test.cpp b/packages/camera/camera_windows/windows/test/capture_controller_test.cpp index 8d6632cbc3f0..145879bae97b 100644 --- a/packages/camera/camera_windows/windows/test/capture_controller_test.cpp +++ b/packages/camera/camera_windows/windows/test/capture_controller_test.cpp @@ -29,10 +29,11 @@ using ::testing::_; using ::testing::Eq; using ::testing::Return; -void MockInitCaptureController(CaptureControllerImpl* capture_controller, - MockTextureRegistrar* texture_registrar, - MockCaptureEngine* engine, MockCamera* camera, - int64_t mock_texture_id) { +void MockInitCaptureController( + CaptureControllerImpl* capture_controller, + MockTextureRegistrar* texture_registrar, MockCaptureEngine* engine, + MockCamera* camera, int64_t mock_texture_id, + const RecordSettings record_settings = RecordSettings(true)) { ComPtr video_source = new MockMediaSource(); ComPtr audio_source = new MockMediaSource(); @@ -60,7 +61,8 @@ void MockInitCaptureController(CaptureControllerImpl* capture_controller, EXPECT_CALL(*engine, Initialize).Times(1); bool result = capture_controller->InitCaptureDevice( - texture_registrar, MOCK_DEVICE_ID, true, ResolutionPreset::kAuto); + texture_registrar, MOCK_DEVICE_ID, ResolutionPreset::kAuto, + record_settings); EXPECT_TRUE(result); @@ -258,7 +260,8 @@ TEST(CaptureController, InitCaptureEngineCanOnlyBeCalledOnce) { EXPECT_CALL(*camera, OnCreateCaptureEngineFailed).Times(1); bool result = capture_controller->InitCaptureDevice( - texture_registrar.get(), MOCK_DEVICE_ID, true, ResolutionPreset::kAuto); + texture_registrar.get(), MOCK_DEVICE_ID, ResolutionPreset::kAuto, + RecordSettings(true)); EXPECT_FALSE(result); @@ -299,7 +302,8 @@ TEST(CaptureController, InitCaptureEngineReportsFailure) { .Times(1); bool result = capture_controller->InitCaptureDevice( - texture_registrar.get(), MOCK_DEVICE_ID, true, ResolutionPreset::kAuto); + texture_registrar.get(), MOCK_DEVICE_ID, ResolutionPreset::kAuto, + RecordSettings(true)); EXPECT_FALSE(result); EXPECT_FALSE(engine->initialized_); @@ -343,7 +347,8 @@ TEST(CaptureController, InitCaptureEngineReportsAccessDenied) { .Times(1); bool result = capture_controller->InitCaptureDevice( - texture_registrar.get(), MOCK_DEVICE_ID, true, ResolutionPreset::kAuto); + texture_registrar.get(), MOCK_DEVICE_ID, ResolutionPreset::kAuto, + RecordSettings(true)); EXPECT_FALSE(result); EXPECT_FALSE(engine->initialized_); @@ -698,6 +703,110 @@ TEST(CaptureController, StartRecordSuccess) { record_sink = nullptr; } +MATCHER_P2(WithFpsAndBitrate, fps, video_bitrate, "") { + UINT64 fps_value; + UINT32 video_bitrate_value; + return S_OK == arg->GetUINT64(MF_MT_FRAME_RATE, &fps_value) && + S_OK == arg->GetUINT32(MF_MT_AVG_BITRATE, &video_bitrate_value) && + fps_value == Pack2UINT32AsUINT64(static_cast(fps), 1) && + video_bitrate_value == static_cast(video_bitrate); +} + +MATCHER_P(WithAudioBitrate, audio_bitrate, "") { + UINT32 audio_bitrate_value; + return S_OK == arg->GetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, + &audio_bitrate_value) && + audio_bitrate_value == static_cast(audio_bitrate); +} + +TEST(CaptureController, StartRecordWithSettingsSuccess) { + ComPtr engine = new MockCaptureEngine(); + std::unique_ptr camera = + std::make_unique(MOCK_DEVICE_ID); + std::unique_ptr capture_controller = + std::make_unique(camera.get()); + std::unique_ptr texture_registrar = + std::make_unique(); + + int64_t mock_texture_id = 1234; + + const auto kFps = 5; + const auto kVideoBitrate = 200000; + const auto kAudioBitrate = 32000; + + RecordSettings record_settings; + record_settings.record_audio = true; + record_settings.fps = kFps; + record_settings.video_bitrate = kVideoBitrate; + record_settings.audio_bitrate = kAudioBitrate; + + // Initialize capture controller to be able to start preview + MockInitCaptureController(capture_controller.get(), texture_registrar.get(), + engine.Get(), camera.get(), mock_texture_id, + record_settings); + + ComPtr capture_source = new MockCaptureSource(); + + // Prepare fake media types + MockAvailableMediaTypes(engine.Get(), capture_source.Get(), 1, 1); + + // Start record + ComPtr record_sink = new MockCaptureRecordSink(); + + std::string mock_path_to_video = "mock_path_to_video"; + + EXPECT_CALL(*engine.Get(), StartRecord()).Times(1).WillOnce(Return(S_OK)); + + EXPECT_CALL(*engine.Get(), GetSink(MF_CAPTURE_ENGINE_SINK_TYPE_RECORD, _)) + .Times(1) + .WillOnce( + [src_sink = record_sink.Get()](MF_CAPTURE_ENGINE_SINK_TYPE sink_type, + IMFCaptureSink** target_sink) { + *target_sink = src_sink; + src_sink->AddRef(); + return S_OK; + }); + + EXPECT_CALL(*record_sink.Get(), RemoveAllStreams) + .Times(1) + .WillOnce(Return(S_OK)); + + EXPECT_CALL( + *record_sink.Get(), + AddStream( + Eq((DWORD)MF_CAPTURE_ENGINE_PREFERRED_SOURCE_STREAM_FOR_VIDEO_RECORD), + WithFpsAndBitrate(kFps, kVideoBitrate), _, _)) + .Times(1) + .WillRepeatedly(Return(S_OK)); + + EXPECT_CALL( + *record_sink.Get(), + AddStream(Eq((DWORD)MF_CAPTURE_ENGINE_PREFERRED_SOURCE_STREAM_FOR_AUDIO), + WithAudioBitrate(kAudioBitrate), _, _)) + .Times(1) + .WillRepeatedly(Return(S_OK)); + + EXPECT_CALL(*record_sink.Get(), SetOutputFileName) + .Times(1) + .WillOnce(Return(S_OK)); + + capture_controller->StartRecord(mock_path_to_video, -1); + + EXPECT_CALL(*camera, OnStartRecordSucceeded()).Times(1); + engine->CreateFakeEvent(S_OK, MF_CAPTURE_ENGINE_RECORD_STARTED); + + // Called by destructor + EXPECT_CALL(*(engine.Get()), StopRecord(true, false)) + .Times(1) + .WillOnce(Return(S_OK)); + + capture_controller = nullptr; + texture_registrar = nullptr; + engine = nullptr; + camera = nullptr; + record_sink = nullptr; +} + TEST(CaptureController, ReportsStartRecordError) { ComPtr engine = new MockCaptureEngine(); std::unique_ptr camera = diff --git a/packages/camera/camera_windows/windows/test/mocks.h b/packages/camera/camera_windows/windows/test/mocks.h index b6416eb7c710..1f71a0e4d5dc 100644 --- a/packages/camera/camera_windows/windows/test/mocks.h +++ b/packages/camera/camera_windows/windows/test/mocks.h @@ -205,8 +205,9 @@ class MockCamera : public Camera { MOCK_METHOD(bool, InitCamera, (flutter::TextureRegistrar * texture_registrar, - flutter::BinaryMessenger* messenger, bool record_audio, - ResolutionPreset resolution_preset), + flutter::BinaryMessenger* messenger, + ResolutionPreset resolution_preset, + const RecordSettings& record_settings), (override)); std::unique_ptr capture_controller_; @@ -235,8 +236,8 @@ class MockCaptureController : public CaptureController { MOCK_METHOD(bool, InitCaptureDevice, (flutter::TextureRegistrar * texture_registrar, - const std::string& device_id, bool record_audio, - ResolutionPreset resolution_preset), + const std::string& device_id, ResolutionPreset resolution_preset, + const RecordSettings& record_settings), (override)); MOCK_METHOD(uint32_t, GetPreviewWidth, (), (const override)); @@ -858,7 +859,20 @@ class FakeMediaType : public FakeIMFAttributesBase { *value = (int64_t)width_ << 32 | (int64_t)height_; return S_OK; } else if (key == MF_MT_FRAME_RATE) { - *value = (int64_t)frame_rate_ << 32 | 1; + *value = frame_rate_; + return S_OK; + } + return E_FAIL; + }; + + // IMFAttributes + HRESULT GetUINT32(REFGUID key, UINT32* value) override { + if (key == MF_MT_AVG_BITRATE && video_bitrate_.has_value()) { + *value = video_bitrate_.value(); + return S_OK; + } else if (key == MF_MT_AUDIO_AVG_BYTES_PER_SECOND && + audio_bitrate_.has_value()) { + *value = audio_bitrate_.value(); return S_OK; } return E_FAIL; @@ -876,11 +890,42 @@ class FakeMediaType : public FakeIMFAttributesBase { return E_FAIL; } + HRESULT SetUINT64(REFGUID key, UINT64 unValue) override { + if (key == MF_MT_FRAME_RATE) { + frame_rate_ = unValue; + return S_OK; + } + + return S_OK; + } + + HRESULT SetUINT32(REFGUID key, UINT32 unValue) override { + if (key == MF_MT_AVG_BITRATE) { + video_bitrate_ = unValue; + return S_OK; + } else if (key == MF_MT_AUDIO_AVG_BYTES_PER_SECOND) { + audio_bitrate_ = unValue; + return S_OK; + } + + return S_OK; + } + // IIMFAttributes HRESULT CopyAllItems(IMFAttributes* pDest) override { pDest->SetUINT64(MF_MT_FRAME_SIZE, (int64_t)width_ << 32 | (int64_t)height_); - pDest->SetUINT64(MF_MT_FRAME_RATE, (int64_t)frame_rate_ << 32 | 1); + pDest->SetUINT64(MF_MT_FRAME_RATE, frame_rate_); + + if (video_bitrate_.has_value()) { + pDest->SetUINT32(MF_MT_AVG_BITRATE, video_bitrate_.value()); + } + + if (audio_bitrate_.has_value()) { + pDest->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, + audio_bitrate_.value()); + } + pDest->SetGUID(MF_MT_MAJOR_TYPE, major_type_); pDest->SetGUID(MF_MT_SUBTYPE, sub_type_); return S_OK; @@ -946,7 +991,9 @@ class FakeMediaType : public FakeIMFAttributesBase { const GUID sub_type_; const int width_; const int height_; - const int frame_rate_ = 30; + UINT64 frame_rate_ = Pack2UINT32AsUINT64(30, 1); + std::optional video_bitrate_ = std::nullopt; + std::optional audio_bitrate_ = std::nullopt; }; class MockCaptureEngine : public IMFCaptureEngine {