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
Binary file modified bun.lockb
Binary file not shown.
35 changes: 24 additions & 11 deletions docs/docs/guides/FOCUSING.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ This is an Example on how to use [react-native-gesture-handler](https://github.c
```tsx
import { Camera, useCameraDevice } from 'react-native-vision-camera'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { runOnJS } from 'react-native-reanimated';
import { runOnJS } from 'react-native-reanimated'

export function App() {
const camera = useRef<Camera>(null)
Expand All @@ -39,25 +39,38 @@ export function App() {
c.focus(point)
}, [])

const gesture = Gesture.Tap()
.onEnd(({ x, y }) => {
runOnJS(focus)({ x, y })
})
const gesture = Gesture.Tap().onEnd(({ x, y }) => {
runOnJS(focus)({ x, y })
})

if (device == null) return <NoCameraDeviceError />
return (
<GestureDetector gesture={gesture}>
<Camera
ref={camera}
style={StyleSheet.absoluteFill}
device={device}
isActive={true}
/>
<Camera ref={camera} style={StyleSheet.absoluteFill} device={device} isActive={true} />
</GestureDetector>
)
}
```

### Focus depth (focus on a fixed manual distance)

You can programmatically set the distance of the focus, or the depth.

```ts
await camera.current.focusDepth(0.5)
```

The value between Android and iOS is reversed:
| distance | Android | iOS |
|---------------|--------------------------------|-----|
| macro (close) | 10 | 0 |
| infinite | 0.2 | 1 |

In practice, you will probably use these values to prevent any issues on **Android**:

- macro: `device.minFocusDistance + 0.1` (0.1 prevents some weird focus changes)
- infinite: `Math.min(0.2, device.minFocusDistance)` (the lowest value for infinite focus, but setting it too low also produces focus artifacts)

<br />

#### 🚀 Next section: [Orientation](orientation)
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.mrousavy.camera.core

import android.annotation.SuppressLint
import android.hardware.camera2.CameraMetadata
import android.hardware.camera2.CaptureRequest
import androidx.camera.camera2.interop.Camera2CameraControl
import androidx.camera.camera2.interop.CaptureRequestOptions
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.camera.core.CameraControl

@ExperimentalCamera2Interop
@SuppressLint("RestrictedApi")
suspend fun CameraSession.focusDepth(depth: Double) {
val camera = camera ?: throw CameraNotReadyError()

try {
Camera2CameraControl.from(camera.cameraControl).let {
CaptureRequestOptions.Builder().apply {
val distance = depth.toFloat()
setCaptureRequestOption(CaptureRequest.LENS_FOCUS_DISTANCE, distance)
setCaptureRequestOption(CaptureRequest.CONTROL_AF_MODE, CameraMetadata.CONTROL_AF_MODE_OFF)
}.let { builder ->
it.addCaptureRequestOptions(builder.build())
}
}
} catch (e: CameraControl.OperationCanceledException) {
throw FocusCanceledError()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.mrousavy.camera.react

import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import com.mrousavy.camera.core.focusDepth

@ExperimentalCamera2Interop
suspend fun CameraView.focusDepth(distance: Double) {
cameraSession.focusDepth(distance)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.mrousavy.camera.react
import android.Manifest
import android.content.pm.PackageManager
import android.util.Log
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.Promise
Expand Down Expand Up @@ -188,6 +189,18 @@ class CameraViewModule(reactContext: ReactApplicationContext) : ReactContextBase
}
}

@ExperimentalCamera2Interop
@ReactMethod
fun focusDepth(viewTag: Int, distance: Double, promise: Promise) {
backgroundCoroutineScope.launch {
val view = findCameraView(viewTag)
withPromise(promise) {
view.focusDepth(distance)
return@withPromise null
}
}
}

private fun canRequestPermission(permission: String): Boolean {
val activity = reactApplicationContext.currentActivity as? PermissionAwareActivity
return activity?.shouldShowRequestPermissionRationale(permission) ?: false
Expand Down
2 changes: 1 addition & 1 deletion package/ios/Core/CameraConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ final class CameraConfiguration {
case disabled
case enabled(config: T)

public static func == (lhs: OutputConfiguration, rhs: OutputConfiguration) -> Bool {
static func == (lhs: OutputConfiguration, rhs: OutputConfiguration) -> Bool {
switch (lhs, rhs) {
case (.disabled, .disabled):
return true
Expand Down
5 changes: 5 additions & 0 deletions package/ios/Core/CameraError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ enum DeviceError {
case microphoneUnavailable
case lowLightBoostNotSupported
case focusNotSupported
case focusDepthNotSupported
case notAvailableOnSimulator
case pixelFormatNotSupported(targetFormats: [FourCharCode], availableFormats: [FourCharCode])

Expand All @@ -95,6 +96,8 @@ enum DeviceError {
return "low-light-boost-not-supported"
case .focusNotSupported:
return "focus-not-supported"
case .focusDepthNotSupported:
return "focus-depth-not-supported"
case .notAvailableOnSimulator:
return "camera-not-available-on-simulator"
case .pixelFormatNotSupported:
Expand All @@ -116,6 +119,8 @@ enum DeviceError {
return "The currently selected camera device does not support low-light boost! Select a device where `device.supportsLowLightBoost` is true."
case .focusNotSupported:
return "The currently selected camera device does not support focusing!"
case .focusDepthNotSupported:
return "The currently selected camera device does not support manual depth-of-field focusing!"
case .microphoneUnavailable:
return "The microphone was unavailable."
case .notAvailableOnSimulator:
Expand Down
37 changes: 37 additions & 0 deletions package/ios/Core/CameraSession+FocusDepth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// CameraSession+FocusDepth.swift
// VisionCamera
//
// Created by Hugo Gresse on 12.08.25.
//

import AVFoundation
import Foundation

extension CameraSession {
/**
Focuses the Camera to the specified distance. The distance must be within 0.001f and device.minFocusDistance
*/
func focusDepth(distance: Float) throws {
guard let device = videoDeviceInput?.device else {
throw CameraError.session(SessionError.cameraNotReady)
}
if !device.isLockingFocusWithCustomLensPositionSupported {
throw CameraError.device(DeviceError.focusDepthNotSupported)
}

VisionLogger.log(level: .info, message: "Focusing depth (\(distance))...")

do {
try device.lockForConfiguration()
defer {
device.unlockForConfiguration()
}

// Set Focus depth
device.setFocusModeLocked(lensPosition: distance, completionHandler: nil)
} catch {
throw CameraError.device(DeviceError.configureError)
}
}
}
2 changes: 1 addition & 1 deletion package/ios/Core/CameraSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ final class CameraSession: NSObject, AVCaptureVideoDataOutputSampleBufferDelegat
}
}

public final func captureOutput(_ captureOutput: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
final func captureOutput(_ captureOutput: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
switch captureOutput {
case is AVCaptureVideoDataOutput:
onVideoFrame(sampleBuffer: sampleBuffer, orientation: connection.orientation, isMirrored: connection.isVideoMirrored)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ extension AVCaptureDevice {
"supportsRawCapture": false, // TODO: supportsRawCapture
"supportsLowLightBoost": isLowLightBoostSupported,
"supportsFocus": isFocusPointOfInterestSupported,
"supportsFocusDepth": isLockingFocusWithCustomLensPositionSupported,
"hardwareLevel": HardwareLevel.full.jsValue,
"sensorOrientation": sensorOrientation.jsValue,
"formats": formats.map { $0.toJSValue() },
Expand Down
2 changes: 1 addition & 1 deletion package/ios/Core/PreviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ final class PreviewView: UIView {
}
}

override public static var layerClass: AnyClass {
override static var layerClass: AnyClass {
return AVCaptureVideoPreviewLayer.self
}

Expand Down
2 changes: 1 addition & 1 deletion package/ios/Core/Recording/Track.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ final class Track {
/**
Returns the last timestamp that was actually written to the track.
*/
public private(set) var lastTimestamp: CMTime?
private(set) var lastTimestamp: CMTime?

/**
Gets the natural size of the asset writer, or zero if it is not a visual track.
Expand Down
8 changes: 4 additions & 4 deletions package/ios/Core/Recording/TrackTimeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,22 @@ final class TrackTimeline {
Represents whether the timeline has been marked as finished or not.
A timeline will automatically be marked as finished when a timestamp arrives that appears after a stop().
*/
public private(set) var isFinished = false
private(set) var isFinished = false

/**
Gets the latency of the buffers in this timeline.
This is computed by (currentTime - mostRecentBuffer.timestamp)
*/
public private(set) var latency: CMTime = .zero
private(set) var latency: CMTime = .zero

/**
Get the first actually written timestamp of this timeline
*/
public private(set) var firstTimestamp: CMTime?
private(set) var firstTimestamp: CMTime?
/**
Get the last actually written timestamp of this timeline.
*/
public private(set) var lastTimestamp: CMTime?
private(set) var lastTimestamp: CMTime?

init(ofTrackType type: TrackType, withClock clock: CMClock) {
trackType = type
Expand Down
18 changes: 18 additions & 0 deletions package/ios/React/CameraView+FocusDepth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// CameraView+FocusDepth.swift
// VisionCamera
//
// Created by Hugo Gresse on 12.08.25.
//

import AVFoundation
import Foundation

extension CameraView {
func focusDepth(distance: Float, promise: Promise) {
withPromise(promise) {
try cameraSession.focusDepth(distance: distance)
return nil
}
}
}
2 changes: 2 additions & 0 deletions package/ios/React/CameraViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,7 @@ @interface RCT_EXTERN_REMAP_MODULE (CameraView, CameraViewManager, RCTViewManage
resolve reject : (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(focus : (nonnull NSNumber*)node point : (NSDictionary*)point resolve : (RCTPromiseResolveBlock)
resolve reject : (RCTPromiseRejectBlock)reject);
RCT_EXTERN_METHOD(focusDepth : (nonnull NSNumber*)node distance : (NSNumber*)distance resolve : (RCTPromiseResolveBlock)
resolve reject : (RCTPromiseRejectBlock)reject);

@end
7 changes: 7 additions & 0 deletions package/ios/React/CameraViewManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ final class CameraViewManager: RCTViewManager {
component.focus(point: CGPoint(x: x.doubleValue, y: y.doubleValue), promise: promise)
}

@objc
final func focusDepth(_ node: NSNumber, distance: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
let promise = Promise(resolver: resolve, rejecter: reject)
let component = getCameraView(withTag: node)
component.focusDepth(distance: distance.floatValue, promise: promise)
}

@objc
final func getCameraPermissionStatus() -> String {
let status = AVCaptureDevice.authorizationStatus(for: .video)
Expand Down
2 changes: 1 addition & 1 deletion package/ios/React/Utils/Promise.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import Foundation
* Represents a JavaScript Promise instance. `reject()` and `resolve()` should only be called once.
*/
class Promise {
public private(set) var didResolve = false
private(set) var didResolve = false

init(resolver: @escaping RCTPromiseResolveBlock, rejecter: @escaping RCTPromiseRejectBlock) {
self.resolver = resolver
Expand Down
21 changes: 21 additions & 0 deletions package/src/Camera.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,27 @@ export class Camera extends React.PureComponent<CameraProps, CameraState> {
throw tryParseNativeCameraError(e)
}
}

/**
* Focus the camera to a specific distance.
* @param {number} distance The distance to focus to. It should be lower than the minFocusDistance. Lower the value (closer to 0.001f), further the distance, higher the value (closer to the minFocusDistance), more macro the focus will be. But reversed on iOS: 0.0 is macro, 1.0 is infinite.
*
* Make sure the value doesn't exceed the device.minFocusDistance.
*
* @throws {@linkcode CameraRuntimeError} When any kind of error occured while focussing.
* Use the {@linkcode CameraRuntimeError.code | code} property to get the actual error
* @example
* ```ts
* await camera.current.focusDepth(5)
* ```
*/
public async focusDepth(distance: number): Promise<void> {
try {
return await CameraModule.focusDepth(this.handle, distance)
} catch (e) {
throw tryParseNativeCameraError(e)
}
}
//#endregion

//#region Static Functions (NativeModule)
Expand Down
1 change: 1 addition & 0 deletions package/src/CameraError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type DeviceError =
| 'device/pixel-format-not-supported'
| 'device/low-light-boost-not-supported'
| 'device/focus-not-supported'
| 'device/focus-depth-not-supported'
| 'device/camera-not-available-on-simulator'
| 'device/camera-already-in-use'
export type FormatError =
Expand Down