From 93f453a461723c13b82f65daf92db9e54adc2851 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Sat, 4 Jan 2025 07:49:03 +0100 Subject: [PATCH 01/15] Update environment actions (using @Entry) --- .../Public/EnvironmentValues/RecordVideoAction.swift | 9 +-------- .../Public/EnvironmentValues/RecordingSettings.swift | 9 +-------- .../Public/EnvironmentValues/TakePictureAction.swift | 11 +---------- 3 files changed, 3 insertions(+), 26 deletions(-) diff --git a/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift b/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift index ddd1cde..456f37d 100644 --- a/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift +++ b/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift @@ -26,13 +26,6 @@ public struct RecordVideoAction { } } -private enum RecordVideoEnvironmentKey: EnvironmentKey { - static var defaultValue: RecordVideoAction = .init() -} - extension EnvironmentValues { - public internal(set) var recordVideo: RecordVideoAction { - get { self[RecordVideoEnvironmentKey.self] } - set { self[RecordVideoEnvironmentKey.self] = newValue } - } + @Entry public internal(set) var recordVideo: RecordVideoAction = .init() } diff --git a/Sources/Capture/Public/EnvironmentValues/RecordingSettings.swift b/Sources/Capture/Public/EnvironmentValues/RecordingSettings.swift index b275d9a..60fc555 100644 --- a/Sources/Capture/Public/EnvironmentValues/RecordingSettings.swift +++ b/Sources/Capture/Public/EnvironmentValues/RecordingSettings.swift @@ -24,15 +24,8 @@ struct RecordingSettings: Equatable { } } -enum RecordingSettingsEnvironmentKey: EnvironmentKey { - static var defaultValue: RecordingSettings? -} - extension EnvironmentValues { - internal var recordingSettings: RecordingSettings? { - get { self[RecordingSettingsEnvironmentKey.self] } - set { self[RecordingSettingsEnvironmentKey.self] = newValue } - } + @Entry internal var recordingSettings: RecordingSettings? public var recordingAudioSettings: AudioSettings { get { recordingSettings?.audio ?? .default } diff --git a/Sources/Capture/Public/EnvironmentValues/TakePictureAction.swift b/Sources/Capture/Public/EnvironmentValues/TakePictureAction.swift index 4253989..06413da 100644 --- a/Sources/Capture/Public/EnvironmentValues/TakePictureAction.swift +++ b/Sources/Capture/Public/EnvironmentValues/TakePictureAction.swift @@ -18,15 +18,6 @@ public struct TakePictureAction { } } -private enum TakePictureEnvironmentKey: EnvironmentKey { - static var defaultValue: TakePictureAction = .init() -} - extension EnvironmentValues { - - public internal(set) var takePicture: TakePictureAction { - get { self[TakePictureEnvironmentKey.self] } - set { self[TakePictureEnvironmentKey.self] = newValue } - } - + @Entry public internal(set) var takePicture: TakePictureAction = .init() } From 573102d3caf3a4075d6ff10c2dfe7ea444b267a7 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Sat, 4 Jan 2025 08:25:39 +0100 Subject: [PATCH 02/15] Remove reference to deviceId (breaking change) --- CaptureExample/CaptureExample/ContentView.swift | 4 ++-- Sources/Capture/Public/Camera.swift | 17 +++-------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/CaptureExample/CaptureExample/ContentView.swift b/CaptureExample/CaptureExample/ContentView.swift index 388981b..c993aec 100644 --- a/CaptureExample/CaptureExample/ContentView.swift +++ b/CaptureExample/CaptureExample/ContentView.swift @@ -87,10 +87,10 @@ struct ContentView: View { } @ViewBuilder var cameraDevicePicker: some View { - Picker(selection: $camera.deviceId) { + Picker(selection: $camera.captureDevice) { ForEach(camera.devices, id: \.uniqueID) { device in Text(device.localizedName) - .tag(device.uniqueID) + .tag(device) } } label: { EmptyView() } } diff --git a/Sources/Capture/Public/Camera.swift b/Sources/Capture/Public/Camera.swift index 00760fb..cbb858e 100644 --- a/Sources/Capture/Public/Camera.swift +++ b/Sources/Capture/Public/Camera.swift @@ -26,17 +26,6 @@ public final class Camera: NSObject, ObservableObject { private var isCaptureSessionConfigured = false - public private(set) var captureDevice: AVCaptureDevice? { - didSet { - if captureDevice != oldValue, let captureDevice { - Task { @MainActor in - deviceId = captureDevice.uniqueID - captureDeviceDidChange(captureDevice) - } - } - } - } - private var captureMovieFileOutput: AVCaptureMovieFileOutput? private var capturePhotoOutput: AVCapturePhotoOutput? private var captureVideoInput: AVCaptureDeviceInput? @@ -58,10 +47,10 @@ public final class Camera: NSObject, ObservableObject { @Published public private(set) var isRecording: Bool = false @Published public private(set) var isPreviewPaused: Bool = false @Published public private(set) var devices: [AVCaptureDevice] = [] - @Published public var deviceId: String = "" { + @Published public var captureDevice: AVCaptureDevice? { didSet { - if deviceId != captureDevice?.uniqueID { - captureDevice = devices.first(where: { $0.uniqueID == deviceId }) + if oldValue != captureDevice, let captureDevice { + captureDeviceDidChange(captureDevice) } } } From 01401e24f2e1a6413efbe7e175a187bd572deaf4 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Sat, 4 Jan 2025 08:35:59 +0100 Subject: [PATCH 03/15] Remove public attribute on internal class AVCaptureVideoFileOutput --- Sources/Capture/Internal/AVCaptureVideoFileOutput.swift | 6 +++--- Sources/Capture/{Internal => Public}/PlatformImage.swift | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename Sources/Capture/{Internal => Public}/PlatformImage.swift (100%) diff --git a/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift b/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift index 933b7d1..95245d0 100644 --- a/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift +++ b/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift @@ -49,7 +49,7 @@ final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { // MARK: - Recording - public private(set) var audioSettings = AudioSettings( + private(set) var audioSettings = AudioSettings( formatID: kAudioFormatMPEG4AAC, sampleRate: 44100, numberOfChannels: 2, @@ -58,14 +58,14 @@ final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { encoderBitRate: .bitRate(128000) ) - public private(set) var videoSettings = VideoSettings( + private(set) var videoSettings = VideoSettings( codec: .h264, width: 0, height: 0, scalingMode: .resizeAspectFill ) - public func configureOutput(audioSettings: AudioSettings? = nil, videoSettings: VideoSettings) { + func configureOutput(audioSettings: AudioSettings? = nil, videoSettings: VideoSettings) { if let audioSettings { self.audioSettings = audioSettings } diff --git a/Sources/Capture/Internal/PlatformImage.swift b/Sources/Capture/Public/PlatformImage.swift similarity index 100% rename from Sources/Capture/Internal/PlatformImage.swift rename to Sources/Capture/Public/PlatformImage.swift From 12bc7a2913b7e338018ed2e30c26a19ed2861168 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Sat, 4 Jan 2025 08:02:15 +0100 Subject: [PATCH 04/15] Set Sendable types --- Sources/Capture/Public/CameraView.swift | 4 ++-- .../EnvironmentValues/RecordVideoAction.swift | 6 +++--- .../EnvironmentValues/TakePictureAction.swift | 4 ++-- .../Public/Settings/AudioSettings.swift | 10 +++++----- .../Public/Settings/VideoSettings.swift | 20 +++++++++---------- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Sources/Capture/Public/CameraView.swift b/Sources/Capture/Public/CameraView.swift index 1b96cf8..8ac5bc4 100644 --- a/Sources/Capture/Public/CameraView.swift +++ b/Sources/Capture/Public/CameraView.swift @@ -8,8 +8,8 @@ import SwiftUI import AVKit -public struct CameraViewOptions { - public private(set) static var `default` = CameraViewOptions() +public struct CameraViewOptions: Sendable { + public static let `default` = CameraViewOptions() var automaticallyRequestAuthorization: Bool = true var isTakePictureFeedbackEnabled: Bool = true } diff --git a/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift b/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift index 456f37d..ab8f6c7 100644 --- a/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift +++ b/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift @@ -7,13 +7,13 @@ import SwiftUI -public struct RecordVideoAction { +public struct RecordVideoAction: Sendable { - var start: () -> Void = { + var start: @Sendable () -> Void = { assertionFailure("@Environment(\\.recordVideo) must be accessed from a camera overlay view") } - var stop: () async -> Void = { + var stop: @Sendable () async -> Void = { assertionFailure("@Environment(\\.recordVideo) must be accessed from a camera overlay view") } diff --git a/Sources/Capture/Public/EnvironmentValues/TakePictureAction.swift b/Sources/Capture/Public/EnvironmentValues/TakePictureAction.swift index 06413da..9bab15e 100644 --- a/Sources/Capture/Public/EnvironmentValues/TakePictureAction.swift +++ b/Sources/Capture/Public/EnvironmentValues/TakePictureAction.swift @@ -7,9 +7,9 @@ import SwiftUI -public struct TakePictureAction { +public struct TakePictureAction: Sendable { - var handler: () async -> Void = { + var handler: @Sendable () async -> Void = { assertionFailure("@Environment(\\.takePicture) must be accessed from a camera overlay view") } diff --git a/Sources/Capture/Public/Settings/AudioSettings.swift b/Sources/Capture/Public/Settings/AudioSettings.swift index d5e29c9..1b39ddc 100644 --- a/Sources/Capture/Public/Settings/AudioSettings.swift +++ b/Sources/Capture/Public/Settings/AudioSettings.swift @@ -18,8 +18,8 @@ extension AudioSettings { ) } -public struct AudioSettings: Equatable { - +public struct AudioSettings: Equatable, Sendable { + /// value is an integer (format ID) from CoreAudioTypes.h public var formatID: AudioFormatID @@ -118,13 +118,13 @@ public struct AudioSettings: Equatable { // MARK: - Property Values - public enum EncoderBitRate: Equatable { + public enum EncoderBitRate: Equatable, Sendable { case bitRate(Int) case bitRatePerChannel(Int) } /// values for AVEncoderBitRateStrategyKey - public enum AudioBitRateStrategy: String { + public enum AudioBitRateStrategy: String, Sendable { case constant case longTermAverage case variableConstrained @@ -132,7 +132,7 @@ public struct AudioSettings: Equatable { } /// values for AVSampleRateConverterAlgorithmKey - public enum SampleRateConverterAlgorithm: String { + public enum SampleRateConverterAlgorithm: String, Sendable { case normal case mastering case minimumPhase diff --git a/Sources/Capture/Public/Settings/VideoSettings.swift b/Sources/Capture/Public/Settings/VideoSettings.swift index 2e77dde..909b371 100644 --- a/Sources/Capture/Public/Settings/VideoSettings.swift +++ b/Sources/Capture/Public/Settings/VideoSettings.swift @@ -16,7 +16,7 @@ extension VideoSettings { ) } -public struct VideoSettings: Equatable { +public struct VideoSettings: Equatable, Sendable { /// A video codec type (for instance public var codec: AVVideoCodecType @@ -80,7 +80,7 @@ extension VideoSettings { // MARK: - - public struct PixelAspectRatio: Equatable { + public struct PixelAspectRatio: Equatable, Sendable { /// public var horizontalSpacing: Int @@ -90,7 +90,7 @@ extension VideoSettings { // MARK: - - public struct CleanAperture: Equatable { + public struct CleanAperture: Equatable, Sendable { /// public var width: Int /// @@ -103,7 +103,7 @@ extension VideoSettings { // MARK: - - public enum ScalingMode: String { + public enum ScalingMode: String, Sendable { // Crop to remove edge processing region; preserve aspect ratio of cropped source by reducing specified width or height if necessary. // Will not scale a small source up to larger dimensions. case fit @@ -118,7 +118,7 @@ extension VideoSettings { // MARK: - - public struct ColorProperties: Equatable { + public struct ColorProperties: Equatable, Sendable { /// public var colorPrimaries: ColorPrimaries @@ -128,21 +128,21 @@ extension VideoSettings { /// public var yCbCrMatrix: YCbCrMatrix - public enum ColorPrimaries: String { + public enum ColorPrimaries: String, Sendable { case ITU_R_709_2 case SMPTE_C case P3_D65 case ITU_R_2020 } - public enum TransferFunction: String { + public enum TransferFunction: String, Sendable { case linear case ITU_R_709_2 case ITU_R_2100_HLG case SMPTE_ST_2084_PQ } - public enum YCbCrMatrix: String { + public enum YCbCrMatrix: String, Sendable { case ITU_R_709_2 case ITU_R_601_4 case ITU_R_2020 @@ -151,7 +151,7 @@ extension VideoSettings { // MARK: - - public struct CompressionProperties: Equatable { + public struct CompressionProperties: Equatable, Sendable { // NSNumber (bits per second, H.264 only) public var averageBitRate: String? @@ -172,7 +172,7 @@ extension VideoSettings { public var allowFrameReorderingKey: Bool? } - public enum ProfileLevel: String { + public enum ProfileLevel: String, Sendable { case H264Baseline30 case H264Baseline31 case H264Baseline41 From 2b3de1782cb714722577de513c21379763ae3bf9 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Sat, 4 Jan 2025 08:29:34 +0100 Subject: [PATCH 05/15] Set @preconcurrency import on AVFoundation --- CaptureExample/CaptureExample/ContentView.swift | 4 ++-- .../Extensions/AVFoundation/AVCaptureDeviceInput.swift | 2 +- .../Extensions/AVFoundation/AVCapturePhotoOutput.swift | 2 +- .../AVFoundation/AVCaptureVideoOrientation+UIDevice.swift | 2 +- .../Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift | 2 +- .../Capture/Extensions/UIKit/UIImage+AVVideoOrientation.swift | 2 +- Sources/Capture/Internal/AVCaptureVideoFileOutput.swift | 2 +- .../Internal/AVCaptureVideoFileOutputRecordingDelegate.swift | 2 +- Sources/Capture/Internal/AVFileType.swift | 2 +- Sources/Capture/Internal/CaptureVideoPreview.swift | 1 + Sources/Capture/Public/Camera+Extensions.swift | 2 ++ Sources/Capture/Public/Camera.swift | 2 +- Sources/Capture/Public/CameraPosition.swift | 2 +- Sources/Capture/Public/Settings/AudioSettings.swift | 2 +- Sources/Capture/Public/Settings/VideoSettings.swift | 2 +- 15 files changed, 17 insertions(+), 14 deletions(-) diff --git a/CaptureExample/CaptureExample/ContentView.swift b/CaptureExample/CaptureExample/ContentView.swift index c993aec..246765f 100644 --- a/CaptureExample/CaptureExample/ContentView.swift +++ b/CaptureExample/CaptureExample/ContentView.swift @@ -130,13 +130,13 @@ struct ContentView: View { .preferredColorScheme(.dark) } -extension URL: Identifiable { +extension URL: @retroactive Identifiable { public var id: String { absoluteString } } -extension PlatformImage: Identifiable { +extension PlatformImage: @retroactive Identifiable { public var id: ObjectIdentifier { ObjectIdentifier(self) } diff --git a/Sources/Capture/Extensions/AVFoundation/AVCaptureDeviceInput.swift b/Sources/Capture/Extensions/AVFoundation/AVCaptureDeviceInput.swift index bd47d63..07d7307 100644 --- a/Sources/Capture/Extensions/AVFoundation/AVCaptureDeviceInput.swift +++ b/Sources/Capture/Extensions/AVFoundation/AVCaptureDeviceInput.swift @@ -5,7 +5,7 @@ // Created by Quentin Fasquel on 17/12/2023. // -import AVFoundation +@preconcurrency import AVFoundation import OSLog extension AVCaptureDeviceInput { diff --git a/Sources/Capture/Extensions/AVFoundation/AVCapturePhotoOutput.swift b/Sources/Capture/Extensions/AVFoundation/AVCapturePhotoOutput.swift index e70d355..f35b469 100644 --- a/Sources/Capture/Extensions/AVFoundation/AVCapturePhotoOutput.swift +++ b/Sources/Capture/Extensions/AVFoundation/AVCapturePhotoOutput.swift @@ -5,7 +5,7 @@ // Created by Quentin Fasquel on 16/12/2023. // -import AVFoundation +@preconcurrency import AVFoundation extension AVCapturePhotoOutput { diff --git a/Sources/Capture/Extensions/AVFoundation/AVCaptureVideoOrientation+UIDevice.swift b/Sources/Capture/Extensions/AVFoundation/AVCaptureVideoOrientation+UIDevice.swift index 8e24ce0..68af2ef 100644 --- a/Sources/Capture/Extensions/AVFoundation/AVCaptureVideoOrientation+UIDevice.swift +++ b/Sources/Capture/Extensions/AVFoundation/AVCaptureVideoOrientation+UIDevice.swift @@ -6,7 +6,7 @@ // #if canImport(UIKit) -import AVFoundation +@preconcurrency import AVFoundation import UIKit.UIDevice extension AVCaptureVideoOrientation { diff --git a/Sources/Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift b/Sources/Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift index 50ac856..0bff5a1 100644 --- a/Sources/Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift +++ b/Sources/Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift @@ -6,7 +6,7 @@ // #if os(macOS) -import AVFoundation +@preconcurrency import AVFoundation import AppKit extension NSImage { diff --git a/Sources/Capture/Extensions/UIKit/UIImage+AVVideoOrientation.swift b/Sources/Capture/Extensions/UIKit/UIImage+AVVideoOrientation.swift index ba78850..ddb4881 100644 --- a/Sources/Capture/Extensions/UIKit/UIImage+AVVideoOrientation.swift +++ b/Sources/Capture/Extensions/UIKit/UIImage+AVVideoOrientation.swift @@ -6,7 +6,7 @@ // #if canImport(UIKit) -import AVFoundation +@preconcurrency import AVFoundation import UIKit extension UIImage.Orientation { diff --git a/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift b/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift index 95245d0..131fa25 100644 --- a/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift +++ b/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift @@ -5,7 +5,7 @@ // Created by Quentin Fasquel on 17/12/2023. // -import AVFoundation +@preconcurrency import AVFoundation protocol CaptureRecording: NSObject { func stopRecording() diff --git a/Sources/Capture/Internal/AVCaptureVideoFileOutputRecordingDelegate.swift b/Sources/Capture/Internal/AVCaptureVideoFileOutputRecordingDelegate.swift index ee4ec47..3047a30 100644 --- a/Sources/Capture/Internal/AVCaptureVideoFileOutputRecordingDelegate.swift +++ b/Sources/Capture/Internal/AVCaptureVideoFileOutputRecordingDelegate.swift @@ -5,7 +5,7 @@ // Created by Quentin Fasquel on 17/12/2023. // -import Foundation +@preconcurrency import AVFoundation protocol AVCaptureVideoFileOutputRecordingDelegate: AnyObject { diff --git a/Sources/Capture/Internal/AVFileType.swift b/Sources/Capture/Internal/AVFileType.swift index a9f35b0..c3d01e6 100644 --- a/Sources/Capture/Internal/AVFileType.swift +++ b/Sources/Capture/Internal/AVFileType.swift @@ -5,7 +5,7 @@ // Created by Quentin Fasquel on 02/01/2024. // -import AVFoundation +@preconcurrency import AVFoundation func fileType(for videoCodec: AVVideoCodecType) -> AVFileType? { switch videoCodec { diff --git a/Sources/Capture/Internal/CaptureVideoPreview.swift b/Sources/Capture/Internal/CaptureVideoPreview.swift index 7e0993e..b48ea38 100644 --- a/Sources/Capture/Internal/CaptureVideoPreview.swift +++ b/Sources/Capture/Internal/CaptureVideoPreview.swift @@ -5,6 +5,7 @@ // Created by Quentin Fasquel on 16/12/2023. // +@preconcurrency import AVFoundation import SwiftUI #if os(iOS) import UIKit diff --git a/Sources/Capture/Public/Camera+Extensions.swift b/Sources/Capture/Public/Camera+Extensions.swift index 020992e..22e28b6 100644 --- a/Sources/Capture/Public/Camera+Extensions.swift +++ b/Sources/Capture/Public/Camera+Extensions.swift @@ -5,6 +5,8 @@ // Created by Quentin Fasquel on 17/12/2023. // +import Foundation + extension Camera { func takePicture(outputSize: CGSize) async -> PlatformImage? { do { diff --git a/Sources/Capture/Public/Camera.swift b/Sources/Capture/Public/Camera.swift index cbb858e..11ed67a 100644 --- a/Sources/Capture/Public/Camera.swift +++ b/Sources/Capture/Public/Camera.swift @@ -5,7 +5,7 @@ // Created by Quentin Fasquel on 17/12/2023. // -@_exported import AVFoundation +@preconcurrency import AVFoundation import Foundation #if canImport(UIKit) import UIKit.UIDevice diff --git a/Sources/Capture/Public/CameraPosition.swift b/Sources/Capture/Public/CameraPosition.swift index 5df512f..dcb630b 100644 --- a/Sources/Capture/Public/CameraPosition.swift +++ b/Sources/Capture/Public/CameraPosition.swift @@ -5,7 +5,7 @@ // Created by Quentin Fasquel on 16/12/2023. // -import Foundation +@preconcurrency import AVFoundation public typealias CameraPosition = AVCaptureDevice.Position diff --git a/Sources/Capture/Public/Settings/AudioSettings.swift b/Sources/Capture/Public/Settings/AudioSettings.swift index 1b39ddc..f7087ba 100644 --- a/Sources/Capture/Public/Settings/AudioSettings.swift +++ b/Sources/Capture/Public/Settings/AudioSettings.swift @@ -6,7 +6,7 @@ // Created by Quentin Fasquel on 24/12/2023. // -import AVFoundation +@preconcurrency import AVFoundation extension AudioSettings { public static let `default` = AudioSettings( diff --git a/Sources/Capture/Public/Settings/VideoSettings.swift b/Sources/Capture/Public/Settings/VideoSettings.swift index 909b371..756afeb 100644 --- a/Sources/Capture/Public/Settings/VideoSettings.swift +++ b/Sources/Capture/Public/Settings/VideoSettings.swift @@ -5,7 +5,7 @@ // Created by Quentin Fasquel on 24/12/2023. // -import AVFoundation +@preconcurrency import AVFoundation extension VideoSettings { public static let `default` = VideoSettings( From b5ba7fe947bfe0cf1326b944660f2c83124d412a Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Sat, 4 Jan 2025 14:02:39 +0100 Subject: [PATCH 06/15] Refactor movie recording into MovieCapture --- .../Internal/AVCaptureVideoFileOutput.swift | 29 +--- Sources/Capture/Internal/AVFileType.swift | 4 + Sources/Capture/Internal/MovieCapture.swift | 155 ++++++++++++++++++ Sources/Capture/Public/Camera.swift | 122 ++------------ 4 files changed, 181 insertions(+), 129 deletions(-) create mode 100644 Sources/Capture/Internal/MovieCapture.swift diff --git a/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift b/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift index 131fa25..b602401 100644 --- a/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift +++ b/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift @@ -20,8 +20,8 @@ extension AVCaptureMovieFileOutput: CaptureRecording { final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { private let outputQueue = DispatchQueue(label: "\(bundleIdentifier).CaptureVideoFileOutput") - fileprivate let audioDataOutput = AVCaptureAudioDataOutput() - fileprivate let videoDataOutput = AVCaptureVideoDataOutput() + let audioDataOutput = AVCaptureAudioDataOutput() + let videoDataOutput = AVCaptureVideoDataOutput() private var assetWriter: AVAssetWriter? private var audioWriterInput: AVAssetWriterInput? @@ -37,7 +37,9 @@ final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { isRecording && !isStoppingRecording } - override init() { + init(audioSettings: AudioSettings = .default, videoSettings: VideoSettings = .default) { + self.audioSettings = audioSettings + self.videoSettings = videoSettings super.init() audioDataOutput.setSampleBufferDelegate(self, queue: outputQueue) videoDataOutput.setSampleBufferDelegate(self, queue: outputQueue) @@ -49,22 +51,9 @@ final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { // MARK: - Recording - private(set) var audioSettings = AudioSettings( - formatID: kAudioFormatMPEG4AAC, - sampleRate: 44100, - numberOfChannels: 2, - audioFileType: kAudioFileMPEG4Type, -// encoderAudioQuality: .high, - encoderBitRate: .bitRate(128000) - ) - - private(set) var videoSettings = VideoSettings( - codec: .h264, - width: 0, - height: 0, - scalingMode: .resizeAspectFill - ) - + private(set) var audioSettings: AudioSettings + private(set) var videoSettings: VideoSettings + func configureOutput(audioSettings: AudioSettings? = nil, videoSettings: VideoSettings) { if let audioSettings { self.audioSettings = audioSettings @@ -77,7 +66,7 @@ final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { return } - let outputFileType = fileType(for: videoSettings.codec) ?? .mov + let outputFileType = Capture.fileType(for: videoSettings.codec) ?? .mov assetWriter = try? AVAssetWriter(outputURL: outputURL, fileType: outputFileType) delegate = recordingDelegate diff --git a/Sources/Capture/Internal/AVFileType.swift b/Sources/Capture/Internal/AVFileType.swift index c3d01e6..4497a11 100644 --- a/Sources/Capture/Internal/AVFileType.swift +++ b/Sources/Capture/Internal/AVFileType.swift @@ -7,6 +7,10 @@ @preconcurrency import AVFoundation +extension AVFileType { + var utType: UTType { UTType(rawValue)! } +} + func fileType(for videoCodec: AVVideoCodecType) -> AVFileType? { switch videoCodec { case .proRes422: diff --git a/Sources/Capture/Internal/MovieCapture.swift b/Sources/Capture/Internal/MovieCapture.swift new file mode 100644 index 0000000..d9b61e3 --- /dev/null +++ b/Sources/Capture/Internal/MovieCapture.swift @@ -0,0 +1,155 @@ +// +// MovieOutput.swift +// Capture +// +// Created by Quentin Fasquel on 04/01/2025. +// + +@preconcurrency import AVFoundation + +final class MovieCapture: NSObject { + private(set) var movieOutput: MovieCaptureOutput? + private var recordingSettings: RecordingSettings? + private var recordingContinuation: CheckedContinuation? + + private var temporaryDirectory: URL { + FileManager.default.temporaryDirectory + } + + @discardableResult + func configureOutput(settings: RecordingSettings?) -> MovieCaptureOutput? { + if movieOutput == nil || recordingSettings != settings { + recordingSettings = settings + if let settings { + movieOutput = AVCaptureVideoFileOutput( + audioSettings: settings.audio, + videoSettings: settings.video + ) + } else { + movieOutput = AVCaptureMovieFileOutput() + } + return movieOutput + } + + return nil + } + + func startRecording() { + guard let movieOutput else { + assertionFailure("movieOutput is nil") + return + } + + let outputURL = temporaryDirectory.appendingPathComponent( + "\(Date.now)", conformingTo: movieOutput.fileType.utType) + + if let videoOutput = movieOutput as? AVCaptureVideoFileOutput { + videoOutput.startRecording(to: outputURL, recordingDelegate: self) + } else if let videoOutput = movieOutput as? AVCaptureMovieFileOutput { + videoOutput.startRecording(to: outputURL, recordingDelegate: self) + } + } + + func stopRecording() async throws -> URL { + guard let movieOutput else { + throw CameraError.missingVideoOutput + } + + return try await withCheckedThrowingContinuation { continuation in + recordingContinuation = continuation + movieOutput.stopRecording() + } + } + + private func didStartRecording() { + + } + + private func didStopRecording(outputFileURL: URL, error: Error?) { + if let error { + recordingContinuation?.resume(throwing: error) + } else { + recordingContinuation?.resume(returning: outputFileURL) + } + recordingContinuation = nil + } +} + +// MARK: - File Output Recording Delegates + +extension MovieCapture: AVCaptureFileOutputRecordingDelegate { + func fileOutput( + _ output: AVCaptureFileOutput, + didStartRecordingTo fileURL: URL, + from connections: [AVCaptureConnection] + ) { + didStartRecording() + } + + func fileOutput( + _ output: AVCaptureFileOutput, + didFinishRecordingTo outputFileURL: URL, + from connections: [AVCaptureConnection], + error: (any Error)? + ) { + didStopRecording(outputFileURL: outputFileURL, error: error) + } +} + +extension MovieCapture: AVCaptureVideoFileOutputRecordingDelegate { + func videoFileOutput( + _ output: AVCaptureVideoFileOutput, + didStartRecordingTo outputURL: URL, + from connections: [AVCaptureConnection] + ) { + didStartRecording() + } + + func videoFileOutput( + _ output: AVCaptureVideoFileOutput, + didFinishRecordingTo outputURL: URL, + from connections: [AVCaptureConnection], + error: (any Error)? + ) { + didStopRecording(outputFileURL: outputURL, error: error) + } +} + +// MARK: - + +protocol MovieCaptureOutput { + var captureOutputs: [AVCaptureOutput] { get } + var fileType: AVFileType { get } + + func stopRecording() +} + +extension AVCaptureMovieFileOutput: MovieCaptureOutput { + var captureOutputs: [AVCaptureOutput] { [self] } + var fileType: AVFileType { .mov } +} + +extension AVCaptureVideoFileOutput: MovieCaptureOutput { + var captureOutputs: [AVCaptureOutput] { [audioDataOutput, videoDataOutput] } + var fileType: AVFileType { Capture.fileType(for: videoSettings.codec) ?? .mov } +} + +extension AVCaptureSession { + func removeOutput(_ output: MovieCaptureOutput) { + output.captureOutputs.forEach { captureOutput in + removeOutput(captureOutput) + } + } + + func canAddOutput(_ output: MovieCaptureOutput) -> Bool { + output.captureOutputs.reduce(true) { partialResult, captureOutput in + partialResult && canAddOutput(captureOutput) + } + } + + func addOutput(_ output: MovieCaptureOutput) { + output.captureOutputs.forEach { captureOutput in + addOutput(captureOutput) + } + } +} diff --git a/Sources/Capture/Public/Camera.swift b/Sources/Capture/Public/Camera.swift index 11ed67a..b8939a7 100644 --- a/Sources/Capture/Public/Camera.swift +++ b/Sources/Capture/Public/Camera.swift @@ -26,12 +26,11 @@ public final class Camera: NSObject, ObservableObject { private var isCaptureSessionConfigured = false - private var captureMovieFileOutput: AVCaptureMovieFileOutput? private var capturePhotoOutput: AVCapturePhotoOutput? private var captureVideoInput: AVCaptureDeviceInput? - private var captureVideoFileOutput: AVCaptureVideoFileOutput? - private var didStopRecording: ((Result) -> Void)? + private let movieCapture = MovieCapture() + private var didTakePicture: ((Result) -> Void)? // MARK: - Internal Properties @@ -191,32 +190,16 @@ public final class Camera: NSObject, ObservableObject { return } + isRecording = true sessionQueue.async { [self] in - let temporaryDirectory = FileManager.default.temporaryDirectory - - if let videoOutput = captureVideoFileOutput { - let outputURL = temporaryDirectory.appending(component: "\(Date.now).mp4") - videoOutput.startRecording(to: outputURL, recordingDelegate: self) - } else if let videoOutput = captureMovieFileOutput { - let outputURL = temporaryDirectory.appending(component: "\(Date.now).mov") - videoOutput.startRecording(to: outputURL, recordingDelegate: self) - } + movieCapture.startRecording() } } public func stopRecording() async throws -> URL { - guard let videoOutput: CaptureRecording = captureVideoFileOutput ?? captureMovieFileOutput else { - throw CameraError.missingVideoOutput - } - - defer { didStopRecording = nil } - - return try await withCheckedThrowingContinuation { continuation in - didStopRecording = { continuation.resume(with: $0) } - sessionQueue.async { - videoOutput.stopRecording() - } - } + defer { isRecording = false } + // sessionQueue.async + return try await movieCapture.stopRecording() } public func takePicture() async throws -> AVCapturePhoto { @@ -453,39 +436,14 @@ public final class Camera: NSObject, ObservableObject { captureSession.beginConfiguration() defer { captureSession.commitConfiguration() } - if let recordingSettings, let captureVideoFileOutput { - captureVideoFileOutput.configureOutput( - audioSettings: recordingSettings.audio, - videoSettings: recordingSettings.video - ) - } else if let recordingSettings { - if let movieFileOutput = captureMovieFileOutput { - captureSession.removeOutput(movieFileOutput) - captureMovieFileOutput = nil - } - - let videoFileOutput = AVCaptureVideoFileOutput() - videoFileOutput.configureOutput( - audioSettings: recordingSettings.audio, - videoSettings: recordingSettings.video - ) - if captureSession.canAddOutput(videoFileOutput) { - captureSession.addOutput(videoFileOutput) - captureVideoFileOutput = videoFileOutput - } else { - log(.cannotAddVideoFileOutput) - } - - } else if captureMovieFileOutput == nil { - if let videoFileOutput = captureVideoFileOutput { - captureSession.removeOutput(videoFileOutput) - captureVideoFileOutput = nil + let previousMovieOutput = movieCapture.movieOutput + if let movieOutput = movieCapture.configureOutput(settings: recordingSettings) { + if let previousMovieOutput { + captureSession.removeOutput(previousMovieOutput) } - let moveFileOutput = AVCaptureMovieFileOutput() - if captureSession.canAddOutput(moveFileOutput) { - captureSession.addOutput(moveFileOutput) - captureMovieFileOutput = moveFileOutput + if captureSession.canAddOutput(movieOutput) { + captureSession.addOutput(movieOutput) } else { log(.cannotAddVideoFileOutput) } @@ -590,60 +548,6 @@ public final class Camera: NSObject, ObservableObject { } -// MARK: - File Output Recording Delegate - -extension Camera: AVCaptureFileOutputRecordingDelegate { - - public func fileOutput( - _ output: AVCaptureFileOutput, - didStartRecordingTo fileURL: URL, - from connections: [AVCaptureConnection] - ) { - isRecording = true - } - - public func fileOutput( - _ output: AVCaptureFileOutput, - didFinishRecordingTo outputFileURL: URL, - from connections: [AVCaptureConnection], - error: Error? - ) { - isRecording = false - if let error { - didStopRecording?(.failure(error)) - } else { - didStopRecording?(.success(outputFileURL)) - } - } -} - -// MARK: - Video File Output Recording Delegate - -extension Camera: AVCaptureVideoFileOutputRecordingDelegate { - - func videoFileOutput( - _ output: AVCaptureVideoFileOutput, - didStartRecordingTo fileURL: URL, - from connections: [AVCaptureConnection] - ) { - isRecording = true - } - - func videoFileOutput( - _ output: AVCaptureVideoFileOutput, - didFinishRecordingTo outputFileURL: URL, - from connections: [AVCaptureConnection], - error: Error? - ) { - isRecording = false - if let error { - didStopRecording?(.failure(error)) - } else { - didStopRecording?(.success(outputFileURL)) - } - } -} - // MARK: - Photo Capture Delegate extension Camera: AVCapturePhotoCaptureDelegate { From fbe9f08d0d0894a4c31ffa392d61348139760eed Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Sat, 4 Jan 2025 14:29:27 +0100 Subject: [PATCH 07/15] Refactor photo capture into PhotoCapture --- .../UIKit/UIImage+AVCapturePhoto.swift | 1 + Sources/Capture/Internal/PhotoCapture.swift | 44 +++++++++++++++++++ .../Capture/Public/Camera+Extensions.swift | 1 + Sources/Capture/Public/Camera.swift | 40 ++--------------- 4 files changed, 50 insertions(+), 36 deletions(-) create mode 100644 Sources/Capture/Internal/PhotoCapture.swift diff --git a/Sources/Capture/Extensions/UIKit/UIImage+AVCapturePhoto.swift b/Sources/Capture/Extensions/UIKit/UIImage+AVCapturePhoto.swift index 6445214..2f18a8e 100644 --- a/Sources/Capture/Extensions/UIKit/UIImage+AVCapturePhoto.swift +++ b/Sources/Capture/Extensions/UIKit/UIImage+AVCapturePhoto.swift @@ -5,6 +5,7 @@ // Created by Quentin Fasquel on 07/12/2023. // +@preconcurrency import AVFoundation #if canImport(UIKit) import UIKit.UIImage diff --git a/Sources/Capture/Internal/PhotoCapture.swift b/Sources/Capture/Internal/PhotoCapture.swift new file mode 100644 index 0000000..52fd110 --- /dev/null +++ b/Sources/Capture/Internal/PhotoCapture.swift @@ -0,0 +1,44 @@ +// +// PhotoOutput.swift +// Capture +// +// Created by Quentin Fasquel on 04/01/2025. +// + +@preconcurrency import AVFoundation + +final class PhotoCapture: NSObject { + let capturePhotoOutput: AVCapturePhotoOutput = AVCapturePhotoOutput() + private var captureContinuation: CheckedContinuation? + + override init() { + super.init() + capturePhotoOutput.maxPhotoQualityPrioritization = .quality + } + + func capturePhoto() async throws -> AVCapturePhoto { + let photoSettings = capturePhotoOutput.photoSettings() + return try await withCheckedThrowingContinuation { continuation in + captureContinuation = continuation + capturePhotoOutput.capturePhoto(with: photoSettings, delegate: self) + } + } +} + +// MARK: - Photo Capture Delegate + +extension PhotoCapture: AVCapturePhotoCaptureDelegate { + + func photoOutput( + _ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error? + ) { + if let error { + captureContinuation?.resume(throwing: error) + } else { + captureContinuation?.resume(returning: photo) + } + captureContinuation = nil + } +} diff --git a/Sources/Capture/Public/Camera+Extensions.swift b/Sources/Capture/Public/Camera+Extensions.swift index 22e28b6..abb7ac0 100644 --- a/Sources/Capture/Public/Camera+Extensions.swift +++ b/Sources/Capture/Public/Camera+Extensions.swift @@ -5,6 +5,7 @@ // Created by Quentin Fasquel on 17/12/2023. // +@preconcurrency import AVFoundation import Foundation extension Camera { diff --git a/Sources/Capture/Public/Camera.swift b/Sources/Capture/Public/Camera.swift index b8939a7..5efad3b 100644 --- a/Sources/Capture/Public/Camera.swift +++ b/Sources/Capture/Public/Camera.swift @@ -26,12 +26,10 @@ public final class Camera: NSObject, ObservableObject { private var isCaptureSessionConfigured = false - private var capturePhotoOutput: AVCapturePhotoOutput? private var captureVideoInput: AVCaptureDeviceInput? private let movieCapture = MovieCapture() - - private var didTakePicture: ((Result) -> Void)? + private let photoCapture = PhotoCapture() // MARK: - Internal Properties @@ -203,19 +201,8 @@ public final class Camera: NSObject, ObservableObject { } public func takePicture() async throws -> AVCapturePhoto { - guard let photoOutput = capturePhotoOutput else { - throw CameraError.missingPhotoOutput - } - - defer { didTakePicture = nil } - - return try await withCheckedThrowingContinuation { continuation in - didTakePicture = { continuation.resume(with: $0) } - sessionQueue.async { - let photoSettings = photoOutput.photoSettings() - photoOutput.capturePhoto(with: photoSettings, delegate: self) - } - } + // sessionQueue.async + try await photoCapture.capturePhoto() } // MARK: - Capture Device Management @@ -374,11 +361,10 @@ public final class Camera: NSObject, ObservableObject { } // Configure photo capture - let photoOutput = AVCapturePhotoOutput() + let photoOutput = photoCapture.capturePhotoOutput photoOutput.maxPhotoQualityPrioritization = .quality if captureSession.canAddOutput(photoOutput) { captureSession.addOutput(photoOutput) - capturePhotoOutput = photoOutput } else { log(.cannotAddPhotoOutput) } @@ -545,22 +531,4 @@ public final class Camera: NSObject, ObservableObject { updateCaptureVideoInput(newCaptureDevice) } } - -} - -// MARK: - Photo Capture Delegate - -extension Camera: AVCapturePhotoCaptureDelegate { - - public func photoOutput( - _ output: AVCapturePhotoOutput, - didFinishProcessingPhoto photo: AVCapturePhoto, - error: Error? - ) { - if let error { - didTakePicture?(.failure(error)) - } else { - didTakePicture?(.success(photo)) - } - } } From be9802479af60cd49dfaaec6524558528940fd06 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Sat, 4 Jan 2025 14:41:06 +0100 Subject: [PATCH 08/15] Refactor device discovery in CaptureDeviceLookup --- .../Internal/CaptureDeviceLookup.swift | 76 +++++++++++++++ Sources/Capture/Public/Camera.swift | 93 +++---------------- 2 files changed, 87 insertions(+), 82 deletions(-) create mode 100644 Sources/Capture/Internal/CaptureDeviceLookup.swift diff --git a/Sources/Capture/Internal/CaptureDeviceLookup.swift b/Sources/Capture/Internal/CaptureDeviceLookup.swift new file mode 100644 index 0000000..34756d9 --- /dev/null +++ b/Sources/Capture/Internal/CaptureDeviceLookup.swift @@ -0,0 +1,76 @@ +// +// CaptureDeviceLookup.swift +// Capture +// +// Created by Quentin Fasquel on 04/01/2025. +// + +@preconcurrency import AVFoundation + +final class CaptureDeviceLookup { + + private lazy var discoverySession: AVCaptureDevice.DiscoverySession = { +#if os(iOS) + var deviceTypes: [AVCaptureDevice.DeviceType] = [ + .builtInDualCamera, + .builtInDualWideCamera, + .builtInUltraWideCamera, + .builtInLiDARDepthCamera, + .builtInTelephotoCamera, + .builtInTripleCamera, + .builtInTrueDepthCamera, + .builtInWideAngleCamera, + ] + if #available(iOS 17, *) { + deviceTypes.append(.continuityCamera) + } +#elseif os(macOS) + var deviceTypes: [AVCaptureDevice.DeviceType] = [ + .builtInWideAngleCamera, + .deskViewCamera, + ] + if #available(macOS 14.0, *) { + deviceTypes.append(.continuityCamera) + deviceTypes.append(.external) + } +#endif + return AVCaptureDevice.DiscoverySession( + deviceTypes: deviceTypes, + mediaType: .video, + position: .unspecified + ) + }() + + var backCaptureDevices: [AVCaptureDevice] { + discoverySession.devices.filter { $0.position == .back } + } + + var frontCaptureDevices: [AVCaptureDevice] { + discoverySession.devices.filter { $0.position == .front } + } + + var captureDevices: [AVCaptureDevice] { + var devices = [AVCaptureDevice]() +#if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst)) + devices += discoverySession.devices +#else + + let defaultDevice = AVCaptureDevice.default(for: .video) + if let defaultDevice { + devices.append(defaultDevice) + } + + if let backDevice = backCaptureDevices.first, backDevice != defaultDevice { + devices += [backDevice] + } + if let frontDevice = frontCaptureDevices.first, frontDevice != defaultDevice { + devices += [frontDevice] + } +#endif + return devices + } + + var availableCaptureDevices: [AVCaptureDevice] { + captureDevices.filter { $0.isConnected && !$0.isSuspended }.unique() + } +} diff --git a/Sources/Capture/Public/Camera.swift b/Sources/Capture/Public/Camera.swift index 5efad3b..ed6742b 100644 --- a/Sources/Capture/Public/Camera.swift +++ b/Sources/Capture/Public/Camera.swift @@ -16,11 +16,11 @@ public enum CameraError: Error { case missingVideoOutput } +@MainActor public final class Camera: NSObject, ObservableObject { public static let `default` = Camera(.back) - let captureSession = AVCaptureSession() private let sessionQueue = DispatchQueue(label: "\(bundleIdentifier).Camera.Session") private let sessionPreset: AVCaptureSession.Preset @@ -28,11 +28,13 @@ public final class Camera: NSObject, ObservableObject { private var captureVideoInput: AVCaptureDeviceInput? + private let deviceLookup = CaptureDeviceLookup() private let movieCapture = MovieCapture() private let photoCapture = PhotoCapture() // MARK: - Internal Properties - + + let captureSession = AVCaptureSession() var devicePosition: CameraPosition var recordingSettings: RecordingSettings? var isAudioEnabled: Bool @@ -91,7 +93,7 @@ public final class Camera: NSObject, ObservableObject { registerDeviceOrientationObserver() } #endif - devices = availableCaptureDevices + devices = deviceLookup.availableCaptureDevices } deinit { @@ -207,85 +209,10 @@ public final class Camera: NSObject, ObservableObject { // MARK: - Capture Device Management - private lazy var discoverySession: AVCaptureDevice.DiscoverySession = { -#if os(iOS) - var deviceTypes: [AVCaptureDevice.DeviceType] = [ - .builtInDualCamera, - .builtInDualWideCamera, - .builtInUltraWideCamera, - .builtInLiDARDepthCamera, - .builtInTelephotoCamera, - .builtInTripleCamera, - .builtInTrueDepthCamera, - .builtInWideAngleCamera, - ] - if #available(iOS 17, *) { - deviceTypes.append(.continuityCamera) - } -#elseif os(macOS) - var deviceTypes: [AVCaptureDevice.DeviceType] = [ - .builtInWideAngleCamera, - .deskViewCamera, - ] - if #available(macOS 14.0, *) { - deviceTypes.append(.continuityCamera) - deviceTypes.append(.external) - } -#endif - return AVCaptureDevice.DiscoverySession( - deviceTypes: deviceTypes, - mediaType: .video, - position: .unspecified - ) - }() - - private var backCaptureDevices: [AVCaptureDevice] { - discoverySession.devices.filter { $0.position == .back } - } - - private var frontCaptureDevices: [AVCaptureDevice] { - discoverySession.devices.filter { $0.position == .front } - } - - private var captureDevices: [AVCaptureDevice] { - var devices = [AVCaptureDevice]() -#if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst)) - devices += discoverySession.devices -#else - - let defaultDevice = AVCaptureDevice.default(for: .video) - if let defaultDevice { - devices.append(defaultDevice) - } - - if let backDevice = backCaptureDevices.first, backDevice != defaultDevice { - devices += [backDevice] - } - if let frontDevice = frontCaptureDevices.first, frontDevice != defaultDevice { - devices += [frontDevice] - } -#endif - return devices - } - - private var availableCaptureDevices: [AVCaptureDevice] { - captureDevices.filter { $0.isConnected && !$0.isSuspended }.unique() - } - - private var isUsingFrontCaptureDevice: Bool { - guard let captureDevice else { return false } - return frontCaptureDevices.contains(captureDevice) - } - - private var isUsingBackCaptureDevice: Bool { - guard let captureDevice else { return false } - return backCaptureDevices.contains(captureDevice) - } - private func updateCaptureDevice(forDevicePosition devicePosition: AVCaptureDevice.Position) { if case .unspecified = devicePosition { captureDevice = AVCaptureDevice.default(for: .video) - } else if let device = captureDevices.first(where: { $0.position == devicePosition }) { + } else if let device = deviceLookup.captureDevices.first(where: { $0.position == devicePosition }) { captureDevice = device } else { logger.warning("Couldn't update capture device for \(String(describing: devicePosition))") @@ -440,7 +367,11 @@ public final class Camera: NSObject, ObservableObject { } private func updateCaptureOutputMirroring() { - let isVideoMirrored = isUsingFrontCaptureDevice + guard let captureDevice else { + return + } + + let isVideoMirrored = captureDevice.position == .front videoConnections.forEach { videoConnection in if videoConnection.isVideoMirroringSupported { videoConnection.isVideoMirrored = isVideoMirrored @@ -448,7 +379,6 @@ public final class Camera: NSObject, ObservableObject { } } - private func updateCaptureOutputOrientation() { #if os(iOS) var deviceOrientation = UIDevice.current.orientation @@ -467,7 +397,6 @@ public final class Camera: NSObject, ObservableObject { #endif } - private func startCaptureSession() { #if os(iOS) Task { @MainActor in From 1f2168369206c944ee59e69860b4e048619c3b84 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Sun, 5 Jan 2025 09:00:50 +0100 Subject: [PATCH 09/15] Refactor photo capture into PhotoCapture --- Sources/Capture/Public/Camera.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/Capture/Public/Camera.swift b/Sources/Capture/Public/Camera.swift index ed6742b..f643622 100644 --- a/Sources/Capture/Public/Camera.swift +++ b/Sources/Capture/Public/Camera.swift @@ -26,8 +26,6 @@ public final class Camera: NSObject, ObservableObject { private var isCaptureSessionConfigured = false - private var captureVideoInput: AVCaptureDeviceInput? - private let deviceLookup = CaptureDeviceLookup() private let movieCapture = MovieCapture() private let photoCapture = PhotoCapture() From 1ac25aaee5f66256d5650da3ff7d62ddadda44c9 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Sun, 5 Jan 2025 09:04:34 +0100 Subject: [PATCH 10/15] Refactor capture session into CaptureService (actor) --- .../Internal/CameraConfigurationWarning.swift | 6 +- Sources/Capture/Internal/CaptureService.swift | 179 +++++++++++++ .../Internal/DispatchQueueExecutor.swift | 30 +++ Sources/Capture/Public/Camera.swift | 242 +++--------------- 4 files changed, 250 insertions(+), 207 deletions(-) create mode 100644 Sources/Capture/Internal/CaptureService.swift create mode 100644 Sources/Capture/Internal/DispatchQueueExecutor.swift diff --git a/Sources/Capture/Internal/CameraConfigurationWarning.swift b/Sources/Capture/Internal/CameraConfigurationWarning.swift index f0f8438..67e59f2 100644 --- a/Sources/Capture/Internal/CameraConfigurationWarning.swift +++ b/Sources/Capture/Internal/CameraConfigurationWarning.swift @@ -7,7 +7,7 @@ import Foundation -enum CameraConfigurationWarning { +enum CaptureConfigurationWarning { case audioDeviceNotFound case cameraDeviceNotSet case cannotAddAudioInput @@ -18,9 +18,9 @@ enum CameraConfigurationWarning { case cannotSetSessionPreset } -extension Camera { +extension CaptureService { - func log(_ warning: CameraConfigurationWarning) { + nonisolated func log(_ warning: CaptureConfigurationWarning) { switch warning { case .audioDeviceNotFound: logger.warning("Audio device not found") diff --git a/Sources/Capture/Internal/CaptureService.swift b/Sources/Capture/Internal/CaptureService.swift new file mode 100644 index 0000000..0162f0e --- /dev/null +++ b/Sources/Capture/Internal/CaptureService.swift @@ -0,0 +1,179 @@ +// +// CaptureService.swift +// Capture +// +// Created by Quentin Fasquel on 04/01/2025. +// + +@preconcurrency import AVFoundation + +actor CaptureService { + + private let captureSession: AVCaptureSession + private let captureSessionExecutor: DispatchQueueExecutor + private var captureAudioInput: AVCaptureDeviceInput? + private var captureVideoInput: AVCaptureDeviceInput? + private var isCapturedSessionConfigured: Bool = false + + private let movieCapture: MovieCapture = .init() + private let photoCapture: PhotoCapture = .init() + + nonisolated var unownedExecutor: UnownedSerialExecutor { + captureSessionExecutor.asUnownedSerialExecutor() + } + + init(session: AVCaptureSession, queue: DispatchQueue) { + captureSession = session + captureSessionExecutor = DispatchQueueExecutor(queue: queue) + } + + func configure( + cameraDevice: AVCaptureDevice?, + microphoneDevice: AVCaptureDevice?, + sessionPreset: AVCaptureSession.Preset, + previewLayer: AVCaptureVideoPreviewLayer, + recordingSettings: RecordingSettings? + ) throws { + + guard let cameraDevice = cameraDevice ?? .default(for: .video) else { + log(.cameraDeviceNotSet) + return + } + + captureSession.beginConfiguration() + defer { captureSession.commitConfiguration() } + + configureCaptureSessionPreset(sessionPreset) + try configureCaptureVideoInput(cameraDevice, microphoneDevice: microphoneDevice) + + configureCaptureMovieOutput(settings: recordingSettings) + configureCapturePhotoOutput() + configureCapturePreviewOutput(previewLayer: previewLayer) + configureCaptureOutputMirroring() + // TODO: video rotation angle / video orientation + + isCapturedSessionConfigured = true + } + + func startCaptureSession() { + guard isCapturedSessionConfigured else { + return + } + captureSession.startRunning() + } + + func stopCaptureSession() { + captureSession.stopRunning() + } + + func startRecording() { + movieCapture.startRecording() + } + + func stopRecording() async throws -> URL { + try await movieCapture.stopRecording() + } + + func capturePhoto() async throws -> AVCapturePhoto { + try await photoCapture.capturePhoto() + } + + func setCaptureDevice(_ captureDevice: AVCaptureDevice) throws { + captureSession.beginConfiguration() + defer { captureSession.commitConfiguration() } + + if let captureVideoInput { + captureSession.removeInput(captureVideoInput) + } + + try configureCaptureVideoInput(captureDevice, microphoneDevice: captureAudioInput?.device) + configureCaptureOutputMirroring() + } + + private var videoConnections: [AVCaptureConnection] { + captureSession.outputs.compactMap { $0.connection(with: .video) } + } + + private func configureCaptureSessionPreset(_ sessionPreset: AVCaptureSession.Preset) { + if captureSession.canSetSessionPreset(sessionPreset) { + captureSession.sessionPreset = sessionPreset + } else { + log(.cannotSetSessionPreset) + } + } + + private func configureCaptureVideoInput( + _ cameraDevice: AVCaptureDevice, + microphoneDevice: AVCaptureDevice? + ) throws { + let videoInput = try AVCaptureDeviceInput(device: cameraDevice) + if captureSession.canAddInput(videoInput) { + captureSession.addInput(videoInput) + captureVideoInput = videoInput + } else { + log(.cannotAddVideoInput) + } + + if let microphoneDevice { + let audioInput = try AVCaptureDeviceInput(device: microphoneDevice) + if captureSession.canAddInput(audioInput) { + captureSession.addInput(audioInput) + captureAudioInput = audioInput + } else { + log(.cannotAddAudioInput) + } + } + } + + private func configureCapturePhotoOutput() { + let photoOutput = photoCapture.capturePhotoOutput + if captureSession.canAddOutput(photoOutput) { + captureSession.addOutput(photoOutput) + } else { + log(.cannotAddPhotoOutput) + } + } + + internal func configureCaptureMovieOutput(settings: RecordingSettings?) { + captureSession.beginConfiguration() + defer { captureSession.commitConfiguration() } + + let previousMovieOutput = movieCapture.movieOutput + if let movieOutput = movieCapture.configureOutput(settings: settings) { + if let previousMovieOutput { + captureSession.removeOutput(previousMovieOutput) + } + + if captureSession.canAddOutput(movieOutput) { + captureSession.addOutput(movieOutput) + } else { + log(.cannotAddVideoFileOutput) + } + } + } + + private func configureCapturePreviewOutput(previewLayer: AVCaptureVideoPreviewLayer) { + previewLayer.session = captureSession + } + + private func configureCaptureOutputMirroring() { + guard let captureDevice = captureVideoInput?.device else { + return + } + + let isVideoMirrored = captureDevice.position == .front + videoConnections.forEach { connection in + if connection.isVideoMirroringSupported { + connection.isVideoMirrored = isVideoMirrored + } + } + } + + func updateCaptureOutputOrientation(_ videoOrientation: AVCaptureVideoOrientation) { + videoConnections.forEach { connection in + if connection.isVideoOrientationSupported { + connection.videoOrientation = videoOrientation + } + } + } +} diff --git a/Sources/Capture/Internal/DispatchQueueExecutor.swift b/Sources/Capture/Internal/DispatchQueueExecutor.swift new file mode 100644 index 0000000..a95c6fd --- /dev/null +++ b/Sources/Capture/Internal/DispatchQueueExecutor.swift @@ -0,0 +1,30 @@ +// +// DispatchQueueExecutor.swift +// Capture +// +// Created by Quentin Fasquel on 01/01/2025. +// + +import Foundation + +final class DispatchQueueExecutor: SerialExecutor { + private let queue: DispatchQueue + + init(queue: DispatchQueue) { + self.queue = queue + } + + func enqueue(_ job: UnownedJob) { + queue.async { + job.runSynchronously(on: self.asUnownedSerialExecutor()) + } + } + + func asUnownedSerialExecutor() -> UnownedSerialExecutor { + UnownedSerialExecutor(ordinary: self) + } + + func checkIsolated() { + dispatchPrecondition(condition: .onQueue(queue)) + } +} diff --git a/Sources/Capture/Public/Camera.swift b/Sources/Capture/Public/Camera.swift index f643622..666793e 100644 --- a/Sources/Capture/Public/Camera.swift +++ b/Sources/Capture/Public/Camera.swift @@ -17,29 +17,24 @@ public enum CameraError: Error { } @MainActor -public final class Camera: NSObject, ObservableObject { +public final class Camera: ObservableObject { public static let `default` = Camera(.back) - private let sessionQueue = DispatchQueue(label: "\(bundleIdentifier).Camera.Session") - private let sessionPreset: AVCaptureSession.Preset - - private var isCaptureSessionConfigured = false - + private let captureService: CaptureService private let deviceLookup = CaptureDeviceLookup() - private let movieCapture = MovieCapture() - private let photoCapture = PhotoCapture() + private let sessionQueue = DispatchQueue(label: "\(bundleIdentifier).Camera.Session") // MARK: - Internal Properties - let captureSession = AVCaptureSession() var devicePosition: CameraPosition + var sessionPreset: AVCaptureSession.Preset var recordingSettings: RecordingSettings? var isAudioEnabled: Bool // MARK: - Public API - public private(set) var previewLayer: AVCaptureVideoPreviewLayer + public let previewLayer = AVCaptureVideoPreviewLayer() @Published public private(set) var isRecording: Bool = false @Published public private(set) var isPreviewPaused: Bool = false @@ -81,11 +76,10 @@ public final class Camera: NSObject, ObservableObject { preset: AVCaptureSession.Preset, audioEnabled: Bool = true ) { + captureService = CaptureService(session: AVCaptureSession(), queue: sessionQueue) devicePosition = position sessionPreset = preset isAudioEnabled = audioEnabled - previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) - super.init() #if os(iOS) Task { @MainActor in registerDeviceOrientationObserver() @@ -110,32 +104,20 @@ public final class Camera: NSObject, ObservableObject { return } - guard !captureSession.isRunning else { - logger.info("Camera is already running") + guard await configureCaptureService() else { + // logger.info("Camera is already running") ? return } - if isCaptureSessionConfigured { - return startCaptureSession() - } - - sessionQueue.async { [self] in - guard configureCaptureSession() else { - return - } - - if !captureSession.isRunning { - captureSession.startRunning() - } - } + await captureService.startCaptureSession() + Self.startObservingDeviceOrientation() } public func stop() { - guard isCaptureSessionConfigured else { - return + Task { + Self.stopObservingDeviceOrientation() + await captureService.stopCaptureSession() } - - stopCaptureSession() } @MainActor @@ -172,15 +154,8 @@ public final class Camera: NSObject, ObservableObject { } recordingSettings = newRecordingSettings + Task { await captureService.configureCaptureMovieOutput(settings: newRecordingSettings) } - guard isCaptureSessionConfigured else { - // else it will be applied during session configuration - return - } - - sessionQueue.async { [self] in - updateCaptureVideoOutput(newRecordingSettings) - } } public func startRecording() { @@ -189,20 +164,16 @@ public final class Camera: NSObject, ObservableObject { } isRecording = true - sessionQueue.async { [self] in - movieCapture.startRecording() - } + Task { await captureService.startRecording() } } public func stopRecording() async throws -> URL { defer { isRecording = false } - // sessionQueue.async - return try await movieCapture.stopRecording() + return try await captureService.stopRecording() } public func takePicture() async throws -> AVCapturePhoto { - // sessionQueue.async - try await photoCapture.capturePhoto() + return try await captureService.capturePhoto() } // MARK: - Capture Device Management @@ -250,131 +221,23 @@ public final class Camera: NSObject, ObservableObject { // MARK: - Capture Session Configuration - private var videoConnections: [AVCaptureConnection] { - captureSession.outputs.compactMap { $0.connection(with: .video) } - } - - private func configureCaptureSession() -> Bool { + private func configureCaptureService() async -> Bool { guard case .authorized = authorizationStatus else { return false } - updateCaptureDevice(forDevicePosition: devicePosition) - - guard let captureDevice else { - log(.cameraDeviceNotSet) + do { + try await captureService.configure( + cameraDevice: deviceLookup.captureDevices.first, + microphoneDevice: isAudioEnabled ? .default(for: .audio) : nil, + sessionPreset: sessionPreset, + previewLayer: previewLayer, + recordingSettings: recordingSettings + ) + return true + } catch { return false } - - captureSession.beginConfiguration() - defer { captureSession.commitConfiguration() } - - if captureSession.canSetSessionPreset(sessionPreset) { - captureSession.sessionPreset = sessionPreset - } else { - captureSession.sessionPreset = .high - log(.cannotSetSessionPreset) - } - - // Adding video input (used for both photo and video capture) - let videoInput = AVCaptureDeviceInput(device: captureDevice, logger: logger) - if let videoInput, captureSession.canAddInput(videoInput) { - captureSession.addInput(videoInput) - captureVideoInput = videoInput - } else { - log(.cannotAddVideoInput) - } - - // Configure photo capture - let photoOutput = photoCapture.capturePhotoOutput - photoOutput.maxPhotoQualityPrioritization = .quality - if captureSession.canAddOutput(photoOutput) { - captureSession.addOutput(photoOutput) - } else { - log(.cannotAddPhotoOutput) - } - - // Configure video capture - if isAudioEnabled { - let audioDevice = AVCaptureDevice.default(for: .audio) - let audioInput = AVCaptureDeviceInput(device: audioDevice, logger: logger) - if let audioInput, captureSession.canAddInput(audioInput) { - captureSession.addInput(audioInput) - } else { - log(.cannotAddAudioInput) - } - } - - updateCaptureVideoOutput(recordingSettings) - - isCaptureSessionConfigured = true - return true - } - - private func updateCaptureVideoInput(_ cameraDevice: AVCaptureDevice) { - guard case .authorized = authorizationStatus else { - return - } - - guard isCaptureSessionConfigured else { - if configureCaptureSession(), !isPreviewPaused { - startCaptureSession() - } - return - } - - captureSession.beginConfiguration() - defer { captureSession.commitConfiguration() } - - // Remove current camera input - if let videoInput = captureVideoInput { - captureSession.removeInput(videoInput) - captureVideoInput = nil - } - - // Add new camera input - let videoInput = AVCaptureDeviceInput(device: cameraDevice, logger: logger) - if let videoInput, captureSession.canAddInput(videoInput) { - captureSession.addInput(videoInput) - captureVideoInput = videoInput - } - - updateCaptureOutputMirroring() - updateCaptureOutputOrientation() - } - - private func updateCaptureVideoOutput(_ recordingSettings: RecordingSettings?) { - captureSession.beginConfiguration() - defer { captureSession.commitConfiguration() } - - let previousMovieOutput = movieCapture.movieOutput - if let movieOutput = movieCapture.configureOutput(settings: recordingSettings) { - if let previousMovieOutput { - captureSession.removeOutput(previousMovieOutput) - } - - if captureSession.canAddOutput(movieOutput) { - captureSession.addOutput(movieOutput) - } else { - log(.cannotAddVideoFileOutput) - } - } - - updateCaptureOutputMirroring() - updateCaptureOutputOrientation() - } - - private func updateCaptureOutputMirroring() { - guard let captureDevice else { - return - } - - let isVideoMirrored = captureDevice.position == .front - videoConnections.forEach { videoConnection in - if videoConnection.isVideoMirroringSupported { - videoConnection.isVideoMirrored = isVideoMirrored - } - } } private func updateCaptureOutputOrientation() { @@ -386,47 +249,14 @@ public final class Camera: NSObject, ObservableObject { deviceOrientation = UIScreen.main.deviceOrientation } - videoConnections.forEach { videoConnection in - if videoConnection.isVideoOrientationSupported { - videoConnection.videoOrientation = AVCaptureVideoOrientation(deviceOrientation) - } + Task { + let videoOrientation = AVCaptureVideoOrientation(deviceOrientation) + await captureService.updateCaptureOutputOrientation(videoOrientation) } #elseif os(macOS) #endif } - private func startCaptureSession() { -#if os(iOS) - Task { @MainActor in - Self.startObservingDeviceOrientation() - } -#endif - if !captureSession.isRunning { - sessionQueue.async { - self.captureSession.startRunning() - } - } - } - - private func stopCaptureSession() { -#if os(iOS) - Task { @MainActor in - Self.stopObservingDeviceOrientation() - } -#endif - if captureSession.isRunning { - sessionQueue.async { - self.captureSession.stopRunning() - } - } - } - - // MARK: - - - public var isVideoMirrored: Bool { - videoConnections.first?.isVideoMirrored ?? false - } - // MARK: - Device Orientation Handling #if os(iOS) private var deviceOrientationObserver: NSObjectProtocol? @@ -453,9 +283,13 @@ public final class Camera: NSObject, ObservableObject { // MARK: - Private Methods private func captureDeviceDidChange(_ newCaptureDevice: AVCaptureDevice) { - logger.debug("Using capture device: \(newCaptureDevice.localizedName)") - sessionQueue.async { [self] in - updateCaptureVideoInput(newCaptureDevice) + Task { + do { + try await captureService.setCaptureDevice(newCaptureDevice) + logger.debug("Using capture device: \(newCaptureDevice.localizedName)") + } catch { + logger.error("Error updating capture device: \(error)") + } } } } From 700a41040266fafa24a537e242a325b4e3492f33 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Mon, 6 Jan 2025 07:27:30 +0100 Subject: [PATCH 11/15] Enable all devices --- Sources/Capture/Internal/CaptureDeviceLookup.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Sources/Capture/Internal/CaptureDeviceLookup.swift b/Sources/Capture/Internal/CaptureDeviceLookup.swift index 34756d9..2cd45fa 100644 --- a/Sources/Capture/Internal/CaptureDeviceLookup.swift +++ b/Sources/Capture/Internal/CaptureDeviceLookup.swift @@ -60,12 +60,8 @@ final class CaptureDeviceLookup { devices.append(defaultDevice) } - if let backDevice = backCaptureDevices.first, backDevice != defaultDevice { - devices += [backDevice] - } - if let frontDevice = frontCaptureDevices.first, frontDevice != defaultDevice { - devices += [frontDevice] - } + devices += backCaptureDevices.filter { $0 != defaultDevice } + devices += frontCaptureDevices.filter { $0 != defaultDevice } #endif return devices } From 2349f7f7c9b01a34ca47894d9fcc5022fc95f056 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Mon, 6 Jan 2025 07:32:08 +0100 Subject: [PATCH 12/15] Refactor capture session into CaptureService (actor) --- Sources/Capture/Internal/CaptureService.swift | 2 +- Sources/Capture/Public/Camera.swift | 11 +++++++---- Sources/Capture/Public/CameraView.swift | 6 ++++-- .../Public/EnvironmentValues/RecordVideoAction.swift | 4 ++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Sources/Capture/Internal/CaptureService.swift b/Sources/Capture/Internal/CaptureService.swift index 0162f0e..8d89c07 100644 --- a/Sources/Capture/Internal/CaptureService.swift +++ b/Sources/Capture/Internal/CaptureService.swift @@ -114,7 +114,7 @@ actor CaptureService { log(.cannotAddVideoInput) } - if let microphoneDevice { + if captureAudioInput == nil, let microphoneDevice { let audioInput = try AVCaptureDeviceInput(device: microphoneDevice) if captureSession.canAddInput(audioInput) { captureSession.addInput(audioInput) diff --git a/Sources/Capture/Public/Camera.swift b/Sources/Capture/Public/Camera.swift index 666793e..4d85b43 100644 --- a/Sources/Capture/Public/Camera.swift +++ b/Sources/Capture/Public/Camera.swift @@ -16,7 +16,6 @@ public enum CameraError: Error { case missingVideoOutput } -@MainActor public final class Camera: ObservableObject { public static let `default` = Camera(.back) @@ -110,12 +109,12 @@ public final class Camera: ObservableObject { } await captureService.startCaptureSession() - Self.startObservingDeviceOrientation() + await Self.startObservingDeviceOrientation() } public func stop() { Task { - Self.stopObservingDeviceOrientation() + await Self.stopObservingDeviceOrientation() await captureService.stopCaptureSession() } } @@ -131,6 +130,7 @@ public final class Camera: ObservableObject { Task { await start() } } + @MainActor public func setCaptureDevice(_ device: AVCaptureDevice) { captureDevice = device } @@ -158,6 +158,7 @@ public final class Camera: ObservableObject { } + @MainActor public func startRecording() { guard !isRecording else { return @@ -167,11 +168,13 @@ public final class Camera: ObservableObject { Task { await captureService.startRecording() } } + @MainActor public func stopRecording() async throws -> URL { defer { isRecording = false } return try await captureService.stopRecording() } + @MainActor public func takePicture() async throws -> AVCapturePhoto { return try await captureService.capturePhoto() } @@ -219,7 +222,7 @@ public final class Camera: ObservableObject { } } - // MARK: - Capture Session Configuration + // MARK: - Capture Service Configuration private func configureCaptureService() async -> Bool { guard case .authorized = authorizationStatus else { diff --git a/Sources/Capture/Public/CameraView.swift b/Sources/Capture/Public/CameraView.swift index 8ac5bc4..6fcd85c 100644 --- a/Sources/Capture/Public/CameraView.swift +++ b/Sources/Capture/Public/CameraView.swift @@ -63,14 +63,16 @@ public struct CameraView: View { cameraOverlay(authorizationStatus) } .environmentObject(camera) - .environment(\.takePicture, TakePictureAction { + .environment(\.takePicture, TakePictureAction { @MainActor in if options.isTakePictureFeedbackEnabled { showsTakePictureFeedback = true } outputImage = await camera.takePicture(outputSize: outputSize) }) - .environment(\.recordVideo, RecordVideoAction(start: camera.startRecording) { + .environment(\.recordVideo, RecordVideoAction { + await camera.startRecording() + } stop: { @MainActor in outputVideo = await camera.stopRecording() }) .onChange(of: recordingSettings) { recordingSettings in diff --git a/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift b/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift index ab8f6c7..b8b273f 100644 --- a/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift +++ b/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift @@ -9,7 +9,7 @@ import SwiftUI public struct RecordVideoAction: Sendable { - var start: @Sendable () -> Void = { + var start: @Sendable () async -> Void = { assertionFailure("@Environment(\\.recordVideo) must be accessed from a camera overlay view") } @@ -18,7 +18,7 @@ public struct RecordVideoAction: Sendable { } public func startRecording() { - start() + Task { await start() } } public func stopRecording() { From 4f5527f715eedcd7c1c3ce0539d0444b9898fed3 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Mon, 6 Jan 2025 13:03:21 +0100 Subject: [PATCH 13/15] Adding support for userPreferredCamera (iOS 17) --- .../CaptureExample/ContentView.swift | 2 +- .../Camera+Extensions.swift | 25 +++++-- .../Internal/CaptureDeviceLookup.swift | 38 ++++++++++ Sources/Capture/Internal/CaptureService.swift | 17 +++-- Sources/Capture/Public/Camera.swift | 74 ++++++++++++++++--- 5 files changed, 131 insertions(+), 25 deletions(-) rename Sources/Capture/{Public => Internal}/Camera+Extensions.swift (57%) diff --git a/CaptureExample/CaptureExample/ContentView.swift b/CaptureExample/CaptureExample/ContentView.swift index 246765f..d80a2dc 100644 --- a/CaptureExample/CaptureExample/ContentView.swift +++ b/CaptureExample/CaptureExample/ContentView.swift @@ -19,7 +19,7 @@ struct ContentView: View { @State private var path = NavigationPath() @State private var isPaused: Bool = false - @StateObject private var camera: Camera = .default + @StateObject private var camera: Camera = .userPreferredCamera @State private var tab: Tab = .photo @Environment(\.takePicture) var takePicture diff --git a/Sources/Capture/Public/Camera+Extensions.swift b/Sources/Capture/Internal/Camera+Extensions.swift similarity index 57% rename from Sources/Capture/Public/Camera+Extensions.swift rename to Sources/Capture/Internal/Camera+Extensions.swift index abb7ac0..6437ea2 100644 --- a/Sources/Capture/Public/Camera+Extensions.swift +++ b/Sources/Capture/Internal/Camera+Extensions.swift @@ -9,22 +9,31 @@ import Foundation extension Camera { - func takePicture(outputSize: CGSize) async -> PlatformImage? { + + func takePicture() async -> PlatformImage? { do { - let capturePhoto = try await takePicture() - let image = PlatformImage(photo: capturePhoto) -#if os(iOS) - return image?.fixOrientation().scaleToFill(in: outputSize) -#elseif os(macOS) - return image?.scaleToFill(in: outputSize) -#endif + let capturePhoto = try await takePicture() as AVCapturePhoto + return PlatformImage(photo: capturePhoto) } catch { return nil } } + + func takePicture(outputSize: CGSize) async -> PlatformImage? { + guard let image = await takePicture() else { + return nil + } + +#if os(iOS) + return image.fixOrientation().scaleToFill(in: outputSize) +#elseif os(macOS) + return image.scaleToFill(in: outputSize) +#endif + } } extension Camera { + func stopRecording() async -> URL? { do { return try await stopRecording() as URL diff --git a/Sources/Capture/Internal/CaptureDeviceLookup.swift b/Sources/Capture/Internal/CaptureDeviceLookup.swift index 2cd45fa..1c23af0 100644 --- a/Sources/Capture/Internal/CaptureDeviceLookup.swift +++ b/Sources/Capture/Internal/CaptureDeviceLookup.swift @@ -69,4 +69,42 @@ final class CaptureDeviceLookup { var availableCaptureDevices: [AVCaptureDevice] { captureDevices.filter { $0.isConnected && !$0.isSuspended }.unique() } + + var userPreferredCamera: AVCaptureDevice? { + guard #available(iOS 17.0, *) else { + return nil + } + return AVCaptureDevice.userPreferredCamera + } + + func microphone() -> AVCaptureDevice? { + if #available(iOS 17.0, *) { + AVCaptureDevice.default(.microphone, for: .audio, position: .unspecified) + } else { + AVCaptureDevice.default(for: .audio) + } + } + + func camera(devicePosition: AVCaptureDevice.Position, defaultsToUserPreferredCamera: Bool) -> AVCaptureDevice? { + if defaultsToUserPreferredCamera, let userPreferredCamera { + if devicePosition == .unspecified || userPreferredCamera.position == devicePosition { + return userPreferredCamera + } + } + + + // Following the documentation, find the best device within device types + // https://developer.apple.com/documentation/avfoundation/choosing-a-capture-device + let defaultDiscoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: [ + .builtInTripleCamera, + .builtInDualCamera, + .builtInDualWideCamera + ], + mediaType: .video, + position: devicePosition + ) + + return defaultDiscoverySession.devices.first + } } diff --git a/Sources/Capture/Internal/CaptureService.swift b/Sources/Capture/Internal/CaptureService.swift index 8d89c07..18bc48d 100644 --- a/Sources/Capture/Internal/CaptureService.swift +++ b/Sources/Capture/Internal/CaptureService.swift @@ -28,18 +28,13 @@ actor CaptureService { } func configure( - cameraDevice: AVCaptureDevice?, + cameraDevice: AVCaptureDevice, microphoneDevice: AVCaptureDevice?, sessionPreset: AVCaptureSession.Preset, previewLayer: AVCaptureVideoPreviewLayer, recordingSettings: RecordingSettings? ) throws { - guard let cameraDevice = cameraDevice ?? .default(for: .video) else { - log(.cameraDeviceNotSet) - return - } - captureSession.beginConfiguration() defer { captureSession.commitConfiguration() } @@ -78,7 +73,11 @@ actor CaptureService { try await photoCapture.capturePhoto() } - func setCaptureDevice(_ captureDevice: AVCaptureDevice) throws { + func setCaptureDevice(_ captureDevice: AVCaptureDevice, updateUserPreferredCamera: Bool) throws { + guard isCapturedSessionConfigured else { + return + } + captureSession.beginConfiguration() defer { captureSession.commitConfiguration() } @@ -88,6 +87,10 @@ actor CaptureService { try configureCaptureVideoInput(captureDevice, microphoneDevice: captureAudioInput?.device) configureCaptureOutputMirroring() + + if updateUserPreferredCamera, #available(iOS 17.0, *) { + AVCaptureDevice.userPreferredCamera = captureDevice + } } private var videoConnections: [AVCaptureConnection] { diff --git a/Sources/Capture/Public/Camera.swift b/Sources/Capture/Public/Camera.swift index 4d85b43..1f4f0cc 100644 --- a/Sources/Capture/Public/Camera.swift +++ b/Sources/Capture/Public/Camera.swift @@ -26,20 +26,22 @@ public final class Camera: ObservableObject { // MARK: - Internal Properties - var devicePosition: CameraPosition var sessionPreset: AVCaptureSession.Preset var recordingSettings: RecordingSettings? var isAudioEnabled: Bool + let isUserPreferredCamera: Bool // MARK: - Public API public let previewLayer = AVCaptureVideoPreviewLayer() + @Published public private(set) var devicePosition: CameraPosition @Published public private(set) var isRecording: Bool = false @Published public private(set) var isPreviewPaused: Bool = false @Published public private(set) var devices: [AVCaptureDevice] = [] @Published public var captureDevice: AVCaptureDevice? { didSet { + devicePosition = captureDevice?.position ?? .unspecified if oldValue != captureDevice, let captureDevice { captureDeviceDidChange(captureDevice) } @@ -59,7 +61,8 @@ public final class Camera: ObservableObject { self.init( position: position, preset: .high, - audioEnabled: audioEnabled + audioEnabled: audioEnabled, + userPreferredCamera: false ) } @@ -70,15 +73,54 @@ public final class Camera: ObservableObject { /// - parameter audioEnabled: whether audio should be enabled when recording videos. The default value is `true`. /// Typically set this value to `false` when using the Camera to only take pictures, avoiding to requesting audio permissions. /// - public required init( - position: CameraPosition, + public convenience init( + position: CameraPosition = .unspecified, + preset: AVCaptureSession.Preset, + audioEnabled: Bool = true + ) { + self.init( + position: position, + preset: preset, + audioEnabled: audioEnabled, + userPreferredCamera: false + ) + } + + @available(iOS 17.0, *) + public static var userPreferredCamera: Camera { + return .userPreferredCamera(preset: .high, audioEnabled: true) + } + + /// + /// Instantiate a Camera instance that will match the user preferred camera and update it when switching capture device + /// - parameter preset: the capture session's preset to use + /// - parameter audioEnabled: whether audio should be enabled when recording videos. The default value is `true`. + /// Typically set this value to `false` when using the Camera to only take pictures, avoiding to requesting audio permissions. + /// + @available(iOS 17.0, *) + public class func userPreferredCamera( preset: AVCaptureSession.Preset, audioEnabled: Bool = true + ) -> Camera { + return Camera( + position: .unspecified, + preset: preset, + audioEnabled: audioEnabled, + userPreferredCamera: true + ) + } + + private init( + position: CameraPosition, + preset: AVCaptureSession.Preset = .high, + audioEnabled: Bool = true, + userPreferredCamera: Bool = false ) { captureService = CaptureService(session: AVCaptureSession(), queue: sessionQueue) devicePosition = position sessionPreset = preset isAudioEnabled = audioEnabled + isUserPreferredCamera = userPreferredCamera #if os(iOS) Task { @MainActor in registerDeviceOrientationObserver() @@ -86,7 +128,7 @@ public final class Camera: ObservableObject { #endif devices = deviceLookup.availableCaptureDevices } - + deinit { #if os(iOS) Task { @MainActor in @@ -229,10 +271,21 @@ public final class Camera: ObservableObject { return false } + guard let cameraDevice = deviceLookup.camera( + devicePosition: devicePosition, + defaultsToUserPreferredCamera: isUserPreferredCamera + ) else { + return false + } + do { + await MainActor.run { + captureDevice = cameraDevice + } + try await captureService.configure( - cameraDevice: deviceLookup.captureDevices.first, - microphoneDevice: isAudioEnabled ? .default(for: .audio) : nil, + cameraDevice: cameraDevice, + microphoneDevice: isAudioEnabled ? deviceLookup.microphone() : nil, sessionPreset: sessionPreset, previewLayer: previewLayer, recordingSettings: recordingSettings @@ -288,8 +341,11 @@ public final class Camera: ObservableObject { private func captureDeviceDidChange(_ newCaptureDevice: AVCaptureDevice) { Task { do { - try await captureService.setCaptureDevice(newCaptureDevice) - logger.debug("Using capture device: \(newCaptureDevice.localizedName)") + logger.debug("Setting capture device: \(newCaptureDevice.localizedName)") + try await captureService.setCaptureDevice( + newCaptureDevice, + updateUserPreferredCamera: isUserPreferredCamera + ) } catch { logger.error("Error updating capture device: \(error)") } From 703cc6c7f2c1723704020a7b98eb1db87035e639 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Mon, 6 Jan 2025 13:05:16 +0100 Subject: [PATCH 14/15] Update example --- CaptureExample/CaptureExample.xcodeproj/project.pbxproj | 2 ++ CaptureExample/CaptureExample/ContentView.swift | 8 ++------ .../Extensions/AppKit/NSImage+AVCapturePhoto.swift | 6 ++++++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CaptureExample/CaptureExample.xcodeproj/project.pbxproj b/CaptureExample/CaptureExample.xcodeproj/project.pbxproj index 366b3e3..8b8051c 100644 --- a/CaptureExample/CaptureExample.xcodeproj/project.pbxproj +++ b/CaptureExample/CaptureExample.xcodeproj/project.pbxproj @@ -309,6 +309,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 13.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.luni.CaptureExample; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -342,6 +343,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 13.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.luni.CaptureExample; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/CaptureExample/CaptureExample/ContentView.swift b/CaptureExample/CaptureExample/ContentView.swift index d80a2dc..e3bc481 100644 --- a/CaptureExample/CaptureExample/ContentView.swift +++ b/CaptureExample/CaptureExample/ContentView.swift @@ -38,12 +38,6 @@ struct ContentView: View { // Environment values override: // - recordingAudioSettings // - recordingVideoSettings - .environment(\.recordingVideoSettings, VideoSettings( - codec: .h264, - width: 200, - height: 200, - scalingMode: .resizeAspectFill - )) .overlay(alignment: .topTrailing) { cameraDevicePicker } @@ -55,10 +49,12 @@ struct ContentView: View { .sheet(item: $capturedImage) { image in #if os(iOS) Image(uiImage: image) + .resizable() .scaledToFit() .ignoresSafeArea() #elseif os(macOS) Image(nsImage: image) + .resizable() .scaledToFit() .ignoresSafeArea() #endif diff --git a/Sources/Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift b/Sources/Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift index 0bff5a1..d23b33f 100644 --- a/Sources/Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift +++ b/Sources/Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift @@ -11,6 +11,12 @@ import AppKit extension NSImage { public convenience init?(photo: AVCapturePhoto) { + if let cgImage = photo.cgImageRepresentation() { + let imageSize = NSSize(width: cgImage.width, height: cgImage.height) + self.init(cgImage: cgImage, size: imageSize) + return + } + // Get the pixel buffer from the AVCapturePhoto guard let pixelBuffer = photo.pixelBuffer else { return nil From 61f8efb8040cb5946a92b0ae29a36f5a31697fa7 Mon Sep 17 00:00:00 2001 From: Quentin Fasquel Date: Mon, 6 Jan 2025 13:33:24 +0100 Subject: [PATCH 15/15] Migrating to Swift 6.0 --- .../CaptureExample.xcodeproj/project.pbxproj | 4 +-- Package.swift | 2 +- .../Internal/AVCaptureVideoFileOutput.swift | 11 ++------ .../Internal/CaptureVideoPreview.swift | 3 ++- Sources/Capture/Internal/MovieCapture.swift | 2 +- Sources/Capture/Internal/PhotoCapture.swift | 2 +- Sources/Capture/Public/Camera.swift | 27 ++++++++++--------- 7 files changed, 24 insertions(+), 27 deletions(-) diff --git a/CaptureExample/CaptureExample.xcodeproj/project.pbxproj b/CaptureExample/CaptureExample.xcodeproj/project.pbxproj index 8b8051c..9e8920c 100644 --- a/CaptureExample/CaptureExample.xcodeproj/project.pbxproj +++ b/CaptureExample/CaptureExample.xcodeproj/project.pbxproj @@ -316,7 +316,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -350,7 +350,7 @@ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/Package.swift b/Package.swift index 945fad6..8c5a74d 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift b/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift index b602401..5a589de 100644 --- a/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift +++ b/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift @@ -7,17 +7,10 @@ @preconcurrency import AVFoundation -protocol CaptureRecording: NSObject { - func stopRecording() -} - -extension AVCaptureMovieFileOutput: CaptureRecording { -} - /// /// A replacement for `AVCaptureMovieFileOutput` /// -final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { +final class AVCaptureVideoFileOutput: NSObject, @unchecked Sendable { private let outputQueue = DispatchQueue(label: "\(bundleIdentifier).CaptureVideoFileOutput") let audioDataOutput = AVCaptureAudioDataOutput() @@ -104,7 +97,7 @@ final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { } isRecording = true - + DispatchQueue.main.async { [self] in delegate?.videoFileOutput( self, diff --git a/Sources/Capture/Internal/CaptureVideoPreview.swift b/Sources/Capture/Internal/CaptureVideoPreview.swift index b48ea38..57fcdd2 100644 --- a/Sources/Capture/Internal/CaptureVideoPreview.swift +++ b/Sources/Capture/Internal/CaptureVideoPreview.swift @@ -131,7 +131,8 @@ final class AVCaptureVideoPreviewView: UIView { extension CaptureVideoPreview { - class Coordinator: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { + @MainActor + class Coordinator: NSObject, @preconcurrency AVCaptureVideoDataOutputSampleBufferDelegate { let previewOutput = AVCaptureVideoDataOutput() let dispatchQueue = DispatchQueue(label: "\(bundleIdentifier).CaptureVideoPreview") var view: AVCaptureVideoPreviewView? diff --git a/Sources/Capture/Internal/MovieCapture.swift b/Sources/Capture/Internal/MovieCapture.swift index d9b61e3..f25fcbd 100644 --- a/Sources/Capture/Internal/MovieCapture.swift +++ b/Sources/Capture/Internal/MovieCapture.swift @@ -7,7 +7,7 @@ @preconcurrency import AVFoundation -final class MovieCapture: NSObject { +final class MovieCapture: NSObject, @unchecked Sendable { private(set) var movieOutput: MovieCaptureOutput? private var recordingSettings: RecordingSettings? private var recordingContinuation: CheckedContinuation? diff --git a/Sources/Capture/Internal/PhotoCapture.swift b/Sources/Capture/Internal/PhotoCapture.swift index 52fd110..83e83ef 100644 --- a/Sources/Capture/Internal/PhotoCapture.swift +++ b/Sources/Capture/Internal/PhotoCapture.swift @@ -7,7 +7,7 @@ @preconcurrency import AVFoundation -final class PhotoCapture: NSObject { +final class PhotoCapture: NSObject, @unchecked Sendable { let capturePhotoOutput: AVCapturePhotoOutput = AVCapturePhotoOutput() private var captureContinuation: CheckedContinuation? diff --git a/Sources/Capture/Public/Camera.swift b/Sources/Capture/Public/Camera.swift index 1f4f0cc..dc9ead2 100644 --- a/Sources/Capture/Public/Camera.swift +++ b/Sources/Capture/Public/Camera.swift @@ -16,7 +16,7 @@ public enum CameraError: Error { case missingVideoOutput } -public final class Camera: ObservableObject { +public final class Camera: ObservableObject, @unchecked Sendable { public static let `default` = Camera(.back) @@ -296,19 +296,17 @@ public final class Camera: ObservableObject { } } - private func updateCaptureOutputOrientation() { + private func updateCaptureOutputOrientation() async { #if os(iOS) - var deviceOrientation = UIDevice.current.orientation + var deviceOrientation = await UIDevice.current.orientation logger.debug("Updating capture outputs video orientation: \(String(describing: deviceOrientation))") if case .unknown = deviceOrientation { // Fix device orientation using's screen coordinate space - deviceOrientation = UIScreen.main.deviceOrientation + deviceOrientation = await UIScreen.main.deviceOrientation } - Task { - let videoOrientation = AVCaptureVideoOrientation(deviceOrientation) - await captureService.updateCaptureOutputOrientation(videoOrientation) - } + let videoOrientation = AVCaptureVideoOrientation(deviceOrientation) + await captureService.updateCaptureOutputOrientation(videoOrientation) #elseif os(macOS) #endif } @@ -317,21 +315,26 @@ public final class Camera: ObservableObject { #if os(iOS) private var deviceOrientationObserver: NSObjectProtocol? - @MainActor private func registerDeviceOrientationObserver() { + @MainActor + private func registerDeviceOrientationObserver() { deviceOrientationObserver = NotificationCenter.default.addObserver( forName: UIDevice.orientationDidChangeNotification, object: UIDevice.current, queue: .main ) { [weak self] notification in - self?.updateCaptureOutputOrientation() + Task { @MainActor in + await self?.updateCaptureOutputOrientation() + } } } - @MainActor private static func startObservingDeviceOrientation() { + @MainActor + private static func startObservingDeviceOrientation() { UIDevice.current.beginGeneratingDeviceOrientationNotifications() } - @MainActor private static func stopObservingDeviceOrientation() { + @MainActor + private static func stopObservingDeviceOrientation() { UIDevice.current.endGeneratingDeviceOrientationNotifications() } #endif