-
Notifications
You must be signed in to change notification settings - Fork 1.3k
fix(ios): prevent crash when capturePhoto has no active video connection #3637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry but this PR is way too many changes/lines for that simple of a fix. I think all you'd need to do is add a simple There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hey @mrousavy, Is this the correct way ? guard photoOutput.connection(with: .video) != nil else { |
||
|
|
||
| // 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 | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is AI - why did you (or your AI) remove/change those comments?