fix me lol
This commit is contained in:
@@ -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 "<exact name printed by video-output ndi-sources>" \
|
||||
--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 <seconds>`, `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 <seconds>`, `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 <name> --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 <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
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+18
-3
@@ -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<String>,
|
||||
},
|
||||
Help,
|
||||
Version,
|
||||
}
|
||||
@@ -109,7 +113,7 @@ USAGE:
|
||||
video-output <profile-name> 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 <name|index> output to use: connector name (HDMI-A-2),
|
||||
@@ -131,6 +135,10 @@ ndi OPTIONS:
|
||||
Default: connect to the first source found.
|
||||
--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:
|
||||
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::<u64>()
|
||||
.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 });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+18
-3
@@ -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 };
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
pub mod mjpeg;
|
||||
pub mod ndi;
|
||||
pub mod ndi_sys;
|
||||
pub mod v4l2;
|
||||
pub mod v4l2_sys;
|
||||
|
||||
|
||||
+1116
-7
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-2
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user