@capgo/camera-preview 7.4.0-beta.13 → 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
+ // }
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
+ 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
+ }
199
+
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()
164
205
 
165
- guard let captureSession = self.captureSession else {
166
- throw CameraControllerError.captureSessionIsMissing
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()
211
+ }
212
+ } else {
213
+ print("[CameraPreview] Outputs ready, proceeding with camera preparation")
214
+ }
215
+ }
216
+
217
+ func upgradeQualitySettings() {
218
+ guard let captureSession = self.captureSession else { return }
219
+
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 }
223
+
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
167
253
  }
168
254
 
169
- print("[CameraPreview] Fast prepare - using pre-initialized session")
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")
170
267
 
171
- // Configure device inputs for the requested camera
172
- try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
268
+ // Pre-create outputs asynchronously to avoid blocking camera opening
269
+ outputPreparationQueue.async { [weak self] in
270
+ self?.prepareOutputs()
271
+ }
173
272
 
174
- // Add outputs to session and apply user settings
175
- try self.addOutputsToSession(cameraMode: cameraMode, aspectRatio: aspectRatio)
273
+ // // Configure device inputs for the requested camera
274
+ try self.configureDeviceInputs(cameraPosition: cameraPosition, deviceId: deviceId, disableAudio: disableAudio)
176
275
 
177
- // Start the session
178
- captureSession.startRunning()
179
- print("[CameraPreview] Session started")
276
+ // Start the session on background thread (AVCaptureSession.startRunning() is thread-safe)
277
+ captureSession.startRunning()
278
+ print("[CameraPreview] Session started")
180
279
 
181
- completionHandler(nil)
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,6 +413,10 @@ 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
 
@@ -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,12 +960,67 @@ 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
 
788
- func setFocus(at point: CGPoint) throws {
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 {
789
1024
  var currentCamera: AVCaptureDevice?
790
1025
  switch currentCameraPosition {
791
1026
  case .front:
@@ -804,6 +1039,13 @@ extension CameraController {
804
1039
  return
805
1040
  }
806
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
+
807
1049
  do {
808
1050
  try device.lockForConfiguration()
809
1051
 
@@ -999,6 +1241,9 @@ extension CameraController {
999
1241
  self.previewLayer?.removeFromSuperlayer()
1000
1242
  self.previewLayer = nil
1001
1243
 
1244
+ self.focusIndicatorView?.removeFromSuperview()
1245
+ self.focusIndicatorView = nil
1246
+
1002
1247
  self.frontCameraInput = nil
1003
1248
  self.rearCameraInput = nil
1004
1249
  self.audioInput = nil
@@ -1013,6 +1258,9 @@ extension CameraController {
1013
1258
 
1014
1259
  self.captureSession = nil
1015
1260
  self.currentCameraPosition = nil
1261
+
1262
+ // Reset output preparation status
1263
+ self.outputsPrepared = false
1016
1264
  }
1017
1265
 
1018
1266
  func captureVideo() throws {
@@ -1089,6 +1337,11 @@ extension CameraController: UIGestureRecognizerDelegate {
1089
1337
  let point = tap.location(in: tap.view)
1090
1338
  let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: point)
1091
1339
 
1340
+ // Show focus indicator at the tap point
1341
+ if let view = tap.view {
1342
+ showFocusIndicator(at: point, in: view)
1343
+ }
1344
+
1092
1345
  do {
1093
1346
  try device.lockForConfiguration()
1094
1347
  defer { device.unlockForConfiguration() }
@@ -1109,6 +1362,54 @@ extension CameraController: UIGestureRecognizerDelegate {
1109
1362
  }
1110
1363
  }
1111
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
+
1112
1413
  @objc
1113
1414
  private func handlePinch(_ pinch: UIPinchGestureRecognizer) {
1114
1415
  guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
@@ -1223,6 +1524,7 @@ enum CameraControllerError: Swift.Error {
1223
1524
  case cannotFindDocumentsDirectory
1224
1525
  case fileVideoOutputNotFound
1225
1526
  case unknown
1527
+ case invalidZoomLevel(min: CGFloat, max: CGFloat, requested: CGFloat)
1226
1528
  }
1227
1529
 
1228
1530
  public enum CameraPosition {
@@ -1249,6 +1551,8 @@ extension CameraControllerError: LocalizedError {
1249
1551
  return NSLocalizedString("Cannot find documents directory", comment: "This should never happen")
1250
1552
  case .fileVideoOutputNotFound:
1251
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")
1252
1556
  }
1253
1557
  }
1254
1558
  }
@@ -1312,9 +1616,13 @@ extension UIImage {
1312
1616
 
1313
1617
  switch imageOrientation {
1314
1618
  case .left, .leftMirrored, .right, .rightMirrored:
1315
- 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
+ }
1316
1622
  default:
1317
- 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
+ }
1318
1626
  }
1319
1627
  guard let newCGImage = ctx.makeImage() else { return nil }
1320
1628
  return UIImage.init(cgImage: newCGImage, scale: 1, orientation: .up)