@capgo/camera-preview 7.4.0-beta.12 → 7.4.0-beta.15

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,27 @@ 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
+ // }
78
85
 
79
- print("[CameraPreview] Full session preparation complete")
86
+ print("[CameraPreview] Full session preparation complete - cameras will be discovered on-demand, outputs being prepared asynchronously")
87
+ }
88
+
89
+ private func ensureCamerasDiscovered() {
90
+ guard allDiscoveredDevices.isEmpty else { return }
91
+ discoverAndConfigureCameras()
80
92
  }
81
93
 
82
94
  private func discoverAndConfigureCameras() {
@@ -137,67 +149,211 @@ extension CameraController {
137
149
  }
138
150
 
139
151
  private func prepareOutputs() {
140
- // Pre-create photo output
152
+ // Pre-create photo output with optimized settings
141
153
  self.photoOutput = AVCapturePhotoOutput()
142
- self.photoOutput?.isHighResolutionCaptureEnabled = false // Default, can be changed
154
+ self.photoOutput?.isHighResolutionCaptureEnabled = false // Start with lower resolution for speed
155
+
156
+ // Configure photo output for better performance
157
+ if #available(iOS 13.0, *) {
158
+ self.photoOutput?.maxPhotoQualityPrioritization = .speed // Prioritize speed over quality initially
159
+ }
143
160
 
144
161
  // Pre-create video output
145
162
  self.fileVideoOutput = AVCaptureMovieFileOutput()
146
163
 
147
- // Pre-create data output
164
+ // Pre-create data output with optimized settings
148
165
  self.dataOutput = AVCaptureVideoDataOutput()
149
166
  self.dataOutput?.videoSettings = [
150
167
  (kCVPixelBufferPixelFormatTypeKey as String): NSNumber(value: kCVPixelFormatType_32BGRA as UInt32)
151
168
  ]
152
169
  self.dataOutput?.alwaysDiscardsLateVideoFrames = true
153
170
 
154
- print("[CameraPreview] Outputs pre-created")
171
+ // Use a background queue for sample buffer processing to avoid blocking main thread
172
+ let dataOutputQueue = DispatchQueue(label: "camera.data.output", qos: .userInitiated)
173
+ self.dataOutput?.setSampleBufferDelegate(nil, queue: dataOutputQueue) // Will be set later
174
+
175
+ // Mark outputs as prepared
176
+ self.outputsPrepared = true
177
+
178
+ print("[CameraPreview] Outputs pre-created with performance optimizations")
155
179
  }
156
180
 
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()
181
+ private func waitForOutputsToBeReady() {
182
+ // If outputs are already prepared, return immediately
183
+ if outputsPrepared {
184
+ return
185
+ }
186
+
187
+ // Wait for outputs to be prepared with a timeout
188
+ let semaphore = DispatchSemaphore(value: 0)
189
+ var outputsReady = false
190
+
191
+ // Check for outputs readiness periodically
192
+ let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in
193
+ if self.outputsPrepared {
194
+ outputsReady = true
195
+ timer.invalidate()
196
+ semaphore.signal()
163
197
  }
198
+ }
164
199
 
165
- guard let captureSession = self.captureSession else {
166
- throw CameraControllerError.captureSessionIsMissing
200
+ // Wait for outputs to be ready or timeout after 2 seconds
201
+ let timeout = DispatchTime.now() + .seconds(2)
202
+ let result = semaphore.wait(timeout: timeout)
203
+
204
+ timer.invalidate()
205
+
206
+ if result == .timedOut && !outputsReady {
207
+ print("[CameraPreview] Warning: Timed out waiting for outputs to be prepared, proceeding anyway")
208
+ // Fallback: prepare outputs synchronously if async preparation failed
209
+ if !outputsPrepared {
210
+ prepareOutputs()
167
211
  }
212
+ } else {
213
+ print("[CameraPreview] Outputs ready, proceeding with camera preparation")
214
+ }
215
+ }
168
216
 
169
- print("[CameraPreview] Fast prepare - using pre-initialized session")
217
+ func upgradeQualitySettings() {
218
+ guard let captureSession = self.captureSession else { return }
170
219
 
171
- // Configure device inputs for the requested camera
172
- try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
220
+ // Upgrade session preset to high quality after initial startup
221
+ DispatchQueue.global(qos: .utility).async { [weak self] in
222
+ guard let self = self else { return }
173
223
 
174
- // Add outputs to session and apply user settings
175
- try self.addOutputsToSession(cameraMode: cameraMode, aspectRatio: aspectRatio)
224
+ captureSession.beginConfiguration()
225
+
226
+ // Upgrade to high quality preset
227
+ if captureSession.canSetSessionPreset(.high) && captureSession.sessionPreset != .high {
228
+ captureSession.sessionPreset = .high
229
+ print("[CameraPreview] Upgraded session preset to high quality")
230
+ }
231
+
232
+ // Upgrade photo output quality
233
+ if let photoOutput = self.photoOutput {
234
+ photoOutput.isHighResolutionCaptureEnabled = true
235
+ if #available(iOS 13.0, *) {
236
+ photoOutput.maxPhotoQualityPrioritization = .quality
237
+ }
238
+ print("[CameraPreview] Upgraded photo output to high resolution")
239
+ }
240
+
241
+ captureSession.commitConfiguration()
242
+ }
243
+ }
244
+
245
+ func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float = 1.0, completionHandler: @escaping (Error?) -> Void) {
246
+ // Use background queue for preparation to avoid blocking main thread
247
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
248
+ guard let self = self else {
249
+ DispatchQueue.main.async {
250
+ completionHandler(CameraControllerError.unknown)
251
+ }
252
+ return
253
+ }
254
+
255
+ do {
256
+ // Session and outputs already created in load(), just configure user-specific settings
257
+ if self.captureSession == nil {
258
+ // Fallback if prepareFullSession() wasn't called
259
+ self.prepareFullSession()
260
+ }
261
+
262
+ guard let captureSession = self.captureSession else {
263
+ throw CameraControllerError.captureSessionIsMissing
264
+ }
265
+
266
+ print("[CameraPreview] Fast prepare - using pre-initialized session")
267
+
268
+ // Pre-create outputs asynchronously to avoid blocking camera opening
269
+ outputPreparationQueue.async { [weak self] in
270
+ self?.prepareOutputs()
271
+ }
176
272
 
177
- // Start the session
178
- captureSession.startRunning()
179
- print("[CameraPreview] Session started")
273
+ // // Configure device inputs for the requested camera
274
+ try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
180
275
 
181
- completionHandler(nil)
276
+ // Start the session on background thread (AVCaptureSession.startRunning() is thread-safe)
277
+ captureSession.startRunning()
278
+ print("[CameraPreview] Session started")
279
+
280
+ // Validate and set initial zoom level asynchronously
281
+ if initialZoomLevel != 1.0 {
282
+ DispatchQueue.main.async { [weak self] in
283
+ self?.setInitialZoom(level: initialZoomLevel)
284
+ }
285
+ }
286
+
287
+ // Call completion on main thread
288
+ DispatchQueue.main.async {
289
+ completionHandler(nil)
290
+
291
+ // Upgrade quality settings after a short delay for better user experience
292
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
293
+ guard let self = self else { return }
294
+
295
+ // Wait for outputs to be prepared before proceeding
296
+ self.waitForOutputsToBeReady()
297
+
298
+ // Add outputs to session and apply user settings
299
+ do {
300
+ try self.addOutputsToSession(cameraMode: cameraMode, aspectRatio: aspectRatio)
301
+ print("[CameraPreview] Outputs successfully added to session")
302
+ } catch {
303
+ print("[CameraPreview] Error adding outputs to session: \(error)")
304
+ }
305
+
306
+ self.upgradeQualitySettings()
307
+ }
308
+ }
309
+ } catch {
310
+ DispatchQueue.main.async {
311
+ completionHandler(error)
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ private func setInitialZoom(level: Float) {
318
+ let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
319
+ guard let device = device else { return }
320
+
321
+ let minZoom = device.minAvailableVideoZoomFactor
322
+ let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
323
+
324
+ guard CGFloat(level) >= minZoom && CGFloat(level) <= maxZoom else {
325
+ print("[CameraPreview] Initial zoom level \(level) out of range (\(minZoom)-\(maxZoom))")
326
+ return
327
+ }
328
+
329
+ do {
330
+ try device.lockForConfiguration()
331
+ device.videoZoomFactor = CGFloat(level)
332
+ device.unlockForConfiguration()
333
+ self.zoomFactor = CGFloat(level)
334
+ print("[CameraPreview] Set initial zoom to \(level)")
182
335
  } catch {
183
- completionHandler(error)
336
+ print("[CameraPreview] Failed to set initial zoom: \(error)")
184
337
  }
185
338
  }
186
339
 
187
340
  private func configureDeviceInputs(cameraPosition: String, deviceId: String?, disableAudio: Bool) throws {
188
341
  guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
189
342
 
343
+ // Ensure cameras are discovered before configuring inputs
344
+ ensureCamerasDiscovered()
345
+
190
346
  var selectedDevice: AVCaptureDevice?
191
347
 
192
- // If deviceId is specified, find that specific device from pre-discovered devices
348
+ // If deviceId is specified, find that specific device from discovered devices
193
349
  if let deviceId = deviceId {
194
350
  selectedDevice = self.allDiscoveredDevices.first(where: { $0.uniqueID == deviceId })
195
351
  guard selectedDevice != nil else {
196
- print("[CameraPreview] ERROR: Device with ID \(deviceId) not found in pre-discovered devices")
352
+ print("[CameraPreview] ERROR: Device with ID \(deviceId) not found in discovered devices")
197
353
  throw CameraControllerError.noCamerasAvailable
198
354
  }
199
355
  } else {
200
- // Use position-based selection from pre-discovered cameras
356
+ // Use position-based selection from discovered cameras
201
357
  if cameraPosition == "rear" {
202
358
  selectedDevice = self.rearCamera
203
359
  } else if cameraPosition == "front" {
@@ -223,16 +379,16 @@ extension CameraController {
223
379
  self.rearCameraInput = deviceInput
224
380
  self.currentCameraPosition = .rear
225
381
 
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 {
382
+ // Configure zoom for multi-camera systems - simplified and faster
383
+ if finalDevice.isVirtualDevice && finalDevice.constituentDevices.count > 1 {
384
+ try finalDevice.lockForConfiguration()
385
+ let defaultWideAngleZoom: CGFloat = 1.0 // Changed from 2.0 to 1.0 for faster startup
230
386
  if defaultWideAngleZoom >= finalDevice.minAvailableVideoZoomFactor && defaultWideAngleZoom <= finalDevice.maxAvailableVideoZoomFactor {
231
387
  print("[CameraPreview] Setting initial zoom to \(defaultWideAngleZoom)")
232
388
  finalDevice.videoZoomFactor = defaultWideAngleZoom
233
389
  }
390
+ finalDevice.unlockForConfiguration()
234
391
  }
235
- finalDevice.unlockForConfiguration()
236
392
  }
237
393
  } else {
238
394
  throw CameraControllerError.inputsAreInvalid
@@ -257,9 +413,13 @@ extension CameraController {
257
413
  private func addOutputsToSession(cameraMode: Bool, aspectRatio: String?) throws {
258
414
  guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
259
415
 
416
+ // Begin configuration to batch all changes
417
+ captureSession.beginConfiguration()
418
+ defer { captureSession.commitConfiguration() }
419
+
260
420
  // Update session preset based on aspect ratio if needed
261
421
  var targetPreset: AVCaptureSession.Preset = .high // Default to high quality
262
-
422
+
263
423
  if let aspectRatio = aspectRatio {
264
424
  switch aspectRatio {
265
425
  case "16:9":
@@ -270,7 +430,7 @@ extension CameraController {
270
430
  targetPreset = .high
271
431
  }
272
432
  }
273
-
433
+
274
434
  // Always try to set the best preset available
275
435
  if captureSession.canSetSessionPreset(targetPreset) {
276
436
  captureSession.sessionPreset = targetPreset
@@ -283,7 +443,7 @@ extension CameraController {
283
443
 
284
444
  // Add photo output (already created in prepareOutputs)
285
445
  if let photoOutput = self.photoOutput, captureSession.canAddOutput(photoOutput) {
286
- photoOutput.isHighResolutionCaptureEnabled = self.highResolutionOutput
446
+ photoOutput.isHighResolutionCaptureEnabled = true
287
447
  captureSession.addOutput(photoOutput)
288
448
  }
289
449
 
@@ -295,9 +455,10 @@ extension CameraController {
295
455
  // Add data output
296
456
  if let dataOutput = self.dataOutput, captureSession.canAddOutput(dataOutput) {
297
457
  captureSession.addOutput(dataOutput)
298
- captureSession.commitConfiguration()
299
-
300
- dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
458
+ // Set delegate after outputs are added for better performance
459
+ DispatchQueue.main.async {
460
+ dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue.main)
461
+ }
301
462
  }
302
463
  }
303
464
 
@@ -306,33 +467,32 @@ extension CameraController {
306
467
 
307
468
  print("[CameraPreview] displayPreview called with view frame: \(view.frame)")
308
469
 
309
- self.previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
310
- self.previewLayer?.videoGravity = AVLayerVideoGravity.resizeAspectFill
470
+ // Create and configure preview layer in one go
471
+ let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
311
472
 
312
- // Optimize preview layer for better quality
313
- self.previewLayer?.connection?.videoOrientation = .portrait
314
- self.previewLayer?.isOpaque = true
315
-
316
- // Set contentsScale for retina display quality
317
- self.previewLayer?.contentsScale = UIScreen.main.scale
473
+ // Batch all layer configuration to avoid multiple redraws
474
+ CATransaction.begin()
475
+ CATransaction.setDisableActions(true)
318
476
 
319
- // Enable high-quality rendering
320
- if #available(iOS 13.0, *) {
321
- self.previewLayer?.videoGravity = .resizeAspectFill
322
- }
477
+ previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
478
+ previewLayer.connection?.videoOrientation = .portrait
479
+ previewLayer.isOpaque = true
480
+ previewLayer.contentsScale = UIScreen.main.scale
481
+ previewLayer.frame = view.bounds
323
482
 
324
- view.layer.insertSublayer(self.previewLayer!, at: 0)
483
+ // Insert layer and store reference
484
+ view.layer.insertSublayer(previewLayer, at: 0)
485
+ self.previewLayer = previewLayer
325
486
 
326
- // Disable animation for frame update
327
- CATransaction.begin()
328
- CATransaction.setDisableActions(true)
329
- self.previewLayer?.frame = view.bounds
330
487
  CATransaction.commit()
331
488
 
332
489
  print("[CameraPreview] Set preview layer frame to view bounds: \(view.bounds)")
333
490
  print("[CameraPreview] Session preset: \(captureSession.sessionPreset.rawValue)")
334
491
 
335
- updateVideoOrientation()
492
+ // Update video orientation asynchronously to avoid blocking
493
+ DispatchQueue.main.async { [weak self] in
494
+ self?.updateVideoOrientation()
495
+ }
336
496
  }
337
497
 
338
498
  func addGridOverlay(to view: UIView, gridMode: String) {
@@ -512,6 +672,26 @@ extension CameraController {
512
672
 
513
673
  let settings = AVCapturePhotoSettings()
514
674
 
675
+ // Apply the current flash mode to the photo settings
676
+ // Check if the current device supports flash
677
+ var currentCamera: AVCaptureDevice?
678
+ switch currentCameraPosition {
679
+ case .front:
680
+ currentCamera = self.frontCamera
681
+ case .rear:
682
+ currentCamera = self.rearCamera
683
+ default:
684
+ break
685
+ }
686
+
687
+ // Only apply flash if the device has flash and the flash mode is supported
688
+ if let device = currentCamera, device.hasFlash {
689
+ let supportedFlashModes = photoOutput.supportedFlashModes
690
+ if supportedFlashModes.contains(self.flashMode) {
691
+ settings.flashMode = self.flashMode
692
+ }
693
+ }
694
+
515
695
  self.photoCaptureCompletionBlock = { (image, error) in
516
696
  if let error = error {
517
697
  completion(nil, error)
@@ -749,7 +929,7 @@ extension CameraController {
749
929
  )
750
930
  }
751
931
 
752
- func setZoom(level: CGFloat, ramp: Bool) throws {
932
+ func setZoom(level: CGFloat, ramp: Bool, autoFocus: Bool = true) throws {
753
933
  var currentCamera: AVCaptureDevice?
754
934
  switch currentCameraPosition {
755
935
  case .front:
@@ -780,11 +960,117 @@ extension CameraController {
780
960
 
781
961
  // Update our internal zoom factor tracking
782
962
  self.zoomFactor = zoomLevel
963
+
964
+ // Trigger autofocus after zoom if requested
965
+ if autoFocus {
966
+ self.triggerAutoFocus()
967
+ }
783
968
  } catch {
784
969
  throw CameraControllerError.invalidOperation
785
970
  }
786
971
  }
787
972
 
973
+ private func triggerAutoFocus() {
974
+ var currentCamera: AVCaptureDevice?
975
+ switch currentCameraPosition {
976
+ case .front:
977
+ currentCamera = self.frontCamera
978
+ case .rear:
979
+ currentCamera = self.rearCamera
980
+ default: break
981
+ }
982
+
983
+ guard let device = currentCamera else {
984
+ return
985
+ }
986
+
987
+ // Focus on the center of the preview (0.5, 0.5)
988
+ let centerPoint = CGPoint(x: 0.5, y: 0.5)
989
+
990
+ do {
991
+ try device.lockForConfiguration()
992
+
993
+ // Set focus mode to auto if supported
994
+ if device.isFocusModeSupported(.autoFocus) {
995
+ device.focusMode = .autoFocus
996
+ if device.isFocusPointOfInterestSupported {
997
+ device.focusPointOfInterest = centerPoint
998
+ }
999
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
1000
+ device.focusMode = .continuousAutoFocus
1001
+ if device.isFocusPointOfInterestSupported {
1002
+ device.focusPointOfInterest = centerPoint
1003
+ }
1004
+ }
1005
+
1006
+ // Also set exposure point if supported
1007
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1008
+ device.exposureMode = .autoExpose
1009
+ device.exposurePointOfInterest = centerPoint
1010
+ } else if device.isExposureModeSupported(.continuousAutoExposure) {
1011
+ device.exposureMode = .continuousAutoExposure
1012
+ if device.isExposurePointOfInterestSupported {
1013
+ device.exposurePointOfInterest = centerPoint
1014
+ }
1015
+ }
1016
+
1017
+ device.unlockForConfiguration()
1018
+ } catch {
1019
+ // Silently ignore errors during autofocus
1020
+ }
1021
+ }
1022
+
1023
+ func setFocus(at point: CGPoint, showIndicator: Bool = false, in view: UIView? = nil) throws {
1024
+ var currentCamera: AVCaptureDevice?
1025
+ switch currentCameraPosition {
1026
+ case .front:
1027
+ currentCamera = self.frontCamera
1028
+ case .rear:
1029
+ currentCamera = self.rearCamera
1030
+ default: break
1031
+ }
1032
+
1033
+ guard let device = currentCamera else {
1034
+ throw CameraControllerError.noCamerasAvailable
1035
+ }
1036
+
1037
+ guard device.isFocusPointOfInterestSupported else {
1038
+ // Device doesn't support focus point of interest
1039
+ return
1040
+ }
1041
+
1042
+ // Show focus indicator if requested and view is provided
1043
+ if showIndicator, let view = view, let previewLayer = self.previewLayer {
1044
+ // Convert the device point to layer point for indicator display
1045
+ let layerPoint = previewLayer.layerPointConverted(fromCaptureDevicePoint: point)
1046
+ showFocusIndicator(at: layerPoint, in: view)
1047
+ }
1048
+
1049
+ do {
1050
+ try device.lockForConfiguration()
1051
+
1052
+ // Set focus mode to auto if supported
1053
+ if device.isFocusModeSupported(.autoFocus) {
1054
+ device.focusMode = .autoFocus
1055
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
1056
+ device.focusMode = .continuousAutoFocus
1057
+ }
1058
+
1059
+ // Set the focus point
1060
+ device.focusPointOfInterest = point
1061
+
1062
+ // Also set exposure point if supported
1063
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1064
+ device.exposureMode = .autoExpose
1065
+ device.exposurePointOfInterest = point
1066
+ }
1067
+
1068
+ device.unlockForConfiguration()
1069
+ } catch {
1070
+ throw CameraControllerError.unknown
1071
+ }
1072
+ }
1073
+
788
1074
  func getFlashMode() throws -> String {
789
1075
  switch self.flashMode {
790
1076
  case .off:
@@ -955,6 +1241,9 @@ extension CameraController {
955
1241
  self.previewLayer?.removeFromSuperlayer()
956
1242
  self.previewLayer = nil
957
1243
 
1244
+ self.focusIndicatorView?.removeFromSuperview()
1245
+ self.focusIndicatorView = nil
1246
+
958
1247
  self.frontCameraInput = nil
959
1248
  self.rearCameraInput = nil
960
1249
  self.audioInput = nil
@@ -969,6 +1258,9 @@ extension CameraController {
969
1258
 
970
1259
  self.captureSession = nil
971
1260
  self.currentCameraPosition = nil
1261
+
1262
+ // Reset output preparation status
1263
+ self.outputsPrepared = false
972
1264
  }
973
1265
 
974
1266
  func captureVideo() throws {
@@ -1045,6 +1337,11 @@ extension CameraController: UIGestureRecognizerDelegate {
1045
1337
  let point = tap.location(in: tap.view)
1046
1338
  let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: point)
1047
1339
 
1340
+ // Show focus indicator at the tap point
1341
+ if let view = tap.view {
1342
+ showFocusIndicator(at: point, in: view)
1343
+ }
1344
+
1048
1345
  do {
1049
1346
  try device.lockForConfiguration()
1050
1347
  defer { device.unlockForConfiguration() }
@@ -1065,6 +1362,54 @@ extension CameraController: UIGestureRecognizerDelegate {
1065
1362
  }
1066
1363
  }
1067
1364
 
1365
+ private func showFocusIndicator(at point: CGPoint, in view: UIView) {
1366
+ // Remove any existing focus indicator
1367
+ focusIndicatorView?.removeFromSuperview()
1368
+
1369
+ // Create a new focus indicator
1370
+ let indicator = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
1371
+ indicator.center = point
1372
+ indicator.layer.borderColor = UIColor.yellow.cgColor
1373
+ indicator.layer.borderWidth = 2.0
1374
+ indicator.layer.cornerRadius = 40
1375
+ indicator.backgroundColor = UIColor.clear
1376
+ indicator.alpha = 0
1377
+ indicator.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
1378
+
1379
+ // Add inner circle for better visibility
1380
+ let innerCircle = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40))
1381
+ innerCircle.layer.borderColor = UIColor.yellow.cgColor
1382
+ innerCircle.layer.borderWidth = 1.0
1383
+ innerCircle.layer.cornerRadius = 20
1384
+ innerCircle.backgroundColor = UIColor.clear
1385
+ indicator.addSubview(innerCircle)
1386
+
1387
+ view.addSubview(indicator)
1388
+ focusIndicatorView = indicator
1389
+
1390
+ // Animate the focus indicator
1391
+ UIView.animate(withDuration: 0.15, animations: {
1392
+ indicator.alpha = 1.0
1393
+ indicator.transform = CGAffineTransform.identity
1394
+ }) { _ in
1395
+ // Keep the indicator visible for a moment
1396
+ UIView.animate(withDuration: 0.2, delay: 0.5, options: [], animations: {
1397
+ indicator.alpha = 0.3
1398
+ }) { _ in
1399
+ // Fade out and remove
1400
+ UIView.animate(withDuration: 0.3, delay: 0.2, options: [], animations: {
1401
+ indicator.alpha = 0
1402
+ indicator.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
1403
+ }) { _ in
1404
+ indicator.removeFromSuperview()
1405
+ if self.focusIndicatorView == indicator {
1406
+ self.focusIndicatorView = nil
1407
+ }
1408
+ }
1409
+ }
1410
+ }
1411
+ }
1412
+
1068
1413
  @objc
1069
1414
  private func handlePinch(_ pinch: UIPinchGestureRecognizer) {
1070
1415
  guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
@@ -1179,6 +1524,7 @@ enum CameraControllerError: Swift.Error {
1179
1524
  case cannotFindDocumentsDirectory
1180
1525
  case fileVideoOutputNotFound
1181
1526
  case unknown
1527
+ case invalidZoomLevel(min: CGFloat, max: CGFloat, requested: CGFloat)
1182
1528
  }
1183
1529
 
1184
1530
  public enum CameraPosition {
@@ -1205,6 +1551,8 @@ extension CameraControllerError: LocalizedError {
1205
1551
  return NSLocalizedString("Cannot find documents directory", comment: "This should never happen")
1206
1552
  case .fileVideoOutputNotFound:
1207
1553
  return NSLocalizedString("Video recording is not available. Make sure the camera is properly initialized.", comment: "Video recording not available")
1554
+ case .invalidZoomLevel(let min, let max, let requested):
1555
+ return NSLocalizedString("Invalid zoom level. Must be between \(min) and \(max). Requested: \(requested)", comment: "Invalid Zoom Level")
1208
1556
  }
1209
1557
  }
1210
1558
  }
@@ -1268,9 +1616,13 @@ extension UIImage {
1268
1616
 
1269
1617
  switch imageOrientation {
1270
1618
  case .left, .leftMirrored, .right, .rightMirrored:
1271
- ctx.draw(self.cgImage!, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
1619
+ if let cgImage = self.cgImage {
1620
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.height, height: size.width))
1621
+ }
1272
1622
  default:
1273
- ctx.draw(self.cgImage!, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
1623
+ if let cgImage = self.cgImage {
1624
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
1625
+ }
1274
1626
  }
1275
1627
  guard let newCGImage = ctx.makeImage() else { return nil }
1276
1628
  return UIImage.init(cgImage: newCGImage, scale: 1, orientation: .up)