diff --git a/package/ios/Core/CameraSession+Photo.swift b/package/ios/Core/CameraSession+Photo.swift index 12f5c07ce3..26353ebe6b 100644 --- a/package/ios/Core/CameraSession+Photo.swift +++ b/package/ios/Core/CameraSession+Photo.swift @@ -11,84 +11,170 @@ import Foundation extension CameraSession { /** - Takes a photo. + Captures a photo. `takePhoto` is only available if `photo={true}`. */ func takePhoto(options: TakePhotoOptions, promise: Promise) { - // Run on Camera Queue CameraQueues.cameraQueue.async { - // Get Photo Output configuration + // Validate configuration guard let configuration = self.configuration else { promise.reject(error: .session(.cameraNotReady)) return } guard configuration.photo != .disabled else { - // User needs to enable photo={true} promise.reject(error: .capture(.photoNotEnabled)) return } - // Check if Photo Output is available + // Validate outputs and inputs guard let photoOutput = self.photoOutput, let videoDeviceInput = self.videoDeviceInput else { - // Camera is not yet ready promise.reject(error: .session(.cameraNotReady)) return } VisionLogger.log(level: .info, message: "Capturing photo...") - // Create photo settings - let photoSettings = AVCapturePhotoSettings() - - // set photo resolution - if #available(iOS 16.0, *) { - photoSettings.maxPhotoDimensions = photoOutput.maxPhotoDimensions - } else { - photoSettings.isHighResolutionPhotoEnabled = photoOutput.isHighResolutionCaptureEnabled - } + // Ensure session and video connection are ready + self.ensureReadyToCapture(repairIfNeeded: true) { ready in + guard ready else { + VisionLogger.log(level: .error, message: "Photo capture failed: session or connection not ready.") + promise.reject(error: .session(.cameraNotReady)) + return + } - // depth data - photoSettings.isDepthDataDeliveryEnabled = photoOutput.isDepthDataDeliveryEnabled - if #available(iOS 12.0, *) { - photoSettings.isPortraitEffectsMatteDeliveryEnabled = photoOutput.isPortraitEffectsMatteDeliveryEnabled - } + // Build photo settings + let photoSettings = AVCapturePhotoSettings() - // quality prioritization - if #available(iOS 13.0, *) { - photoSettings.photoQualityPrioritization = photoOutput.maxPhotoQualityPrioritization - } + if #available(iOS 16.0, *) { + photoSettings.maxPhotoDimensions = photoOutput.maxPhotoDimensions + } else { + photoSettings.isHighResolutionPhotoEnabled = photoOutput.isHighResolutionCaptureEnabled + } - // red-eye reduction - photoSettings.isAutoRedEyeReductionEnabled = options.enableAutoRedEyeReduction + // Depth / Portrait / Distortion + if photoOutput.isDepthDataDeliverySupported { + photoSettings.isDepthDataDeliveryEnabled = photoOutput.isDepthDataDeliveryEnabled + } + if #available(iOS 12.0, *) { + photoSettings.isPortraitEffectsMatteDeliveryEnabled = photoOutput.isPortraitEffectsMatteDeliveryEnabled + } + if #available(iOS 13.0, *) { + photoSettings.photoQualityPrioritization = photoOutput.maxPhotoQualityPrioritization + } + photoSettings.isAutoRedEyeReductionEnabled = options.enableAutoRedEyeReduction + if #available(iOS 14.1, *), + photoOutput.isContentAwareDistortionCorrectionSupported { + photoSettings.isAutoContentAwareDistortionCorrectionEnabled = options.enableAutoDistortionCorrection + } - // distortion correction - if #available(iOS 14.1, *) { - photoSettings.isAutoContentAwareDistortionCorrectionEnabled = options.enableAutoDistortionCorrection - } + // Flash + if options.flash != .off { + guard videoDeviceInput.device.hasFlash else { + promise.reject(error: .capture(.flashNotAvailable)) + return + } + } + if videoDeviceInput.device.isFlashAvailable { + photoSettings.flashMode = options.flash.toFlashMode() + } - // flash - if options.flash != .off { - guard videoDeviceInput.device.hasFlash else { - // If user enabled flash, but the device doesn't have a flash, throw an error. - promise.reject(error: .capture(.flashNotAvailable)) + // Final connection guard + guard let videoConn = photoOutput.connection(with: .video), + videoConn.isEnabled, videoConn.isActive else { + VisionLogger.log(level: .error, message: "No active and enabled video connection for photo capture.") + promise.reject(error: .session(.cameraNotReady)) return } + + // Capture + let photoCaptureDelegate = PhotoCaptureDelegate( + promise: promise, + enableShutterSound: options.enableShutterSound, + metadataProvider: self.metadataProvider, + path: options.path, + cameraSessionDelegate: self.delegate + ) + photoOutput.capturePhoto(with: photoSettings, delegate: photoCaptureDelegate) + + // Prepare next settings + photoOutput.setPreparedPhotoSettingsArray([photoSettings], completionHandler: nil) } - if videoDeviceInput.device.isFlashAvailable { - photoSettings.flashMode = options.flash.toFlashMode() + } + } + + // MARK: - Readiness / Repair + + /// Ensures the session is running and the video connection is enabled + active. + /// Optionally tries to repair the output and waits shortly to handle race conditions. + private func ensureReadyToCapture(repairIfNeeded: Bool, + timeout: TimeInterval = 0.5, + poll: TimeInterval = 0.05, + completion: @escaping (Bool) -> Void) { + if isReadyNowActive() { + completion(true) + return + } + + var repairedOnce = false + let deadline = DispatchTime.now() + timeout + + func tick() { + if isReadyNowActive() { + completion(true) + return + } + if repairIfNeeded && !repairedOnce { + repairedOnce = repairPhotoConnectionIfNeeded() + } + if DispatchTime.now() < deadline { + CameraQueues.cameraQueue.asyncAfter(deadline: .now() + poll) { tick() } + } else { + completion(false) } + } + + tick() + } + + /// Checks if session is running and the video connection is enabled and active. + private func isReadyNowActive() -> Bool { + guard let photoOutput = self.photoOutput else { return false } + let running = self.captureSession.isRunning + guard running, let conn = photoOutput.connection(with: .video) else { return false } + return conn.isEnabled && conn.isActive + } - // Actually do the capture! - let photoCaptureDelegate = PhotoCaptureDelegate(promise: promise, - enableShutterSound: options.enableShutterSound, - metadataProvider: self.metadataProvider, - path: options.path, - cameraSessionDelegate: self.delegate) - photoOutput.capturePhoto(with: photoSettings, delegate: photoCaptureDelegate) + /// Repairs the photo output connection by re-adding it to the session. + @discardableResult + private func repairPhotoConnectionIfNeeded() -> Bool { + guard let photoOutput = self.photoOutput else { return false } + let session = self.captureSession - // Assume that `takePhoto` is always called with the same parameters, so prepare the next call too. - photoOutput.setPreparedPhotoSettingsArray([photoSettings], completionHandler: nil) + let conn = photoOutput.connection(with: .video) + if conn?.isEnabled == true && conn?.isActive == true { return false } + + VisionLogger.log(level: .info, message: "Repairing photo connection...") + + session.beginConfiguration() + + if session.canSetSessionPreset(.photo) { + session.sessionPreset = .photo + } + + if session.outputs.contains(photoOutput) { + session.removeOutput(photoOutput) } + if session.canAddOutput(photoOutput) { + session.addOutput(photoOutput) + } + + session.commitConfiguration() + + if !session.isRunning { + session.startRunning() + } + + return true } }