grafton-ndi is an idiomatic Rust interface to the NDI® 6 SDK. It gives Rust applications a practical way to discover NDI sources, receive and publish video/audio/metadata streams, monitor status and tally, control PTZ devices, and integrate NDI work into synchronous or async application architectures.
The crate is intentionally a binding layer rather than a media framework. It keeps the generated C bindings behind a narrow FFI boundary and exposes Rust types that are safe to compose with the rest of an application. You bring the renderer, mixer, encoder, storage layer, or UI; grafton-ndi handles the NDI-facing parts.
- Runtime lifecycle - Initialize and tear down the process-global NDI runtime through cheap, reference-counted
NDIhandles. - Network discovery - Discover sources, wait for source-list changes, search groups or extra IP ranges, and cache host/IP lookups.
- Receiving - Capture video, audio, and metadata with configurable bandwidth and color-format choices.
- Sending - Publish an NDI source and send video, audio, metadata, connection metadata, and failover information.
- FrameSync - Use NDI's clock-corrected pull API for playback, render loops, and audio-device driven workflows.
- Monitoring - Query receiver connection status, frame-drop statistics, tally state, and sender connection counts.
- PTZ control - Drive supported pan, tilt, zoom, focus, exposure, and white-balance commands.
- Async integration - Use optional Tokio or async-std wrappers for receive workflows without blocking the async runtime.
- Image snapshots - Encode captured video frames as PNG, JPEG, or data URLs with the default
image-encodingfeature. - Advanced SDK hooks - Enable Advanced SDK-specific functionality when your installed SDK exposes those symbols.
use grafton_ndi::{Finder, FinderOptions, NDI};
use std::time::Duration;
fn main() -> grafton_ndi::Result<()> {
let ndi = NDI::new()?;
let finder = Finder::new(
&ndi,
&FinderOptions::builder()
.show_local_sources(true)
.build(),
)?;
for source in finder.find_sources(Duration::from_secs(5))? {
println!("Found source: {source}");
}
Ok(())
}Add the crate to Cargo.toml:
[dependencies]
grafton-ndi = "1.0"Feature flags:
# Minimal build without PNG/JPEG/data URL helpers
# grafton-ndi = { version = "1.0", default-features = false }
# Image encoding support is enabled by default
# grafton-ndi = { version = "1.0", features = ["image-encoding"] }
# Async receiver wrappers
# grafton-ndi = { version = "1.0", features = ["tokio"] }
# grafton-ndi = { version = "1.0", features = ["async-std"] }
# Advanced SDK symbols, when available from the installed SDK
# grafton-ndi = { version = "1.0", features = ["advanced_sdk"] }-
NDI SDK 6.x: Install the NDI SDK for your platform.
- Windows default:
C:\Program Files\NDI\NDI 6 SDK - Linux defaults:
/usr/share/NDI Advanced SDK for Linuxor/usr/share/NDI SDK for Linux - macOS defaults include
/Library/NDI SDK for macOS,/Library/NDI SDK for Apple, and/Library/NDI 6 SDK - Set
NDI_SDK_DIRwhen the SDK is installed elsewhere.
- Windows default:
-
Rust: Rust 1.87 or later.
-
Build dependencies: bindgen generates the FFI bindings at build time from your installed SDK headers, so it needs an LLVM/Clang toolchain:
- Windows: Visual Studio 2019+ or Build Tools, plus LLVM/Clang for bindgen
- Linux: a C toolchain and LLVM/Clang headers for bindgen
- macOS: Xcode Command Line Tools
-
Runtime libraries:
- Windows: ensure the NDI runtime DLL directory is on
PATH - Linux: install the NDI runtime/tools or configure
LD_LIBRARY_PATH - macOS: install the NDI runtime/tools or configure
DYLD_LIBRARY_PATHas needed
- Windows: ensure the NDI runtime DLL directory is on
Finder wraps the NDI discovery API. It can return a current source snapshot, wait for source-list changes, or perform a bounded discovery pass.
use grafton_ndi::{Finder, FinderOptions};
use std::time::Duration;
let options = FinderOptions::builder()
.show_local_sources(true)
.groups("Public,Studio")
.extra_ips("192.168.1.0/24")
.build();
let finder = Finder::new(&ndi, &options)?;
if finder.wait_for_sources(Duration::from_secs(2))? {
for source in finder.current_sources()? {
println!("{source}");
}
}For applications that repeatedly reconnect to known devices, SourceCache handles runtime initialization, discovery, host/IP matching, and cache invalidation.
use grafton_ndi::SourceCache;
use std::time::Duration;
let cache = SourceCache::new()?;
let camera = cache.find_by_host("192.168.1.100", Duration::from_secs(5))?;Receiver captures video, audio, and metadata from a selected Source. Use bandwidth and color-format options to match the role of the receiver, from low-bandwidth monitors to full-quality capture.
use grafton_ndi::{
Receiver, ReceiverBandwidth, ReceiverColorFormat, ReceiverOptions,
};
use std::time::Duration;
let options = ReceiverOptions::builder(camera)
.color(ReceiverColorFormat::RGBX_RGBA)
.bandwidth(ReceiverBandwidth::Highest)
.build();
let receiver = Receiver::new(&ndi, &options)?;
let video = receiver.video().capture(Duration::from_secs(5))?;
println!(
"{}x{} {:?}",
video.width(),
video.height(),
video.pixel_format()
);
let audio = receiver.audio().try_capture(Duration::from_millis(100))?;
let metadata = receiver.metadata().try_capture(Duration::from_millis(100))?;Sender publishes a named NDI source and sends video, audio, and metadata. It also exposes connection count, tally, failover, and connection metadata APIs.
use grafton_ndi::{PixelFormat, Sender, SenderOptions, VideoFrame};
use std::time::Duration;
let options = SenderOptions::builder("Rust Program Output")
.clock_video(true)
.clock_audio(true)
.build();
let sender = Sender::new(&ndi, &options)?;
let mut frame = VideoFrame::builder()
.resolution(1920, 1080)
.pixel_format(PixelFormat::BGRA)
.frame_rate(60, 1)
.build()?;
frame.data_mut().fill(0);
sender.send_video(&frame);
let connections = sender.connection_count(Duration::from_millis(500))?;
println!("connected receivers: {connections}");FrameSync is for pull-based capture when your application has its own clock: a GPU vsync loop, audio callback, timeline, or mixer. Video capture returns the frame appropriate for the requested time base, and audio capture can resample to the requested output shape.
use grafton_ndi::{FrameSync, FrameSyncAudioRequest, ScanType};
use std::num::NonZeroI32;
let frame_sync = FrameSync::new(receiver)?;
if let Some(video) = frame_sync.capture_video(ScanType::Progressive)? {
println!("video: {}x{}", video.width(), video.height());
}
let audio = frame_sync.capture_audio(FrameSyncAudioRequest::capture(
NonZeroI32::new(1024).unwrap(),
))?;
if !audio.is_empty() {
println!("audio: {} channels at {} Hz", audio.num_channels(), audio.sample_rate());
}Receivers can report connection health, frame-drop statistics, tally changes, and PTZ support.
use std::time::Duration;
if receiver.is_connected() {
let stats = receiver.connection_stats();
println!("video drop rate: {:.2}%", stats.video_drop_percentage());
}
if let Some(status) = receiver.poll_status_change(Duration::from_millis(100))? {
if let Some(tally) = status.tally {
println!(
"tally: program={}, preview={}",
tally.on_program,
tally.on_preview
);
}
}
if receiver.ptz_is_supported() {
receiver.ptz_zoom(0.25)?;
}NDI receive calls are fundamentally blocking SDK calls. The optional async wrappers make that explicit by running receive work on the runtime's blocking pool while preserving the timeout budget from the moment the async method is called.
use grafton_ndi::tokio::AsyncReceiver;
use std::time::Duration;
let async_receiver = AsyncReceiver::new(receiver);
let frame = async_receiver.video().capture(Duration::from_secs(5)).await?;The crate's public API is organized around a small set of resource types:
NDIowns a reference to the process-global NDI runtime.FinderdiscoversSourcevalues on the network.Receiverconnects to a source and captures video, audio, and metadata.Senderpublishes an NDI source.FrameSyncwraps a receiver for clock-corrected pull capture.VideoFrame,AudioFrame, andMetadataFramerepresent application-owned frame data.
Most applications can start with owned frame APIs such as receiver.video().capture() and sender.send_video(). For hot paths, the crate also exposes borrowed receive refs and borrowed async-send video frames so data can stay in SDK or application buffers without an extra copy. Those zero-copy APIs use Rust lifetimes to make buffer reuse explicit.
Frame layout fields that describe SDK-facing memory are private. Builders, accessors, checked mutation methods, and PixelFormat helpers keep dimensions, strides, metadata, and buffer sizes consistent before data crosses the FFI boundary.
grafton-ndi aims to be a predictable Rust layer over NDI, not a replacement for the NDI SDK documentation.
- Small FFI boundary: generated bindings live behind safe wrappers.
- RAII lifecycle: NDI handles, senders, receivers, frames, and async send tokens clean up through ownership.
- Checked frame descriptions: frame dimensions, line strides, channel strides, metadata strings, and buffer sizes are validated before slices or strings are exposed.
- Explicit blocking behavior: synchronous methods block with checked timeouts; async adapters use
spawn_blocking. - Forward-compatible SDK enums: public SDK-mode enums that may grow are
#[non_exhaustive].
- Use owned frame APIs when clarity matters or when frame data must outlive the SDK capture buffer.
- Use borrowed receive refs when you need direct access to SDK-owned buffers during a tight capture loop.
- Use borrowed async-send video frames when you want NDI to send from an application buffer without copying.
- Use
FrameSyncwhen capture timing is driven by an external output clock. - Use lower bandwidth modes for previews and monitoring surfaces.
- Keep image encoding off real-time paths unless the workload is sized for compression.
The compile-contract tests in tests/ui verify the important lifetime rules around borrowed receive refs and async send tokens.
With the default image-encoding feature, captured VideoFrame values can be encoded directly:
use grafton_ndi::ImageFormat;
let png = video.encode_png()?;
let jpeg = video.encode_jpeg(85)?;
let data_url = video.encode_data_url(ImageFormat::Png)?;- API documentation - Full rustdoc reference, built in CI and self-hosted (the NDI SDK license prevents distributing the generated bindings docs.rs would need).
- Examples - Runnable examples for discovery, receiving, FrameSync, PTZ, monitoring, and sending.
- CHANGELOG.md - Release notes and migration guidance.
- Migration notes - Older version-to-version migration notes.
Run examples with:
cargo run --example NDIlib_FindDiscovery and monitoring:
NDIlib_Find.rs- Discover NDI sources on the network.status_monitor.rs- Monitor receiver connection status, tally, and frame drops.
Receiving:
NDIlib_Recv_Audio.rs- Receive and inspect audio streams.NDIlib_Recv_Audio_16bpp.rs- Receive 16-bit audio samples.NDIlib_Recv_FrameSync.rs- Clock-corrected capture withFrameSync.NDIlib_Recv_PNG.rs- Receive video and save PNG snapshots.NDIlib_Recv_PTZ.rs- Control PTZ cameras.concurrent_capture.rs- Capture from multiple sources concurrently.
Sending:
NDIlib_Send_Audio.rs- Send audio.NDIlib_Send_Video.rs- Send video.async_send.rs- Demonstrate async video send tokens and completion callbacks.zero_copy_send.rs- Send borrowed video buffers without copying.
| Platform | Status | Notes |
|---|---|---|
| Windows | CI-tested | Uses the NDI SDK import library at build time and NDI runtime DLLs at runtime. |
| Linux | CI-tested | Supports standard and Advanced SDK install directories; runtime libraries must be discoverable by the dynamic linker. |
| macOS | CI-tested | Supports current NDI SDK package layouts used by the CI setup action and common local install paths. |
Common checks:
cargo fmt --all -- --check
cargo test
cargo clippy -- -D warnings
cargo build --examples
cargo test --test compile_contractsSee CONTRIBUTING.md for development setup, CI notes, and contribution guidelines.
Licensed under the Apache License, Version 2.0. See LICENSE for details.
This is an unofficial community project and is not affiliated with NewTek or Vizrt.
NDI® is a registered trademark of Vizrt NDI AB.