@capgo/camera-preview 8.5.1 → 8.7.1

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.
@@ -45,6 +45,7 @@ import androidx.annotation.NonNull;
45
45
  import androidx.annotation.OptIn;
46
46
  import androidx.camera.camera2.interop.Camera2CameraControl;
47
47
  import androidx.camera.camera2.interop.Camera2CameraInfo;
48
+ import androidx.camera.camera2.interop.Camera2Interop;
48
49
  import androidx.camera.camera2.interop.CaptureRequestOptions;
49
50
  import androidx.camera.camera2.interop.ExperimentalCamera2Interop;
50
51
  import androidx.camera.core.AspectRatio;
@@ -110,6 +111,7 @@ import java.util.Locale;
110
111
  import java.util.Map;
111
112
  import java.util.Objects;
112
113
  import java.util.Set;
114
+ import java.util.TreeSet;
113
115
  import java.util.concurrent.ConcurrentHashMap;
114
116
  import java.util.concurrent.Executor;
115
117
  import java.util.concurrent.ExecutorService;
@@ -184,6 +186,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
184
186
  private boolean isRunning = false;
185
187
  private Size currentPreviewResolution = null;
186
188
  private ListenableFuture<FocusMeteringResult> currentFocusFuture = null; // Track current focus operation
189
+ private Integer configuredVideoFrameRate = null;
190
+ private Range<Integer> configuredVideoFrameRateRange = null;
191
+ private Runnable pendingFrameRateBindSuccess;
192
+ private java.util.function.Consumer<String> pendingFrameRateBindError;
187
193
  private String currentExposureMode = "CONTINUOUS"; // Default behavior
188
194
  // Capture/stop coordination
189
195
  private final Object captureLock = new Object();
@@ -1071,7 +1077,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1071
1077
  ? previewView.getDisplay().getRotation()
1072
1078
  : android.view.Surface.ROTATION_0;
1073
1079
 
1074
- Preview preview = new Preview.Builder().setResolutionSelector(resolutionSelector).setTargetRotation(rotation).build();
1080
+ Preview.Builder previewBuilder = new Preview.Builder()
1081
+ .setResolutionSelector(resolutionSelector)
1082
+ .setTargetRotation(rotation);
1083
+ previewBuilder = applyTargetFpsToPreviewBuilder(previewBuilder);
1084
+ Preview preview = previewBuilder.build();
1075
1085
  // Keep reference to preview use case for later re-binding (e.g., when enabling video)
1076
1086
  imageCapture = new ImageCapture.Builder()
1077
1087
  .setResolutionSelector(resolutionSelector)
@@ -1139,7 +1149,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1139
1149
  recorderBuilder.setVideoCapabilitiesSource(Recorder.VIDEO_CAPABILITIES_SOURCE_CODEC_CAPABILITIES);
1140
1150
  }
1141
1151
  Recorder recorder = recorderBuilder.build();
1142
- videoCapture = VideoCapture.withOutput(recorder);
1152
+ VideoCapture.Builder<Recorder> videoCaptureBuilder = new VideoCapture.Builder<>(recorder);
1153
+ videoCaptureBuilder = applyTargetFpsToVideoCaptureBuilder(videoCaptureBuilder);
1154
+ videoCaptureBuilder.setVideoStabilizationEnabled(isVideoStabilizationEnabledForSession());
1155
+ videoCapture = videoCaptureBuilder.build();
1143
1156
  }
1144
1157
 
1145
1158
  // Unbind any existing use cases and bind new ones
@@ -1287,6 +1300,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1287
1300
  }
1288
1301
 
1289
1302
  isRunning = true;
1303
+ completePendingFrameRateBindSuccess();
1290
1304
  Log.d(TAG, "bindCameraUseCases: Camera bound successfully");
1291
1305
  if (listener != null) {
1292
1306
  if (sessionConfig != null && sessionConfig.isToBack()) {
@@ -1324,6 +1338,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1324
1338
  } catch (Exception e) {
1325
1339
  // Restore webView background on error
1326
1340
  restoreWebViewBackground();
1341
+ completePendingFrameRateBindError("Error binding camera: " + e.getMessage());
1327
1342
  if (listener != null) listener.onCameraStartError(this, "Error binding camera: " + e.getMessage());
1328
1343
  }
1329
1344
  });
@@ -3355,6 +3370,185 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3355
3370
  }
3356
3371
  }
3357
3372
 
3373
+ private void clearConfiguredVideoFrameRate() {
3374
+ configuredVideoFrameRate = null;
3375
+ configuredVideoFrameRateRange = null;
3376
+ }
3377
+
3378
+ private Range<Integer>[] getAdvertisedFpsRanges() throws Exception {
3379
+ CameraManager manager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
3380
+ String cameraId = resolveActiveCameraIdForCharacteristics();
3381
+ CameraCharacteristics characteristics = manager.getCameraCharacteristics(cameraId);
3382
+ return characteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES);
3383
+ }
3384
+
3385
+ private Range<Integer> findAdvertisedFpsRangeForRate(int frameRate, Range<Integer>[] ranges) {
3386
+ if (ranges == null) {
3387
+ return null;
3388
+ }
3389
+
3390
+ Range<Integer> containingRange = null;
3391
+ for (Range<Integer> range : ranges) {
3392
+ if (range.getLower() > frameRate || range.getUpper() < frameRate) {
3393
+ continue;
3394
+ }
3395
+ if (range.getLower().equals(range.getUpper()) && range.getLower() == frameRate) {
3396
+ return range;
3397
+ }
3398
+ containingRange = range;
3399
+ }
3400
+ return containingRange;
3401
+ }
3402
+
3403
+ private Range<Integer> resolveConfiguredVideoFrameRateRange() {
3404
+ if (configuredVideoFrameRate == null) {
3405
+ return null;
3406
+ }
3407
+ try {
3408
+ Range<Integer>[] ranges = getAdvertisedFpsRanges();
3409
+ return findAdvertisedFpsRangeForRate(configuredVideoFrameRate, ranges);
3410
+ } catch (Exception e) {
3411
+ Log.w(TAG, "resolveConfiguredVideoFrameRateRange: Failed to resolve FPS range", e);
3412
+ return null;
3413
+ }
3414
+ }
3415
+
3416
+ private void completePendingFrameRateBindSuccess() {
3417
+ if (pendingFrameRateBindSuccess == null) {
3418
+ return;
3419
+ }
3420
+ Runnable callback = pendingFrameRateBindSuccess;
3421
+ pendingFrameRateBindSuccess = null;
3422
+ pendingFrameRateBindError = null;
3423
+ callback.run();
3424
+ }
3425
+
3426
+ private void completePendingFrameRateBindError(String message) {
3427
+ if (pendingFrameRateBindError == null) {
3428
+ return;
3429
+ }
3430
+ java.util.function.Consumer<String> callback = pendingFrameRateBindError;
3431
+ pendingFrameRateBindSuccess = null;
3432
+ pendingFrameRateBindError = null;
3433
+ callback.accept(message);
3434
+ }
3435
+
3436
+ @OptIn(markerClass = ExperimentalCamera2Interop.class)
3437
+ private Preview.Builder applyTargetFpsToPreviewBuilder(Preview.Builder builder) {
3438
+ Range<Integer> fpsRange = resolveConfiguredVideoFrameRateRange();
3439
+ if (fpsRange == null) {
3440
+ return builder;
3441
+ }
3442
+
3443
+ Camera2Interop.Extender<Preview> extender = new Camera2Interop.Extender<>(builder);
3444
+ extender.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, fpsRange);
3445
+ return builder;
3446
+ }
3447
+
3448
+ @OptIn(markerClass = ExperimentalCamera2Interop.class)
3449
+ private VideoCapture.Builder<Recorder> applyTargetFpsToVideoCaptureBuilder(VideoCapture.Builder<Recorder> builder) {
3450
+ Range<Integer> fpsRange = resolveConfiguredVideoFrameRateRange();
3451
+ if (fpsRange == null) {
3452
+ return builder;
3453
+ }
3454
+
3455
+ Camera2Interop.Extender<VideoCapture<Recorder>> extender = new Camera2Interop.Extender<>(builder);
3456
+ extender.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, fpsRange);
3457
+ return builder;
3458
+ }
3459
+
3460
+ private String resolveActiveCameraIdForCharacteristics() throws Exception {
3461
+ if (currentLogicalDeviceId != null) {
3462
+ return currentLogicalDeviceId;
3463
+ }
3464
+ if (camera != null) {
3465
+ return Camera2CameraInfo.from(camera.getCameraInfo()).getCameraId();
3466
+ }
3467
+ throw new Exception("Camera not initialized");
3468
+ }
3469
+
3470
+ private List<Integer> frameRatesFromFpsRanges(Range<Integer>[] ranges) {
3471
+ TreeSet<Integer> rates = new TreeSet<>();
3472
+ int[] standardRates = new int[] { 24, 25, 30, 50, 60, 120 };
3473
+
3474
+ if (ranges == null) {
3475
+ return new ArrayList<>();
3476
+ }
3477
+
3478
+ for (Range<Integer> range : ranges) {
3479
+ int min = range.getLower();
3480
+ int max = range.getUpper();
3481
+ if (min == max) {
3482
+ rates.add(min);
3483
+ continue;
3484
+ }
3485
+
3486
+ rates.add(min);
3487
+ rates.add(max);
3488
+ for (int standardRate : standardRates) {
3489
+ if (standardRate >= min && standardRate <= max) {
3490
+ rates.add(standardRate);
3491
+ }
3492
+ }
3493
+ }
3494
+
3495
+ return new ArrayList<>(rates);
3496
+ }
3497
+
3498
+ public List<Integer> getSupportedVideoFrameRates() throws Exception {
3499
+ return frameRatesFromFpsRanges(getAdvertisedFpsRanges());
3500
+ }
3501
+
3502
+ public int getVideoFrameRate() throws Exception {
3503
+ if (configuredVideoFrameRate != null) {
3504
+ return configuredVideoFrameRate;
3505
+ }
3506
+ return 30;
3507
+ }
3508
+
3509
+ public void setVideoFrameRate(int frameRate, Runnable onSuccess, java.util.function.Consumer<String> onError) {
3510
+ mainExecutor.execute(() -> {
3511
+ Integer previousFrameRate = configuredVideoFrameRate;
3512
+ Range<Integer> previousRange = configuredVideoFrameRateRange;
3513
+ try {
3514
+ if (currentRecording != null) {
3515
+ throw new Exception("Cannot change video frame rate while recording");
3516
+ }
3517
+ if (frameRate <= 0) {
3518
+ throw new Exception("frameRate must be greater than 0");
3519
+ }
3520
+
3521
+ List<Integer> supportedRates = getSupportedVideoFrameRates();
3522
+ if (!supportedRates.contains(frameRate)) {
3523
+ throw new Exception("Unsupported frame rate " + frameRate + ". Supported values: " + supportedRates);
3524
+ }
3525
+
3526
+ Range<Integer> advertisedRange = findAdvertisedFpsRangeForRate(frameRate, getAdvertisedFpsRanges());
3527
+ if (advertisedRange == null) {
3528
+ throw new Exception("Unsupported frame rate " + frameRate + " for the active camera");
3529
+ }
3530
+
3531
+ configuredVideoFrameRate = frameRate;
3532
+ configuredVideoFrameRateRange = advertisedRange;
3533
+ if (isRunning && cameraProvider != null) {
3534
+ pendingFrameRateBindSuccess = onSuccess;
3535
+ pendingFrameRateBindError = (message) -> {
3536
+ configuredVideoFrameRate = previousFrameRate;
3537
+ configuredVideoFrameRateRange = previousRange;
3538
+ onError.accept(message);
3539
+ };
3540
+ bindCameraUseCases();
3541
+ return;
3542
+ }
3543
+ onSuccess.run();
3544
+ } catch (Exception e) {
3545
+ configuredVideoFrameRate = previousFrameRate;
3546
+ configuredVideoFrameRateRange = previousRange;
3547
+ onError.accept(e.getMessage());
3548
+ }
3549
+ });
3550
+ }
3551
+
3358
3552
  private long showFocusIndicator(float x, float y) {
3359
3553
  // If preview is gone (e.g., stopping/closing), bail out safely
3360
3554
  if (previewContainer == null) {
@@ -3646,6 +3840,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3646
3840
 
3647
3841
  mainExecutor.execute(() -> {
3648
3842
  try {
3843
+ String previousDeviceId = currentDeviceId;
3649
3844
  CameraSessionConfiguration previousConfig = sessionConfig;
3650
3845
  CameraInfo targetCameraInfo = findAvailableCameraInfoById(deviceId);
3651
3846
  String fallbackPosition = resolveFallbackPositionForDeviceId(deviceId);
@@ -3688,6 +3883,9 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3688
3883
  );
3689
3884
  copyMutableSessionConfigState(previousConfig, updatedConfig);
3690
3885
  updatedConfig.setTargetZoom(1.0f);
3886
+ if (!Objects.equals(deviceId, previousDeviceId)) {
3887
+ clearConfiguredVideoFrameRate();
3888
+ }
3691
3889
  sessionConfig = updatedConfig;
3692
3890
 
3693
3891
  Log.d(TAG, "switchToDevice: Updated sessionConfig with deviceId: " + deviceId);
@@ -3701,6 +3899,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3701
3899
 
3702
3900
  public void flipCamera() {
3703
3901
  Log.d(TAG, "flipCamera: Flipping camera");
3902
+ clearConfiguredVideoFrameRate();
3704
3903
 
3705
3904
  // Determine current position based on session config and flip it
3706
3905
  String currentPosition = sessionConfig.getPosition();
@@ -4663,6 +4862,30 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
4663
4862
  }
4664
4863
  }
4665
4864
 
4865
+ public void startRecordVideo(
4866
+ Long maxDurationMillis,
4867
+ Long maxFileSize,
4868
+ Integer frameRate,
4869
+ Runnable onSuccess,
4870
+ java.util.function.Consumer<String> onError
4871
+ ) {
4872
+ Runnable startRecording = () -> {
4873
+ try {
4874
+ startRecordVideo(maxDurationMillis, maxFileSize);
4875
+ onSuccess.run();
4876
+ } catch (Exception e) {
4877
+ onError.accept(e.getMessage());
4878
+ }
4879
+ };
4880
+
4881
+ if (frameRate == null) {
4882
+ mainExecutor.execute(startRecording);
4883
+ return;
4884
+ }
4885
+
4886
+ setVideoFrameRate(frameRate, startRecording, onError);
4887
+ }
4888
+
4666
4889
  /** @noinspection ResultOfMethodCallIgnored*/
4667
4890
  public void startRecordVideo(Long maxDurationMillis, Long maxFileSize) throws Exception {
4668
4891
  if (videoCapture == null) {
@@ -4785,6 +5008,15 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
4785
5008
  private static final List<String> ALL_VIDEO_QUALITIES = Arrays.asList("low", "medium", "high", "2160p", "1080p", "720p", "480p", "4:3");
4786
5009
 
4787
5010
  private static final List<String> ALL_VIDEO_CODECS = Arrays.asList("avc1", "hvc1");
5011
+ private static final List<String> ALL_VIDEO_STABILIZATION_MODES = Arrays.asList("off", "standard");
5012
+ private static final List<String> IOS_ONLY_VIDEO_STABILIZATION_MODES = Arrays.asList(
5013
+ "cinematic",
5014
+ "cinematicExtended",
5015
+ "previewOptimized",
5016
+ "cinematicExtendedEnhanced",
5017
+ "auto",
5018
+ "lowLatency"
5019
+ );
4788
5020
 
4789
5021
  public String getVideoQualitySetting() {
4790
5022
  if (sessionConfig == null) {
@@ -4864,4 +5096,86 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
4864
5096
  }
4865
5097
  return false;
4866
5098
  }
5099
+
5100
+ public boolean isVideoStabilizationSupported() {
5101
+ return isStandardVideoStabilizationSupported();
5102
+ }
5103
+
5104
+ public List<String> getSupportedVideoStabilizationModes() {
5105
+ List<String> modes = new ArrayList<>();
5106
+ modes.add("off");
5107
+ if (isStandardVideoStabilizationSupported()) {
5108
+ modes.add("standard");
5109
+ }
5110
+ return modes;
5111
+ }
5112
+
5113
+ public String getVideoStabilizationModeSetting() {
5114
+ if (sessionConfig == null) {
5115
+ return "off";
5116
+ }
5117
+ return sessionConfig.getVideoStabilizationMode();
5118
+ }
5119
+
5120
+ public void setVideoStabilizationModeSetting(String mode) {
5121
+ if (sessionConfig == null) {
5122
+ throw new IllegalStateException("Camera session is not running");
5123
+ }
5124
+ if (currentRecording != null) {
5125
+ throw new IllegalStateException("Cannot change video stabilization while recording is in progress");
5126
+ }
5127
+ String normalized = mode != null ? mode : "off";
5128
+ if (IOS_ONLY_VIDEO_STABILIZATION_MODES.contains(normalized)) {
5129
+ throw new IllegalArgumentException(
5130
+ "Video stabilization mode '" + normalized + "' is only supported on iOS. Use 'off' or 'standard' on Android."
5131
+ );
5132
+ }
5133
+ if (!ALL_VIDEO_STABILIZATION_MODES.contains(normalized)) {
5134
+ throw new IllegalArgumentException("Unsupported video stabilization mode: " + mode);
5135
+ }
5136
+ if ("standard".equals(normalized) && !isStandardVideoStabilizationSupported()) {
5137
+ throw new IllegalArgumentException("Video stabilization mode 'standard' is not supported on this device");
5138
+ }
5139
+ if (normalized.equals(sessionConfig.getVideoStabilizationMode())) {
5140
+ return;
5141
+ }
5142
+ sessionConfig.setVideoStabilizationMode(normalized);
5143
+ if (sessionConfig.isVideoModeEnabled() && isRunning) {
5144
+ mainExecutor.execute(this::bindCameraUseCases);
5145
+ }
5146
+ }
5147
+
5148
+ private boolean isVideoStabilizationEnabledForSession() {
5149
+ if (sessionConfig == null) {
5150
+ return false;
5151
+ }
5152
+ return "standard".equalsIgnoreCase(sessionConfig.getVideoStabilizationMode());
5153
+ }
5154
+
5155
+ private boolean isStandardVideoStabilizationSupported() {
5156
+ if (camera == null) {
5157
+ return false;
5158
+ }
5159
+ try {
5160
+ String cameraId = Camera2CameraInfo.from(camera.getCameraInfo()).getCameraId();
5161
+ CameraManager cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
5162
+ if (cameraManager == null) {
5163
+ return false;
5164
+ }
5165
+ int[] modes = cameraManager
5166
+ .getCameraCharacteristics(cameraId)
5167
+ .get(CameraCharacteristics.CONTROL_AVAILABLE_VIDEO_STABILIZATION_MODES);
5168
+ if (modes == null) {
5169
+ return false;
5170
+ }
5171
+ for (int availableMode : modes) {
5172
+ if (availableMode == CaptureRequest.CONTROL_VIDEO_STABILIZATION_MODE_ON) {
5173
+ return true;
5174
+ }
5175
+ }
5176
+ } catch (CameraAccessException e) {
5177
+ Log.w(TAG, "Failed to read video stabilization modes", e);
5178
+ }
5179
+ return false;
5180
+ }
4867
5181
  }
@@ -27,6 +27,7 @@ public class CameraSessionConfiguration {
27
27
  private boolean isCentered = false;
28
28
  private String videoQuality;
29
29
  private String videoCodec = "avc1";
30
+ private String videoStabilizationMode = "off";
30
31
  private boolean enablePhysicalDeviceSelection = false;
31
32
  private boolean barcodeScannerEnabled = false;
32
33
 
@@ -160,6 +161,14 @@ public class CameraSessionConfiguration {
160
161
  this.videoCodec = codec != null ? codec : "avc1";
161
162
  }
162
163
 
164
+ public String getVideoStabilizationMode() {
165
+ return videoStabilizationMode;
166
+ }
167
+
168
+ public void setVideoStabilizationMode(String mode) {
169
+ this.videoStabilizationMode = mode != null ? mode : "off";
170
+ }
171
+
163
172
  public boolean isPhysicalDeviceSelectionEnabled() {
164
173
  return enablePhysicalDeviceSelection;
165
174
  }