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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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
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) {
|
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;
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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