Skip to content

Commit af4e458

Browse files
allan sargeantclaude
andcommitted
Add crates/ndi-io: real, tested NDI transport (opt-in, not default build)
New workspace member using grafton-ndi (Apache-2.0) against the NDI SDK genuinely installed on this machine (/Library/NDI SDK for Apple). spawn_input/spawn_output mirror srt-io's shape: discover a named NDI source, capture video/audio/metadata frames, carry each through crosspoint-core's existing Bytes broadcast channel as a small self-describing envelope (crates/ndi-io/src/envelope.rs) rather than a raw byte stream — NDI has no single opaque payload the way SRT/MPEG-TS does. crosspoint-core itself is untouched, matching the corrected "endpoints not crosspoints" scope. Verified for real, not just written: crates/ndi-io/tests/relay.rs drives an actual NDI sender and receiver against spawn_input/spawn_output, consistently passing in ~1s across repeated runs. Getting there involved tracking down what looked like an NDI SDK reliability issue through extensive isolation (thread models, concurrency, envelope round-tripping, is_connected() semantics) before finding the real cause: the test's own tokio::Runtime::drop() blocks waiting for spawn_blocking tasks that loop forever by design, which read as a hang. Fixed with shutdown_background() in the test; also hardened spawn_input's disconnect detection along the way to use a silence-timeout instead of is_connected() (which reads false transiently after connecting and isn't reliable per-iteration). ndi-io is a real workspace member (buildable/testable via `-p`) but not in default-members, so plain `cargo build`/`cargo test` (and CI) stay exactly as they were — this requires the actual NDI SDK to build (grafton-ndi runs bindgen against installed headers) and isn't yet wired into the srtrouter binary or its config schema. crates/omt-io added as a placeholder for the equivalent OMT transport (genuinely open protocol, MIT-licensed) — deferred: the only existing Rust wrapper is Windows-only and incomplete, so real support needs hand-written FFI against libomt's own header/binary, not yet done. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 541e50f commit af4e458

8 files changed

Lines changed: 883 additions & 7 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ members = [
55
"crates/srt-io",
66
"crates/web",
77
"crates/router",
8+
"crates/ndi-io",
9+
"crates/omt-io",
10+
]
11+
# Plain `cargo build`/`cargo test` (no -p) only touches these — the SRT-only
12+
# core, unaffected by whether the NDI SDK or libomt are installed. ndi-io and
13+
# omt-io are still real workspace members (buildable/testable via `-p`, and
14+
# pulled in by crates/router's opt-in `ndi`/`omt` Cargo features) but require
15+
# real, non-crates.io SDKs to build; see README's Transports section.
16+
default-members = [
17+
"crates/core",
18+
"crates/srt-io",
19+
"crates/web",
20+
"crates/router",
821
]
922

1023
[workspace.package]

crates/ndi-io/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "ndi-io"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
7+
[dependencies]
8+
crosspoint-core = { path = "../core" }
9+
grafton-ndi = "1.0"
10+
tokio = { version = "1", features = ["full"] }
11+
bytes = "1"
12+
serde = { version = "1", features = ["derive"] }
13+
thiserror = "2"
14+
tracing = "0.1"
15+
16+
[dev-dependencies]
17+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

crates/ndi-io/src/envelope.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
//! Wire format for one NDI frame (video, audio, or metadata) as an opaque
2+
//! `Bytes` blob, so it can travel through `crosspoint-core`'s payload-agnostic
3+
//! broadcast channel exactly like an SRT relay chunk does. This is what lets
4+
//! `crosspoint-core` stay untouched: it never sees a `VideoFrame`, only bytes.
5+
//!
6+
//! Known limitation: video frames are re-created via `VideoFrame::builder()`
7+
//! from resolution + pixel format, which allocates a default (unpadded) line
8+
//! stride. A source with a non-default stride (uncommon, but the NDI SDK
9+
//! doesn't rule it out) would round-trip with corrupted rows. Not something
10+
//! that could be exercised without such a source; see the crate-level docs.
11+
12+
use bytes::{Buf, BufMut, Bytes, BytesMut};
13+
use grafton_ndi::{
14+
AudioFormat, AudioFrame, AudioLayout, MetadataFrame, PixelFormat, ScanType, VideoFrame,
15+
};
16+
use thiserror::Error;
17+
18+
const KIND_VIDEO: u8 = 0;
19+
const KIND_AUDIO: u8 = 1;
20+
const KIND_METADATA: u8 = 2;
21+
22+
#[derive(Debug, Error)]
23+
pub enum EnvelopeError {
24+
#[error("envelope truncated")]
25+
Truncated,
26+
#[error("unknown frame kind byte {0}")]
27+
UnknownKind(u8),
28+
#[error("unrecognized pixel format {0}")]
29+
BadPixelFormat(u32),
30+
#[error("unrecognized scan type {0}")]
31+
BadScanType(u32),
32+
#[error("invalid utf-8 metadata")]
33+
BadMetadata,
34+
#[error("grafton-ndi rejected the reconstructed frame: {0}")]
35+
Ndi(#[from] grafton_ndi::Error),
36+
}
37+
38+
pub fn encode_video(frame: &VideoFrame) -> Bytes {
39+
let data = frame.data();
40+
let mut buf = BytesMut::with_capacity(1 + 33 + data.len());
41+
buf.put_u8(KIND_VIDEO);
42+
buf.put_i32(frame.width());
43+
buf.put_i32(frame.height());
44+
buf.put_u32(frame.pixel_format().into());
45+
buf.put_i32(frame.frame_rate_n());
46+
buf.put_i32(frame.frame_rate_d());
47+
buf.put_f32(frame.picture_aspect_ratio());
48+
buf.put_u32(frame.scan_type().into());
49+
buf.put_i64(frame.timecode());
50+
buf.put_u32(data.len() as u32);
51+
buf.put_slice(data);
52+
buf.freeze()
53+
}
54+
55+
pub fn encode_audio(frame: &AudioFrame) -> Bytes {
56+
let data = frame.data();
57+
let mut buf = BytesMut::with_capacity(1 + 20 + data.len() * 4);
58+
buf.put_u8(KIND_AUDIO);
59+
buf.put_i32(frame.sample_rate());
60+
buf.put_i32(frame.num_channels());
61+
buf.put_i32(frame.num_samples());
62+
buf.put_i64(frame.timecode());
63+
buf.put_u32(data.len() as u32);
64+
for sample in data {
65+
buf.put_f32(*sample);
66+
}
67+
buf.freeze()
68+
}
69+
70+
pub fn encode_metadata(frame: &MetadataFrame) -> Bytes {
71+
let data = frame.data().as_bytes();
72+
let mut buf = BytesMut::with_capacity(1 + 12 + data.len());
73+
buf.put_u8(KIND_METADATA);
74+
buf.put_i64(frame.timecode());
75+
buf.put_u32(data.len() as u32);
76+
buf.put_slice(data);
77+
buf.freeze()
78+
}
79+
80+
pub enum DecodedFrame {
81+
Video(VideoFrame),
82+
Audio(AudioFrame),
83+
Metadata(MetadataFrame),
84+
}
85+
86+
pub fn decode(mut bytes: Bytes) -> Result<DecodedFrame, EnvelopeError> {
87+
if bytes.is_empty() {
88+
return Err(EnvelopeError::Truncated);
89+
}
90+
match bytes.get_u8() {
91+
KIND_VIDEO => decode_video(bytes).map(DecodedFrame::Video),
92+
KIND_AUDIO => decode_audio(bytes).map(DecodedFrame::Audio),
93+
KIND_METADATA => decode_metadata(bytes).map(DecodedFrame::Metadata),
94+
other => Err(EnvelopeError::UnknownKind(other)),
95+
}
96+
}
97+
98+
fn decode_video(mut bytes: Bytes) -> Result<VideoFrame, EnvelopeError> {
99+
if bytes.remaining() < 33 {
100+
return Err(EnvelopeError::Truncated);
101+
}
102+
let width = bytes.get_i32();
103+
let height = bytes.get_i32();
104+
let pixel_format_raw = bytes.get_u32();
105+
let pixel_format = PixelFormat::try_from(pixel_format_raw)
106+
.map_err(|_| EnvelopeError::BadPixelFormat(pixel_format_raw))?;
107+
let frame_rate_n = bytes.get_i32();
108+
let frame_rate_d = bytes.get_i32();
109+
let aspect_ratio = bytes.get_f32();
110+
let scan_type_raw = bytes.get_u32();
111+
let scan_type =
112+
ScanType::try_from(scan_type_raw).map_err(|_| EnvelopeError::BadScanType(scan_type_raw))?;
113+
let timecode = bytes.get_i64();
114+
let len = bytes.get_u32() as usize;
115+
if bytes.remaining() < len {
116+
return Err(EnvelopeError::Truncated);
117+
}
118+
let data = bytes.split_to(len).to_vec();
119+
120+
let mut frame = VideoFrame::builder()
121+
.resolution(width, height)
122+
.pixel_format(pixel_format)
123+
.frame_rate(frame_rate_n, frame_rate_d)
124+
.aspect_ratio(aspect_ratio)
125+
.scan_type(scan_type)
126+
.timecode(timecode)
127+
.build()?;
128+
frame.replace_data(data)?;
129+
Ok(frame)
130+
}
131+
132+
fn decode_audio(mut bytes: Bytes) -> Result<AudioFrame, EnvelopeError> {
133+
if bytes.remaining() < 20 {
134+
return Err(EnvelopeError::Truncated);
135+
}
136+
let sample_rate = bytes.get_i32();
137+
let channels = bytes.get_i32();
138+
let samples = bytes.get_i32();
139+
let timecode = bytes.get_i64();
140+
let len = bytes.get_u32() as usize;
141+
if bytes.remaining() < len * 4 {
142+
return Err(EnvelopeError::Truncated);
143+
}
144+
let mut data = Vec::with_capacity(len);
145+
for _ in 0..len {
146+
data.push(bytes.get_f32());
147+
}
148+
149+
let frame = AudioFrame::builder()
150+
.sample_rate(sample_rate)
151+
.channels(channels)
152+
.samples(samples)
153+
.timecode(timecode)
154+
.format(AudioFormat::FLTP)
155+
.layout(AudioLayout::Planar)
156+
.data(data)
157+
.build()?;
158+
Ok(frame)
159+
}
160+
161+
fn decode_metadata(mut bytes: Bytes) -> Result<MetadataFrame, EnvelopeError> {
162+
if bytes.remaining() < 12 {
163+
return Err(EnvelopeError::Truncated);
164+
}
165+
let timecode = bytes.get_i64();
166+
let len = bytes.get_u32() as usize;
167+
if bytes.remaining() < len {
168+
return Err(EnvelopeError::Truncated);
169+
}
170+
let data = bytes.split_to(len).to_vec();
171+
let text = String::from_utf8(data).map_err(|_| EnvelopeError::BadMetadata)?;
172+
Ok(MetadataFrame::with_data(text, timecode)?)
173+
}

0 commit comments

Comments
 (0)