Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/simple-camera/__tests__/visioncamera.devices.harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 10 additions & 7 deletions docs/content/docs/zooming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The [`<Camera />`](/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 (
<Camera
Expand Down Expand Up @@ -48,7 +48,7 @@ To zoom on a [`CameraController`](/api/react-native-vision-camera/hybrid-objects
const device = ...
const controller = ...
// [!code ++]
await controller.setZoom(device.minZoom)
await controller.setZoom(device.neutralZoom)
```
</Tab>
</Tabs>
Expand Down Expand Up @@ -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) &lt;= `1` (e.g. 0.5x)
- natural zoom = `1`
- [`maxZoom`](/api/react-native-vision-camera/hybrid-objects/CameraDevice#maxzoom) &gt;= `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) &lt;= [`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) &gt;= [`neutralZoom`](/api/react-native-vision-camera/hybrid-objects/CameraDevice#neutralzoom)

### Getting current zoom values

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value changes as the device starts zooming. So if you zoom in, and then ask device.neutralZoomFactor, it is no longer 1 (or 2) but might be any arbitrary number.
I think.

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
Comment on lines +24 to +38

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also this Plan B and Plan C approach are kinda ugly. I'm not sure if there is a better way, but as it is, its a bit ugly.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down