diff --git a/CaptureExample/CaptureExample.xcodeproj/project.pbxproj b/CaptureExample/CaptureExample.xcodeproj/project.pbxproj index 366b3e3..9e8920c 100644 --- a/CaptureExample/CaptureExample.xcodeproj/project.pbxproj +++ b/CaptureExample/CaptureExample.xcodeproj/project.pbxproj @@ -309,13 +309,14 @@ "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 13.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.luni.CaptureExample; PRODUCT_NAME = "$(TARGET_NAME)"; 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; @@ -342,13 +343,14 @@ "$(inherited)", "@executable_path/Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 13.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.luni.CaptureExample; PRODUCT_NAME = "$(TARGET_NAME)"; 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/CaptureExample/CaptureExample/ContentView.swift b/CaptureExample/CaptureExample/ContentView.swift index 388981b..e3bc481 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 @@ -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 @@ -87,10 +83,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() } } @@ -130,13 +126,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/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/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..d23b33f 100644 --- a/Sources/Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift +++ b/Sources/Capture/Extensions/AppKit/NSImage+AVCapturePhoto.swift @@ -6,11 +6,17 @@ // #if os(macOS) -import AVFoundation +@preconcurrency import AVFoundation 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 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/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 933b7d1..5a589de 100644 --- a/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift +++ b/Sources/Capture/Internal/AVCaptureVideoFileOutput.swift @@ -5,23 +5,16 @@ // Created by Quentin Fasquel on 17/12/2023. // -import AVFoundation - -protocol CaptureRecording: NSObject { - func stopRecording() -} - -extension AVCaptureMovieFileOutput: CaptureRecording { -} +@preconcurrency import AVFoundation /// /// A replacement for `AVCaptureMovieFileOutput` /// -final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { +final class AVCaptureVideoFileOutput: NSObject, @unchecked Sendable { 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 +30,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,23 +44,10 @@ final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { // MARK: - Recording - public private(set) var audioSettings = AudioSettings( - formatID: kAudioFormatMPEG4AAC, - sampleRate: 44100, - numberOfChannels: 2, - audioFileType: kAudioFileMPEG4Type, -// encoderAudioQuality: .high, - encoderBitRate: .bitRate(128000) - ) - - public private(set) var videoSettings = VideoSettings( - codec: .h264, - width: 0, - height: 0, - scalingMode: .resizeAspectFill - ) - - public func configureOutput(audioSettings: AudioSettings? = nil, videoSettings: VideoSettings) { + 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 +59,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 @@ -115,7 +97,7 @@ final class AVCaptureVideoFileOutput: NSObject, CaptureRecording { } isRecording = true - + DispatchQueue.main.async { [self] in delegate?.videoFileOutput( self, 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..4497a11 100644 --- a/Sources/Capture/Internal/AVFileType.swift +++ b/Sources/Capture/Internal/AVFileType.swift @@ -5,7 +5,11 @@ // Created by Quentin Fasquel on 02/01/2024. // -import AVFoundation +@preconcurrency import AVFoundation + +extension AVFileType { + var utType: UTType { UTType(rawValue)! } +} func fileType(for videoCodec: AVVideoCodecType) -> AVFileType? { switch videoCodec { diff --git a/Sources/Capture/Public/Camera+Extensions.swift b/Sources/Capture/Internal/Camera+Extensions.swift similarity index 51% rename from Sources/Capture/Public/Camera+Extensions.swift rename to Sources/Capture/Internal/Camera+Extensions.swift index 020992e..6437ea2 100644 --- a/Sources/Capture/Public/Camera+Extensions.swift +++ b/Sources/Capture/Internal/Camera+Extensions.swift @@ -5,23 +5,35 @@ // Created by Quentin Fasquel on 17/12/2023. // +@preconcurrency import AVFoundation +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/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/CaptureDeviceLookup.swift b/Sources/Capture/Internal/CaptureDeviceLookup.swift new file mode 100644 index 0000000..1c23af0 --- /dev/null +++ b/Sources/Capture/Internal/CaptureDeviceLookup.swift @@ -0,0 +1,110 @@ +// +// 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) + } + + devices += backCaptureDevices.filter { $0 != defaultDevice } + devices += frontCaptureDevices.filter { $0 != defaultDevice } +#endif + return devices + } + + 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 new file mode 100644 index 0000000..18bc48d --- /dev/null +++ b/Sources/Capture/Internal/CaptureService.swift @@ -0,0 +1,182 @@ +// +// 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 { + + 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, updateUserPreferredCamera: Bool) throws { + guard isCapturedSessionConfigured else { + return + } + + captureSession.beginConfiguration() + defer { captureSession.commitConfiguration() } + + if let captureVideoInput { + captureSession.removeInput(captureVideoInput) + } + + try configureCaptureVideoInput(captureDevice, microphoneDevice: captureAudioInput?.device) + configureCaptureOutputMirroring() + + if updateUserPreferredCamera, #available(iOS 17.0, *) { + AVCaptureDevice.userPreferredCamera = captureDevice + } + } + + 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 captureAudioInput == nil, 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/CaptureVideoPreview.swift b/Sources/Capture/Internal/CaptureVideoPreview.swift index 7e0993e..57fcdd2 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 @@ -130,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/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/Internal/MovieCapture.swift b/Sources/Capture/Internal/MovieCapture.swift new file mode 100644 index 0000000..f25fcbd --- /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, @unchecked Sendable { + 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/Internal/PhotoCapture.swift b/Sources/Capture/Internal/PhotoCapture.swift new file mode 100644 index 0000000..83e83ef --- /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, @unchecked Sendable { + 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.swift b/Sources/Capture/Public/Camera.swift index 00760fb..dc9ead2 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 @@ -16,52 +16,34 @@ public enum CameraError: Error { case missingVideoOutput } -public final class Camera: NSObject, ObservableObject { +public final class Camera: ObservableObject, @unchecked Sendable { public static let `default` = Camera(.back) - let captureSession = AVCaptureSession() + private let captureService: CaptureService + private let deviceLookup = CaptureDeviceLookup() private let sessionQueue = DispatchQueue(label: "\(bundleIdentifier).Camera.Session") - private let sessionPreset: AVCaptureSession.Preset - - 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? - private var captureVideoFileOutput: AVCaptureVideoFileOutput? - - private var didStopRecording: ((Result) -> Void)? - private var didTakePicture: ((Result) -> Void)? // MARK: - Internal Properties - - var devicePosition: CameraPosition + + var sessionPreset: AVCaptureSession.Preset var recordingSettings: RecordingSettings? var isAudioEnabled: Bool + let isUserPreferredCamera: Bool // MARK: - Public API - public private(set) var previewLayer: AVCaptureVideoPreviewLayer + 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 deviceId: String = "" { + @Published public var captureDevice: AVCaptureDevice? { didSet { - if deviceId != captureDevice?.uniqueID { - captureDevice = devices.first(where: { $0.uniqueID == deviceId }) + devicePosition = captureDevice?.position ?? .unspecified + if oldValue != captureDevice, let captureDevice { + captureDeviceDidChange(captureDevice) } } } @@ -79,7 +61,8 @@ public final class Camera: NSObject, ObservableObject { self.init( position: position, preset: .high, - audioEnabled: audioEnabled + audioEnabled: audioEnabled, + userPreferredCamera: false ) } @@ -90,24 +73,62 @@ public final class Camera: NSObject, 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 - previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) - super.init() + isUserPreferredCamera = userPreferredCamera #if os(iOS) Task { @MainActor in registerDeviceOrientationObserver() } #endif - devices = availableCaptureDevices + devices = deviceLookup.availableCaptureDevices } - + deinit { #if os(iOS) Task { @MainActor in @@ -124,32 +145,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() + await Self.startObservingDeviceOrientation() } public func stop() { - guard isCaptureSessionConfigured else { - return + Task { + await Self.stopObservingDeviceOrientation() + await captureService.stopCaptureSession() } - - stopCaptureSession() } @MainActor @@ -163,6 +172,7 @@ public final class Camera: NSObject, ObservableObject { Task { await start() } } + @MainActor public func setCaptureDevice(_ device: AVCaptureDevice) { captureDevice = device } @@ -186,147 +196,37 @@ 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) - } } + @MainActor public func startRecording() { guard !isRecording else { return } - 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) - } - } + isRecording = true + Task { await captureService.startRecording() } } + @MainActor 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 } + return try await captureService.stopRecording() } + @MainActor 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) - } - } + return try await captureService.capturePhoto() } // 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))") @@ -364,228 +264,77 @@ public final class Camera: NSObject, ObservableObject { } } - // MARK: - Capture Session Configuration - - private var videoConnections: [AVCaptureConnection] { - captureSession.outputs.compactMap { $0.connection(with: .video) } - } + // MARK: - Capture Service Configuration - 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) + guard let cameraDevice = deviceLookup.camera( + devicePosition: devicePosition, + defaultsToUserPreferredCamera: isUserPreferredCamera + ) else { 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 = AVCapturePhotoOutput() - photoOutput.maxPhotoQualityPrioritization = .quality - if captureSession.canAddOutput(photoOutput) { - captureSession.addOutput(photoOutput) - capturePhotoOutput = 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() } - - 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 + do { + await MainActor.run { + captureDevice = cameraDevice } - let videoFileOutput = AVCaptureVideoFileOutput() - videoFileOutput.configureOutput( - audioSettings: recordingSettings.audio, - videoSettings: recordingSettings.video + try await captureService.configure( + cameraDevice: cameraDevice, + microphoneDevice: isAudioEnabled ? deviceLookup.microphone() : nil, + sessionPreset: sessionPreset, + previewLayer: previewLayer, + recordingSettings: recordingSettings ) - 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 moveFileOutput = AVCaptureMovieFileOutput() - if captureSession.canAddOutput(moveFileOutput) { - captureSession.addOutput(moveFileOutput) - captureMovieFileOutput = moveFileOutput - } else { - log(.cannotAddVideoFileOutput) - } - } - - updateCaptureOutputMirroring() - updateCaptureOutputOrientation() - } - - private func updateCaptureOutputMirroring() { - let isVideoMirrored = isUsingFrontCaptureDevice - videoConnections.forEach { videoConnection in - if videoConnection.isVideoMirroringSupported { - videoConnection.isVideoMirrored = isVideoMirrored - } + return true + } catch { + return false } } - - 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 } - videoConnections.forEach { videoConnection in - if videoConnection.isVideoOrientationSupported { - videoConnection.videoOrientation = AVCaptureVideoOrientation(deviceOrientation) - } - } + 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? - @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 @@ -593,81 +342,16 @@ 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) - } - } - -} - -// 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 { - - public func photoOutput( - _ output: AVCapturePhotoOutput, - didFinishProcessingPhoto photo: AVCapturePhoto, - error: Error? - ) { - if let error { - didTakePicture?(.failure(error)) - } else { - didTakePicture?(.success(photo)) + Task { + do { + logger.debug("Setting capture device: \(newCaptureDevice.localizedName)") + try await captureService.setCaptureDevice( + newCaptureDevice, + updateUserPreferredCamera: isUserPreferredCamera + ) + } catch { + logger.error("Error updating capture device: \(error)") + } } } } 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/CameraView.swift b/Sources/Capture/Public/CameraView.swift index 1b96cf8..6fcd85c 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 } @@ -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 ddd1cde..b8b273f 100644 --- a/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift +++ b/Sources/Capture/Public/EnvironmentValues/RecordVideoAction.swift @@ -7,18 +7,18 @@ import SwiftUI -public struct RecordVideoAction { +public struct RecordVideoAction: Sendable { - var start: () -> Void = { + var start: @Sendable () async -> 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") } public func startRecording() { - start() + Task { await start() } } public func stopRecording() { @@ -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..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") } @@ -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() } 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 diff --git a/Sources/Capture/Public/Settings/AudioSettings.swift b/Sources/Capture/Public/Settings/AudioSettings.swift index d5e29c9..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( @@ -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..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( @@ -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