fix me lol

This commit is contained in:
2026-08-29 17:11:55 -05:00
parent 4b7ab4f10a
commit 94a2d4fed6
12 changed files with 1912 additions and 55 deletions
+159 -39
View File
@@ -13,8 +13,10 @@ Two jobs:
No MPV, no GStreamer, no OBS, no browser, no MQTT, no audio, no recording, no 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. playback controls. One process that a launcher starts and kills.
**Status:** the camera path is complete and verified on real hardware. The NDI **Status:** both input backends are implemented. The camera path is verified on
backend is stubbed and reports that it is not implemented yet. 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 suite with no extra machinery, and a long-running unattended process that
parses frames off a USB bus benefits from bounds checking. The whole parses frames off a USB bus benefits from bounds checking. The whole
dependency tree is **two crates**: `sdl3-sys` and `libc`. V4L2, TurboJPEG and 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 * **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 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 `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 * **libjpeg-turbo (TurboJPEG API)** — SIMD MJPEG decode, and its
`tj3DecompressToYUVPlanes8` hands back raw YUV planes so the CPU never does a `tj3DecompressToYUVPlanes8` hands back raw YUV planes so the CPU never does a
colour conversion. 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 pixel path
The ATEM emits **baseline 4:2:2 JPEG**. Rather than decode to RGBA (4 bytes per 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** 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 (2 bytes per pixel) and uploaded to an SDL YUY2 texture. SDL's selected renderer
convert. Half the upload bandwidth and no CPU matrix multiply. 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 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. 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 ### 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 1. **Driver queue** — each wakeup drains every queued V4L2 buffer and decodes
only the newest, returning the rest unread. 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. renderer. Publishing a second frame throws the first away.
A backlog can never form. If decoding falls behind, frames are dropped, not A backlog cannot grow in application memory. If capture, decoding, or display
queued. Latency stays bounded by one capture interval plus one present falls behind, stale frames are dropped instead of replayed later.
interval.
--- ---
@@ -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 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 Download the **NDI SDK for Linux** from ndi.video and install its runtime
`libndi.so.6` into `/usr/local/lib` and run `sudo ldconfig`, or point library on the system loader path. On the production Fedora machine it is:
`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. /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/ 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 ### 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 > is structured so an uncompressed device would only need a new branch in
> `pixel_format_for_fourcc`, not a restructure. > `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 ### Displays
```bash ```bash
@@ -175,8 +239,10 @@ preferred over indices, which shift when an output is hotplugged.
Simplest possible form: Simplest possible form:
```bash ```bash
video-output camera --device /dev/video0 --display BMD video-output camera --device /dev/video0 --display "Sceptre Tech Inc Sceptre F27"
video-output ndi --source "ProPresenter Lower Thirds" --display HDMI video-output ndi \
--source "<exact name printed by video-output ndi-sources>" \
--display "HXA BMD HDMI"
``` ```
Full options: `video-output --help`. Full options: `video-output --help`.
@@ -188,7 +254,9 @@ So the launcher only needs a stable word:
```bash ```bash
sudo mkdir -p /etc/video-output sudo mkdir -p /etc/video-output
sudo cp examples/multiview.toml /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 multiview
video-output lower-thirds
``` ```
A profile is a flat `key = value` file using the same names as the long 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 * start it, and it opens fullscreen on the chosen output
* `SIGTERM` (or `SIGINT`/`SIGHUP`) makes it exit cleanly * `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 ```bash
video-output multiview & video-output multiview &
@@ -254,20 +324,26 @@ silently wrong monitor.
| Situation | What happens | | 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 | | Device busy | says so explicitly, keeps retrying |
| Permission denied | tells you to join the `video` group | | Permission denied | tells you to join the `video` group |
| Device unplugged while running | `signal lost` logged once, black output, reopens automatically when it returns | | Camera unplugged while running | `signal lost` logged once, black output, reopens automatically when it returns |
| Malformed frame | dropped, counted, stream continues | | 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 | | 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 | | Backend panics | caught, logged, backend restarted; the display never goes down with it |
| No valid frame | black | | No valid frame | black |
Logging is edge-triggered — a camera unplugged for twenty minutes produces one Logging is edge-triggered — an input absent for twenty minutes produces a state
`signal lost` line, not one per retry — plus one throughput line per minute transition, not a line every frame or discovery poll — plus one throughput line
(`--stats <seconds>`, `0` disables). Set `VIDEO_OUTPUT_LOG=debug` or per minute (`--stats <seconds>`, `0` disables). Set `VIDEO_OUTPUT_LOG=debug` or
`--log debug` for per-frame diagnostics. Under systemd, journald severity `--log debug` for additional discovery and recovery diagnostics. Frame problems
prefixes are emitted automatically so `journalctl -p warning` filters correctly. 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 cargo test
``` ```
87 tests covering CLI and profile parsing, invalid configuration, display The hardware-independent suite covers CLI and profile parsing, invalid
selection (including the ambiguity rules), the frame slot's drop-stale configuration, deterministic display and NDI-source matching, reconnect policy,
guarantee, backoff and signal-transition state, the V4L2 struct ABI against the NDI and V4L2 ABI layouts, malformed NDI layouts, both stale-frame drop points,
kernel headers, and real JPEG decoding of all four subsampling paths — plus and the one-frame handoff. Process tests force an isolated missing NDI runtime
truncation and bit-flip fuzzing to prove a corrupt frame cannot panic or wedge and verify both the clear NDI error and that a non-NDI command still succeeds;
the decoder. 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 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 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. 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: Hardware paths that cannot be unit tested:
```bash ```bash
# 1. Devices and displays are discovered # 1. Devices and displays are discovered.
video-output devices video-output devices
video-output displays video-output displays
@@ -308,7 +389,7 @@ video-output camera --device /dev/video0 --display <name> --stats 5
# 4. Recovery: unplug the ATEM's USB cable while it runs. # 4. Recovery: unplug the ATEM's USB cable while it runs.
# Expect "signal lost", black output, then "signal restored" on replug, # Expect "signal lost", black output, then "signal restored" on replug,
# with no restart and no repeated log lines # with no restart and no repeated log lines
# 5. Clean shutdown # 5. Clean shutdown.
kill -TERM <pid> # exits promptly, no error kill -TERM <pid> # 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 frames, 0 stale buffers, 0 frames dropped before display, ~33% of one CPU core
and ~98 MB RSS. 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 <ProPresenter-IP>
```
Then run the exact name on the production lower-thirds output:
```bash
video-output ndi \
--source "<exact advertised ProPresenter source name>" \
--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 ## Troubleshooting
+1 -1
View File
@@ -9,7 +9,7 @@ mode = "ndi"
# `video-output ndi-sources`. Omit entirely to take the first source found. # `video-output ndi-sources`. Omit entirely to take the first source found.
source = "ProPresenter" source = "ProPresenter"
display = "HDMI" display = "HXA BMD HDMI"
# Uncomment on networks where mDNS discovery does not cross subnets: # Uncomment on networks where mDNS discovery does not cross subnets:
# extra-ip = "10.0.0.42" # extra-ip = "10.0.0.42"
+18 -3
View File
@@ -80,7 +80,11 @@ pub enum Command {
/// List V4L2 capture devices and their formats, then exit. /// List V4L2 capture devices and their formats, then exit.
ListDevices, ListDevices,
/// List visible NDI sources and exit. /// 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<String>,
},
Help, Help,
Version, Version,
} }
@@ -109,7 +113,7 @@ USAGE:
video-output <profile-name> run a profile from the search path video-output <profile-name> run a profile from the search path
video-output displays list connected outputs video-output displays list connected outputs
video-output devices list V4L2 capture devices and formats 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: COMMON OPTIONS:
--display <name|index> output to use: connector name (HDMI-A-2), --display <name|index> output to use: connector name (HDMI-A-2),
@@ -131,6 +135,10 @@ ndi OPTIONS:
Default: connect to the first source found. Default: connect to the first source found.
--extra-ip <host> unicast discovery hint, repeatable --extra-ip <host> unicast discovery hint, repeatable
ndi-sources OPTIONS:
--timeout <seconds> discovery time, 1..=300. Default: 5
--extra-ip <host> unicast discovery hint, repeatable
PROFILES: PROFILES:
A profile is a small TOML-subset file of `key = value` lines using the same 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\"`. 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), "devices" => return Ok(Command::ListDevices),
"ndi-sources" => { "ndi-sources" => {
let mut timeout_secs = 5; let mut timeout_secs = 5;
let mut extra_ips = Vec::new();
let mut i = 1; let mut i = 1;
while i < args.len() { while i < args.len() {
match args[i].as_str() { match args[i].as_str() {
@@ -170,12 +179,18 @@ pub fn parse_args_with_loader(
timeout_secs = v timeout_secs = v
.parse::<u64>() .parse::<u64>()
.map_err(|_| ParseError(format!("--timeout expects seconds, got {v:?}")))?; .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`")), other => return err(format!("unknown option {other:?} for `ndi-sources`")),
} }
i += 1; i += 1;
} }
return Ok(Command::ListNdiSources { timeout_secs }); return Ok(Command::ListNdiSources { timeout_secs, extra_ips });
} }
_ => {} _ => {}
} }
+4
View File
@@ -92,12 +92,16 @@ pub struct PlaneLayout {
/// key colour enough to matter. /// key colour enough to matter.
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Colorspace { 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. /// BT.601 primaries, full range. What JFIF/MJPEG encodes.
Jpeg, Jpeg,
/// BT.601 primaries, studio range. NDI sources at SD resolutions. /// BT.601 primaries, studio range. NDI sources at SD resolutions.
Bt601Limited, Bt601Limited,
/// BT.709 primaries, studio range. NDI sources at HD resolutions. /// BT.709 primaries, studio range. NDI sources at HD resolutions.
Bt709Limited, Bt709Limited,
/// BT.2020 primaries, studio range. NDI sources at UHD resolutions.
Bt2020Limited,
} }
pub struct Frame { pub struct Frame {
+18 -3
View File
@@ -348,9 +348,7 @@ impl MjpegDecoder {
width: info.width, width: info.width,
height: info.height, height: info.height,
format, format,
// JFIF is BT.601 primaries at full range regardless of subsampling. colorspace: output_colorspace(format),
// Grayscale still rides the BGRX path, where the matrix is unused.
colorspace: Colorspace::Jpeg,
layout, layout,
data: buf, data: buf,
seq, 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 { impl Drop for MjpegDecoder {
fn drop(&mut self) { fn drop(&mut self) {
if !self.handle.is_null() { 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] #[test]
fn subsamp_and_colorspace_names_are_human_readable() { fn subsamp_and_colorspace_names_are_human_readable() {
let i = JpegInfo { width: 2, height: 2, subsamp: TJSAMP_422, colorspace: TJCS_YCBCR }; let i = JpegInfo { width: 2, height: 2, subsamp: TJSAMP_422, colorspace: TJCS_YCBCR };
+1
View File
@@ -16,6 +16,7 @@
pub mod mjpeg; pub mod mjpeg;
pub mod ndi; pub mod ndi;
pub mod ndi_sys;
pub mod v4l2; pub mod v4l2;
pub mod v4l2_sys; pub mod v4l2_sys;
+1116 -7
View File
File diff suppressed because it is too large Load Diff
+463
View File
@@ -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<Result<NdiLib, String>> = 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<NdiLib, String> {
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<PathBuf> {
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<NdiLib, String> {
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<NdiLib, String> {
// 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<String> {
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::<NDIlib_source_t>() == 16);
assert!(size_of::<NDIlib_video_frame_v2_t>() == 72);
assert!(size_of::<NDIlib_recv_create_v3_t>() == 40);
assert!(size_of::<NDIlib_find_create_t>() == 24);
assert!(size_of::<NDIlib_recv_queue_t>() == 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}");
}
}
+10
View File
@@ -230,6 +230,16 @@ fn render_loop(win: &mut Renderer, slot: &FrameSlot, signal: &SignalState) -> i3
} }
match slot.take_timeout(wait) { 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) { Some(frame) => match win.draw(&frame) {
Ok(()) => { Ok(()) => {
showing_black = false; showing_black = false;
+4
View File
@@ -132,9 +132,11 @@ fn sdl_format(f: PixelFormat) -> SDL_PixelFormat {
fn sdl_colorspace(c: Colorspace) -> SDL_Colorspace { fn sdl_colorspace(c: Colorspace) -> SDL_Colorspace {
match c { match c {
Colorspace::Srgb => SDL_COLORSPACE_SRGB,
Colorspace::Jpeg => SDL_COLORSPACE_JPEG, Colorspace::Jpeg => SDL_COLORSPACE_JPEG,
Colorspace::Bt601Limited => SDL_COLORSPACE_BT601_LIMITED, Colorspace::Bt601Limited => SDL_COLORSPACE_BT601_LIMITED,
Colorspace::Bt709Limited => SDL_COLORSPACE_BT709_LIMITED, Colorspace::Bt709Limited => SDL_COLORSPACE_BT709_LIMITED,
Colorspace::Bt2020Limited => SDL_COLORSPACE_BT2020_LIMITED,
} }
} }
@@ -470,8 +472,10 @@ mod tests {
#[test] #[test]
fn colorspaces_map_to_the_matching_sdl_colorspace() { 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::Jpeg).0, SDL_COLORSPACE_JPEG.0);
assert_eq!(sdl_colorspace(Colorspace::Bt601Limited).0, SDL_COLORSPACE_BT601_LIMITED.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::Bt709Limited).0, SDL_COLORSPACE_BT709_LIMITED.0);
assert_eq!(sdl_colorspace(Colorspace::Bt2020Limited).0, SDL_COLORSPACE_BT2020_LIMITED.0);
} }
} }
+32 -2
View File
@@ -42,14 +42,44 @@ fn bare_subcommands_are_recognised() {
assert_eq!(parse_args(&args(&["--version"])).unwrap(), Command::Version); assert_eq!(parse_args(&args(&["--version"])).unwrap(), Command::Version);
assert_eq!( assert_eq!(
parse_args(&args(&["ndi-sources"])).unwrap(), parse_args(&args(&["ndi-sources"])).unwrap(),
Command::ListNdiSources { timeout_secs: 5 } Command::ListNdiSources { timeout_secs: 5, extra_ips: Vec::new() }
); );
assert_eq!( assert_eq!(
parse_args(&args(&["ndi-sources", "--timeout", "20"])).unwrap(), 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] #[test]
fn no_arguments_is_an_error_with_usage() { fn no_arguments_is_an_error_with_usage() {
let e = parse_args(&[]).unwrap_err(); let e = parse_args(&[]).unwrap_err();
+86
View File
@@ -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")
);
}