@capgo/camera-preview 7.4.0-alpha.3 → 7.4.0-alpha.32

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.
@@ -25,7 +25,7 @@ class CameraController: NSObject {
25
25
  var focusIndicatorView: UIView?
26
26
 
27
27
  var flashMode = AVCaptureDevice.FlashMode.off
28
- var photoCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
28
+ var photoCaptureCompletionBlock: ((UIImage?, Data?, [AnyHashable: Any]?, Error?) -> Void)?
29
29
 
30
30
  var sampleBufferCaptureCompletionBlock: ((UIImage?, Error?) -> Void)?
31
31
 
@@ -51,6 +51,25 @@ class CameraController: NSObject {
51
51
  // A rear multi-lens virtual camera will have a min zoom of 1.0 but support wider angles
52
52
  return device.position == .back && device.isVirtualDevice && device.constituentDevices.count > 1
53
53
  }
54
+
55
+ // Returns the display zoom multiplier introduced in iOS 18 to map between
56
+ // native zoom factor and the UI-displayed zoom factor. Falls back to 1.0 on
57
+ // older systems or if the property is unavailable.
58
+ func getDisplayZoomMultiplier() -> Float {
59
+ var multiplier: Float = 1.0
60
+ // Use KVC to avoid compile-time dependency on the iOS 18 SDK symbol
61
+ let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
62
+ if #available(iOS 18.0, *), let device = device {
63
+ if let value = device.value(forKey: "displayVideoZoomFactorMultiplier") as? NSNumber {
64
+ let m = value.floatValue
65
+ if m > 0 { multiplier = m }
66
+ }
67
+ }
68
+ return multiplier
69
+ }
70
+
71
+ // Track whether an aspect ratio was explicitly requested
72
+ var requestedAspectRatio: String?
54
73
  }
55
74
 
56
75
  extension CameraController {
@@ -169,7 +188,7 @@ extension CameraController {
169
188
  self.outputsPrepared = true
170
189
  }
171
190
 
172
- func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float = 1.0, completionHandler: @escaping (Error?) -> Void) {
191
+ func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float?, completionHandler: @escaping (Error?) -> Void) {
173
192
  print("[CameraPreview] 🎬 Starting prepare - position: \(cameraPosition), deviceId: \(deviceId ?? "nil"), disableAudio: \(disableAudio), cameraMode: \(cameraMode), aspectRatio: \(aspectRatio ?? "nil"), zoom: \(initialZoomLevel)")
174
193
 
175
194
  DispatchQueue.global(qos: .userInitiated).async { [weak self] in
@@ -196,7 +215,8 @@ extension CameraController {
196
215
  // Configure the session
197
216
  captureSession.beginConfiguration()
198
217
 
199
- // Set aspect ratio preset
218
+ // Set aspect ratio preset and remember requested ratio
219
+ self.requestedAspectRatio = aspectRatio
200
220
  self.configureSessionPreset(for: aspectRatio)
201
221
 
202
222
  // Configure device inputs
@@ -252,16 +272,31 @@ extension CameraController {
252
272
  private func configureSessionPreset(for aspectRatio: String?) {
253
273
  guard let captureSession = self.captureSession else { return }
254
274
 
255
- var targetPreset: AVCaptureSession.Preset = .high
256
-
275
+ var targetPreset: AVCaptureSession.Preset = .photo
257
276
  if let aspectRatio = aspectRatio {
258
277
  switch aspectRatio {
259
278
  case "16:9":
260
- targetPreset = captureSession.canSetSessionPreset(.hd1920x1080) ? .hd1920x1080 : .high
279
+ if captureSession.canSetSessionPreset(.hd4K3840x2160) {
280
+ targetPreset = .hd4K3840x2160
281
+ } else if captureSession.canSetSessionPreset(.hd1920x1080) {
282
+ targetPreset = .hd1920x1080
283
+ }
261
284
  case "4:3":
262
- targetPreset = captureSession.canSetSessionPreset(.photo) ? .photo : .high
285
+ if captureSession.canSetSessionPreset(.photo) {
286
+ targetPreset = .photo
287
+ } else if captureSession.canSetSessionPreset(.high) {
288
+ targetPreset = .high
289
+ } else {
290
+ targetPreset = captureSession.sessionPreset
291
+ }
263
292
  default:
264
- targetPreset = .high
293
+ if captureSession.canSetSessionPreset(.photo) {
294
+ targetPreset = .photo
295
+ } else if captureSession.canSetSessionPreset(.high) {
296
+ targetPreset = .high
297
+ } else {
298
+ targetPreset = captureSession.sessionPreset
299
+ }
265
300
  }
266
301
  }
267
302
 
@@ -270,7 +305,7 @@ extension CameraController {
270
305
  }
271
306
  }
272
307
 
273
- private func setInitialZoom(level: Float) {
308
+ private func setInitialZoom(level: Float?) {
274
309
  let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
275
310
  guard let device = device else {
276
311
  print("[CameraPreview] No device available for initial zoom")
@@ -280,7 +315,12 @@ extension CameraController {
280
315
  let minZoom = device.minAvailableVideoZoomFactor
281
316
  let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
282
317
 
283
- let adjustedLevel = level
318
+ // Compute UI-level default = 1 * multiplier when not provided
319
+ let multiplier = self.getDisplayZoomMultiplier()
320
+ // if level is nil, it's the initial zoom
321
+ let uiLevel: Float = level ?? (2.0 * multiplier)
322
+ // Map UI/display zoom to native zoom using iOS 18+ multiplier
323
+ let adjustedLevel = multiplier != 1.0 ? (uiLevel / multiplier) : uiLevel
284
324
 
285
325
  guard CGFloat(adjustedLevel) >= minZoom && CGFloat(adjustedLevel) <= maxZoom else {
286
326
  print("[CameraPreview] Initial zoom level \(adjustedLevel) out of range (\(minZoom)-\(maxZoom))")
@@ -376,7 +416,8 @@ extension CameraController {
376
416
  }
377
417
 
378
418
  // Fast configuration without CATransaction overhead
379
- previewLayer.videoGravity = .resizeAspectFill
419
+ // Use resizeAspect to avoid crop when no aspect ratio is requested; otherwise fill
420
+ previewLayer.videoGravity = (requestedAspectRatio == nil) ? .resizeAspect : .resizeAspectFill
380
421
  previewLayer.frame = view.bounds
381
422
 
382
423
  // Insert layer immediately (only if new)
@@ -464,6 +505,33 @@ extension CameraController {
464
505
  photoOutput?.connections.forEach { $0.videoOrientation = videoOrientation }
465
506
  }
466
507
 
508
+ private func setDefaultZoomAfterFlip() {
509
+ let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
510
+ guard let device = device else {
511
+ print("[CameraPreview] No device available for default zoom after flip")
512
+ return
513
+ }
514
+
515
+ // Set zoom to 1.0x in UI terms, accounting for display multiplier
516
+ let multiplier = self.getDisplayZoomMultiplier()
517
+ let targetUIZoom: Float = 1.0 // We want 1.0x in the UI
518
+ let nativeZoom = multiplier != 1.0 ? (targetUIZoom / multiplier) : targetUIZoom
519
+
520
+ let minZoom = device.minAvailableVideoZoomFactor
521
+ let maxZoom = min(device.maxAvailableVideoZoomFactor, saneMaxZoomFactor)
522
+ let clampedZoom = max(minZoom, min(CGFloat(nativeZoom), maxZoom))
523
+
524
+ do {
525
+ try device.lockForConfiguration()
526
+ device.videoZoomFactor = clampedZoom
527
+ device.unlockForConfiguration()
528
+ self.zoomFactor = clampedZoom
529
+ print("[CameraPreview] Set default zoom after flip: UI=\(targetUIZoom)x, native=\(clampedZoom), multiplier=\(multiplier)")
530
+ } catch {
531
+ print("[CameraPreview] Failed to set default zoom after flip: \(error)")
532
+ }
533
+ }
534
+
467
535
  func switchCameras() throws {
468
536
  guard let currentCameraPosition = currentCameraPosition,
469
537
  let captureSession = self.captureSession else {
@@ -553,17 +621,29 @@ extension CameraController {
553
621
 
554
622
  // Update video orientation
555
623
  self.updateVideoOrientation()
624
+
625
+ // Set default 1.0 zoom level after camera switch to prevent iOS 18+ zoom jumps
626
+ DispatchQueue.main.async { [weak self] in
627
+ self?.setDefaultZoomAfterFlip()
628
+ }
556
629
  }
557
630
 
558
- func captureImage(width: Int?, height: Int?, aspectRatio: String?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Error?) -> Void) {
631
+ func captureImage(width: Int?, height: Int?, aspectRatio: String?, quality: Float, gpsLocation: CLLocation?, completion: @escaping (UIImage?, Data?, [AnyHashable: Any]?, Error?) -> Void) {
559
632
  print("[CameraPreview] captureImage called - width: \(width ?? -1), height: \(height ?? -1), aspectRatio: \(aspectRatio ?? "nil")")
560
633
 
561
634
  guard let photoOutput = self.photoOutput else {
562
- completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
635
+ completion(nil, nil, nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Photo output is not available"]))
563
636
  return
564
637
  }
565
638
 
566
639
  let settings = AVCapturePhotoSettings()
640
+ // Request highest quality photo capture
641
+ if #available(iOS 13.0, *) {
642
+ settings.isHighResolutionPhotoEnabled = true
643
+ }
644
+ if #available(iOS 15.0, *) {
645
+ settings.photoQualityPrioritization = .balanced
646
+ }
567
647
 
568
648
  // Apply the current flash mode to the photo settings
569
649
  // Check if the current device supports flash
@@ -585,14 +665,14 @@ extension CameraController {
585
665
  }
586
666
  }
587
667
 
588
- self.photoCaptureCompletionBlock = { (image, error) in
668
+ self.photoCaptureCompletionBlock = { (image, photoData, metadata, error) in
589
669
  if let error = error {
590
- completion(nil, error)
670
+ completion(nil, nil, nil, error)
591
671
  return
592
672
  }
593
673
 
594
674
  guard let image = image else {
595
- completion(nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to capture image"]))
675
+ completion(nil, nil, nil, NSError(domain: "Camera", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to capture image"]))
596
676
  return
597
677
  }
598
678
 
@@ -646,7 +726,7 @@ extension CameraController {
646
726
  }
647
727
  }
648
728
 
649
- completion(finalImage, nil)
729
+ completion(finalImage, photoData, metadata, nil)
650
730
  }
651
731
 
652
732
  photoOutput.capturePhoto(with: settings, delegate: self)
@@ -804,6 +884,7 @@ extension CameraController {
804
884
  return supportedFlashModesAsStrings
805
885
 
806
886
  }
887
+
807
888
  func getHorizontalFov() throws -> Float {
808
889
  var currentCamera: AVCaptureDevice?
809
890
  switch currentCameraPosition {
@@ -830,6 +911,7 @@ extension CameraController {
830
911
 
831
912
  return adjustedFov
832
913
  }
914
+
833
915
  func setFlashMode(flashMode: AVCaptureDevice.FlashMode) throws {
834
916
  var currentCamera: AVCaptureDevice?
835
917
  switch currentCameraPosition {
@@ -903,6 +985,7 @@ extension CameraController {
903
985
 
904
986
  func getZoom() throws -> (min: Float, max: Float, current: Float) {
905
987
  var currentCamera: AVCaptureDevice?
988
+
906
989
  switch currentCameraPosition {
907
990
  case .front:
908
991
  currentCamera = self.frontCamera
@@ -957,9 +1040,9 @@ extension CameraController {
957
1040
  self.zoomFactor = zoomLevel
958
1041
 
959
1042
  // Trigger autofocus after zoom if requested
960
- // if autoFocus {
961
- // self.triggerAutoFocus()
962
- // }
1043
+ if autoFocus {
1044
+ self.triggerAutoFocus()
1045
+ }
963
1046
  } catch {
964
1047
  throw CameraControllerError.invalidOperation
965
1048
  }
@@ -1372,23 +1455,48 @@ extension CameraController: UIGestureRecognizerDelegate {
1372
1455
  // Remove any existing focus indicator
1373
1456
  focusIndicatorView?.removeFromSuperview()
1374
1457
 
1375
- // Create a new focus indicator
1458
+ // Create a new focus indicator (iOS Camera style): square with mid-edge ticks
1376
1459
  let indicator = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
1377
1460
  indicator.center = point
1378
1461
  indicator.layer.borderColor = UIColor.yellow.cgColor
1379
1462
  indicator.layer.borderWidth = 2.0
1380
- indicator.layer.cornerRadius = 40
1463
+ indicator.layer.cornerRadius = 0
1381
1464
  indicator.backgroundColor = UIColor.clear
1382
1465
  indicator.alpha = 0
1383
1466
  indicator.transform = CGAffineTransform(scaleX: 1.5, y: 1.5)
1384
1467
 
1385
- // Add inner circle for better visibility
1386
- let innerCircle = UIView(frame: CGRect(x: 20, y: 20, width: 40, height: 40))
1387
- innerCircle.layer.borderColor = UIColor.yellow.cgColor
1388
- innerCircle.layer.borderWidth = 1.0
1389
- innerCircle.layer.cornerRadius = 20
1390
- innerCircle.backgroundColor = UIColor.clear
1391
- indicator.addSubview(innerCircle)
1468
+ // Add 4 tiny mid-edge ticks inside the square
1469
+ let stroke: CGFloat = 2.0
1470
+ let tickLen: CGFloat = 12.0
1471
+ let inset: CGFloat = stroke // ticks should touch the sides
1472
+ // Top tick (perpendicular): vertical inward from top edge
1473
+ let topTick = UIView(frame: CGRect(x: (indicator.bounds.width - stroke)/2,
1474
+ y: inset,
1475
+ width: stroke,
1476
+ height: tickLen))
1477
+ topTick.backgroundColor = .yellow
1478
+ indicator.addSubview(topTick)
1479
+ // Bottom tick (perpendicular): vertical inward from bottom edge
1480
+ let bottomTick = UIView(frame: CGRect(x: (indicator.bounds.width - stroke)/2,
1481
+ y: indicator.bounds.height - inset - tickLen,
1482
+ width: stroke,
1483
+ height: tickLen))
1484
+ bottomTick.backgroundColor = .yellow
1485
+ indicator.addSubview(bottomTick)
1486
+ // Left tick (perpendicular): horizontal inward from left edge
1487
+ let leftTick = UIView(frame: CGRect(x: inset,
1488
+ y: (indicator.bounds.height - stroke)/2,
1489
+ width: tickLen,
1490
+ height: stroke))
1491
+ leftTick.backgroundColor = .yellow
1492
+ indicator.addSubview(leftTick)
1493
+ // Right tick (perpendicular): horizontal inward from right edge
1494
+ let rightTick = UIView(frame: CGRect(x: indicator.bounds.width - inset - tickLen,
1495
+ y: (indicator.bounds.height - stroke)/2,
1496
+ width: tickLen,
1497
+ height: stroke))
1498
+ rightTick.backgroundColor = .yellow
1499
+ indicator.addSubview(rightTick)
1392
1500
 
1393
1501
  view.addSubview(indicator)
1394
1502
  focusIndicatorView = indicator
@@ -1398,7 +1506,7 @@ extension CameraController: UIGestureRecognizerDelegate {
1398
1506
  indicator.alpha = 1.0
1399
1507
  indicator.transform = CGAffineTransform.identity
1400
1508
  }) { _ in
1401
- // Keep the indicator visible for a moment
1509
+ // Keep the indicator visible briefly
1402
1510
  UIView.animate(withDuration: 0.2, delay: 0.5, options: [], animations: {
1403
1511
  indicator.alpha = 0.3
1404
1512
  }) { _ in
@@ -1460,22 +1568,23 @@ extension CameraController: UIGestureRecognizerDelegate {
1460
1568
  extension CameraController: AVCapturePhotoCaptureDelegate {
1461
1569
  public func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
1462
1570
  if let error = error {
1463
- self.photoCaptureCompletionBlock?(nil, error)
1571
+ self.photoCaptureCompletionBlock?(nil, nil, nil, error)
1464
1572
  return
1465
1573
  }
1466
1574
 
1467
1575
  // Get the photo data using the modern API
1468
1576
  guard let imageData = photo.fileDataRepresentation() else {
1469
- self.photoCaptureCompletionBlock?(nil, CameraControllerError.unknown)
1577
+ self.photoCaptureCompletionBlock?(nil, nil, nil, CameraControllerError.unknown)
1470
1578
  return
1471
1579
  }
1472
1580
 
1473
1581
  guard let image = UIImage(data: imageData) else {
1474
- self.photoCaptureCompletionBlock?(nil, CameraControllerError.unknown)
1582
+ self.photoCaptureCompletionBlock?(nil, nil, nil, CameraControllerError.unknown)
1475
1583
  return
1476
1584
  }
1477
1585
 
1478
- self.photoCaptureCompletionBlock?(image.fixedOrientation(), nil)
1586
+ // Pass through original file data and metadata so callers can preserve EXIF
1587
+ self.photoCaptureCompletionBlock?(image.fixedOrientation(), imageData, photo.metadata, nil)
1479
1588
  }
1480
1589
  }
1481
1590