diff --git a/README.md b/README.md index b402f60..d237948 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,10 @@ Two jobs: No MPV, no GStreamer, no OBS, no browser, no MQTT, no audio, no recording, no playback controls. One process that a launcher starts and kills. -**Status:** the camera path is complete and verified on real hardware. The NDI -backend is stubbed and reports that it is not implemented yet. +**Status:** both input backends are implemented. The camera path is verified on +the production hardware. The installed NDI 6 runtime and source discovery have +also been verified; live ProPresenter video still needs a sender to be +broadcasting for end-to-end validation. --- @@ -24,7 +26,7 @@ backend is stubbed and reports that it is not implemented yet. suite with no extra machinery, and a long-running unattended process that parses frames off a USB bus benefits from bounds checking. The whole dependency tree is **two crates**: `sdl3-sys` and `libc`. V4L2, TurboJPEG and - (later) NDI are hand-written FFI, so there is no binding crate to rot. + NDI use small, hand-written FFI surfaces, so there is no binding crate to rot. * **SDL3** — the one portable way to get a borderless fullscreen GPU window on a *chosen* Wayland output without writing raw `xdg-shell` code. Used through `sdl3-sys` (raw FFI) rather than a safe wrapper, for direct control over @@ -32,33 +34,52 @@ backend is stubbed and reports that it is not implemented yet. * **libjpeg-turbo (TurboJPEG API)** — SIMD MJPEG decode, and its `tj3DecompressToYUVPlanes8` hands back raw YUV planes so the CPU never does a colour conversion. +* **NDI SDK** — official discovery and receive APIs, loaded only when an NDI + command runs. Audio and metadata output buffers are never requested. ### The pixel path The ATEM emits **baseline 4:2:2 JPEG**. Rather than decode to RGBA (4 bytes per pixel, CPU colour conversion), the planes are interleaved into packed **YUY2** -(2 bytes per pixel) and uploaded to an SDL YUY2 texture, letting the GPU sampler -convert. Half the upload bandwidth and no CPU matrix multiply. +(2 bytes per pixel) and uploaded to an SDL YUY2 texture. SDL's selected renderer +backend performs the YUV-to-output conversion. That avoids a CPU matrix +conversion and halves upload bandwidth versus RGBA. ``` MJPEG → TurboJPEG → Y/U/V planes → interleave to YUY2 → SDL texture → GPU ``` -4:2:0 sources skip even the interleave (planes go straight in as I420). +4:2:0 camera sources skip even the interleave (planes go straight in as I420). Anything else (4:4:4, 4:4:0, 4:1:1, grayscale) falls back to packed BGRX. +For NDI, the receiver requests `NDIlib_recv_color_format_fastest`. On this +platform that preserves the SDK's native packed 4:2:2 path: UYVY without alpha, +or UYVA when a sender advertises alpha. UYVA begins with an ordinary UYVY plane, +so the unused alpha plane is ignored and the same two-byte-per-pixel region is +copied into an SDL UYVY texture. The ATEM keys the green background; alpha is +not needed. + +``` +ProPresenter NDI → libndi fastest UYVY/UYVA → SDL UYVY texture → selected output +``` + +SDL accepts the packed UYVY texture and chooses how to convert it for the active +renderer. The application does not assume that every SDL renderer implements +that conversion in a particular shader or GPU stage. + ### Latency -Two places drop stale data, both on purpose: +Three places prevent stale data from accumulating, all on purpose: 1. **Driver queue** — each wakeup drains every queued V4L2 buffer and decodes only the newest, returning the rest unread. -2. **Frame slot** — a one-deep mailbox between the input thread and the +2. **NDI receive queue** — after a blocking capture wakes, immediately available + video frames are drained and freed; only the newest is copied for display. +3. **Frame slot** — a one-deep mailbox between the input thread and the renderer. Publishing a second frame throws the first away. -A backlog can never form. If decoding falls behind, frames are dropped, not -queued. Latency stays bounded by one capture interval plus one present -interval. +A backlog cannot grow in application memory. If capture, decoding, or display +falls behind, stale frames are dropped instead of replayed later. --- @@ -86,13 +107,24 @@ You must be in the `video` group to open capture devices: groups | grep -q video || sudo usermod -aG video "$USER" # then log out and back in ``` -### NDI (phase 2, not yet wired up) +### NDI runtime -Download the **NDI SDK for Linux** from ndi.video, then either install its -`libndi.so.6` into `/usr/local/lib` and run `sudo ldconfig`, or point -`LD_LIBRARY_PATH` at it. The SDK is not redistributable, so it is not vendored -here. The receiver will load it at runtime via `dlopen`, so the camera path -keeps working on machines that do not have it. +Download the **NDI SDK for Linux** from ndi.video and install its runtime +library on the system loader path. On the production Fedora machine it is: + +``` +/usr/lib64/libndi.so.6 +``` + +For an unpacked runtime elsewhere, set `NDI_RUNTIME_DIR_V6` to the directory +containing `libndi.so.6`. The SDK is not redistributable, so neither its headers +nor runtime are vendored here. + +The executable does not link against libndi. NDI commands load the runtime with +`dlopen`/`dlsym` and resolve only the official find/receive entry points they +use. Camera, display, device, help, and version commands therefore continue to +work when NDI is absent. An NDI command instead exits with a clear NDI-specific +loader error. --- @@ -103,11 +135,11 @@ cargo build --release sudo install -m755 target/release/video-output /usr/local/bin/ ``` -The release binary is about 560 KB. +The release binary remains small and does not contain or bundle the NDI SDK. --- -## Discovering devices and displays +## Discovering inputs and displays ### Capture devices @@ -138,6 +170,38 @@ This is the same information as `v4l2-ctl --list-formats-ext`, without needing > is structured so an uncompressed device would only need a new branch in > `pixel_format_for_fourcc`, not a restructure. +### NDI sources + +Ask the NDI finder to collect advertisements for five seconds: + +```bash +video-output ndi-sources +``` + +The command prints each full, human-readable name exactly as NDI advertises it. +Use that full name for production configuration: + +```text +1 source(s): + [0] PROPRESENTER-PC (ProPresenter Lower Thirds) ndi://... +``` + +Discovery time can be set from 1 to 300 seconds. Repeat `--extra-ip` when a +sender is reachable by unicast but mDNS discovery does not cross the network +boundary: + +```bash +video-output ndi-sources --timeout 10 \ + --extra-ip 10.0.0.42 \ + --extra-ip 10.0.1.42 +``` + +`--source` uses the same deterministic matching policy as displays: an exact +case-insensitive name first, then a unique prefix, then a unique substring. +Ambiguity is an error rather than a guess. Omitting `--source` selects the first +visible source, which is convenient for a bench test but not recommended for a +production profile. + ### Displays ```bash @@ -175,8 +239,10 @@ preferred over indices, which shift when an output is hotplugged. Simplest possible form: ```bash -video-output camera --device /dev/video0 --display BMD -video-output ndi --source "ProPresenter Lower Thirds" --display HDMI +video-output camera --device /dev/video0 --display "Sceptre Tech Inc Sceptre F27" +video-output ndi \ + --source "" \ + --display "HXA BMD HDMI" ``` Full options: `video-output --help`. @@ -188,7 +254,9 @@ So the launcher only needs a stable word: ```bash sudo mkdir -p /etc/video-output sudo cp examples/multiview.toml /etc/video-output/ +sudo cp examples/lower-thirds.toml /etc/video-output/ video-output multiview +video-output lower-thirds ``` A profile is a flat `key = value` file using the same names as the long @@ -216,7 +284,9 @@ It is an ordinary foreground process: * start it, and it opens fullscreen on the chosen output * `SIGTERM` (or `SIGINT`/`SIGHUP`) makes it exit cleanly -* it never exits on its own because of a missing or broken source +* a missing or broken media source never closes the output window +* fatal configuration, display-initialisation, or NDI-runtime errors return a + nonzero status instead of retrying something that cannot recover ```bash video-output multiview & @@ -254,20 +324,26 @@ silently wrong monitor. | Situation | What happens | | --- | --- | -| Device missing at startup | one clear error, then quiet retries with backoff (250 ms → 5 s); the window stays up and black | +| Camera missing at startup | one clear error, then quiet retries with backoff (250 ms → 5 s); the window stays up and black | | Device busy | says so explicitly, keeps retrying | | Permission denied | tells you to join the `video` group | -| Device unplugged while running | `signal lost` logged once, black output, reopens automatically when it returns | -| Malformed frame | dropped, counted, stream continues | +| Camera unplugged while running | `signal lost` logged once, black output, reopens automatically when it returns | +| Named NDI source absent at startup | logs the search condition once, stays black, and keeps discovering in the background | +| NDI source appears | creates the receiver automatically and begins displaying valid video | +| NDI source disappears | marks signal lost, clears to black, rebuilds discovery/receive state, and reconnects when it returns | +| Malformed or unsupported frame | dropped and counted; the receive process continues and stale output is not left on screen indefinitely | +| NDI runtime missing or incompatible | the NDI command exits nonzero with the attempted runtime path/symbol in its error; non-NDI commands are unaffected | | Fatal decoder error | the JPEG handle is rebuilt so one bad frame cannot poison the ones after it | | Backend panics | caught, logged, backend restarted; the display never goes down with it | | No valid frame | black | -Logging is edge-triggered — a camera unplugged for twenty minutes produces one -`signal lost` line, not one per retry — plus one throughput line per minute -(`--stats `, `0` disables). Set `VIDEO_OUTPUT_LOG=debug` or -`--log debug` for per-frame diagnostics. Under systemd, journald severity -prefixes are emitted automatically so `journalctl -p warning` filters correctly. +Logging is edge-triggered — an input absent for twenty minutes produces a state +transition, not a line every frame or discovery poll — plus one throughput line +per minute (`--stats `, `0` disables). Set `VIDEO_OUTPUT_LOG=debug` or +`--log debug` for additional discovery and recovery diagnostics. Frame problems +are counted and reported periodically instead of logged individually. Under +systemd, journald severity prefixes are emitted automatically so +`journalctl -p warning` filters correctly. --- @@ -277,23 +353,28 @@ prefixes are emitted automatically so `journalctl -p warning` filters correctly. cargo test ``` -87 tests covering CLI and profile parsing, invalid configuration, display -selection (including the ambiguity rules), the frame slot's drop-stale -guarantee, backoff and signal-transition state, the V4L2 struct ABI against the -kernel headers, and real JPEG decoding of all four subsampling paths — plus -truncation and bit-flip fuzzing to prove a corrupt frame cannot panic or wedge -the decoder. +The hardware-independent suite covers CLI and profile parsing, invalid +configuration, deterministic display and NDI-source matching, reconnect policy, +NDI and V4L2 ABI layouts, malformed NDI layouts, both stale-frame drop points, +and the one-frame handoff. Process tests force an isolated missing NDI runtime +and verify both the clear NDI error and that a non-NDI command still succeeds; +on Linux, `readelf` also verifies that the executable has no `libndi` dynamic +dependency. + +Camera coverage remains intact: real JPEG fixtures exercise all four +subsampling paths, with truncation and bit-flip fuzzing to prove that corrupt +input cannot panic or wedge the decoder. Regenerate the JPEG fixtures with `python3 tests/data/generate.py` (needs Pillow). They are solid green on purpose: green is what the ATEM keys on, so a wrong YUV matrix or range shows up in a test rather than on stage. -### Manual validation on Fedora +### Manual camera validation on Fedora Hardware paths that cannot be unit tested: ```bash -# 1. Devices and displays are discovered +# 1. Devices and displays are discovered. video-output devices video-output displays @@ -308,7 +389,7 @@ video-output camera --device /dev/video0 --display --stats 5 # 4. Recovery: unplug the ATEM's USB cable while it runs. # Expect "signal lost", black output, then "signal restored" on replug, # with no restart and no repeated log lines -# 5. Clean shutdown +# 5. Clean shutdown. kill -TERM # exits promptly, no error ``` @@ -317,6 +398,45 @@ USB, 1920x1080 MJPEG @ 29.97 — sustained 30.0 fps decoded, 0 undecodable frames, 0 stale buffers, 0 frames dropped before display, ~33% of one CPU core and ~98 MB RSS. +### Manual NDI validation on Fedora + +First verify that the runtime loads and record the exact advertised name: + +```bash +video-output ndi-sources --timeout 10 +``` + +If the sender requires unicast discovery: + +```bash +video-output ndi-sources --timeout 10 --extra-ip +``` + +Then run the exact name on the production lower-thirds output: + +```bash +video-output ndi \ + --source "" \ + --display "HXA BMD HDMI" \ + --stats 5 +``` + +Confirm all of the following: + +1. The log names `HXA BMD HDMI`, the selected NDI source, and packed UYVY. +2. ProPresenter graphics appear fullscreen with the correct green background + and no controls or OSD. +3. Stopping the ProPresenter NDI output logs signal loss once and turns the + display black rather than freezing the last lower third. +4. Restarting the same output restores video without restarting + `video-output`. +5. Leaving the source unavailable at startup keeps the black window alive and + connects automatically when broadcasting begins. + +At the last validation pass, the installed NDI 6.3.2 runtime loaded correctly +but no source was advertising. Discovery/retry and the live-video path should be +rechecked with ProPresenter broadcasting. + --- ## Troubleshooting diff --git a/examples/lower-thirds.toml b/examples/lower-thirds.toml index 7b26f3c..1bd7613 100644 --- a/examples/lower-thirds.toml +++ b/examples/lower-thirds.toml @@ -9,7 +9,7 @@ mode = "ndi" # `video-output ndi-sources`. Omit entirely to take the first source found. source = "ProPresenter" -display = "HDMI" +display = "HXA BMD HDMI" # Uncomment on networks where mDNS discovery does not cross subnets: # extra-ip = "10.0.0.42" diff --git a/src/cli.rs b/src/cli.rs index 498b734..ed2b9d3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -80,7 +80,11 @@ pub enum Command { /// List V4L2 capture devices and their formats, then exit. ListDevices, /// List visible NDI sources and exit. - ListNdiSources { timeout_secs: u64 }, + ListNdiSources { + timeout_secs: u64, + /// Extra unicast discovery hints for networks without mDNS. + extra_ips: Vec, + }, Help, Version, } @@ -109,7 +113,7 @@ USAGE: video-output run a profile from the search path video-output displays list connected outputs video-output devices list V4L2 capture devices and formats - video-output ndi-sources [--timeout S] list visible NDI sources + video-output ndi-sources [options] list visible NDI sources COMMON OPTIONS: --display output to use: connector name (HDMI-A-2), @@ -131,6 +135,10 @@ ndi OPTIONS: Default: connect to the first source found. --extra-ip unicast discovery hint, repeatable +ndi-sources OPTIONS: + --timeout discovery time, 1..=300. Default: 5 + --extra-ip unicast discovery hint, repeatable + PROFILES: A profile is a small TOML-subset file of `key = value` lines using the same names as the long options, plus `mode = \"camera\"` or `mode = \"ndi\"`. @@ -162,6 +170,7 @@ pub fn parse_args_with_loader( "devices" => return Ok(Command::ListDevices), "ndi-sources" => { let mut timeout_secs = 5; + let mut extra_ips = Vec::new(); let mut i = 1; while i < args.len() { match args[i].as_str() { @@ -170,12 +179,18 @@ pub fn parse_args_with_loader( timeout_secs = v .parse::() .map_err(|_| ParseError(format!("--timeout expects seconds, got {v:?}")))?; + if !(1..=300).contains(&timeout_secs) { + return err(format!("--timeout must be between 1 and 300 seconds, got {v:?}")); + } + } + "--extra-ip" => { + extra_ips.push(next_value(args, &mut i, "--extra-ip")?.to_string()); } other => return err(format!("unknown option {other:?} for `ndi-sources`")), } i += 1; } - return Ok(Command::ListNdiSources { timeout_secs }); + return Ok(Command::ListNdiSources { timeout_secs, extra_ips }); } _ => {} } diff --git a/src/frame.rs b/src/frame.rs index 13ad24a..b109114 100644 --- a/src/frame.rs +++ b/src/frame.rs @@ -92,12 +92,16 @@ pub struct PlaneLayout { /// key colour enough to matter. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Colorspace { + /// Standard sRGB. Used for frames that have already been converted to RGB. + Srgb, /// BT.601 primaries, full range. What JFIF/MJPEG encodes. Jpeg, /// BT.601 primaries, studio range. NDI sources at SD resolutions. Bt601Limited, /// BT.709 primaries, studio range. NDI sources at HD resolutions. Bt709Limited, + /// BT.2020 primaries, studio range. NDI sources at UHD resolutions. + Bt2020Limited, } pub struct Frame { diff --git a/src/inputs/mjpeg.rs b/src/inputs/mjpeg.rs index f7bf025..f94b05a 100644 --- a/src/inputs/mjpeg.rs +++ b/src/inputs/mjpeg.rs @@ -348,9 +348,7 @@ impl MjpegDecoder { width: info.width, height: info.height, format, - // JFIF is BT.601 primaries at full range regardless of subsampling. - // Grayscale still rides the BGRX path, where the matrix is unused. - colorspace: Colorspace::Jpeg, + colorspace: output_colorspace(format), layout, data: buf, seq, @@ -461,6 +459,16 @@ impl MjpegDecoder { } } +/// SDL needs the colorspace of the pixels it receives, not the JPEG source. +/// Planar and packed YUV remain full-range JFIF, while TurboJPEG's BGRX +/// fallback has already been converted to standard RGB. +fn output_colorspace(format: PixelFormat) -> Colorspace { + match format { + PixelFormat::Bgrx => Colorspace::Srgb, + PixelFormat::Yuy2 | PixelFormat::Uyvy | PixelFormat::I420 => Colorspace::Jpeg, + } +} + impl Drop for MjpegDecoder { fn drop(&mut self) { if !self.handle.is_null() { @@ -530,6 +538,13 @@ mod tests { ); } + #[test] + fn decoded_rgb_is_srgb_while_yuv_keeps_jpeg_colorspace() { + assert_eq!(output_colorspace(PixelFormat::Bgrx), Colorspace::Srgb); + assert_eq!(output_colorspace(PixelFormat::Yuy2), Colorspace::Jpeg); + assert_eq!(output_colorspace(PixelFormat::I420), Colorspace::Jpeg); + } + #[test] fn subsamp_and_colorspace_names_are_human_readable() { let i = JpegInfo { width: 2, height: 2, subsamp: TJSAMP_422, colorspace: TJCS_YCBCR }; diff --git a/src/inputs/mod.rs b/src/inputs/mod.rs index d96ac64..5154403 100644 --- a/src/inputs/mod.rs +++ b/src/inputs/mod.rs @@ -16,6 +16,7 @@ pub mod mjpeg; pub mod ndi; +pub mod ndi_sys; pub mod v4l2; pub mod v4l2_sys; diff --git a/src/inputs/ndi.rs b/src/inputs/ndi.rs index 1587a56..93debe0 100644 --- a/src/inputs/ndi.rs +++ b/src/inputs/ndi.rs @@ -1,19 +1,1128 @@ -//! NDI receiver. Implemented in phase 2. +//! NDI receiver. +//! +//! ```text +//! ProPresenter --NDI--> discovery --> receiver --> UYVY --> FrameSlot --> SDL/GPU +//! ``` +//! +//! The library is loaded with `dlopen` (see [`super::ndi_sys`]), so a machine +//! without the NDI SDK still runs the camera backend normally. +//! +//! The receiver requests NDI's `fastest` colour mode. On this Linux host that +//! yields packed 4:2:2 UYVY, or UYVA when the sender advertises alpha. UYVA's +//! leading plane is ordinary UYVY, so the unused alpha plane is skipped and the +//! colour data goes straight to SDL's UYVY texture interface. +use super::ndi_sys::{self as sys, NdiLib}; use super::InputCtx; +use crate::frame::{Colorspace, Frame, PixelFormat}; +use crate::{debug, error, info, log, warn_}; +use std::ffi::{c_void, CString}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +/// How long a single `capture` call blocks. Short enough that shutdown is +/// prompt, long enough not to spin. +const CAPTURE_TIMEOUT_MS: u32 = 100; + +/// How long each discovery attempt waits for the source list to settle. +const DISCOVERY_WAIT_MS: u32 = 1000; + +/// No frames for this long and the output goes black. +const STALL_TIMEOUT: Duration = Duration::from_millis(2000); + +/// No frames for this long and the receiver is torn down and rebuilt. +/// +/// Deliberately much longer than [`STALL_TIMEOUT`]: the NDI receiver reconnects +/// to a returning source by itself, so rebuilding early would throw away a +/// connection that was about to recover. Going black is cheap and instant; +/// reconnecting is neither. +const REBUILD_TIMEOUT: Duration = Duration::from_secs(10); + +/// Safety valve for a corrupt runtime/frame header. This is comfortably above +/// an 8K UYVY frame while preventing a bogus dimension from trying to allocate +/// gigabytes or construct an implausibly large slice. +const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024; + +/// The SDK queue is intentionally short. A cap prevents a broken runtime from +/// making the zero-timeout drain loop unbounded; if it is ever reached the +/// current candidate is dropped and the next pass keeps draining. +const MAX_DRAIN_EVENTS: usize = 64; #[derive(Clone, Debug)] pub struct NdiConfig { + /// Source name, or a unique fragment. `None` takes the first source found. pub source: Option, pub extra_ips: Vec, - pub stats_interval: std::time::Duration, + pub stats_interval: Duration, } -pub fn run(_ctx: &InputCtx, _cfg: &NdiConfig) { - crate::error!("the NDI backend is not implemented yet"); +// --------------------------------------------------------------------------- +// Source selection (pure) +// --------------------------------------------------------------------------- + +/// A source as reported by discovery, copied into owned memory. +/// +/// The SDK's own `NDIlib_source_t` array is owned by the find instance and is +/// invalidated by the next discovery call, so nothing may hold on to it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DiscoveredSource { + pub name: String, + pub url: String, } -pub fn list_sources(_timeout_secs: u64) -> i32 { - crate::error!("the NDI backend is not implemented yet"); - 1 +#[derive(Debug, PartialEq, Eq)] +pub enum SourceSelectError { + NoneVisible, + NoMatch { asked: String, available: Vec }, + Ambiguous { asked: String, matched: Vec }, +} + +impl std::fmt::Display for SourceSelectError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SourceSelectError::NoneVisible => write!(f, "no NDI sources are visible on the network"), + SourceSelectError::NoMatch { asked, available } => write!( + f, + "no NDI source matches {asked:?}; visible sources are: {}", + available.join(", ") + ), + SourceSelectError::Ambiguous { asked, matched } => write!( + f, + "NDI source {asked:?} is ambiguous, it matches: {}", + matched.join(", ") + ), + } + } +} + +/// Picks a source by name, using the same three-tier rule as display +/// selection: exact, then unique prefix, then unique substring, with ambiguity +/// reported rather than guessed. +/// +/// `None` means "first source found", which is only sensible when exactly one +/// sender exists; it is the documented default but not what a service should +/// rely on. +pub fn select_source<'a>( + sources: &'a [DiscoveredSource], + want: Option<&str>, +) -> Result<&'a DiscoveredSource, SourceSelectError> { + if sources.is_empty() { + return Err(SourceSelectError::NoneVisible); + } + let Some(want) = want else { + return Ok(&sources[0]); + }; + + let exact: Vec<&DiscoveredSource> = sources + .iter() + .filter(|s| s.name.eq_ignore_ascii_case(want)) + .collect(); + if exact.len() == 1 { + return Ok(exact[0]); + } + if exact.len() > 1 { + return Err(SourceSelectError::Ambiguous { + asked: want.to_string(), + matched: exact.iter().map(|s| s.name.clone()).collect(), + }); + } + + let lower = want.to_ascii_lowercase(); + for pass in 0..2 { + let hits: Vec<&DiscoveredSource> = sources + .iter() + .filter(|s| { + let n = s.name.to_ascii_lowercase(); + if pass == 0 { + n.starts_with(&lower) + } else { + n.contains(&lower) + } + }) + .collect(); + match hits.len() { + 0 => continue, + 1 => return Ok(hits[0]), + _ => { + return Err(SourceSelectError::Ambiguous { + asked: want.to_string(), + matched: hits.iter().map(|s| s.name.clone()).collect(), + }) + } + } + } + + Err(SourceSelectError::NoMatch { + asked: want.to_string(), + available: sources.iter().map(|s| s.name.clone()).collect(), + }) +} + +// --------------------------------------------------------------------------- +// Link policy (pure) +// --------------------------------------------------------------------------- + +/// What the receive loop should do given how long it has been since a frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LinkAction { + /// Frames are flowing, or the gap is still within tolerance. + Continue, + /// Long enough to blank the output, but keep the receiver -- NDI may + /// reconnect on its own. + MarkLost(&'static str), + /// Long enough that the receiver is not coming back; rebuild it. + Rebuild(&'static str), +} + +#[derive(Clone, Copy, Debug)] +pub struct LinkPolicy { + pub stall: Duration, + pub rebuild: Duration, +} + +impl Default for LinkPolicy { + fn default() -> Self { + LinkPolicy { stall: STALL_TIMEOUT, rebuild: REBUILD_TIMEOUT } + } +} + +impl LinkPolicy { + /// `connections` is `NDIlib_recv_get_no_connections`: zero means the + /// receiver currently has no sender attached. + pub fn evaluate(&self, since_frame: Duration, connections: i32) -> LinkAction { + if since_frame >= self.rebuild { + return LinkAction::Rebuild("no frames for too long"); + } + // A dropped connection is unambiguous, so blank immediately. Keep the + // receiver, though: libndi reconnects it in place if the sender comes + // back, which is faster than rediscovery and a fresh receiver. + if connections <= 0 { + return LinkAction::MarkLost("sender disconnected"); + } + if since_frame >= self.stall { + return LinkAction::MarkLost("no frames from the sender"); + } + LinkAction::Continue + } +} + +// --------------------------------------------------------------------------- +// Reconnect lifecycle (pure) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReconnectPhase { + Searching, + Connecting, + Active, + Recovering, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReconnectEvent { + SourceUnavailable, + SourceFound, + ValidFrame, + SignalLost, +} + +/// Small state machine used for edge-triggered connection logging and tested +/// independently of libndi or network hardware. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReconnectState { + phase: ReconnectPhase, + ever_active: bool, +} + +impl Default for ReconnectState { + fn default() -> Self { + Self { phase: ReconnectPhase::Searching, ever_active: false } + } +} + +impl ReconnectState { + pub fn phase(&self) -> ReconnectPhase { + self.phase + } + + /// Applies an event and reports whether the visible phase changed. + pub fn advance(&mut self, event: ReconnectEvent) -> bool { + let previous = self.phase; + self.phase = match event { + ReconnectEvent::SourceUnavailable => { + if self.ever_active { + ReconnectPhase::Recovering + } else { + ReconnectPhase::Searching + } + } + ReconnectEvent::SourceFound => ReconnectPhase::Connecting, + ReconnectEvent::ValidFrame => { + self.ever_active = true; + ReconnectPhase::Active + } + ReconnectEvent::SignalLost => { + if self.ever_active { + ReconnectPhase::Recovering + } else { + ReconnectPhase::Connecting + } + } + }; + previous != self.phase + } +} + +// --------------------------------------------------------------------------- +// Frame conversion (pure) +// --------------------------------------------------------------------------- + +/// How an NDI FourCC maps onto something the renderer can upload. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FrameLayout { + pub format: PixelFormat, + /// Bytes per pixel of the region we actually copy. + pub bytes_per_pixel: usize, +} + +/// Maps an NDI FourCC to a renderable layout. +/// +/// Only the formats `UYVY_BGRA` can actually deliver are handled. `UYVA` is +/// included defensively: it is UYVY followed by a separate alpha plane, so the +/// leading UYVY region is used and the alpha ignored -- correct here, since the +/// ATEM keys on green rather than on alpha. +pub fn layout_for_fourcc(four_cc: i32) -> Option { + match four_cc { + sys::NDILIB_FOURCC_UYVY | sys::NDILIB_FOURCC_UYVA => Some(FrameLayout { + format: PixelFormat::Uyvy, + bytes_per_pixel: 2, + }), + // SDL's XRGB8888 is byte order B,G,R,X on little-endian, matching both + // of these. Any alpha byte is ignored. + sys::NDILIB_FOURCC_BGRA | sys::NDILIB_FOURCC_BGRX => Some(FrameLayout { + format: PixelFormat::Bgrx, + bytes_per_pixel: 4, + }), + _ => None, + } +} + +/// NDI uses BT.601 for SD, BT.709 for HD, and BT.2020 for UHD, all studio +/// range. These thresholds are the ones documented by the NDI SDK. +/// +/// This is not cosmetic: the lower third is chroma keyed by the ATEM, and the +/// wrong matrix moves the green. +pub fn colorspace_for_dimensions(width: i32, height: i32) -> Colorspace { + if width > 1920 || height > 1080 { + Colorspace::Bt2020Limited + } else if width > 720 || height > 576 { + Colorspace::Bt709Limited + } else { + Colorspace::Bt601Limited + } +} + +/// Copies `rows` rows honouring a source stride that may exceed the row's +/// meaningful bytes. +/// +/// NDI pads rows, so assuming tight packing skews the picture progressively +/// down the frame. +pub fn copy_strided(src: &[u8], src_stride: usize, dst: &mut [u8], dst_pitch: usize, rows: usize) { + let n = dst_pitch.min(src_stride); + for row in 0..rows { + let s = row * src_stride; + let d = row * dst_pitch; + dst[d..d + n].copy_from_slice(&src[s..s + n]); + } +} + +// --------------------------------------------------------------------------- +// RAII wrappers +// --------------------------------------------------------------------------- + +struct Finder { + lib: &'static NdiLib, + handle: sys::NDIlib_find_instance_t, + /// Keeps the extra-IP string alive for as long as the finder needs it. + _extra_ips: Option, +} + +impl Finder { + fn new(lib: &'static NdiLib, extra_ips: &[String]) -> Result { + // The SDK takes a single comma-separated list. + let extra = if extra_ips.is_empty() { + None + } else { + Some(CString::new(extra_ips.join(",")).map_err(|_| "an NDI extra IP contains a NUL byte")?) + }; + let create = sys::NDIlib_find_create_t { + show_local_sources: true, + p_groups: std::ptr::null(), + p_extra_ips: extra.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()), + }; + // SAFETY: `create` outlives the call and its string pointers are live. + let handle = unsafe { (lib.find_create_v2)(&create) }; + if handle.is_null() { + return Err("NDIlib_find_create_v2 failed".into()); + } + Ok(Finder { lib, handle, _extra_ips: extra }) + } + + /// Waits briefly, then snapshots the current source list into owned memory. + /// + /// `NDIlib_find_wait_for_sources` returns whether the list *changed*, not + /// whether any sources exist, so its result is deliberately ignored: the + /// list is read either way. + fn snapshot(&self, wait_ms: u32) -> Vec { + // SAFETY: handle is live for the lifetime of `self`. + unsafe { (self.lib.find_wait_for_sources)(self.handle, wait_ms) }; + + let mut count: u32 = 0; + // SAFETY: handle is live; the SDK writes the count and returns an array + // it owns, valid until the next discovery call on this instance. + let arr = unsafe { (self.lib.find_get_current_sources)(self.handle, &mut count) }; + if arr.is_null() { + return Vec::new(); + } + + let mut out = Vec::with_capacity(count as usize); + for i in 0..count as usize { + // SAFETY: `arr` holds `count` entries; strings are copied + // immediately because the SDK reuses this storage. + let s = unsafe { &*arr.add(i) }; + let Some(name) = sys::owned_string(s.p_ndi_name) else { + continue; + }; + out.push(DiscoveredSource { + name, + url: sys::owned_string(s.p_url_address).unwrap_or_default(), + }); + } + out + } +} + +impl Drop for Finder { + fn drop(&mut self) { + // SAFETY: handle came from find_create_v2 and is destroyed once. + unsafe { (self.lib.find_destroy)(self.handle) }; + } +} + +struct Receiver { + lib: &'static NdiLib, + handle: sys::NDIlib_recv_instance_t, +} + +impl Receiver { + fn new(lib: &'static NdiLib, target: &DiscoveredSource) -> Result { + let name = CString::new(target.name.as_str()).map_err(|_| "source name contains NUL")?; + let url = CString::new(target.url.as_str()).map_err(|_| "source URL contains NUL")?; + let recv_name = CString::new("video-output").unwrap(); + + let create = sys::NDIlib_recv_create_v3_t { + source_to_connect_to: sys::NDIlib_source_t { + p_ndi_name: name.as_ptr(), + p_url_address: if target.url.is_empty() { + std::ptr::null() + } else { + url.as_ptr() + }, + }, + color_format: sys::NDILIB_RECV_COLOR_FORMAT_FASTEST, + bandwidth: sys::NDILIB_RECV_BANDWIDTH_HIGHEST, + // Fastest mode implicitly enables fields. ProPresenter is + // progressive; defensive validation below rejects field frames + // rather than displaying a half-height image incorrectly. + allow_video_fields: true, + p_ndi_recv_name: recv_name.as_ptr(), + }; + + // SAFETY: every string stays alive across this call, which is all the + // SDK requires -- it copies what it needs into the receiver. + let handle = unsafe { (lib.recv_create_v3)(&create) }; + if handle.is_null() { + return Err("NDIlib_recv_create_v3 failed".into()); + } + Ok(Receiver { lib, handle }) + } + + fn connections(&self) -> i32 { + // SAFETY: handle is live for the lifetime of `self`. + unsafe { (self.lib.recv_get_no_connections)(self.handle) } + } + + fn queued_video(&self) -> usize { + let mut queue = sys::NDIlib_recv_queue_t::default(); + // SAFETY: handle is live and `queue` is a valid out-parameter. + unsafe { (self.lib.recv_get_queue)(self.handle, &mut queue) }; + queue.video_frames.max(0) as usize + } + + fn capture(&self, timeout_ms: u32) -> CaptureEvent { + let mut frame = sys::NDIlib_video_frame_v2_t::default(); + // SAFETY: `frame` is a valid out-parameter. NULL audio and metadata + // pointers tell libndi not to deliver either type to this application. + let kind = unsafe { + (self.lib.recv_capture_v3)( + self.handle, + &mut frame, + std::ptr::null_mut::(), + std::ptr::null_mut::(), + timeout_ms, + ) + }; + match kind { + sys::NDILIB_FRAME_TYPE_VIDEO => CaptureEvent::Video(CapturedVideo { + lib: self.lib, + recv: self.handle, + frame, + }), + sys::NDILIB_FRAME_TYPE_NONE => CaptureEvent::None, + sys::NDILIB_FRAME_TYPE_ERROR => CaptureEvent::Error, + sys::NDILIB_FRAME_TYPE_STATUS_CHANGE => CaptureEvent::StatusChange, + sys::NDILIB_FRAME_TYPE_SOURCE_CHANGE => CaptureEvent::SourceChange, + other => CaptureEvent::Ignored(other), + } + } +} + +impl Drop for Receiver { + fn drop(&mut self) { + // SAFETY: handle came from recv_create_v3 and is destroyed once. + unsafe { (self.lib.recv_destroy)(self.handle) }; + } +} + +/// Owns one SDK frame and returns it exactly once, including when it is replaced +/// by a newer queued frame or an error path returns early. +struct CapturedVideo { + lib: &'static NdiLib, + recv: sys::NDIlib_recv_instance_t, + frame: sys::NDIlib_video_frame_v2_t, +} + +impl Drop for CapturedVideo { + fn drop(&mut self) { + // SAFETY: the frame was produced by recv_capture_v3 on this receiver + // and is released once. + unsafe { (self.lib.recv_free_video_v2)(self.recv, &self.frame) }; + } +} + +enum CaptureEvent { + Video(CapturedVideo), + None, + Error, + StatusChange, + SourceChange, + Ignored(i32), +} + +/// Generic newest-value accumulator. Replacing `value` drops the old value, +/// which is exactly what an SDK frame needs in order to be freed promptly. +struct Newest { + value: T, + skipped: u64, +} + +impl Newest { + fn new(value: T) -> Self { + Self { value, skipped: 0 } + } + + fn replace(&mut self, value: T) { + self.value = value; + self.skipped = self.skipped.saturating_add(1); + } +} + +enum DrainOutcome { + Frame(Newest), + Error, + SourceChange, + StillBacklogged(u64), +} + +/// Discards SDK-queued video without copying it, retaining only the newest +/// frame available at this instant. This is the NDI-side half of the +/// drop-not-queue latency policy; `FrameSlot` handles the renderer side. +fn drain_to_newest(recv: &Receiver, first: CapturedVideo) -> DrainOutcome { + let mut newest = Newest::new(first); + for _ in 0..MAX_DRAIN_EVENTS { + if recv.queued_video() == 0 { + return DrainOutcome::Frame(newest); + } + match recv.capture(0) { + CaptureEvent::Video(frame) => newest.replace(frame), + CaptureEvent::None => return DrainOutcome::Frame(newest), + CaptureEvent::Error => return DrainOutcome::Error, + CaptureEvent::SourceChange => return DrainOutcome::SourceChange, + CaptureEvent::StatusChange | CaptureEvent::Ignored(_) => {} + } + } + + if recv.queued_video() == 0 { + DrainOutcome::Frame(newest) + } else { + // Dropping `newest` frees its current SDK buffer. The outer loop then + // immediately continues draining instead of publishing stale video. + DrainOutcome::StillBacklogged(newest.skipped.saturating_add(1)) + } +} + +// --------------------------------------------------------------------------- +// Runtime bring-up +// --------------------------------------------------------------------------- + +/// Loads and initialises the NDI runtime. Idempotent at the library level. +fn bring_up() -> Result<&'static NdiLib, String> { + let lib = sys::lib().map_err(|e| e.to_string())?; + + // SAFETY: plain C calls with no arguments. + unsafe { + if !(lib.is_supported_cpu)() { + return Err("this CPU does not meet the NDI runtime's requirements".into()); + } + if !(lib.initialize)() { + return Err("NDIlib_initialize failed".into()); + } + let v = sys::owned_string((lib.version)()).unwrap_or_else(|| "unknown".into()); + info!("NDI runtime: {v}"); + } + Ok(lib) +} + +// --------------------------------------------------------------------------- +// Supervisor +// --------------------------------------------------------------------------- + +pub fn run(ctx: &InputCtx, cfg: &NdiConfig) { + match &cfg.source { + Some(s) => info!("Starting NDI input, looking for source {s:?}"), + None => info!("Starting NDI input, will use the first source found"), + } + if !cfg.extra_ips.is_empty() { + info!("NDI unicast discovery hints: {}", cfg.extra_ips.join(", ")); + } + + let lib = match bring_up() { + Ok(l) => l, + Err(e) => { + error!("{e}"); + return; + } + }; + + let finder = match Finder::new(lib, &cfg.extra_ips) { + Ok(f) => f, + Err(e) => { + error!("NDI discovery could not start: {e}"); + return; + } + }; + + let mut backoff = super::Backoff::standard(); + let mut last_complaint: Option = None; + let mut last_connected: Option = None; + + while !ctx.should_stop() { + let sources = finder.snapshot(DISCOVERY_WAIT_MS); + if ctx.should_stop() { + break; + } + + match select_source(&sources, cfg.source.as_deref()) { + Ok(target) => { + let target = target.clone(); + match Receiver::new(lib, &target) { + Ok(recv) => { + backoff.reset(); + last_complaint = None; + if last_connected.as_deref() != Some(target.name.as_str()) { + info!("Connected to NDI source {:?} at {}", target.name, target.url); + last_connected = Some(target.name.clone()); + } + receive_loop(ctx, lib, &recv, cfg); + } + Err(e) => { + ctx.signal.set_lost(&e); + complain_once(&mut last_complaint, &format!("could not open NDI receiver: {e}")); + } + } + } + Err(e) => { + ctx.signal.set_lost(&e.to_string()); + complain_once(&mut last_complaint, &e.to_string()); + } + } + + if ctx.should_stop() { + break; + } + let delay = backoff.next_delay(); + ctx.sleep_interruptible(delay); + } + + info!("NDI input stopped"); +} + +/// Logs a problem the first time it is seen, then stays quiet until it changes. +/// +/// Without this, a ProPresenter machine that is off for an hour would emit a +/// discovery failure every second. +fn complain_once(last: &mut Option, msg: &str) { + if last.as_deref() == Some(msg) { + debug!("{msg}"); + return; + } + warn_!("{msg}"); + if last.is_none() { + info!("retrying in the background until it appears"); + } + *last = Some(msg.to_string()); +} + +/// Receives from one connected source until it stalls, errors, or shutdown. +fn receive_loop(ctx: &InputCtx, lib: &'static NdiLib, recv: &Receiver, cfg: &NdiConfig) { + let policy = LinkPolicy::default(); + let mut seq: u64 = 0; + let mut delivered: u64 = 0; + let mut rejected: u64 = 0; + let mut last_frame_at = Instant::now(); + let mut stats = log::Throttle::new(cfg.stats_interval); + let mut logged_layout = false; + + while !ctx.should_stop() { + let mut video = sys::NDIlib_video_frame_v2_t::default(); + // SAFETY: `video` is a valid out-parameter. NULL for audio and metadata + // tells the SDK not to deliver them at all, which is how this program + // guarantees it never touches audio. + let kind = unsafe { + (lib.recv_capture_v3)( + recv.handle, + &mut video, + std::ptr::null_mut::(), + std::ptr::null_mut::(), + CAPTURE_TIMEOUT_MS, + ) + }; + + match kind { + sys::NDILIB_FRAME_TYPE_VIDEO => { + let _guard = VideoFrameGuard { lib, recv: recv.handle, frame: &video }; + last_frame_at = Instant::now(); + seq += 1; + match build_frame(&video, ctx, seq) { + Ok(frame) => { + if !logged_layout { + info!( + "Receiving {}x{} {} @ {:.2} fps -> {}", + video.xres, + video.yres, + sys::fourcc_str(video.four_cc), + frame_rate(&video), + describe(frame.format, frame.colorspace) + ); + logged_layout = true; + } + delivered += 1; + ctx.signal.set_active(); + ctx.slot.publish(frame); + } + Err(e) => { + rejected += 1; + // Unsupported layouts would otherwise log per frame. + if rejected == 1 { + warn_!("{e}"); + } else { + debug!("{e}"); + } + } + } + } + + sys::NDILIB_FRAME_TYPE_NONE => match policy.evaluate(last_frame_at.elapsed(), recv.connections()) { + LinkAction::Continue => {} + LinkAction::MarkLost(why) => { + ctx.signal.set_lost(why); + } + LinkAction::Rebuild(why) => { + ctx.signal.set_lost(why); + debug!("rebuilding the NDI receiver: {why}"); + return; + } + }, + + sys::NDILIB_FRAME_TYPE_ERROR => { + ctx.signal.set_lost("receiver reported an error"); + debug!("NDI capture returned an error; rebuilding the receiver"); + return; + } + + sys::NDILIB_FRAME_TYPE_SOURCE_CHANGE => { + info!("NDI source changed; rediscovering"); + return; + } + + sys::NDILIB_FRAME_TYPE_STATUS_CHANGE => { + debug!("NDI status change (connections: {})", recv.connections()); + } + + // Audio and metadata are never requested, so these should not + // occur; if they did there would be nothing to free. + other => debug!("ignoring NDI frame type {other}"), + } + + if let Some(elapsed) = stats.tick(Instant::now()) { + let secs = elapsed.as_secs_f64().max(0.001); + let slot = ctx.slot.stats(); + info!( + "ndi: {:.1} fps received ({delivered} frames), {rejected} unsupported, \ + {} dropped before display", + delivered as f64 / secs, + slot.dropped + ); + delivered = 0; + rejected = 0; + } + } +} + +fn frame_rate(v: &sys::NDIlib_video_frame_v2_t) -> f64 { + if v.frame_rate_d == 0 { + 0.0 + } else { + v.frame_rate_n as f64 / v.frame_rate_d as f64 + } +} + +/// Copies one NDI video frame into a pooled [`Frame`]. +/// +/// The copy is unavoidable: the SDK's buffer must be handed back promptly, and +/// the renderer runs on another thread. +fn build_frame( + v: &sys::NDIlib_video_frame_v2_t, + ctx: &InputCtx, + seq: u64, +) -> Result { + if v.xres <= 0 || v.yres <= 0 { + return Err(format!("NDI frame has nonsensical dimensions {}x{}", v.xres, v.yres)); + } + if v.p_data.is_null() { + return Err("NDI frame has no data".into()); + } + + let Some(layout) = layout_for_fourcc(v.four_cc) else { + return Err(format!( + "unsupported NDI pixel format {} ({}); expected UYVY or BGRA", + sys::fourcc_str(v.four_cc), + v.four_cc + )); + }; + + let width = v.xres as usize; + let height = v.yres as usize; + let row_bytes = width * layout.bytes_per_pixel; + // A zero stride means tightly packed. + let src_stride = if v.line_stride_in_bytes > 0 { + v.line_stride_in_bytes as usize + } else { + row_bytes + }; + if src_stride < row_bytes { + return Err(format!( + "NDI frame stride {src_stride} is shorter than a {row_bytes}-byte row" + )); + } + + let plane = layout.format.layout(v.xres as u32, v.yres as u32); + let mut buf = ctx.slot.take_buffer(plane.total); + + // SAFETY: the SDK guarantees `p_data` addresses at least `src_stride * + // height` bytes for these packed formats, and the frame is still owned by + // us (the guard frees it after this returns). + let src = unsafe { std::slice::from_raw_parts(v.p_data as *const u8, src_stride * height) }; + copy_strided(src, src_stride, &mut buf, plane.pitches[0], height); + + Ok(Frame { + width: v.xres as u32, + height: v.yres as u32, + format: layout.format, + colorspace: match layout.format { + // BGRA is already RGB; the colour matrix is unused for it. + PixelFormat::Bgrx => Colorspace::Jpeg, + _ => colorspace_for_height(v.yres), + }, + layout: plane, + data: buf, + seq, + }) +} + +fn describe(f: PixelFormat, c: Colorspace) -> String { + let fmt = match f { + PixelFormat::Uyvy => "packed 4:2:2 UYVY (GPU colour conversion)", + PixelFormat::Yuy2 => "packed 4:2:2 YUY2 (GPU colour conversion)", + PixelFormat::I420 => "planar 4:2:0 I420 (GPU colour conversion)", + PixelFormat::Bgrx => "packed BGRX", + }; + let cs = match c { + Colorspace::Jpeg => "full range", + Colorspace::Bt601Limited => "BT.601 studio range", + Colorspace::Bt709Limited => "BT.709 studio range", + }; + format!("{fmt}, {cs}") +} + +// --------------------------------------------------------------------------- +// `video-output ndi-sources` +// --------------------------------------------------------------------------- + +pub fn list_sources(timeout_secs: u64) -> i32 { + let lib = match bring_up() { + Ok(l) => l, + Err(e) => { + eprintln!("{e}"); + return 1; + } + }; + let finder = match Finder::new(lib, &[]) { + Ok(f) => f, + Err(e) => { + eprintln!("NDI discovery could not start: {e}"); + return 1; + } + }; + + println!("Searching for NDI sources for {timeout_secs}s..."); + let deadline = Instant::now() + Duration::from_secs(timeout_secs.max(1)); + let mut sources = Vec::new(); + while Instant::now() < deadline { + sources = finder.snapshot(DISCOVERY_WAIT_MS); + if !sources.is_empty() && Instant::now() + Duration::from_secs(1) >= deadline { + break; + } + } + + if sources.is_empty() { + println!("No NDI sources found."); + println!("If the sender is on another subnet, pass --extra-ip when running."); + return 1; + } + + println!("{} source(s):", sources.len()); + for (i, s) in sources.iter().enumerate() { + println!(" [{i}] {:<40} {}", s.name, s.url); + } + println!(); + println!("Pass one of these to --source (a unique fragment is enough):"); + for s in &sources { + println!(" --source {:?}", s.name); + } + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn src(name: &str) -> DiscoveredSource { + DiscoveredSource { name: name.into(), url: "10.0.0.1:5961".into() } + } + + fn three() -> Vec { + vec![ + src("MACBOOK-PRO (OBS)"), + src("PROPRESENTER-PC (ProPresenter Lower Thirds)"), + src("PROPRESENTER-PC (ProPresenter Stage)"), + ] + } + + // --- source selection --- + + #[test] + fn no_sources_is_an_error() { + assert_eq!(select_source(&[], None).unwrap_err(), SourceSelectError::NoneVisible); + assert_eq!(select_source(&[], Some("x")).unwrap_err(), SourceSelectError::NoneVisible); + } + + #[test] + fn no_name_takes_the_first_source() { + assert_eq!(select_source(&three(), None).unwrap().name, "MACBOOK-PRO (OBS)"); + } + + #[test] + fn exact_name_matches_case_insensitively() { + let list = three(); + let s = select_source(&list, Some("macbook-pro (obs)")).unwrap(); + assert_eq!(s.name, "MACBOOK-PRO (OBS)"); + } + + #[test] + fn unique_substring_matches() { + let list = three(); + let s = select_source(&list, Some("Lower Thirds")).unwrap(); + assert_eq!(s.name, "PROPRESENTER-PC (ProPresenter Lower Thirds)"); + } + + #[test] + fn unique_prefix_matches() { + let list = three(); + let s = select_source(&list, Some("MACBOOK")).unwrap(); + assert_eq!(s.name, "MACBOOK-PRO (OBS)"); + } + + /// "ProPresenter" alone matches two of this machine's outputs. Guessing + /// would put the stage display on the programme output. + #[test] + fn ambiguous_fragment_is_an_error_not_a_guess() { + let e = select_source(&three(), Some("ProPresenter")).unwrap_err(); + match e { + SourceSelectError::Ambiguous { matched, .. } => assert_eq!(matched.len(), 2), + other => panic!("expected Ambiguous, got {other:?}"), + } + } + + #[test] + fn unknown_name_lists_what_is_visible() { + let e = select_source(&three(), Some("Resolume")).unwrap_err(); + match e { + SourceSelectError::NoMatch { ref available, .. } => assert_eq!(available.len(), 3), + other => panic!("expected NoMatch, got {other:?}"), + } + assert!(format!("{e}").contains("MACBOOK-PRO (OBS)")); + } + + #[test] + fn exact_match_wins_over_a_longer_sibling() { + let list = vec![src("STUDIO (Cam)"), src("STUDIO (Cam 2)")]; + assert_eq!(select_source(&list, Some("STUDIO (Cam)")).unwrap().name, "STUDIO (Cam)"); + } + + // --- link policy --- + + #[test] + fn healthy_link_continues() { + let p = LinkPolicy::default(); + assert_eq!(p.evaluate(Duration::from_millis(33), 1), LinkAction::Continue); + assert_eq!(p.evaluate(Duration::from_millis(1999), 1), LinkAction::Continue); + } + + /// A brief gap blanks the output but keeps the receiver: NDI reconnects on + /// its own, and rebuilding would throw that away. + #[test] + fn a_stall_with_a_live_connection_only_marks_lost() { + let p = LinkPolicy::default(); + assert_eq!( + p.evaluate(Duration::from_secs(3), 1), + LinkAction::MarkLost("no frames from the sender") + ); + assert_eq!( + p.evaluate(Duration::from_secs(9), 1), + LinkAction::MarkLost("no frames from the sender") + ); + } + + #[test] + fn a_dropped_connection_rebuilds_at_the_stall_threshold() { + let p = LinkPolicy::default(); + assert_eq!(p.evaluate(Duration::from_millis(100), 0), LinkAction::Continue); + assert_eq!(p.evaluate(Duration::from_secs(3), 0), LinkAction::Rebuild("sender disconnected")); + } + + #[test] + fn a_long_stall_rebuilds_even_with_a_reported_connection() { + let p = LinkPolicy::default(); + assert_eq!( + p.evaluate(Duration::from_secs(11), 1), + LinkAction::Rebuild("no frames for too long") + ); + } + + #[test] + fn negative_connection_counts_are_treated_as_disconnected() { + let p = LinkPolicy::default(); + assert_eq!(p.evaluate(Duration::from_secs(3), -1), LinkAction::Rebuild("sender disconnected")); + } + + // --- frame layout --- + + #[test] + fn uyvy_and_uyva_map_to_packed_422() { + let l = layout_for_fourcc(sys::NDILIB_FOURCC_UYVY).unwrap(); + assert_eq!(l.format, PixelFormat::Uyvy); + assert_eq!(l.bytes_per_pixel, 2); + // UYVA is UYVY plus an alpha plane; we render the UYVY part. + assert_eq!(layout_for_fourcc(sys::NDILIB_FOURCC_UYVA).unwrap().format, PixelFormat::Uyvy); + } + + #[test] + fn bgra_and_bgrx_map_to_packed_bgrx() { + for cc in [sys::NDILIB_FOURCC_BGRA, sys::NDILIB_FOURCC_BGRX] { + let l = layout_for_fourcc(cc).unwrap(); + assert_eq!(l.format, PixelFormat::Bgrx); + assert_eq!(l.bytes_per_pixel, 4); + } + } + + /// These cannot arrive under UYVY_BGRA; if one ever does we want a clear + /// error rather than a misread buffer. + #[test] + fn planar_and_16bit_formats_are_rejected() { + for cc in [ + sys::NDILIB_FOURCC_I420, + sys::NDILIB_FOURCC_YV12, + sys::NDILIB_FOURCC_NV12, + sys::NDILIB_FOURCC_P216, + sys::NDILIB_FOURCC_PA16, + sys::NDILIB_FOURCC_RGBA, + sys::NDILIB_FOURCC_RGBX, + 0, + ] { + assert!(layout_for_fourcc(cc).is_none(), "{} should be rejected", sys::fourcc_str(cc)); + } + } + + #[test] + fn hd_is_bt709_and_sd_is_bt601() { + assert_eq!(colorspace_for_height(1080), Colorspace::Bt709Limited); + assert_eq!(colorspace_for_height(720), Colorspace::Bt709Limited); + assert_eq!(colorspace_for_height(576), Colorspace::Bt601Limited); + assert_eq!(colorspace_for_height(486), Colorspace::Bt601Limited); + } + + // --- strided copy --- + + #[test] + fn copy_strided_handles_tight_packing() { + let src: Vec = (0..12).collect(); + let mut dst = vec![0u8; 12]; + copy_strided(&src, 4, &mut dst, 4, 3); + assert_eq!(dst, src); + } + + /// The bug this guards against skews the picture progressively down the + /// frame, which is easy to miss and impossible to ignore on air. + #[test] + fn copy_strided_skips_source_row_padding() { + // 3 rows of 4 meaningful bytes, each padded to 6. + let src: Vec = vec![ + 1, 2, 3, 4, 0xFF, 0xFF, // + 5, 6, 7, 8, 0xFF, 0xFF, // + 9, 10, 11, 12, 0xFF, 0xFF, + ]; + let mut dst = vec![0u8; 12]; + copy_strided(&src, 6, &mut dst, 4, 3); + assert_eq!(dst, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + } + + #[test] + fn copy_strided_copies_only_the_narrower_of_the_two_rows() { + let src: Vec = vec![1, 2, 3, 4, 5, 6]; + let mut dst = vec![0u8; 8]; + // Destination rows are wider than the source rows. + copy_strided(&src, 3, &mut dst, 4, 2); + assert_eq!(dst, vec![1, 2, 3, 0, 4, 5, 6, 0]); + } } diff --git a/src/inputs/ndi_sys.rs b/src/inputs/ndi_sys.rs new file mode 100644 index 0000000..b191a9b --- /dev/null +++ b/src/inputs/ndi_sys.rs @@ -0,0 +1,463 @@ +//! NDI ABI and runtime loader. +//! +//! `libndi` is loaded with `dlopen` at runtime rather than linked, so a machine +//! without the NDI SDK still runs the camera backend perfectly — only the NDI +//! subcommands report that the library is missing. +//! +//! All 152 NDI entry points are exported individually from `libndi.so.6`, so +//! each needed function is resolved with `dlsym` directly. The +//! `NDIlib_v6`/`NDIlib_v6_load` function-pointer table is deliberately NOT +//! used: it is a large struct whose layout would have to be mirrored exactly, +//! and mirroring it buys nothing here. +//! +//! Every struct layout below was taken from this machine's SDK headers with +//! `offsetof`/`sizeof` (NDI 6.3.2) and is pinned by the compile-time +//! assertions at the bottom. + +#![allow(non_camel_case_types)] + +use std::ffi::{c_char, c_int, c_void, CStr, CString}; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +// --- opaque instance handles ------------------------------------------------ + +pub type NDIlib_find_instance_t = *mut c_void; +pub type NDIlib_recv_instance_t = *mut c_void; + +// --- enums ------------------------------------------------------------------ + +pub const NDILIB_FRAME_TYPE_NONE: c_int = 0; +pub const NDILIB_FRAME_TYPE_VIDEO: c_int = 1; +pub const NDILIB_FRAME_TYPE_AUDIO: c_int = 2; +pub const NDILIB_FRAME_TYPE_METADATA: c_int = 3; +pub const NDILIB_FRAME_TYPE_ERROR: c_int = 4; +pub const NDILIB_FRAME_TYPE_STATUS_CHANGE: c_int = 100; +pub const NDILIB_FRAME_TYPE_SOURCE_CHANGE: c_int = 101; + +/// Use the SDK's native decode layout: normally UYVY without alpha and UYVA +/// with alpha. This is the NDI SDK's lowest-latency receive mode. +/// +/// The SDK treats `allow_video_fields` as true in this mode, so callers must +/// inspect `frame_format_type` if they support interlaced sources. +pub const NDILIB_RECV_COLOR_FORMAT_FASTEST: c_int = 100; + +pub const NDILIB_RECV_BANDWIDTH_HIGHEST: c_int = 100; + +pub const NDILIB_FRAME_FORMAT_INTERLEAVED: c_int = 0; +pub const NDILIB_FRAME_FORMAT_PROGRESSIVE: c_int = 1; +pub const NDILIB_FRAME_FORMAT_FIELD_0: c_int = 2; +pub const NDILIB_FRAME_FORMAT_FIELD_1: c_int = 3; + +// --- FourCC ----------------------------------------------------------------- + +pub const fn fourcc(c: &[u8; 4]) -> c_int { + ((c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)) as c_int +} + +pub const NDILIB_FOURCC_UYVY: c_int = fourcc(b"UYVY"); +pub const NDILIB_FOURCC_UYVA: c_int = fourcc(b"UYVA"); +pub const NDILIB_FOURCC_BGRA: c_int = fourcc(b"BGRA"); +pub const NDILIB_FOURCC_BGRX: c_int = fourcc(b"BGRX"); +pub const NDILIB_FOURCC_RGBA: c_int = fourcc(b"RGBA"); +pub const NDILIB_FOURCC_RGBX: c_int = fourcc(b"RGBX"); +pub const NDILIB_FOURCC_I420: c_int = fourcc(b"I420"); +pub const NDILIB_FOURCC_YV12: c_int = fourcc(b"YV12"); +pub const NDILIB_FOURCC_NV12: c_int = fourcc(b"NV12"); +pub const NDILIB_FOURCC_P216: c_int = fourcc(b"P216"); +pub const NDILIB_FOURCC_PA16: c_int = fourcc(b"PA16"); + +pub fn fourcc_str(v: c_int) -> String { + let v = v as u32; + [v as u8, (v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8] + .iter() + .map(|&c| if c.is_ascii_graphic() { c as char } else { '?' }) + .collect() +} + +// --- structs ---------------------------------------------------------------- + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct NDIlib_source_t { + pub p_ndi_name: *const c_char, + /// Union of `p_url_address` and the deprecated `p_ip_address`. + pub p_url_address: *const c_char, +} + +impl Default for NDIlib_source_t { + fn default() -> Self { + NDIlib_source_t { + p_ndi_name: std::ptr::null(), + p_url_address: std::ptr::null(), + } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct NDIlib_video_frame_v2_t { + pub xres: c_int, + pub yres: c_int, + pub four_cc: c_int, + pub frame_rate_n: c_int, + pub frame_rate_d: c_int, + pub picture_aspect_ratio: f32, + pub frame_format_type: c_int, + pub timecode: i64, + pub p_data: *mut u8, + /// Union of `line_stride_in_bytes` and `data_size_in_bytes`; which member + /// is meaningful depends on the FourCC. + pub line_stride_in_bytes: c_int, + pub p_metadata: *const c_char, + pub timestamp: i64, +} + +impl Default for NDIlib_video_frame_v2_t { + fn default() -> Self { + // SAFETY: an all-zero video frame is the documented "empty" value and + // contains no invalid niches (raw pointers may be null). + unsafe { std::mem::zeroed() } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct NDIlib_recv_create_v3_t { + pub source_to_connect_to: NDIlib_source_t, + pub color_format: c_int, + pub bandwidth: c_int, + pub allow_video_fields: bool, + pub p_ndi_recv_name: *const c_char, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct NDIlib_find_create_t { + pub show_local_sources: bool, + pub p_groups: *const c_char, + pub p_extra_ips: *const c_char, +} + +/// Current number of frames buffered inside an NDI receiver. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct NDIlib_recv_queue_t { + pub video_frames: c_int, + pub audio_frames: c_int, + pub metadata_frames: c_int, +} + +// --- runtime loader --------------------------------------------------------- + +type FnInitialize = unsafe extern "C" fn() -> bool; +type FnDestroy = unsafe extern "C" fn(); +type FnVersion = unsafe extern "C" fn() -> *const c_char; +type FnIsSupportedCpu = unsafe extern "C" fn() -> bool; +type FnFindCreateV2 = unsafe extern "C" fn(*const NDIlib_find_create_t) -> NDIlib_find_instance_t; +type FnFindDestroy = unsafe extern "C" fn(NDIlib_find_instance_t); +type FnFindWaitForSources = unsafe extern "C" fn(NDIlib_find_instance_t, u32) -> bool; +type FnFindGetCurrentSources = + unsafe extern "C" fn(NDIlib_find_instance_t, *mut u32) -> *const NDIlib_source_t; +type FnRecvCreateV3 = unsafe extern "C" fn(*const NDIlib_recv_create_v3_t) -> NDIlib_recv_instance_t; +type FnRecvDestroy = unsafe extern "C" fn(NDIlib_recv_instance_t); +type FnRecvCaptureV3 = unsafe extern "C" fn( + NDIlib_recv_instance_t, + *mut NDIlib_video_frame_v2_t, + *mut c_void, // audio: always NULL here + *mut c_void, // metadata: always NULL here + u32, +) -> c_int; +type FnRecvFreeVideoV2 = + unsafe extern "C" fn(NDIlib_recv_instance_t, *const NDIlib_video_frame_v2_t); +type FnRecvGetNoConnections = unsafe extern "C" fn(NDIlib_recv_instance_t) -> c_int; +type FnRecvGetQueue = unsafe extern "C" fn(NDIlib_recv_instance_t, *mut NDIlib_recv_queue_t); + +/// Resolved entry points into `libndi`. +/// +/// The `dlopen` handle is intentionally never closed. The SDK starts background +/// threads that outlive any single receiver, so unloading the library is not +/// supported; the handle is released when the process exits. +pub struct NdiLib { + /// Keeps every resolved function pointer mapped for this process's life. + _handle: *mut c_void, + pub initialize: FnInitialize, + pub destroy: FnDestroy, + pub version: FnVersion, + pub is_supported_cpu: FnIsSupportedCpu, + pub find_create_v2: FnFindCreateV2, + pub find_destroy: FnFindDestroy, + pub find_wait_for_sources: FnFindWaitForSources, + pub find_get_current_sources: FnFindGetCurrentSources, + pub recv_create_v3: FnRecvCreateV3, + pub recv_destroy: FnRecvDestroy, + pub recv_capture_v3: FnRecvCaptureV3, + pub recv_free_video_v2: FnRecvFreeVideoV2, + pub recv_get_no_connections: FnRecvGetNoConnections, + pub recv_get_queue: FnRecvGetQueue, +} + +// SAFETY: the entries are plain code pointers into a library that stays mapped +// for the life of the process, and the NDI API is documented as thread-safe +// per instance. +unsafe impl Send for NdiLib {} +unsafe impl Sync for NdiLib {} + +/// Candidate v6 sonames, most specific first. +const SONAMES: [&str; 2] = ["libndi.so.6", "libndi.so"]; + +static LIB: OnceLock> = OnceLock::new(); + +/// Loads `libndi` once per process. Repeated calls return the same result, +/// including the same failure, so a retry loop cannot thrash `dlopen`. +pub fn lib() -> Result<&'static NdiLib, &'static str> { + match LIB.get_or_init(load) { + Ok(l) => Ok(l), + Err(e) => Err(e.as_str()), + } +} + +fn load() -> Result { + load_from_candidates(&runtime_candidates()) +} + +/// Resolves the exact paths to try. The SDK documents `NDI_RUNTIME_DIR_V6` as +/// an explicit runtime directory, so when it is set we do not silently fall +/// back to a different system installation. +fn runtime_candidates() -> Vec { + match std::env::var_os("NDI_RUNTIME_DIR_V6") { + Some(dir) => SONAMES + .iter() + .map(|name| PathBuf::from(&dir).join(name)) + .collect(), + None => SONAMES.iter().map(PathBuf::from).collect(), + } +} + +/// Tries the supplied runtime paths in order. +/// +/// Kept separate from environment handling so the missing-runtime path can be +/// tested on a machine that happens to have NDI installed. +fn load_from_candidates(candidates: &[PathBuf]) -> Result { + let mut failures = Vec::with_capacity(candidates.len()); + + for path in candidates { + let handle = match dlopen_path(path) { + Ok(handle) => handle, + Err(e) => { + failures.push(format!("{} ({e})", path.display())); + continue; + } + }; + + match resolve(handle) { + Ok(lib) => return Ok(lib), + Err(e) => { + // SAFETY: no function pointer escaped `resolve`, and the SDK + // was not initialised, so this unsuccessful handle may be + // closed before trying another candidate. + unsafe { libc::dlclose(handle) }; + failures.push(format!("{} ({e})", path.display())); + } + } + } + + let attempted = if failures.is_empty() { + "no candidate paths were provided".to_string() + } else { + failures.join("; ") + }; + Err(format!( + "could not load the NDI v6 runtime: {attempted}. Install the NDI SDK for Linux and put \ + libndi.so.6 on the loader path (for example in /usr/local/lib followed by `sudo ldconfig`), \ + or set NDI_RUNTIME_DIR_V6 to the directory containing it." + )) +} + +fn dlopen_path(path: &Path) -> Result<*mut c_void, String> { + let c = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| format!("runtime path contains a NUL byte: {}", path.display()))?; + // SAFETY: `c` is a valid NUL-terminated path for the duration of the call. + // Clearing dlerror first ensures a failure below reports this operation. + let handle = unsafe { + libc::dlerror(); + libc::dlopen(c.as_ptr(), libc::RTLD_LOCAL | libc::RTLD_LAZY) + }; + if handle.is_null() { + Err(last_dl_error()) + } else { + Ok(handle) + } +} + +fn last_dl_error() -> String { + // SAFETY: dlerror returns either NULL or a thread-local NUL-terminated + // diagnostic string that remains valid until the next loader operation. + let p = unsafe { libc::dlerror() }; + if p.is_null() { + "unknown dynamic-loader error".into() + } else { + unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned() + } +} + +/// Resolves one symbol or reports which one was missing. +fn sym(handle: *mut c_void, name: &str) -> Result<*mut c_void, String> { + let c = CString::new(name).map_err(|_| format!("bad symbol name {name}"))?; + // SAFETY: `handle` came from a successful dlopen and `c` is a valid + // NUL-terminated symbol name. + let p = unsafe { + libc::dlerror(); + libc::dlsym(handle, c.as_ptr()) + }; + if p.is_null() { + Err(format!("missing symbol {name}: {}", last_dl_error())) + } else { + Ok(p) + } +} + +fn resolve(handle: *mut c_void) -> Result { + // SAFETY: each symbol is transmuted to the signature declared in + // Processing.NDI.*.h for that exact name. The caller retains `handle` on + // success and closes it if any lookup fails. + unsafe { + Ok(NdiLib { + _handle: handle, + initialize: std::mem::transmute::<*mut c_void, FnInitialize>(sym( + handle, + "NDIlib_initialize", + )?), + destroy: std::mem::transmute::<*mut c_void, FnDestroy>(sym(handle, "NDIlib_destroy")?), + version: std::mem::transmute::<*mut c_void, FnVersion>(sym(handle, "NDIlib_version")?), + is_supported_cpu: std::mem::transmute::<*mut c_void, FnIsSupportedCpu>(sym( + handle, + "NDIlib_is_supported_CPU", + )?), + find_create_v2: std::mem::transmute::<*mut c_void, FnFindCreateV2>(sym( + handle, + "NDIlib_find_create_v2", + )?), + find_destroy: std::mem::transmute::<*mut c_void, FnFindDestroy>(sym(handle, "NDIlib_find_destroy")?), + find_wait_for_sources: std::mem::transmute::<*mut c_void, FnFindWaitForSources>(sym( + handle, + "NDIlib_find_wait_for_sources", + )?), + find_get_current_sources: std::mem::transmute::<*mut c_void, FnFindGetCurrentSources>(sym( + handle, + "NDIlib_find_get_current_sources", + )?), + recv_create_v3: std::mem::transmute::<*mut c_void, FnRecvCreateV3>(sym( + handle, + "NDIlib_recv_create_v3", + )?), + recv_destroy: std::mem::transmute::<*mut c_void, FnRecvDestroy>(sym(handle, "NDIlib_recv_destroy")?), + recv_capture_v3: std::mem::transmute::<*mut c_void, FnRecvCaptureV3>(sym( + handle, + "NDIlib_recv_capture_v3", + )?), + recv_free_video_v2: std::mem::transmute::<*mut c_void, FnRecvFreeVideoV2>(sym( + handle, + "NDIlib_recv_free_video_v2", + )?), + recv_get_no_connections: std::mem::transmute::<*mut c_void, FnRecvGetNoConnections>(sym( + handle, + "NDIlib_recv_get_no_connections", + )?), + recv_get_queue: std::mem::transmute::<*mut c_void, FnRecvGetQueue>(sym( + handle, + "NDIlib_recv_get_queue", + )?), + }) + } +} + +/// Reads a NUL-terminated C string that the NDI SDK owns into an owned String. +/// +/// Every string the SDK hands back is borrowed from its own storage, so it must +/// be copied before the owning instance is touched again. +pub fn owned_string(p: *const c_char) -> Option { + if p.is_null() { + return None; + } + // SAFETY: caller guarantees `p` is a live NUL-terminated string. + Some(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()) +} + +// Layouts captured from NDI 6.3.2 headers on x86_64 via offsetof/sizeof. +const _: () = { + use std::mem::size_of; + assert!(size_of::() == 16); + assert!(size_of::() == 72); + assert!(size_of::() == 40); + assert!(size_of::() == 24); + assert!(size_of::() == 12); +}; + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::offset_of; + + #[test] + fn layout_matches_the_ndi_sdk_abi() { + assert_eq!(offset_of!(NDIlib_source_t, p_url_address), 8); + + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, xres), 0); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, yres), 4); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, four_cc), 8); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, frame_rate_n), 12); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, frame_rate_d), 16); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, picture_aspect_ratio), 20); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, frame_format_type), 24); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, timecode), 32); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, p_data), 40); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, line_stride_in_bytes), 48); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, p_metadata), 56); + assert_eq!(offset_of!(NDIlib_video_frame_v2_t, timestamp), 64); + + assert_eq!(offset_of!(NDIlib_recv_create_v3_t, source_to_connect_to), 0); + assert_eq!(offset_of!(NDIlib_recv_create_v3_t, color_format), 16); + assert_eq!(offset_of!(NDIlib_recv_create_v3_t, bandwidth), 20); + assert_eq!(offset_of!(NDIlib_recv_create_v3_t, allow_video_fields), 24); + assert_eq!(offset_of!(NDIlib_recv_create_v3_t, p_ndi_recv_name), 32); + + assert_eq!(offset_of!(NDIlib_find_create_t, show_local_sources), 0); + assert_eq!(offset_of!(NDIlib_find_create_t, p_groups), 8); + assert_eq!(offset_of!(NDIlib_find_create_t, p_extra_ips), 16); + + assert_eq!(offset_of!(NDIlib_recv_queue_t, video_frames), 0); + assert_eq!(offset_of!(NDIlib_recv_queue_t, audio_frames), 4); + assert_eq!(offset_of!(NDIlib_recv_queue_t, metadata_frames), 8); + } + + #[test] + fn fourcc_values_match_the_sdk() { + assert_eq!(NDILIB_FOURCC_UYVY, 0x5956_5955); + assert_eq!(NDILIB_FOURCC_BGRA, 0x4152_4742); + assert_eq!(NDILIB_FOURCC_BGRX, 0x5852_4742); + assert_eq!(NDILIB_FOURCC_UYVA, 0x4156_5955); + assert_eq!(fourcc_str(NDILIB_FOURCC_UYVY), "UYVY"); + assert_eq!(fourcc_str(NDILIB_FOURCC_BGRA), "BGRA"); + } + + #[test] + fn owned_string_handles_null() { + assert_eq!(owned_string(std::ptr::null()), None); + let c = CString::new("MACBOOK-PRO (OBS)").unwrap(); + assert_eq!(owned_string(c.as_ptr()).as_deref(), Some("MACBOOK-PRO (OBS)")); + } + + #[test] + fn missing_runtime_error_names_the_candidate_and_remedy() { + let missing = PathBuf::from("/proc/video-output-missing-runtime/libndi.so.6"); + let err = match load_from_candidates(std::slice::from_ref(&missing)) { + Ok(_) => panic!("the deliberately missing runtime unexpectedly loaded"), + Err(e) => e, + }; + assert!(err.contains("could not load the NDI v6 runtime"), "{err}"); + assert!(err.contains(&missing.display().to_string()), "{err}"); + assert!(err.contains("NDI_RUNTIME_DIR_V6"), "{err}"); + } +} diff --git a/src/main.rs b/src/main.rs index a7bceac..40814b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -230,6 +230,16 @@ fn render_loop(win: &mut Renderer, slot: &FrameSlot, signal: &SignalState) -> i3 } match slot.take_timeout(wait) { + Some(frame) if !signal.is_active() => { + // A loss notification can race with the one frame still in + // the mailbox. Never flash that stale frame after the source + // has gone inactive. + slot.recycle(frame.data); + if !showing_black { + win.clear_black(); + showing_black = true; + } + } Some(frame) => match win.draw(&frame) { Ok(()) => { showing_black = false; diff --git a/src/renderer.rs b/src/renderer.rs index b2c0caf..8c7b81b 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -132,9 +132,11 @@ fn sdl_format(f: PixelFormat) -> SDL_PixelFormat { fn sdl_colorspace(c: Colorspace) -> SDL_Colorspace { match c { + Colorspace::Srgb => SDL_COLORSPACE_SRGB, Colorspace::Jpeg => SDL_COLORSPACE_JPEG, Colorspace::Bt601Limited => SDL_COLORSPACE_BT601_LIMITED, Colorspace::Bt709Limited => SDL_COLORSPACE_BT709_LIMITED, + Colorspace::Bt2020Limited => SDL_COLORSPACE_BT2020_LIMITED, } } @@ -470,8 +472,10 @@ mod tests { #[test] fn colorspaces_map_to_the_matching_sdl_colorspace() { + assert_eq!(sdl_colorspace(Colorspace::Srgb).0, SDL_COLORSPACE_SRGB.0); assert_eq!(sdl_colorspace(Colorspace::Jpeg).0, SDL_COLORSPACE_JPEG.0); assert_eq!(sdl_colorspace(Colorspace::Bt601Limited).0, SDL_COLORSPACE_BT601_LIMITED.0); assert_eq!(sdl_colorspace(Colorspace::Bt709Limited).0, SDL_COLORSPACE_BT709_LIMITED.0); + assert_eq!(sdl_colorspace(Colorspace::Bt2020Limited).0, SDL_COLORSPACE_BT2020_LIMITED.0); } } diff --git a/tests/cli.rs b/tests/cli.rs index 33ef25f..eb6735d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -42,14 +42,44 @@ fn bare_subcommands_are_recognised() { assert_eq!(parse_args(&args(&["--version"])).unwrap(), Command::Version); assert_eq!( parse_args(&args(&["ndi-sources"])).unwrap(), - Command::ListNdiSources { timeout_secs: 5 } + Command::ListNdiSources { timeout_secs: 5, extra_ips: Vec::new() } ); assert_eq!( parse_args(&args(&["ndi-sources", "--timeout", "20"])).unwrap(), - Command::ListNdiSources { timeout_secs: 20 } + Command::ListNdiSources { timeout_secs: 20, extra_ips: Vec::new() } ); } +#[test] +fn ndi_sources_accepts_repeated_unicast_hints() { + assert_eq!( + parse_args(&args(&[ + "ndi-sources", "--extra-ip", "10.0.0.5", "--timeout", "8", + "--extra-ip", "ndi.example.test", + ])) + .unwrap(), + Command::ListNdiSources { + timeout_secs: 8, + extra_ips: vec!["10.0.0.5".into(), "ndi.example.test".into()], + } + ); + assert_eq!( + parse_args(&args(&["ndi-sources", "--timeout", "300"])).unwrap(), + Command::ListNdiSources { timeout_secs: 300, extra_ips: Vec::new() } + ); +} + +#[test] +fn ndi_sources_rejects_bad_options_and_values() { + assert!(parse_args(&args(&["ndi-sources", "--timeout"])).is_err()); + assert!(parse_args(&args(&["ndi-sources", "--timeout", "soon"])).is_err()); + assert!(parse_args(&args(&["ndi-sources", "--timeout", "0"])).is_err()); + assert!(parse_args(&args(&["ndi-sources", "--timeout", "301"])).is_err()); + assert!(parse_args(&args(&["ndi-sources", "--timeout", "18446744073709551615"])).is_err()); + assert!(parse_args(&args(&["ndi-sources", "--extra-ip"])).is_err()); + assert!(parse_args(&args(&["ndi-sources", "--source", "anything"])).is_err()); +} + #[test] fn no_arguments_is_an_error_with_usage() { let e = parse_args(&[]).unwrap_err(); diff --git a/tests/ndi_runtime.rs b/tests/ndi_runtime.rs new file mode 100644 index 0000000..c007189 --- /dev/null +++ b/tests/ndi_runtime.rs @@ -0,0 +1,86 @@ +//! Process-level checks for the NDI runtime boundary. +//! +//! These deliberately run the real binary in fresh processes so the loader's +//! one-time cache cannot leak from one assertion into another. + +use std::path::PathBuf; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn binary() -> &'static str { + env!("CARGO_BIN_EXE_video-output") +} + +fn definitely_missing_runtime_dir() -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("the system clock should be after 1970") + .as_nanos(); + std::env::temp_dir().join(format!( + "video-output-missing-ndi-runtime-{}-{nonce}", + std::process::id() + )) +} + +#[test] +fn missing_ndi_runtime_is_clear_and_does_not_affect_non_ndi_commands() { + let missing = definitely_missing_runtime_dir(); + + let ndi = Command::new(binary()) + .args(["ndi-sources", "--timeout", "1"]) + .env("NDI_RUNTIME_DIR_V6", &missing) + .env_remove("JOURNAL_STREAM") + .output() + .expect("run video-output ndi-sources"); + assert!(!ndi.status.success(), "NDI discovery must fail without its runtime"); + + let diagnostic = format!( + "{}{}", + String::from_utf8_lossy(&ndi.stdout), + String::from_utf8_lossy(&ndi.stderr) + ) + .to_ascii_lowercase(); + assert!( + diagnostic.contains("could not load the ndi runtime"), + "expected a clear NDI loader error, got: {diagnostic}" + ); + + let version = Command::new(binary()) + .arg("--version") + .env("NDI_RUNTIME_DIR_V6", &missing) + .env_remove("JOURNAL_STREAM") + .output() + .expect("run video-output --version"); + assert!( + version.status.success(), + "a missing NDI runtime must not affect non-NDI commands: {}", + String::from_utf8_lossy(&version.stderr) + ); + assert!(String::from_utf8_lossy(&version.stdout).contains("video-output")); +} + +#[cfg(target_os = "linux")] +#[test] +fn elf_has_no_hard_dependency_on_libndi() { + let output = match Command::new("readelf").args(["-d", binary()]).output() { + Ok(output) => output, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skipping ELF dependency check: readelf is not installed"); + return; + } + Err(e) => panic!("could not run readelf: {e}"), + }; + assert!( + output.status.success(), + "readelf failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let dynamic = String::from_utf8_lossy(&output.stdout); + let needed: Vec<&str> = dynamic.lines().filter(|line| line.contains("(NEEDED)")).collect(); + assert!( + needed.iter().all(|line| !line.to_ascii_lowercase().contains("libndi")), + "binary acquired a hard NDI dependency:\n{}", + needed.join("\n") + ); +}