// Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import AVFoundation import Flutter import XCTest @testable import camera_avfoundation private let testResolutionPreset = PlatformResolutionPreset.medium private let testFramesPerSecond: Int64 = 15 private let testVideoBitrate: Int64 = 200000 private let testAudioBitrate: Int64 = 32000 private final class TestMediaSettingsAVWrapper: FLTCamMediaSettingsAVWrapper { let lockExpectation: XCTestExpectation let unlockExpectation: XCTestExpectation let minFrameDurationExpectation: XCTestExpectation let maxFrameDurationExpectation: XCTestExpectation let beginConfigurationExpectation: XCTestExpectation let commitConfigurationExpectation: XCTestExpectation let audioSettingsExpectation: XCTestExpectation let videoSettingsExpectation: XCTestExpectation init(test: XCTestCase, expectAudio: Bool) { lockExpectation = test.expectation(description: "lockExpectation") unlockExpectation = test.expectation(description: "unlockExpectation") minFrameDurationExpectation = test.expectation(description: "minFrameDurationExpectation") maxFrameDurationExpectation = test.expectation(description: "maxFrameDurationExpectation") beginConfigurationExpectation = test.expectation(description: "beginConfigurationExpectation") commitConfigurationExpectation = test.expectation(description: "commitConfigurationExpectation") audioSettingsExpectation = test.expectation(description: "audioSettingsExpectation") audioSettingsExpectation.isInverted = !expectAudio videoSettingsExpectation = test.expectation(description: "videoSettingsExpectation") } override func lockDevice(_ captureDevice: CaptureDevice) throws { lockExpectation.fulfill() } override func unlockDevice(_ captureDevice: CaptureDevice) { unlockExpectation.fulfill() } override func beginConfiguration(for videoCaptureSession: CaptureSession) { beginConfigurationExpectation.fulfill() } override func commitConfiguration(for videoCaptureSession: CaptureSession) { commitConfigurationExpectation.fulfill() } override func setMinFrameDuration(_ duration: CMTime, on captureDevice: CaptureDevice) { // Camera allows to set frame rate with 1/10 precision. let expectedDuration = CMTimeMake(value: 10, timescale: Int32(testFramesPerSecond * 10)) if duration == expectedDuration { minFrameDurationExpectation.fulfill() } } override func setMaxFrameDuration(_ duration: CMTime, on captureDevice: CaptureDevice) { // Camera allows to set frame rate with 1/10 precision. let expectedDuration = CMTimeMake(value: 10, timescale: Int32(testFramesPerSecond * 10)) if duration == expectedDuration { maxFrameDurationExpectation.fulfill() } } override func assetWriterAudioInput(withOutputSettings outputSettings: [String: Any]?) -> AssetWriterInput { if let bitrate = outputSettings?[AVEncoderBitRateKey] as? Int, bitrate == Int(testAudioBitrate) { audioSettingsExpectation.fulfill() } return MockAssetWriterInput() } override func assetWriterVideoInput(withOutputSettings outputSettings: [String: Any]?) -> AssetWriterInput { if let compressionProperties = outputSettings?[AVVideoCompressionPropertiesKey] as? [String: Any], let bitrate = compressionProperties[AVVideoAverageBitRateKey] as? Int, let frameRate = compressionProperties[AVVideoExpectedSourceFrameRateKey] as? Double, bitrate == testVideoBitrate, frameRate == Double(testFramesPerSecond) { videoSettingsExpectation.fulfill() } // AVAssetWriterInput needs these three keys, otherwise it throws. var outputSettingsWithRequiredKeys = outputSettings ?? [:] outputSettingsWithRequiredKeys[AVVideoCodecKey] = AVVideoCodecType.h264 outputSettingsWithRequiredKeys[AVVideoWidthKey] = 1280 outputSettingsWithRequiredKeys[AVVideoHeightKey] = 720 return MockAssetWriterInput() } override func addInput(_ writerInput: AssetWriterInput, to writer: AssetWriter) { // No-op. } override func recommendedVideoSettingsForAssetWriter( withFileType fileType: AVFileType, for output: CaptureVideoDataOutput ) -> [String: Any]? { return [:] } } final class CameraSettingsTests: XCTestCase { func testSettings_shouldPassConfigurationToCameraDeviceAndWriter() { let enableAudio: Bool = true let settings = PlatformMediaSettings( resolutionPreset: testResolutionPreset, framesPerSecond: testFramesPerSecond, videoBitrate: testVideoBitrate, audioBitrate: testAudioBitrate, enableAudio: enableAudio ) let injectedWrapper = TestMediaSettingsAVWrapper(test: self, expectAudio: enableAudio) let configuration = CameraTestUtils.createTestCameraConfiguration() configuration.mediaSettingsWrapper = injectedWrapper configuration.mediaSettings = settings let camera = CameraTestUtils.createTestCamera(configuration) // Expect FPS configuration is passed to camera device. wait( for: [ injectedWrapper.lockExpectation, injectedWrapper.beginConfigurationExpectation, injectedWrapper.minFrameDurationExpectation, injectedWrapper.maxFrameDurationExpectation, injectedWrapper.commitConfigurationExpectation, injectedWrapper.unlockExpectation, ], timeout: 1, enforceOrder: true) camera.startVideoRecording( completion: { error in // No-op. }, messengerForStreaming: nil) wait( for: [ injectedWrapper.audioSettingsExpectation, injectedWrapper.videoSettingsExpectation, ], timeout: 1) } func testSettings_ShouldBeSupportedByMethodCall() { let mockDevice = MockCaptureDevice() let mockSession = MockCaptureSession() mockSession.canSetSessionPresetStub = { _ in true } let camera = CameraPlugin( registry: MockFlutterTextureRegistry(), messenger: MockFlutterBinaryMessenger(), globalAPI: MockGlobalEventApi(), deviceDiscoverer: MockCameraDeviceDiscoverer(), permissionManager: MockCameraPermissionManager(), deviceFactory: { _ in mockDevice }, captureSessionFactory: { mockSession }, captureDeviceInputFactory: MockCaptureDeviceInputFactory(), captureSessionQueue: DispatchQueue(label: "io.flutter.camera.captureSessionQueue") ) let expectation = self.expectation(description: "Result finished") let mediaSettings = PlatformMediaSettings( resolutionPreset: testResolutionPreset, framesPerSecond: testFramesPerSecond, videoBitrate: testVideoBitrate, audioBitrate: testAudioBitrate, enableAudio: false ) var resultValue: Int64? camera.createCameraOnSessionQueue( withName: "acamera", settings: mediaSettings ) { result in resultValue = self.assertSuccess(result) expectation.fulfill() } waitForExpectations(timeout: 30, handler: nil) XCTAssertNotNil(resultValue) } func testSettings_ShouldSelectFormatWhichSupports60FPS() { let settings = PlatformMediaSettings( resolutionPreset: testResolutionPreset, framesPerSecond: 60, videoBitrate: testVideoBitrate, audioBitrate: testAudioBitrate, enableAudio: false ) let configuration = CameraTestUtils.createTestCameraConfiguration() configuration.mediaSettings = settings let camera = CameraTestUtils.createTestCamera(configuration) let range = camera.captureDevice.flutterActiveFormat.flutterVideoSupportedFrameRateRanges[0] XCTAssertLessThanOrEqual(range.minFrameRate, 60) XCTAssertGreaterThanOrEqual(range.maxFrameRate, 60) } func test_setUpCaptureSessionForAudioIfNeeded_skipsAudioSession_whenAudioDisabled() { let settings = PlatformMediaSettings( resolutionPreset: testResolutionPreset, framesPerSecond: testFramesPerSecond, videoBitrate: testVideoBitrate, audioBitrate: testAudioBitrate, enableAudio: false ) let wrapper = TestMediaSettingsAVWrapper(test: self, expectAudio: false) let mockAudioSession = MockCaptureSession() let configuration = CameraTestUtils.createTestCameraConfiguration() configuration.mediaSettingsWrapper = wrapper configuration.mediaSettings = settings configuration.audioCaptureSession = mockAudioSession let camera = CameraTestUtils.createTestCamera(configuration) wait( for: [ wrapper.lockExpectation, wrapper.beginConfigurationExpectation, wrapper.minFrameDurationExpectation, wrapper.maxFrameDurationExpectation, wrapper.commitConfigurationExpectation, wrapper.unlockExpectation, ], timeout: 1, enforceOrder: true ) camera.startVideoRecording(completion: { _ in }, messengerForStreaming: nil) wait( for: [ wrapper.audioSettingsExpectation, wrapper.videoSettingsExpectation, ], timeout: 1 ) XCTAssertEqual( mockAudioSession.addedAudioOutputCount, 0, "Audio session should not receive AVCaptureAudioDataOutput when enableAudio is false" ) } func test_setUpCaptureSessionForAudioIfNeeded_addsAudioSession_whenAudioEnabled() { let settings = PlatformMediaSettings( resolutionPreset: testResolutionPreset, framesPerSecond: testFramesPerSecond, videoBitrate: testVideoBitrate, audioBitrate: testAudioBitrate, enableAudio: true ) let wrapper = TestMediaSettingsAVWrapper(test: self, expectAudio: true) let mockAudioSession = MockCaptureSession() let configuration = CameraTestUtils.createTestCameraConfiguration() configuration.mediaSettingsWrapper = wrapper configuration.mediaSettings = settings configuration.audioCaptureSession = mockAudioSession let camera = CameraTestUtils.createTestCamera(configuration) wait( for: [ wrapper.lockExpectation, wrapper.beginConfigurationExpectation, wrapper.minFrameDurationExpectation, wrapper.maxFrameDurationExpectation, wrapper.commitConfigurationExpectation, wrapper.unlockExpectation, ], timeout: 1, enforceOrder: true ) camera.startVideoRecording(completion: { _ in }, messengerForStreaming: nil) wait( for: [ wrapper.audioSettingsExpectation, wrapper.videoSettingsExpectation, ], timeout: 1 ) XCTAssertGreaterThan( mockAudioSession.addedAudioOutputCount, 0, "Audio session should receive AVCaptureAudioDataOutput when enableAudio is true" ) } func testResolutionPresetWithMax_mustIgnoreLossyFormatsAndSquares() { let videoSessionMock = MockCaptureSession() videoSessionMock.canSetSessionPresetStub = { _ in true } let lossyFormat = MockCaptureDeviceFormat( codecType: 1_651_798_066, // 'btp2' width: 4224, height: 3024 ) let squareFormat = MockCaptureDeviceFormat( codecType: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, width: 4032, height: 4032 ) let safe4KFormat = MockCaptureDeviceFormat( codecType: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange, width: 3840, height: 2160 ) let captureDeviceMock = MockCaptureDevice() captureDeviceMock.flutterFormats = [lossyFormat, squareFormat, safe4KFormat] var currentFormat: CaptureDeviceFormat = safe4KFormat captureDeviceMock.activeFormatStub = { currentFormat } captureDeviceMock.setActiveFormatStub = { newFormat in currentFormat = newFormat } let configuration = CameraTestUtils.createTestCameraConfiguration() configuration.videoCaptureDeviceFactory = { _ in captureDeviceMock } configuration.videoCaptureSession = videoSessionMock configuration.videoDimensionsConverter = { format in return CMVideoFormatDescriptionGetDimensions(format.formatDescription) } configuration.mediaSettings = CameraTestUtils.createDefaultMediaSettings( resolutionPreset: PlatformResolutionPreset.max ) let _ = CameraTestUtils.createTestCamera(configuration) let selectedFormat = captureDeviceMock.flutterActiveFormat let selectedDimensions = CMVideoFormatDescriptionGetDimensions(selectedFormat.formatDescription) XCTAssertEqual( selectedDimensions.width, 3840, "Camera should have ignored the lossy and square formats, safely falling back to 4K." ) XCTAssertEqual( selectedDimensions.height, 2160, "Camera should have ignored the lossy and square formats, safely falling back to 4K." ) } }