From 2a582f0ac0feda5a6067757e576453087a12feee Mon Sep 17 00:00:00 2001 From: Shabbir Vijapura Date: Tue, 16 Jul 2024 12:38:11 -0400 Subject: [PATCH 1/6] Allow saving video to custom path --- Sources/CameraManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CameraManager.swift b/Sources/CameraManager.swift index a568cea..1c61a09 100644 --- a/Sources/CameraManager.swift +++ b/Sources/CameraManager.swift @@ -740,7 +740,7 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest /** Starts recording a video with or without voice as in the session preset. */ - open func startRecordingVideo() { + open func startRecordingVideo(toURL url: URL? = nil) { guard cameraOutputMode != .stillImage else { _show(NSLocalizedString("Capture session output still image", comment: ""), message: NSLocalizedString("I can only take pictures", comment: "")) return @@ -772,7 +772,7 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest _updateIlluminationMode(flashMode) - videoOutput.startRecording(to: _tempFilePath(), recordingDelegate: self) + videoOutput.startRecording(to: url ?? _tempFilePath(), recordingDelegate: self) } /** From f96e2cb946c03cefbee22ff38ccca0975ccb3cbe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Feb 2026 05:49:42 +0000 Subject: [PATCH 2/6] Add support for 0.5x ultra-wide zoom level Changed minimum zoom level from 1.0x to 0.5x to support newer cameras with ultra-wide lenses. Updated documentation to reflect the new zoom range (0.5x to device maximum). https://claude.ai/code/session_01KpFgCpFkFy6d6S2V73DQqm --- README.md | 9 ++++++++- Sources/CameraManager.swift | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 62ffd04..229c911 100755 --- a/README.md +++ b/README.md @@ -88,13 +88,20 @@ cameraManager.stopVideoRecording({ (videoURL, recordError) -> Void in }) ``` -To zoom in manually: +To zoom manually: ```swift +// Zoom in let zoomScale = CGFloat(2.0) cameraManager.zoom(zoomScale) + +// Zoom out (ultra-wide, 0.5x - supported on newer cameras) +let zoomScale = CGFloat(0.5) +cameraManager.zoom(zoomScale) ``` +The zoom range is 0.5x to the device's maximum zoom factor. Ultra-wide zoom (0.5x) is available on newer camera devices that support it. + ### Properties You can set input device to front or back camera. `(Default: .Back)` diff --git a/Sources/CameraManager.swift b/Sources/CameraManager.swift index 1c61a09..b50ac81 100644 --- a/Sources/CameraManager.swift +++ b/Sources/CameraManager.swift @@ -971,7 +971,7 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest let captureDevice = device try captureDevice?.lockForConfiguration() - zoomScale = max(1.0, min(beginZoomScale * scale, maxZoomScale)) + zoomScale = max(0.5, min(beginZoomScale * scale, maxZoomScale)) captureDevice?.videoZoomFactor = zoomScale From fc7b5af8f0f271ad55604329b4bd324bb82c8387 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Feb 2026 06:00:49 +0000 Subject: [PATCH 3/6] Use device-specific minimum zoom instead of hardcoded value Changed zoom implementation to dynamically detect minimum zoom level using minAvailableVideoZoomFactor from the camera device. This ensures ultra-wide cameras are properly supported only on devices that have them. - Added minZoomScale property to track device minimum zoom - Updated _setupMaxZoomScale() to set both min and max zoom limits - Changed _zoom() to use dynamic minZoomScale instead of hardcoded 0.5 - Updated README to clarify that zoom range is device-dependent On multi-camera devices (iPhone 11+), the minimum zoom factor is 1.0, which automatically uses the ultra-wide lens. This is the correct iOS behavior rather than using an arbitrary 0.5x value. https://claude.ai/code/session_01KpFgCpFkFy6d6S2V73DQqm --- README.md | 6 +++--- Sources/CameraManager.swift | 11 ++++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 229c911..eb7c131 100755 --- a/README.md +++ b/README.md @@ -95,12 +95,12 @@ To zoom manually: let zoomScale = CGFloat(2.0) cameraManager.zoom(zoomScale) -// Zoom out (ultra-wide, 0.5x - supported on newer cameras) -let zoomScale = CGFloat(0.5) +// Zoom out (uses ultra-wide camera on supported devices) +let zoomScale = CGFloat(1.0) cameraManager.zoom(zoomScale) ``` -The zoom range is 0.5x to the device's maximum zoom factor. Ultra-wide zoom (0.5x) is available on newer camera devices that support it. +The zoom range is automatically determined by the device's camera capabilities using `minAvailableVideoZoomFactor` and `maxAvailableVideoZoomFactor`. On devices with ultra-wide cameras (iPhone 11+), setting zoom to 1.0 will use the ultra-wide lens. ### Properties diff --git a/Sources/CameraManager.swift b/Sources/CameraManager.swift index b50ac81..26a18bf 100644 --- a/Sources/CameraManager.swift +++ b/Sources/CameraManager.swift @@ -366,6 +366,7 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest fileprivate var zoomScale = CGFloat(1.0) fileprivate var beginZoomScale = CGFloat(1.0) fileprivate var maxZoomScale = CGFloat(1.0) + fileprivate var minZoomScale = CGFloat(1.0) fileprivate func _tempFilePath() -> URL { let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("tempMovie\(Date().timeIntervalSince1970)").appendingPathExtension("mp4") @@ -971,7 +972,7 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest let captureDevice = device try captureDevice?.lockForConfiguration() - zoomScale = max(0.5, min(beginZoomScale * scale, maxZoomScale)) + zoomScale = max(minZoomScale, min(beginZoomScale * scale, maxZoomScale)) captureDevice?.videoZoomFactor = zoomScale @@ -1541,15 +1542,19 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest fileprivate func _setupMaxZoomScale() { var maxZoom = CGFloat(1.0) + var minZoom = CGFloat(1.0) beginZoomScale = CGFloat(1.0) - + if cameraDevice == .back, let backCameraDevice = backCameraDevice { maxZoom = backCameraDevice.activeFormat.videoMaxZoomFactor + minZoom = backCameraDevice.minAvailableVideoZoomFactor } else if cameraDevice == .front, let frontCameraDevice = frontCameraDevice { maxZoom = frontCameraDevice.activeFormat.videoMaxZoomFactor + minZoom = frontCameraDevice.minAvailableVideoZoomFactor } - + maxZoomScale = maxZoom + minZoomScale = minZoom } fileprivate func _checkIfCameraIsAvailable() -> CameraState { From 013ffb0498506eb282e9b384e138905ad9f6c6fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 03:44:34 +0000 Subject: [PATCH 4/6] Fix ultra-wide camera support by using multi-camera virtual devices The previous implementation only selected the first back camera device, which would return the basic wide-angle camera without ultra-wide support. Changed camera discovery to use AVCaptureDevice.DiscoverySession with priority order: 1. .builtInTripleCamera (iPhone 11 Pro+, supports ultra-wide) 2. .builtInDualWideCamera (iPhone 11/13/14, supports ultra-wide) 3. .builtInDualCamera (older dual camera models) 4. .builtInWideAngleCamera (fallback for single camera devices) This ensures that on devices with ultra-wide cameras (iPhone 11+), the multi-camera virtual device is selected, which allows iOS to automatically switch to the ultra-wide lens when zoom is set to 1.0. Updated README to explain which device types are now supported and how ultra-wide camera switching works. https://claude.ai/code/session_01KpFgCpFkFy6d6S2V73DQqm --- README.md | 9 ++++++++- Sources/CameraManager.swift | 21 ++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eb7c131..53afac5 100755 --- a/README.md +++ b/README.md @@ -100,7 +100,14 @@ let zoomScale = CGFloat(1.0) cameraManager.zoom(zoomScale) ``` -The zoom range is automatically determined by the device's camera capabilities using `minAvailableVideoZoomFactor` and `maxAvailableVideoZoomFactor`. On devices with ultra-wide cameras (iPhone 11+), setting zoom to 1.0 will use the ultra-wide lens. +The zoom range is automatically determined by the device's camera capabilities using `minAvailableVideoZoomFactor` and `maxAvailableVideoZoomFactor`. + +**Ultra-Wide Camera Support:** +The library automatically detects and uses multi-camera virtual devices (iPhone 11+) that include ultra-wide lenses: +- iPhone 11 Pro/12 Pro/13 Pro/14 Pro: `.builtInTripleCamera` (ultra-wide + wide + telephoto) +- iPhone 11/13/14: `.builtInDualWideCamera` (ultra-wide + wide) + +On these devices, setting zoom to 1.0 automatically uses the ultra-wide lens. iOS handles the camera switching seamlessly as you zoom in and out. ### Properties diff --git a/Sources/CameraManager.swift b/Sources/CameraManager.swift index 26a18bf..0c50b12 100644 --- a/Sources/CameraManager.swift +++ b/Sources/CameraManager.swift @@ -348,7 +348,26 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest }() fileprivate lazy var backCameraDevice: AVCaptureDevice? = { - AVCaptureDevice.videoDevices.filter { $0.position == .back }.first + // Try to get multi-camera virtual devices first (supports ultra-wide) + var deviceTypes: [AVCaptureDevice.DeviceType] = [.builtInWideAngleCamera] + + if #available(iOS 13.0, *) { + // Prefer virtual devices that support multiple cameras including ultra-wide + deviceTypes = [ + .builtInTripleCamera, // iPhone 11 Pro, 12 Pro, 13 Pro, 14 Pro, etc. + .builtInDualWideCamera, // iPhone 11, 13, 14 (wide + ultra-wide) + .builtInDualCamera, // iPhone 7 Plus, 8 Plus, X, XS (wide + telephoto) + .builtInWideAngleCamera // Fallback for single camera devices + ] + } + + let discoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: deviceTypes, + mediaType: .video, + position: .back + ) + + return discoverySession.devices.first }() fileprivate lazy var mic: AVCaptureDevice? = { From f3ae9678c2d6c0ee4e82ee0b021535b13d8e23b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 04:06:04 +0000 Subject: [PATCH 5/6] Set default zoom to wide-angle camera (1x) on multi-camera devices On multi-camera devices with ultra-wide cameras, the default zoom now starts at the first switchover point (e.g., 2.0) which represents the main wide-angle camera at "1x" zoom - matching the default Camera app behavior. Changes: - Detect virtualDeviceSwitchOverVideoZoomFactors on iOS 13+ - Set initial zoom to first switchover point for multi-camera devices - Change _zoom(0) to _zoom(1) to maintain the default zoom level - Fallback to minimum zoom for single-camera devices This fixes the issue where the camera would start at the ultra-wide lens (0.5x zoom) instead of the expected wide-angle lens (1x zoom). https://claude.ai/code/session_01KpFgCpFkFy6d6S2V73DQqm --- Sources/CameraManager.swift | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/Sources/CameraManager.swift b/Sources/CameraManager.swift index 0c50b12..3e226cc 100644 --- a/Sources/CameraManager.swift +++ b/Sources/CameraManager.swift @@ -260,7 +260,7 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest _updateCameraDevice(cameraDevice) _updateIlluminationMode(flashMode) _setupMaxZoomScale() - _zoom(0) + _zoom(1) _orientationChanged() } } @@ -292,7 +292,7 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest _setupOutputMode(cameraOutputMode, oldCameraOutputMode: oldValue) } _setupMaxZoomScale() - _zoom(0) + _zoom(1) } } } @@ -1562,18 +1562,40 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest fileprivate func _setupMaxZoomScale() { var maxZoom = CGFloat(1.0) var minZoom = CGFloat(1.0) + var defaultZoom = CGFloat(1.0) beginZoomScale = CGFloat(1.0) if cameraDevice == .back, let backCameraDevice = backCameraDevice { maxZoom = backCameraDevice.activeFormat.videoMaxZoomFactor minZoom = backCameraDevice.minAvailableVideoZoomFactor + + // For multi-camera devices, start at the first switchover point (main wide-angle camera) + // This represents "1x" zoom in the Camera app + if #available(iOS 13.0, *), + let switchOverFactors = backCameraDevice.virtualDeviceSwitchOverVideoZoomFactors as? [CGFloat], + let firstSwitchOver = switchOverFactors.first { + defaultZoom = firstSwitchOver + } else { + defaultZoom = minZoom + } } else if cameraDevice == .front, let frontCameraDevice = frontCameraDevice { maxZoom = frontCameraDevice.activeFormat.videoMaxZoomFactor minZoom = frontCameraDevice.minAvailableVideoZoomFactor + + // For multi-camera front devices, start at the first switchover point + if #available(iOS 13.0, *), + let switchOverFactors = frontCameraDevice.virtualDeviceSwitchOverVideoZoomFactors as? [CGFloat], + let firstSwitchOver = switchOverFactors.first { + defaultZoom = firstSwitchOver + } else { + defaultZoom = minZoom + } } maxZoomScale = maxZoom minZoomScale = minZoom + zoomScale = defaultZoom + beginZoomScale = defaultZoom } fileprivate func _checkIfCameraIsAvailable() -> CameraState { From fd23c39f62027ab54538c3a087669688f30c2529 Mon Sep 17 00:00:00 2001 From: Shabbir Vijapura Date: Tue, 31 Mar 2026 12:39:15 -0700 Subject: [PATCH 6/6] Fix flash by migrating from deprecated AVCaptureStillImageOutput to AVCapturePhotoOutput AVCaptureDevice.flashMode was deprecated in iOS 10 and is a no-op on modern iOS. Flash for still photos must be set via AVCapturePhotoSettings at capture time. - Replace stillImageOutput (AVCaptureStillImageOutput) with photoOutput (AVCapturePhotoOutput) - Apply flashMode via AVCapturePhotoSettings in capturePictureDataWithCompletion - Add AVCapturePhotoCaptureDelegate to handle photo capture result - Remove _updateFlash (was setting deprecated AVCaptureDevice.flashMode) - Torch mode for video is unchanged via _updateTorch Co-Authored-By: Claude Sonnet 4.6 --- Sources/CameraManager.swift | 123 ++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 67 deletions(-) diff --git a/Sources/CameraManager.swift b/Sources/CameraManager.swift index 3e226cc..4844798 100644 --- a/Sources/CameraManager.swift +++ b/Sources/CameraManager.swift @@ -374,7 +374,8 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest AVCaptureDevice.default(for: AVMediaType.audio) }() - fileprivate var stillImageOutput: AVCaptureStillImageOutput? + fileprivate var photoOutput: AVCapturePhotoOutput? + fileprivate var photoCaptureCompletion: ((CaptureResult) -> Void)? fileprivate var movieOutput: AVCaptureMovieFileOutput? fileprivate var previewLayer: AVCaptureVideoPreviewLayer? fileprivate var library: PHPhotoLibrary? @@ -518,7 +519,8 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest frontCameraDevice = nil backCameraDevice = nil mic = nil - stillImageOutput = nil + photoOutput = nil + photoCaptureCompletion = nil movieOutput = nil animateCameraDeviceChange = oldAnimationValue } @@ -708,39 +710,27 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest return } - _updateIlluminationMode(flashMode) - sessionQueue.async { - let stillImageOutput = self._getStillImageOutput() - if let connection = stillImageOutput.connection(with: AVMediaType.video), - connection.isEnabled { - if self.cameraDevice == CameraDevice.front, connection.isVideoMirroringSupported, - self.shouldFlipFrontCameraImage { - connection.isVideoMirrored = true - } - if connection.isVideoOrientationSupported { - connection.videoOrientation = self._currentCaptureVideoOrientation() - } - - stillImageOutput.captureStillImageAsynchronously(from: connection, completionHandler: { [weak self] sample, error in - - if let error = error { - self?._show(NSLocalizedString("Error", comment: ""), message: error.localizedDescription) - imageCompletion(.failure(error)) - return - } - - guard let sample = sample else { imageCompletion(.failure(CaptureError.noSampleBuffer)); return } - if let imageData = AVCaptureStillImageOutput.jpegStillImageNSDataRepresentation(sample) { - imageCompletion(CaptureResult(imageData)) - } else { - imageCompletion(.failure(CaptureError.noImageData)) - } - - }) - } else { + let photoOutput = self._getPhotoOutput() + guard let connection = photoOutput.connection(with: AVMediaType.video), connection.isEnabled else { imageCompletion(.failure(CaptureError.noVideoConnection)) + return + } + if self.cameraDevice == CameraDevice.front, connection.isVideoMirroringSupported, + self.shouldFlipFrontCameraImage { + connection.isVideoMirrored = true + } + if connection.isVideoOrientationSupported { + connection.videoOrientation = self._currentCaptureVideoOrientation() } + + let settings = AVCapturePhotoSettings() + if self.cameraDevice == .back, photoOutput.supportedFlashModes.contains(AVCaptureDevice.FlashMode(rawValue: self.flashMode.rawValue) ?? .off) { + settings.flashMode = AVCaptureDevice.FlashMode(rawValue: self.flashMode.rawValue) ?? .off + } + + self.photoCaptureCompletion = imageCompletion + photoOutput.capturePhoto(with: settings, delegate: self) } } @@ -1303,20 +1293,20 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest } } - fileprivate func _getStillImageOutput() -> AVCaptureStillImageOutput { - if let stillImageOutput = stillImageOutput, let connection = stillImageOutput.connection(with: AVMediaType.video), + fileprivate func _getPhotoOutput() -> AVCapturePhotoOutput { + if let photoOutput = photoOutput, let connection = photoOutput.connection(with: AVMediaType.video), connection.isActive { - return stillImageOutput + return photoOutput } - let newStillImageOutput = AVCaptureStillImageOutput() - stillImageOutput = newStillImageOutput + let newPhotoOutput = AVCapturePhotoOutput() + photoOutput = newPhotoOutput if let captureSession = captureSession, - captureSession.canAddOutput(newStillImageOutput) { + captureSession.canAddOutput(newPhotoOutput) { captureSession.beginConfiguration() - captureSession.addOutput(newStillImageOutput) + captureSession.addOutput(newPhotoOutput) captureSession.commitConfiguration() } - return newStillImageOutput + return newPhotoOutput } @objc fileprivate func _orientationChanged() { @@ -1324,7 +1314,7 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest switch cameraOutputMode { case .stillImage: - currentConnection = stillImageOutput?.connection(with: AVMediaType.video) + currentConnection = photoOutput?.connection(with: AVMediaType.video) case .videoOnly, .videoWithMic: currentConnection = _getMovieOutput().connection(with: AVMediaType.video) if let location = locationManager?.latestLocation { @@ -1624,8 +1614,8 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest // remove current setting switch cameraOutputToRemove { case .stillImage: - if let validStillImageOutput = stillImageOutput { - captureSession?.removeOutput(validStillImageOutput) + if let validPhotoOutput = photoOutput { + captureSession?.removeOutput(validPhotoOutput) } case .videoOnly, .videoWithMic: if let validMovieOutput = movieOutput { @@ -1642,10 +1632,10 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest // configure new devices switch newCameraOutputMode { case .stillImage: - let validStillImageOutput = _getStillImageOutput() + let validPhotoOutput = _getPhotoOutput() if let captureSession = captureSession, - captureSession.canAddOutput(validStillImageOutput) { - captureSession.addOutput(validStillImageOutput) + captureSession.canAddOutput(validPhotoOutput) { + captureSession.addOutput(validPhotoOutput) } case .videoOnly, .videoWithMic: let videoMovieOutput = _getMovieOutput() @@ -1665,8 +1655,8 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest } fileprivate func _setupOutputs() { - if stillImageOutput == nil { - stillImageOutput = AVCaptureStillImageOutput() + if photoOutput == nil { + photoOutput = AVCapturePhotoOutput() } if movieOutput == nil { movieOutput = _getMovieOutput() @@ -1868,11 +1858,10 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest fileprivate func _updateIlluminationMode(_ mode: CameraFlashMode) { if cameraOutputMode != .stillImage { _updateTorch(mode) - } else { - _updateFlash(mode) } + // For stillImage, flash is applied via AVCapturePhotoSettings at capture time } - + fileprivate func _updateTorch(_: CameraFlashMode) { captureSession?.beginConfiguration() defer { captureSession?.commitConfiguration() } @@ -1892,22 +1881,6 @@ open class CameraManager: NSObject, AVCaptureFileOutputRecordingDelegate, UIGest } } - fileprivate func _updateFlash(_ flashMode: CameraFlashMode) { - captureSession?.beginConfiguration() - defer { captureSession?.commitConfiguration() } - for captureDevice in AVCaptureDevice.videoDevices { - guard let avFlashMode = AVCaptureDevice.FlashMode(rawValue: flashMode.rawValue) else { continue } - if captureDevice.isFlashModeSupported(avFlashMode) { - do { - try captureDevice.lockForConfiguration() - captureDevice.flashMode = avFlashMode - captureDevice.unlockForConfiguration() - } catch { - return - } - } - } - } fileprivate func _performShutterAnimation(_ completion: (() -> Void)?) { if let validPreviewLayer = previewLayer { @@ -2163,6 +2136,22 @@ extension PHPhotoLibrary { } } +extension CameraManager: AVCapturePhotoCaptureDelegate { + public func photoOutput(_: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + let completion = photoCaptureCompletion + photoCaptureCompletion = nil + if let error = error { + completion?(.failure(error)) + return + } + guard let imageData = photo.fileDataRepresentation() else { + completion?(.failure(CaptureError.noImageData)) + return + } + completion?(CaptureResult(imageData)) + } +} + extension CameraManager: AVCaptureMetadataOutputObjectsDelegate { /** Called when a QR code is detected.