@capgo/camera-preview 7.4.0-beta.2 → 7.4.0-beta.20

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 (35) hide show
  1. package/README.md +212 -35
  2. package/android/.gradle/8.14.2/checksums/checksums.lock +0 -0
  3. package/android/.gradle/8.14.2/checksums/md5-checksums.bin +0 -0
  4. package/android/.gradle/8.14.2/checksums/sha1-checksums.bin +0 -0
  5. package/android/.gradle/8.14.2/executionHistory/executionHistory.bin +0 -0
  6. package/android/.gradle/8.14.2/executionHistory/executionHistory.lock +0 -0
  7. package/android/.gradle/8.14.2/fileHashes/fileHashes.bin +0 -0
  8. package/android/.gradle/8.14.2/fileHashes/fileHashes.lock +0 -0
  9. package/android/.gradle/8.14.2/fileHashes/resourceHashesCache.bin +0 -0
  10. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  11. package/android/.gradle/file-system.probe +0 -0
  12. package/android/build.gradle +3 -1
  13. package/android/src/main/AndroidManifest.xml +1 -4
  14. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraPreview.java +731 -83
  15. package/android/src/main/java/com/ahm/capacitor/camera/preview/CameraXView.java +2813 -805
  16. package/android/src/main/java/com/ahm/capacitor/camera/preview/GridOverlayView.java +112 -0
  17. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraDevice.java +55 -46
  18. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraLens.java +61 -52
  19. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/CameraSessionConfiguration.java +161 -59
  20. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/LensInfo.java +29 -23
  21. package/android/src/main/java/com/ahm/capacitor/camera/preview/model/ZoomFactors.java +24 -23
  22. package/dist/docs.json +292 -29
  23. package/dist/esm/definitions.d.ts +148 -13
  24. package/dist/esm/definitions.js.map +1 -1
  25. package/dist/esm/web.d.ts +52 -3
  26. package/dist/esm/web.js +555 -97
  27. package/dist/esm/web.js.map +1 -1
  28. package/dist/plugin.cjs.js +553 -97
  29. package/dist/plugin.cjs.js.map +1 -1
  30. package/dist/plugin.js +553 -97
  31. package/dist/plugin.js.map +1 -1
  32. package/ios/Sources/CapgoCameraPreview/CameraController.swift +888 -214
  33. package/ios/Sources/CapgoCameraPreview/GridOverlayView.swift +65 -0
  34. package/ios/Sources/CapgoCameraPreview/Plugin.swift +967 -250
  35. package/package.json +2 -2
@@ -8,6 +8,7 @@
8
8
 
9
9
  import AVFoundation
10
10
  import UIKit
11
+ import CoreLocation
11
12
 
12
13
  class CameraController: NSObject {
13
14
  var captureSession: AVCaptureSession?
@@ -23,25 +24,37 @@ class CameraController: NSObject {
23
24
  var rearCamera: AVCaptureDevice?
24
25
  var rearCameraInput: AVCaptureDeviceInput?
25
26
 
27
+ var allDiscoveredDevices: [AVCaptureDevice] = []
28
+
26
29
  var fileVideoOutput: AVCaptureMovieFileOutput?
27
30
 
28
31
  var previewLayer: AVCaptureVideoPreviewLayer?
32
+ var gridOverlayView: GridOverlayView?
33
+ var focusIndicatorView: UIView?
29
34
 
30
35
  var flashMode = AVCaptureDevice.FlashMode.off
31
36
  var photoCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
32
37
 
33
38
  var sampleBufferCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
34
-
35
- var highResolutionOutput: Bool = false
39
+
40
+ // Add callback for detecting when first frame is ready
41
+ var firstFrameReadyCallback: (() -> Void)?
42
+ var hasReceivedFirstFrame = false
36
43
 
37
44
  var audioDevice: AVCaptureDevice?
38
45
  var audioInput: AVCaptureDeviceInput?
39
46
 
40
47
  var zoomFactor: CGFloat = 1.0
48
+ private var lastZoomUpdateTime: TimeInterval = 0
49
+ private let zoomUpdateThrottle: TimeInterval = 1.0 / 60.0 // 60 FPS max
41
50
 
42
51
  var videoFileURL: URL?
43
52
  private let saneMaxZoomFactor: CGFloat = 25.5
44
53
 
54
+ // Track output preparation status
55
+ private var outputsPrepared: Bool = false
56
+ private let outputPreparationQueue = DispatchQueue(label: "camera.output.preparation", qos: .utility)
57
+
45
58
  var isUsingMultiLensVirtualCamera: Bool {
46
59
  guard let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera else { return false }
47
60
  // A rear multi-lens virtual camera will have a min zoom of 1.0 but support wider angles
@@ -50,215 +63,458 @@ class CameraController: NSObject {
50
63
  }
51
64
 
52
65
  extension CameraController {
53
- func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, completionHandler: @escaping (Error?) -> Void) {
54
- func createCaptureSession() {
55
- self.captureSession = AVCaptureSession()
56
- }
57
-
58
- func configureCaptureDevices() throws {
59
- // Expanded device types to support more camera configurations
60
- let deviceTypes: [AVCaptureDevice.DeviceType] = [
61
- .builtInWideAngleCamera,
62
- .builtInUltraWideCamera,
63
- .builtInTelephotoCamera,
64
- .builtInDualCamera,
65
- .builtInDualWideCamera,
66
- .builtInTripleCamera,
67
- .builtInTrueDepthCamera
68
- ]
69
-
70
- let session = AVCaptureDevice.DiscoverySession(deviceTypes: deviceTypes, mediaType: AVMediaType.video, position: .unspecified)
71
-
72
- let cameras = session.devices.compactMap { $0 }
73
-
74
- // Log all found devices for debugging
75
- print("[CameraPreview] Found \(cameras.count) devices:")
76
- for camera in cameras {
77
- let constituentCount = camera.isVirtualDevice ? camera.constituentDevices.count : 1
78
- print("[CameraPreview] - \(camera.localizedName) (Position: \(camera.position.rawValue), Virtual: \(camera.isVirtualDevice), Lenses: \(constituentCount), Zoom: \(camera.minAvailableVideoZoomFactor)-\(camera.maxAvailableVideoZoomFactor))")
66
+ func prepareFullSession() {
67
+ // Only prepare if we don't already have a session
68
+ guard self.captureSession == nil else { return }
69
+
70
+ print("[CameraPreview] Preparing full camera session in background")
71
+
72
+ // 1. Create and configure session
73
+ self.captureSession = AVCaptureSession()
74
+
75
+ // 2. Pre-configure session preset (can be changed later) - use medium for faster startup
76
+ if captureSession!.canSetSessionPreset(.medium) {
77
+ captureSession!.sessionPreset = .medium // Start with medium, upgrade later if needed
78
+ } else if captureSession!.canSetSessionPreset(.high) {
79
+ captureSession!.sessionPreset = .high
80
+ }
81
+
82
+ // 3. Discover cameras on-demand (only when needed for better startup performance)
83
+ // discoverAndConfigureCameras() - moved to lazy loading
84
+
85
+ // // 4. Pre-create outputs asynchronously to avoid blocking camera opening
86
+ // outputPreparationQueue.async { [weak self] in
87
+ // self?.prepareOutputs()
88
+ // }
89
+
90
+ print("[CameraPreview] Full session preparation complete - cameras will be discovered on-demand, outputs being prepared asynchronously")
91
+ }
92
+
93
+ private func ensureCamerasDiscovered() {
94
+ // Rediscover cameras if the array is empty OR if the camera pointers are nil
95
+ guard allDiscoveredDevices.isEmpty || (rearCamera == nil && frontCamera == nil) else { return }
96
+ discoverAndConfigureCameras()
97
+ }
98
+
99
+ private func discoverAndConfigureCameras() {
100
+ let deviceTypes: [AVCaptureDevice.DeviceType] = [
101
+ .builtInWideAngleCamera,
102
+ .builtInUltraWideCamera,
103
+ .builtInTelephotoCamera,
104
+ .builtInDualCamera,
105
+ .builtInDualWideCamera,
106
+ .builtInTripleCamera,
107
+ .builtInTrueDepthCamera
108
+ ]
109
+
110
+ let session = AVCaptureDevice.DiscoverySession(deviceTypes: deviceTypes, mediaType: AVMediaType.video, position: .unspecified)
111
+ let cameras = session.devices.compactMap { $0 }
112
+
113
+ // Store all discovered devices for fast lookup later
114
+ self.allDiscoveredDevices = cameras
115
+
116
+ // Log all found devices for debugging
117
+ print("[CameraPreview] Found \(cameras.count) devices:")
118
+ for camera in cameras {
119
+ let constituentCount = camera.isVirtualDevice ? camera.constituentDevices.count : 1
120
+ print("[CameraPreview] - \(camera.localizedName) (Position: \(camera.position.rawValue), Virtual: \(camera.isVirtualDevice), Lenses: \(constituentCount))")
121
+ }
122
+
123
+ // Find best cameras
124
+ let rearVirtualDevices = cameras.filter { $0.position == .back && $0.isVirtualDevice }
125
+ let bestRearVirtualDevice = rearVirtualDevices.max { $0.constituentDevices.count < $1.constituentDevices.count }
126
+
127
+ self.frontCamera = cameras.first(where: { $0.position == .front })
128
+
129
+ if let bestCamera = bestRearVirtualDevice {
130
+ self.rearCamera = bestCamera
131
+ print("[CameraPreview] Selected best virtual rear camera: \(bestCamera.localizedName) with \(bestCamera.constituentDevices.count) physical cameras.")
132
+ } else if let firstRearCamera = cameras.first(where: { $0.position == .back }) {
133
+ self.rearCamera = firstRearCamera
134
+ print("[CameraPreview] WARN: No virtual rear camera found. Selected first available: \(firstRearCamera.localizedName)")
135
+ }
136
+
137
+ // Pre-configure focus modes
138
+ configureCameraFocus(camera: self.rearCamera)
139
+ configureCameraFocus(camera: self.frontCamera)
140
+ }
141
+
142
+ private func configureCameraFocus(camera: AVCaptureDevice?) {
143
+ guard let camera = camera else { return }
144
+
145
+ do {
146
+ try camera.lockForConfiguration()
147
+ if camera.isFocusModeSupported(.continuousAutoFocus) {
148
+ camera.focusMode = .continuousAutoFocus
79
149
  }
150
+ camera.unlockForConfiguration()
151
+ } catch {
152
+ print("[CameraPreview] Could not configure focus for \(camera.localizedName): \(error)")
153
+ }
154
+ }
80
155
 
81
- guard !cameras.isEmpty else {
82
- print("[CameraPreview] ERROR: No cameras found.")
83
- throw CameraControllerError.noCamerasAvailable
156
+ private func prepareOutputs() {
157
+ // Pre-create photo output with optimized settings
158
+ self.photoOutput = AVCapturePhotoOutput()
159
+ self.photoOutput?.isHighResolutionCaptureEnabled = false // Start with lower resolution for speed
160
+
161
+ // Configure photo output for better performance
162
+ if #available(iOS 13.0, *) {
163
+ self.photoOutput?.maxPhotoQualityPrioritization = .speed // Prioritize speed over quality initially
164
+ }
165
+
166
+ // Pre-create video output
167
+ self.fileVideoOutput = AVCaptureMovieFileOutput()
168
+
169
+ // Pre-create data output with optimized settings
170
+ self.dataOutput = AVCaptureVideoDataOutput()
171
+ self.dataOutput?.videoSettings = [
172
+ (kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
173
+ ]
174
+ self.dataOutput?.alwaysDiscardsLateVideoFrames = true
175
+
176
+ // Use a background queue for sample buffer processing to avoid blocking main thread
177
+ let dataOutputQueue = DispatchQueue(label: "camera.data.output", qos: .userInitiated)
178
+ self.dataOutput?.setSampleBufferDelegate(nil, queue: dataOutputQueue) // Will be set later
179
+
180
+ // Mark outputs as prepared
181
+ self.outputsPrepared = true
182
+
183
+ print("[CameraPreview] Outputs pre-created with performance optimizations")
184
+ }
185
+
186
+ private func waitForOutputsToBeReady() {
187
+ // If outputs are already prepared, return immediately
188
+ if outputsPrepared {
189
+ return
190
+ }
191
+
192
+ // Wait for outputs to be prepared with a timeout
193
+ let semaphore = DispatchSemaphore(value: 0)
194
+ var outputsReady = false
195
+
196
+ // Check for outputs readiness periodically
197
+ let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
198
+ if self.outputsPrepared {
199
+ outputsReady = true
200
+ timer.invalidate()
201
+ semaphore.signal()
84
202
  }
203
+ }
85
204
 
86
- // --- Corrected Device Selection Logic ---
87
- // Find the virtual device with the most constituent cameras (this is the most capable one)
88
- let rearVirtualDevices = cameras.filter { $0.position == .back && $0.isVirtualDevice }
89
- let bestRearVirtualDevice = rearVirtualDevices.max { $0.constituentDevices.count < $1.constituentDevices.count }
205
+ // Wait for outputs to be ready or timeout after 2 seconds
206
+ let timeout = DispatchTime.now() + .seconds(2)
207
+ let result = semaphore.wait(timeout: timeout)
90
208
 
91
- self.frontCamera = cameras.first(where: { $0.position == .front })
209
+ timer.invalidate()
92
210
 
93
- if let bestCamera = bestRearVirtualDevice {
94
- self.rearCamera = bestCamera
95
- print("[CameraPreview] Selected best virtual rear camera: \(bestCamera.localizedName) with \(bestCamera.constituentDevices.count) physical cameras.")
96
- } else if let firstRearCamera = cameras.first(where: { $0.position == .back }) {
97
- // Fallback for devices without a virtual camera system
98
- self.rearCamera = firstRearCamera
99
- print("[CameraPreview] WARN: No virtual rear camera found. Selected first available: \(firstRearCamera.localizedName)")
211
+ if result == .timedOut && !outputsReady {
212
+ print("[CameraPreview] Warning: Timed out waiting for outputs to be prepared, proceeding anyway")
213
+ // Fallback: prepare outputs synchronously if async preparation failed
214
+ if !outputsPrepared {
215
+ prepareOutputs()
100
216
  }
101
- // --- End of Correction ---
217
+ } else {
218
+ print("[CameraPreview] Outputs ready, proceeding with camera preparation")
219
+ }
220
+ }
102
221
 
103
- if let rearCamera = self.rearCamera {
104
- do {
105
- try rearCamera.lockForConfiguration()
106
- if rearCamera.isFocusModeSupported(.continuousAutoFocus) {
107
- rearCamera.focusMode = .continuousAutoFocus
108
- }
109
- rearCamera.unlockForConfiguration()
110
- } catch {
111
- print("[CameraPreview] WARN: Could not set focus mode on rear camera. \(error)")
112
- }
222
+ func upgradeQualitySettings() {
223
+ guard let captureSession = self.captureSession else { return }
224
+
225
+ // Upgrade session preset to high quality after initial startup
226
+ DispatchQueue.global(qos: .utility).async { [weak self] in
227
+ guard let self = self else { return }
228
+
229
+ captureSession.beginConfiguration()
230
+
231
+ // Upgrade to high quality preset
232
+ if captureSession.canSetSessionPreset(.high) && captureSession.sessionPreset != .high {
233
+ captureSession.sessionPreset = .high
234
+ print("[CameraPreview] Upgraded session preset to high quality")
113
235
  }
114
236
 
115
- if disableAudio == false {
116
- self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
237
+ // Upgrade photo output quality
238
+ if let photoOutput = self.photoOutput {
239
+ photoOutput.isHighResolutionCaptureEnabled = true
240
+ if #available(iOS 13.0, *) {
241
+ photoOutput.maxPhotoQualityPrioritization = .quality
242
+ }
243
+ print("[CameraPreview] Upgraded photo output to high resolution")
117
244
  }
245
+
246
+ captureSession.commitConfiguration()
118
247
  }
248
+ }
119
249
 
120
- func configureDeviceInputs() throws {
121
- guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
250
+ func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float = 1.0, completionHandler: @escaping (Error?) -> Void) {
251
+ // Use background queue for preparation to avoid blocking main thread
252
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
253
+ guard let self = self else {
254
+ DispatchQueue.main.async {
255
+ completionHandler(CameraControllerError.unknown)
256
+ }
257
+ return
258
+ }
122
259
 
123
- var selectedDevice: AVCaptureDevice?
260
+ do {
261
+ // Session and outputs already created in load(), just configure user-specific settings
262
+ if self.captureSession == nil {
263
+ // Fallback if prepareFullSession() wasn't called
264
+ self.prepareFullSession()
265
+ }
124
266
 
125
- // If deviceId is specified, use that specific device
126
- if let deviceId = deviceId {
127
- let allDevices = AVCaptureDevice.DiscoverySession(
128
- deviceTypes: [.builtInWideAngleCamera, .builtInUltraWideCamera, .builtInTelephotoCamera, .builtInDualCamera, .builtInDualWideCamera, .builtInTripleCamera, .builtInTrueDepthCamera],
129
- mediaType: .video,
130
- position: .unspecified
131
- ).devices
267
+ guard let captureSession = self.captureSession else {
268
+ throw CameraControllerError.captureSessionIsMissing
269
+ }
132
270
 
133
- selectedDevice = allDevices.first(where: { $0.uniqueID == deviceId })
134
- guard selectedDevice != nil else {
135
- throw CameraControllerError.noCamerasAvailable
271
+ print("[CameraPreview] Fast prepare - using pre-initialized session")
272
+
273
+ // Ensure outputs are prepared synchronously before starting session
274
+ self.prepareOutputs()
275
+ self.waitForOutputsToBeReady()
276
+
277
+ // Configure device inputs for the requested camera
278
+ try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
279
+
280
+ // Add data output early to detect first frame
281
+ captureSession.beginConfiguration()
282
+ if let dataOutput = self.dataOutput, captureSession.canAddOutput(dataOutput) {
283
+ captureSession.addOutput(dataOutput)
284
+ // Set delegate to detect first frame
285
+ dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
136
286
  }
137
- } else {
138
- // Use position-based selection
139
- if cameraPosition == "rear" {
140
- selectedDevice = self.rearCamera
141
- } else if cameraPosition == "front" {
142
- selectedDevice = self.frontCamera
287
+ captureSession.commitConfiguration()
288
+
289
+ // Reset first frame detection
290
+ self.hasReceivedFirstFrame = false
291
+
292
+ // Start the session on background thread (AVCaptureSession.startRunning() is thread-safe)
293
+ captureSession.startRunning()
294
+ print("[CameraPreview] Session started")
295
+
296
+ // Validate and set initial zoom level asynchronously
297
+ if initialZoomLevel != 1.0 {
298
+ DispatchQueue.main.async { [weak self] in
299
+ self?.setInitialZoom(level: initialZoomLevel)
300
+ }
301
+ }
302
+
303
+ // Call completion on main thread
304
+ DispatchQueue.main.async {
305
+ completionHandler(nil)
306
+
307
+ // Upgrade quality settings after a short delay for better user experience
308
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
309
+ guard let self = self else { return }
310
+
311
+ // Add remaining outputs to session and apply user settings
312
+ do {
313
+ try self.addRemainingOutputsToSession(cameraMode: cameraMode, aspectRatio: aspectRatio)
314
+ print("[CameraPreview] Remaining outputs successfully added to session")
315
+ } catch {
316
+ print("[CameraPreview] Error adding outputs to session: \(error)")
317
+ }
318
+
319
+ self.upgradeQualitySettings()
320
+ }
321
+ }
322
+ } catch {
323
+ DispatchQueue.main.async {
324
+ completionHandler(error)
143
325
  }
144
326
  }
327
+ }
328
+ }
329
+
330
+ private func setInitialZoom(level: Float) {
331
+ let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
332
+ guard let device = device else { return }
145
333
 
146
- guard let finalDevice = selectedDevice else {
147
- print("[CameraPreview] ERROR: No camera device selected for position: \(cameraPosition)")
334
+ let minZoom = device.minAvailableVideoZoomFactor
335
+ let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
336
+
337
+ guard CGFloat(level) >= minZoom && CGFloat(level) <= maxZoom else {
338
+ print("[CameraPreview] Initial zoom level \(level) out of range (\(minZoom)-\(maxZoom))")
339
+ return
340
+ }
341
+
342
+ do {
343
+ try device.lockForConfiguration()
344
+ device.videoZoomFactor = CGFloat(level)
345
+ device.unlockForConfiguration()
346
+ self.zoomFactor = CGFloat(level)
347
+ print("[CameraPreview] Set initial zoom to \(level)")
348
+ } catch {
349
+ print("[CameraPreview] Failed to set initial zoom: \(error)")
350
+ }
351
+ }
352
+
353
+ private func configureDeviceInputs(cameraPosition: String, deviceId: String?, disableAudio: Bool) throws {
354
+ guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
355
+
356
+ // Ensure cameras are discovered before configuring inputs
357
+ ensureCamerasDiscovered()
358
+
359
+ var selectedDevice: AVCaptureDevice?
360
+
361
+ // If deviceId is specified, find that specific device from discovered devices
362
+ if let deviceId = deviceId {
363
+ selectedDevice = self.allDiscoveredDevices.first(where: { $0.uniqueID == deviceId })
364
+ guard selectedDevice != nil else {
365
+ print("[CameraPreview] ERROR: Device with ID \(deviceId) not found in discovered devices")
148
366
  throw CameraControllerError.noCamerasAvailable
149
367
  }
368
+ } else {
369
+ // Use position-based selection from discovered cameras
370
+ if cameraPosition == "rear" {
371
+ selectedDevice = self.rearCamera
372
+ } else if cameraPosition == "front" {
373
+ selectedDevice = self.frontCamera
374
+ }
375
+ }
150
376
 
151
- print("[CameraPreview] Configuring device: \(finalDevice.localizedName)")
152
- let deviceInput = try AVCaptureDeviceInput(device: finalDevice)
377
+ guard let finalDevice = selectedDevice else {
378
+ print("[CameraPreview] ERROR: No camera device selected for position: \(cameraPosition)")
379
+ throw CameraControllerError.noCamerasAvailable
380
+ }
153
381
 
154
- if captureSession.canAddInput(deviceInput) {
155
- captureSession.addInput(deviceInput)
382
+ print("[CameraPreview] Configuring device: \(finalDevice.localizedName)")
383
+ let deviceInput = try AVCaptureDeviceInput(device: finalDevice)
156
384
 
157
- if finalDevice.position == .front {
158
- self.frontCameraInput = deviceInput
159
- self.frontCamera = finalDevice
160
- self.currentCameraPosition = .front
161
- } else {
162
- self.rearCameraInput = deviceInput
163
- self.rearCamera = finalDevice
164
- self.currentCameraPosition = .rear
385
+ if captureSession.canAddInput(deviceInput) {
386
+ captureSession.addInput(deviceInput)
165
387
 
166
- // --- Corrected Initial Zoom Logic ---
167
- try finalDevice.lockForConfiguration()
168
- if finalDevice.isFocusModeSupported(.continuousAutoFocus) {
169
- finalDevice.focusMode = .continuousAutoFocus
170
- }
388
+ if finalDevice.position == .front {
389
+ self.frontCameraInput = deviceInput
390
+ self.currentCameraPosition = .front
391
+ } else {
392
+ self.rearCameraInput = deviceInput
393
+ self.currentCameraPosition = .rear
171
394
 
172
- // On a multi-camera system, a zoom factor of 2.0 often corresponds to the standard "1x" wide-angle lens.
173
- // We set this as the default to provide a familiar starting point for users.
174
- let defaultWideAngleZoom: CGFloat = 2.0
175
- if finalDevice.isVirtualDevice && finalDevice.constituentDevices.count > 1 && finalDevice.videoZoomFactor != defaultWideAngleZoom {
176
- // Check if 2.0 is a valid zoom factor before setting it.
177
- if defaultWideAngleZoom >= finalDevice.minAvailableVideoZoomFactor && defaultWideAngleZoom <= finalDevice.maxAvailableVideoZoomFactor {
178
- print("[CameraPreview] Multi-camera system detected. Setting initial zoom to \(defaultWideAngleZoom) (standard wide-angle).")
179
- finalDevice.videoZoomFactor = defaultWideAngleZoom
180
- }
395
+ // Configure zoom for multi-camera systems - simplified and faster
396
+ if finalDevice.isVirtualDevice && finalDevice.constituentDevices.count > 1 {
397
+ try finalDevice.lockForConfiguration()
398
+ let defaultWideAngleZoom: CGFloat = 1.0 // Changed from 2.0 to 1.0 for faster startup
399
+ if defaultWideAngleZoom >= finalDevice.minAvailableVideoZoomFactor && defaultWideAngleZoom <= finalDevice.maxAvailableVideoZoomFactor {
400
+ print("[CameraPreview] Setting initial zoom to \(defaultWideAngleZoom)")
401
+ finalDevice.videoZoomFactor = defaultWideAngleZoom
181
402
  }
182
403
  finalDevice.unlockForConfiguration()
183
- // --- End of Correction ---
184
404
  }
185
- } else {
186
- print("[CameraPreview] ERROR: Cannot add device input to session.")
187
- throw CameraControllerError.inputsAreInvalid
188
405
  }
406
+ } else {
407
+ throw CameraControllerError.inputsAreInvalid
408
+ }
189
409
 
190
- // Add audio input
191
- if disableAudio == false {
192
- if let audioDevice = self.audioDevice {
193
- self.audioInput = try AVCaptureDeviceInput(device: audioDevice)
194
- if captureSession.canAddInput(self.audioInput!) {
195
- captureSession.addInput(self.audioInput!)
196
- } else {
197
- throw CameraControllerError.inputsAreInvalid
198
- }
410
+ // Add audio input if needed
411
+ if !disableAudio {
412
+ if self.audioDevice == nil {
413
+ self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
414
+ }
415
+ if let audioDevice = self.audioDevice {
416
+ self.audioInput = try AVCaptureDeviceInput(device: audioDevice)
417
+ if captureSession.canAddInput(self.audioInput!) {
418
+ captureSession.addInput(self.audioInput!)
419
+ } else {
420
+ throw CameraControllerError.inputsAreInvalid
199
421
  }
200
422
  }
201
423
  }
424
+ }
202
425
 
203
- func configurePhotoOutput(cameraMode: Bool) throws {
204
- guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
426
+ private func addRemainingOutputsToSession(cameraMode: Bool, aspectRatio: String?) throws {
427
+ guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
205
428
 
206
- // TODO: check if that really useful
207
- if !cameraMode && self.highResolutionOutput && captureSession.canSetSessionPreset(.photo) {
208
- captureSession.sessionPreset = .photo
209
- } else if cameraMode && self.highResolutionOutput && captureSession.canSetSessionPreset(.high) {
210
- captureSession.sessionPreset = .high
211
- }
429
+ // Begin configuration to batch all changes
430
+ captureSession.beginConfiguration()
431
+ defer { captureSession.commitConfiguration() }
212
432
 
213
- self.photoOutput = AVCapturePhotoOutput()
214
- self.photoOutput!.setPreparedPhotoSettingsArray([AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])], completionHandler: nil)
215
- self.photoOutput?.isHighResolutionCaptureEnabled = self.highResolutionOutput
216
- if captureSession.canAddOutput(self.photoOutput!) { captureSession.addOutput(self.photoOutput!) }
433
+ // Update session preset based on aspect ratio if needed
434
+ var targetPreset: AVCaptureSession.Preset = .high // Default to high quality
217
435
 
218
- let fileVideoOutput = AVCaptureMovieFileOutput()
219
- if captureSession.canAddOutput(fileVideoOutput) {
220
- captureSession.addOutput(fileVideoOutput)
221
- self.fileVideoOutput = fileVideoOutput
436
+ if let aspectRatio = aspectRatio {
437
+ switch aspectRatio {
438
+ case "16:9":
439
+ targetPreset = captureSession.canSetSessionPreset(.hd1920x1080) ? .hd1920x1080 : .high
440
+ case "4:3":
441
+ targetPreset = captureSession.canSetSessionPreset(.photo) ? .photo : .high
442
+ default:
443
+ targetPreset = .high
222
444
  }
223
- captureSession.startRunning()
224
445
  }
225
446
 
226
- func configureDataOutput() throws {
227
- guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
228
-
229
- self.dataOutput = AVCaptureVideoDataOutput()
230
- self.dataOutput?.videoSettings = [
231
- (kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
232
- ]
233
- self.dataOutput?.alwaysDiscardsLateVideoFrames = true
234
- if captureSession.canAddOutput(self.dataOutput!) {
235
- captureSession.addOutput(self.dataOutput!)
236
- }
447
+ // Always try to set the best preset available
448
+ if captureSession.canSetSessionPreset(targetPreset) {
449
+ captureSession.sessionPreset = targetPreset
450
+ print("[CameraPreview] Updated preset to \(targetPreset) for aspect ratio: \(aspectRatio ?? "default")")
451
+ } else if captureSession.canSetSessionPreset(.high) {
452
+ // Fallback to high if target preset not available
453
+ captureSession.sessionPreset = .high
454
+ print("[CameraPreview] Fallback to high preset")
455
+ }
237
456
 
238
- captureSession.commitConfiguration()
457
+ // Add photo output (already created in prepareOutputs)
458
+ if let photoOutput = self.photoOutput, captureSession.canAddOutput(photoOutput) {
459
+ photoOutput.isHighResolutionCaptureEnabled = true
460
+ captureSession.addOutput(photoOutput)
461
+ }
239
462
 
240
- let queue = DispatchQueue(label: "DataOutput", attributes: [])
241
- self.dataOutput?.setSampleBufferDelegate(self, queue: queue)
463
+ // Add video output only if camera mode is enabled
464
+ if cameraMode, let videoOutput = self.fileVideoOutput, captureSession.canAddOutput(videoOutput) {
465
+ captureSession.addOutput(videoOutput)
242
466
  }
467
+ // Data output was already added in prepare() to detect first frame
468
+ }
469
+
470
+ private func addOutputsToSession(cameraMode: Bool, aspectRatio: String?) throws {
471
+ guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
243
472
 
244
- DispatchQueue(label: "prepare").async {
245
- do {
246
- createCaptureSession()
247
- try configureCaptureDevices()
248
- try configureDeviceInputs()
249
- try configurePhotoOutput(cameraMode: cameraMode)
250
- try configureDataOutput()
251
- // try configureVideoOutput()
252
- } catch {
253
- DispatchQueue.main.async {
254
- completionHandler(error)
255
- }
473
+ // Begin configuration to batch all changes
474
+ captureSession.beginConfiguration()
475
+ defer { captureSession.commitConfiguration() }
256
476
 
257
- return
477
+ // Update session preset based on aspect ratio if needed
478
+ var targetPreset: AVCaptureSession.Preset = .high // Default to high quality
479
+
480
+ if let aspectRatio = aspectRatio {
481
+ switch aspectRatio {
482
+ case "16:9":
483
+ targetPreset = captureSession.canSetSessionPreset(.hd1920x1080) ? .hd1920x1080 : .high
484
+ case "4:3":
485
+ targetPreset = captureSession.canSetSessionPreset(.photo) ? .photo : .high
486
+ default:
487
+ targetPreset = .high
258
488
  }
489
+ }
259
490
 
491
+ // Always try to set the best preset available
492
+ if captureSession.canSetSessionPreset(targetPreset) {
493
+ captureSession.sessionPreset = targetPreset
494
+ print("[CameraPreview] Updated preset to \(targetPreset) for aspect ratio: \(aspectRatio ?? "default")")
495
+ } else if captureSession.canSetSessionPreset(.high) {
496
+ // Fallback to high if target preset not available
497
+ captureSession.sessionPreset = .high
498
+ print("[CameraPreview] Fallback to high preset")
499
+ }
500
+
501
+ // Add photo output (already created in prepareOutputs)
502
+ if let photoOutput = self.photoOutput, captureSession.canAddOutput(photoOutput) {
503
+ photoOutput.isHighResolutionCaptureEnabled = true
504
+ captureSession.addOutput(photoOutput)
505
+ }
506
+
507
+ // Add video output only if camera mode is enabled
508
+ if cameraMode, let videoOutput = self.fileVideoOutput, captureSession.canAddOutput(videoOutput) {
509
+ captureSession.addOutput(videoOutput)
510
+ }
511
+
512
+ // Add data output
513
+ if let dataOutput = self.dataOutput, captureSession.canAddOutput(dataOutput) {
514
+ captureSession.addOutput(dataOutput)
515
+ // Set delegate after outputs are added for better performance
260
516
  DispatchQueue.main.async {
261
- completionHandler(nil)
517
+ dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
262
518
  }
263
519
  }
264
520
  }
@@ -266,13 +522,51 @@ extension CameraController {
266
522
  func displayPreview(on view: UIView) throws {
267
523
  guard let captureSession = self.captureSession, captureSession.isRunning else { throw CameraControllerError.captureSessionIsMissing }
268
524
 
269
- self.previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
270
- self.previewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
525
+ print("[CameraPreview] displayPreview called with view frame: \(view.frame)")
526
+
527
+ // Create and configure preview layer in one go
528
+ let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
529
+
530
+ // Batch all layer configuration to avoid multiple redraws
531
+ CATransaction.begin()
532
+ CATransaction.setDisableActions(true)
533
+
534
+ previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
535
+ previewLayer.connection?.videoOrientation = .portrait
536
+ previewLayer.isOpaque = true
537
+ previewLayer.contentsScale = UIScreen.main.scale
538
+ previewLayer.frame = view.bounds
539
+
540
+ // Insert layer and store reference
541
+ view.layer.insertSublayer(previewLayer, at: 0)
542
+ self.previewLayer = previewLayer
543
+
544
+ CATransaction.commit()
545
+
546
+ print("[CameraPreview] Set preview layer frame to view bounds: \(view.bounds)")
547
+ print("[CameraPreview] Session preset: \(captureSession.sessionPreset.rawValue)")
548
+
549
+ // Update video orientation asynchronously to avoid blocking
550
+ DispatchQueue.main.async { [weak self] in
551
+ self?.updateVideoOrientation()
552
+ }
553
+ }
554
+
555
+ func addGridOverlay(to view: UIView, gridMode: String) {
556
+ removeGridOverlay()
271
557
 
272
- view.layer.insertSublayer(self.previewLayer!, at: 0)
273
- self.previewLayer?.frame = view.frame
558
+ // Disable animation for grid overlay creation and positioning
559
+ CATransaction.begin()
560
+ CATransaction.setDisableActions(true)
561
+ gridOverlayView = GridOverlayView(frame: view.bounds)
562
+ gridOverlayView?.gridMode = gridMode
563
+ view.addSubview(gridOverlayView!)
564
+ CATransaction.commit()
565
+ }
274
566
 
275
- updateVideoOrientation()
567
+ func removeGridOverlay() {
568
+ gridOverlayView?.removeFromSuperview()
569
+ gridOverlayView = nil
276
570
  }
277
571
 
278
572
  func setupGestures(target: UIView, enableZoom: Bool) {
@@ -291,6 +585,10 @@ extension CameraController {
291
585
  func setupPinchGesture(target: UIView, selector: Selector, delegate: UIGestureRecognizerDelegate?) {
292
586
  let pinchGesture = UIPinchGestureRecognizer(target: self, action: selector)
293
587
  pinchGesture.delegate = delegate
588
+ // Optimize gesture recognition for better performance
589
+ pinchGesture.delaysTouchesBegan = false
590
+ pinchGesture.delaysTouchesEnded = false
591
+ pinchGesture.cancelsTouchesInView = false
294
592
  target.addGestureRecognizer(pinchGesture)
295
593
  }
296
594
 
@@ -298,8 +596,8 @@ extension CameraController {
298
596
  if Thread.isMainThread {
299
597
  updateVideoOrientationOnMainThread()
300
598
  } else {
301
- DispatchQueue.main.async { [weak self] in
302
- self?.updateVideoOrientationOnMainThread()
599
+ DispatchQueue.main.sync {
600
+ self.updateVideoOrientationOnMainThread()
303
601
  }
304
602
  }
305
603
  }
@@ -340,7 +638,7 @@ extension CameraController {
340
638
 
341
639
  // Ensure we have the necessary cameras
342
640
  guard (currentCameraPosition == .front && rearCamera != nil) ||
343
- (currentCameraPosition == .rear && frontCamera != nil) else {
641
+ (currentCameraPosition == .rear && frontCamera != nil) else {
344
642
  throw CameraControllerError.noCamerasAvailable
345
643
  }
346
644
 
@@ -356,9 +654,7 @@ extension CameraController {
356
654
  captureSession.commitConfiguration()
357
655
  // Restart the session if it was running before
358
656
  if wasRunning {
359
- DispatchQueue.global(qos: .userInitiated).async { [weak self] in
360
- self?.captureSession?.startRunning()
361
- }
657
+ captureSession.startRunning()
362
658
  }
363
659
  }
364
660
 
@@ -368,7 +664,7 @@ extension CameraController {
368
664
  // Remove only video inputs
369
665
  captureSession.inputs.forEach { input in
370
666
  if (input as? AVCaptureDeviceInput)?.device.hasMediaType(.video) ?? false {
371
- captureSession.removeInput(input)
667
+ captureSession.removeInput(input)
372
668
  }
373
669
  }
374
670
 
@@ -387,7 +683,7 @@ extension CameraController {
387
683
  rearCamera.unlockForConfiguration()
388
684
 
389
685
  if let newInput = try? AVCaptureDeviceInput(device: rearCamera),
390
- captureSession.canAddInput(newInput) {
686
+ captureSession.canAddInput(newInput) {
391
687
  captureSession.addInput(newInput)
392
688
  rearCameraInput = newInput
393
689
  self.currentCameraPosition = .rear
@@ -407,7 +703,7 @@ extension CameraController {
407
703
  frontCamera.unlockForConfiguration()
408
704
 
409
705
  if let newInput = try? AVCaptureDeviceInput(device: frontCamera),
410
- captureSession.canAddInput(newInput) {
706
+ captureSession.canAddInput(newInput) {
411
707
  captureSession.addInput(newInput)
412
708
  frontCameraInput = newInput
413
709
  self.currentCameraPosition = .front
@@ -422,20 +718,186 @@ extension CameraController {
422
718
  }
423
719
 
424
720
  // Update video orientation
425
- DispatchQueue.main.async { [weak self] in
426
- self?.updateVideoOrientation()
427
- }
721
+ self.updateVideoOrientation()
428
722
  }
429
723
 
430
- func captureImage(completion: @escaping (UIImage?, Error?) -> Void) {
431
- guard let captureSession = captureSession, captureSession.isRunning else { completion(nil, CameraControllerError.captureSessionIsMissing); return }
724
+ func captureImage(width: Int?, height: Int?, aspectRatio: String?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Error?) -> Void) {
725
+ guard let photoOutput = self.photoOutput else {
726
+ completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
727
+ return
728
+ }
729
+
432
730
  let settings = AVCapturePhotoSettings()
433
731
 
434
- settings.flashMode = self.flashMode
435
- settings.isHighResolutionPhotoEnabled = self.highResolutionOutput
732
+ // Apply the current flash mode to the photo settings
733
+ // Check if the current device supports flash
734
+ var currentCamera: AVCaptureDevice?
735
+ switch currentCameraPosition {
736
+ case .front:
737
+ currentCamera = self.frontCamera
738
+ case .rear:
739
+ currentCamera = self.rearCamera
740
+ default:
741
+ break
742
+ }
436
743
 
437
- self.photoOutput?.capturePhoto(with: settings, delegate: self)
438
- self.photoCaptureCompletionBlock = completion
744
+ // Only apply flash if the device has flash and the flash mode is supported
745
+ if let device = currentCamera, device.hasFlash {
746
+ let supportedFlashModes = photoOutput.supportedFlashModes
747
+ if supportedFlashModes.contains(self.flashMode) {
748
+ settings.flashMode = self.flashMode
749
+ }
750
+ }
751
+
752
+ self.photoCaptureCompletionBlock = { (image, error) in
753
+ if let error = error {
754
+ completion(nil, error)
755
+ return
756
+ }
757
+
758
+ guard let image = image else {
759
+ completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to capture image"]))
760
+ return
761
+ }
762
+
763
+ if let location = gpsLocation {
764
+ self.addGPSMetadata(to: image, location: location)
765
+ }
766
+
767
+ var finalImage = image
768
+
769
+ // When no dimensions are specified, crop to match what's visible in preview
770
+ if width == nil && height == nil {
771
+ // If aspectRatio is specified, use it to crop
772
+ if let aspectRatio = aspectRatio {
773
+ let components = aspectRatio.split(separator: ":").compactMap { Double($0) }
774
+ if components.count == 2 {
775
+ // For capture in portrait orientation, swap the aspect ratio (16:9 becomes 9:16)
776
+ let isPortrait = image.size.height > image.size.width
777
+ let targetAspectRatio = isPortrait ? components[1] / components[0] : components[0] / components[1]
778
+ let imageSize = image.size
779
+ let originalAspectRatio = imageSize.width / imageSize.height
780
+
781
+ var targetSize = imageSize
782
+
783
+ if originalAspectRatio > targetAspectRatio {
784
+ // Original is wider than target - fit by height
785
+ targetSize.width = imageSize.height * CGFloat(targetAspectRatio)
786
+ } else {
787
+ // Original is taller than target - fit by width
788
+ targetSize.height = imageSize.width / CGFloat(targetAspectRatio)
789
+ }
790
+
791
+ // Center crop the image
792
+ if let croppedImage = self.cropImageToAspectRatio(image: image, targetSize: targetSize) {
793
+ finalImage = croppedImage
794
+ }
795
+ }
796
+ } else {
797
+ // No aspectRatio specified but we need to crop to match preview
798
+ // Since preview uses resizeAspectFill, we need to crop the captured image
799
+ // to match what's visible in the preview
800
+ if let previewLayer = self.previewLayer,
801
+ let croppedImage = self.cropImageToMatchPreview(image: image, previewLayer: previewLayer) {
802
+ finalImage = croppedImage
803
+ }
804
+ }
805
+ } else if let width = width, let height = height {
806
+ finalImage = self.resizeImage(image: image, to: CGSize(width: width, height: height))!
807
+ }
808
+
809
+ completion(finalImage, nil)
810
+ }
811
+
812
+ photoOutput.capturePhoto(with: settings, delegate: self)
813
+ }
814
+
815
+ func addGPSMetadata(to image: UIImage, location: CLLocation) {
816
+ guard let jpegData = image.jpegData(compressionQuality: 1.0),
817
+ let source = CGImageSourceCreateWithData(jpegData as CFData, nil),
818
+ let uti = CGImageSourceGetType(source) else { return }
819
+
820
+ var metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] ?? [:]
821
+
822
+ let formatter = DateFormatter()
823
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
824
+ formatter.timeZone = TimeZone(abbreviation: "UTC")
825
+
826
+ let gpsDict: [String: Any] = [
827
+ kCGImagePropertyGPSLatitude as String: abs(location.coordinate.latitude),
828
+ kCGImagePropertyGPSLatitudeRef as String: location.coordinate.latitude >= 0 ? "N" : "S",
829
+ kCGImagePropertyGPSLongitude as String: abs(location.coordinate.longitude),
830
+ kCGImagePropertyGPSLongitudeRef as String: location.coordinate.longitude >= 0 ? "E" : "W",
831
+ kCGImagePropertyGPSTimeStamp as String: formatter.string(from: location.timestamp),
832
+ kCGImagePropertyGPSAltitude as String: location.altitude,
833
+ kCGImagePropertyGPSAltitudeRef as String: location.altitude >= 0 ? 0 : 1
834
+ ]
835
+
836
+ metadata[kCGImagePropertyGPSDictionary as String] = gpsDict
837
+
838
+ let destData = NSMutableData()
839
+ guard let destination = CGImageDestinationCreateWithData(destData, uti, 1, nil) else { return }
840
+ CGImageDestinationAddImageFromSource(destination, source, 0, metadata as CFDictionary)
841
+ CGImageDestinationFinalize(destination)
842
+ }
843
+
844
+ func resizeImage(image: UIImage, to size: CGSize) -> UIImage? {
845
+ let renderer = UIGraphicsImageRenderer(size: size)
846
+ let resizedImage = renderer.image { (_) in
847
+ image.draw(in: CGRect(origin: .zero, size: size))
848
+ }
849
+ return resizedImage
850
+ }
851
+
852
+ func cropImageToAspectRatio(image: UIImage, targetSize: CGSize) -> UIImage? {
853
+ let imageSize = image.size
854
+
855
+ // Calculate the crop rect - center crop
856
+ let xOffset = (imageSize.width - targetSize.width) / 2
857
+ let yOffset = (imageSize.height - targetSize.height) / 2
858
+ let cropRect = CGRect(x: xOffset, y: yOffset, width: targetSize.width, height: targetSize.height)
859
+
860
+ // Create the cropped image
861
+ guard let cgImage = image.cgImage,
862
+ let croppedCGImage = cgImage.cropping(to: cropRect) else {
863
+ return nil
864
+ }
865
+
866
+ return UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
867
+ }
868
+
869
+ func cropImageToMatchPreview(image: UIImage, previewLayer: AVCaptureVideoPreviewLayer) -> UIImage? {
870
+ // Get the dimensions of the preview layer
871
+ let previewBounds = previewLayer.bounds
872
+ let previewAspectRatio = previewBounds.width / previewBounds.height
873
+
874
+ // Get the dimensions of the captured image
875
+ let imageSize = image.size
876
+ let imageAspectRatio = imageSize.width / imageSize.height
877
+
878
+ // Since we're using resizeAspectFill, we need to calculate what portion of the image
879
+ // is visible in the preview
880
+ var cropRect: CGRect
881
+
882
+ if imageAspectRatio > previewAspectRatio {
883
+ // Image is wider than preview - crop horizontally
884
+ let visibleWidth = imageSize.height * previewAspectRatio
885
+ let xOffset = (imageSize.width - visibleWidth) / 2
886
+ cropRect = CGRect(x: xOffset, y: 0, width: visibleWidth, height: imageSize.height)
887
+ } else {
888
+ // Image is taller than preview - crop vertically
889
+ let visibleHeight = imageSize.width / previewAspectRatio
890
+ let yOffset = (imageSize.height - visibleHeight) / 2
891
+ cropRect = CGRect(x: 0, y: yOffset, width: imageSize.width, height: visibleHeight)
892
+ }
893
+
894
+ // Create the cropped image
895
+ guard let cgImage = image.cgImage,
896
+ let croppedCGImage = cgImage.cropping(to: cropRect) else {
897
+ return nil
898
+ }
899
+
900
+ return UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
439
901
  }
440
902
 
441
903
  func captureSample(completion: @escaping (UIImage?, Error?) -> Void) {
@@ -612,7 +1074,7 @@ extension CameraController {
612
1074
  )
613
1075
  }
614
1076
 
615
- func setZoom(level: CGFloat, ramp: Bool) throws {
1077
+ func setZoom(level: CGFloat, ramp: Bool, autoFocus: Bool = true) throws {
616
1078
  var currentCamera: AVCaptureDevice?
617
1079
  switch currentCameraPosition {
618
1080
  case .front:
@@ -633,7 +1095,8 @@ extension CameraController {
633
1095
  try device.lockForConfiguration()
634
1096
 
635
1097
  if ramp {
636
- device.ramp(toVideoZoomFactor: zoomLevel, withRate: 1.0)
1098
+ // Use a very fast ramp rate for immediate response
1099
+ device.ramp(toVideoZoomFactor: zoomLevel, withRate: 8.0)
637
1100
  } else {
638
1101
  device.videoZoomFactor = zoomLevel
639
1102
  }
@@ -642,11 +1105,123 @@ extension CameraController {
642
1105
 
643
1106
  // Update our internal zoom factor tracking
644
1107
  self.zoomFactor = zoomLevel
1108
+
1109
+ // Trigger autofocus after zoom if requested
1110
+ if autoFocus {
1111
+ self.triggerAutoFocus()
1112
+ }
645
1113
  } catch {
646
1114
  throw CameraControllerError.invalidOperation
647
1115
  }
648
1116
  }
649
1117
 
1118
+ private func triggerAutoFocus() {
1119
+ var currentCamera: AVCaptureDevice?
1120
+ switch currentCameraPosition {
1121
+ case .front:
1122
+ currentCamera = self.frontCamera
1123
+ case .rear:
1124
+ currentCamera = self.rearCamera
1125
+ default: break
1126
+ }
1127
+
1128
+ guard let device = currentCamera else {
1129
+ return
1130
+ }
1131
+
1132
+ // Focus on the center of the preview (0.5, 0.5)
1133
+ let centerPoint = CGPoint(x: 0.5, y: 0.5)
1134
+
1135
+ do {
1136
+ try device.lockForConfiguration()
1137
+
1138
+ // Set focus mode to auto if supported
1139
+ if device.isFocusModeSupported(.autoFocus) {
1140
+ device.focusMode = .autoFocus
1141
+ if device.isFocusPointOfInterestSupported {
1142
+ device.focusPointOfInterest = centerPoint
1143
+ }
1144
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
1145
+ device.focusMode = .continuousAutoFocus
1146
+ if device.isFocusPointOfInterestSupported {
1147
+ device.focusPointOfInterest = centerPoint
1148
+ }
1149
+ }
1150
+
1151
+ // Also set exposure point if supported
1152
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1153
+ device.exposureMode = .autoExpose
1154
+ device.exposurePointOfInterest = centerPoint
1155
+ } else if device.isExposureModeSupported(.continuousAutoExposure) {
1156
+ device.exposureMode = .continuousAutoExposure
1157
+ if device.isExposurePointOfInterestSupported {
1158
+ device.exposurePointOfInterest = centerPoint
1159
+ }
1160
+ }
1161
+
1162
+ device.unlockForConfiguration()
1163
+ } catch {
1164
+ // Silently ignore errors during autofocus
1165
+ }
1166
+ }
1167
+
1168
+ func setFocus(at point: CGPoint, showIndicator: Bool = false, in view: UIView? = nil) throws {
1169
+ // Validate that coordinates are within bounds (0-1 range for device coordinates)
1170
+ if point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1 {
1171
+ print("setFocus: Coordinates out of bounds - x: \(point.x), y: \(point.y)")
1172
+ throw CameraControllerError.invalidOperation
1173
+ }
1174
+
1175
+ var currentCamera: AVCaptureDevice?
1176
+ switch currentCameraPosition {
1177
+ case .front:
1178
+ currentCamera = self.frontCamera
1179
+ case .rear:
1180
+ currentCamera = self.rearCamera
1181
+ default: break
1182
+ }
1183
+
1184
+ guard let device = currentCamera else {
1185
+ throw CameraControllerError.noCamerasAvailable
1186
+ }
1187
+
1188
+ guard device.isFocusPointOfInterestSupported else {
1189
+ // Device doesn't support focus point of interest
1190
+ return
1191
+ }
1192
+
1193
+ // Show focus indicator if requested and view is provided - only after validation
1194
+ if showIndicator, let view = view, let previewLayer = self.previewLayer {
1195
+ // Convert the device point to layer point for indicator display
1196
+ let layerPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: point)
1197
+ showFocusIndicator(at: layerPoint, in: view)
1198
+ }
1199
+
1200
+ do {
1201
+ try device.lockForConfiguration()
1202
+
1203
+ // Set focus mode to auto if supported
1204
+ if device.isFocusModeSupported(.autoFocus) {
1205
+ device.focusMode = .autoFocus
1206
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
1207
+ device.focusMode = .continuousAutoFocus
1208
+ }
1209
+
1210
+ // Set the focus point
1211
+ device.focusPointOfInterest = point
1212
+
1213
+ // Also set exposure point if supported
1214
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1215
+ device.exposureMode = .autoExpose
1216
+ device.exposurePointOfInterest = point
1217
+ }
1218
+
1219
+ device.unlockForConfiguration()
1220
+ } catch {
1221
+ throw CameraControllerError.unknown
1222
+ }
1223
+ }
1224
+
650
1225
  func getFlashMode() throws -> String {
651
1226
  switch self.flashMode {
652
1227
  case .off:
@@ -677,7 +1252,7 @@ extension CameraController {
677
1252
 
678
1253
  return device.uniqueID
679
1254
  }
680
-
1255
+
681
1256
  func getCurrentLensInfo() throws -> (focalLength: Float, deviceType: String, baseZoomRatio: Float) {
682
1257
  var currentCamera: AVCaptureDevice?
683
1258
  switch currentCameraPosition {
@@ -757,9 +1332,7 @@ extension CameraController {
757
1332
  captureSession.commitConfiguration()
758
1333
  // Restart the session if it was running before
759
1334
  if wasRunning {
760
- DispatchQueue.global(qos: .userInitiated).async { [weak self] in
761
- self?.captureSession?.startRunning()
762
- }
1335
+ captureSession.startRunning()
763
1336
  }
764
1337
  }
765
1338
 
@@ -806,13 +1379,9 @@ extension CameraController {
806
1379
  }
807
1380
 
808
1381
  // Update video orientation
809
- DispatchQueue.main.async { [weak self] in
810
- self?.updateVideoOrientation()
811
- }
1382
+ self.updateVideoOrientation()
812
1383
  }
813
1384
 
814
-
815
-
816
1385
  func cleanup() {
817
1386
  if let captureSession = self.captureSession {
818
1387
  captureSession.stopRunning()
@@ -823,6 +1392,9 @@ extension CameraController {
823
1392
  self.previewLayer?.removeFromSuperlayer()
824
1393
  self.previewLayer = nil
825
1394
 
1395
+ self.focusIndicatorView?.removeFromSuperview()
1396
+ self.focusIndicatorView = nil
1397
+
826
1398
  self.frontCameraInput = nil
827
1399
  self.rearCameraInput = nil
828
1400
  self.audioInput = nil
@@ -830,6 +1402,7 @@ extension CameraController {
830
1402
  self.frontCamera = nil
831
1403
  self.rearCamera = nil
832
1404
  self.audioDevice = nil
1405
+ self.allDiscoveredDevices = []
833
1406
 
834
1407
  self.dataOutput = nil
835
1408
  self.photoOutput = nil
@@ -837,6 +1410,13 @@ extension CameraController {
837
1410
 
838
1411
  self.captureSession = nil
839
1412
  self.currentCameraPosition = nil
1413
+
1414
+ // Reset output preparation status
1415
+ self.outputsPrepared = false
1416
+
1417
+ // Reset first frame detection
1418
+ self.hasReceivedFirstFrame = false
1419
+ self.firstFrameReadyCallback = nil
840
1420
  }
841
1421
 
842
1422
  func captureVideo() throws {
@@ -913,6 +1493,11 @@ extension CameraController: UIGestureRecognizerDelegate {
913
1493
  let point = tap.location(in: tap.view)
914
1494
  let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: point)
915
1495
 
1496
+ // Show focus indicator at the tap point
1497
+ if let view = tap.view {
1498
+ showFocusIndicator(at: point, in: view)
1499
+ }
1500
+
916
1501
  do {
917
1502
  try device.lockForConfiguration()
918
1503
  defer { device.unlockForConfiguration() }
@@ -933,6 +1518,54 @@ extension CameraController: UIGestureRecognizerDelegate {
933
1518
  }
934
1519
  }
935
1520
 
1521
+ private func showFocusIndicator(at point: CGPoint, in view: UIView) {
1522
+ // Remove any existing focus indicator
1523
+ focusIndicatorView?.removeFromSuperview()
1524
+
1525
+ // Create a new focus indicator
1526
+ let indicator = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
1527
+ indicator.center = point
1528
+ indicator.layer.borderColor = UIColor.yellow.cgColor
1529
+ indicator.layer.borderWidth = 2.0
1530
+ indicator.layer.cornerRadius = 40
1531
+ indicator.backgroundColor = UIColor.clear
1532
+ indicator.alpha = 0
1533
+ indicator.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
1534
+
1535
+ // Add inner circle for better visibility
1536
+ let innerCircle = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40))
1537
+ innerCircle.layer.borderColor = UIColor.yellow.cgColor
1538
+ innerCircle.layer.borderWidth = 1.0
1539
+ innerCircle.layer.cornerRadius = 20
1540
+ innerCircle.backgroundColor = UIColor.clear
1541
+ indicator.addSubview(innerCircle)
1542
+
1543
+ view.addSubview(indicator)
1544
+ focusIndicatorView = indicator
1545
+
1546
+ // Animate the focus indicator
1547
+ UIView.animate(withDuration: 0.15, animations: {
1548
+ indicator.alpha = 1.0
1549
+ indicator.transform = CGAffineTransform.identity
1550
+ }) { _ in
1551
+ // Keep the indicator visible for a moment
1552
+ UIView.animate(withDuration: 0.2, delay: 0.5, options: [], animations: {
1553
+ indicator.alpha = 0.3
1554
+ }) { _ in
1555
+ // Fade out and remove
1556
+ UIView.animate(withDuration: 0.3, delay: 0.2, options: [], animations: {
1557
+ indicator.alpha = 0
1558
+ indicator.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
1559
+ }) { _ in
1560
+ indicator.removeFromSuperview()
1561
+ if self.focusIndicatorView == indicator {
1562
+ self.focusIndicatorView = nil
1563
+ }
1564
+ }
1565
+ }
1566
+ }
1567
+ }
1568
+
936
1569
  @objc
937
1570
  private func handlePinch(_ pinch: UIPinchGestureRecognizer) {
938
1571
  guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
@@ -940,45 +1573,75 @@ extension CameraController: UIGestureRecognizerDelegate {
940
1573
  let effectiveMaxZoom = min(device.maxAvailableVideoZoomFactor, self.saneMaxZoomFactor)
941
1574
  func minMaxZoom(_ factor: CGFloat) -> CGFloat { return max(device.minAvailableVideoZoomFactor, min(factor, effectiveMaxZoom)) }
942
1575
 
943
- func update(scale factor: CGFloat) {
1576
+ switch pinch.state {
1577
+ case .began:
1578
+ // Store the initial zoom factor when pinch begins
1579
+ zoomFactor = device.videoZoomFactor
1580
+
1581
+ case .changed:
1582
+ // Throttle zoom updates to prevent excessive CPU usage
1583
+ let currentTime = CACurrentMediaTime()
1584
+ guard currentTime - lastZoomUpdateTime >= zoomUpdateThrottle else { return }
1585
+ lastZoomUpdateTime = currentTime
1586
+
1587
+ // Calculate new zoom factor based on pinch scale
1588
+ let newScaleFactor = minMaxZoom(pinch.scale * zoomFactor)
1589
+
1590
+ // Use ramping for smooth zoom transitions during pinch
1591
+ // This provides much smoother performance than direct setting
944
1592
  do {
945
1593
  try device.lockForConfiguration()
946
- defer { device.unlockForConfiguration() }
947
-
948
- device.videoZoomFactor = factor
1594
+ // Use a very fast ramp rate for immediate response
1595
+ device.ramp(toVideoZoomFactor: newScaleFactor, withRate: 5.0)
1596
+ device.unlockForConfiguration()
949
1597
  } catch {
950
- debugPrint(error)
1598
+ debugPrint("Failed to set zoom: \(error)")
951
1599
  }
952
- }
953
1600
 
954
- switch pinch.state {
955
- case .began: fallthrough
956
- case .changed:
957
- let newScaleFactor = minMaxZoom(pinch.scale * zoomFactor)
958
- update(scale: newScaleFactor)
959
1601
  case .ended:
1602
+ // Update our internal zoom factor tracking
960
1603
  zoomFactor = device.videoZoomFactor
1604
+
961
1605
  default: break
962
1606
  }
963
1607
  }
964
1608
  }
965
1609
 
966
1610
  extension CameraController: AVCapturePhotoCaptureDelegate {
967
- public func photoOutput(_ captureOutput: AVCapturePhotoOutput, didFinishProcessingPhoto photoSampleBuffer: CMSampleBuffer?, previewPhoto previewPhotoSampleBuffer: CMSampleBuffer?,
968
- resolvedSettings: AVCaptureResolvedPhotoSettings, bracketSettings: AVCaptureBracketedStillImageSettings?, error: Swift.Error?) {
1611
+ public func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
969
1612
  if let error = error {
970
1613
  self.photoCaptureCompletionBlock?(nil, error)
971
- } else if let buffer = photoSampleBuffer, let data = AVCapturePhotoOutput.jpegPhotoDataRepresentation(forJPEGSampleBuffer: buffer, previewPhotoSampleBuffer: nil),
972
- let image = UIImage(data: data) {
973
- self.photoCaptureCompletionBlock?(image.fixedOrientation(), nil)
974
- } else {
1614
+ return
1615
+ }
1616
+
1617
+ // Get the photo data using the modern API
1618
+ guard let imageData = photo.fileDataRepresentation() else {
975
1619
  self.photoCaptureCompletionBlock?(nil, CameraControllerError.unknown)
1620
+ return
1621
+ }
1622
+
1623
+ guard let image = UIImage(data: imageData) else {
1624
+ self.photoCaptureCompletionBlock?(nil, CameraControllerError.unknown)
1625
+ return
976
1626
  }
1627
+
1628
+ self.photoCaptureCompletionBlock?(image.fixedOrientation(), nil)
977
1629
  }
978
1630
  }
979
1631
 
980
1632
  extension CameraController: AVCaptureVideoDataOutputSampleBufferDelegate {
981
1633
  func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
1634
+ // Check if we're waiting for the first frame
1635
+ if !hasReceivedFirstFrame, let firstFrameCallback = firstFrameReadyCallback {
1636
+ hasReceivedFirstFrame = true
1637
+ firstFrameCallback()
1638
+ firstFrameReadyCallback = nil
1639
+ // If no capture is in progress, we can return early
1640
+ if sampleBufferCaptureCompletionBlock == nil {
1641
+ return
1642
+ }
1643
+ }
1644
+
982
1645
  guard let completion = sampleBufferCaptureCompletionBlock else { return }
983
1646
 
984
1647
  guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
@@ -1028,6 +1691,7 @@ enum CameraControllerError: Swift.Error {
1028
1691
  case cannotFindDocumentsDirectory
1029
1692
  case fileVideoOutputNotFound
1030
1693
  case unknown
1694
+ case invalidZoomLevel(min: CGFloat, max: CGFloat, requested: CGFloat)
1031
1695
  }
1032
1696
 
1033
1697
  public enum CameraPosition {
@@ -1054,6 +1718,8 @@ extension CameraControllerError: LocalizedError {
1054
1718
  return NSLocalizedString("Cannot find documents directory", comment: "This should never happen")
1055
1719
  case .fileVideoOutputNotFound:
1056
1720
  return NSLocalizedString("Video recording is not available. Make sure the camera is properly initialized.", comment: "Video recording not available")
1721
+ case .invalidZoomLevel(let min, let max, let requested):
1722
+ return NSLocalizedString("Invalid zoom level. Must be between \(min) and \(max). Requested: \(requested)", comment: "Invalid Zoom Level")
1057
1723
  }
1058
1724
  }
1059
1725
  }
@@ -1095,6 +1761,8 @@ extension UIImage {
1095
1761
  print("right")
1096
1762
  case .up, .upMirrored:
1097
1763
  break
1764
+ @unknown default:
1765
+ break
1098
1766
  }
1099
1767
 
1100
1768
  // Flip image one more time if needed to, this is to prevent flipped image
@@ -1107,15 +1775,21 @@ extension UIImage {
1107
1775
  transform.scaledBy(x: -1, y: 1)
1108
1776
  case .up, .down, .left, .right:
1109
1777
  break
1778
+ @unknown default:
1779
+ break
1110
1780
  }
1111
1781
 
1112
1782
  ctx.concatenate(transform)
1113
1783
 
1114
1784
  switch imageOrientation {
1115
1785
  case .left, .leftMirrored, .right, .rightMirrored:
1116
- ctx.draw(self.cgImage!, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
1786
+ if let cgImage = self.cgImage {
1787
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
1788
+ }
1117
1789
  default:
1118
- ctx.draw(self.cgImage!, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
1790
+ if let cgImage = self.cgImage {
1791
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
1792
+ }
1119
1793
  }
1120
1794
  guard let newCGImage = ctx.makeImage() else { return nil }
1121
1795
  return UIImage.init(cgImage: newCGImage, scale: 1, orientation: .up)