Skip to content

Commit

Permalink
feat: Add minFps and maxFps props (mrousavy#3074)
Browse files Browse the repository at this point in the history
* feat: Add `minFps` and `maxFps` props

* Implement Android

* Pods

* fix: Fix comparison

* feat: Allow `fps` to be either a number or a tuple

* Update CameraSession+Configuration.swift

* docs: Add docs explaining variable FPS
  • Loading branch information
mrousavy authored Jul 12, 2024
1 parent 105963e commit 86fa3ac
Show file tree
Hide file tree
Showing 14 changed files with 118 additions and 44 deletions.
45 changes: 38 additions & 7 deletions docs/docs/guides/FORMATS.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,21 @@ const format = getCameraFormat(device, Templates.Snapchat)

## Camera Props

The `Camera` View provides a few props that depend on the specified `format`. For example, you can only set the `fps` prop to a value that is supported by the current `format`. So if you have a format that supports 240 FPS, you can set the `fps` to `240`:
The `Camera` View provides a few props that depend on the specified `format`.

### FPS

For example, a camera device might have a 1080p and a 4k format, but the 4k one can only stream at 60 FPS, while the 1080p format can do 240 FPS.

To find a 240 FPS format we can use the [`useCameraFormat(..)`](/docs/api#usecameraformat) hook to find a suitable format, then pass it's maximum supported FPS as the Camera's target FPS:

```tsx
function App() {
// ...
const format = ...
const fps = format.maxFps >= 240 ? 240 : format.maxFps
const device = ...
const format = useCameraFormat(device, [
{ fps: 240 }
])
const fps = format.maxFps // <-- 240 FPS, or lower if 240 FPS is not available

return (
<Camera
Expand All @@ -200,14 +208,37 @@ function App() {
}
```

Setting [`fps`](/docs/api/interfaces/CameraProps#fps) to a single number will configure the Camera to use a fixed FPS rate.

Under low/dark lighting conditions, a Camera could throttle it's FPS rate to receive more light, which would result in **higher quality and better exposed photos and videos**.
VisionCamera provides an API to set a variable FPS rate, which internally automatically adjusts FPS rate depending on lighting conditions.
To use this, simply set [`fps`](/docs/api/interfaces/CameraProps#fps) to a tuple (`[min, max]`).

For example, we could target 30 FPS, but allow the Camera to throttle down to 20 FPS under low lighting conditions:

```tsx
function App() {
// ...
const format = ...
const minFps = Math.max(format.minFps, 20)
const maxFps = Math.min(format.maxFps, 30)

return (
<Camera
{...props}
fps={[minFps, maxFps]}
/>
)
}
```

### Other Props

Other props that depend on the `format`:

* `fps`: Specifies the frame rate to use
* `videoHdr`: Enables HDR video capture and preview
* `photoHdr`: Enables HDR photo capture
* `lowLightBoost`: Enables a night-mode/low-light-boost for photo or video capture and preview
* `videoStabilizationMode`: Specifies the video stabilization mode to use for the video pipeline
* `pixelFormat`: Specifies the pixel format to use for the video pipeline


<br />
Expand Down
6 changes: 4 additions & 2 deletions docs/docs/guides/PREVIEW.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@ To get notified about pauses in the preview view, use the [`onPreviewStarted`](/
/>
```

### FPS
### Preview Frame Rate (FPS)

The Preview view is running at the same frame rate as the Video stream, configured by the [`fps`](/docs/api/interfaces/CameraProps#fps) prop, or 30 FPS by default.
The Preview view is running at the same frame rate as the Video stream, configured by the [`fps`](/docs/api/interfaces/CameraProps#fps) prop, or a value close to 30 FPS by default.

```tsx
<Camera {...props} fps={60} />
```

See [FPS](formats#fps) for more information.

### Resolution

On iOS, the Video resolution also determines the Preview resolution, so if you Camera format has a low Video resolution, your Preview will also be in low resolution:
Expand Down
10 changes: 10 additions & 0 deletions docs/docs/guides/RECORDING_VIDEOS.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,16 @@ camera.current.startRecording({
})
```

### Video Frame Rate (FPS)

The resulting video will be recorded at the frame rate provided to the [`fps`](/docs/api/interfaces/CameraProps#fps) prop.

```tsx
<Camera {...props} fps={60} />
```

See [FPS](formats#fps) for more information.

## Saving the Video to the Camera Roll

Since the Video is stored as a temporary file, you need save it to the Camera Roll to permanentely store it. You can use [react-native-cameraroll](https://github.com/react-native-cameraroll/react-native-cameraroll) for this:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ data class CameraConfiguration(
var video: Output<Video> = Output.Disabled.create(),
var frameProcessor: Output<FrameProcessor> = Output.Disabled.create(),
var codeScanner: Output<CodeScanner> = Output.Disabled.create(),
var minFps: Int? = null,
var maxFps: Int? = null,
var enableLocation: Boolean = false,

// Orientation
Expand All @@ -29,7 +31,6 @@ data class CameraConfiguration(
var format: CameraDeviceFormat? = null,

// Side-Props
var fps: Int? = null,
var enableLowLightBoost: Boolean = false,
var torch: Torch = Torch.OFF,
var videoStabilizationMode: VideoStabilizationMode = VideoStabilizationMode.OFF,
Expand All @@ -54,9 +55,8 @@ data class CameraConfiguration(

val targetFpsRange: Range<Int>?
get() {
val maxFps = fps ?: return null
val format = format ?: throw PropRequiresFormatToBeNonNullError("fps")
val minFps = format.minFps.toInt()
val minFps = minFps ?: return null
val maxFps = maxFps ?: return null
return Range(minFps, maxFps)
}

Expand Down Expand Up @@ -130,7 +130,8 @@ data class CameraConfiguration(
left.codeScanner != right.codeScanner ||
left.preview != right.preview ||
left.format != right.format ||
left.fps != right.fps
left.minFps != right.minFps ||
left.maxFps != right.maxFps

// input device
val deviceChanged = outputsChanged || left?.cameraId != right.cameraId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ class CameraView(context: Context) :

// props that require format reconfiguring
var format: CameraDeviceFormat? = null
var fps: Int? = null
var minFps: Int? = null
var maxFps: Int? = null
var videoStabilizationMode: VideoStabilizationMode? = null
var videoHdr = false
var photoHdr = false
Expand Down Expand Up @@ -218,7 +219,8 @@ class CameraView(context: Context) :
config.format = format

// Side-Props
config.fps = fps
config.minFps = minFps
config.maxFps = maxFps
config.enableLowLightBoost = lowLightBoost
config.torch = torch
config.exposure = exposure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,17 @@ class CameraViewManager : ViewGroupManager<CameraView>() {
// TODO: Change when TurboModules release.
// We're treating -1 as "null" here, because when I make the fps parameter
// of type "Int?" the react bridge throws an error.
@ReactProp(name = "fps", defaultInt = -1)
fun setFps(view: CameraView, fps: Int) {
view.fps = if (fps > 0) fps else null
@ReactProp(name = "minFps", defaultInt = -1)
fun setMinFps(view: CameraView, minFps: Int) {
view.minFps = if (minFps > 0) minFps else null
}

// TODO: Change when TurboModules release.
// We're treating -1 as "null" here, because when I make the fps parameter
// of type "Int?" the react bridge throws an error.
@ReactProp(name = "maxFps", defaultInt = -1)
fun setMaxFps(view: CameraView, maxFps: Int) {
view.maxFps = if (maxFps > 0) maxFps else null
}

@ReactProp(name = "photoHdr")
Expand Down
16 changes: 8 additions & 8 deletions package/example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1391,16 +1391,16 @@ PODS:
- ReactCommon/turbomodule/core
- Yoga
- SocketRocket (0.7.0)
- VisionCamera (4.4.1):
- VisionCamera/Core (= 4.4.1)
- VisionCamera/FrameProcessors (= 4.4.1)
- VisionCamera/React (= 4.4.1)
- VisionCamera/Core (4.4.1)
- VisionCamera/FrameProcessors (4.4.1):
- VisionCamera (4.4.2):
- VisionCamera/Core (= 4.4.2)
- VisionCamera/FrameProcessors (= 4.4.2)
- VisionCamera/React (= 4.4.2)
- VisionCamera/Core (4.4.2)
- VisionCamera/FrameProcessors (4.4.2):
- React
- React-callinvoker
- react-native-worklets-core
- VisionCamera/React (4.4.1):
- VisionCamera/React (4.4.2):
- React-Core
- VisionCamera/FrameProcessors
- Yoga (0.0.0)
Expand Down Expand Up @@ -1688,7 +1688,7 @@ SPEC CHECKSUMS:
RNStaticSafeAreaInsets: 055ddbf5e476321720457cdaeec0ff2ba40ec1b8
RNVectorIcons: 2a2f79274248390b80684ea3c4400bd374a15c90
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
VisionCamera: 670bd7d5ec35b6e3bfaf9d48f4a102248291b00d
VisionCamera: 0292805e99ae8d121c8ec5bb8a9554f6f1c36b82
Yoga: 2f71ecf38d934aecb366e686278102a51679c308

PODFILE CHECKSUM: 49584be049764895189f1f88ebc9769116621103
Expand Down
8 changes: 5 additions & 3 deletions package/ios/Core/CameraConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ final class CameraConfiguration {
var format: CameraDeviceFormat?

// Side-Props
var fps: Int32?
var minFps: Int32?
var maxFps: Int32?
var enableLowLightBoost = false
var torch: Torch = .off

Expand Down Expand Up @@ -64,7 +65,8 @@ final class CameraConfiguration {
videoStabilizationMode = other.videoStabilizationMode
outputOrientation = other.outputOrientation
format = other.format
fps = other.fps
minFps = other.minFps
maxFps = other.maxFps
enableLowLightBoost = other.enableLowLightBoost
torch = other.torch
zoom = other.zoom
Expand Down Expand Up @@ -129,7 +131,7 @@ final class CameraConfiguration {
// format (depends on cameraId)
formatChanged = inputChanged || left?.format != right.format
// side-props (depends on format)
sidePropsChanged = formatChanged || left?.fps != right.fps || left?.enableLowLightBoost != right.enableLowLightBoost
sidePropsChanged = formatChanged || left?.minFps != right.minFps || left?.maxFps != right.maxFps || left?.enableLowLightBoost != right.enableLowLightBoost
// torch (depends on isActive)
let wasInactiveAndNeedsToEnableTorchAgain = left?.isActive == false && right.isActive == true && right.torch == .on
torchChanged = inputChanged || wasInactiveAndNeedsToEnableTorchAgain || left?.torch != right.torch
Expand Down
17 changes: 9 additions & 8 deletions package/ios/Core/CameraSession+Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,20 +249,21 @@ extension CameraSession {
*/
func configureSideProps(configuration: CameraConfiguration, device: AVCaptureDevice) throws {
// Configure FPS
if let fps = configuration.fps {
let supportsGivenFps = device.activeFormat.videoSupportedFrameRateRanges.contains { range in
return range.includes(fps: Double(fps))
if let minFps = configuration.minFps,
let maxFps = configuration.maxFps {
let fpsRanges = device.activeFormat.videoSupportedFrameRateRanges
if !fpsRanges.contains(where: { $0.minFrameRate <= Double(minFps) }) {
throw CameraError.format(.invalidFps(fps: Int(minFps)))
}
if !supportsGivenFps {
throw CameraError.format(.invalidFps(fps: Int(fps)))
if !fpsRanges.contains(where: { $0.maxFrameRate >= Double(maxFps) }) {
throw CameraError.format(.invalidFps(fps: Int(maxFps)))
}

let minFps = configuration.enableLowLightBoost ? fps / 2 : fps
device.activeVideoMinFrameDuration = CMTimeMake(value: 1, timescale: fps)
device.activeVideoMaxFrameDuration = CMTimeMake(value: 1, timescale: minFps)
device.activeVideoMinFrameDuration = CMTimeMake(value: 1, timescale: maxFps)
} else {
device.activeVideoMinFrameDuration = CMTime.invalid
device.activeVideoMaxFrameDuration = CMTime.invalid
device.activeVideoMinFrameDuration = CMTime.invalid
}

// Configure Low-Light-Boost
Expand Down
6 changes: 4 additions & 2 deletions package/ios/React/CameraView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat

// props that require format reconfiguring
@objc var format: NSDictionary?
@objc var fps: NSNumber?
@objc var minFps: NSNumber?
@objc var maxFps: NSNumber?
@objc var videoHdr = false
@objc var photoHdr = false
@objc var photoQualityBalance: NSString?
Expand Down Expand Up @@ -256,7 +257,8 @@ public final class CameraView: UIView, CameraSessionDelegate, PreviewViewDelegat
}

// Side-Props
config.fps = fps?.int32Value
config.minFps = minFps?.int32Value
config.maxFps = maxFps?.int32Value
config.enableLowLightBoost = lowLightBoost
config.torch = try Torch(jsValue: torch)

Expand Down
3 changes: 2 additions & 1 deletion package/ios/React/CameraViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ @interface RCT_EXTERN_REMAP_MODULE (CameraView, CameraViewManager, RCTViewManage
RCT_EXPORT_VIEW_PROPERTY(enableLocation, BOOL);
// device format
RCT_EXPORT_VIEW_PROPERTY(format, NSDictionary);
RCT_EXPORT_VIEW_PROPERTY(fps, NSNumber);
RCT_EXPORT_VIEW_PROPERTY(minFps, NSNumber);
RCT_EXPORT_VIEW_PROPERTY(maxFps, NSNumber);
RCT_EXPORT_VIEW_PROPERTY(videoHdr, BOOL);
RCT_EXPORT_VIEW_PROPERTY(photoHdr, BOOL);
RCT_EXPORT_VIEW_PROPERTY(photoQualityBalance, NSString);
Expand Down
8 changes: 7 additions & 1 deletion package/src/Camera.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ export class Camera extends React.PureComponent<CameraProps, CameraState> {
/** @internal */
public render(): React.ReactNode {
// We remove the big `device` object from the props because we only need to pass `cameraId` to native.
const { device, frameProcessor, codeScanner, enableFpsGraph, ...props } = this.props
const { device, frameProcessor, codeScanner, enableFpsGraph, fps, ...props } = this.props

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (device == null) {
Expand All @@ -636,12 +636,18 @@ export class Camera extends React.PureComponent<CameraProps, CameraState> {
const isRenderingWithSkia = isSkiaFrameProcessor(frameProcessor)
const shouldBeMirrored = device.position === 'front'

// minFps/maxFps is either the fixed `fps` value, or a value from the [min, max] tuple
const minFps = fps == null ? undefined : typeof fps === 'number' ? fps : fps[0]
const maxFps = fps == null ? undefined : typeof fps === 'number' ? fps : fps[1]

return (
<NativeCameraView
{...props}
cameraId={device.id}
ref={this.ref}
torch={torch}
minFps={minFps}
maxFps={maxFps}
isMirrored={props.isMirrored ?? shouldBeMirrored}
onViewReady={this.onViewReady}
onAverageFpsChanged={enableFpsGraph ? this.onAverageFpsChanged : undefined}
Expand Down
3 changes: 3 additions & 0 deletions package/src/NativeCameraView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ export type NativeCameraViewProps = Omit<
| 'onPreviewOrientationChanged'
| 'frameProcessor'
| 'codeScanner'
| 'fps'
> & {
// private intermediate props
cameraId: string
enableFrameProcessor: boolean
codeScannerOptions?: Omit<CodeScanner, 'onCodeScanned'>
minFps?: number
maxFps?: number
// private events
onViewReady: (event: NativeSyntheticEvent<void>) => void
onAverageFpsChanged?: (event: NativeSyntheticEvent<AverageFpsChangedEvent>) => void
Expand Down
9 changes: 7 additions & 2 deletions package/src/types/CameraProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,16 @@ export interface CameraProps extends ViewProps {
*/
androidPreviewViewType?: 'surface-view' | 'texture-view'
/**
* Specify the frames per second this camera should stream frames at.
* Specify a the number of frames per second this camera should stream frames at.
*
* - If `fps` is a single number, the Camera will be streaming at a fixed FPS value.
* - If `fps` is a tuple/array, the Camera will be free to choose a FPS value between `minFps` and `maxFps`,
* depending on current lighting conditions. Allowing a lower `minFps` value can result in better photos
* and videos, as the Camera can take more time to properly receive light for frames.
*
* Make sure the given {@linkcode format} can stream at the target {@linkcode fps} value (see {@linkcode CameraDeviceFormat.minFps format.minFps} and {@linkcode CameraDeviceFormat.maxFps format.maxFps}).
*/
fps?: number
fps?: number | [minFps: number, maxFps: number]
/**
* Enables or disables HDR Video Streaming for Preview, Video and Frame Processor via a 10-bit wide-color pixel format.
*
Expand Down

0 comments on commit 86fa3ac

Please sign in to comment.