@capgo/camera-preview 8.8.0 → 8.9.0

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.
@@ -105,6 +105,10 @@ class CameraController: NSObject {
105
105
 
106
106
  var captureSession: AVCaptureSession?
107
107
  var disableFocusIndicator: Bool = false
108
+ private var focusExposureRestoreGeneration: UInt = 0
109
+ private var focusExposureRestoreTimeoutWorkItem: DispatchWorkItem?
110
+ private let focusExposureRestoreTimeout: TimeInterval = 3.0
111
+ private let focusExposureRestorePollInterval: TimeInterval = 0.05
108
112
 
109
113
  var currentCameraPosition: CameraPosition?
110
114
 
@@ -619,7 +623,10 @@ extension CameraController {
619
623
  self.photoOutput?.isHighResolutionCaptureEnabled = true
620
624
 
621
625
  // Create video output
622
- self.fileVideoOutput = AVCaptureMovieFileOutput()
626
+ let movieFileOutput = AVCaptureMovieFileOutput()
627
+ // MP4 does not support movie fragments; the default 10s interval drops the final partial segment on stop.
628
+ movieFileOutput.movieFragmentInterval = .invalid
629
+ self.fileVideoOutput = movieFileOutput
623
630
 
624
631
  // Create data output for preview
625
632
  self.dataOutput = AVCaptureVideoDataOutput()
@@ -1000,6 +1007,10 @@ extension CameraController {
1000
1007
  finalDevice.exposurePointOfInterest = CGPoint(x: 0.5, y: 0.5)
1001
1008
  }
1002
1009
  }
1010
+ // Default white balance to continuous auto (prevents a warm/yellow cast)
1011
+ if finalDevice.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) {
1012
+ finalDevice.whiteBalanceMode = .continuousAutoWhiteBalance
1013
+ }
1003
1014
  // Reset exposure compensation so sessions start neutral
1004
1015
  let minBias = finalDevice.minExposureTargetBias
1005
1016
  let maxBias = finalDevice.maxExposureTargetBias
@@ -1195,6 +1206,7 @@ extension CameraController {
1195
1206
  }
1196
1207
 
1197
1208
  func switchCameras() throws {
1209
+ self.cancelPendingFocusExposureRestore()
1198
1210
  configuredVideoFrameRate = nil
1199
1211
  guard let currentCameraPosition = currentCameraPosition,
1200
1212
  let captureSession = self.captureSession else {
@@ -2179,46 +2191,116 @@ extension CameraController {
2179
2191
  }
2180
2192
  }
2181
2193
 
2182
- private func triggerAutoFocus() {
2183
- var currentCamera: AVCaptureDevice?
2184
- switch currentCameraPosition {
2185
- case .front:
2186
- currentCamera = self.frontCamera
2187
- case .rear:
2188
- currentCamera = self.rearCamera
2189
- default: break
2194
+
2195
+ private func cancelPendingFocusExposureRestore() {
2196
+ self.focusExposureRestoreGeneration &+= 1
2197
+ self.focusExposureRestoreTimeoutWorkItem?.cancel()
2198
+ self.focusExposureRestoreTimeoutWorkItem = nil
2199
+ }
2200
+
2201
+ private func applyOneShotFocusAndExposure(at point: CGPoint, on device: AVCaptureDevice, adjustExposure: Bool) throws {
2202
+ try device.lockForConfiguration()
2203
+ defer { device.unlockForConfiguration() }
2204
+
2205
+ if device.isFocusModeSupported(.autoFocus) {
2206
+ device.focusMode = .autoFocus
2207
+ } else if device.isFocusModeSupported(.continuousAutoFocus) {
2208
+ device.focusMode = .continuousAutoFocus
2190
2209
  }
2191
2210
 
2192
- guard let device = currentCamera else {
2211
+ if device.isFocusPointOfInterestSupported {
2212
+ device.focusPointOfInterest = point
2213
+ }
2214
+
2215
+ if adjustExposure, device.exposureMode != .locked {
2216
+ if device.isExposurePointOfInterestSupported, device.isExposureModeSupported(.autoExpose) {
2217
+ device.exposureMode = .autoExpose
2218
+ device.setExposureTargetBias(0.0) { _ in }
2219
+ device.exposurePointOfInterest = point
2220
+ }
2221
+ }
2222
+
2223
+ device.isSubjectAreaChangeMonitoringEnabled = true
2224
+ }
2225
+
2226
+ private func scheduleFocusExposureRestore(for device: AVCaptureDevice) {
2227
+ self.focusExposureRestoreGeneration &+= 1
2228
+ let generation = self.focusExposureRestoreGeneration
2229
+
2230
+ self.focusExposureRestoreTimeoutWorkItem?.cancel()
2231
+
2232
+ let timeoutWork = DispatchWorkItem { [weak self] in
2233
+ self?.restoreContinuousFocusAndExposureIfCurrent(on: device, generation: generation)
2234
+ }
2235
+ self.focusExposureRestoreTimeoutWorkItem = timeoutWork
2236
+
2237
+ self.pollFocusExposureAdjustmentCompletion(on: device, generation: generation)
2238
+ DispatchQueue.main.asyncAfter(deadline: .now() + self.focusExposureRestoreTimeout, execute: timeoutWork)
2239
+ }
2240
+
2241
+ private func pollFocusExposureAdjustmentCompletion(on device: AVCaptureDevice, generation: UInt) {
2242
+ guard generation == self.focusExposureRestoreGeneration else { return }
2243
+
2244
+ if device.isAdjustingFocus || device.isAdjustingExposure {
2245
+ DispatchQueue.main.asyncAfter(deadline: .now() + self.focusExposureRestorePollInterval) { [weak self] in
2246
+ self?.pollFocusExposureAdjustmentCompletion(on: device, generation: generation)
2247
+ }
2193
2248
  return
2194
2249
  }
2195
2250
 
2196
- // Focus on the center of the preview (0.5, 0.5)
2197
- let centerPoint = CGPoint(x: 0.5, y: 0.5)
2251
+ self.restoreContinuousFocusAndExposureIfCurrent(on: device, generation: generation)
2252
+ }
2253
+
2254
+ private func restoreContinuousFocusAndExposureIfCurrent(on device: AVCaptureDevice, generation: UInt) {
2255
+ guard generation == self.focusExposureRestoreGeneration else { return }
2256
+
2257
+ self.focusExposureRestoreTimeoutWorkItem?.cancel()
2258
+ self.focusExposureRestoreTimeoutWorkItem = nil
2198
2259
 
2199
2260
  do {
2200
2261
  try device.lockForConfiguration()
2262
+ defer { device.unlockForConfiguration() }
2201
2263
 
2202
- // Set focus mode to auto if supported
2203
- if device.isFocusModeSupported(.autoFocus) {
2204
- device.focusMode = .autoFocus
2205
- if device.isFocusPointOfInterestSupported {
2206
- device.focusPointOfInterest = centerPoint
2207
- }
2208
- } else if device.isFocusModeSupported(.continuousAutoFocus) {
2264
+ let centerPoint = CGPoint(x: 0.5, y: 0.5)
2265
+
2266
+ if device.isFocusModeSupported(.continuousAutoFocus) {
2209
2267
  device.focusMode = .continuousAutoFocus
2210
2268
  if device.isFocusPointOfInterestSupported {
2211
2269
  device.focusPointOfInterest = centerPoint
2212
2270
  }
2213
2271
  }
2214
2272
 
2215
- if device.isExposurePointOfInterestSupported {
2216
- let exposureMode = try getExposureMode()
2217
- if exposureMode == "AUTO" || exposureMode == "CONTINUOUS" {
2218
- device.exposurePointOfInterest = centerPoint
2273
+ if device.exposureMode != .locked {
2274
+ if device.isExposureModeSupported(.continuousAutoExposure) {
2275
+ device.exposureMode = .continuousAutoExposure
2276
+ if device.isExposurePointOfInterestSupported {
2277
+ device.exposurePointOfInterest = centerPoint
2278
+ }
2279
+ device.setExposureTargetBias(0.0) { _ in }
2219
2280
  }
2220
2281
  }
2221
- device.unlockForConfiguration()
2282
+
2283
+ device.isSubjectAreaChangeMonitoringEnabled = false
2284
+ } catch {
2285
+ print("[CameraPreview] Failed to restore continuous focus/exposure: \(error)")
2286
+ }
2287
+ }
2288
+
2289
+ private func triggerAutoFocus() {
2290
+ guard let device = self.activeVideoDevice() else { return }
2291
+
2292
+ let centerPoint = CGPoint(x: 0.5, y: 0.5)
2293
+ let adjustExposure: Bool
2294
+ do {
2295
+ let exposureMode = try self.getExposureMode()
2296
+ adjustExposure = exposureMode == "AUTO" || exposureMode == "CONTINUOUS"
2297
+ } catch {
2298
+ adjustExposure = true
2299
+ }
2300
+
2301
+ do {
2302
+ try self.applyOneShotFocusAndExposure(at: centerPoint, on: device, adjustExposure: adjustExposure)
2303
+ self.scheduleFocusExposureRestore(for: device)
2222
2304
  } catch {
2223
2305
  // Silently ignore errors during autofocus
2224
2306
  }
@@ -2257,33 +2339,8 @@ extension CameraController {
2257
2339
  }
2258
2340
 
2259
2341
  do {
2260
- try device.lockForConfiguration()
2261
-
2262
- // Set focus mode to auto if supported
2263
- if device.isFocusModeSupported(.autoFocus) {
2264
- device.focusMode = .autoFocus
2265
- } else if device.isFocusModeSupported(.continuousAutoFocus) {
2266
- device.focusMode = .continuousAutoFocus
2267
- }
2268
-
2269
- // Set the focus point
2270
- device.focusPointOfInterest = point
2271
-
2272
- // Skip exposure point if exposure locked
2273
- if device.exposureMode != .locked {
2274
-
2275
- // Also set exposure point if supported
2276
- if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
2277
- device.exposureMode = .autoExpose
2278
- device.setExposureTargetBias(0.0) { _ in }
2279
- device.exposurePointOfInterest = point
2280
- }
2281
- }
2282
-
2283
- // Turn on subject area monitor for switch to continuous focus if needed
2284
- device.isSubjectAreaChangeMonitoringEnabled = true
2285
-
2286
- device.unlockForConfiguration()
2342
+ try self.applyOneShotFocusAndExposure(at: point, on: device, adjustExposure: true)
2343
+ self.scheduleFocusExposureRestore(for: device)
2287
2344
  } catch {
2288
2345
  throw CameraControllerError.unknown
2289
2346
  }
@@ -2372,6 +2429,7 @@ extension CameraController {
2372
2429
  }
2373
2430
 
2374
2431
  func swapToDevice(deviceId: String) throws {
2432
+ self.cancelPendingFocusExposureRestore()
2375
2433
  guard let captureSession = self.captureSession else {
2376
2434
  throw CameraControllerError.captureSessionIsMissing
2377
2435
  }
@@ -2460,6 +2518,7 @@ extension CameraController {
2460
2518
  }
2461
2519
 
2462
2520
  func cleanup() {
2521
+ self.cancelPendingFocusExposureRestore()
2463
2522
  configuredVideoFrameRate = nil
2464
2523
  stopBarcodeScanner()
2465
2524
  if let captureSession = self.captureSession {
@@ -2669,6 +2728,100 @@ extension CameraController {
2669
2728
  }
2670
2729
  }
2671
2730
 
2731
+ // MARK: - White Balance Controls
2732
+
2733
+ func getWhiteBalanceModes() throws -> [String] {
2734
+ var currentCamera: AVCaptureDevice?
2735
+ switch currentCameraPosition {
2736
+ case .front:
2737
+ currentCamera = self.frontCamera
2738
+ case .rear:
2739
+ currentCamera = self.rearCamera
2740
+ default:
2741
+ break
2742
+ }
2743
+
2744
+ guard let device = currentCamera else {
2745
+ throw CameraControllerError.noCamerasAvailable
2746
+ }
2747
+
2748
+ var modes: [String] = []
2749
+ if device.isWhiteBalanceModeSupported(.locked) { modes.append("LOCK") }
2750
+ if device.isWhiteBalanceModeSupported(.autoWhiteBalance) { modes.append("AUTO") }
2751
+ if device.isWhiteBalanceModeSupported(.continuousAutoWhiteBalance) { modes.append("CONTINUOUS") }
2752
+ return modes
2753
+ }
2754
+
2755
+ func getWhiteBalanceMode() throws -> String {
2756
+ var currentCamera: AVCaptureDevice?
2757
+ switch currentCameraPosition {
2758
+ case .front:
2759
+ currentCamera = self.frontCamera
2760
+ case .rear:
2761
+ currentCamera = self.rearCamera
2762
+ default:
2763
+ break
2764
+ }
2765
+
2766
+ guard let device = currentCamera else {
2767
+ throw CameraControllerError.noCamerasAvailable
2768
+ }
2769
+
2770
+ switch device.whiteBalanceMode {
2771
+ case .locked:
2772
+ return "LOCK"
2773
+ case .autoWhiteBalance:
2774
+ return "AUTO"
2775
+ case .continuousAutoWhiteBalance:
2776
+ return "CONTINUOUS"
2777
+ @unknown default:
2778
+ return "CONTINUOUS"
2779
+ }
2780
+ }
2781
+
2782
+ func setWhiteBalanceMode(mode: String) throws {
2783
+ var currentCamera: AVCaptureDevice?
2784
+ switch currentCameraPosition {
2785
+ case .front:
2786
+ currentCamera = self.frontCamera
2787
+ case .rear:
2788
+ currentCamera = self.rearCamera
2789
+ default:
2790
+ break
2791
+ }
2792
+
2793
+ guard let device = currentCamera else {
2794
+ throw CameraControllerError.noCamerasAvailable
2795
+ }
2796
+
2797
+ let normalized = mode.uppercased()
2798
+ let desiredMode: AVCaptureDevice.WhiteBalanceMode?
2799
+ switch normalized {
2800
+ case "LOCK":
2801
+ desiredMode = .locked
2802
+ case "AUTO":
2803
+ desiredMode = .autoWhiteBalance
2804
+ case "CONTINUOUS":
2805
+ desiredMode = .continuousAutoWhiteBalance
2806
+ case "CUSTOM":
2807
+ throw CameraControllerError.invalidOperation
2808
+ default:
2809
+ throw CameraControllerError.invalidOperation
2810
+ }
2811
+
2812
+ guard let finalMode = desiredMode, device.isWhiteBalanceModeSupported(finalMode) else {
2813
+ throw CameraControllerError.invalidOperation
2814
+ }
2815
+
2816
+ do {
2817
+ try device.lockForConfiguration()
2818
+ device.whiteBalanceMode = finalMode
2819
+ device.unlockForConfiguration()
2820
+ } catch {
2821
+ throw CameraControllerError.invalidOperation
2822
+ }
2823
+ }
2824
+
2672
2825
  private func activeVideoDevice() -> AVCaptureDevice? {
2673
2826
  switch currentCameraPosition {
2674
2827
  case .front:
@@ -2914,38 +3067,16 @@ extension CameraController: UIGestureRecognizerDelegate {
2914
3067
 
2915
3068
  @objc
2916
3069
  func handleTap(_ tap: UITapGestureRecognizer) {
2917
- guard let device = self.currentCameraPosition == .rear ? rearCamera : frontCamera else { return }
2918
-
2919
- let point = tap.location(in: tap.view)
2920
- let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: point)
2921
-
2922
- // Show focus indicator at the tap point if not disabled
2923
- if !self.disableFocusIndicator, let view = tap.view {
2924
- showFocusIndicator(at: point, in: view)
3070
+ guard let devicePoint = self.previewLayer?.captureDevicePointConverted(fromLayerPoint: tap.location(in: tap.view)) else {
3071
+ return
2925
3072
  }
2926
3073
 
2927
3074
  do {
2928
- try device.lockForConfiguration()
2929
- defer { device.unlockForConfiguration() }
2930
-
2931
- let focusMode = AVCaptureDevice.FocusMode.autoFocus
2932
- if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) {
2933
- device.focusPointOfInterest = CGPoint(x: CGFloat(devicePoint?.x ?? 0), y: CGFloat(devicePoint?.y ?? 0))
2934
- device.focusMode = focusMode
2935
- }
2936
- // Skip exposure point if locked
2937
- if device.exposureMode != .locked {
2938
- let exposureMode = AVCaptureDevice.ExposureMode.autoExpose
2939
- if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) {
2940
- device.exposurePointOfInterest = CGPoint(x: CGFloat(devicePoint?.x ?? 0), y: CGFloat(devicePoint?.y ?? 0))
2941
- device.exposureMode = exposureMode
2942
- device.setExposureTargetBias(0.0) { _ in }
2943
- }
2944
- }
2945
-
2946
- // Turn on subject area monitor for switch to continuous focus if needed
2947
- device.isSubjectAreaChangeMonitoringEnabled = true
2948
-
3075
+ try self.setFocus(
3076
+ at: devicePoint,
3077
+ showIndicator: !self.disableFocusIndicator,
3078
+ in: tap.view
3079
+ )
2949
3080
  } catch {
2950
3081
  debugPrint(error)
2951
3082
  }
@@ -35,7 +35,7 @@ extension UIWindow {
35
35
  */
36
36
  @objc(CameraPreview)
37
37
  public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelegate {
38
- private let pluginVersion: String = "8.8.0"
38
+ private let pluginVersion: String = "8.9.0"
39
39
  public let identifier = "CameraPreviewPlugin"
40
40
  public let jsName = "CameraPreview"
41
41
  public let pluginMethods: [CAPPluginMethod] = [
@@ -90,6 +90,9 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
90
90
  CAPPluginMethod(name: "getExposureCompensationRange", returnType: CAPPluginReturnPromise),
91
91
  CAPPluginMethod(name: "getExposureCompensation", returnType: CAPPluginReturnPromise),
92
92
  CAPPluginMethod(name: "setExposureCompensation", returnType: CAPPluginReturnPromise),
93
+ CAPPluginMethod(name: "getWhiteBalanceModes", returnType: CAPPluginReturnPromise),
94
+ CAPPluginMethod(name: "getWhiteBalanceMode", returnType: CAPPluginReturnPromise),
95
+ CAPPluginMethod(name: "setWhiteBalanceMode", returnType: CAPPluginReturnPromise),
93
96
  CAPPluginMethod(name: "getSupportedVideoFrameRates", returnType: CAPPluginReturnPromise),
94
97
  CAPPluginMethod(name: "getVideoFrameRate", returnType: CAPPluginReturnPromise),
95
98
  CAPPluginMethod(name: "setVideoFrameRate", returnType: CAPPluginReturnPromise),
@@ -2392,6 +2395,58 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
2392
2395
  }
2393
2396
  }
2394
2397
 
2398
+ // MARK: - White Balance Bridge
2399
+
2400
+ @objc func getWhiteBalanceModes(_ call: CAPPluginCall) {
2401
+ guard isInitialized else {
2402
+ call.reject("Camera not initialized")
2403
+ return
2404
+ }
2405
+ do {
2406
+ let modes = try self.cameraController.getWhiteBalanceModes()
2407
+ call.resolve(["modes": modes])
2408
+ } catch {
2409
+ call.reject("Failed to get white balance modes: \(error.localizedDescription)")
2410
+ }
2411
+ }
2412
+
2413
+ @objc func getWhiteBalanceMode(_ call: CAPPluginCall) {
2414
+ guard isInitialized else {
2415
+ call.reject("Camera not initialized")
2416
+ return
2417
+ }
2418
+ do {
2419
+ let mode = try self.cameraController.getWhiteBalanceMode()
2420
+ call.resolve(["mode": mode])
2421
+ } catch {
2422
+ call.reject("Failed to get white balance mode: \(error.localizedDescription)")
2423
+ }
2424
+ }
2425
+
2426
+ @objc func setWhiteBalanceMode(_ call: CAPPluginCall) {
2427
+ guard isInitialized else {
2428
+ call.reject("Camera not initialized")
2429
+ return
2430
+ }
2431
+ guard let mode = call.getString("mode") else {
2432
+ call.reject("mode parameter is required")
2433
+ return
2434
+ }
2435
+ let normalized = mode.uppercased()
2436
+ let allowedModes: Set<String> = ["AUTO", "LOCK", "CONTINUOUS"]
2437
+ guard allowedModes.contains(normalized) else {
2438
+ let allowedList = Array(allowedModes).sorted().joined(separator: ", ")
2439
+ call.reject("Invalid white balance mode: \(mode). Allowed values: \(allowedList)")
2440
+ return
2441
+ }
2442
+ do {
2443
+ try self.cameraController.setWhiteBalanceMode(mode: normalized)
2444
+ call.resolve()
2445
+ } catch {
2446
+ call.reject("Failed to set white balance mode: \(error.localizedDescription)")
2447
+ }
2448
+ }
2449
+
2395
2450
  // MARK: - Exposure Bridge
2396
2451
 
2397
2452
  @objc func getExposureModes(_ call: CAPPluginCall) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/camera-preview",
3
- "version": "8.8.0",
3
+ "version": "8.9.0",
4
4
  "description": "Camera preview",
5
5
  "license": "MPL-2.0",
6
6
  "repository": {