@capgo/camera-preview 7.4.0-beta.13 → 7.4.0-beta.16

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.
@@ -30,14 +30,13 @@ class CameraController: NSObject {
30
30
 
31
31
  var previewLayer: AVCaptureVideoPreviewLayer?
32
32
  var gridOverlayView: GridOverlayView?
33
+ var focusIndicatorView: UIView?
33
34
 
34
35
  var flashMode = AVCaptureDevice.FlashMode.off
35
36
  var photoCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
36
37
 
37
38
  var sampleBufferCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
38
39
 
39
- var highResolutionOutput: Bool = false
40
-
41
40
  var audioDevice: AVCaptureDevice?
42
41
  var audioInput: AVCaptureDeviceInput?
43
42
 
@@ -48,6 +47,10 @@ class CameraController: NSObject {
48
47
  var videoFileURL: URL?
49
48
  private let saneMaxZoomFactor: CGFloat = 25.5
50
49
 
50
+ // Track output preparation status
51
+ private var outputsPrepared: Bool = false
52
+ private let outputPreparationQueue = DispatchQueue(label: "camera.output.preparation", qos: .utility)
53
+
51
54
  var isUsingMultiLensVirtualCamera: Bool {
52
55
  guard let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera else { return false }
53
56
  // A rear multi-lens virtual camera will have a min zoom of 1.0 but support wider angles
@@ -65,18 +68,28 @@ extension CameraController {
65
68
  // 1. Create and configure session
66
69
  self.captureSession = AVCaptureSession()
67
70
 
68
- // 2. Pre-configure session preset (can be changed later)
69
- if captureSession!.canSetSessionPreset(.high) {
71
+ // 2. Pre-configure session preset (can be changed later) - use medium for faster startup
72
+ if captureSession!.canSetSessionPreset(.medium) {
73
+ captureSession!.sessionPreset = .medium // Start with medium, upgrade later if needed
74
+ } else if captureSession!.canSetSessionPreset(.high) {
70
75
  captureSession!.sessionPreset = .high
71
76
  }
72
77
 
73
- // 3. Discover and configure all cameras
74
- discoverAndConfigureCameras()
78
+ // 3. Discover cameras on-demand (only when needed for better startup performance)
79
+ // discoverAndConfigureCameras() - moved to lazy loading
75
80
 
76
- // 4. Pre-create outputs (don't add to session yet)
77
- prepareOutputs()
81
+ // // 4. Pre-create outputs asynchronously to avoid blocking camera opening
82
+ // outputPreparationQueue.async { [weak self] in
83
+ // self?.prepareOutputs()
84
+ // }
85
+
86
+ print("[CameraPreview] Full session preparation complete - cameras will be discovered on-demand, outputs being prepared asynchronously")
87
+ }
78
88
 
79
- print("[CameraPreview] Full session preparation complete")
89
+ private func ensureCamerasDiscovered() {
90
+ // Rediscover cameras if the array is empty OR if the camera pointers are nil
91
+ guard allDiscoveredDevices.isEmpty || (rearCamera == nil && frontCamera == nil) else { return }
92
+ discoverAndConfigureCameras()
80
93
  }
81
94
 
82
95
  private func discoverAndConfigureCameras() {
@@ -137,67 +150,211 @@ extension CameraController {
137
150
  }
138
151
 
139
152
  private func prepareOutputs() {
140
- // Pre-create photo output
153
+ // Pre-create photo output with optimized settings
141
154
  self.photoOutput = AVCapturePhotoOutput()
142
- self.photoOutput?.isHighResolutionCaptureEnabled = false // Default, can be changed
155
+ self.photoOutput?.isHighResolutionCaptureEnabled = false // Start with lower resolution for speed
156
+
157
+ // Configure photo output for better performance
158
+ if #available(iOS 13.0, *) {
159
+ self.photoOutput?.maxPhotoQualityPrioritization = .speed // Prioritize speed over quality initially
160
+ }
143
161
 
144
162
  // Pre-create video output
145
163
  self.fileVideoOutput = AVCaptureMovieFileOutput()
146
164
 
147
- // Pre-create data output
165
+ // Pre-create data output with optimized settings
148
166
  self.dataOutput = AVCaptureVideoDataOutput()
149
167
  self.dataOutput?.videoSettings = [
150
168
  (kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
151
169
  ]
152
170
  self.dataOutput?.alwaysDiscardsLateVideoFrames = true
153
171
 
154
- print("[CameraPreview] Outputs pre-created")
172
+ // Use a background queue for sample buffer processing to avoid blocking main thread
173
+ let dataOutputQueue = DispatchQueue(label: "camera.data.output", qos: .userInitiated)
174
+ self.dataOutput?.setSampleBufferDelegate(nil, queue: dataOutputQueue) // Will be set later
175
+
176
+ // Mark outputs as prepared
177
+ self.outputsPrepared = true
178
+
179
+ print("[CameraPreview] Outputs pre-created with performance optimizations")
155
180
  }
156
181
 
157
- func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, completionHandler: @escaping (Error?) -> Void) {
158
- do {
159
- // Session and outputs already created in load(), just configure user-specific settings
160
- if self.captureSession == nil {
161
- // Fallback if prepareFullSession() wasn't called
162
- self.prepareFullSession()
182
+ private func waitForOutputsToBeReady() {
183
+ // If outputs are already prepared, return immediately
184
+ if outputsPrepared {
185
+ return
186
+ }
187
+
188
+ // Wait for outputs to be prepared with a timeout
189
+ let semaphore = DispatchSemaphore(value: 0)
190
+ var outputsReady = false
191
+
192
+ // Check for outputs readiness periodically
193
+ let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
194
+ if self.outputsPrepared {
195
+ outputsReady = true
196
+ timer.invalidate()
197
+ semaphore.signal()
198
+ }
199
+ }
200
+
201
+ // Wait for outputs to be ready or timeout after 2 seconds
202
+ let timeout = DispatchTime.now() + .seconds(2)
203
+ let result = semaphore.wait(timeout: timeout)
204
+
205
+ timer.invalidate()
206
+
207
+ if result == .timedOut && !outputsReady {
208
+ print("[CameraPreview] Warning: Timed out waiting for outputs to be prepared, proceeding anyway")
209
+ // Fallback: prepare outputs synchronously if async preparation failed
210
+ if !outputsPrepared {
211
+ prepareOutputs()
212
+ }
213
+ } else {
214
+ print("[CameraPreview] Outputs ready, proceeding with camera preparation")
215
+ }
216
+ }
217
+
218
+ func upgradeQualitySettings() {
219
+ guard let captureSession = self.captureSession else { return }
220
+
221
+ // Upgrade session preset to high quality after initial startup
222
+ DispatchQueue.global(qos: .utility).async { [weak self] in
223
+ guard let self = self else { return }
224
+
225
+ captureSession.beginConfiguration()
226
+
227
+ // Upgrade to high quality preset
228
+ if captureSession.canSetSessionPreset(.high) && captureSession.sessionPreset != .high {
229
+ captureSession.sessionPreset = .high
230
+ print("[CameraPreview] Upgraded session preset to high quality")
163
231
  }
164
232
 
165
- guard let captureSession = self.captureSession else {
166
- throw CameraControllerError.captureSessionIsMissing
233
+ // Upgrade photo output quality
234
+ if let photoOutput = self.photoOutput {
235
+ photoOutput.isHighResolutionCaptureEnabled = true
236
+ if #available(iOS 13.0, *) {
237
+ photoOutput.maxPhotoQualityPrioritization = .quality
238
+ }
239
+ print("[CameraPreview] Upgraded photo output to high resolution")
167
240
  }
168
241
 
169
- print("[CameraPreview] Fast prepare - using pre-initialized session")
242
+ captureSession.commitConfiguration()
243
+ }
244
+ }
245
+
246
+ func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float = 1.0, completionHandler: @escaping (Error?) -> Void) {
247
+ // Use background queue for preparation to avoid blocking main thread
248
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
249
+ guard let self = self else {
250
+ DispatchQueue.main.async {
251
+ completionHandler(CameraControllerError.unknown)
252
+ }
253
+ return
254
+ }
170
255
 
171
- // Configure device inputs for the requested camera
172
- try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
256
+ do {
257
+ // Session and outputs already created in load(), just configure user-specific settings
258
+ if self.captureSession == nil {
259
+ // Fallback if prepareFullSession() wasn't called
260
+ self.prepareFullSession()
261
+ }
173
262
 
174
- // Add outputs to session and apply user settings
175
- try self.addOutputsToSession(cameraMode: cameraMode, aspectRatio: aspectRatio)
263
+ guard let captureSession = self.captureSession else {
264
+ throw CameraControllerError.captureSessionIsMissing
265
+ }
176
266
 
177
- // Start the session
178
- captureSession.startRunning()
179
- print("[CameraPreview] Session started")
267
+ print("[CameraPreview] Fast prepare - using pre-initialized session")
180
268
 
181
- completionHandler(nil)
269
+ // Pre-create outputs asynchronously to avoid blocking camera opening
270
+ outputPreparationQueue.async { [weak self] in
271
+ self?.prepareOutputs()
272
+ }
273
+
274
+ // // Configure device inputs for the requested camera
275
+ try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
276
+
277
+ // Start the session on background thread (AVCaptureSession.startRunning() is thread-safe)
278
+ captureSession.startRunning()
279
+ print("[CameraPreview] Session started")
280
+
281
+ // Validate and set initial zoom level asynchronously
282
+ if initialZoomLevel != 1.0 {
283
+ DispatchQueue.main.async { [weak self] in
284
+ self?.setInitialZoom(level: initialZoomLevel)
285
+ }
286
+ }
287
+
288
+ // Call completion on main thread
289
+ DispatchQueue.main.async {
290
+ completionHandler(nil)
291
+
292
+ // Upgrade quality settings after a short delay for better user experience
293
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
294
+ guard let self = self else { return }
295
+
296
+ // Wait for outputs to be prepared before proceeding
297
+ self.waitForOutputsToBeReady()
298
+
299
+ // Add outputs to session and apply user settings
300
+ do {
301
+ try self.addOutputsToSession(cameraMode: cameraMode, aspectRatio: aspectRatio)
302
+ print("[CameraPreview] Outputs successfully added to session")
303
+ } catch {
304
+ print("[CameraPreview] Error adding outputs to session: \(error)")
305
+ }
306
+
307
+ self.upgradeQualitySettings()
308
+ }
309
+ }
310
+ } catch {
311
+ DispatchQueue.main.async {
312
+ completionHandler(error)
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ private func setInitialZoom(level: Float) {
319
+ let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
320
+ guard let device = device else { return }
321
+
322
+ let minZoom = device.minAvailableVideoZoomFactor
323
+ let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
324
+
325
+ guard CGFloat(level) >= minZoom && CGFloat(level) <= maxZoom else {
326
+ print("[CameraPreview] Initial zoom level \(level) out of range (\(minZoom)-\(maxZoom))")
327
+ return
328
+ }
329
+
330
+ do {
331
+ try device.lockForConfiguration()
332
+ device.videoZoomFactor = CGFloat(level)
333
+ device.unlockForConfiguration()
334
+ self.zoomFactor = CGFloat(level)
335
+ print("[CameraPreview] Set initial zoom to \(level)")
182
336
  } catch {
183
- completionHandler(error)
337
+ print("[CameraPreview] Failed to set initial zoom: \(error)")
184
338
  }
185
339
  }
186
340
 
187
341
  private func configureDeviceInputs(cameraPosition: String, deviceId: String?, disableAudio: Bool) throws {
188
342
  guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
189
343
 
344
+ // Ensure cameras are discovered before configuring inputs
345
+ ensureCamerasDiscovered()
346
+
190
347
  var selectedDevice: AVCaptureDevice?
191
348
 
192
- // If deviceId is specified, find that specific device from pre-discovered devices
349
+ // If deviceId is specified, find that specific device from discovered devices
193
350
  if let deviceId = deviceId {
194
351
  selectedDevice = self.allDiscoveredDevices.first(where: { $0.uniqueID == deviceId })
195
352
  guard selectedDevice != nil else {
196
- print("[CameraPreview] ERROR: Device with ID \(deviceId) not found in pre-discovered devices")
353
+ print("[CameraPreview] ERROR: Device with ID \(deviceId) not found in discovered devices")
197
354
  throw CameraControllerError.noCamerasAvailable
198
355
  }
199
356
  } else {
200
- // Use position-based selection from pre-discovered cameras
357
+ // Use position-based selection from discovered cameras
201
358
  if cameraPosition == "rear" {
202
359
  selectedDevice = self.rearCamera
203
360
  } else if cameraPosition == "front" {
@@ -223,16 +380,16 @@ extension CameraController {
223
380
  self.rearCameraInput = deviceInput
224
381
  self.currentCameraPosition = .rear
225
382
 
226
- // Configure zoom for multi-camera systems
227
- try finalDevice.lockForConfiguration()
228
- let defaultWideAngleZoom: CGFloat = 2.0
229
- if finalDevice.isVirtualDevice && finalDevice.constituentDevices.count > 1 && finalDevice.videoZoomFactor != defaultWideAngleZoom {
383
+ // Configure zoom for multi-camera systems - simplified and faster
384
+ if finalDevice.isVirtualDevice && finalDevice.constituentDevices.count > 1 {
385
+ try finalDevice.lockForConfiguration()
386
+ let defaultWideAngleZoom: CGFloat = 1.0 // Changed from 2.0 to 1.0 for faster startup
230
387
  if defaultWideAngleZoom >= finalDevice.minAvailableVideoZoomFactor && defaultWideAngleZoom <= finalDevice.maxAvailableVideoZoomFactor {
231
388
  print("[CameraPreview] Setting initial zoom to \(defaultWideAngleZoom)")
232
389
  finalDevice.videoZoomFactor = defaultWideAngleZoom
233
390
  }
391
+ finalDevice.unlockForConfiguration()
234
392
  }
235
- finalDevice.unlockForConfiguration()
236
393
  }
237
394
  } else {
238
395
  throw CameraControllerError.inputsAreInvalid
@@ -257,6 +414,10 @@ extension CameraController {
257
414
  private func addOutputsToSession(cameraMode: Bool, aspectRatio: String?) throws {
258
415
  guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
259
416
 
417
+ // Begin configuration to batch all changes
418
+ captureSession.beginConfiguration()
419
+ defer { captureSession.commitConfiguration() }
420
+
260
421
  // Update session preset based on aspect ratio if needed
261
422
  var targetPreset: AVCaptureSession.Preset = .high // Default to high quality
262
423
 
@@ -283,7 +444,7 @@ extension CameraController {
283
444
 
284
445
  // Add photo output (already created in prepareOutputs)
285
446
  if let photoOutput = self.photoOutput, captureSession.canAddOutput(photoOutput) {
286
- photoOutput.isHighResolutionCaptureEnabled = self.highResolutionOutput
447
+ photoOutput.isHighResolutionCaptureEnabled = true
287
448
  captureSession.addOutput(photoOutput)
288
449
  }
289
450
 
@@ -295,9 +456,10 @@ extension CameraController {
295
456
  // Add data output
296
457
  if let dataOutput = self.dataOutput, captureSession.canAddOutput(dataOutput) {
297
458
  captureSession.addOutput(dataOutput)
298
- captureSession.commitConfiguration()
299
-
300
- dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
459
+ // Set delegate after outputs are added for better performance
460
+ DispatchQueue.main.async {
461
+ dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
462
+ }
301
463
  }
302
464
  }
303
465
 
@@ -306,33 +468,32 @@ extension CameraController {
306
468
 
307
469
  print("[CameraPreview] displayPreview called with view frame: \(view.frame)")
308
470
 
309
- self.previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
310
- self.previewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
311
-
312
- // Optimize preview layer for better quality
313
- self.previewLayer?.connection?.videoOrientation = .portrait
314
- self.previewLayer?.isOpaque = true
471
+ // Create and configure preview layer in one go
472
+ let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
315
473
 
316
- // Set contentsScale for retina display quality
317
- self.previewLayer?.contentsScale = UIScreen.main.scale
474
+ // Batch all layer configuration to avoid multiple redraws
475
+ CATransaction.begin()
476
+ CATransaction.setDisableActions(true)
318
477
 
319
- // Enable high-quality rendering
320
- if #available(iOS 13.0, *) {
321
- self.previewLayer?.videoGravity = .resizeAspectFill
322
- }
478
+ previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
479
+ previewLayer.connection?.videoOrientation = .portrait
480
+ previewLayer.isOpaque = true
481
+ previewLayer.contentsScale = UIScreen.main.scale
482
+ previewLayer.frame = view.bounds
323
483
 
324
- view.layer.insertSublayer(self.previewLayer!, at: 0)
484
+ // Insert layer and store reference
485
+ view.layer.insertSublayer(previewLayer, at: 0)
486
+ self.previewLayer = previewLayer
325
487
 
326
- // Disable animation for frame update
327
- CATransaction.begin()
328
- CATransaction.setDisableActions(true)
329
- self.previewLayer?.frame = view.bounds
330
488
  CATransaction.commit()
331
489
 
332
490
  print("[CameraPreview] Set preview layer frame to view bounds: \(view.bounds)")
333
491
  print("[CameraPreview] Session preset: \(captureSession.sessionPreset.rawValue)")
334
492
 
335
- updateVideoOrientation()
493
+ // Update video orientation asynchronously to avoid blocking
494
+ DispatchQueue.main.async { [weak self] in
495
+ self?.updateVideoOrientation()
496
+ }
336
497
  }
337
498
 
338
499
  func addGridOverlay(to view: UIView, gridMode: String) {
@@ -504,7 +665,7 @@ extension CameraController {
504
665
  self.updateVideoOrientation()
505
666
  }
506
667
 
507
- func captureImage(width: Int?, height: Int?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Error?) -> Void) {
668
+ func captureImage(width: Int?, height: Int?, aspectRatio: String?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Error?) -> Void) {
508
669
  guard let photoOutput = self.photoOutput else {
509
670
  completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
510
671
  return
@@ -512,6 +673,26 @@ extension CameraController {
512
673
 
513
674
  let settings = AVCapturePhotoSettings()
514
675
 
676
+ // Apply the current flash mode to the photo settings
677
+ // Check if the current device supports flash
678
+ var currentCamera: AVCaptureDevice?
679
+ switch currentCameraPosition {
680
+ case .front:
681
+ currentCamera = self.frontCamera
682
+ case .rear:
683
+ currentCamera = self.rearCamera
684
+ default:
685
+ break
686
+ }
687
+
688
+ // Only apply flash if the device has flash and the flash mode is supported
689
+ if let device = currentCamera, device.hasFlash {
690
+ let supportedFlashModes = photoOutput.supportedFlashModes
691
+ if supportedFlashModes.contains(self.flashMode) {
692
+ settings.flashMode = self.flashMode
693
+ }
694
+ }
695
+
515
696
  self.photoCaptureCompletionBlock = { (image, error) in
516
697
  if let error = error {
517
698
  completion(nil, error)
@@ -527,12 +708,38 @@ extension CameraController {
527
708
  self.addGPSMetadata(to: image, location: location)
528
709
  }
529
710
 
530
- if let width = width, let height = height {
531
- let resizedImage = self.resizeImage(image: image, to: CGSize(width: width, height: height))
532
- completion(resizedImage, nil)
533
- } else {
534
- completion(image, nil)
711
+ var finalImage = image
712
+
713
+ // Handle aspect ratio if no width/height specified
714
+ if width == nil && height == nil, let aspectRatio = aspectRatio {
715
+ let components = aspectRatio.split(separator: ":").compactMap { Double($0) }
716
+ if components.count == 2 {
717
+ // For capture in portrait orientation, swap the aspect ratio (16:9 becomes 9:16)
718
+ let isPortrait = image.size.height > image.size.width
719
+ let targetAspectRatio = isPortrait ? components[1] / components[0] : components[0] / components[1]
720
+ let imageSize = image.size
721
+ let originalAspectRatio = imageSize.width / imageSize.height
722
+
723
+ var targetSize = imageSize
724
+
725
+ if originalAspectRatio > targetAspectRatio {
726
+ // Original is wider than target - fit by height
727
+ targetSize.width = imageSize.height * CGFloat(targetAspectRatio)
728
+ } else {
729
+ // Original is taller than target - fit by width
730
+ targetSize.height = imageSize.width / CGFloat(targetAspectRatio)
731
+ }
732
+
733
+ // Center crop the image
734
+ if let croppedImage = self.cropImageToAspectRatio(image: image, targetSize: targetSize) {
735
+ finalImage = croppedImage
736
+ }
737
+ }
738
+ } else if let width = width, let height = height {
739
+ finalImage = self.resizeImage(image: image, to: CGSize(width: width, height: height))!
535
740
  }
741
+
742
+ completion(finalImage, nil)
536
743
  }
537
744
 
538
745
  photoOutput.capturePhoto(with: settings, delegate: self)
@@ -574,6 +781,23 @@ extension CameraController {
574
781
  }
575
782
  return resizedImage
576
783
  }
784
+
785
+ func cropImageToAspectRatio(image: UIImage, targetSize: CGSize) -> UIImage? {
786
+ let imageSize = image.size
787
+
788
+ // Calculate the crop rect - center crop
789
+ let xOffset = (imageSize.width - targetSize.width) / 2
790
+ let yOffset = (imageSize.height - targetSize.height) / 2
791
+ let cropRect = CGRect(x: xOffset, y: yOffset, width: targetSize.width, height: targetSize.height)
792
+
793
+ // Create the cropped image
794
+ guard let cgImage = image.cgImage,
795
+ let croppedCGImage = cgImage.cropping(to: cropRect) else {
796
+ return nil
797
+ }
798
+
799
+ return UIImage(cgImage: croppedCGImage, scale: image.scale, orientation: image.imageOrientation)
800
+ }
577
801
 
578
802
  func captureSample(completion: @escaping (UIImage?, Error?) -> Void) {
579
803
  guard let captureSession = captureSession,
@@ -749,7 +973,7 @@ extension CameraController {
749
973
  )
750
974
  }
751
975
 
752
- func setZoom(level: CGFloat, ramp: Bool) throws {
976
+ func setZoom(level: CGFloat, ramp: Bool, autoFocus: Bool = true) throws {
753
977
  var currentCamera: AVCaptureDevice?
754
978
  switch currentCameraPosition {
755
979
  case .front:
@@ -780,12 +1004,73 @@ extension CameraController {
780
1004
 
781
1005
  // Update our internal zoom factor tracking
782
1006
  self.zoomFactor = zoomLevel
1007
+
1008
+ // Trigger autofocus after zoom if requested
1009
+ if autoFocus {
1010
+ self.triggerAutoFocus()
1011
+ }
783
1012
  } catch {
784
1013
  throw CameraControllerError.invalidOperation
785
1014
  }
786
1015
  }
787
1016
 
788
- func setFocus(at point: CGPoint) throws {
1017
+ private func triggerAutoFocus() {
1018
+ var currentCamera: AVCaptureDevice?
1019
+ switch currentCameraPosition {
1020
+ case .front:
1021
+ currentCamera = self.frontCamera
1022
+ case .rear:
1023
+ currentCamera = self.rearCamera
1024
+ default: break
1025
+ }
1026
+
1027
+ guard let device = currentCamera else {
1028
+ return
1029
+ }
1030
+
1031
+ // Focus on the center of the preview (0.5, 0.5)
1032
+ let centerPoint = CGPoint(x: 0.5, y: 0.5)
1033
+
1034
+ do {
1035
+ try device.lockForConfiguration()
1036
+
1037
+ // Set focus mode to auto if supported
1038
+ if device.isFocusModeSupported(.autoFocus) {
1039
+ device.focusMode = .autoFocus
1040
+ if device.isFocusPointOfInterestSupported {
1041
+ device.focusPointOfInterest = centerPoint
1042
+ }
1043
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
1044
+ device.focusMode = .continuousAutoFocus
1045
+ if device.isFocusPointOfInterestSupported {
1046
+ device.focusPointOfInterest = centerPoint
1047
+ }
1048
+ }
1049
+
1050
+ // Also set exposure point if supported
1051
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1052
+ device.exposureMode = .autoExpose
1053
+ device.exposurePointOfInterest = centerPoint
1054
+ } else if device.isExposureModeSupported(.continuousAutoExposure) {
1055
+ device.exposureMode = .continuousAutoExposure
1056
+ if device.isExposurePointOfInterestSupported {
1057
+ device.exposurePointOfInterest = centerPoint
1058
+ }
1059
+ }
1060
+
1061
+ device.unlockForConfiguration()
1062
+ } catch {
1063
+ // Silently ignore errors during autofocus
1064
+ }
1065
+ }
1066
+
1067
+ func setFocus(at point: CGPoint, showIndicator: Bool = false, in view: UIView? = nil) throws {
1068
+ // Validate that coordinates are within bounds (0-1 range for device coordinates)
1069
+ if point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1 {
1070
+ print("setFocus: Coordinates out of bounds - x: \(point.x), y: \(point.y)")
1071
+ throw CameraControllerError.invalidOperation
1072
+ }
1073
+
789
1074
  var currentCamera: AVCaptureDevice?
790
1075
  switch currentCameraPosition {
791
1076
  case .front:
@@ -804,6 +1089,13 @@ extension CameraController {
804
1089
  return
805
1090
  }
806
1091
 
1092
+ // Show focus indicator if requested and view is provided - only after validation
1093
+ if showIndicator, let view = view, let previewLayer = self.previewLayer {
1094
+ // Convert the device point to layer point for indicator display
1095
+ let layerPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: point)
1096
+ showFocusIndicator(at: layerPoint, in: view)
1097
+ }
1098
+
807
1099
  do {
808
1100
  try device.lockForConfiguration()
809
1101
 
@@ -999,6 +1291,9 @@ extension CameraController {
999
1291
  self.previewLayer?.removeFromSuperlayer()
1000
1292
  self.previewLayer = nil
1001
1293
 
1294
+ self.focusIndicatorView?.removeFromSuperview()
1295
+ self.focusIndicatorView = nil
1296
+
1002
1297
  self.frontCameraInput = nil
1003
1298
  self.rearCameraInput = nil
1004
1299
  self.audioInput = nil
@@ -1006,6 +1301,7 @@ extension CameraController {
1006
1301
  self.frontCamera = nil
1007
1302
  self.rearCamera = nil
1008
1303
  self.audioDevice = nil
1304
+ self.allDiscoveredDevices = []
1009
1305
 
1010
1306
  self.dataOutput = nil
1011
1307
  self.photoOutput = nil
@@ -1013,6 +1309,9 @@ extension CameraController {
1013
1309
 
1014
1310
  self.captureSession = nil
1015
1311
  self.currentCameraPosition = nil
1312
+
1313
+ // Reset output preparation status
1314
+ self.outputsPrepared = false
1016
1315
  }
1017
1316
 
1018
1317
  func captureVideo() throws {
@@ -1089,6 +1388,11 @@ extension CameraController: UIGestureRecognizerDelegate {
1089
1388
  let point = tap.location(in: tap.view)
1090
1389
  let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: point)
1091
1390
 
1391
+ // Show focus indicator at the tap point
1392
+ if let view = tap.view {
1393
+ showFocusIndicator(at: point, in: view)
1394
+ }
1395
+
1092
1396
  do {
1093
1397
  try device.lockForConfiguration()
1094
1398
  defer { device.unlockForConfiguration() }
@@ -1109,6 +1413,54 @@ extension CameraController: UIGestureRecognizerDelegate {
1109
1413
  }
1110
1414
  }
1111
1415
 
1416
+ private func showFocusIndicator(at point: CGPoint, in view: UIView) {
1417
+ // Remove any existing focus indicator
1418
+ focusIndicatorView?.removeFromSuperview()
1419
+
1420
+ // Create a new focus indicator
1421
+ let indicator = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
1422
+ indicator.center = point
1423
+ indicator.layer.borderColor = UIColor.yellow.cgColor
1424
+ indicator.layer.borderWidth = 2.0
1425
+ indicator.layer.cornerRadius = 40
1426
+ indicator.backgroundColor = UIColor.clear
1427
+ indicator.alpha = 0
1428
+ indicator.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
1429
+
1430
+ // Add inner circle for better visibility
1431
+ let innerCircle = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40))
1432
+ innerCircle.layer.borderColor = UIColor.yellow.cgColor
1433
+ innerCircle.layer.borderWidth = 1.0
1434
+ innerCircle.layer.cornerRadius = 20
1435
+ innerCircle.backgroundColor = UIColor.clear
1436
+ indicator.addSubview(innerCircle)
1437
+
1438
+ view.addSubview(indicator)
1439
+ focusIndicatorView = indicator
1440
+
1441
+ // Animate the focus indicator
1442
+ UIView.animate(withDuration: 0.15, animations: {
1443
+ indicator.alpha = 1.0
1444
+ indicator.transform = CGAffineTransform.identity
1445
+ }) { _ in
1446
+ // Keep the indicator visible for a moment
1447
+ UIView.animate(withDuration: 0.2, delay: 0.5, options: [], animations: {
1448
+ indicator.alpha = 0.3
1449
+ }) { _ in
1450
+ // Fade out and remove
1451
+ UIView.animate(withDuration: 0.3, delay: 0.2, options: [], animations: {
1452
+ indicator.alpha = 0
1453
+ indicator.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
1454
+ }) { _ in
1455
+ indicator.removeFromSuperview()
1456
+ if self.focusIndicatorView == indicator {
1457
+ self.focusIndicatorView = nil
1458
+ }
1459
+ }
1460
+ }
1461
+ }
1462
+ }
1463
+
1112
1464
  @objc
1113
1465
  private func handlePinch(_ pinch: UIPinchGestureRecognizer) {
1114
1466
  guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
@@ -1223,6 +1575,7 @@ enum CameraControllerError: Swift.Error {
1223
1575
  case cannotFindDocumentsDirectory
1224
1576
  case fileVideoOutputNotFound
1225
1577
  case unknown
1578
+ case invalidZoomLevel(min: CGFloat, max: CGFloat, requested: CGFloat)
1226
1579
  }
1227
1580
 
1228
1581
  public enum CameraPosition {
@@ -1249,6 +1602,8 @@ extension CameraControllerError: LocalizedError {
1249
1602
  return NSLocalizedString("Cannot find documents directory", comment: "This should never happen")
1250
1603
  case .fileVideoOutputNotFound:
1251
1604
  return NSLocalizedString("Video recording is not available. Make sure the camera is properly initialized.", comment: "Video recording not available")
1605
+ case .invalidZoomLevel(let min, let max, let requested):
1606
+ return NSLocalizedString("Invalid zoom level. Must be between \(min) and \(max). Requested: \(requested)", comment: "Invalid Zoom Level")
1252
1607
  }
1253
1608
  }
1254
1609
  }
@@ -1312,9 +1667,13 @@ extension UIImage {
1312
1667
 
1313
1668
  switch imageOrientation {
1314
1669
  case .left, .leftMirrored, .right, .rightMirrored:
1315
- ctx.draw(self.cgImage!, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
1670
+ if let cgImage = self.cgImage {
1671
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
1672
+ }
1316
1673
  default:
1317
- ctx.draw(self.cgImage!, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
1674
+ if let cgImage = self.cgImage {
1675
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
1676
+ }
1318
1677
  }
1319
1678
  guard let newCGImage = ctx.makeImage() else { return nil }
1320
1679
  return UIImage.init(cgImage: newCGImage, scale: 1, orientation: .up)