Publish frames with the low-level API Mobile
This guide is exclusively for Mobile (React Native) applications.
@fishjam-cloud/react-native-custom-video-source is the generic layer under the Vision Camera integration. Use it directly when your frames come from another source, such as a native ML pipeline or your own renderer. It is built on the raw track primitives of @fishjam-cloud/react-native-webrtc, covered at the end of this guide.
Pick a mode
The two modes differ in how you produce frames:
| Mode | Hook | Use it when |
|---|---|---|
| Forwarding | useManagedForwardTrack | You already have finished native buffers (CVPixelBufferRef on iOS, AHardwareBuffer* on Android) |
| Render-target (pooled) | useManagedPooledTrack | You render the frames yourself; the hook allocates GPU-shareable surfaces for you to draw into |
Each hook manages the track's asynchronous lifecycle: creation, the published stream, and teardown. Your code only supplies frames.
Prerequisites
@fishjam-cloud/react-native-webrtcand@fishjam-cloud/react-native-client, with your app wrapped inFishjamProvider- The New Architecture; custom video tracks require it and reject with a clear error on the old architecture
- npm
- Yarn
- pnpm
- Bun
npm install @fishjam-cloud/react-native-custom-video-source
yarn add @fishjam-cloud/react-native-custom-video-source
pnpm add @fishjam-cloud/react-native-custom-video-source
bun add @fishjam-cloud/react-native-custom-video-source
Publish the managed stream
Both hooks return a stream. Publish it with useCustomSource, like any other custom source:
export functionusePublishStream (sourceId : string,stream :MediaStream | null) { const {setStream } =useCustomSource (sourceId );useEffect (() => { if (!stream ) return;setStream (stream ); return () => {setStream (null); }; }, [stream ,setStream ]); }
Forward finished buffers
useManagedForwardTrack creates and owns the track; hand each buffer pointer to forwardFrame:
const {track ,stream ,error } =useManagedForwardTrack (true /* enabled */); // publish `stream` via useCustomSource, then per frame: if (track ) {forwardFrame (track , {nativeBuffer :getNextFrameBuffer (), }); }
Buffer requirements:
nativeBufferis a retainable, IOSurface-backedCVPixelBufferRef(iOS) or anAHardwareBuffer*(Android), passed as abigintpointer.- The SDK retains the buffer for encoding; you may release your reference immediately after
forwardFramereturns. timestampNsis optional: when omitted, frames are stamped with the native monotonic clock. Pass your own capture timestamps only if you need to align the video with an audio track you also produce.rotation(0 | 90 | 180 | 270) rotates the frame for the receiver. Don't set it if your buffers are already upright, or the frame gets rotated twice.
forwardFrame and pushFrame are worklet-safe, but a track's sink binds to the first worklet runtime that touches it. Feed each track from a single runtime (or from the JS thread) consistently.
Render into pooled surfaces
useManagedPooledTrack allocates a pool of native GPU-shareable surfaces; draw into a slot and hand it back with pushFrame:
const {track ,stream ,bufferDescriptors ,error } =useManagedPooledTrack ( true, // enabled 720, // width 1280, // height 3, // poolSize ); letnextSlot = 0; functionrenderFrame () { if (!track || !bufferDescriptors ) return; constdescriptor =bufferDescriptors [nextSlot ];nextSlot = (nextSlot + 1) %bufferDescriptors .length ;renderInto (descriptor .surfaceHandle );pushFrame (track , {bufferIndex :descriptor .index ,timestampNs :nowNanoseconds (), }); }
Each WorkletBufferDescriptor carries { index, surfaceHandle, width, height }. Import each surfaceHandle into your GPU once and cache the result per index; don't re-import it every frame.
The surfaces are:
| Platform | Surface type | Pixel format | Import as |
|---|---|---|---|
| iOS | IOSurface | BGRA8 | bgra8unorm |
| Android | AHardwareBuffer | RGBA8 | rgba8unorm |
Rendering with the wrong format typically shows up as swapped red/blue channels, or the import is rejected outright.
Unlike forwarding, timestampNs is required here and must be strictly increasing.
Synchronize with your GPU
If your renderer works asynchronously, pass a fence so the encoder waits for your rendering to finish instead of reading a half-drawn surface:
- iOS:
handleis anMTLSharedEventpointer andsignaledValuethe value your GPU work signals. - Android:
handleis a sync file descriptor; pass0nassignaledValue.
If you render with WebGPU, prefer useVisionCameraWebGpuSource or the /webgpu toolkit of this package; they handle fencing, surface import, and frame lifetimes for you.
Lifecycle and errors
track,streamandbufferDescriptorsarenulluntil asynchronous creation finishes; failures surface in the hook'serrorfield rather than throwing.- Set
enabled: falseto tear everything down; the hooks stop the tracks (and dispose the pool) in the correct order on unmount too. - Changing the pooled dimensions reallocates the pool, so release any GPU objects you imported when
bufferDescriptorschanges.
Raw track primitives
The managed hooks wrap four primitives from @fishjam-cloud/react-native-webrtc: createCustomVideoBufferPool, createCustomVideoTrack, pushFrame and forwardFrame. Use them directly only when a React hook cannot own the lifecycle, for example inside a headless native-driven runtime:
constpool = awaitcreateCustomVideoBufferPool ({width : 720,height : 1280,poolSize : 3, }); const {stream ,track } = awaitcreateCustomVideoTrack ({pool }); // render into pool.buffers[i].surfaceHandle, then pushFrame(track, ...) // Teardown; order matters:stream .getTracks ().forEach ((mediaStreamTrack ) =>mediaStreamTrack .stop ()); awaitpool .dispose ();
When using the primitives directly, you must follow the rules the hooks otherwise enforce:
- A pool binds to exactly one track.
- Stop the track's stream before disposing the pool;
dispose()rejects while the track is live. - Stopping a track never frees the pool; you own it and must dispose it yourself.
createCustomVideoTrack()without a pool creates a forwarding track forforwardFrame.
WebGPU render targets
The /webgpu entry point of this package is a camera-rendering toolkit for the pooled mode. It provides a shared camera-capable GPUDevice, camera sampling from your shaders, a passthrough pipeline, and crop helpers. It is covered in WebGPU effects.
Related guides
- Custom sources in React Native: publishing the stream
- Vision Camera: the ready-made camera integration built on this layer
- How custom sources work
- API reference: Custom Video Source package