react-native-nitro-ndi
v0.1.0
Published
NDI video and audio for React Native — discover sources, send frames, and receive from the network.
Maintainers
Readme
react-native-nitro-ndi
NDI (Network Device Interface) for React Native, powered by Nitro Modules.
Discover NDI sources, send video and audio to the network, and receive from a remote source.
Frames are plain byte buffers, so anything that can produce pixels can send: a camera frame, a rendered surface, a decoded file, or something you drew yourself. The library has no opinion about where the bytes came from.
Platform support
| Platform | Status | |---|---| | iOS (physical device) | Supported | | iOS Simulator | Not supported — see below | | Android | Not yet — the build is wired but unverified, and needs an Android NDI SDK | | visionOS | Not yet — the podspec claims the platform but does not link a visionOS library |
The iOS Simulator is not supported on Apple Silicon. The NDI SDK for Apple ships
libndi_ios.a as a fat x86_64 + arm64 archive, and the arm64 slice is the
device one — there is no arm64-simulator slice to link against. The library
compiles for the simulator with NDI stubbed out, and every entry point throws a
descriptive error there. Test on a physical device.
Prerequisites
- React Native >= 0.85 with the New Architecture enabled
react-native-nitro-modulesinstalled in your app- The NDI SDK — see below
NDI SDK Setup
The NDI SDK is proprietary and cannot be included in this repository. Obtain it directly from NDI: https://ndi.video/for-developers/ndi-sdk/
The SDK lives outside this library. The build autodetects the standard installation path, so you only need to intervene if you installed it somewhere non-standard:
export NDI_SDK_PATH="/path/to/your/ndi-sdk"Add that to your shell profile, or set it as a CI secret.
| Host | Default path |
|---|---|
| macOS | /Library/NDI SDK for Apple |
Expected layout
Both the native installer layout and a normalized layout are accepted:
$NDI_SDK_PATH/
├── include/ ← NDI headers (Processing.NDI.*.h)
└── lib/
└── iOS/
└── libndi_ios.a$NDI_SDK_PATH/
├── include/
└── ios/
└── libndi_ios.aiOS
Once the SDK is in place, run:
cd your-app/ios && pod installIf the library cannot be found, pod install fails with a descriptive error.
Android
Android is not usable yet. The Gradle build reads NDI_SDK_PATH and expects
$NDI_SDK_PATH/android/<abi>/libndi.so, but the NDI SDK for Apple does not ship
Android libraries — its lib/ contains only iOS, macOS, tvOS and visionOS.
An Android build therefore fails to link against a default macOS installation.
Sourcing an Android-capable SDK, confirming which NDI distribution provides it, and
verifying a build are outstanding. Discovery additionally needs manifest permissions
and a WifiManager.MulticastLock, neither of which is in place, so getSources()
would return empty even once it links.
API
NdiFinder
Discovers NDI sources on the local network (and optionally on specific IP addresses).
import { NdiFinder, NdiSource } from 'react-native-nitro-ndi'
import { NitroModules } from 'react-native-nitro-modules'
const finder = NitroModules.createHybridObject<NdiFinder>('NdiFinder')
finder.start() // begin discovery (optional: pass extra IPs)
const sources: NdiSource[] = finder.getSources()
finder.stop()| Method | Signature | Description |
|---|---|---|
| start | (extraIps?: string) => void | Start NDI source discovery. Optionally pass a comma-separated list of additional IP addresses to scan. |
| stop | () => void | Stop discovery and release resources. |
| getSources | () => NdiSource[] | Returns currently discovered sources ({ name: string, urlAddress: string }). |
Discovery takes a moment to populate — getSources() immediately after start()
will usually return an empty array. Poll it, or call it in response to user action.
NdiSender
Sends NDI video and audio frames to the network.
import { NdiSender, NdiFourCC } from 'react-native-nitro-ndi'
import { NitroModules } from 'react-native-nitro-modules'
const sender = NitroModules.createHybridObject<NdiSender>('NdiSender')
sender.sourceName = 'My Camera' // set before the first send
sender.targetFps = 30
// `data` is an ArrayBuffer of packed pixels, from any source.
if (sender.shouldSendFrame()) {
sender.sendVideoFrame({
data, width, height,
frameRateN: 30000, frameRateD: 1001,
fourCC: NdiFourCC.BGRA,
lineStrideBytes: width * 4,
})
}
sender.close() // release the name when done| Property / Method | Signature | Description |
|---|---|---|
| sourceName | string | The NDI source name broadcast on the network. Assigning recreates the underlying sender, so receivers reconnect. |
| sendVideoFrame | (frame: NdiVideoFrame) => void | Send a raw video frame. Opens the sender on first call. |
| sendAudioFrame | (frame: NdiAudioFrame) => void | Send a raw audio frame (32-bit float planar). Never rate-limited. |
| shouldSendFrame | () => boolean | Whether targetFps would admit a frame now. Cheap pre-check so a producer can skip conversion work it would only discard. sendVideoFrame applies the same limit itself. |
| close | () => void | Release the sender and its name immediately. Sending again reopens it. |
| targetFps | number | Caps video frames per second forwarded to NDI. 0 (default) forwards every frame. |
Two senders cannot share a name. NDI refuses to create a sender whose name is
already present on the network, so give each one its own. The sender is opened
lazily on the first send, which means you can assign sourceName freely before
then without touching the network.
Call close() rather than relying on garbage collection. NDI will refuse the
name while an undestroyed sender still holds it, so a component that tears down and
reopens under the same name must close deterministically.
Pixel formats
| NdiFourCC | Layout |
|---|---|
| BGRA | 32-bit, blue-green-red-alpha |
| BGRX | 32-bit, alpha byte ignored |
| RGBA | 32-bit, red-green-blue-alpha |
| RGBX | 32-bit, alpha byte ignored |
| NV12 | 8-bit Y plane + interleaved CbCr — half the bytes of BGRA |
| UYVY | YCbCr 4:2:2 packed |
lineStrideBytes is bytes per row including any row padding, which is not always
width * 4 — a camera buffer often pads rows to an alignment boundary. Read the
stride from your source rather than computing it. For NV12 the stride is the Y
plane's, i.e. width.
NdiReceiver
Receives NDI video and audio frames from a remote source.
import { NdiReceiver } from 'react-native-nitro-ndi'
import { NitroModules } from 'react-native-nitro-modules'
const receiver = NitroModules.createHybridObject<NdiReceiver>('NdiReceiver')
receiver.connect(source)
const frame = await receiver.captureVideoFrame(100) // null if nothing arrived
if (frame != null) {
// frame.data is an ArrayBuffer of packed pixels
}
receiver.disconnect()| Method | Signature | Description |
|---|---|---|
| connect | (source: NdiSource) => void | Connect to an NDI source. |
| disconnect | () => void | Disconnect and release the receiver. Returns immediately; a capture already in flight finishes on its own. |
| captureVideoFrame | (timeoutMs: number) => Promise<NdiVideoFrame \| null> | Capture one video frame, resolving null if none arrives within timeoutMs. |
| captureAudioFrame | (timeoutMs: number) => Promise<NdiAudioFrame \| null> | Capture one audio frame, resolving null on timeout. |
Capture runs off the JS thread. NDI blocks for the full timeout when a source stalls, so these resolve asynchronously rather than freezing the UI. Await each capture before issuing the next.
Captured frames hand you NDI's own buffer without copying it, and the buffer returns to NDI when JavaScript releases the frame. Hold frames only as long as you need them: the receiver falls back to copying once too many are outstanding, so hoarding them costs memory rather than stalling the stream, but neither is free.
Development
See DEVELOPMENT.md for the repository layout, the checks, git hooks, and the branching convention. (Contributor docs are not shipped in the published package.)
