@capgo/camera-preview 7.4.0-beta.8 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +243 -51
  2. package/android/gradle/wrapper/gradle-wrapper.properties +1 -1
  3. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraPreview.java +1249 -143
  4. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraXView.java +3400 -1382
  5. package/android/src/main/java/com/ahm/capacitor/camera/preview/GridOverlayView.java +95 -58
  6. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraDevice.java +55 -46
  7. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraLens.java +61 -52
  8. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraSessionConfiguration.java +160 -72
  9. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/LensInfo.java +29 -23
  10. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/ZoomFactors.java +24 -23
  11. package/dist/docs.json +441 -40
  12. package/dist/esm/definitions.d.ts +167 -25
  13. package/dist/esm/definitions.js.map +1 -1
  14. package/dist/esm/index.d.ts +2 -0
  15. package/dist/esm/index.js +24 -1
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/web.d.ts +23 -3
  18. package/dist/esm/web.js +463 -65
  19. package/dist/esm/web.js.map +1 -1
  20. package/dist/plugin.cjs.js +485 -64
  21. package/dist/plugin.cjs.js.map +1 -1
  22. package/dist/plugin.js +485 -64
  23. package/dist/plugin.js.map +1 -1
  24. package/ios/Sources/{CapgoCameraPreview → CapgoCameraPreviewPlugin}/CameraController.swift +731 -315
  25. package/ios/Sources/CapgoCameraPreviewPlugin/Plugin.swift +1902 -0
  26. package/package.json +11 -3
  27. package/android/.gradle/8.14.2/checksums/checksums.lock +0 -0
  28. package/android/.gradle/8.14.2/checksums/md5-checksums.bin +0 -0
  29. package/android/.gradle/8.14.2/checksums/sha1-checksums.bin +0 -0
  30. package/android/.gradle/8.14.2/executionHistory/executionHistory.bin +0 -0
  31. package/android/.gradle/8.14.2/executionHistory/executionHistory.lock +0 -0
  32. package/android/.gradle/8.14.2/fileChanges/last-build.bin +0 -0
  33. package/android/.gradle/8.14.2/fileHashes/fileHashes.bin +0 -0
  34. package/android/.gradle/8.14.2/fileHashes/fileHashes.lock +0 -0
  35. package/android/.gradle/8.14.2/fileHashes/resourceHashesCache.bin +0 -0
  36. package/android/.gradle/8.14.2/gc.properties +0 -0
  37. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  38. package/android/.gradle/buildOutputCleanup/cache.properties +0 -2
  39. package/android/.gradle/buildOutputCleanup/outputFiles.bin +0 -0
  40. package/android/.gradle/file-system.probe +0 -0
  41. package/android/.gradle/vcs-1/gc.properties +0 -0
  42. package/ios/Sources/CapgoCameraPreview/Plugin.swift +0 -1211
  43. /package/ios/Sources/{CapgoCameraPreview → CapgoCameraPreviewPlugin}/GridOverlayView.swift +0 -0
@@ -1,11 +1,3 @@
1
- //
2
- // CameraController.swift
3
- // Plugin
4
- //
5
- // Created by Ariel Hernandez Musa on 7/14/19.
6
- // Copyright © 2019 Max Lynch. All rights reserved.
7
- //
8
-
9
1
  import AVFoundation
10
2
  import UIKit
11
3
  import CoreLocation
@@ -24,46 +16,78 @@ class CameraController: NSObject {
24
16
  var rearCamera: AVCaptureDevice?
25
17
  var rearCameraInput: AVCaptureDeviceInput?
26
18
 
19
+ var allDiscoveredDevices: [AVCaptureDevice] = []
20
+
27
21
  var fileVideoOutput: AVCaptureMovieFileOutput?
28
22
 
29
23
  var previewLayer: AVCaptureVideoPreviewLayer?
30
24
  var gridOverlayView: GridOverlayView?
25
+ var focusIndicatorView: UIView?
31
26
 
32
27
  var flashMode = AVCaptureDevice.FlashMode.off
33
- var photoCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
28
+ var photoCaptureCompletionBlock: ((UIImage?, Data?, [AnyHashable: Any]?, Error?) -> Void)?
34
29
 
35
30
  var sampleBufferCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
36
31
 
37
- var highResolutionOutput: Bool = false
32
+ // Add callback for detecting when first frame is ready
33
+ var firstFrameReadyCallback: (() -> Void)?
34
+ var hasReceivedFirstFrame = false
38
35
 
39
36
  var audioDevice: AVCaptureDevice?
40
37
  var audioInput: AVCaptureDeviceInput?
41
38
 
42
- var zoomFactor: CGFloat = 1.0
39
+ var zoomFactor: CGFloat = 2.0
43
40
  private var lastZoomUpdateTime: TimeInterval = 0
44
41
  private let zoomUpdateThrottle: TimeInterval = 1.0 / 60.0 // 60 FPS max
45
42
 
46
43
  var videoFileURL: URL?
47
44
  private let saneMaxZoomFactor: CGFloat = 25.5
48
45
 
46
+ // Track output preparation status
47
+ private var outputsPrepared: Bool = false
48
+
49
49
  var isUsingMultiLensVirtualCamera: Bool {
50
50
  guard let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera else { return false }
51
51
  // A rear multi-lens virtual camera will have a min zoom of 1.0 but support wider angles
52
52
  return device.position == .back && device.isVirtualDevice && device.constituentDevices.count > 1
53
53
  }
54
+
55
+ // Returns the display zoom multiplier introduced in iOS 18 to map between
56
+ // native zoom factor and the UI-displayed zoom factor. Falls back to 1.0 on
57
+ // older systems or if the property is unavailable.
58
+ func getDisplayZoomMultiplier() -> Float {
59
+ var multiplier: Float = 1.0
60
+ // Use KVC to avoid compile-time dependency on the iOS 18 SDK symbol
61
+ let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
62
+ if #available(iOS 18.0, *), let device = device {
63
+ if let value = device.value(forKey: "displayVideoZoomFactorMultiplier") as? NSNumber {
64
+ let m = value.floatValue
65
+ if m > 0 { multiplier = m }
66
+ }
67
+ }
68
+ return multiplier
69
+ }
70
+
71
+ // Track whether an aspect ratio was explicitly requested
72
+ var requestedAspectRatio: String?
54
73
  }
55
74
 
56
75
  extension CameraController {
57
- func prepareBasicSession() {
58
- // Only prepare if we don't already have a session
76
+ func prepareFullSession() {
77
+ // This function is now deprecated in favor of inline session creation in prepare()
78
+ // Kept for backward compatibility
59
79
  guard self.captureSession == nil else { return }
60
-
61
- print("[CameraPreview] Preparing basic camera session in background")
62
-
63
- // Create basic capture session
80
+
64
81
  self.captureSession = AVCaptureSession()
65
-
66
- // Configure basic devices without full preparation
82
+ }
83
+
84
+ private func ensureCamerasDiscovered() {
85
+ // Rediscover cameras if the array is empty OR if the camera pointers are nil
86
+ guard allDiscoveredDevices.isEmpty || (rearCamera == nil && frontCamera == nil) else { return }
87
+ discoverAndConfigureCameras()
88
+ }
89
+
90
+ private func discoverAndConfigureCameras() {
67
91
  let deviceTypes: [AVCaptureDevice.DeviceType] = [
68
92
  .builtInWideAngleCamera,
69
93
  .builtInUltraWideCamera,
@@ -76,327 +100,331 @@ extension CameraController {
76
100
 
77
101
  let session = AVCaptureDevice.DiscoverySession(deviceTypes: deviceTypes, mediaType: AVMediaType.video, position: .unspecified)
78
102
  let cameras = session.devices.compactMap { $0 }
79
-
80
- // Find best cameras
81
- let rearVirtualDevices = cameras.filter { $0.position == .back && $0.isVirtualDevice }
82
- let bestRearVirtualDevice = rearVirtualDevices.max { $0.constituentDevices.count < $1.constituentDevices.count }
83
-
103
+
104
+ // Store all discovered devices for fast lookup later
105
+ self.allDiscoveredDevices = cameras
106
+
107
+ // Log all found devices for debugging
108
+
109
+ for camera in cameras {
110
+ let constituentCount = camera.isVirtualDevice ? camera.constituentDevices.count : 1
111
+
112
+ }
113
+
114
+ // Set front camera (usually just one option)
84
115
  self.frontCamera = cameras.first(where: { $0.position == .front })
85
-
86
- if let bestCamera = bestRearVirtualDevice {
87
- self.rearCamera = bestCamera
88
- } else if let firstRearCamera = cameras.first(where: { $0.position == .back }) {
116
+
117
+ // Find rear camera - prefer tripleCamera for multi-lens support
118
+ let rearCameras = cameras.filter { $0.position == .back }
119
+
120
+ // First try to find built-in triple camera (provides access to all lenses)
121
+ if let tripleCamera = rearCameras.first(where: {
122
+ $0.deviceType == .builtInTripleCamera
123
+ }) {
124
+ self.rearCamera = tripleCamera
125
+ } else if let dualWideCamera = rearCameras.first(where: {
126
+ $0.deviceType == .builtInDualWideCamera
127
+ }) {
128
+ // Fallback to dual wide camera
129
+ self.rearCamera = dualWideCamera
130
+ } else if let dualCamera = rearCameras.first(where: {
131
+ $0.deviceType == .builtInDualCamera
132
+ }) {
133
+ // Fallback to dual camera
134
+ self.rearCamera = dualCamera
135
+ } else if let wideAngleCamera = rearCameras.first(where: {
136
+ $0.deviceType == .builtInWideAngleCamera
137
+ }) {
138
+ // Fallback to wide angle camera
139
+ self.rearCamera = wideAngleCamera
140
+ } else if let firstRearCamera = rearCameras.first {
141
+ // Final fallback to any rear camera
89
142
  self.rearCamera = firstRearCamera
90
143
  }
91
-
92
- print("[CameraPreview] Basic session prepared with \(cameras.count) devices")
144
+
145
+ // Pre-configure focus modes
146
+ configureCameraFocus(camera: self.rearCamera)
147
+ configureCameraFocus(camera: self.frontCamera)
93
148
  }
94
149
 
95
- func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, completionHandler: @escaping (Error?) -> Void) {
96
- func createCaptureSession() {
97
- // Use existing session if available from background preparation
98
- if self.captureSession == nil {
99
- self.captureSession = AVCaptureSession()
150
+ private func configureCameraFocus(camera: AVCaptureDevice?) {
151
+ guard let camera = camera else { return }
152
+
153
+ do {
154
+ try camera.lockForConfiguration()
155
+ if camera.isFocusModeSupported(.continuousAutoFocus) {
156
+ camera.focusMode = .continuousAutoFocus
100
157
  }
158
+ camera.unlockForConfiguration()
159
+ } catch {
160
+ print("[CameraPreview] Could not configure focus for \(camera.localizedName): \(error)")
101
161
  }
162
+ }
102
163
 
103
- func configureCaptureDevices() throws {
104
- // Skip device discovery if cameras are already found during background preparation
105
- if self.frontCamera != nil || self.rearCamera != nil {
106
- print("[CameraPreview] Using pre-discovered cameras")
107
- return
108
- }
109
-
110
- // Expanded device types to support more camera configurations
111
- let deviceTypes: [AVCaptureDevice.DeviceType] = [
112
- .builtInWideAngleCamera,
113
- .builtInUltraWideCamera,
114
- .builtInTelephotoCamera,
115
- .builtInDualCamera,
116
- .builtInDualWideCamera,
117
- .builtInTripleCamera,
118
- .builtInTrueDepthCamera
119
- ]
120
-
121
- let session = AVCaptureDevice.DiscoverySession(deviceTypes: deviceTypes, mediaType: AVMediaType.video, position: .unspecified)
122
-
123
- let cameras = session.devices.compactMap { $0 }
124
-
125
- // Log all found devices for debugging
126
- print("[CameraPreview] Found \(cameras.count) devices:")
127
- for camera in cameras {
128
- let constituentCount = camera.isVirtualDevice ? camera.constituentDevices.count : 1
129
- print("[CameraPreview] - \(camera.localizedName) (Position: \(camera.position.rawValue), Virtual: \(camera.isVirtualDevice), Lenses: \(constituentCount), Zoom: \(camera.minAvailableVideoZoomFactor)-\(camera.maxAvailableVideoZoomFactor))")
130
- }
164
+ private func prepareOutputs() {
165
+ // Skip if already prepared
166
+ guard !self.outputsPrepared else { return }
131
167
 
132
- guard !cameras.isEmpty else {
133
- print("[CameraPreview] ERROR: No cameras found.")
134
- throw CameraControllerError.noCamerasAvailable
135
- }
168
+ // Create photo output
169
+ self.photoOutput = AVCapturePhotoOutput()
170
+ self.photoOutput?.isHighResolutionCaptureEnabled = true
136
171
 
137
- // --- Corrected Device Selection Logic ---
138
- // Find the virtual device with the most constituent cameras (this is the most capable one)
139
- let rearVirtualDevices = cameras.filter { $0.position == .back && $0.isVirtualDevice }
140
- let bestRearVirtualDevice = rearVirtualDevices.max { $0.constituentDevices.count < $1.constituentDevices.count }
172
+ // Create video output
173
+ self.fileVideoOutput = AVCaptureMovieFileOutput()
141
174
 
142
- self.frontCamera = cameras.first(where: { $0.position == .front })
175
+ // Create data output for preview
176
+ self.dataOutput = AVCaptureVideoDataOutput()
177
+ self.dataOutput?.videoSettings = [
178
+ (kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
179
+ ]
180
+ self.dataOutput?.alwaysDiscardsLateVideoFrames = true
143
181
 
144
- if let bestCamera = bestRearVirtualDevice {
145
- self.rearCamera = bestCamera
146
- print("[CameraPreview] Selected best virtual rear camera: \(bestCamera.localizedName) with \(bestCamera.constituentDevices.count) physical cameras.")
147
- } else if let firstRearCamera = cameras.first(where: { $0.position == .back }) {
148
- // Fallback for devices without a virtual camera system
149
- self.rearCamera = firstRearCamera
150
- print("[CameraPreview] WARN: No virtual rear camera found. Selected first available: \(firstRearCamera.localizedName)")
151
- }
152
- // --- End of Correction ---
182
+ // Pre-create preview layer to avoid delay later
183
+ if self.previewLayer == nil {
184
+ self.previewLayer = AVCaptureVideoPreviewLayer()
185
+ }
153
186
 
154
- if let rearCamera = self.rearCamera {
155
- do {
156
- try rearCamera.lockForConfiguration()
157
- if rearCamera.isFocusModeSupported(.continuousAutoFocus) {
158
- rearCamera.focusMode = .continuousAutoFocus
159
- }
160
- rearCamera.unlockForConfiguration()
161
- } catch {
162
- print("[CameraPreview] WARN: Could not set focus mode on rear camera. \(error)")
187
+ // Mark as prepared
188
+ self.outputsPrepared = true
189
+ }
190
+
191
+ func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float?, completionHandler: @escaping (Error?) -> Void) {
192
+ print("[CameraPreview] 🎬 Starting prepare - position: \(cameraPosition), deviceId: \(deviceId ?? "nil"), disableAudio: \(disableAudio), cameraMode: \(cameraMode), aspectRatio: \(aspectRatio ?? "nil"), zoom: \(initialZoomLevel)")
193
+
194
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
195
+ guard let self = self else {
196
+ DispatchQueue.main.async {
197
+ completionHandler(CameraControllerError.unknown)
163
198
  }
199
+ return
164
200
  }
165
201
 
166
- if disableAudio == false {
167
- self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
168
- }
169
- }
202
+ do {
203
+ // Create session if needed
204
+ if self.captureSession == nil {
205
+ self.captureSession = AVCaptureSession()
206
+ }
170
207
 
171
- func configureDeviceInputs() throws {
172
- guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
208
+ guard let captureSession = self.captureSession else {
209
+ throw CameraControllerError.captureSessionIsMissing
210
+ }
173
211
 
174
- var selectedDevice: AVCaptureDevice?
212
+ // Prepare outputs
213
+ self.prepareOutputs()
175
214
 
176
- // If deviceId is specified, use that specific device
177
- if let deviceId = deviceId {
178
- let allDevices = AVCaptureDevice.DiscoverySession(
179
- deviceTypes: [.builtInWideAngleCamera, .builtInUltraWideCamera, .builtInTelephotoCamera, .builtInDualCamera, .builtInDualWideCamera, .builtInTripleCamera, .builtInTrueDepthCamera],
180
- mediaType: .video,
181
- position: .unspecified
182
- ).devices
215
+ // Configure the session
216
+ captureSession.beginConfiguration()
183
217
 
184
- selectedDevice = allDevices.first(where: { $0.uniqueID == deviceId })
185
- guard selectedDevice != nil else {
186
- throw CameraControllerError.noCamerasAvailable
187
- }
188
- } else {
189
- // Use position-based selection
190
- if cameraPosition == "rear" {
191
- selectedDevice = self.rearCamera
192
- } else if cameraPosition == "front" {
193
- selectedDevice = self.frontCamera
218
+ // Set aspect ratio preset and remember requested ratio
219
+ self.requestedAspectRatio = aspectRatio
220
+ self.configureSessionPreset(for: aspectRatio)
221
+
222
+ // Configure device inputs
223
+ try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
224
+
225
+ // Add data output BEFORE starting session for faster first frame
226
+ if let dataOutput = self.dataOutput, captureSession.canAddOutput(dataOutput) {
227
+ captureSession.addOutput(dataOutput)
228
+ dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
194
229
  }
195
- }
196
230
 
197
- guard let finalDevice = selectedDevice else {
198
- print("[CameraPreview] ERROR: No camera device selected for position: \(cameraPosition)")
199
- throw CameraControllerError.noCamerasAvailable
200
- }
231
+ captureSession.commitConfiguration()
201
232
 
202
- print("[CameraPreview] Configuring device: \(finalDevice.localizedName)")
203
- let deviceInput = try AVCaptureDeviceInput(device: finalDevice)
233
+ // Set initial zoom
234
+ self.setInitialZoom(level: initialZoomLevel)
204
235
 
205
- if captureSession.canAddInput(deviceInput) {
206
- captureSession.addInput(deviceInput)
236
+ // Start the session
237
+ captureSession.startRunning()
207
238
 
208
- if finalDevice.position == .front {
209
- self.frontCameraInput = deviceInput
210
- self.frontCamera = finalDevice
211
- self.currentCameraPosition = .front
212
- } else {
213
- self.rearCameraInput = deviceInput
214
- self.rearCamera = finalDevice
215
- self.currentCameraPosition = .rear
216
-
217
- // --- Corrected Initial Zoom Logic ---
218
- try finalDevice.lockForConfiguration()
219
- if finalDevice.isFocusModeSupported(.continuousAutoFocus) {
220
- finalDevice.focusMode = .continuousAutoFocus
239
+ // Defer adding photo/video outputs to avoid blocking
240
+ // These aren't needed immediately for preview
241
+ DispatchQueue.global(qos: .utility).async { [weak self] in
242
+ guard let self = self else { return }
243
+
244
+ captureSession.beginConfiguration()
245
+
246
+ // Add photo output
247
+ if let photoOutput = self.photoOutput, captureSession.canAddOutput(photoOutput) {
248
+ photoOutput.isHighResolutionCaptureEnabled = true
249
+ captureSession.addOutput(photoOutput)
221
250
  }
222
251
 
223
- // On a multi-camera system, a zoom factor of 2.0 often corresponds to the standard "1x" wide-angle lens.
224
- // We set this as the default to provide a familiar starting point for users.
225
- let defaultWideAngleZoom: CGFloat = 2.0
226
- if finalDevice.isVirtualDevice && finalDevice.constituentDevices.count > 1 && finalDevice.videoZoomFactor != defaultWideAngleZoom {
227
- // Check if 2.0 is a valid zoom factor before setting it.
228
- if defaultWideAngleZoom >= finalDevice.minAvailableVideoZoomFactor && defaultWideAngleZoom <= finalDevice.maxAvailableVideoZoomFactor {
229
- print("[CameraPreview] Multi-camera system detected. Setting initial zoom to \(defaultWideAngleZoom) (standard wide-angle).")
230
- finalDevice.videoZoomFactor = defaultWideAngleZoom
231
- }
252
+ // Add video output if needed
253
+ if cameraMode, let fileVideoOutput = self.fileVideoOutput, captureSession.canAddOutput(fileVideoOutput) {
254
+ captureSession.addOutput(fileVideoOutput)
232
255
  }
233
- finalDevice.unlockForConfiguration()
234
- // --- End of Correction ---
256
+
257
+ captureSession.commitConfiguration()
235
258
  }
236
- } else {
237
- print("[CameraPreview] ERROR: Cannot add device input to session.")
238
- throw CameraControllerError.inputsAreInvalid
239
- }
240
259
 
241
- // Add audio input
242
- if disableAudio == false {
243
- if let audioDevice = self.audioDevice {
244
- self.audioInput = try AVCaptureDeviceInput(device: audioDevice)
245
- if captureSession.canAddInput(self.audioInput!) {
246
- captureSession.addInput(self.audioInput!)
247
- } else {
248
- throw CameraControllerError.inputsAreInvalid
249
- }
260
+ // Success callback
261
+ DispatchQueue.main.async {
262
+ completionHandler(nil)
263
+ }
264
+ } catch {
265
+ DispatchQueue.main.async {
266
+ completionHandler(error)
250
267
  }
251
268
  }
252
269
  }
270
+ }
253
271
 
254
- func configurePhotoOutput(cameraMode: Bool) throws {
255
- guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
256
-
257
- // Configure session preset for high-quality preview
258
- // Prioritize higher quality presets for better preview resolution
259
- var targetPreset: AVCaptureSession.Preset = .high // Default to high quality
260
-
261
- if let aspectRatio = aspectRatio {
262
- switch aspectRatio {
263
- case "16:9":
264
- // Use highest available HD preset for 16:9 aspect ratio
265
- if captureSession.canSetSessionPreset(.hd4K3840x2160) {
266
- targetPreset = .hd4K3840x2160
267
- } else if captureSession.canSetSessionPreset(.hd1920x1080) {
268
- targetPreset = .hd1920x1080
269
- } else if captureSession.canSetSessionPreset(.hd1280x720) {
270
- targetPreset = .hd1280x720
271
- } else {
272
- targetPreset = .high
273
- }
274
- case "4:3":
275
- // Use photo preset for 4:3 aspect ratio (highest quality)
276
- if captureSession.canSetSessionPreset(.photo) {
277
- targetPreset = .photo
278
- } else if captureSession.canSetSessionPreset(.high) {
279
- targetPreset = .high
280
- } else {
281
- targetPreset = .medium
282
- }
283
- default:
284
- // Default to highest available quality
285
- if captureSession.canSetSessionPreset(.photo) {
286
- targetPreset = .photo
287
- } else if captureSession.canSetSessionPreset(.high) {
288
- targetPreset = .high
289
- } else {
290
- targetPreset = .medium
291
- }
272
+ private func configureSessionPreset(for aspectRatio: String?) {
273
+ guard let captureSession = self.captureSession else { return }
274
+
275
+ var targetPreset: AVCaptureSession.Preset = .photo
276
+ if let aspectRatio = aspectRatio {
277
+ switch aspectRatio {
278
+ case "16:9":
279
+ if captureSession.canSetSessionPreset(.hd4K3840x2160) {
280
+ targetPreset = .hd4K3840x2160
281
+ } else if captureSession.canSetSessionPreset(.hd1920x1080) {
282
+ targetPreset = .hd1920x1080
292
283
  }
293
- } else {
294
- // Default to highest available quality when no aspect ratio specified
284
+ case "4:3":
295
285
  if captureSession.canSetSessionPreset(.photo) {
296
286
  targetPreset = .photo
297
287
  } else if captureSession.canSetSessionPreset(.high) {
298
288
  targetPreset = .high
299
289
  } else {
300
- targetPreset = .medium
290
+ targetPreset = captureSession.sessionPreset
301
291
  }
302
- }
303
-
304
- // Apply the determined preset
305
- if captureSession.canSetSessionPreset(targetPreset) {
306
- captureSession.sessionPreset = targetPreset
307
- print("[CameraPreview] Set session preset to \(targetPreset) for aspect ratio: \(aspectRatio ?? "default")")
308
- } else {
309
- // Fallback to high quality preset if the target preset is not supported
310
- print("[CameraPreview] Target preset \(targetPreset) not supported, falling back to .high")
311
- if captureSession.canSetSessionPreset(.high) {
312
- captureSession.sessionPreset = .high
313
- } else if captureSession.canSetSessionPreset(.medium) {
314
- captureSession.sessionPreset = .medium
292
+ default:
293
+ if captureSession.canSetSessionPreset(.photo) {
294
+ targetPreset = .photo
295
+ } else if captureSession.canSetSessionPreset(.high) {
296
+ targetPreset = .high
297
+ } else {
298
+ targetPreset = captureSession.sessionPreset
315
299
  }
316
300
  }
301
+ }
317
302
 
318
- self.photoOutput = AVCapturePhotoOutput()
319
- self.photoOutput!.setPreparedPhotoSettingsArray([AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])], completionHandler: nil)
320
- self.photoOutput?.isHighResolutionCaptureEnabled = self.highResolutionOutput
321
- if captureSession.canAddOutput(self.photoOutput!) { captureSession.addOutput(self.photoOutput!) }
303
+ if captureSession.canSetSessionPreset(targetPreset) {
304
+ captureSession.sessionPreset = targetPreset
305
+ }
306
+ }
322
307
 
323
- let fileVideoOutput = AVCaptureMovieFileOutput()
324
- if captureSession.canAddOutput(fileVideoOutput) {
325
- captureSession.addOutput(fileVideoOutput)
326
- self.fileVideoOutput = fileVideoOutput
327
- }
328
- captureSession.startRunning()
308
+ private func setInitialZoom(level: Float?) {
309
+ let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
310
+ guard let device = device else {
311
+ print("[CameraPreview] No device available for initial zoom")
312
+ return
329
313
  }
330
314
 
331
- func configureDataOutput() throws {
332
- guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
315
+ let minZoom = device.minAvailableVideoZoomFactor
316
+ let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
333
317
 
334
- self.dataOutput = AVCaptureVideoDataOutput()
335
- self.dataOutput?.videoSettings = [
336
- (kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
337
- ]
338
- self.dataOutput?.alwaysDiscardsLateVideoFrames = true
339
- if captureSession.canAddOutput(self.dataOutput!) {
340
- captureSession.addOutput(self.dataOutput!)
341
- }
318
+ // Compute UI-level default = 1 * multiplier when not provided
319
+ let multiplier = self.getDisplayZoomMultiplier()
320
+ // if level is nil, it's the initial zoom
321
+ let uiLevel: Float = level ?? (2.0 * multiplier)
322
+ // Map UI/display zoom to native zoom using iOS 18+ multiplier
323
+ let adjustedLevel = multiplier != 1.0 ? (uiLevel / multiplier) : uiLevel
342
324
 
343
- captureSession.commitConfiguration()
325
+ guard CGFloat(adjustedLevel) >= minZoom && CGFloat(adjustedLevel) <= maxZoom else {
326
+ print("[CameraPreview] Initial zoom level \(adjustedLevel) out of range (\(minZoom)-\(maxZoom))")
327
+ return
328
+ }
344
329
 
345
- let queue = DispatchQueue(label: "DataOutput", attributes: [])
346
- self.dataOutput?.setSampleBufferDelegate(self, queue: queue)
330
+ do {
331
+ try device.lockForConfiguration()
332
+ device.videoZoomFactor = CGFloat(adjustedLevel)
333
+ device.unlockForConfiguration()
334
+ self.zoomFactor = CGFloat(adjustedLevel)
335
+ } catch {
336
+ print("[CameraPreview] Failed to set initial zoom: \(error)")
347
337
  }
338
+ }
348
339
 
349
- DispatchQueue(label: "prepare").async {
350
- do {
351
- createCaptureSession()
352
- try configureCaptureDevices()
353
- try configureDeviceInputs()
354
- try configurePhotoOutput(cameraMode: cameraMode)
355
- try configureDataOutput()
356
- // try configureVideoOutput()
357
- } catch {
358
- DispatchQueue.main.async {
359
- completionHandler(error)
360
- }
340
+ private func configureDeviceInputs(cameraPosition: String, deviceId: String?, disableAudio: Bool) throws {
341
+ guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
361
342
 
362
- return
363
- }
343
+ // Ensure cameras are discovered before configuring inputs
344
+ ensureCamerasDiscovered()
364
345
 
365
- DispatchQueue.main.async {
366
- completionHandler(nil)
346
+ var selectedDevice: AVCaptureDevice?
347
+
348
+ // If deviceId is specified, find that specific device from discovered devices
349
+ if let deviceId = deviceId {
350
+ selectedDevice = self.allDiscoveredDevices.first(where: { $0.uniqueID == deviceId })
351
+ guard selectedDevice != nil else {
352
+ throw CameraControllerError.noCamerasAvailable
353
+ }
354
+ } else {
355
+ // Use position-based selection from discovered cameras
356
+ if cameraPosition == "rear" {
357
+ selectedDevice = self.rearCamera
358
+ } else if cameraPosition == "front" {
359
+ selectedDevice = self.frontCamera
367
360
  }
368
361
  }
369
- }
370
362
 
371
- func displayPreview(on view: UIView) throws {
372
- guard let captureSession = self.captureSession, captureSession.isRunning else { throw CameraControllerError.captureSessionIsMissing }
363
+ guard let finalDevice = selectedDevice else {
364
+ throw CameraControllerError.noCamerasAvailable
365
+ }
373
366
 
374
- print("[CameraPreview] displayPreview called with view frame: \(view.frame)")
367
+ let deviceInput = try AVCaptureDeviceInput(device: finalDevice)
375
368
 
376
- self.previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
377
- self.previewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
369
+ if captureSession.canAddInput(deviceInput) {
370
+ captureSession.addInput(deviceInput)
378
371
 
379
- // Optimize preview layer for better quality
380
- self.previewLayer?.connection?.videoOrientation = .portrait
381
- self.previewLayer?.isOpaque = true
372
+ if finalDevice.position == .front {
373
+ self.frontCameraInput = deviceInput
374
+ self.currentCameraPosition = .front
375
+ } else {
376
+ self.rearCameraInput = deviceInput
377
+ self.currentCameraPosition = .rear
378
+ }
379
+ } else {
380
+ throw CameraControllerError.inputsAreInvalid
381
+ }
382
382
 
383
- // Enable high-quality rendering
384
- if #available(iOS 13.0, *) {
385
- self.previewLayer?.videoGravity = .resizeAspectFill
383
+ // Add audio input if needed
384
+ if !disableAudio {
385
+ if self.audioDevice == nil {
386
+ self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
387
+ }
388
+ if let audioDevice = self.audioDevice {
389
+ self.audioInput = try AVCaptureDeviceInput(device: audioDevice)
390
+ if captureSession.canAddInput(self.audioInput!) {
391
+ captureSession.addInput(self.audioInput!)
392
+ } else {
393
+ throw CameraControllerError.inputsAreInvalid
394
+ }
395
+ }
386
396
  }
397
+ }
387
398
 
388
- view.layer.insertSublayer(self.previewLayer!, at: 0)
389
-
390
- // Disable animation for frame update
391
- CATransaction.begin()
392
- CATransaction.setDisableActions(true)
393
- self.previewLayer?.frame = view.bounds
394
- CATransaction.commit()
399
+ func displayPreview(on view: UIView) throws {
400
+ guard let captureSession = self.captureSession, captureSession.isRunning else {
401
+ throw CameraControllerError.captureSessionIsMissing
402
+ }
395
403
 
396
- print("[CameraPreview] Set preview layer frame to view bounds: \(view.bounds)")
397
- print("[CameraPreview] Session preset: \(captureSession.sessionPreset.rawValue)")
404
+ // Create or reuse preview layer
405
+ let previewLayer: AVCaptureVideoPreviewLayer
406
+ if let existingLayer = self.previewLayer {
407
+ // Always reuse if we have one - just update the session if needed
408
+ previewLayer = existingLayer
409
+ if existingLayer.session != captureSession {
410
+ existingLayer.session = captureSession
411
+ }
412
+ } else {
413
+ // Create layer with minimal properties to speed up creation
414
+ previewLayer = AVCaptureVideoPreviewLayer()
415
+ previewLayer.session = captureSession
416
+ }
398
417
 
399
- updateVideoOrientation()
418
+ // Fast configuration without CATransaction overhead
419
+ // Use resizeAspect to avoid crop when no aspect ratio is requested; otherwise fill
420
+ previewLayer.videoGravity = (requestedAspectRatio == nil) ? .resizeAspect : .resizeAspectFill
421
+ previewLayer.frame = view.bounds
422
+
423
+ // Insert layer immediately (only if new)
424
+ if previewLayer.superlayer != view.layer {
425
+ view.layer.insertSublayer(previewLayer, at: 0)
426
+ }
427
+ self.previewLayer = previewLayer
400
428
  }
401
429
 
402
430
  func addGridOverlay(to view: UIView, gridMode: String) {
@@ -443,8 +471,8 @@ extension CameraController {
443
471
  if Thread.isMainThread {
444
472
  updateVideoOrientationOnMainThread()
445
473
  } else {
446
- DispatchQueue.main.async { [weak self] in
447
- self?.updateVideoOrientationOnMainThread()
474
+ DispatchQueue.main.sync {
475
+ self.updateVideoOrientationOnMainThread()
448
476
  }
449
477
  }
450
478
  }
@@ -477,6 +505,33 @@ extension CameraController {
477
505
  photoOutput?.connections.forEach { $0.videoOrientation = videoOrientation }
478
506
  }
479
507
 
508
+ private func setDefaultZoomAfterFlip() {
509
+ let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
510
+ guard let device = device else {
511
+ print("[CameraPreview] No device available for default zoom after flip")
512
+ return
513
+ }
514
+
515
+ // Set zoom to 1.0x in UI terms, accounting for display multiplier
516
+ let multiplier = self.getDisplayZoomMultiplier()
517
+ let targetUIZoom: Float = 1.0 // We want 1.0x in the UI
518
+ let nativeZoom = multiplier != 1.0 ? (targetUIZoom / multiplier) : targetUIZoom
519
+
520
+ let minZoom = device.minAvailableVideoZoomFactor
521
+ let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
522
+ let clampedZoom = max(minZoom, min(CGFloat(nativeZoom), maxZoom))
523
+
524
+ do {
525
+ try device.lockForConfiguration()
526
+ device.videoZoomFactor = clampedZoom
527
+ device.unlockForConfiguration()
528
+ self.zoomFactor = clampedZoom
529
+ print("[CameraPreview] Set default zoom after flip: UI=\(targetUIZoom)x, native=\(clampedZoom), multiplier=\(multiplier)")
530
+ } catch {
531
+ print("[CameraPreview] Failed to set default zoom after flip: \(error)")
532
+ }
533
+ }
534
+
480
535
  func switchCameras() throws {
481
536
  guard let currentCameraPosition = currentCameraPosition,
482
537
  let captureSession = self.captureSession else {
@@ -485,7 +540,7 @@ extension CameraController {
485
540
 
486
541
  // Ensure we have the necessary cameras
487
542
  guard (currentCameraPosition == .front && rearCamera != nil) ||
488
- (currentCameraPosition == .rear && frontCamera != nil) else {
543
+ (currentCameraPosition == .rear && frontCamera != nil) else {
489
544
  throw CameraControllerError.noCamerasAvailable
490
545
  }
491
546
 
@@ -501,9 +556,7 @@ extension CameraController {
501
556
  captureSession.commitConfiguration()
502
557
  // Restart the session if it was running before
503
558
  if wasRunning {
504
- DispatchQueue.global(qos: .userInitiated).async { [weak self] in
505
- self?.captureSession?.startRunning()
506
- }
559
+ captureSession.startRunning()
507
560
  }
508
561
  }
509
562
 
@@ -513,7 +566,7 @@ extension CameraController {
513
566
  // Remove only video inputs
514
567
  captureSession.inputs.forEach { input in
515
568
  if (input as? AVCaptureDeviceInput)?.device.hasMediaType(.video) ?? false {
516
- captureSession.removeInput(input)
569
+ captureSession.removeInput(input)
517
570
  }
518
571
  }
519
572
 
@@ -532,7 +585,7 @@ extension CameraController {
532
585
  rearCamera.unlockForConfiguration()
533
586
 
534
587
  if let newInput = try? AVCaptureDeviceInput(device: rearCamera),
535
- captureSession.canAddInput(newInput) {
588
+ captureSession.canAddInput(newInput) {
536
589
  captureSession.addInput(newInput)
537
590
  rearCameraInput = newInput
538
591
  self.currentCameraPosition = .rear
@@ -552,7 +605,7 @@ extension CameraController {
552
605
  frontCamera.unlockForConfiguration()
553
606
 
554
607
  if let newInput = try? AVCaptureDeviceInput(device: frontCamera),
555
- captureSession.canAddInput(newInput) {
608
+ captureSession.canAddInput(newInput) {
556
609
  captureSession.addInput(newInput)
557
610
  frontCameraInput = newInput
558
611
  self.currentCameraPosition = .front
@@ -567,27 +620,59 @@ extension CameraController {
567
620
  }
568
621
 
569
622
  // Update video orientation
623
+ self.updateVideoOrientation()
624
+
625
+ // Set default 1.0 zoom level after camera switch to prevent iOS 18+ zoom jumps
570
626
  DispatchQueue.main.async { [weak self] in
571
- self?.updateVideoOrientation()
627
+ self?.setDefaultZoomAfterFlip()
572
628
  }
573
629
  }
574
630
 
575
- func captureImage(width: Int?, height: Int?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Error?) -> Void) {
631
+ func captureImage(width: Int?, height: Int?, aspectRatio: String?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Data?, [AnyHashable: Any]?, Error?) -> Void) {
632
+ print("[CameraPreview] captureImage called - width: \(width ?? -1), height: \(height ?? -1), aspectRatio: \(aspectRatio ?? "nil")")
633
+
576
634
  guard let photoOutput = self.photoOutput else {
577
- completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
635
+ completion(nil, nil, nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
578
636
  return
579
637
  }
580
638
 
581
639
  let settings = AVCapturePhotoSettings()
640
+ // Request highest quality photo capture
641
+ if #available(iOS 13.0, *) {
642
+ settings.isHighResolutionPhotoEnabled = true
643
+ }
644
+ if #available(iOS 15.0, *) {
645
+ settings.photoQualityPrioritization = .balanced
646
+ }
582
647
 
583
- self.photoCaptureCompletionBlock = { (image, error) in
648
+ // Apply the current flash mode to the photo settings
649
+ // Check if the current device supports flash
650
+ var currentCamera: AVCaptureDevice?
651
+ switch currentCameraPosition {
652
+ case .front:
653
+ currentCamera = self.frontCamera
654
+ case .rear:
655
+ currentCamera = self.rearCamera
656
+ default:
657
+ break
658
+ }
659
+
660
+ // Only apply flash if the device has flash and the flash mode is supported
661
+ if let device = currentCamera, device.hasFlash {
662
+ let supportedFlashModes = photoOutput.supportedFlashModes
663
+ if supportedFlashModes.contains(self.flashMode) {
664
+ settings.flashMode = self.flashMode
665
+ }
666
+ }
667
+
668
+ self.photoCaptureCompletionBlock = { (image, photoData, metadata, error) in
584
669
  if let error = error {
585
- completion(nil, error)
670
+ completion(nil, nil, nil, error)
586
671
  return
587
672
  }
588
673
 
589
674
  guard let image = image else {
590
- completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to capture image"]))
675
+ completion(nil, nil, nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to capture image"]))
591
676
  return
592
677
  }
593
678
 
@@ -595,12 +680,53 @@ extension CameraController {
595
680
  self.addGPSMetadata(to: image, location: location)
596
681
  }
597
682
 
683
+ var finalImage = image
684
+
685
+ // Determine what to do based on parameters
598
686
  if let width = width, let height = height {
599
- let resizedImage = self.resizeImage(image: image, to: CGSize(width: width, height: height))
600
- completion(resizedImage, nil)
687
+ // Specific dimensions requested - resize to exact size
688
+ finalImage = self.resizeImage(image: image, to: CGSize(width: width, height: height))!
689
+ print("[CameraPreview] Resized to exact dimensions: \(finalImage.size.width)x\(finalImage.size.height)")
690
+ } else if let aspectRatio = aspectRatio {
691
+ // Aspect ratio specified - crop to that ratio
692
+ let components = aspectRatio.split(separator: ":").compactMap { Double($0) }
693
+ if components.count == 2 {
694
+ // For capture in portrait orientation, swap the aspect ratio (16:9 becomes 9:16)
695
+ let isPortrait = image.size.height > image.size.width
696
+ let targetAspectRatio = isPortrait ? components[1] / components[0] : components[0] / components[1]
697
+ let imageSize = image.size
698
+ let originalAspectRatio = imageSize.width / imageSize.height
699
+
700
+ // Only crop if the aspect ratios don't match
701
+ if abs(originalAspectRatio - targetAspectRatio) > 0.01 {
702
+ var targetSize = imageSize
703
+
704
+ if originalAspectRatio > targetAspectRatio {
705
+ // Original is wider than target - fit by height
706
+ targetSize.width = imageSize.height * CGFloat(targetAspectRatio)
707
+ } else {
708
+ // Original is taller than target - fit by width
709
+ targetSize.height = imageSize.width / CGFloat(targetAspectRatio)
710
+ }
711
+
712
+ // Center crop the image
713
+ if let croppedImage = self.cropImageToAspectRatio(image: image, targetSize: targetSize) {
714
+ finalImage = croppedImage
715
+ print("[CameraPreview] Applied aspect ratio crop: \(finalImage.size.width)x\(finalImage.size.height)")
716
+ }
717
+ }
718
+ }
601
719
  } else {
602
- completion(image, nil)
720
+ // No parameters specified - crop to match what's visible in the preview
721
+ // This ensures we capture exactly what the user sees
722
+ if let previewLayer = self.previewLayer,
723
+ let previewCroppedImage = self.cropImageToMatchPreview(image: image, previewLayer: previewLayer) {
724
+ finalImage = previewCroppedImage
725
+ print("[CameraPreview] Cropped to match preview: \(finalImage.size.width)x\(finalImage.size.height)")
726
+ }
603
727
  }
728
+
729
+ completion(finalImage, photoData, metadata, nil)
604
730
  }
605
731
 
606
732
  photoOutput.capturePhoto(with: settings, delegate: self)
@@ -637,12 +763,73 @@ extension CameraController {
637
763
 
638
764
  func resizeImage(image: UIImage, to size: CGSize) -> UIImage? {
639
765
  let renderer = UIGraphicsImageRenderer(size: size)
640
- let resizedImage = renderer.image { (context) in
766
+ let resizedImage = renderer.image { (_) in
641
767
  image.draw(in: CGRect(origin: .zero, size: size))
642
768
  }
643
769
  return resizedImage
644
770
  }
645
771
 
772
+ func cropImageToAspectRatio(image: UIImage, targetSize: CGSize) -> UIImage? {
773
+ let imageSize = image.size
774
+
775
+ // Calculate the crop rect - center crop
776
+ let xOffset = (imageSize.width - targetSize.width) / 2
777
+ let yOffset = (imageSize.height - targetSize.height) / 2
778
+ let cropRect = CGRect(x: xOffset, y: yOffset, width: targetSize.width, height: targetSize.height)
779
+
780
+ // Create the cropped image
781
+ guard let cgImage = image.cgImage,
782
+ let croppedCGImage = cgImage.cropping(to: cropRect) else {
783
+ return nil
784
+ }
785
+
786
+ return UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
787
+ }
788
+
789
+ func cropImageToMatchPreview(image: UIImage, previewLayer: AVCaptureVideoPreviewLayer) -> UIImage? {
790
+ // When using resizeAspectFill, the preview layer shows a cropped portion of the video
791
+ // We need to calculate what portion of the captured image corresponds to what's visible
792
+
793
+ let previewBounds = previewLayer.bounds
794
+ let previewAspectRatio = previewBounds.width / previewBounds.height
795
+
796
+ // Get the dimensions of the captured image
797
+ let imageSize = image.size
798
+ let imageAspectRatio = imageSize.width / imageSize.height
799
+
800
+ print("[CameraPreview] cropImageToMatchPreview - Preview bounds: \(previewBounds.width)x\(previewBounds.height) (ratio: \(previewAspectRatio))")
801
+ print("[CameraPreview] cropImageToMatchPreview - Image size: \(imageSize.width)x\(imageSize.height) (ratio: \(imageAspectRatio))")
802
+
803
+ // Since we're using resizeAspectFill, we need to calculate what portion of the image
804
+ // is visible in the preview
805
+ var cropRect: CGRect
806
+
807
+ if imageAspectRatio > previewAspectRatio {
808
+ // Image is wider than preview - crop horizontally
809
+ let visibleWidth = imageSize.height * previewAspectRatio
810
+ let xOffset = (imageSize.width - visibleWidth) / 2
811
+ cropRect = CGRect(x: xOffset, y: 0, width: visibleWidth, height: imageSize.height)
812
+
813
+ } else {
814
+ // Image is taller than preview - crop vertically
815
+ let visibleHeight = imageSize.width / previewAspectRatio
816
+ let yOffset = (imageSize.height - visibleHeight) / 2
817
+ cropRect = CGRect(x: 0, y: yOffset, width: imageSize.width, height: visibleHeight)
818
+
819
+ }
820
+
821
+ // Create the cropped image
822
+ guard let cgImage = image.cgImage,
823
+ let croppedCGImage = cgImage.cropping(to: cropRect) else {
824
+
825
+ return nil
826
+ }
827
+
828
+ let result = UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
829
+
830
+ return result
831
+ }
832
+
646
833
  func captureSample(completion: @escaping (UIImage?, Error?) -> Void) {
647
834
  guard let captureSession = captureSession,
648
835
  captureSession.isRunning else {
@@ -697,6 +884,7 @@ extension CameraController {
697
884
  return supportedFlashModesAsStrings
698
885
 
699
886
  }
887
+
700
888
  func getHorizontalFov() throws -> Float {
701
889
  var currentCamera: AVCaptureDevice?
702
890
  switch currentCameraPosition {
@@ -723,6 +911,7 @@ extension CameraController {
723
911
 
724
912
  return adjustedFov
725
913
  }
914
+
726
915
  func setFlashMode(flashMode: AVCaptureDevice.FlashMode) throws {
727
916
  var currentCamera: AVCaptureDevice?
728
917
  switch currentCameraPosition {
@@ -796,6 +985,7 @@ extension CameraController {
796
985
 
797
986
  func getZoom() throws -> (min: Float, max: Float, current: Float) {
798
987
  var currentCamera: AVCaptureDevice?
988
+
799
989
  switch currentCameraPosition {
800
990
  case .front:
801
991
  currentCamera = self.frontCamera
@@ -817,7 +1007,7 @@ extension CameraController {
817
1007
  )
818
1008
  }
819
1009
 
820
- func setZoom(level: CGFloat, ramp: Bool) throws {
1010
+ func setZoom(level: CGFloat, ramp: Bool, autoFocus: Bool = true) throws {
821
1011
  var currentCamera: AVCaptureDevice?
822
1012
  switch currentCameraPosition {
823
1013
  case .front:
@@ -848,9 +1038,121 @@ extension CameraController {
848
1038
 
849
1039
  // Update our internal zoom factor tracking
850
1040
  self.zoomFactor = zoomLevel
1041
+
1042
+ // Trigger autofocus after zoom if requested
1043
+ if autoFocus {
1044
+ self.triggerAutoFocus()
1045
+ }
1046
+ } catch {
1047
+ throw CameraControllerError.invalidOperation
1048
+ }
1049
+ }
1050
+
1051
+ private func triggerAutoFocus() {
1052
+ var currentCamera: AVCaptureDevice?
1053
+ switch currentCameraPosition {
1054
+ case .front:
1055
+ currentCamera = self.frontCamera
1056
+ case .rear:
1057
+ currentCamera = self.rearCamera
1058
+ default: break
1059
+ }
1060
+
1061
+ guard let device = currentCamera else {
1062
+ return
1063
+ }
1064
+
1065
+ // Focus on the center of the preview (0.5, 0.5)
1066
+ let centerPoint = CGPoint(x: 0.5, y: 0.5)
1067
+
1068
+ do {
1069
+ try device.lockForConfiguration()
1070
+
1071
+ // Set focus mode to auto if supported
1072
+ if device.isFocusModeSupported(.autoFocus) {
1073
+ device.focusMode = .autoFocus
1074
+ if device.isFocusPointOfInterestSupported {
1075
+ device.focusPointOfInterest = centerPoint
1076
+ }
1077
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
1078
+ device.focusMode = .continuousAutoFocus
1079
+ if device.isFocusPointOfInterestSupported {
1080
+ device.focusPointOfInterest = centerPoint
1081
+ }
1082
+ }
1083
+
1084
+ // Also set exposure point if supported
1085
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1086
+ device.exposureMode = .autoExpose
1087
+ device.exposurePointOfInterest = centerPoint
1088
+ } else if device.isExposureModeSupported(.continuousAutoExposure) {
1089
+ device.exposureMode = .continuousAutoExposure
1090
+ if device.isExposurePointOfInterestSupported {
1091
+ device.exposurePointOfInterest = centerPoint
1092
+ }
1093
+ }
1094
+
1095
+ device.unlockForConfiguration()
851
1096
  } catch {
1097
+ // Silently ignore errors during autofocus
1098
+ }
1099
+ }
1100
+
1101
+ func setFocus(at point: CGPoint, showIndicator: Bool = false, in view: UIView? = nil) throws {
1102
+ // Validate that coordinates are within bounds (0-1 range for device coordinates)
1103
+ if point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1 {
1104
+ print("setFocus: Coordinates out of bounds - x: \(point.x), y: \(point.y)")
852
1105
  throw CameraControllerError.invalidOperation
853
1106
  }
1107
+
1108
+ var currentCamera: AVCaptureDevice?
1109
+ switch currentCameraPosition {
1110
+ case .front:
1111
+ currentCamera = self.frontCamera
1112
+ case .rear:
1113
+ currentCamera = self.rearCamera
1114
+ default: break
1115
+ }
1116
+
1117
+ guard let device = currentCamera else {
1118
+ throw CameraControllerError.noCamerasAvailable
1119
+ }
1120
+
1121
+ guard device.isFocusPointOfInterestSupported else {
1122
+ // Device doesn't support focus point of interest
1123
+ return
1124
+ }
1125
+
1126
+ // Show focus indicator if requested and view is provided - only after validation
1127
+ if showIndicator, let view = view, let previewLayer = self.previewLayer {
1128
+ // Convert the device point to layer point for indicator display
1129
+ let layerPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: point)
1130
+ showFocusIndicator(at: layerPoint, in: view)
1131
+ }
1132
+
1133
+ do {
1134
+ try device.lockForConfiguration()
1135
+
1136
+ // Set focus mode to auto if supported
1137
+ if device.isFocusModeSupported(.autoFocus) {
1138
+ device.focusMode = .autoFocus
1139
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
1140
+ device.focusMode = .continuousAutoFocus
1141
+ }
1142
+
1143
+ // Set the focus point
1144
+ device.focusPointOfInterest = point
1145
+
1146
+ // Also set exposure point if supported
1147
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1148
+ device.exposureMode = .autoExpose
1149
+ device.exposurePointOfInterest = point
1150
+ }
1151
+
1152
+ device.unlockForConfiguration()
1153
+ } catch {
1154
+ throw CameraControllerError.unknown
1155
+ }
854
1156
  }
855
1157
 
856
1158
  func getFlashMode() throws -> String {
@@ -963,9 +1265,7 @@ extension CameraController {
963
1265
  captureSession.commitConfiguration()
964
1266
  // Restart the session if it was running before
965
1267
  if wasRunning {
966
- DispatchQueue.global(qos: .userInitiated).async { [weak self] in
967
- self?.captureSession?.startRunning()
968
- }
1268
+ captureSession.startRunning()
969
1269
  }
970
1270
  }
971
1271
 
@@ -1012,13 +1312,9 @@ extension CameraController {
1012
1312
  }
1013
1313
 
1014
1314
  // Update video orientation
1015
- DispatchQueue.main.async { [weak self] in
1016
- self?.updateVideoOrientation()
1017
- }
1315
+ self.updateVideoOrientation()
1018
1316
  }
1019
1317
 
1020
-
1021
-
1022
1318
  func cleanup() {
1023
1319
  if let captureSession = self.captureSession {
1024
1320
  captureSession.stopRunning()
@@ -1029,6 +1325,9 @@ extension CameraController {
1029
1325
  self.previewLayer?.removeFromSuperlayer()
1030
1326
  self.previewLayer = nil
1031
1327
 
1328
+ self.focusIndicatorView?.removeFromSuperview()
1329
+ self.focusIndicatorView = nil
1330
+
1032
1331
  self.frontCameraInput = nil
1033
1332
  self.rearCameraInput = nil
1034
1333
  self.audioInput = nil
@@ -1036,6 +1335,7 @@ extension CameraController {
1036
1335
  self.frontCamera = nil
1037
1336
  self.rearCamera = nil
1038
1337
  self.audioDevice = nil
1338
+ self.allDiscoveredDevices = []
1039
1339
 
1040
1340
  self.dataOutput = nil
1041
1341
  self.photoOutput = nil
@@ -1043,6 +1343,13 @@ extension CameraController {
1043
1343
 
1044
1344
  self.captureSession = nil
1045
1345
  self.currentCameraPosition = nil
1346
+
1347
+ // Reset output preparation status
1348
+ self.outputsPrepared = false
1349
+
1350
+ // Reset first frame detection
1351
+ self.hasReceivedFirstFrame = false
1352
+ self.firstFrameReadyCallback = nil
1046
1353
  }
1047
1354
 
1048
1355
  func captureVideo() throws {
@@ -1119,6 +1426,11 @@ extension CameraController: UIGestureRecognizerDelegate {
1119
1426
  let point = tap.location(in: tap.view)
1120
1427
  let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: point)
1121
1428
 
1429
+ // Show focus indicator at the tap point
1430
+ if let view = tap.view {
1431
+ showFocusIndicator(at: point, in: view)
1432
+ }
1433
+
1122
1434
  do {
1123
1435
  try device.lockForConfiguration()
1124
1436
  defer { device.unlockForConfiguration() }
@@ -1139,7 +1451,80 @@ extension CameraController: UIGestureRecognizerDelegate {
1139
1451
  }
1140
1452
  }
1141
1453
 
1142
- @objc
1454
+ private func showFocusIndicator(at point: CGPoint, in view: UIView) {
1455
+ // Remove any existing focus indicator
1456
+ focusIndicatorView?.removeFromSuperview()
1457
+
1458
+ // Create a new focus indicator (iOS Camera style): square with mid-edge ticks
1459
+ let indicator = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
1460
+ indicator.center = point
1461
+ indicator.layer.borderColor = UIColor.yellow.cgColor
1462
+ indicator.layer.borderWidth = 2.0
1463
+ indicator.layer.cornerRadius = 0
1464
+ indicator.backgroundColor = UIColor.clear
1465
+ indicator.alpha = 0
1466
+ indicator.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
1467
+
1468
+ // Add 4 tiny mid-edge ticks inside the square
1469
+ let stroke: CGFloat = 2.0
1470
+ let tickLen: CGFloat = 12.0
1471
+ let inset: CGFloat = stroke // ticks should touch the sides
1472
+ // Top tick (perpendicular): vertical inward from top edge
1473
+ let topTick = UIView(frame: CGRect(x: (indicator.bounds.width - stroke)/2,
1474
+ y: inset,
1475
+ width: stroke,
1476
+ height: tickLen))
1477
+ topTick.backgroundColor = .yellow
1478
+ indicator.addSubview(topTick)
1479
+ // Bottom tick (perpendicular): vertical inward from bottom edge
1480
+ let bottomTick = UIView(frame: CGRect(x: (indicator.bounds.width - stroke)/2,
1481
+ y: indicator.bounds.height - inset - tickLen,
1482
+ width: stroke,
1483
+ height: tickLen))
1484
+ bottomTick.backgroundColor = .yellow
1485
+ indicator.addSubview(bottomTick)
1486
+ // Left tick (perpendicular): horizontal inward from left edge
1487
+ let leftTick = UIView(frame: CGRect(x: inset,
1488
+ y: (indicator.bounds.height - stroke)/2,
1489
+ width: tickLen,
1490
+ height: stroke))
1491
+ leftTick.backgroundColor = .yellow
1492
+ indicator.addSubview(leftTick)
1493
+ // Right tick (perpendicular): horizontal inward from right edge
1494
+ let rightTick = UIView(frame: CGRect(x: indicator.bounds.width - inset - tickLen,
1495
+ y: (indicator.bounds.height - stroke)/2,
1496
+ width: tickLen,
1497
+ height: stroke))
1498
+ rightTick.backgroundColor = .yellow
1499
+ indicator.addSubview(rightTick)
1500
+
1501
+ view.addSubview(indicator)
1502
+ focusIndicatorView = indicator
1503
+
1504
+ // Animate the focus indicator
1505
+ UIView.animate(withDuration: 0.15, animations: {
1506
+ indicator.alpha = 1.0
1507
+ indicator.transform = CGAffineTransform.identity
1508
+ }) { _ in
1509
+ // Keep the indicator visible briefly
1510
+ UIView.animate(withDuration: 0.2, delay: 0.5, options: [], animations: {
1511
+ indicator.alpha = 0.3
1512
+ }) { _ in
1513
+ // Fade out and remove
1514
+ UIView.animate(withDuration: 0.3, delay: 0.2, options: [], animations: {
1515
+ indicator.alpha = 0
1516
+ indicator.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
1517
+ }) { _ in
1518
+ indicator.removeFromSuperview()
1519
+ if self.focusIndicatorView == indicator {
1520
+ self.focusIndicatorView = nil
1521
+ }
1522
+ }
1523
+ }
1524
+ }
1525
+ }
1526
+
1527
+ @objc
1143
1528
  private func handlePinch(_ pinch: UIPinchGestureRecognizer) {
1144
1529
  guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
1145
1530
 
@@ -1151,7 +1536,7 @@ extension CameraController: UIGestureRecognizerDelegate {
1151
1536
  // Store the initial zoom factor when pinch begins
1152
1537
  zoomFactor = device.videoZoomFactor
1153
1538
 
1154
- case .changed:
1539
+ case .changed:
1155
1540
  // Throttle zoom updates to prevent excessive CPU usage
1156
1541
  let currentTime = CACurrentMediaTime()
1157
1542
  guard currentTime - lastZoomUpdateTime >= zoomUpdateThrottle else { return }
@@ -1181,21 +1566,41 @@ extension CameraController: UIGestureRecognizerDelegate {
1181
1566
  }
1182
1567
 
1183
1568
  extension CameraController: AVCapturePhotoCaptureDelegate {
1184
- public func photoOutput(_ captureOutput: AVCapturePhotoOutput, didFinishProcessingPhoto photoSampleBuffer: CMSampleBuffer?, previewPhoto previewPhotoSampleBuffer: CMSampleBuffer?,
1185
- resolvedSettings: AVCaptureResolvedPhotoSettings, bracketSettings: AVCaptureBracketedStillImageSettings?, error: Swift.Error?) {
1569
+ public func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
1186
1570
  if let error = error {
1187
- self.photoCaptureCompletionBlock?(nil, error)
1188
- } else if let buffer = photoSampleBuffer, let data = AVCapturePhotoOutput.jpegPhotoDataRepresentation(forJPEGSampleBuffer: buffer, previewPhotoSampleBuffer: nil),
1189
- let image = UIImage(data: data) {
1190
- self.photoCaptureCompletionBlock?(image.fixedOrientation(), nil)
1191
- } else {
1192
- self.photoCaptureCompletionBlock?(nil, CameraControllerError.unknown)
1571
+ self.photoCaptureCompletionBlock?(nil, nil, nil, error)
1572
+ return
1193
1573
  }
1574
+
1575
+ // Get the photo data using the modern API
1576
+ guard let imageData = photo.fileDataRepresentation() else {
1577
+ self.photoCaptureCompletionBlock?(nil, nil, nil, CameraControllerError.unknown)
1578
+ return
1579
+ }
1580
+
1581
+ guard let image = UIImage(data: imageData) else {
1582
+ self.photoCaptureCompletionBlock?(nil, nil, nil, CameraControllerError.unknown)
1583
+ return
1584
+ }
1585
+
1586
+ // Pass through original file data and metadata so callers can preserve EXIF
1587
+ self.photoCaptureCompletionBlock?(image.fixedOrientation(), imageData, photo.metadata, nil)
1194
1588
  }
1195
1589
  }
1196
1590
 
1197
1591
  extension CameraController: AVCaptureVideoDataOutputSampleBufferDelegate {
1198
1592
  func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
1593
+ // Check if we're waiting for the first frame
1594
+ if !hasReceivedFirstFrame, let firstFrameCallback = firstFrameReadyCallback {
1595
+ hasReceivedFirstFrame = true
1596
+ firstFrameCallback()
1597
+ firstFrameReadyCallback = nil
1598
+ // If no capture is in progress, we can return early
1599
+ if sampleBufferCaptureCompletionBlock == nil {
1600
+ return
1601
+ }
1602
+ }
1603
+
1199
1604
  guard let completion = sampleBufferCaptureCompletionBlock else { return }
1200
1605
 
1201
1606
  guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
@@ -1245,6 +1650,7 @@ enum CameraControllerError: Swift.Error {
1245
1650
  case cannotFindDocumentsDirectory
1246
1651
  case fileVideoOutputNotFound
1247
1652
  case unknown
1653
+ case invalidZoomLevel(min: CGFloat, max: CGFloat, requested: CGFloat)
1248
1654
  }
1249
1655
 
1250
1656
  public enum CameraPosition {
@@ -1271,6 +1677,8 @@ extension CameraControllerError: LocalizedError {
1271
1677
  return NSLocalizedString("Cannot find documents directory", comment: "This should never happen")
1272
1678
  case .fileVideoOutputNotFound:
1273
1679
  return NSLocalizedString("Video recording is not available. Make sure the camera is properly initialized.", comment: "Video recording not available")
1680
+ case .invalidZoomLevel(let min, let max, let requested):
1681
+ return NSLocalizedString("Invalid zoom level. Must be between \(min) and \(max). Requested: \(requested)", comment: "Invalid Zoom Level")
1274
1682
  }
1275
1683
  }
1276
1684
  }
@@ -1312,6 +1720,8 @@ extension UIImage {
1312
1720
  print("right")
1313
1721
  case .up, .upMirrored:
1314
1722
  break
1723
+ @unknown default:
1724
+ break
1315
1725
  }
1316
1726
 
1317
1727
  // Flip image one more time if needed to, this is to prevent flipped image
@@ -1324,15 +1734,21 @@ extension UIImage {
1324
1734
  transform.scaledBy(x: -1, y: 1)
1325
1735
  case .up, .down, .left, .right:
1326
1736
  break
1737
+ @unknown default:
1738
+ break
1327
1739
  }
1328
1740
 
1329
1741
  ctx.concatenate(transform)
1330
1742
 
1331
1743
  switch imageOrientation {
1332
1744
  case .left, .leftMirrored, .right, .rightMirrored:
1333
- ctx.draw(self.cgImage!, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
1745
+ if let cgImage = self.cgImage {
1746
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
1747
+ }
1334
1748
  default:
1335
- ctx.draw(self.cgImage!, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
1749
+ if let cgImage = self.cgImage {
1750
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
1751
+ }
1336
1752
  }
1337
1753
  guard let newCGImage = ctx.makeImage() else { return nil }
1338
1754
  return UIImage.init(cgImage: newCGImage, scale: 1, orientation: .up)