From 2d578f69c2bdb76984dd2c973bf33d6986033489 Mon Sep 17 00:00:00 2001 From: Ngoc Le Date: Sat, 22 Aug 2026 21:10:43 +0700 Subject: [PATCH] fix: restore CameraDevice neutralZoom --- .../__tests__/visioncamera.devices.harness.ts | 2 + docs/content/docs/zooming.mdx | 17 ++++---- .../hybrids/inputs/HybridCameraDevice.kt | 1 + .../inputs/HybridPhysicalCameraDevice.kt | 1 + .../AVCaptureDevice+neutralZoom.swift | 40 +++++++++++++++++++ .../Inputs/HybridCameraDevice.swift | 4 ++ .../android/c++/JHybridCameraDeviceSpec.cpp | 5 +++ .../android/c++/JHybridCameraDeviceSpec.hpp | 1 + .../nitro/camera/HybridCameraDeviceSpec.kt | 4 ++ .../ios/c++/HybridCameraDeviceSpecSwift.hpp | 3 ++ .../ios/swift/HybridCameraDeviceSpec.swift | 1 + .../swift/HybridCameraDeviceSpec_cxx.swift | 7 ++++ .../shared/c++/HybridCameraDeviceSpec.cpp | 1 + .../shared/c++/HybridCameraDeviceSpec.hpp | 1 + .../src/specs/inputs/CameraDevice.nitro.ts | 13 ++++++ 15 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 packages/react-native-vision-camera/ios/Extensions/AVFoundation/AVCaptureDevice+neutralZoom.swift diff --git a/apps/simple-camera/__tests__/visioncamera.devices.harness.ts b/apps/simple-camera/__tests__/visioncamera.devices.harness.ts index df49bb6378..6c186ec479 100644 --- a/apps/simple-camera/__tests__/visioncamera.devices.harness.ts +++ b/apps/simple-camera/__tests__/visioncamera.devices.harness.ts @@ -94,6 +94,8 @@ describe('VisionCamera - Devices', () => { it('reports sane capability invariants for each device', () => { for (const device of factory.cameraDevices) { expect(device.minZoom).toBeLessThanOrEqual(device.maxZoom) + expect(device.neutralZoom).toBeGreaterThanOrEqual(device.minZoom) + expect(device.neutralZoom).toBeLessThanOrEqual(device.maxZoom) if (device.supportsExposureBias) { expect(device.minExposureBias).toBeLessThanOrEqual( diff --git a/docs/content/docs/zooming.mdx b/docs/content/docs/zooming.mdx index 686aa49c99..ae3132676a 100644 --- a/docs/content/docs/zooming.mdx +++ b/docs/content/docs/zooming.mdx @@ -13,7 +13,7 @@ The [``](/api/react-native-vision-camera/views/Camera) view allows adj ```tsx function App() { // [!code ++] - const zoom = useSharedValue(device.minZoom) + const zoom = useSharedValue(device.neutralZoom) return ( @@ -97,12 +97,15 @@ for (const switchOverFactor of device.zoomLensSwitchFactors) { #### Default Zoom -To provide a natural user experience, always start at zoom `1`. -If a virtual Camera contains an [`'ultra-wide-angle'`](/api/react-native-vision-camera/type-aliases/DeviceType) Camera (the "0.5x Camera"), the user may zoom out to less than `1` (here `0.5`) instead. +To provide a natural user experience, start at the Camera's [`neutralZoom`](/api/react-native-vision-camera/hybrid-objects/CameraDevice#neutralzoom). +This is the internal zoom factor that appears as `1x` to the user and selects the standard wide-angle lens. -- [`minZoom`](/api/react-native-vision-camera/hybrid-objects/CameraDevice#minzoom) <= `1` (e.g. 0.5x) -- natural zoom = `1` -- [`maxZoom`](/api/react-native-vision-camera/hybrid-objects/CameraDevice#maxzoom) >= `1` (e.g. 3x) +On most Cameras `neutralZoom` is `1`, but iOS virtual Cameras that contain an [`'ultra-wide-angle'`](/api/react-native-vision-camera/type-aliases/DeviceType) lens can use a larger internal factor, such as `2`. +In that case the user can still zoom out below `neutralZoom` to reach the ultra-wide-angle lens. + +- [`minZoom`](/api/react-native-vision-camera/hybrid-objects/CameraDevice#minzoom) <= [`neutralZoom`](/api/react-native-vision-camera/hybrid-objects/CameraDevice#neutralzoom) +- natural zoom = [`neutralZoom`](/api/react-native-vision-camera/hybrid-objects/CameraDevice#neutralzoom) (displayed as `1x`) +- [`maxZoom`](/api/react-native-vision-camera/hybrid-objects/CameraDevice#maxzoom) >= [`neutralZoom`](/api/react-native-vision-camera/hybrid-objects/CameraDevice#neutralzoom) ### Getting current zoom values diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/inputs/HybridCameraDevice.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/inputs/HybridCameraDevice.kt index a00188fb15..7d08a62c8a 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/inputs/HybridCameraDevice.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/inputs/HybridCameraDevice.kt @@ -194,6 +194,7 @@ class HybridCameraDevice( val zoomState = cameraInfo.zoomState.value ?: return 0.0 return zoomState.maxZoomRatio.toDouble() } + override val neutralZoom: Double = 1.0 override val zoomLensSwitchFactors: DoubleArray get() = cameraInfo.zoomLensSwitchFactors diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/inputs/HybridPhysicalCameraDevice.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/inputs/HybridPhysicalCameraDevice.kt index ef1d9b73f0..8e24861a63 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/inputs/HybridPhysicalCameraDevice.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/inputs/HybridPhysicalCameraDevice.kt @@ -92,6 +92,7 @@ class HybridPhysicalCameraDevice( override val supportsLowLightBoost: Boolean = false override val minZoom: Double = 0.0 override val maxZoom: Double = 0.0 + override val neutralZoom: Double = 1.0 override val zoomLensSwitchFactors: DoubleArray = doubleArrayOf() override val supportsDistortionCorrection: Boolean = false diff --git a/packages/react-native-vision-camera/ios/Extensions/AVFoundation/AVCaptureDevice+neutralZoom.swift b/packages/react-native-vision-camera/ios/Extensions/AVFoundation/AVCaptureDevice+neutralZoom.swift new file mode 100644 index 0000000000..cde83d8133 --- /dev/null +++ b/packages/react-native-vision-camera/ios/Extensions/AVFoundation/AVCaptureDevice+neutralZoom.swift @@ -0,0 +1,40 @@ +/// +/// AVCaptureDevice+neutralZoom.swift +/// VisionCamera +/// Copyright © 2026 Marc Rousavy @ Margelo +/// + +import AVFoundation +import Foundation + +extension AVCaptureDevice { + /** + * The internal video zoom factor that AVFoundation displays as `1x`. + * + * On older iOS versions, derive it from the switchover factor immediately + * before the standard wide-angle constituent Camera. + */ + var neutralZoomFactor: Double { + if #available(iOS 18.0, *) { + let multiplier = displayVideoZoomFactorMultiplier + if multiplier > 0 { + return 1 / multiplier + } + } + + guard + let wideAngleIndex = constituentDevices.firstIndex(where: { + $0.deviceType == .builtInWideAngleCamera + }), + wideAngleIndex > 0 + else { + return 1 + } + + let switchoverIndex = wideAngleIndex - 1 + guard virtualDeviceSwitchOverVideoZoomFactors.indices.contains(switchoverIndex) else { + return 1 + } + return virtualDeviceSwitchOverVideoZoomFactors[switchoverIndex].doubleValue + } +} diff --git a/packages/react-native-vision-camera/ios/Hybrid Objects/Inputs/HybridCameraDevice.swift b/packages/react-native-vision-camera/ios/Hybrid Objects/Inputs/HybridCameraDevice.swift index bf7bafae85..df9d4a8d43 100644 --- a/packages/react-native-vision-camera/ios/Hybrid Objects/Inputs/HybridCameraDevice.swift +++ b/packages/react-native-vision-camera/ios/Hybrid Objects/Inputs/HybridCameraDevice.swift @@ -221,6 +221,10 @@ final class HybridCameraDevice: HybridCameraDeviceSpec, NativeCameraDevice { return device.maxAvailableVideoZoomFactor } + var neutralZoom: Double { + return device.neutralZoomFactor + } + var zoomLensSwitchFactors: [Double] { return device.virtualDeviceSwitchOverVideoZoomFactors.map { $0.doubleValue } } diff --git a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridCameraDeviceSpec.cpp b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridCameraDeviceSpec.cpp index e6f0189fbc..6b8c17ba76 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridCameraDeviceSpec.cpp +++ b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridCameraDeviceSpec.cpp @@ -338,6 +338,11 @@ namespace margelo::nitro::camera { auto __result = method(_javaPart); return __result; } + double JHybridCameraDeviceSpec::getNeutralZoom() { + static const auto method = _javaPart->javaClassStatic()->getMethod("getNeutralZoom"); + auto __result = method(_javaPart); + return __result; + } std::vector JHybridCameraDeviceSpec::getZoomLensSwitchFactors() { static const auto method = _javaPart->javaClassStatic()->getMethod()>("getZoomLensSwitchFactors"); auto __result = method(_javaPart); diff --git a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridCameraDeviceSpec.hpp b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridCameraDeviceSpec.hpp index d80aeffe1b..11f43eb718 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridCameraDeviceSpec.hpp +++ b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridCameraDeviceSpec.hpp @@ -88,6 +88,7 @@ namespace margelo::nitro::camera { bool getSupportsLowLightBoost() override; double getMinZoom() override; double getMaxZoom() override; + double getNeutralZoom() override; std::vector getZoomLensSwitchFactors() override; bool getSupportsDistortionCorrection() override; diff --git a/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/HybridCameraDeviceSpec.kt b/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/HybridCameraDeviceSpec.kt index 0cf2ca167f..706be44e50 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/HybridCameraDeviceSpec.kt +++ b/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/HybridCameraDeviceSpec.kt @@ -178,6 +178,10 @@ abstract class HybridCameraDeviceSpec: HybridObject() { @get:Keep abstract val maxZoom: Double + @get:DoNotStrip + @get:Keep + abstract val neutralZoom: Double + @get:DoNotStrip @get:Keep abstract val zoomLensSwitchFactors: DoubleArray diff --git a/packages/react-native-vision-camera/nitrogen/generated/ios/c++/HybridCameraDeviceSpecSwift.hpp b/packages/react-native-vision-camera/nitrogen/generated/ios/c++/HybridCameraDeviceSpecSwift.hpp index d8518138ab..a6c4dd2f69 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/ios/c++/HybridCameraDeviceSpecSwift.hpp +++ b/packages/react-native-vision-camera/nitrogen/generated/ios/c++/HybridCameraDeviceSpecSwift.hpp @@ -236,6 +236,9 @@ namespace margelo::nitro::camera { inline double getMaxZoom() noexcept override { return _swiftPart.getMaxZoom(); } + inline double getNeutralZoom() noexcept override { + return _swiftPart.getNeutralZoom(); + } inline std::vector getZoomLensSwitchFactors() noexcept override { auto __result = _swiftPart.getZoomLensSwitchFactors(); return __result; diff --git a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridCameraDeviceSpec.swift b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridCameraDeviceSpec.swift index cee3f745f9..ddcce82164 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridCameraDeviceSpec.swift +++ b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridCameraDeviceSpec.swift @@ -48,6 +48,7 @@ public protocol HybridCameraDeviceSpec_protocol: HybridObject { var supportsLowLightBoost: Bool { get } var minZoom: Double { get } var maxZoom: Double { get } + var neutralZoom: Double { get } var zoomLensSwitchFactors: [Double] { get } var supportsDistortionCorrection: Bool { get } diff --git a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridCameraDeviceSpec_cxx.swift b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridCameraDeviceSpec_cxx.swift index 2269e3df93..547324ca36 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridCameraDeviceSpec_cxx.swift +++ b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridCameraDeviceSpec_cxx.swift @@ -435,6 +435,13 @@ open class HybridCameraDeviceSpec_cxx { } } + public final var neutralZoom: Double { + @inline(__always) + get { + return self.__implementation.neutralZoom + } + } + public final var zoomLensSwitchFactors: bridge.std__vector_double_ { @inline(__always) get { diff --git a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridCameraDeviceSpec.cpp b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridCameraDeviceSpec.cpp index e0c9926771..5ac9bc711f 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridCameraDeviceSpec.cpp +++ b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridCameraDeviceSpec.cpp @@ -52,6 +52,7 @@ namespace margelo::nitro::camera { prototype.registerHybridGetter("supportsLowLightBoost", &HybridCameraDeviceSpec::getSupportsLowLightBoost); prototype.registerHybridGetter("minZoom", &HybridCameraDeviceSpec::getMinZoom); prototype.registerHybridGetter("maxZoom", &HybridCameraDeviceSpec::getMaxZoom); + prototype.registerHybridGetter("neutralZoom", &HybridCameraDeviceSpec::getNeutralZoom); prototype.registerHybridGetter("zoomLensSwitchFactors", &HybridCameraDeviceSpec::getZoomLensSwitchFactors); prototype.registerHybridGetter("supportsDistortionCorrection", &HybridCameraDeviceSpec::getSupportsDistortionCorrection); prototype.registerHybridMethod("getSupportedResolutions", &HybridCameraDeviceSpec::getSupportedResolutions); diff --git a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridCameraDeviceSpec.hpp b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridCameraDeviceSpec.hpp index df4ecd4e0b..3da17ffe8a 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridCameraDeviceSpec.hpp +++ b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridCameraDeviceSpec.hpp @@ -120,6 +120,7 @@ namespace margelo::nitro::camera { virtual bool getSupportsLowLightBoost() = 0; virtual double getMinZoom() = 0; virtual double getMaxZoom() = 0; + virtual double getNeutralZoom() = 0; virtual std::vector getZoomLensSwitchFactors() = 0; virtual bool getSupportsDistortionCorrection() = 0; diff --git a/packages/react-native-vision-camera/src/specs/inputs/CameraDevice.nitro.ts b/packages/react-native-vision-camera/src/specs/inputs/CameraDevice.nitro.ts index 6f984fc4e9..411f5e1130 100644 --- a/packages/react-native-vision-camera/src/specs/inputs/CameraDevice.nitro.ts +++ b/packages/react-native-vision-camera/src/specs/inputs/CameraDevice.nitro.ts @@ -578,6 +578,19 @@ export interface CameraDevice * for a true current maximum. */ readonly maxZoom: number + /** + * The zoom factor at which this Camera appears naturally zoomed (`1x`) + * to the user. + * + * For physical Cameras and Android devices this is typically `1.0`. + * On iOS virtual Cameras that include an ultra-wide-angle lens, this can + * be greater than `1.0` because AVFoundation's internal zoom scale starts + * at the widest constituent lens. + * + * Use this value as the initial zoom if the Camera should start on its + * standard wide-angle lens while still allowing the user to zoom out. + */ + readonly neutralZoom: number /** * If this {@linkcode CameraDevice} is a virtual device, * this returns a list of zoom factors at which the virtual