Skip to content

Commit efdc142

Browse files
authored
feat: Add Frame.hasPixelBuffer and Frame.hasNativeBuffer (#3928)
* feat: Add `Frame.hasPixelBuffer` and `Frame.hasNativeBuffer` * update docs * chore: Allow skipping if unsupported * more typed
1 parent 49d94e7 commit efdc142

14 files changed

Lines changed: 262 additions & 63 deletions

File tree

apps/simple-camera/__tests__/visioncamera.frame.harness.ts

Lines changed: 94 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe('VisionCamera - Frame', () => {
7676
expect(framesReceived).toBeGreaterThanOrEqual(3)
7777
})
7878

79-
it('gets pixel buffers and releases native buffers from native frames', async () => {
79+
it('reports and conditionally reads native frame buffers', async (context) => {
8080
const session = await VisionCamera.createCameraSession(false)
8181
const frameOutput = VisionCamera.createFrameOutput({
8282
targetResolution: CommonResolutions.HD_16_9,
@@ -95,59 +95,111 @@ describe('VisionCamera - Frame', () => {
9595
},
9696
])
9797

98-
const receivedBuffers = deferred()
98+
type NativeFrameBufferReport =
99+
| { state: 'skip'; reason: string }
100+
| { state: 'error'; errorMessage: string }
101+
| { state: 'success' }
102+
103+
type NativeFrameBufferResult =
104+
| { state: 'skip'; reason: string }
105+
| { state: 'success'; frames: number }
106+
107+
const receivedBuffers = deferred<NativeFrameBufferResult>()
99108
let buffersReceived = 0
100-
const report = (hasPixelBuffer: boolean, hasNativeBuffer: boolean) => {
101-
if (!hasPixelBuffer) {
102-
receivedBuffers.reject(new Error('Frame pixel buffer was empty.'))
103-
} else if (!hasNativeBuffer) {
104-
receivedBuffers.reject(new Error('Frame native buffer pointer was 0.'))
105-
} else {
106-
buffersReceived++
107-
if (buffersReceived >= 3) {
108-
receivedBuffers.resolve()
109-
}
109+
const report = (frameBufferReport: NativeFrameBufferReport) => {
110+
switch (frameBufferReport.state) {
111+
case 'skip':
112+
receivedBuffers.resolve(frameBufferReport)
113+
break
114+
case 'error':
115+
receivedBuffers.reject(new Error(frameBufferReport.errorMessage))
116+
break
117+
case 'success':
118+
buffersReceived++
119+
if (buffersReceived >= 3) {
120+
receivedBuffers.resolve({
121+
state: 'success',
122+
frames: buffersReceived,
123+
})
124+
}
125+
break
110126
}
111127
}
112-
const reportError = (errorMessage: string) => {
113-
receivedBuffers.reject(new Error(errorMessage))
114-
}
115128
const errorSub = session.addOnErrorListener(receivedBuffers.reject)
116129

117130
const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread)
118131
runtime.setOnFrameCallback(frameOutput, (frame) => {
119132
'worklet'
120133
try {
121-
const pixelBuffer = frame.getPixelBuffer()
122-
const hasPixelBuffer = pixelBuffer.byteLength > 0
134+
if (!frame.hasPixelBuffer) {
135+
scheduleOnRN(report, {
136+
state: 'skip',
137+
reason:
138+
'native frame buffers: device does not expose readable pixel buffers',
139+
})
140+
return
141+
}
142+
143+
const pixelBufferBytes = frame.getPixelBuffer().byteLength
144+
if (pixelBufferBytes <= 0) {
145+
scheduleOnRN(report, {
146+
state: 'error',
147+
errorMessage: 'Frame pixel buffer was empty.',
148+
})
149+
return
150+
}
151+
152+
if (!frame.hasNativeBuffer) {
153+
scheduleOnRN(report, {
154+
state: 'skip',
155+
reason:
156+
'native frame buffers: device does not expose native buffers',
157+
})
158+
return
159+
}
160+
123161
const nativeBuffer = frame.getNativeBuffer()
124-
let hasNativeBuffer = false
125162
try {
126-
hasNativeBuffer = nativeBuffer.pointer !== 0n
163+
if (nativeBuffer.pointer === 0n) {
164+
scheduleOnRN(report, {
165+
state: 'error',
166+
errorMessage: 'Frame native buffer pointer was 0.',
167+
})
168+
return
169+
}
127170
} finally {
128171
nativeBuffer.release()
129172
}
130-
scheduleOnRN(report, hasPixelBuffer, hasNativeBuffer)
173+
174+
scheduleOnRN(report, {
175+
state: 'success',
176+
})
131177
} catch (e) {
132-
scheduleOnRN(reportError, String(e))
178+
scheduleOnRN(report, {
179+
state: 'error',
180+
errorMessage: String(e),
181+
})
133182
} finally {
134183
frame.dispose()
135184
}
136185
})
137186

138187
await session.start()
139188
try {
140-
await withTimeout(
189+
const result = await withTimeout(
141190
receivedBuffers.promise,
142191
15_000,
143-
'receive native frame pixel buffers',
192+
'receive native frame buffer reports',
144193
)
194+
if (result.state === 'skip') {
195+
return context.skip(result.reason)
196+
}
197+
expect(result.frames).toBeGreaterThanOrEqual(3)
145198
} finally {
146199
runtime.setOnFrameCallback(frameOutput, undefined)
147200
errorSub.remove()
148201
await session.stop()
149202
}
150-
expect(buffersReceived).toBeGreaterThanOrEqual(3)
151203
})
152204

153205
it('keeps YUV plane buffers readable across repeated reads', async () => {
@@ -302,7 +354,7 @@ describe('VisionCamera - Frame', () => {
302354
expect(reportedPlanes).toBeGreaterThanOrEqual(1)
303355
})
304356

305-
it('delivers frames when streaming in rgb', async () => {
357+
it('delivers readable pixel buffers when streaming in rgb', async () => {
306358
const session = await VisionCamera.createCameraSession(false)
307359
const frameOutput = VisionCamera.createFrameOutput({
308360
targetResolution: CommonResolutions.VGA_16_9,
@@ -322,16 +374,29 @@ describe('VisionCamera - Frame', () => {
322374
])
323375

324376
const receivedFrame = deferred()
325-
const onFrame = () => {
326-
receivedFrame.resolve()
377+
const onFrame = (pixelBufferBytes: number) => {
378+
if (pixelBufferBytes > 0) {
379+
receivedFrame.resolve()
380+
} else {
381+
receivedFrame.reject(new Error('RGB frame pixel buffer was empty.'))
382+
}
383+
}
384+
const onError = (errorMessage: string) => {
385+
receivedFrame.reject(new Error(errorMessage))
327386
}
328387
const errorSub = session.addOnErrorListener(receivedFrame.reject)
329388

330389
const runtime = workletsProvider.createRuntimeForThread(frameOutput.thread)
331390
runtime.setOnFrameCallback(frameOutput, (frame) => {
332391
'worklet'
333-
scheduleOnRN(onFrame)
334-
frame.dispose()
392+
try {
393+
const pixelBufferBytes = frame.getPixelBuffer().byteLength
394+
scheduleOnRN(onFrame, pixelBufferBytes)
395+
} catch (e) {
396+
scheduleOnRN(onError, String(e))
397+
} finally {
398+
frame.dispose()
399+
}
335400
})
336401

337402
await session.start()

packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ImageProxy+getNativeBuffer.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ import androidx.camera.core.ImageProxy
77
import com.margelo.nitro.camera.NativeBuffer
88
import com.margelo.nitro.camera.utils.NativeBufferHelper
99

10+
val ImageProxy.hasNativeBuffer: Boolean
11+
@OptIn(ExperimentalGetImage::class)
12+
get() {
13+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
14+
return false
15+
}
16+
image?.hardwareBuffer?.use {
17+
return true
18+
}
19+
return false
20+
}
21+
1022
@OptIn(ExperimentalGetImage::class)
1123
fun ImageProxy.getNativeBuffer(): NativeBuffer {
1224
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {

packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/extensions/ImageProxy+getPixelBuffer.kt

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.camera.core.ExperimentalGetImage
77
import androidx.camera.core.ImageProxy
88
import com.margelo.nitro.camera.utils.DirectByteBufferPool
99
import com.margelo.nitro.core.ArrayBuffer
10+
import java.nio.ByteBuffer
1011

1112
val ImageProxy.hasPixelBuffer: Boolean
1213
@OptIn(ExperimentalGetImage::class)
@@ -30,6 +31,23 @@ data class DisposableArrayBuffer(
3031
val dispose: () -> Unit,
3132
)
3233

34+
private fun ByteBuffer.wrapOrCopyIntoArrayBuffer(): DisposableArrayBuffer {
35+
val buffer = readableBytes()
36+
if (buffer.isDirect) {
37+
val arrayBuffer = ArrayBuffer.wrap(buffer)
38+
return DisposableArrayBuffer(arrayBuffer) {
39+
// no release
40+
}
41+
}
42+
43+
val directBuffer = DirectByteBufferPool.Shared.acquire(buffer.remaining())
44+
directBuffer.put(buffer)
45+
val arrayBuffer = ArrayBuffer.wrap(directBuffer)
46+
return DisposableArrayBuffer(arrayBuffer) {
47+
DirectByteBufferPool.Shared.release(directBuffer)
48+
}
49+
}
50+
3351
@OptIn(ExperimentalGetImage::class)
3452
fun ImageProxy.getPixelBuffer(): DisposableArrayBuffer {
3553
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
@@ -47,12 +65,8 @@ fun ImageProxy.getPixelBuffer(): DisposableArrayBuffer {
4765
}
4866
when {
4967
planes.size == 1 -> {
50-
// Medium Path: We can wrap a single plane as a ByteBuffer
51-
val byteBuffer = planes.single().buffer.readableBytes()
52-
val arrayBuffer = ArrayBuffer.wrap(byteBuffer)
53-
return DisposableArrayBuffer(arrayBuffer) {
54-
// no release
55-
}
68+
// Medium Path: We can wrap a single direct plane as a ByteBuffer, or copy it into one if needed.
69+
return planes.single().buffer.wrapOrCopyIntoArrayBuffer()
5670
}
5771
planes.size > 1 -> {
5872
// Slow Path: We have to copy all planes into a new ByteBuffer.

packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/instances/HybridFrame.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import com.margelo.nitro.camera.extensions.DisposableArrayBuffer
1212
import com.margelo.nitro.camera.extensions.convertPoint
1313
import com.margelo.nitro.camera.extensions.getNativeBuffer
1414
import com.margelo.nitro.camera.extensions.getPixelBuffer
15+
import com.margelo.nitro.camera.extensions.hasNativeBuffer
16+
import com.margelo.nitro.camera.extensions.hasPixelBuffer
1517
import com.margelo.nitro.camera.extensions.mapToArray
1618
import com.margelo.nitro.camera.extensions.pixelFormat
1719
import com.margelo.nitro.camera.public.NativeFrame
@@ -50,6 +52,10 @@ class HybridFrame(
5052
get() = image.pixelFormat
5153
override val isPlanar: Boolean
5254
get() = image.planes.size > 1
55+
override val hasPixelBuffer: Boolean
56+
get() = image.hasPixelBuffer
57+
override val hasNativeBuffer: Boolean
58+
get() = image.hasNativeBuffer
5359

5460
// TODO: Implement `cameraIntrinsicMatrix`
5561
override val cameraIntrinsicMatrix: DoubleArray?

packages/react-native-vision-camera/ios/Hybrid Objects/Image Types/HybridFrame.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ final class HybridFrame: HybridFrameSpec, NativeFrame, LazyLockableBuffer {
7474
return CVPixelBufferIsPlanar(pixelBuffer)
7575
}
7676

77+
var hasPixelBuffer: Bool {
78+
return pixelBuffer != nil
79+
}
80+
81+
var hasNativeBuffer: Bool {
82+
return pixelBuffer != nil
83+
}
84+
7785
var pixelFormat: PixelFormat {
7886
guard let pixelBuffer else {
7987
return .unknown

packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridFrameSpec.cpp

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridFrameSpec.hpp

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/HybridFrameSpec.kt

Lines changed: 21 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)