From 4b7ab4f10a2d16c54026793234cacdb0f5f5ba02 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 15:55:37 -0500 Subject: [PATCH] Phase 1: V4L2/MJPEG camera path to fullscreen SDL3 output A small live-video-to-display appliance for the church stream machine, replacing MPV/GStreamer for two fixed jobs. This lands job 1 end to end and scaffolds job 2. Pipeline: V4L2 mmap capture -> TurboJPEG decode to YUV planes -> interleave to packed YUY2 -> SDL3 streaming texture -> borderless fullscreen on a chosen Wayland output. The GPU does the colour conversion, so no CPU matrix multiply and half the upload bandwidth of RGBA. Latency is managed by dropping, never queueing: the driver queue is drained to its newest buffer each wakeup, and a one-deep slot between the input thread and the renderer discards any frame the renderer has not taken. Dependency tree is two crates (sdl3-sys, libc). V4L2 and TurboJPEG are hand-written FFI; the V4L2 struct layouts were taken from the target kernel's headers and are pinned by compile-time assertions. Three hardware behaviours found while validating against the real ATEM: * /dev/video1 is a metadata node with no formats, so MJPEG on /dev/video0 is the only path on this hardware. * The ATEM pads frames with a few bytes before the EOI marker. libjpeg warns, and TurboJPEG returns -1 for a warning as well as a failure, so the first cut rejected ~97% of frames. Warnings are now distinguished from failures via tj3GetErrorCode, with a structural SOI/EOI check to still catch genuinely truncated frames. * On KDE Wayland, SDL reports monitor model names, not connector names. Also fixes sticky libjpeg error state, where one corrupt frame would latch an error and fail every valid frame after it. 87 tests. Verified on the real ATEM: sustained 30.0 fps, 0 undecodable frames, 0 frames dropped before display, ~33% of one core. --- .gitignore | 3 + Cargo.lock | 39 ++ Cargo.toml | 26 ++ LICENSE | 21 + README.md | 353 ++++++++++++++ examples/lower-thirds.toml | 15 + examples/multiview.toml | 13 + packaging/video-output@.service | 23 + src/cli.rs | 533 +++++++++++++++++++++ src/display.rs | 317 +++++++++++++ src/frame.rs | 399 ++++++++++++++++ src/inputs/mjpeg.rs | 596 ++++++++++++++++++++++++ src/inputs/mod.rs | 216 +++++++++ src/inputs/ndi.rs | 19 + src/inputs/v4l2.rs | 788 ++++++++++++++++++++++++++++++++ src/inputs/v4l2_sys.rs | 412 +++++++++++++++++ src/lib.rs | 12 + src/log.rs | 162 +++++++ src/main.rs | 256 +++++++++++ src/renderer.rs | 477 +++++++++++++++++++ tests/cli.rs | 350 ++++++++++++++ tests/data/generate.py | 24 + tests/data/gray.jpg | Bin 0 -> 354 bytes tests/data/green-420.jpg | Bin 0 -> 663 bytes tests/data/green-422.jpg | Bin 0 -> 671 bytes tests/data/green-444.jpg | Bin 0 -> 688 bytes tests/decode.rs | 178 ++++++++ 27 files changed, 5232 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/lower-thirds.toml create mode 100644 examples/multiview.toml create mode 100644 packaging/video-output@.service create mode 100644 src/cli.rs create mode 100644 src/display.rs create mode 100644 src/frame.rs create mode 100644 src/inputs/mjpeg.rs create mode 100644 src/inputs/mod.rs create mode 100644 src/inputs/ndi.rs create mode 100644 src/inputs/v4l2.rs create mode 100644 src/inputs/v4l2_sys.rs create mode 100644 src/lib.rs create mode 100644 src/log.rs create mode 100644 src/main.rs create mode 100644 src/renderer.rs create mode 100644 tests/cli.rs create mode 100644 tests/data/generate.py create mode 100644 tests/data/gray.jpg create mode 100644 tests/data/green-420.jpg create mode 100644 tests/data/green-422.jpg create mode 100644 tests/data/green-444.jpg create mode 100644 tests/decode.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7141ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +*.pdb diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..70dfddc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,39 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "sdl3-sys" +version = "0.6.8+SDL-3.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97e3d18c4994224aec3fb2d3d189f5fc88af0958fb8ed7b569272fde558f033" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "video-output" +version = "0.1.0" +dependencies = [ + "libc", + "sdl3-sys", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f65490c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "video-output" +version = "0.1.0" +edition = "2021" +rust-version = "1.82" +description = "Minimal live-video-to-display appliance: V4L2/MJPEG and NDI sources rendered fullscreen on a chosen output." +license = "MIT" +publish = false + +[lib] +name = "video_output" +path = "src/lib.rs" + +[[bin]] +name = "video-output" +path = "src/main.rs" + +[dependencies] +libc = "0.2" +sdl3-sys = "0.6" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1db479d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 video-output contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b402f60 --- /dev/null +++ b/README.md @@ -0,0 +1,353 @@ +# video-output + +A small live-video-to-display appliance for Fedora. It receives live video and +puts it fullscreen on one chosen monitor. That is all it does. + +Two jobs: + +| Command | Path | +| --- | --- | +| `video-output camera` | ATEM Mini Pro over USB → V4L2 → MJPEG decode → GPU texture → fullscreen HDMI | +| `video-output ndi` | ProPresenter → NDI → GPU texture → fullscreen HDMI → ATEM chroma key | + +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. + +--- + +## Why Rust, SDL3, libjpeg-turbo + +* **Rust** — the toolchain was already present, `cargo test` gives the test + 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. +* **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 + display selection and YUV texture formats. +* **libjpeg-turbo (TurboJPEG API)** — SIMD MJPEG decode, and its + `tj3DecompressToYUVPlanes8` hands back raw YUV planes so the CPU never does a + colour conversion. + +### 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. + +``` +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). +Anything else (4:4:4, 4:4:0, 4:1:1, grayscale) falls back to packed BGRX. + +### Latency + +Two places drop stale data, both 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 + 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. + +--- + +## Fedora dependencies + +```bash +sudo dnf install -y SDL3-devel turbojpeg-devel gcc +``` + +| Package | Why | +| --- | --- | +| `SDL3-devel` | headers and the `libSDL3.so` link symlink (the `SDL3` runtime package alone is not enough to build) | +| `turbojpeg-devel` | `libturbojpeg.so` for the MJPEG decoder | +| `gcc` | the linker driver cargo invokes | + +Rust, if not present: + +```bash +sudo dnf install -y cargo rust # or: https://rustup.rs +``` + +You must be in the `video` group to open capture devices: + +```bash +groups | grep -q video || sudo usermod -aG video "$USER" # then log out and back in +``` + +### NDI (phase 2, not yet wired up) + +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. + +--- + +## Build + +```bash +cargo build --release +sudo install -m755 target/release/video-output /usr/local/bin/ +``` + +The release binary is about 560 KB. + +--- + +## Discovering devices and displays + +### Capture devices + +```bash +video-output devices +``` + +``` +/dev/video0 + card : ATEM Mini Pro: Blackmagic Desig + driver : uvcvideo + bus : usb-0000:67:00.3-1 + usable : yes, video capture + streaming + [0] MJPG (Motion-JPEG) [needs JPEG decode] + 1920x1080 @ 29.97 fps + +/dev/video1 + card : ATEM Mini Pro: Blackmagic Desig + (not a video capture node -- nothing to stream from here) +``` + +This is the same information as `v4l2-ctl --list-formats-ext`, without needing +`v4l-utils` installed. + +> **On the ATEM Mini Pro specifically:** `/dev/video1` is a *metadata* node, not +> a second video node. It exposes no formats at all, so there is no uncompressed +> (YUYV/NV12) alternative on this hardware — MJPEG is the only option. The code +> is structured so an uncompressed device would only need a new branch in +> `pixel_format_for_fourcc`, not a restructure. + +### Displays + +```bash +video-output displays +``` + +``` +Video driver: wayland +2 display(s): + [0] Sceptre Tech Inc Sceptre F27 1920x1080+0+1080 @ 110.00 Hz (primary) + [1] HXA BMD HDMI 1920x1080+0+0 @ 29.97 Hz +``` + +> **Important, and probably not what you expect:** on KDE Plasma / Wayland, SDL +> reports the **monitor's model name**, not the connector name. You will see +> `HXA BMD HDMI`, not `HDMI-A-2`. Always run `video-output displays` and use a +> name from that list. + +`--display` accepts, in order of precedence: + +1. an exact name (case-insensitive) — `--display "HXA BMD HDMI"` +2. a unique prefix — `--display Sceptre` +3. a unique substring — `--display BMD` +4. an index — `--display 1` or `--display #1` +5. `primary`, the default + +An ambiguous fragment is an **error**, never a guess: picking the wrong screen +mid-service is the failure this is designed to avoid. Names are strongly +preferred over indices, which shift when an output is hotplugged. + +--- + +## Running it + +Simplest possible form: + +```bash +video-output camera --device /dev/video0 --display BMD +video-output ndi --source "ProPresenter Lower Thirds" --display HDMI +``` + +Full options: `video-output --help`. + +### Profiles + +So the launcher only needs a stable word: + +```bash +sudo mkdir -p /etc/video-output +sudo cp examples/multiview.toml /etc/video-output/ +video-output multiview +``` + +A profile is a flat `key = value` file using the same names as the long +options, plus `mode = "camera"` or `mode = "ndi"`. Searched in order: + +``` +./.toml +$XDG_CONFIG_HOME/video-output/.toml +~/.config/video-output/.toml +/etc/video-output/.toml +``` + +Flags given after a profile name override it: + +```bash +video-output multiview --display Sceptre +``` + +Unknown keys are rejected at startup rather than ignored — a typo should fail +before the service, not during it. + +### From a launcher + +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 + +```bash +video-output multiview & +LAUNCHER_PID=$! +# ... +kill -TERM "$LAUNCHER_PID" +``` + +Nothing needs to be restarted because the camera was unplugged or ProPresenter +stopped sending; it reconnects on its own. An optional systemd template is in +[`packaging/video-output@.service`](packaging/video-output@.service). + +--- + +## Display behaviour + +Borderless, fullscreen, no title bar, no controls, no OSD, cursor hidden, +screensaver disabled. Fullscreen is *fullscreen-desktop*: it adopts the +output's current resolution rather than forcing a mode change, so the HDMI link +is never renegotiated on a live output. + +Aspect ratio is preserved by SDL's letterbox logical presentation, so a 16:9 +source fills a 16:9 output exactly and is never stretched. + +Wayland gives a client no way to *place* a window, only to request fullscreen on +a particular `wl_output`. The window is therefore created hidden, positioned on +the target display, set fullscreen, and only then shown — and afterwards the +program re-reads which display it actually landed on. If the compositor +overrode the request you get a warning naming both displays, rather than a +silently wrong monitor. + +--- + +## Failure behaviour + +| 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 | +| 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 | +| Fatal decoder error | the JPEG handle is rebuilt so one bad frame cannot poison the ones after it | +| Backend panics | caught, logged, backend restarted; the display never goes down with it | +| No valid frame | black | + +Logging is edge-triggered — a camera unplugged for twenty minutes produces one +`signal lost` line, not one per retry — plus one throughput line per minute +(`--stats `, `0` disables). Set `VIDEO_OUTPUT_LOG=debug` or +`--log debug` for per-frame diagnostics. Under systemd, journald severity +prefixes are emitted automatically so `journalctl -p warning` filters correctly. + +--- + +## Testing + +```bash +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. + +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 + +Hardware paths that cannot be unit tested: + +```bash +# 1. Devices and displays are discovered +video-output devices +video-output displays + +# 2. Windowed, so it does not take over a screen; expect ~29.97 fps and +# "0 undecodable" +video-output camera --device /dev/video0 --display --windowed --stats 3 + +# 3. Fullscreen on the intended output. Confirm the "Opened display:" line +# names the monitor you meant, with no placement warning after it +video-output camera --device /dev/video0 --display --stats 5 + +# 4. Recovery: unplug the ATEM's USB cable while it runs. +# Expect "signal lost", black output, then "signal restored" on replug, +# with no restart and no repeated log lines +# 5. Clean shutdown +kill -TERM # exits promptly, no error +``` + +Verified on this machine: Fedora 44, KDE Plasma on Wayland, ATEM Mini Pro on +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. + +--- + +## Troubleshooting + +**`cannot find -lSDL3` or `cannot find -lturbojpeg` when building** +`SDL3-devel` / `turbojpeg-devel` are not installed. The runtime packages alone +do not provide the link symlinks. + +**`cannot open /dev/video0: Permission denied`** +Join the `video` group and log out and back in. + +**`device is busy`** +Something else is capturing — often another copy of this program, or OBS. + +**`no display matches "..."`** +Run `video-output displays`. Remember the names are monitor models, not +connector names, on KDE Wayland. + +**`display "HDMI" is ambiguous`** +Two outputs match that fragment. Use a longer one, or the exact name. + +**Warning that the compositor placed the window on the wrong display** +KWin overrode the fullscreen output request. Check for a KWin window rule +matching app id `video-output`. + +**Low frame rate, many "stale buffers skipped"** +You are running a debug build. Use `cargo build --release`; the YUY2 interleave +is several times slower without optimisation. + +**Log line says "decoded despite a libjpeg warning"** +Normal for the ATEM. It pads frames with a few bytes before the JPEG +end-of-image marker, which makes libjpeg warn on an otherwise perfect image. +These frames are complete and are kept. Truncated frames are caught separately +by a structural check and dropped. diff --git a/examples/lower-thirds.toml b/examples/lower-thirds.toml new file mode 100644 index 0000000..7b26f3c --- /dev/null +++ b/examples/lower-thirds.toml @@ -0,0 +1,15 @@ +# Job 2: ProPresenter lower thirds over NDI -> fullscreen HDMI -> ATEM key. +# +# Install as /etc/video-output/lower-thirds.toml and run: +# video-output lower-thirds + +mode = "ndi" + +# A unique fragment of the NDI source name is enough; see +# `video-output ndi-sources`. Omit entirely to take the first source found. +source = "ProPresenter" + +display = "HDMI" + +# Uncomment on networks where mDNS discovery does not cross subnets: +# extra-ip = "10.0.0.42" diff --git a/examples/multiview.toml b/examples/multiview.toml new file mode 100644 index 0000000..52b987a --- /dev/null +++ b/examples/multiview.toml @@ -0,0 +1,13 @@ +# Job 1: ATEM Mini Pro multiview, USB -> fullscreen HDMI. +# +# Install as /etc/video-output/multiview.toml and run: video-output multiview + +mode = "camera" +device = "/dev/video0" + +# Output name from `video-output displays`. On KDE Wayland these are monitor +# model names, not connector names -- a unique fragment is enough. +display = "BMD" + +size = "1920x1080" +fps = "29.97" diff --git a/packaging/video-output@.service b/packaging/video-output@.service new file mode 100644 index 0000000..a40874b --- /dev/null +++ b/packaging/video-output@.service @@ -0,0 +1,23 @@ +# Optional systemd unit. Not required -- the program is a plain foreground +# process and an external launcher can start and stop it directly. +# +# sudo cp packaging/video-output@.service /etc/systemd/system/ +# sudo systemctl start video-output@multiview +# +# The instance name is the profile name in /etc/video-output/.toml. + +[Unit] +Description=Fullscreen video output (%i) +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/video-output %i +Restart=always +RestartSec=2 +# Needs the user's Wayland session; systemd --user supplies these. +Environment=SDL_VIDEODRIVER=wayland + +[Install] +WantedBy=graphical-session.target diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..498b734 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,533 @@ +//! Command line and profile-file parsing. +//! +//! Two ways in, both producing the same [`Settings`]: +//! +//! video-output camera --device /dev/video0 --display HDMI-A-2 +//! video-output multiview # reads multiview.toml from the search path +//! +//! The profile form exists so a launcher can invoke a single stable word and +//! the operator can retune the details without touching the launcher. + +use crate::display::DisplaySelector; +use std::collections::BTreeMap; +use std::fmt; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_WIDTH: u32 = 1920; +pub const DEFAULT_HEIGHT: u32 = 1080; +/// 30000/1001 -- the ATEM's actual rate. Stored as a rational because V4L2 +/// takes a fraction and 29.97 is not representable. +pub const DEFAULT_FPS_NUM: u32 = 30000; +pub const DEFAULT_FPS_DEN: u32 = 1001; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Source { + Camera { + device: String, + width: u32, + height: u32, + fps_num: u32, + fps_den: u32, + }, + Ndi { + /// Source name to look for. `None` means take the first source found. + source: Option, + /// Extra unicast host:port hints for networks without mDNS. + extra_ips: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Settings { + pub source: Source, + pub display: DisplaySelector, + /// Wait for the compositor's vertical blank before presenting. On by + /// default: it costs up to one refresh of latency and removes tearing. + pub vsync: bool, + /// Keep the window on top and fullscreen. Always true in practice; the flag + /// exists to make windowed debugging possible. + pub fullscreen: bool, + pub log_level: Option, + /// Seconds between the periodic throughput log line. + pub stats_interval: u64, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + source: Source::Camera { + device: "/dev/video0".into(), + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + fps_num: DEFAULT_FPS_NUM, + fps_den: DEFAULT_FPS_DEN, + }, + display: DisplaySelector::Primary, + vsync: true, + fullscreen: true, + log_level: None, + stats_interval: 60, + } + } +} + +/// What `main` was asked to do. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Command { + Run(Box), + /// List connected outputs and exit. + ListDisplays, + /// List V4L2 capture devices and their formats, then exit. + ListDevices, + /// List visible NDI sources and exit. + ListNdiSources { timeout_secs: u64 }, + Help, + Version, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParseError(pub String); + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for ParseError {} + +fn err(msg: impl Into) -> Result { + Err(ParseError(msg.into())) +} + +pub const USAGE: &str = "\ +video-output - fullscreen live video appliance + +USAGE: + video-output camera [options] + video-output ndi [options] + video-output run a profile from the search path + video-output displays list connected outputs + video-output devices list V4L2 capture devices and formats + video-output ndi-sources [--timeout S] list visible NDI sources + +COMMON OPTIONS: + --display output to use: connector name (HDMI-A-2), + enumeration index (0, #1) or `primary`. + Default: primary. Names are recommended -- + indices move when outputs are hotplugged. + --no-vsync present without waiting for vblank + --windowed do not go fullscreen (debugging only) + --log error | warn | info | debug + --stats throughput log interval, 0 disables. Default 60 + +camera OPTIONS: + --device V4L2 node. Default /dev/video0 + --size capture size. Default 1920x1080 + --fps frame rate, accepts 29.97 or 30000/1001 + +ndi OPTIONS: + --source NDI source name, or a unique fragment of one. + Default: connect to the first source found. + --extra-ip unicast discovery hint, repeatable + +PROFILES: + A profile is a small TOML-subset file of `key = value` lines using the same + names as the long options, plus `mode = \"camera\"` or `mode = \"ndi\"`. + Searched, in order: + ./.toml + $XDG_CONFIG_HOME/video-output/.toml + ~/.config/video-output/.toml + /etc/video-output/.toml +"; + +/// Parses argv (without the program name). +pub fn parse_args(args: &[String]) -> Result { + parse_args_with_loader(args, &|name| load_profile_file(name)) +} + +/// Profile lookup is injected so tests do not touch the filesystem. +pub fn parse_args_with_loader( + args: &[String], + load: &dyn Fn(&str) -> Result<(PathBuf, String), ParseError>, +) -> Result { + let Some(first) = args.first() else { + return err("no command given\n\n".to_string() + USAGE); + }; + + match first.as_str() { + "-h" | "--help" | "help" => return Ok(Command::Help), + "-V" | "--version" | "version" => return Ok(Command::Version), + "displays" => return Ok(Command::ListDisplays), + "devices" => return Ok(Command::ListDevices), + "ndi-sources" => { + let mut timeout_secs = 5; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--timeout" => { + let v = next_value(args, &mut i, "--timeout")?; + timeout_secs = v + .parse::() + .map_err(|_| ParseError(format!("--timeout expects seconds, got {v:?}")))?; + } + other => return err(format!("unknown option {other:?} for `ndi-sources`")), + } + i += 1; + } + return Ok(Command::ListNdiSources { timeout_secs }); + } + _ => {} + } + + // Either an explicit mode, or a profile name whose file supplies the mode. + let (mut settings, opts_start) = match first.as_str() { + "camera" => (Settings::default(), 1), + "ndi" => { + let mut s = Settings::default(); + s.source = Source::Ndi { source: None, extra_ips: Vec::new() }; + (s, 1) + } + name => { + if name.starts_with('-') { + return err(format!("expected a command before options, found {name:?}\n\n{USAGE}")); + } + let (path, text) = load(name)?; + let s = settings_from_profile(&text) + .map_err(|e| ParseError(format!("{}: {}", path.display(), e.0)))?; + (s, 1) + } + }; + + apply_options(&mut settings, args, opts_start)?; + validate(&settings)?; + Ok(Command::Run(Box::new(settings))) +} + +fn next_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a str, ParseError> { + *i += 1; + args.get(*i) + .map(|s| s.as_str()) + .ok_or_else(|| ParseError(format!("{flag} requires a value"))) +} + +fn apply_options(s: &mut Settings, args: &[String], start: usize) -> Result<(), ParseError> { + let mut i = start; + while i < args.len() { + let a = args[i].as_str(); + match a { + "--display" => { + let v = next_value(args, &mut i, "--display")?; + s.display = DisplaySelector::parse(v); + } + "--no-vsync" => s.vsync = false, + "--vsync" => s.vsync = true, + "--windowed" => s.fullscreen = false, + "--fullscreen" => s.fullscreen = true, + "--log" => { + let v = next_value(args, &mut i, "--log")?; + s.log_level = Some( + crate::log::Level::parse(v) + .ok_or_else(|| ParseError(format!("unknown log level {v:?}")))?, + ); + } + "--stats" => { + let v = next_value(args, &mut i, "--stats")?; + s.stats_interval = v + .parse() + .map_err(|_| ParseError(format!("--stats expects seconds, got {v:?}")))?; + } + "--device" => { + let v = next_value(args, &mut i, "--device")?.to_string(); + match &mut s.source { + Source::Camera { device, .. } => *device = v, + Source::Ndi { .. } => return err("--device applies to `camera`, not `ndi`"), + } + } + "--size" => { + let v = next_value(args, &mut i, "--size")?; + let (w, h) = parse_size(v)?; + match &mut s.source { + Source::Camera { width, height, .. } => { + *width = w; + *height = h; + } + Source::Ndi { .. } => return err("--size applies to `camera`, not `ndi`"), + } + } + "--fps" => { + let v = next_value(args, &mut i, "--fps")?; + let (n, d) = parse_fps(v)?; + match &mut s.source { + Source::Camera { fps_num, fps_den, .. } => { + *fps_num = n; + *fps_den = d; + } + Source::Ndi { .. } => return err("--fps applies to `camera`, not `ndi`"), + } + } + "--source" => { + let v = next_value(args, &mut i, "--source")?.to_string(); + match &mut s.source { + Source::Ndi { source, .. } => *source = Some(v), + Source::Camera { .. } => return err("--source applies to `ndi`, not `camera`"), + } + } + "--extra-ip" => { + let v = next_value(args, &mut i, "--extra-ip")?.to_string(); + match &mut s.source { + Source::Ndi { extra_ips, .. } => extra_ips.push(v), + Source::Camera { .. } => return err("--extra-ip applies to `ndi`, not `camera`"), + } + } + other => return err(format!("unknown option {other:?}\n\n{USAGE}")), + } + i += 1; + } + Ok(()) +} + +pub fn parse_size(v: &str) -> Result<(u32, u32), ParseError> { + let lower = v.to_ascii_lowercase(); + let (w, h) = lower + .split_once('x') + .ok_or_else(|| ParseError(format!("--size expects WxH, got {v:?}")))?; + let w: u32 = w + .trim() + .parse() + .map_err(|_| ParseError(format!("bad width in {v:?}")))?; + let h: u32 = h + .trim() + .parse() + .map_err(|_| ParseError(format!("bad height in {v:?}")))?; + if w == 0 || h == 0 { + return err(format!("--size must be non-zero, got {v:?}")); + } + Ok((w, h)) +} + +/// Accepts `30`, `29.97`, or an exact rational `30000/1001`. +/// +/// Decimals that look like a broadcast rate are snapped to their exact +/// rational: asking a UVC device for 29.97 as 2997/100 makes it round to 30 and +/// then free-run slightly fast against the source. +pub fn parse_fps(v: &str) -> Result<(u32, u32), ParseError> { + let t = v.trim(); + if let Some((n, d)) = t.split_once('/') { + let n: u32 = n.trim().parse().map_err(|_| ParseError(format!("bad fps numerator in {v:?}")))?; + let d: u32 = d.trim().parse().map_err(|_| ParseError(format!("bad fps denominator in {v:?}")))?; + if n == 0 || d == 0 { + return err(format!("--fps must be non-zero, got {v:?}")); + } + return Ok((n, d)); + } + let f: f64 = t.parse().map_err(|_| ParseError(format!("bad fps {v:?}")))?; + if !(f.is_finite() && f > 0.0) { + return err(format!("--fps must be a positive number, got {v:?}")); + } + for (num, den) in [(24000, 1001), (30000, 1001), (60000, 1001), (120000, 1001)] { + if (f - num as f64 / den as f64).abs() < 0.01 { + return Ok((num, den)); + } + } + if (f - f.round()).abs() < 1e-9 { + return Ok((f.round() as u32, 1)); + } + // Two decimal places is enough for any rate a capture device advertises. + Ok(((f * 100.0).round() as u32, 100)) +} + +fn validate(s: &Settings) -> Result<(), ParseError> { + match &s.source { + Source::Camera { device, width, height, .. } => { + if device.is_empty() { + return err("--device must not be empty"); + } + if *width == 0 || *height == 0 { + return err("capture size must be non-zero"); + } + } + Source::Ndi { source, .. } => { + if let Some(n) = source { + if n.trim().is_empty() { + return err("--source must not be empty"); + } + } + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Profile files +// --------------------------------------------------------------------------- + +/// Parses the TOML subset we accept: `key = value` lines, `#` comments, and +/// optionally quoted values. No tables, arrays or multi-line strings -- a +/// profile that needs those is a profile that has grown too complicated. +/// +/// Repeated keys are allowed only for `extra-ip`, which accumulates. +pub fn parse_profile(text: &str) -> Result<(BTreeMap, Vec), ParseError> { + let mut map = BTreeMap::new(); + let mut extra_ips = Vec::new(); + + for (lineno, raw) in text.lines().enumerate() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + if line.starts_with('[') { + return err(format!("line {}: tables are not supported in profiles", lineno + 1)); + } + let (k, v) = line + .split_once('=') + .ok_or_else(|| ParseError(format!("line {}: expected `key = value`, got {raw:?}", lineno + 1)))?; + let key = k.trim().trim_start_matches("--").replace('_', "-").to_ascii_lowercase(); + let val = unquote(v.trim()); + if key.is_empty() { + return err(format!("line {}: empty key", lineno + 1)); + } + if key == "extra-ip" { + extra_ips.push(val); + } else if map.insert(key.clone(), val).is_some() { + return err(format!("line {}: duplicate key {key:?}", lineno + 1)); + } + } + Ok((map, extra_ips)) +} + +/// Strips a `#` comment, honouring quotes so a `#` inside a source name +/// survives. +fn strip_comment(line: &str) -> &str { + let bytes = line.as_bytes(); + let mut in_quote = None::; + for (i, &b) in bytes.iter().enumerate() { + match (in_quote, b) { + (None, b'"') | (None, b'\'') => in_quote = Some(b), + (Some(q), c) if c == q => in_quote = None, + (None, b'#') => return &line[..i], + _ => {} + } + } + line +} + +fn unquote(v: &str) -> String { + let b = v.as_bytes(); + if b.len() >= 2 && ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\'')) { + v[1..v.len() - 1].to_string() + } else { + v.to_string() + } +} + +fn parse_bool(key: &str, v: &str) -> Result { + match v.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" => Ok(true), + "false" | "no" | "off" | "0" => Ok(false), + _ => err(format!("{key} expects true or false, got {v:?}")), + } +} + +/// Builds [`Settings`] from profile text. Unknown keys are rejected rather than +/// ignored: a typo in a config file should fail at start, not silently do the +/// wrong thing during a service. +pub fn settings_from_profile(text: &str) -> Result { + let (map, extra_ips) = parse_profile(text)?; + + let mode = map + .get("mode") + .map(|s| s.as_str()) + .ok_or_else(|| ParseError("profile must set `mode = \"camera\"` or `mode = \"ndi\"`".into()))?; + + let mut s = Settings::default(); + s.source = match mode { + "camera" | "v4l2" => Source::Camera { + device: map.get("device").cloned().unwrap_or_else(|| "/dev/video0".into()), + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + fps_num: DEFAULT_FPS_NUM, + fps_den: DEFAULT_FPS_DEN, + }, + "ndi" => Source::Ndi { + source: map.get("source").cloned(), + extra_ips: extra_ips.clone(), + }, + other => return err(format!("unknown mode {other:?}, expected \"camera\" or \"ndi\"")), + }; + + for (k, v) in &map { + match k.as_str() { + "mode" | "device" | "source" => {} + "display" => s.display = DisplaySelector::parse(v), + "vsync" => s.vsync = parse_bool("vsync", v)?, + "fullscreen" => s.fullscreen = parse_bool("fullscreen", v)?, + "log" | "log-level" => { + s.log_level = + Some(crate::log::Level::parse(v).ok_or_else(|| ParseError(format!("unknown log level {v:?}")))?) + } + "stats" | "stats-interval" => { + s.stats_interval = v + .parse() + .map_err(|_| ParseError(format!("stats expects seconds, got {v:?}")))? + } + "size" => { + let (w, h) = parse_size(v)?; + if let Source::Camera { width, height, .. } = &mut s.source { + *width = w; + *height = h; + } else { + return err("`size` applies to camera profiles only"); + } + } + "fps" => { + let (n, d) = parse_fps(v)?; + if let Source::Camera { fps_num, fps_den, .. } = &mut s.source { + *fps_num = n; + *fps_den = d; + } else { + return err("`fps` applies to camera profiles only"); + } + } + other => return err(format!("unknown profile key {other:?}")), + } + } + + if matches!(s.source, Source::Camera { .. }) && !extra_ips.is_empty() { + return err("`extra-ip` applies to ndi profiles only"); + } + + validate(&s)?; + Ok(s) +} + +/// Directories searched for `.toml`, most specific first. +pub fn profile_search_path() -> Vec { + let mut v = vec![PathBuf::from(".")]; + if let Some(x) = std::env::var_os("XDG_CONFIG_HOME") { + v.push(Path::new(&x).join("video-output")); + } + if let Some(h) = std::env::var_os("HOME") { + v.push(Path::new(&h).join(".config/video-output")); + } + v.push(PathBuf::from("/etc/video-output")); + v +} + +fn load_profile_file(name: &str) -> Result<(PathBuf, String), ParseError> { + if name.contains('/') || name.contains("..") { + return err(format!("profile name {name:?} must not contain a path")); + } + let mut tried = Vec::new(); + for dir in profile_search_path() { + let p = dir.join(format!("{name}.toml")); + match std::fs::read_to_string(&p) { + Ok(text) => return Ok((p, text)), + Err(_) => tried.push(p), + } + } + err(format!( + "unknown command or profile {name:?}\nlooked for:\n{}\n\n{USAGE}", + tried.iter().map(|p| format!(" {}", p.display())).collect::>().join("\n") + )) +} diff --git a/src/display.rs b/src/display.rs new file mode 100644 index 0000000..aa9ae25 --- /dev/null +++ b/src/display.rs @@ -0,0 +1,317 @@ +//! Deterministic output selection. +//! +//! Kept free of SDL types so the matching rules can be unit tested without a +//! compositor. [`crate::renderer`] enumerates real displays into [`DisplayInfo`] +//! and calls [`select`]. +//! +//! Wayland note: a client cannot place a window at absolute coordinates, so +//! "put it on HDMI-2" is expressed as a fullscreen request against a specific +//! `wl_output`. SDL routes that through `xdg_toplevel.set_fullscreen(output)` +//! when the window is created on the target display. Matching by connector name +//! is therefore the reliable identifier; indices shift when outputs are +//! hotplugged, so names are preferred and warned about below. + +use std::fmt; + +/// One connected output as reported by SDL. +#[derive(Clone, Debug, PartialEq)] +pub struct DisplayInfo { + /// Position in SDL's enumeration order, 0-based. + pub index: usize, + /// SDL's opaque display id. Stable only for the lifetime of the process. + pub id: u32, + /// Connector name, e.g. "HDMI-A-2" or "DP-1" on Wayland. + pub name: String, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + pub refresh_hz: f32, + pub is_primary: bool, +} + +impl fmt::Display for DisplayInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "[{}] {:<14} {}x{}+{}+{} @ {:.2} Hz{}", + self.index, + self.name, + self.width, + self.height, + self.x, + self.y, + self.refresh_hz, + if self.is_primary { " (primary)" } else { "" } + ) + } +} + +/// How the user asked for an output. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DisplaySelector { + /// Whatever the compositor calls primary. The default, and the only + /// selector that is not deterministic across reconfiguration. + Primary, + /// Enumeration index, written as `0`, `1`, ... or `#0`. + Index(usize), + /// Connector name or a unique fragment of one. + Name(String), +} + +impl DisplaySelector { + /// Parses a `--display` value. Bare integers and `#N` mean an index, + /// `primary` (or an empty string) means the compositor's primary output, + /// and anything else is treated as a connector name. + pub fn parse(s: &str) -> DisplaySelector { + let t = s.trim(); + if let Some(rest) = t.strip_prefix('#') { + if let Ok(n) = rest.trim().parse::() { + return DisplaySelector::Index(n); + } + } + if let Ok(n) = t.parse::() { + return DisplaySelector::Index(n); + } + if t.is_empty() || t.eq_ignore_ascii_case("primary") { + DisplaySelector::Primary + } else { + DisplaySelector::Name(t.to_string()) + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum SelectError { + NoDisplays, + IndexOutOfRange { asked: usize, count: usize }, + NoMatch { asked: String, available: Vec }, + Ambiguous { asked: String, matched: Vec }, +} + +impl fmt::Display for SelectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SelectError::NoDisplays => write!(f, "no displays are connected"), + SelectError::IndexOutOfRange { asked, count } => write!( + f, + "display index {asked} is out of range ({count} display(s) connected, valid indices 0..={})", + count.saturating_sub(1) + ), + SelectError::NoMatch { asked, available } => write!( + f, + "no display matches {asked:?}; connected outputs are: {}", + available.join(", ") + ), + SelectError::Ambiguous { asked, matched } => write!( + f, + "display {asked:?} is ambiguous, it matches: {}", + matched.join(", ") + ), + } + } +} + +impl std::error::Error for SelectError {} + +/// Resolves a selector against the connected outputs. +/// +/// Name matching is three-tier, most specific first: +/// 1. exact, case-insensitive +/// 2. unique case-insensitive prefix (so `HDMI-A-2` answers to `hdmi-a-2`) +/// 3. unique case-insensitive substring (so `HDMI-A-2` answers to `hdmi-2` +/// is *not* true, but `A-2` is) -- ambiguity is an error, never a guess. +pub fn select<'a>( + displays: &'a [DisplayInfo], + sel: &DisplaySelector, +) -> Result<&'a DisplayInfo, SelectError> { + if displays.is_empty() { + return Err(SelectError::NoDisplays); + } + match sel { + DisplaySelector::Primary => Ok(displays + .iter() + .find(|d| d.is_primary) + .unwrap_or(&displays[0])), + + DisplaySelector::Index(n) => displays.get(*n).ok_or(SelectError::IndexOutOfRange { + asked: *n, + count: displays.len(), + }), + + DisplaySelector::Name(want) => { + let exact: Vec<&DisplayInfo> = displays + .iter() + .filter(|d| d.name.eq_ignore_ascii_case(want)) + .collect(); + if exact.len() == 1 { + return Ok(exact[0]); + } + if exact.len() > 1 { + return Err(SelectError::Ambiguous { + asked: want.clone(), + matched: exact.iter().map(|d| d.name.clone()).collect(), + }); + } + + let lower = want.to_ascii_lowercase(); + for pass in 0..2 { + let hits: Vec<&DisplayInfo> = displays + .iter() + .filter(|d| { + let n = d.name.to_ascii_lowercase(); + if pass == 0 { + n.starts_with(&lower) + } else { + n.contains(&lower) + } + }) + .collect(); + match hits.len() { + 0 => continue, + 1 => return Ok(hits[0]), + _ => { + return Err(SelectError::Ambiguous { + asked: want.clone(), + matched: hits.iter().map(|d| d.name.clone()).collect(), + }) + } + } + } + + Err(SelectError::NoMatch { + asked: want.clone(), + available: displays.iter().map(|d| d.name.clone()).collect(), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn d(index: usize, name: &str, primary: bool) -> DisplayInfo { + DisplayInfo { + index, + id: index as u32 + 1, + name: name.to_string(), + x: index as i32 * 1920, + y: 0, + width: 1920, + height: 1080, + refresh_hz: 60.0, + is_primary: primary, + } + } + + fn three() -> Vec { + vec![ + d(0, "eDP-1", true), + d(1, "HDMI-A-1", false), + d(2, "HDMI-A-2", false), + ] + } + + #[test] + fn parses_index_forms() { + assert_eq!(DisplaySelector::parse("0"), DisplaySelector::Index(0)); + assert_eq!(DisplaySelector::parse(" 2 "), DisplaySelector::Index(2)); + assert_eq!(DisplaySelector::parse("#1"), DisplaySelector::Index(1)); + } + + #[test] + fn parses_name_and_primary_forms() { + assert_eq!( + DisplaySelector::parse("HDMI-A-2"), + DisplaySelector::Name("HDMI-A-2".into()) + ); + assert_eq!(DisplaySelector::parse("primary"), DisplaySelector::Primary); + assert_eq!(DisplaySelector::parse("PRIMARY"), DisplaySelector::Primary); + assert_eq!(DisplaySelector::parse(""), DisplaySelector::Primary); + } + + #[test] + fn selects_exact_name_case_insensitively() { + let ds = three(); + let got = select(&ds, &DisplaySelector::Name("hdmi-a-2".into())).unwrap(); + assert_eq!(got.name, "HDMI-A-2"); + } + + #[test] + fn selects_by_index() { + let ds = three(); + assert_eq!(select(&ds, &DisplaySelector::Index(1)).unwrap().name, "HDMI-A-1"); + } + + #[test] + fn selects_primary_when_unspecified() { + let ds = three(); + assert_eq!(select(&ds, &DisplaySelector::Primary).unwrap().name, "eDP-1"); + } + + #[test] + fn falls_back_to_first_display_when_none_is_primary() { + let ds = vec![d(0, "DP-1", false), d(1, "DP-2", false)]; + assert_eq!(select(&ds, &DisplaySelector::Primary).unwrap().name, "DP-1"); + } + + #[test] + fn unique_prefix_matches() { + let ds = three(); + assert_eq!(select(&ds, &DisplaySelector::Name("eDP".into())).unwrap().name, "eDP-1"); + } + + /// A prefix shared by two outputs must be reported, never silently + /// resolved -- picking the wrong screen mid-service is the failure we care + /// about most. + #[test] + fn ambiguous_prefix_is_an_error_not_a_guess() { + let ds = three(); + let err = select(&ds, &DisplaySelector::Name("HDMI".into())).unwrap_err(); + match err { + SelectError::Ambiguous { matched, .. } => { + assert_eq!(matched, vec!["HDMI-A-1", "HDMI-A-2"]); + } + other => panic!("expected Ambiguous, got {other:?}"), + } + } + + #[test] + fn unique_substring_matches_when_no_prefix_does() { + let ds = three(); + assert_eq!(select(&ds, &DisplaySelector::Name("A-1".into())).unwrap().name, "HDMI-A-1"); + } + + #[test] + fn exact_match_beats_a_longer_prefix_sibling() { + let ds = vec![d(0, "DP-1", false), d(1, "DP-10", false)]; + assert_eq!(select(&ds, &DisplaySelector::Name("DP-1".into())).unwrap().name, "DP-1"); + } + + #[test] + fn unknown_name_lists_what_is_available() { + let ds = three(); + let err = select(&ds, &DisplaySelector::Name("VGA-1".into())).unwrap_err(); + match err { + SelectError::NoMatch { ref available, .. } => { + assert_eq!(available, &["eDP-1", "HDMI-A-1", "HDMI-A-2"]); + } + other => panic!("expected NoMatch, got {other:?}"), + } + assert!(format!("{err}").contains("HDMI-A-2")); + } + + #[test] + fn out_of_range_index_reports_the_valid_range() { + let ds = three(); + let err = select(&ds, &DisplaySelector::Index(7)).unwrap_err(); + assert_eq!(err, SelectError::IndexOutOfRange { asked: 7, count: 3 }); + assert!(format!("{err}").contains("0..=2")); + } + + #[test] + fn empty_display_list_is_an_error() { + assert_eq!(select(&[], &DisplaySelector::Primary).unwrap_err(), SelectError::NoDisplays); + } +} diff --git a/src/frame.rs b/src/frame.rs new file mode 100644 index 0000000..13ad24a --- /dev/null +++ b/src/frame.rs @@ -0,0 +1,399 @@ +//! Frame representation and the single-slot handoff between an input thread and +//! the renderer. +//! +//! The slot is the heart of the latency policy: it holds *at most one* frame. +//! Publishing a second frame before the renderer has taken the first throws the +//! first away. Latency therefore stays bounded by one capture interval plus one +//! present interval no matter how far behind the renderer falls, which is the +//! trade the live-production use case wants. + +use std::sync::{Condvar, Mutex}; +use std::time::{Duration, Instant}; + +/// Pixel layouts we can hand to SDL without a CPU colour conversion. +/// +/// Both real sources land on packed 4:2:2 (`Yuy2` from MJPEG, `Uyvy` from NDI), +/// which halves upload bandwidth versus RGBA and lets the GPU shader do the +/// YUV->RGB conversion for free. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PixelFormat { + /// Packed 4:2:2, byte order Y0 U Y1 V. + Yuy2, + /// Packed 4:2:2, byte order U Y0 V Y1. + Uyvy, + /// Planar 4:2:0, three planes (Y, then U, then V). + I420, + /// Packed 32-bit BGRX. Fallback for JPEGs whose subsampling has no direct + /// packed-YUV equivalent (4:4:4, 4:4:0, 4:1:1, grayscale). + Bgrx, +} + +impl PixelFormat { + pub fn plane_count(self) -> usize { + match self { + PixelFormat::I420 => 3, + _ => 1, + } + } + + /// Frames are always even-width/even-height for the subsampled formats. + /// The V4L2 and NDI paths only ever produce 1920x1080-class sizes, but the + /// rounding here keeps odd sizes from producing a short buffer. + pub fn layout(self, width: u32, height: u32) -> PlaneLayout { + let (w, h) = (width as usize, height as usize); + match self { + PixelFormat::Yuy2 | PixelFormat::Uyvy => { + let pitch = w.div_ceil(2) * 4; + PlaneLayout { + offsets: [0, 0, 0], + pitches: [pitch, 0, 0], + planes: 1, + total: pitch * h, + } + } + PixelFormat::Bgrx => { + let pitch = w * 4; + PlaneLayout { + offsets: [0, 0, 0], + pitches: [pitch, 0, 0], + planes: 1, + total: pitch * h, + } + } + PixelFormat::I420 => { + let cw = w.div_ceil(2); + let ch = h.div_ceil(2); + let y = w * h; + let c = cw * ch; + PlaneLayout { + offsets: [0, y, y + c], + pitches: [w, cw, cw], + planes: 3, + total: y + 2 * c, + } + } + } + } +} + +/// Byte offsets and row strides of each plane inside a frame's single buffer. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct PlaneLayout { + pub offsets: [usize; 3], + pub pitches: [usize; 3], + pub planes: usize, + pub total: usize, +} + +/// How the YUV samples should be interpreted when converted to RGB. +/// +/// Getting this wrong is not cosmetic here: job 2 feeds a green-screen lower +/// third into an ATEM chroma keyer, and a mismatched matrix or range shifts the +/// key colour enough to matter. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Colorspace { + /// 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, +} + +pub struct Frame { + pub width: u32, + pub height: u32, + pub format: PixelFormat, + pub colorspace: Colorspace, + pub layout: PlaneLayout, + /// All planes packed contiguously, addressed through `layout`. + pub data: Vec, + /// Monotonic counter from the producer, for logging and drop accounting. + pub seq: u64, +} + +impl Frame { + pub fn plane(&self, i: usize) -> &[u8] { + let off = self.layout.offsets[i]; + let rows = match (self.format, i) { + (PixelFormat::I420, 1) | (PixelFormat::I420, 2) => (self.height as usize).div_ceil(2), + _ => self.height as usize, + }; + &self.data[off..off + self.layout.pitches[i] * rows] + } +} + +#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)] +pub struct SlotStats { + /// Frames published that were overwritten before the renderer took them. + pub dropped: u64, + /// Frames the renderer actually consumed. + pub taken: u64, +} + +struct SlotState { + ready: Option, + /// Recycled allocations. 1080p 4:2:2 is ~4 MB; without reuse we would + /// mmap/munmap that 30 times a second and eat the page faults. + spare: Vec>, + stats: SlotStats, + closed: bool, +} + +/// Single-frame mailbox. Producers overwrite, the consumer takes. +pub struct FrameSlot { + state: Mutex, + ready_cv: Condvar, +} + +/// How many spare buffers to keep. Two is enough for one producer and one +/// consumer holding a frame each. +const MAX_SPARE: usize = 2; + +impl Default for FrameSlot { + fn default() -> Self { + Self::new() + } +} + +impl FrameSlot { + pub fn new() -> Self { + FrameSlot { + state: Mutex::new(SlotState { + ready: None, + spare: Vec::new(), + stats: SlotStats::default(), + closed: false, + }), + ready_cv: Condvar::new(), + } + } + + /// Borrow a buffer of at least `len` bytes, reusing a recycled allocation + /// where possible. The returned vec has `len` initialised bytes. + pub fn take_buffer(&self, len: usize) -> Vec { + let mut st = self.state.lock().unwrap(); + let mut buf = st.spare.pop().unwrap_or_default(); + drop(st); + buf.clear(); + buf.resize(len, 0); + buf + } + + /// Hand a buffer back without publishing a frame (decode failed, etc). + pub fn recycle(&self, buf: Vec) { + let mut st = self.state.lock().unwrap(); + if st.spare.len() < MAX_SPARE { + st.spare.push(buf); + } + } + + /// Publish a frame, discarding any frame the consumer has not yet taken. + pub fn publish(&self, frame: Frame) { + let mut st = self.state.lock().unwrap(); + if let Some(old) = st.ready.take() { + st.stats.dropped += 1; + if st.spare.len() < MAX_SPARE { + st.spare.push(old.data); + } + } + st.ready = Some(frame); + drop(st); + self.ready_cv.notify_one(); + } + + /// Take the pending frame if there is one. Never blocks. + pub fn take(&self) -> Option { + let mut st = self.state.lock().unwrap(); + let f = st.ready.take(); + if f.is_some() { + st.stats.taken += 1; + } + f + } + + /// Take the pending frame, waiting up to `timeout` for one to arrive. + /// Returns `None` on timeout or once the slot is closed. + pub fn take_timeout(&self, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + let mut st = self.state.lock().unwrap(); + loop { + if let Some(f) = st.ready.take() { + st.stats.taken += 1; + return Some(f); + } + if st.closed { + return None; + } + let now = Instant::now(); + if now >= deadline { + return None; + } + let (guard, _) = self.ready_cv.wait_timeout(st, deadline - now).unwrap(); + st = guard; + } + } + + /// Wake any waiter and stop blocking. Used on shutdown. + pub fn close(&self) { + let mut st = self.state.lock().unwrap(); + st.closed = true; + drop(st); + self.ready_cv.notify_all(); + } + + pub fn stats(&self) -> SlotStats { + self.state.lock().unwrap().stats + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(seq: u64, fmt: PixelFormat) -> Frame { + let layout = fmt.layout(4, 4); + Frame { + width: 4, + height: 4, + format: fmt, + colorspace: Colorspace::Jpeg, + layout, + data: vec![seq as u8; layout.total], + seq, + } + } + + #[test] + fn yuy2_layout_is_two_bytes_per_pixel() { + let l = PixelFormat::Yuy2.layout(1920, 1080); + assert_eq!(l.planes, 1); + assert_eq!(l.pitches[0], 1920 * 2); + assert_eq!(l.total, 1920 * 1080 * 2); + } + + #[test] + fn i420_layout_has_three_planes_at_one_and_a_half_bytes_per_pixel() { + let l = PixelFormat::I420.layout(1920, 1080); + assert_eq!(l.planes, 3); + assert_eq!(l.pitches, [1920, 960, 960]); + assert_eq!(l.offsets, [0, 1920 * 1080, 1920 * 1080 + 960 * 540]); + assert_eq!(l.total, 1920 * 1080 * 3 / 2); + } + + #[test] + fn bgrx_layout_is_four_bytes_per_pixel() { + let l = PixelFormat::Bgrx.layout(1920, 1080); + assert_eq!(l.total, 1920 * 1080 * 4); + } + + #[test] + fn plane_slices_cover_the_whole_buffer_without_overlap() { + let f = frame(1, PixelFormat::I420); + let total: usize = (0..3).map(|i| f.plane(i).len()).sum(); + assert_eq!(total, f.layout.total); + } + + #[test] + fn slot_returns_the_published_frame() { + let slot = FrameSlot::new(); + assert!(slot.take().is_none()); + slot.publish(frame(7, PixelFormat::Yuy2)); + assert_eq!(slot.take().unwrap().seq, 7); + assert!(slot.take().is_none()); + } + + /// The central latency guarantee: a backlog can never form. + #[test] + fn slot_keeps_newest_and_drops_stale_frames() { + let slot = FrameSlot::new(); + for seq in 0..10 { + slot.publish(frame(seq, PixelFormat::Yuy2)); + } + let got = slot.take().expect("a frame should be pending"); + assert_eq!(got.seq, 9, "consumer must see the newest frame"); + assert_eq!(slot.stats().dropped, 9, "the other nine must be counted as dropped"); + assert_eq!(slot.stats().taken, 1); + } + + #[test] + fn slot_recycles_buffers_from_dropped_frames() { + let slot = FrameSlot::new(); + slot.publish(frame(0, PixelFormat::Yuy2)); + slot.publish(frame(1, PixelFormat::Yuy2)); // drops frame 0, recycling it + + // The recycled allocation is handed back out rather than a fresh one. + let buf = slot.take_buffer(8); + assert_eq!(buf.len(), 8); + assert!(buf.capacity() >= 8); + } + + #[test] + fn spare_pool_is_bounded() { + let slot = FrameSlot::new(); + for _ in 0..10 { + slot.recycle(vec![0u8; 32]); + } + assert!(slot.state.lock().unwrap().spare.len() <= MAX_SPARE); + } + + #[test] + fn take_timeout_returns_none_when_no_frame_arrives() { + let slot = FrameSlot::new(); + let t0 = Instant::now(); + assert!(slot.take_timeout(Duration::from_millis(50)).is_none()); + assert!(t0.elapsed() >= Duration::from_millis(40)); + } + + #[test] + fn take_timeout_wakes_on_publish() { + use std::sync::Arc; + let slot = Arc::new(FrameSlot::new()); + let producer = Arc::clone(&slot); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + producer.publish(frame(42, PixelFormat::Yuy2)); + }); + let got = slot.take_timeout(Duration::from_secs(5)).expect("should wake"); + assert_eq!(got.seq, 42); + } + + #[test] + fn close_unblocks_a_waiting_consumer() { + use std::sync::Arc; + let slot = Arc::new(FrameSlot::new()); + let closer = Arc::clone(&slot); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + closer.close(); + }); + assert!(slot.take_timeout(Duration::from_secs(5)).is_none()); + } +} + +/// Source of reusable frame allocations. Implemented by [`FrameSlot`]; tests +/// use a throwaway implementation so decoding can be exercised without the +/// threading machinery. +pub trait BufferPool { + fn take_buffer(&self, len: usize) -> Vec; + fn recycle(&self, buf: Vec); +} + +impl BufferPool for FrameSlot { + fn take_buffer(&self, len: usize) -> Vec { + FrameSlot::take_buffer(self, len) + } + fn recycle(&self, buf: Vec) { + FrameSlot::recycle(self, buf) + } +} + +/// Allocates fresh every time. For tests and one-shot paths. +pub struct NullPool; + +impl BufferPool for NullPool { + fn take_buffer(&self, len: usize) -> Vec { + vec![0u8; len] + } + fn recycle(&self, _buf: Vec) {} +} diff --git a/src/inputs/mjpeg.rs b/src/inputs/mjpeg.rs new file mode 100644 index 0000000..f7bf025 --- /dev/null +++ b/src/inputs/mjpeg.rs @@ -0,0 +1,596 @@ +//! MJPEG decoding through libjpeg-turbo's TurboJPEG API. +//! +//! The ATEM Mini Pro emits baseline 4:2:2 JPEG. Rather than paying for a CPU +//! YUV->RGB conversion and a 4-bytes-per-pixel upload, we pull the raw planes +//! out of the decoder, interleave them into packed YUY2 (one sequential pass, +//! 2 bytes per pixel) and let the GPU's sampler do the colour conversion. +//! +//! 4:2:0 sources skip even the interleave: their planes go straight into the +//! frame buffer in I420 order. Anything else falls back to BGRX, which is +//! always correct but does the conversion on the CPU. + +use crate::frame::{BufferPool, Colorspace, Frame, PixelFormat}; +use std::ffi::{c_char, c_int, c_uchar, c_void}; +use std::fmt; + +// --- TurboJPEG FFI ---------------------------------------------------------- +// Values transcribed from turbojpeg.h (libjpeg-turbo 3.x). The API is an +// opaque handle plus plain ints, so there is no struct layout to get wrong. + +type TjHandle = *mut c_void; + +const TJINIT_DECOMPRESS: c_int = 1; + +const TJPARAM_STOPONWARNING: c_int = 0; +const TJPARAM_SUBSAMP: c_int = 4; +const TJPARAM_JPEGWIDTH: c_int = 5; +const TJPARAM_JPEGHEIGHT: c_int = 6; +const TJPARAM_PRECISION: c_int = 7; +const TJPARAM_COLORSPACE: c_int = 8; +const TJPARAM_MAXPIXELS: c_int = 24; + +const TJSAMP_444: c_int = 0; +const TJSAMP_422: c_int = 1; +const TJSAMP_420: c_int = 2; +const TJSAMP_GRAY: c_int = 3; + +const TJPF_BGRX: c_int = 3; + +const TJERR_WARNING: c_int = 0; + +const TJCS_RGB: c_int = 0; +const TJCS_YCBCR: c_int = 1; +const TJCS_GRAY: c_int = 2; +const TJCS_CMYK: c_int = 3; +const TJCS_YCCK: c_int = 4; + +#[link(name = "turbojpeg")] +extern "C" { + fn tj3Init(init_type: c_int) -> TjHandle; + fn tj3Destroy(handle: TjHandle); + fn tj3GetErrorStr(handle: TjHandle) -> *const c_char; + fn tj3GetErrorCode(handle: TjHandle) -> c_int; + fn tj3Set(handle: TjHandle, param: c_int, value: c_int) -> c_int; + fn tj3Get(handle: TjHandle, param: c_int) -> c_int; + fn tj3DecompressHeader(handle: TjHandle, jpeg_buf: *const c_uchar, jpeg_size: usize) -> c_int; + fn tj3Decompress8( + handle: TjHandle, + jpeg_buf: *const c_uchar, + jpeg_size: usize, + dst_buf: *mut c_uchar, + pitch: c_int, + pixel_format: c_int, + ) -> c_int; + fn tj3DecompressToYUVPlanes8( + handle: TjHandle, + jpeg_buf: *const c_uchar, + jpeg_size: usize, + dst_planes: *mut *mut c_uchar, + strides: *mut c_int, + ) -> c_int; +} + +/// Refuse absurd dimensions before allocating. 8K worth of pixels is far beyond +/// anything this appliance handles and keeps a corrupt header from asking for a +/// multi-gigabyte buffer. +const MAX_PIXELS: c_int = 7680 * 4320; + +#[derive(Debug)] +pub enum DecodeError { + Init(String), + Header(String), + Decode(String), + /// Header parsed but described something we will not render. + Unsupported(String), +} + +impl fmt::Display for DecodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DecodeError::Init(m) => write!(f, "turbojpeg init failed: {m}"), + DecodeError::Header(m) => write!(f, "bad JPEG header: {m}"), + DecodeError::Decode(m) => write!(f, "JPEG decode failed: {m}"), + DecodeError::Unsupported(m) => write!(f, "unsupported JPEG: {m}"), + } + } +} + +impl std::error::Error for DecodeError {} + +/// Description of the incoming JPEG, read from its header. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct JpegInfo { + pub width: u32, + pub height: u32, + pub subsamp: i32, + pub colorspace: i32, +} + +impl JpegInfo { + /// Which SDL-uploadable layout this JPEG decodes into most cheaply. + pub fn target_format(&self) -> PixelFormat { + match self.subsamp { + TJSAMP_422 => PixelFormat::Yuy2, + TJSAMP_420 => PixelFormat::I420, + // 4:4:4, 4:4:0, 4:1:1, grayscale and anything else: let turbojpeg + // produce packed BGRX rather than growing a converter per layout. + _ => PixelFormat::Bgrx, + } + } + + pub fn colorspace_name(&self) -> &'static str { + match self.colorspace { + TJCS_RGB => "RGB", + TJCS_YCBCR => "YCbCr", + TJCS_GRAY => "grayscale", + TJCS_CMYK => "CMYK", + TJCS_YCCK => "YCCK", + _ => "unknown", + } + } + + pub fn subsamp_name(&self) -> &'static str { + match self.subsamp { + TJSAMP_444 => "4:4:4", + TJSAMP_422 => "4:2:2", + TJSAMP_420 => "4:2:0", + TJSAMP_GRAY => "grayscale", + 4 => "4:4:0", + 5 => "4:1:1", + 6 => "4:4:1", + _ => "unknown", + } + } +} + +/// How far back from the end to look for the EOI marker. +/// +/// The ATEM leaves 2-4 bytes of padding there; other devices append a handful +/// of trailing zeros. Anything further than this and the frame is not simply +/// padded, it is damaged. +const EOI_SEARCH_WINDOW: usize = 64; + +/// Cheap structural check for a whole JPEG: starts with SOI, ends with EOI. +/// +/// This is what separates "padded" from "truncated". Entropy-coded data can +/// never contain a bare `FF D9` (an `FF` in the entropy stream is always +/// followed by a stuffed `00` or a restart marker), so finding EOI near the end +/// is a reliable signal that the frame arrived whole. +pub fn looks_complete(jpeg: &[u8]) -> bool { + if jpeg.len() < 4 || jpeg[0] != 0xFF || jpeg[1] != 0xD8 { + return false; + } + let start = jpeg.len().saturating_sub(EOI_SEARCH_WINDOW).max(2); + jpeg[start..].windows(2).any(|w| w == [0xFF, 0xD9]) +} + +pub struct MjpegDecoder { + handle: TjHandle, + /// Scratch planar buffer for the 4:2:2 path, reused across frames. + planar: Vec, + /// Frames that decoded fully but tripped a libjpeg warning. + warnings: u64, +} + +// SAFETY: a tjhandle is not shared; the decoder is owned by exactly one thread +// and every call goes through `&mut self`. +unsafe impl Send for MjpegDecoder {} + +impl MjpegDecoder { + pub fn new() -> Result { + let mut d = MjpegDecoder { handle: std::ptr::null_mut(), planar: Vec::new(), warnings: 0 }; + d.reinit()?; + Ok(d) + } + + /// Builds a fresh decompressor, discarding any existing one. + /// + /// libjpeg keeps error and warning state on the handle. A truncated frame + /// leaves "Premature end of JPEG file" latched, and every subsequent + /// *valid* frame then fails -- one bad USB frame would kill the feed for + /// good. Rebuilding the handle after a failure is the only reliable way to + /// clear that, and it costs nothing on the happy path. + fn reinit(&mut self) -> Result<(), DecodeError> { + if !self.handle.is_null() { + // SAFETY: handle came from tj3Init and is destroyed once. + unsafe { tj3Destroy(self.handle) }; + self.handle = std::ptr::null_mut(); + } + // SAFETY: plain C call with a constant argument. + let handle = unsafe { tj3Init(TJINIT_DECOMPRESS) }; + if handle.is_null() { + return Err(DecodeError::Init("tj3Init returned NULL".into())); + } + self.handle = handle; + // Warnings are NOT treated as fatal. The ATEM pads each frame with a + // couple of bytes before the EOI marker, which makes libjpeg warn + // ("extraneous bytes before marker 0xd9") on an otherwise perfect + // image -- rejecting those would drop about 97% of frames. Truncated + // frames are caught structurally by `looks_complete` instead. + self.set(TJPARAM_STOPONWARNING, 0)?; + self.set(TJPARAM_MAXPIXELS, MAX_PIXELS)?; + Ok(()) + } + + /// Clears latched libjpeg state after a failed frame. Best effort: if the + /// rebuild itself fails the handle is left null and later calls report it + /// rather than dereferencing it. + fn recover(&mut self) { + if let Err(e) = self.reinit() { + crate::warn_!("could not reinitialise the JPEG decoder: {e}"); + } + } + + fn set(&self, param: c_int, value: c_int) -> Result<(), DecodeError> { + // SAFETY: `self.handle` is a live decompressor handle. + if unsafe { tj3Set(self.handle, param, value) } < 0 { + return Err(DecodeError::Init(self.last_error())); + } + Ok(()) + } + + /// Interprets a TurboJPEG return code. + /// + /// TurboJPEG returns -1 for a *warning* as well as for a hard failure + /// (`if (this->jerr.warning) retval = -1;` in turbojpeg.c), and only + /// `tj3GetErrorCode` tells them apart. On a warning libjpeg still finished + /// and the output buffer is complete, so the frame is kept. + /// + /// This is the ATEM's everyday case: it pads each frame with a few bytes + /// before the EOI marker, which warns on every single frame. Treating that + /// as failure discards the entire stream. + fn check(&mut self, rc: c_int, what: &str) -> Result<(), DecodeError> { + if rc >= 0 { + return Ok(()); + } + // SAFETY: handle is live whenever a tj3 call has just returned. + let code = unsafe { tj3GetErrorCode(self.handle) }; + if code == TJERR_WARNING { + self.warnings = self.warnings.saturating_add(1); + return Ok(()); + } + let msg = self.last_error(); + self.recover(); + Err(DecodeError::Decode(format!("{what}: {msg}"))) + } + + /// Number of frames decoded with a recoverable warning since the last call. + pub fn take_warning_count(&mut self) -> u64 { + std::mem::take(&mut self.warnings) + } + + fn last_error(&self) -> String { + // SAFETY: tj3GetErrorStr always returns a valid NUL-terminated string + // for a live handle. + unsafe { + let p = tj3GetErrorStr(self.handle); + if p.is_null() { + "unknown turbojpeg error".to_string() + } else { + std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned() + } + } + } + + /// Reads dimensions and subsampling without decoding pixel data. + pub fn read_header(&mut self, jpeg: &[u8]) -> Result { + if self.handle.is_null() { + self.reinit()?; + } + if jpeg.len() < 4 { + return Err(DecodeError::Header(format!("frame too short ({} bytes)", jpeg.len()))); + } + if !looks_complete(jpeg) { + return Err(DecodeError::Header(format!( + "incomplete frame ({} bytes, no end-of-image marker)", + jpeg.len() + ))); + } + // SAFETY: pointer and length describe the caller's live slice. + let rc = unsafe { tj3DecompressHeader(self.handle, jpeg.as_ptr(), jpeg.len()) }; + self.check(rc, "reading the header") + .map_err(|e| DecodeError::Header(e.to_string()))?; + // SAFETY: handle is live; these params are always readable post-header. + let (w, h, subsamp, colorspace, precision) = unsafe { + ( + tj3Get(self.handle, TJPARAM_JPEGWIDTH), + tj3Get(self.handle, TJPARAM_JPEGHEIGHT), + tj3Get(self.handle, TJPARAM_SUBSAMP), + tj3Get(self.handle, TJPARAM_COLORSPACE), + tj3Get(self.handle, TJPARAM_PRECISION), + ) + }; + if w <= 0 || h <= 0 { + self.recover(); + return Err(DecodeError::Header(format!("nonsensical dimensions {w}x{h}"))); + } + if precision != 8 { + self.recover(); + return Err(DecodeError::Unsupported(format!("{precision}-bit samples"))); + } + Ok(JpegInfo { + width: w as u32, + height: h as u32, + subsamp, + colorspace, + }) + } + + /// Decodes one JPEG into a [`Frame`] backed by an allocation from `pool`. + /// + /// On failure the borrowed buffer is returned to the pool, so a stream of + /// corrupt frames neither leaks nor thrashes the allocator. + pub fn decode( + &mut self, + jpeg: &[u8], + pool: &dyn BufferPool, + seq: u64, + ) -> Result { + let info = self.read_header(jpeg)?; + let format = info.target_format(); + let layout = format.layout(info.width, info.height); + let mut buf = pool.take_buffer(layout.total); + + let result = match format { + PixelFormat::Yuy2 => self.decode_422_to_yuy2(jpeg, &info, &mut buf, layout.pitches[0]), + PixelFormat::I420 => self.decode_420_to_i420(jpeg, &info, &mut buf, &layout), + PixelFormat::Bgrx => self.decode_to_bgrx(jpeg, &mut buf, layout.pitches[0]), + PixelFormat::Uyvy => unreachable!("MJPEG never targets UYVY"), + }; + + if let Err(e) = result { + // `check` has already rebuilt the handle on a fatal error. + pool.recycle(buf); + return Err(e); + } + + Ok(Frame { + 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, + layout, + data: buf, + seq, + }) + } + + /// 4:2:2: decode to planes, then interleave to Y0 U Y1 V. + fn decode_422_to_yuy2( + &mut self, + jpeg: &[u8], + info: &JpegInfo, + out: &mut [u8], + out_pitch: usize, + ) -> Result<(), DecodeError> { + let w = info.width as usize; + let h = info.height as usize; + let cw = w.div_ceil(2); + + let y_size = w * h; + let c_size = cw * h; + let need = y_size + 2 * c_size; + if self.planar.len() != need { + self.planar.resize(need, 0); + } + + let rc = { + let (y_plane, rest) = self.planar.split_at_mut(y_size); + let (u_plane, v_plane) = rest.split_at_mut(c_size); + let mut planes: [*mut c_uchar; 3] = + [y_plane.as_mut_ptr(), u_plane.as_mut_ptr(), v_plane.as_mut_ptr()]; + let mut strides: [c_int; 3] = [w as c_int, cw as c_int, cw as c_int]; + + // SAFETY: the three plane pointers address disjoint regions of + // `self.planar`, each large enough for `stride * h` bytes as + // required for 4:2:2 output. + unsafe { + tj3DecompressToYUVPlanes8( + self.handle, + jpeg.as_ptr(), + jpeg.len(), + planes.as_mut_ptr(), + strides.as_mut_ptr(), + ) + } + }; + self.check(rc, "decoding 4:2:2 planes")?; + + let (y_plane, rest) = self.planar.split_at(y_size); + let (u_plane, v_plane) = rest.split_at(c_size); + interleave_422_to_yuy2(y_plane, u_plane, v_plane, w, h, cw, out, out_pitch); + Ok(()) + } + + /// 4:2:0: the frame buffer already has the I420 plane order SDL wants, so + /// point the decoder straight at it. + fn decode_420_to_i420( + &mut self, + jpeg: &[u8], + info: &JpegInfo, + out: &mut [u8], + layout: &crate::frame::PlaneLayout, + ) -> Result<(), DecodeError> { + debug_assert_eq!(out.len(), layout.total); + let base = out.as_mut_ptr(); + // SAFETY: the three offsets come from `PixelFormat::I420.layout`, which + // partitions exactly `layout.total` bytes into non-overlapping planes, + // and `out` is that long. + let mut planes: [*mut c_uchar; 3] = unsafe { + [ + base.add(layout.offsets[0]), + base.add(layout.offsets[1]), + base.add(layout.offsets[2]), + ] + }; + let mut strides: [c_int; 3] = [ + layout.pitches[0] as c_int, + layout.pitches[1] as c_int, + layout.pitches[2] as c_int, + ]; + let _ = info; + // SAFETY: as above; planes and strides describe a valid I420 buffer. + let rc = unsafe { + tj3DecompressToYUVPlanes8( + self.handle, + jpeg.as_ptr(), + jpeg.len(), + planes.as_mut_ptr(), + strides.as_mut_ptr(), + ) + }; + self.check(rc, "decoding 4:2:0 planes") + } + + fn decode_to_bgrx(&mut self, jpeg: &[u8], out: &mut [u8], pitch: usize) -> Result<(), DecodeError> { + // SAFETY: `out` is `pitch * height` bytes, matching what turbojpeg + // writes for TJPF_BGRX at the header's dimensions. + let rc = unsafe { + tj3Decompress8( + self.handle, + jpeg.as_ptr(), + jpeg.len(), + out.as_mut_ptr(), + pitch as c_int, + TJPF_BGRX, + ) + }; + self.check(rc, "decoding to BGRX") + } +} + +impl Drop for MjpegDecoder { + fn drop(&mut self) { + if !self.handle.is_null() { + // SAFETY: handle was produced by tj3Init and is dropped once. + unsafe { tj3Destroy(self.handle) }; + } + } +} + +/// Interleaves planar 4:2:2 into packed YUY2 (Y0 U Y1 V). +/// +/// Split out from the FFI so it can be tested directly. +pub fn interleave_422_to_yuy2( + y: &[u8], + u: &[u8], + v: &[u8], + width: usize, + height: usize, + chroma_width: usize, + out: &mut [u8], + out_pitch: usize, +) { + let pairs = width / 2; + for row in 0..height { + let y_row = &y[row * width..row * width + width]; + let u_row = &u[row * chroma_width..row * chroma_width + chroma_width]; + let v_row = &v[row * chroma_width..row * chroma_width + chroma_width]; + let o_row = &mut out[row * out_pitch..row * out_pitch + pairs * 4]; + + for (i, quad) in o_row.chunks_exact_mut(4).enumerate() { + quad[0] = y_row[2 * i]; + quad[1] = u_row[i]; + quad[2] = y_row[2 * i + 1]; + quad[3] = v_row[i]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::NullPool; + + #[test] + fn interleave_produces_yuy2_byte_order() { + // 4x2 image, chroma width 2. + let y = vec![10, 11, 12, 13, /**/ 20, 21, 22, 23]; + let u = vec![100, 101, /**/ 110, 111]; + let v = vec![200, 201, /**/ 210, 211]; + let mut out = vec![0u8; 4 * 2 * 2]; + + interleave_422_to_yuy2(&y, &u, &v, 4, 2, 2, &mut out, 8); + + assert_eq!(&out[0..8], &[10, 100, 11, 200, 12, 101, 13, 201]); + assert_eq!(&out[8..16], &[20, 110, 21, 210, 22, 111, 23, 211]); + } + + #[test] + fn target_format_follows_subsampling() { + let base = JpegInfo { width: 1920, height: 1080, subsamp: TJSAMP_422, colorspace: TJCS_YCBCR }; + assert_eq!(base.target_format(), PixelFormat::Yuy2); + assert_eq!(JpegInfo { subsamp: TJSAMP_420, ..base }.target_format(), PixelFormat::I420); + assert_eq!(JpegInfo { subsamp: TJSAMP_444, ..base }.target_format(), PixelFormat::Bgrx); + assert_eq!( + JpegInfo { subsamp: TJSAMP_GRAY, colorspace: TJCS_GRAY, ..base }.target_format(), + PixelFormat::Bgrx + ); + } + + #[test] + fn subsamp_and_colorspace_names_are_human_readable() { + let i = JpegInfo { width: 2, height: 2, subsamp: TJSAMP_422, colorspace: TJCS_YCBCR }; + assert_eq!(i.subsamp_name(), "4:2:2"); + assert_eq!(i.colorspace_name(), "YCbCr"); + assert_eq!(JpegInfo { subsamp: TJSAMP_GRAY, colorspace: TJCS_GRAY, ..i }.colorspace_name(), "grayscale"); + } + + #[test] + fn looks_complete_accepts_a_padded_frame_and_rejects_a_truncated_one() { + // Minimal shapes: the predicate only inspects the ends. + let whole = [0xFFu8, 0xD8, 0x11, 0x22, 0xFF, 0xD9]; + assert!(looks_complete(&whole)); + + // The ATEM's quirk: two stray bytes before EOI. + let padded = [0xFFu8, 0xD8, 0x11, 0x22, 0x00, 0x00, 0xFF, 0xD9]; + assert!(looks_complete(&padded), "padding before EOI is benign"); + + // Trailing zeros after EOI, as some devices emit. + let trailing = [0xFFu8, 0xD8, 0x11, 0xFF, 0xD9, 0, 0, 0, 0]; + assert!(looks_complete(&trailing)); + + assert!(!looks_complete(&[0xFF, 0xD8, 0x11, 0x22]), "no EOI at all"); + assert!(!looks_complete(&[0x00, 0x00, 0xFF, 0xD9]), "no SOI"); + assert!(!looks_complete(&[])); + assert!(!looks_complete(&[0xFF])); + } + + #[test] + fn looks_complete_rejects_an_eoi_that_is_too_far_from_the_end() { + let mut v = vec![0xFFu8, 0xD8, 0xFF, 0xD9]; + v.extend(std::iter::repeat(0u8).take(EOI_SEARCH_WINDOW * 2)); + assert!(!looks_complete(&v), "EOI buried far from the end means damage, not padding"); + } + + #[test] + fn decoder_initialises() { + MjpegDecoder::new().expect("turbojpeg should initialise"); + } + + #[test] + fn empty_input_is_rejected_without_panicking() { + let mut d = MjpegDecoder::new().unwrap(); + assert!(matches!(d.read_header(&[]), Err(DecodeError::Header(_)))); + assert!(d.decode(&[], &NullPool, 0).is_err()); + } + + #[test] + fn garbage_input_is_rejected_without_panicking() { + let mut d = MjpegDecoder::new().unwrap(); + let junk: Vec = (0..4096u32).map(|i| (i * 7 % 251) as u8).collect(); + assert!(d.decode(&junk, &NullPool, 0).is_err()); + } + + /// A real JPEG header followed by nothing must fail cleanly rather than + /// reading past the buffer. + #[test] + fn truncated_jpeg_is_rejected_without_panicking() { + let mut d = MjpegDecoder::new().unwrap(); + let mut buf = vec![0xFFu8, 0xD8, 0xFF, 0xDB]; + buf.extend_from_slice(&[0u8; 64]); + assert!(d.decode(&buf, &NullPool, 0).is_err()); + } +} diff --git a/src/inputs/mod.rs b/src/inputs/mod.rs new file mode 100644 index 0000000..d96ac64 --- /dev/null +++ b/src/inputs/mod.rs @@ -0,0 +1,216 @@ +//! Input backends and the supervision they share. +//! +//! Every backend runs on its own thread and follows the same shape: +//! +//! ```text +//! loop { +//! connect() -> on failure: log once, back off, retry +//! stream frames -> publish into the FrameSlot, newest wins +//! on stall or error -> mark the signal lost, tear down, retry +//! } +//! ``` +//! +//! Nothing here ever exits the process. A camera unplugged mid-service or a +//! ProPresenter machine that stops sending must leave the window up and black, +//! then recover on its own. + +pub mod mjpeg; +pub mod ndi; +pub mod v4l2; +pub mod v4l2_sys; + +use crate::frame::FrameSlot; +use crate::{info, warn_}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +/// Shared handles given to every input thread. +pub struct InputCtx { + pub slot: Arc, + pub shutdown: Arc, + pub signal: Arc, +} + +impl InputCtx { + pub fn should_stop(&self) -> bool { + self.shutdown.load(Ordering::Relaxed) + } + + /// Sleeps in short slices so shutdown is responsive during a long backoff. + pub fn sleep_interruptible(&self, total: Duration) { + let step = Duration::from_millis(100); + let mut left = total; + while left > Duration::ZERO && !self.should_stop() { + let s = step.min(left); + std::thread::sleep(s); + left -= s; + } + } +} + +/// Exponential backoff with a ceiling. +/// +/// Reconnection is cheap and the operator wants the picture back fast, so the +/// first few retries are quick; the ceiling stops a permanently absent device +/// from spinning. +#[derive(Debug, Clone)] +pub struct Backoff { + base: Duration, + max: Duration, + attempt: u32, +} + +impl Backoff { + pub fn new(base: Duration, max: Duration) -> Self { + Backoff { base, max, attempt: 0 } + } + + /// Default schedule: 250 ms doubling to a 5 s ceiling. + pub fn standard() -> Self { + Backoff::new(Duration::from_millis(250), Duration::from_secs(5)) + } + + /// Returns the delay to wait before the next attempt and advances. + pub fn next_delay(&mut self) -> Duration { + let shift = self.attempt.min(16); + let d = self.base.saturating_mul(1u32 << shift); + self.attempt = self.attempt.saturating_add(1); + d.min(self.max) + } + + /// Call after a successful connection so the next outage retries fast. + pub fn reset(&mut self) { + self.attempt = 0; + } + + pub fn attempts(&self) -> u32 { + self.attempt + } +} + +/// Edge-triggered stream status. +/// +/// The point is log discipline: a camera that is unplugged for twenty minutes +/// produces exactly one "signal lost" line, not one per retry. +pub struct SignalState { + label: &'static str, + active: AtomicBool, + /// Whether the stream has ever been up, so the first success reads + /// "stream active" rather than "signal restored". + seen: AtomicBool, +} + +impl SignalState { + pub fn new(label: &'static str) -> Self { + SignalState { + label, + active: AtomicBool::new(false), + seen: AtomicBool::new(false), + } + } + + pub fn is_active(&self) -> bool { + self.active.load(Ordering::Relaxed) + } + + /// Marks the stream up. Returns true if this was a transition. + pub fn set_active(&self) -> bool { + if self.active.swap(true, Ordering::Relaxed) { + return false; + } + if self.seen.swap(true, Ordering::Relaxed) { + info!("{} signal restored", self.label); + } else { + info!("{} stream active", self.label); + } + true + } + + /// Marks the stream down. Returns true if this was a transition. + pub fn set_lost(&self, reason: &str) -> bool { + if !self.active.swap(false, Ordering::Relaxed) { + return false; + } + warn_!("{} signal lost: {}", self.label, reason); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_doubles_up_to_the_ceiling() { + let mut b = Backoff::new(Duration::from_millis(250), Duration::from_secs(5)); + assert_eq!(b.next_delay(), Duration::from_millis(250)); + assert_eq!(b.next_delay(), Duration::from_millis(500)); + assert_eq!(b.next_delay(), Duration::from_secs(1)); + assert_eq!(b.next_delay(), Duration::from_secs(2)); + assert_eq!(b.next_delay(), Duration::from_secs(4)); + assert_eq!(b.next_delay(), Duration::from_secs(5), "clamped"); + assert_eq!(b.next_delay(), Duration::from_secs(5), "stays clamped"); + } + + #[test] + fn backoff_reset_returns_to_the_fast_first_retry() { + let mut b = Backoff::standard(); + for _ in 0..10 { + b.next_delay(); + } + assert_eq!(b.next_delay(), Duration::from_secs(5)); + b.reset(); + assert_eq!(b.attempts(), 0); + assert_eq!(b.next_delay(), Duration::from_millis(250)); + } + + /// A very long outage must not overflow the shift or the multiply. + #[test] + fn backoff_survives_a_huge_attempt_count() { + let mut b = Backoff::standard(); + for _ in 0..100_000 { + let d = b.next_delay(); + assert!(d <= Duration::from_secs(5)); + } + } + + #[test] + fn signal_transitions_are_edge_triggered() { + let s = SignalState::new("Camera"); + + assert!(s.set_active(), "first activation is a transition"); + assert!(!s.set_active(), "already active"); + assert!(s.is_active()); + + assert!(s.set_lost("timeout"), "first loss is a transition"); + assert!(!s.set_lost("timeout"), "already lost, stays quiet"); + assert!(!s.is_active()); + + assert!(s.set_active(), "recovery is a transition"); + } + + #[test] + fn signal_starts_inactive_and_loss_before_first_frame_is_quiet() { + let s = SignalState::new("NDI"); + assert!(!s.is_active()); + assert!(!s.set_lost("never connected"), "no transition from the initial state"); + } + + #[test] + fn sleep_interruptible_returns_early_once_shutdown_is_set() { + let ctx = InputCtx { + slot: Arc::new(FrameSlot::new()), + shutdown: Arc::new(AtomicBool::new(false)), + signal: Arc::new(SignalState::new("test")), + }; + let flag = Arc::clone(&ctx.shutdown); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(50)); + flag.store(true, Ordering::Relaxed); + }); + let t0 = std::time::Instant::now(); + ctx.sleep_interruptible(Duration::from_secs(30)); + assert!(t0.elapsed() < Duration::from_secs(2), "should not wait the full 30s"); + } +} diff --git a/src/inputs/ndi.rs b/src/inputs/ndi.rs new file mode 100644 index 0000000..1587a56 --- /dev/null +++ b/src/inputs/ndi.rs @@ -0,0 +1,19 @@ +//! NDI receiver. Implemented in phase 2. + +use super::InputCtx; + +#[derive(Clone, Debug)] +pub struct NdiConfig { + pub source: Option, + pub extra_ips: Vec, + pub stats_interval: std::time::Duration, +} + +pub fn run(_ctx: &InputCtx, _cfg: &NdiConfig) { + crate::error!("the NDI backend is not implemented yet"); +} + +pub fn list_sources(_timeout_secs: u64) -> i32 { + crate::error!("the NDI backend is not implemented yet"); + 1 +} diff --git a/src/inputs/v4l2.rs b/src/inputs/v4l2.rs new file mode 100644 index 0000000..36e61f1 --- /dev/null +++ b/src/inputs/v4l2.rs @@ -0,0 +1,788 @@ +//! V4L2 memory-mapped capture. +//! +//! Streams MJPEG (or an uncompressed format, if a device offers one) from a +//! capture node, decodes each frame and publishes it to the renderer's slot. +//! +//! Two places discard stale data, both deliberate: +//! * the driver queue is drained to its newest buffer before decoding, so a +//! slow decode never replays a backlog; +//! * the frame slot keeps only the newest decoded frame. + +use super::mjpeg::MjpegDecoder; +use super::v4l2_sys::*; +use super::InputCtx; +use crate::frame::{Colorspace, PixelFormat}; +use crate::{debug, error, info, log, warn_}; +use std::io; +use std::os::fd::RawFd; +use std::time::{Duration, Instant}; + +/// Driver-side buffers. Enough to absorb scheduling jitter, few enough that a +/// full queue represents ~130 ms rather than a deep backlog. +const BUFFER_COUNT: u32 = 4; + +/// How long without a frame before the stream counts as lost. +const SIGNAL_TIMEOUT: Duration = Duration::from_millis(2000); + +/// Single `poll` wait. Short enough to notice shutdown promptly. +const POLL_TIMEOUT_MS: i32 = 250; + +#[derive(Clone, Debug)] +pub struct CameraConfig { + pub device: String, + pub width: u32, + pub height: u32, + pub fps_num: u32, + pub fps_den: u32, + pub stats_interval: Duration, +} + +struct MappedBuffer { + ptr: *mut u8, + len: usize, +} + +impl MappedBuffer { + /// # Safety + /// Only valid while the owning `Device` is alive and the buffer is + /// dequeued (not owned by the driver). + unsafe fn as_slice(&self, used: usize) -> &[u8] { + unsafe { std::slice::from_raw_parts(self.ptr, used.min(self.len)) } + } +} + +/// An open, configured capture node. +struct Device { + fd: RawFd, + path: String, + buffers: Vec, + streaming: bool, + /// Card, driver and bus, as reported by VIDIOC_QUERYCAP. + identity: String, + pub negotiated: NegotiatedFormat, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NegotiatedFormat { + pub width: u32, + pub height: u32, + pub fourcc: u32, + pub fps_num: u32, + pub fps_den: u32, +} + +fn open_fd(path: &str) -> io::Result { + let c = std::ffi::CString::new(path).map_err(|_| io::Error::other("device path contains NUL"))?; + // SAFETY: `c` is a valid NUL-terminated path for the duration of the call. + let fd = unsafe { libc::open(c.as_ptr(), libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + Ok(fd) +} + +impl Device { + fn open(cfg: &CameraConfig) -> io::Result { + let fd = open_fd(&cfg.device)?; + let mut dev = Device { + fd, + path: cfg.device.clone(), + buffers: Vec::new(), + streaming: false, + identity: String::new(), + negotiated: NegotiatedFormat { + width: 0, + height: 0, + fourcc: 0, + fps_num: 0, + fps_den: 0, + }, + }; + dev.check_caps()?; + dev.set_format(cfg)?; + dev.set_frame_rate(cfg)?; + dev.map_buffers()?; + Ok(dev) + } + + fn check_caps(&mut self) -> io::Result<()> { + let mut cap: v4l2_capability = unsafe { std::mem::zeroed() }; + // SAFETY: QUERYCAP writes a v4l2_capability, which is what we pass. + unsafe { ioctl(self.fd, VIDIOC_QUERYCAP, &mut cap)? }; + + let effective = if cap.capabilities & V4L2_CAP_DEVICE_CAPS != 0 { + cap.device_caps + } else { + cap.capabilities + }; + + // Recorded rather than logged here: `check_caps` runs on every retry, + // and a device that is missing for an hour must not narrate each one. + self.identity = format!( + "{} (driver {}, bus {})", + cstr_field(&cap.card), + cstr_field(&cap.driver), + cstr_field(&cap.bus_info) + ); + debug!("V4L2 device {} -> {}", self.path, self.identity); + + if effective & V4L2_CAP_VIDEO_CAPTURE == 0 { + return Err(io::Error::other(format!( + "{} is not a video capture node (it may be a metadata node; try another /dev/videoN)", + self.path + ))); + } + if effective & V4L2_CAP_STREAMING == 0 { + return Err(io::Error::other(format!("{} does not support streaming I/O", self.path))); + } + Ok(()) + } + + fn set_format(&mut self, cfg: &CameraConfig) -> io::Result<()> { + let mut fmt = v4l2_format::new_capture(); + let mut pix = v4l2_pix_format::default(); + pix.width = cfg.width; + pix.height = cfg.height; + pix.pixelformat = V4L2_PIX_FMT_MJPEG; + pix.field = V4L2_FIELD_NONE; + fmt.set_pix(pix); + + // SAFETY: S_FMT takes a v4l2_format. + unsafe { ioctl(self.fd, VIDIOC_S_FMT, &mut fmt)? }; + + // The driver rewrites the struct with what it actually accepted. + let got = fmt.pix(); + self.negotiated.width = got.width; + self.negotiated.height = got.height; + self.negotiated.fourcc = got.pixelformat; + + if got.width != cfg.width || got.height != cfg.height { + warn_!( + "device adjusted capture size: asked {}x{}, got {}x{}", + cfg.width, cfg.height, got.width, got.height + ); + } + if got.pixelformat != V4L2_PIX_FMT_MJPEG { + return Err(io::Error::other(format!( + "device would not provide MJPEG (gave {}); run `video-output devices` to see what it offers", + fourcc_str(got.pixelformat) + ))); + } + Ok(()) + } + + fn set_frame_rate(&mut self, cfg: &CameraConfig) -> io::Result<()> { + let mut sp = v4l2_streamparm::new_capture(); + // SAFETY: G_PARM takes a v4l2_streamparm. + unsafe { ioctl(self.fd, VIDIOC_G_PARM, &mut sp)? }; + let cur = sp.capture(); + + if cur.capability & V4L2_CAP_TIMEPERFRAME == 0 { + debug!("device does not allow setting the frame interval; leaving it alone"); + self.negotiated.fps_num = cur.timeperframe.denominator; + self.negotiated.fps_den = cur.timeperframe.numerator.max(1); + return Ok(()); + } + + let mut want = cur; + // timeperframe is the interval, i.e. the reciprocal of the rate. + want.timeperframe = v4l2_fract { + numerator: cfg.fps_den, + denominator: cfg.fps_num, + }; + let mut sp2 = v4l2_streamparm::new_capture(); + sp2.set_capture(want); + // SAFETY: S_PARM takes a v4l2_streamparm. + if let Err(e) = unsafe { ioctl(self.fd, VIDIOC_S_PARM, &mut sp2) } { + warn_!("could not set frame interval ({e}); continuing at the device default"); + } + + let got = sp2.capture().timeperframe; + if got.numerator > 0 && got.denominator > 0 { + self.negotiated.fps_num = got.denominator; + self.negotiated.fps_den = got.numerator; + } else { + self.negotiated.fps_num = cfg.fps_num; + self.negotiated.fps_den = cfg.fps_den; + } + Ok(()) + } + + fn map_buffers(&mut self) -> io::Result<()> { + let mut req = v4l2_requestbuffers { + count: BUFFER_COUNT, + type_: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + ..Default::default() + }; + // SAFETY: REQBUFS takes a v4l2_requestbuffers. + unsafe { ioctl(self.fd, VIDIOC_REQBUFS, &mut req)? }; + if req.count < 2 { + return Err(io::Error::other(format!( + "driver granted only {} buffers, need at least 2", + req.count + ))); + } + + for i in 0..req.count { + let mut buf = v4l2_buffer { + index: i, + type_: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + ..Default::default() + }; + // SAFETY: QUERYBUF takes a v4l2_buffer. + unsafe { ioctl(self.fd, VIDIOC_QUERYBUF, &mut buf)? }; + + // SAFETY: the driver told us the offset and length of a mappable + // region on this fd. + let p = unsafe { + libc::mmap( + std::ptr::null_mut(), + buf.length as usize, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + self.fd, + buf.mmap_offset() as libc::off_t, + ) + }; + if p == libc::MAP_FAILED { + return Err(io::Error::last_os_error()); + } + self.buffers.push(MappedBuffer { ptr: p as *mut u8, len: buf.length as usize }); + } + debug!("mapped {} capture buffers", self.buffers.len()); + Ok(()) + } + + fn queue_buffer(&self, index: u32) -> io::Result<()> { + let mut buf = v4l2_buffer { + index, + type_: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + ..Default::default() + }; + // SAFETY: QBUF takes a v4l2_buffer. + unsafe { ioctl(self.fd, VIDIOC_QBUF, &mut buf) } + } + + /// Non-blocking dequeue. `Ok(None)` means the queue is currently empty. + fn dequeue_buffer(&self) -> io::Result> { + let mut buf = v4l2_buffer { + type_: V4L2_BUF_TYPE_VIDEO_CAPTURE, + memory: V4L2_MEMORY_MMAP, + ..Default::default() + }; + // SAFETY: DQBUF takes a v4l2_buffer. + match unsafe { ioctl(self.fd, VIDIOC_DQBUF, &mut buf) } { + Ok(()) => Ok(Some(buf)), + Err(e) if e.raw_os_error() == Some(libc::EAGAIN) => Ok(None), + Err(e) => Err(e), + } + } + + fn start(&mut self) -> io::Result<()> { + for i in 0..self.buffers.len() as u32 { + self.queue_buffer(i)?; + } + let mut t = V4L2_BUF_TYPE_VIDEO_CAPTURE; + // SAFETY: STREAMON takes an int. + unsafe { ioctl(self.fd, VIDIOC_STREAMON, &mut t)? }; + self.streaming = true; + Ok(()) + } + + /// Waits for readable data. `Ok(false)` on timeout. + fn wait_readable(&self, timeout_ms: i32) -> io::Result { + let mut pfd = libc::pollfd { + fd: self.fd, + events: libc::POLLIN, + revents: 0, + }; + loop { + // SAFETY: single valid pollfd. + let r = unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; + if r < 0 { + let e = io::Error::last_os_error(); + if e.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(e); + } + if r == 0 { + return Ok(false); + } + // A disconnected USB device surfaces here rather than as an ioctl + // error, so treat it explicitly. + if pfd.revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0 { + return Err(io::Error::from_raw_os_error(libc::ENODEV)); + } + return Ok(true); + } + } +} + +impl Drop for Device { + fn drop(&mut self) { + if self.streaming { + let mut t = V4L2_BUF_TYPE_VIDEO_CAPTURE; + // SAFETY: STREAMOFF takes an int; errors during teardown are moot. + let _ = unsafe { ioctl(self.fd, VIDIOC_STREAMOFF, &mut t) }; + } + for b in &self.buffers { + // SAFETY: each ptr/len pair came from a successful mmap above. + unsafe { libc::munmap(b.ptr as *mut libc::c_void, b.len) }; + } + // SAFETY: fd was opened by us and is closed exactly once. + unsafe { libc::close(self.fd) }; + } +} + +/// Whether an error means the node is gone rather than merely unhappy. +fn is_disconnect(e: &io::Error) -> bool { + matches!( + e.raw_os_error(), + Some(libc::ENODEV) | Some(libc::ENXIO) | Some(libc::EIO) | Some(libc::EBADF) | Some(libc::EPIPE) + ) +} + +/// Runs the camera input until shutdown. Never returns on stream errors; it +/// reopens the device instead. +pub fn run(ctx: &InputCtx, cfg: &CameraConfig) { + info!("Starting camera input {}", cfg.device); + info!( + "Requested format: {}x{} MJPEG @ {:.2} fps", + cfg.width, + cfg.height, + cfg.fps_num as f64 / cfg.fps_den as f64 + ); + + let mut decoder = match MjpegDecoder::new() { + Ok(d) => d, + Err(e) => { + error!("cannot initialise the JPEG decoder: {e}"); + return; + } + }; + + let mut backoff = super::Backoff::standard(); + // Logged only when it changes, so a reconnect loop stays quiet. + let mut last_identity = String::new(); + + while !ctx.should_stop() { + match Device::open(cfg) { + Ok(mut dev) => match dev.start() { + Ok(()) => { + backoff.reset(); + if dev.identity != last_identity { + info!("V4L2 device {} -> {}", cfg.device, dev.identity); + last_identity = dev.identity.clone(); + } + info!( + "Camera negotiated: {}x{} {} @ {:.2} fps", + dev.negotiated.width, + dev.negotiated.height, + fourcc_str(dev.negotiated.fourcc), + dev.negotiated.fps_num as f64 / dev.negotiated.fps_den.max(1) as f64 + ); + stream_loop(ctx, &mut dev, &mut decoder, cfg); + } + Err(e) => { + ctx.signal.set_lost(&format!("could not start streaming: {e}")); + report_open_failure(&mut backoff, &cfg.device, &e); + } + }, + Err(e) => { + ctx.signal.set_lost(&format!("device unavailable: {e}")); + report_open_failure(&mut backoff, &cfg.device, &e); + } + } + + if ctx.should_stop() { + break; + } + let delay = backoff.next_delay(); + ctx.sleep_interruptible(delay); + } + + info!("Camera input stopped"); +} + +/// Logs the first failure loudly and later identical ones quietly, so a device +/// that is missing for an hour does not fill the journal. +fn report_open_failure(backoff: &mut super::Backoff, device: &str, e: &io::Error) { + if backoff.attempts() == 0 { + error!("cannot open {device}: {e}"); + if e.raw_os_error() == Some(libc::EACCES) { + error!("permission denied -- add your user to the `video` group and log back in"); + } + if e.raw_os_error() == Some(libc::EBUSY) { + error!("device is busy -- another process is already capturing from it"); + } + info!("retrying in the background until it appears"); + } else { + debug!("still cannot open {device}: {e}"); + } +} + +/// Inner loop for one open device. Returns when the device errors, disconnects, +/// or shutdown is requested. +fn stream_loop(ctx: &InputCtx, dev: &mut Device, decoder: &mut MjpegDecoder, cfg: &CameraConfig) { + let mut seq: u64 = 0; + let mut decoded: u64 = 0; + let mut decode_errors: u64 = 0; + let mut driver_errors: u64 = 0; + let mut driver_drops: u64 = 0; + let mut last_frame_at = Instant::now(); + let mut stats = log::Throttle::new(cfg.stats_interval); + let mut logged_layout = false; + + while !ctx.should_stop() { + match dev.wait_readable(POLL_TIMEOUT_MS) { + Ok(true) => {} + Ok(false) => { + if last_frame_at.elapsed() > SIGNAL_TIMEOUT { + ctx.signal.set_lost("no frames from the device"); + } + continue; + } + Err(e) => { + ctx.signal.set_lost(&format!("device error: {e}")); + if is_disconnect(&e) { + warn_!("camera disconnected ({e}); will reopen when it returns"); + } else { + warn_!("poll failed ({e}); reopening the device"); + } + return; + } + } + + // Drain to the newest queued buffer, returning the older ones to the + // driver unread. This is what keeps a slow decode from replaying a + // backlog after a hiccup. + let mut newest: Option = None; + loop { + match dev.dequeue_buffer() { + Ok(Some(buf)) => { + if let Some(old) = newest.replace(buf) { + driver_drops += 1; + if let Err(e) = dev.queue_buffer(old.index) { + warn_!("requeue failed ({e}); reopening the device"); + return; + } + } + } + Ok(None) => break, + Err(e) => { + ctx.signal.set_lost(&format!("dequeue failed: {e}")); + if is_disconnect(&e) { + warn_!("camera disconnected ({e}); will reopen when it returns"); + } else { + warn_!("dequeue failed ({e}); reopening the device"); + } + return; + } + } + } + + let Some(buf) = newest else { continue }; + last_frame_at = Instant::now(); + + if buf.flags & V4L2_BUF_FLAG_ERROR != 0 || buf.bytesused == 0 { + // The driver flagged the frame as damaged; skip it and move on. + driver_errors += 1; + let _ = dev.queue_buffer(buf.index); + continue; + } + + let index = buf.index as usize; + let used = buf.bytesused as usize; + let decode_result = { + // SAFETY: this buffer is dequeued, so the driver is not writing to + // it, and it stays mapped until `dev` is dropped. The slice is + // consumed before the buffer is requeued below. + let jpeg = unsafe { dev.buffers[index].as_slice(used) }; + seq += 1; + decoder.decode(jpeg, ctx.slot.as_ref(), seq) + }; + + if let Err(e) = dev.queue_buffer(buf.index) { + warn_!("requeue failed ({e}); reopening the device"); + return; + } + + match decode_result { + Ok(frame) => { + if !logged_layout { + info!( + "Decoding {}x{} into {}", + frame.width, + frame.height, + describe_format(frame.format, frame.colorspace) + ); + logged_layout = true; + } + decoded += 1; + ctx.signal.set_active(); + ctx.slot.publish(frame); + } + Err(e) => { + decode_errors += 1; + // One bad frame is normal on USB; a flood is worth seeing. + debug!("dropped a malformed frame: {e}"); + } + } + + if let Some(elapsed) = stats.tick(Instant::now()) { + let secs = elapsed.as_secs_f64().max(0.001); + let slot = ctx.slot.stats(); + let warnings = decoder.take_warning_count(); + info!( + "camera: {:.1} fps decoded ({decoded} frames), {decode_errors} undecodable, \ + {driver_errors} flagged by the driver, {driver_drops} stale buffers skipped, \ + {} dropped before display{}", + decoded as f64 / secs, + slot.dropped, + if warnings > 0 { + format!(" [{warnings} decoded despite a libjpeg warning]") + } else { + String::new() + } + ); + decoded = 0; + decode_errors = 0; + driver_errors = 0; + driver_drops = 0; + } + } +} + +fn describe_format(f: PixelFormat, c: Colorspace) -> String { + let fmt = match f { + PixelFormat::Yuy2 => "packed 4:2:2 YUY2 (GPU colour conversion)", + PixelFormat::Uyvy => "packed 4:2:2 UYVY (GPU colour conversion)", + PixelFormat::I420 => "planar 4:2:0 I420 (GPU colour conversion)", + PixelFormat::Bgrx => "packed BGRX (CPU colour conversion)", + }; + let cs = match c { + Colorspace::Jpeg => "BT.601 full range", + Colorspace::Bt601Limited => "BT.601 studio range", + Colorspace::Bt709Limited => "BT.709 studio range", + }; + format!("{fmt}, {cs}") +} + +/// Unused today but kept so an uncompressed device needs only a decoder branch, +/// not a restructure of the capture loop. +#[allow(dead_code)] +pub fn pixel_format_for_fourcc(fourcc: u32) -> Option { + match fourcc { + V4L2_PIX_FMT_YUYV => Some(PixelFormat::Yuy2), + V4L2_PIX_FMT_UYVY => Some(PixelFormat::Uyvy), + V4L2_PIX_FMT_YUV420 => Some(PixelFormat::I420), + _ => None, + } +} + +/// True when the fourcc needs a JPEG decode rather than a straight upload. +pub fn is_compressed(fourcc: u32) -> bool { + matches!(fourcc, V4L2_PIX_FMT_MJPEG | V4L2_PIX_FMT_JPEG) +} + +// --------------------------------------------------------------------------- +// `video-output devices` +// --------------------------------------------------------------------------- + +/// Prints every /dev/videoN with its capabilities, formats, sizes and rates. +/// Equivalent to `v4l2-ctl --list-devices` plus `--list-formats-ext`, so the +/// appliance can be diagnosed without v4l-utils installed. +pub fn list_devices() -> i32 { + let mut nodes: Vec = Vec::new(); + if let Ok(rd) = std::fs::read_dir("/dev") { + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if let Some(rest) = name.strip_prefix("video") { + if rest.chars().all(|c| c.is_ascii_digit()) { + nodes.push(format!("/dev/{name}")); + } + } + } + } + nodes.sort_by_key(|p| { + p.trim_start_matches("/dev/video") + .parse::() + .unwrap_or(u32::MAX) + }); + + if nodes.is_empty() { + println!("No /dev/videoN nodes found."); + return 1; + } + + for path in &nodes { + println!("{path}"); + match open_fd(path) { + Ok(fd) => { + describe_node(fd, path); + // SAFETY: fd was opened above and is closed once. + unsafe { libc::close(fd) }; + } + Err(e) => println!(" cannot open: {e}"), + } + println!(); + } + 0 +} + +fn describe_node(fd: RawFd, path: &str) { + let mut cap: v4l2_capability = unsafe { std::mem::zeroed() }; + // SAFETY: QUERYCAP takes a v4l2_capability. + if let Err(e) = unsafe { ioctl(fd, VIDIOC_QUERYCAP, &mut cap) } { + println!(" VIDIOC_QUERYCAP failed: {e}"); + return; + } + println!(" card : {}", cstr_field(&cap.card)); + println!(" driver : {}", cstr_field(&cap.driver)); + println!(" bus : {}", cstr_field(&cap.bus_info)); + + let effective = if cap.capabilities & V4L2_CAP_DEVICE_CAPS != 0 { + cap.device_caps + } else { + cap.capabilities + }; + if effective & V4L2_CAP_VIDEO_CAPTURE == 0 { + println!(" (not a video capture node -- nothing to stream from here)"); + return; + } + println!(" usable : yes, video capture + streaming"); + let _ = path; + + for index in 0.. { + let mut fd_desc = v4l2_fmtdesc { + index, + ..Default::default() + }; + // SAFETY: ENUM_FMT takes a v4l2_fmtdesc. + if unsafe { ioctl(fd, VIDIOC_ENUM_FMT, &mut fd_desc) }.is_err() { + break; + } + let cc = fd_desc.pixelformat; + println!( + " [{index}] {} ({}){}", + fourcc_str(cc), + cstr_field(&fd_desc.description), + if is_compressed(cc) { + " [needs JPEG decode]" + } else if pixel_format_for_fourcc(cc).is_some() { + " [uploadable without decoding]" + } else { + "" + } + ); + list_frame_sizes(fd, cc); + } +} + +fn list_frame_sizes(fd: RawFd, fourcc: u32) { + for index in 0.. { + let mut fs = v4l2_frmsizeenum { + index, + pixel_format: fourcc, + ..Default::default() + }; + // SAFETY: ENUM_FRAMESIZES takes a v4l2_frmsizeenum. + if unsafe { ioctl(fd, VIDIOC_ENUM_FRAMESIZES, &mut fs) }.is_err() { + break; + } + if fs.type_ != V4L2_FRMSIZE_TYPE_DISCRETE { + println!(" (stepwise/continuous sizes)"); + break; + } + let d = fs.discrete(); + let rates = frame_rates(fd, fourcc, d.width, d.height); + println!( + " {}x{}{}", + d.width, + d.height, + if rates.is_empty() { + String::new() + } else { + format!(" @ {}", rates.join(", ")) + } + ); + } +} + +fn frame_rates(fd: RawFd, fourcc: u32, w: u32, h: u32) -> Vec { + let mut out = Vec::new(); + for index in 0.. { + let mut fi = v4l2_frmivalenum { + index, + pixel_format: fourcc, + width: w, + height: h, + ..Default::default() + }; + // SAFETY: ENUM_FRAMEINTERVALS takes a v4l2_frmivalenum. + if unsafe { ioctl(fd, VIDIOC_ENUM_FRAMEINTERVALS, &mut fi) }.is_err() { + break; + } + if fi.type_ != V4L2_FRMIVAL_TYPE_DISCRETE { + out.push("stepwise".into()); + break; + } + let f = fi.discrete(); + if f.numerator > 0 { + out.push(format!("{:.2} fps", f.denominator as f64 / f.numerator as f64)); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compressed_formats_are_recognised() { + assert!(is_compressed(V4L2_PIX_FMT_MJPEG)); + assert!(is_compressed(V4L2_PIX_FMT_JPEG)); + assert!(!is_compressed(V4L2_PIX_FMT_YUYV)); + } + + /// The ATEM only offers MJPEG, but the mapping is what a future + /// uncompressed device would ride in on. + #[test] + fn uncompressed_formats_map_to_uploadable_layouts() { + assert_eq!(pixel_format_for_fourcc(V4L2_PIX_FMT_YUYV), Some(PixelFormat::Yuy2)); + assert_eq!(pixel_format_for_fourcc(V4L2_PIX_FMT_UYVY), Some(PixelFormat::Uyvy)); + assert_eq!(pixel_format_for_fourcc(V4L2_PIX_FMT_YUV420), Some(PixelFormat::I420)); + assert_eq!(pixel_format_for_fourcc(V4L2_PIX_FMT_MJPEG), None); + } + + #[test] + fn disconnect_errors_are_classified() { + assert!(is_disconnect(&io::Error::from_raw_os_error(libc::ENODEV))); + assert!(is_disconnect(&io::Error::from_raw_os_error(libc::EIO))); + assert!(!is_disconnect(&io::Error::from_raw_os_error(libc::EAGAIN))); + assert!(!is_disconnect(&io::Error::from_raw_os_error(libc::EINVAL))); + } + + #[test] + fn opening_a_missing_device_fails_cleanly() { + let cfg = CameraConfig { + device: "/dev/video-does-not-exist".into(), + width: 1920, + height: 1080, + fps_num: 30000, + fps_den: 1001, + stats_interval: Duration::from_secs(60), + }; + assert!(Device::open(&cfg).is_err()); + } + + #[test] + fn device_path_with_nul_is_rejected() { + assert!(open_fd("/dev/video\0evil").is_err()); + } +} diff --git a/src/inputs/v4l2_sys.rs b/src/inputs/v4l2_sys.rs new file mode 100644 index 0000000..3ed90c9 --- /dev/null +++ b/src/inputs/v4l2_sys.rs @@ -0,0 +1,412 @@ +//! V4L2 ABI: the handful of structs and ioctls a capture-only client needs. +//! +//! Hand-written rather than pulled from a binding crate so the dependency tree +//! stays at two crates. Every layout below was verified against this machine's +//! `` (see the `layout_matches_kernel_abi` assertions), and +//! the ioctl request codes are the literal encoded values, not recomputed by a +//! macro that could drift. + +#![allow(non_camel_case_types)] + +use std::os::fd::RawFd; + +// --- capability flags ------------------------------------------------------- +pub const V4L2_CAP_VIDEO_CAPTURE: u32 = 0x0000_0001; +pub const V4L2_CAP_STREAMING: u32 = 0x0400_0000; +pub const V4L2_CAP_DEVICE_CAPS: u32 = 0x8000_0000; + +pub const V4L2_BUF_TYPE_VIDEO_CAPTURE: u32 = 1; +pub const V4L2_MEMORY_MMAP: u32 = 1; +pub const V4L2_FIELD_NONE: u32 = 1; +pub const V4L2_CAP_TIMEPERFRAME: u32 = 0x1000; +pub const V4L2_BUF_FLAG_ERROR: u32 = 0x0040; +pub const V4L2_FRMSIZE_TYPE_DISCRETE: u32 = 1; +pub const V4L2_FRMIVAL_TYPE_DISCRETE: u32 = 1; + +// --- fourccs ---------------------------------------------------------------- +pub const V4L2_PIX_FMT_MJPEG: u32 = fourcc(b"MJPG"); +pub const V4L2_PIX_FMT_JPEG: u32 = fourcc(b"JPEG"); +pub const V4L2_PIX_FMT_YUYV: u32 = fourcc(b"YUYV"); +pub const V4L2_PIX_FMT_UYVY: u32 = fourcc(b"UYVY"); +pub const V4L2_PIX_FMT_NV12: u32 = fourcc(b"NV12"); +pub const V4L2_PIX_FMT_YUV420: u32 = fourcc(b"YU12"); + +pub const fn fourcc(c: &[u8; 4]) -> u32 { + (c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24) +} + +pub fn fourcc_str(v: u32) -> String { + let b = [v as u8, (v >> 8) as u8, (v >> 16) as u8, (v >> 24) as u8]; + b.iter().map(|&c| if c.is_ascii_graphic() { c as char } else { '?' }).collect() +} + +// --- ioctl request codes ---------------------------------------------------- +// Taken verbatim from the kernel headers on the target platform (x86_64). +pub const VIDIOC_QUERYCAP: u64 = 0x8068_5600; +pub const VIDIOC_ENUM_FMT: u64 = 0xc040_5602; +pub const VIDIOC_G_FMT: u64 = 0xc0d0_5604; +pub const VIDIOC_S_FMT: u64 = 0xc0d0_5605; +pub const VIDIOC_REQBUFS: u64 = 0xc014_5608; +pub const VIDIOC_QUERYBUF: u64 = 0xc058_5609; +pub const VIDIOC_QBUF: u64 = 0xc058_560f; +pub const VIDIOC_DQBUF: u64 = 0xc058_5611; +pub const VIDIOC_STREAMON: u64 = 0x4004_5612; +pub const VIDIOC_STREAMOFF: u64 = 0x4004_5613; +pub const VIDIOC_G_PARM: u64 = 0xc0cc_5615; +pub const VIDIOC_S_PARM: u64 = 0xc0cc_5616; +pub const VIDIOC_ENUM_FRAMESIZES: u64 = 0xc02c_564a; +pub const VIDIOC_ENUM_FRAMEINTERVALS: u64 = 0xc034_564b; + +// --- structs ---------------------------------------------------------------- + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct v4l2_capability { + pub driver: [u8; 16], + pub card: [u8; 32], + pub bus_info: [u8; 32], + pub version: u32, + pub capabilities: u32, + pub device_caps: u32, + pub reserved: [u32; 3], +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct v4l2_pix_format { + pub width: u32, + pub height: u32, + pub pixelformat: u32, + pub field: u32, + pub bytesperline: u32, + pub sizeimage: u32, + pub colorspace: u32, + pub priv_: u32, + pub flags: u32, + pub enc: u32, + pub quantization: u32, + pub xfer_func: u32, +} + +/// The `fmt` union is 200 bytes with 8-byte alignment; we only ever touch the +/// `pix` arm, which occupies its first 48 bytes. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct v4l2_format { + pub type_: u32, + pub _pad: u32, + pub fmt: [u64; 25], +} + +impl v4l2_format { + pub fn new_capture() -> Self { + v4l2_format { type_: V4L2_BUF_TYPE_VIDEO_CAPTURE, _pad: 0, fmt: [0; 25] } + } + + pub fn pix(&self) -> v4l2_pix_format { + // SAFETY: `pix` is the first arm of the union and is 48 bytes, well + // inside the 200-byte `fmt` field. + unsafe { *(self.fmt.as_ptr() as *const v4l2_pix_format) } + } + + pub fn set_pix(&mut self, p: v4l2_pix_format) { + // SAFETY: as above. + unsafe { *(self.fmt.as_mut_ptr() as *mut v4l2_pix_format) = p }; + } +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct v4l2_requestbuffers { + pub count: u32, + pub type_: u32, + pub memory: u32, + pub capabilities: u32, + pub flags: u8, + pub reserved: [u8; 3], +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct v4l2_timecode { + pub type_: u32, + pub flags: u32, + pub frames: u8, + pub seconds: u8, + pub minutes: u8, + pub hours: u8, + pub userbits: [u8; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct timeval { + pub tv_sec: i64, + pub tv_usec: i64, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct v4l2_buffer { + pub index: u32, + pub type_: u32, + pub bytesused: u32, + pub flags: u32, + pub field: u32, + pub _pad0: u32, + pub timestamp: timeval, + pub timecode: v4l2_timecode, + pub sequence: u32, + pub memory: u32, + /// Union of `offset`/`userptr`/`planes`/`fd`. For MMAP the meaningful part + /// is the low 32 bits, the mmap offset. + pub m: u64, + pub length: u32, + pub reserved2: u32, + pub request_fd: i32, + pub _pad1: u32, +} + +impl v4l2_buffer { + pub fn mmap_offset(&self) -> u64 { + self.m & 0xffff_ffff + } +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct v4l2_fract { + pub numerator: u32, + pub denominator: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct v4l2_captureparm { + pub capability: u32, + pub capturemode: u32, + pub timeperframe: v4l2_fract, + pub extendedmode: u32, + pub readbuffers: u32, + pub reserved: [u32; 4], +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct v4l2_streamparm { + pub type_: u32, + pub parm: [u8; 200], +} + +impl v4l2_streamparm { + pub fn new_capture() -> Self { + v4l2_streamparm { type_: V4L2_BUF_TYPE_VIDEO_CAPTURE, parm: [0; 200] } + } + + pub fn capture(&self) -> v4l2_captureparm { + // SAFETY: `capture` is the first arm of the 200-byte union. + unsafe { *(self.parm.as_ptr() as *const v4l2_captureparm) } + } + + pub fn set_capture(&mut self, c: v4l2_captureparm) { + // SAFETY: as above. + unsafe { *(self.parm.as_mut_ptr() as *mut v4l2_captureparm) = c }; + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct v4l2_fmtdesc { + pub index: u32, + pub type_: u32, + pub flags: u32, + pub description: [u8; 32], + pub pixelformat: u32, + pub mbus_code: u32, + pub reserved: [u32; 3], +} + +impl Default for v4l2_fmtdesc { + fn default() -> Self { + v4l2_fmtdesc { + index: 0, + type_: V4L2_BUF_TYPE_VIDEO_CAPTURE, + flags: 0, + description: [0; 32], + pixelformat: 0, + mbus_code: 0, + reserved: [0; 3], + } + } +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct v4l2_frmsize_discrete { + pub width: u32, + pub height: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct v4l2_frmsizeenum { + pub index: u32, + pub pixel_format: u32, + pub type_: u32, + /// Union of discrete (8 bytes) and stepwise (24 bytes). + pub union_: [u32; 6], + pub reserved: [u32; 2], +} + +impl v4l2_frmsizeenum { + pub fn discrete(&self) -> v4l2_frmsize_discrete { + v4l2_frmsize_discrete { width: self.union_[0], height: self.union_[1] } + } +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct v4l2_frmivalenum { + pub index: u32, + pub pixel_format: u32, + pub width: u32, + pub height: u32, + pub type_: u32, + /// Union of discrete `v4l2_fract` (8 bytes) and stepwise (24 bytes). + pub union_: [u32; 6], + pub reserved: [u32; 2], +} + +impl v4l2_frmivalenum { + pub fn discrete(&self) -> v4l2_fract { + v4l2_fract { numerator: self.union_[0], denominator: self.union_[1] } + } +} + +// --- ioctl helper ----------------------------------------------------------- + +/// `ioctl`, retrying on EINTR. +/// +/// # Safety +/// `arg` must point at a value of the type the request code expects. +pub unsafe fn ioctl(fd: RawFd, request: u64, arg: *mut T) -> std::io::Result<()> { + loop { + let r = unsafe { libc::ioctl(fd, request as _, arg) }; + if r >= 0 { + return Ok(()); + } + let e = std::io::Error::last_os_error(); + if e.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(e); + } +} + +pub fn cstr_field(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(&b[..end]).into_owned() +} + +// Layouts were captured from the target kernel's headers. If a future kernel or +// architecture disagrees these fail the build rather than corrupting ioctls. +const _: () = { + use std::mem::size_of; + assert!(size_of::() == 104); + assert!(size_of::() == 48); + assert!(size_of::() == 208); + assert!(size_of::() == 20); + assert!(size_of::() == 16); + assert!(size_of::() == 16); + assert!(size_of::() == 88); + assert!(size_of::() == 40); + assert!(size_of::() == 204); + assert!(size_of::() == 64); + assert!(size_of::() == 44); + assert!(size_of::() == 52); +}; + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::offset_of; + + /// Offsets recorded from `offsetof` against `` on the + /// target. A mismatch means the hand-written structs have drifted. + #[test] + fn layout_matches_kernel_abi() { + assert_eq!(offset_of!(v4l2_capability, card), 16); + assert_eq!(offset_of!(v4l2_capability, bus_info), 48); + assert_eq!(offset_of!(v4l2_capability, capabilities), 84); + assert_eq!(offset_of!(v4l2_capability, device_caps), 88); + + assert_eq!(offset_of!(v4l2_pix_format, pixelformat), 8); + assert_eq!(offset_of!(v4l2_pix_format, sizeimage), 20); + + assert_eq!(offset_of!(v4l2_format, fmt), 8); + + assert_eq!(offset_of!(v4l2_requestbuffers, memory), 8); + assert_eq!(offset_of!(v4l2_requestbuffers, capabilities), 12); + + assert_eq!(offset_of!(v4l2_buffer, bytesused), 8); + assert_eq!(offset_of!(v4l2_buffer, timestamp), 24); + assert_eq!(offset_of!(v4l2_buffer, timecode), 40); + assert_eq!(offset_of!(v4l2_buffer, sequence), 56); + assert_eq!(offset_of!(v4l2_buffer, memory), 60); + assert_eq!(offset_of!(v4l2_buffer, m), 64); + assert_eq!(offset_of!(v4l2_buffer, length), 72); + + assert_eq!(offset_of!(v4l2_streamparm, parm), 4); + assert_eq!(offset_of!(v4l2_captureparm, timeperframe), 8); + assert_eq!(offset_of!(v4l2_captureparm, readbuffers), 20); + + assert_eq!(offset_of!(v4l2_fmtdesc, description), 12); + assert_eq!(offset_of!(v4l2_fmtdesc, pixelformat), 44); + + assert_eq!(offset_of!(v4l2_frmsizeenum, union_), 12); + assert_eq!(offset_of!(v4l2_frmivalenum, union_), 20); + } + + #[test] + fn fourcc_round_trips() { + assert_eq!(V4L2_PIX_FMT_MJPEG, 0x4750_4a4d); + assert_eq!(V4L2_PIX_FMT_YUYV, 0x5659_5559); + assert_eq!(fourcc_str(V4L2_PIX_FMT_MJPEG), "MJPG"); + assert_eq!(fourcc_str(V4L2_PIX_FMT_NV12), "NV12"); + } + + #[test] + fn pix_format_round_trips_through_the_union() { + let mut f = v4l2_format::new_capture(); + let mut p = v4l2_pix_format::default(); + p.width = 1920; + p.height = 1080; + p.pixelformat = V4L2_PIX_FMT_MJPEG; + p.field = V4L2_FIELD_NONE; + f.set_pix(p); + + let back = f.pix(); + assert_eq!(back.width, 1920); + assert_eq!(back.height, 1080); + assert_eq!(back.pixelformat, V4L2_PIX_FMT_MJPEG); + assert_eq!(f.type_, V4L2_BUF_TYPE_VIDEO_CAPTURE); + } + + #[test] + fn captureparm_round_trips_through_the_union() { + let mut sp = v4l2_streamparm::new_capture(); + let mut cp = v4l2_captureparm::default(); + cp.timeperframe = v4l2_fract { numerator: 1001, denominator: 30000 }; + sp.set_capture(cp); + let back = sp.capture(); + assert_eq!(back.timeperframe.numerator, 1001); + assert_eq!(back.timeperframe.denominator, 30000); + } + + #[test] + fn cstr_field_stops_at_nul() { + let mut b = [0u8; 16]; + b[..8].copy_from_slice(b"uvcvideo"); + assert_eq!(cstr_field(&b), "uvcvideo"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c3fd640 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +//! A small live-video-to-display appliance. +//! +//! One process, one input, one fullscreen output, no controls. See `README.md` +//! for the operational story and `src/renderer.rs` for the Wayland +//! display-selection notes. + +pub mod cli; +pub mod display; +pub mod frame; +pub mod inputs; +pub mod log; +pub mod renderer; diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..702c6a5 --- /dev/null +++ b/src/log.rs @@ -0,0 +1,162 @@ +//! Minimal stderr logging. +//! +//! Deliberately tiny: no crate dependency, no async, no buffering. stderr is +//! what systemd captures, so `journalctl -u video-output` just works. +//! +//! Level is chosen once at startup from `VIDEO_OUTPUT_LOG` (error|warn|info|debug). +//! Nothing in this program logs per frame; see [`Throttle`] for the periodic +//! statistics lines and `inputs::SignalState` for edge-triggered status changes. + +use std::fmt::Arguments; +use std::io::Write; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum Level { + Error = 0, + Warn = 1, + Info = 2, + Debug = 3, +} + +impl Level { + fn tag(self) -> &'static str { + match self { + Level::Error => "ERROR", + Level::Warn => "WARN ", + Level::Info => "INFO ", + Level::Debug => "DEBUG", + } + } + + /// systemd/journald severity prefix, consumed by `sd-daemon` log parsing so + /// that `journalctl -p warning` filters correctly. + fn sd_prefix(self) -> &'static str { + match self { + Level::Error => "<3>", + Level::Warn => "<4>", + Level::Info => "<6>", + Level::Debug => "<7>", + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "error" => Some(Level::Error), + "warn" | "warning" => Some(Level::Warn), + "info" => Some(Level::Info), + "debug" | "trace" => Some(Level::Debug), + _ => None, + } + } +} + +static LEVEL: AtomicU8 = AtomicU8::new(Level::Info as u8); +static USE_SD_PREFIX: AtomicU8 = AtomicU8::new(0); + +pub fn init_from_env() { + if let Ok(v) = std::env::var("VIDEO_OUTPUT_LOG") { + if let Some(l) = Level::parse(&v) { + set_level(l); + } + } + // Under systemd, JOURNAL_STREAM is set and timestamps are added by the + // journal itself, so we switch to severity prefixes and drop our own clock. + if std::env::var_os("JOURNAL_STREAM").is_some() { + USE_SD_PREFIX.store(1, Ordering::Relaxed); + } +} + +pub fn set_level(l: Level) { + LEVEL.store(l as u8, Ordering::Relaxed); +} + +pub fn enabled(l: Level) -> bool { + (l as u8) <= LEVEL.load(Ordering::Relaxed) +} + +pub fn log(level: Level, args: Arguments<'_>) { + if !enabled(level) { + return; + } + let mut err = std::io::stderr().lock(); + if USE_SD_PREFIX.load(Ordering::Relaxed) == 1 { + let _ = writeln!(err, "{}{}", level.sd_prefix(), args); + } else { + let _ = writeln!(err, "[{}] {}", level.tag(), args); + } + let _ = err.flush(); +} + +#[macro_export] +macro_rules! error { ($($a:tt)*) => { $crate::log::log($crate::log::Level::Error, format_args!($($a)*)) } } +#[macro_export] +macro_rules! warn_ { ($($a:tt)*) => { $crate::log::log($crate::log::Level::Warn, format_args!($($a)*)) } } +#[macro_export] +macro_rules! info { ($($a:tt)*) => { $crate::log::log($crate::log::Level::Info, format_args!($($a)*)) } } +#[macro_export] +macro_rules! debug { ($($a:tt)*) => { $crate::log::log($crate::log::Level::Debug, format_args!($($a)*)) } } + +/// Fires at most once per interval. Used for the periodic throughput line so a +/// 30 fps stream produces one log entry per minute rather than 1800. +pub struct Throttle { + interval: Duration, + last: Option, +} + +impl Throttle { + pub fn new(interval: Duration) -> Self { + Throttle { interval, last: None } + } + + /// Returns the elapsed time since the previous fire when the interval has + /// passed, otherwise `None`. The first call arms the timer without firing. + pub fn tick(&mut self, now: Instant) -> Option { + match self.last { + None => { + self.last = Some(now); + None + } + Some(prev) => { + let elapsed = now.duration_since(prev); + if elapsed >= self.interval { + self.last = Some(now); + Some(elapsed) + } else { + None + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_level_names() { + assert_eq!(Level::parse("debug"), Some(Level::Debug)); + assert_eq!(Level::parse(" WARN "), Some(Level::Warn)); + assert_eq!(Level::parse("warning"), Some(Level::Warn)); + assert_eq!(Level::parse("nonsense"), None); + } + + #[test] + fn throttle_arms_then_fires_once_per_interval() { + let start = Instant::now(); + let mut t = Throttle::new(Duration::from_secs(10)); + + // First call only arms the timer. + assert!(t.tick(start).is_none()); + assert!(t.tick(start + Duration::from_secs(9)).is_none()); + + let fired = t.tick(start + Duration::from_secs(10)).expect("should fire"); + assert_eq!(fired, Duration::from_secs(10)); + + // Timer restarts from the fire point, not from `start`. + assert!(t.tick(start + Duration::from_secs(19)).is_none()); + assert!(t.tick(start + Duration::from_secs(20)).is_some()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a7bceac --- /dev/null +++ b/src/main.rs @@ -0,0 +1,256 @@ +//! Entry point: parse, open the output, run one input thread, present frames. +//! +//! The main thread owns SDL and does nothing but present; the input backend +//! runs on its own thread and hands over frames through a one-deep slot. That +//! split is what lets a stalled camera leave the window alive and black. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use video_output::cli::{self, Command, Settings, Source}; +use video_output::display::{self, DisplaySelector}; +use video_output::frame::FrameSlot; +use video_output::inputs::{self, InputCtx, SignalState}; +use video_output::renderer::{self, Renderer}; +use video_output::{error, info, warn_}; + +/// Set from the signal handler; polled by both threads. +static SHUTDOWN: AtomicBool = AtomicBool::new(false); + +extern "C" fn handle_signal(_sig: libc::c_int) { + SHUTDOWN.store(true, Ordering::SeqCst); +} + +fn install_signal_handlers() { + // SAFETY: the handler only stores to an atomic, which is async-signal-safe. + unsafe { + let handler = handle_signal as *const () as libc::sighandler_t; + libc::signal(libc::SIGINT, handler); + libc::signal(libc::SIGTERM, handler); + libc::signal(libc::SIGHUP, handler); + } +} + +fn main() { + video_output::log::init_from_env(); + let args: Vec = std::env::args().skip(1).collect(); + + let command = match cli::parse_args(&args) { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(2); + } + }; + + let code = match command { + Command::Help => { + print!("{}", cli::USAGE); + 0 + } + Command::Version => { + println!("video-output {}", env!("CARGO_PKG_VERSION")); + 0 + } + Command::ListDevices => inputs::v4l2::list_devices(), + Command::ListNdiSources { timeout_secs } => inputs::ndi::list_sources(timeout_secs), + Command::ListDisplays => list_displays(), + Command::Run(settings) => run(*settings), + }; + + std::process::exit(code); +} + +fn list_displays() -> i32 { + if let Err(e) = renderer::init() { + eprintln!("{e}"); + return 1; + } + let displays = renderer::enumerate_displays(); + println!("Video driver: {}", renderer::current_video_driver()); + if displays.is_empty() { + println!("No displays detected."); + renderer::shutdown(); + return 1; + } + println!("{} display(s):", displays.len()); + for d in &displays { + println!(" {d}"); + } + println!(); + println!("Pass one of these to --display, by name (recommended) or index:"); + for d in &displays { + println!(" --display {}", d.name); + } + renderer::shutdown(); + 0 +} + +fn run(settings: Settings) -> i32 { + if let Some(l) = settings.log_level { + video_output::log::set_level(l); + } + + if let Err(e) = renderer::init() { + error!("{e}"); + return 1; + } + install_signal_handlers(); + + let displays = renderer::enumerate_displays(); + let target = match display::select(&displays, &settings.display) { + Ok(d) => d.clone(), + Err(e) => { + error!("{e}"); + if !displays.is_empty() { + error!("run `video-output displays` to see the available outputs"); + } + renderer::shutdown(); + return 1; + } + }; + if matches!(settings.display, DisplaySelector::Primary) && displays.len() > 1 { + warn_!( + "no --display given, using the primary output {}; \ + name an output explicitly so the choice survives a reboot", + target.name + ); + } + + let mut win = match Renderer::new(&target, settings.fullscreen, settings.vsync) { + Ok(r) => r, + Err(e) => { + error!("{e}"); + renderer::shutdown(); + return 1; + } + }; + + let slot = Arc::new(FrameSlot::new()); + let shutdown = Arc::new(AtomicBool::new(false)); + let label: &'static str = match settings.source { + Source::Camera { .. } => "Camera", + Source::Ndi { .. } => "NDI", + }; + let signal = Arc::new(SignalState::new(label)); + let stats_interval = if settings.stats_interval == 0 { + Duration::from_secs(u32::MAX as u64) + } else { + Duration::from_secs(settings.stats_interval) + }; + + let ctx = InputCtx { + slot: Arc::clone(&slot), + shutdown: Arc::clone(&shutdown), + signal: Arc::clone(&signal), + }; + + let source = settings.source.clone(); + let input_thread = std::thread::Builder::new() + .name("input".into()) + .spawn(move || { + // A panic in a backend must not take the display down with it. The + // loop reinstates the input after logging, matching how an I/O + // error would be handled. + while !ctx.should_stop() { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + match &source { + Source::Camera { device, width, height, fps_num, fps_den } => { + inputs::v4l2::run( + &ctx, + &inputs::v4l2::CameraConfig { + device: device.clone(), + width: *width, + height: *height, + fps_num: *fps_num, + fps_den: *fps_den, + stats_interval, + }, + ) + } + Source::Ndi { source, extra_ips } => inputs::ndi::run( + &ctx, + &inputs::ndi::NdiConfig { + source: source.clone(), + extra_ips: extra_ips.clone(), + stats_interval, + }, + ), + } + })); + if result.is_ok() { + break; // clean return means shutdown was requested + } + error!("input backend panicked; restarting it in 1s"); + ctx.signal.set_lost("internal error"); + ctx.sleep_interruptible(Duration::from_secs(1)); + } + }); + + let input_thread = match input_thread { + Ok(h) => h, + Err(e) => { + error!("could not start the input thread: {e}"); + drop(win); + renderer::shutdown(); + return 1; + } + }; + + let exit_code = render_loop(&mut win, &slot, &signal); + + info!("shutting down"); + shutdown.store(true, Ordering::SeqCst); + slot.close(); + let _ = input_thread.join(); + drop(win); + renderer::shutdown(); + exit_code +} + +/// Presents frames until asked to stop. +/// +/// Blanks the output when the signal is lost rather than freezing on the last +/// frame: a stale picture on a live output is worse than an obvious black one. +fn render_loop(win: &mut Renderer, slot: &FrameSlot, signal: &SignalState) -> i32 { + // Long enough not to spin, short enough that shutdown and signal loss are + // noticed within one display refresh. + let wait = Duration::from_millis(16); + let mut showing_black = true; + let mut last_upload_error = Instant::now() - Duration::from_secs(3600); + + loop { + if SHUTDOWN.load(Ordering::SeqCst) { + info!("received termination signal"); + return 0; + } + if !win.pump_events() { + return 0; + } + + match slot.take_timeout(wait) { + Some(frame) => match win.draw(&frame) { + Ok(()) => { + showing_black = false; + slot.recycle(frame.data); + } + Err(e) => { + // Throttled: a persistent GPU problem should not fill the + // journal at frame rate. + if last_upload_error.elapsed() > Duration::from_secs(5) { + error!("{e}"); + last_upload_error = Instant::now(); + } + slot.recycle(frame.data); + } + }, + None => { + if !signal.is_active() && !showing_black { + win.clear_black(); + showing_black = true; + } + } + } + } +} diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..b2c0caf --- /dev/null +++ b/src/renderer.rs @@ -0,0 +1,477 @@ +//! SDL3 output: one borderless fullscreen window on a chosen display, one +//! streaming texture, nothing else. +//! +//! ## Choosing the output on Wayland +//! +//! A Wayland client cannot move its own window to a monitor -- there are no +//! global coordinates to move it to. The protocol instead lets a client ask to +//! be fullscreen *on a named `wl_output`*, via +//! `xdg_toplevel.set_fullscreen(output)`. +//! +//! SDL exposes that as: create the window already flagged fullscreen with its +//! position set to `SDL_WINDOWPOS_CENTERED_DISPLAY(target)`. SDL resolves the +//! position to a display at map time and passes that output to the compositor. +//! Creating the window hidden and showing it after the fullscreen mode is set +//! avoids a visible hop from one screen to another. +//! +//! This is why the window is created hidden, configured, then shown -- and why +//! `verify_placement` re-reads the display afterwards: if KWin overrides the +//! request we want a log line saying so, not a silently wrong monitor. + +use crate::display::DisplayInfo; +use crate::frame::{Colorspace, Frame, PixelFormat}; +use crate::{info, warn_}; +use sdl3_sys::everything::*; +use std::ffi::{c_char, CStr, CString}; + +fn sdl_error() -> String { + // SAFETY: SDL_GetError always returns a valid NUL-terminated string. + unsafe { + let p = SDL_GetError(); + if p.is_null() { + "unknown SDL error".into() + } else { + CStr::from_ptr(p).to_string_lossy().into_owned() + } + } +} + +/// Initialises SDL's video subsystem. Audio is never initialised: this +/// appliance has no sound path at all. +pub fn init() -> Result<(), String> { + // SAFETY: string literals are NUL-terminated and live for the program. + unsafe { + SDL_SetAppMetadata( + c"video-output".as_ptr(), + c"0.1.0".as_ptr(), + c"org.local.video-output".as_ptr(), + ); + // KWin keys window rules off the app id, so give it a stable one. + SDL_SetHint(SDL_HINT_APP_ID, c"video-output".as_ptr()); + // Nothing here is interactive; never let the compositor blank the + // screen mid-service. + if !SDL_Init(SDL_INIT_VIDEO) { + return Err(format!("SDL_Init(VIDEO) failed: {}", sdl_error())); + } + SDL_DisableScreenSaver(); + } + Ok(()) +} + +pub fn shutdown() { + // SAFETY: called once, after every window and renderer is destroyed. + unsafe { SDL_Quit() }; +} + +pub fn current_video_driver() -> String { + // SAFETY: valid after SDL_Init; may be NULL before it. + unsafe { + let p = SDL_GetCurrentVideoDriver(); + if p.is_null() { + "none".into() + } else { + CStr::from_ptr(p).to_string_lossy().into_owned() + } + } +} + +/// Snapshots the connected outputs into the plain structs `display::select` +/// works on. +pub fn enumerate_displays() -> Vec { + let mut out = Vec::new(); + // SAFETY: SDL_GetDisplays allocates an array of `count` ids which we free. + unsafe { + let primary = SDL_GetPrimaryDisplay(); + let mut count: i32 = 0; + let ids = SDL_GetDisplays(&mut count); + if ids.is_null() { + return out; + } + for i in 0..count.max(0) as usize { + let id = *ids.add(i); + let name_ptr: *const c_char = SDL_GetDisplayName(id); + let name = if name_ptr.is_null() { + format!("display-{}", id.0) + } else { + CStr::from_ptr(name_ptr).to_string_lossy().into_owned() + }; + + let mut bounds = SDL_Rect::default(); + SDL_GetDisplayBounds(id, &mut bounds); + + let mode = SDL_GetDesktopDisplayMode(id); + let refresh = if mode.is_null() { 0.0 } else { (*mode).refresh_rate }; + + out.push(DisplayInfo { + index: i, + id: id.0, + name, + x: bounds.x, + y: bounds.y, + width: bounds.w, + height: bounds.h, + refresh_hz: refresh, + is_primary: id.0 == primary.0, + }); + } + SDL_free(ids as *mut _); + } + out +} + +fn sdl_format(f: PixelFormat) -> SDL_PixelFormat { + match f { + PixelFormat::Yuy2 => SDL_PIXELFORMAT_YUY2, + PixelFormat::Uyvy => SDL_PIXELFORMAT_UYVY, + PixelFormat::I420 => SDL_PIXELFORMAT_IYUV, + // Packed 32-bit XRGB is byte order B,G,R,X on little-endian, which is + // exactly what TurboJPEG's TJPF_BGRX writes. + PixelFormat::Bgrx => SDL_PIXELFORMAT_XRGB8888, + } +} + +fn sdl_colorspace(c: Colorspace) -> SDL_Colorspace { + match c { + Colorspace::Jpeg => SDL_COLORSPACE_JPEG, + Colorspace::Bt601Limited => SDL_COLORSPACE_BT601_LIMITED, + Colorspace::Bt709Limited => SDL_COLORSPACE_BT709_LIMITED, + } +} + +struct TextureSpec { + width: u32, + height: u32, + format: PixelFormat, + colorspace: Colorspace, +} + +pub struct Renderer { + window: *mut SDL_Window, + renderer: *mut SDL_Renderer, + texture: *mut SDL_Texture, + spec: Option, + /// Display the window was asked to occupy, for the placement check. + target_id: u32, + has_content: bool, +} + +impl Renderer { + pub fn new(target: &DisplayInfo, fullscreen: bool, vsync: bool) -> Result { + let title = CString::new("video-output").unwrap(); + + // SAFETY: every pointer below is checked before use, and the property + // set is destroyed on both the success and failure paths. + unsafe { + let props = SDL_CreateProperties(); + if props.0 == 0 { + return Err(format!("SDL_CreateProperties failed: {}", sdl_error())); + } + SDL_SetStringProperty(props, SDL_PROP_WINDOW_CREATE_TITLE_STRING, title.as_ptr()); + SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_WIDTH_NUMBER, target.width.max(640) as i64); + SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_HEIGHT_NUMBER, target.height.max(360) as i64); + + // This is the display selection. SDL turns the centred-on-display + // position into the `wl_output` it hands to the compositor. + let pos = SDL_WINDOWPOS_CENTERED_DISPLAY(SDL_DisplayID(target.id)) as i64; + SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_X_NUMBER, pos); + SDL_SetNumberProperty(props, SDL_PROP_WINDOW_CREATE_Y_NUMBER, pos); + + SDL_SetBooleanProperty(props, SDL_PROP_WINDOW_CREATE_FULLSCREEN_BOOLEAN, fullscreen); + SDL_SetBooleanProperty(props, SDL_PROP_WINDOW_CREATE_BORDERLESS_BOOLEAN, true); + // Stay hidden until the fullscreen mode is set, so the window never + // flashes up on the wrong monitor. + SDL_SetBooleanProperty(props, SDL_PROP_WINDOW_CREATE_HIDDEN_BOOLEAN, true); + + let window = SDL_CreateWindowWithProperties(props); + SDL_DestroyProperties(props); + if window.is_null() { + return Err(format!("could not create the window: {}", sdl_error())); + } + + // NULL mode means "fullscreen desktop": take the output's current + // resolution rather than forcing a modeset. A modeset would blank + // the projector for a second and can renegotiate HDMI, which is + // exactly what we do not want on a live output. + if fullscreen && !SDL_SetWindowFullscreenMode(window, std::ptr::null()) { + warn_!("could not set fullscreen-desktop mode: {}", sdl_error()); + } + + SDL_ShowWindow(window); + // Block until the compositor has actually applied the above, so the + // placement check below reads the settled state. + SDL_SyncWindow(window); + + let renderer = SDL_CreateRenderer(window, std::ptr::null()); + if renderer.is_null() { + let e = sdl_error(); + SDL_DestroyWindow(window); + return Err(format!("could not create the renderer: {e}")); + } + + if !SDL_SetRenderVSync(renderer, if vsync { 1 } else { SDL_RENDERER_VSYNC_DISABLED }) { + warn_!("could not set vsync: {}", sdl_error()); + } + + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); + SDL_HideCursor(); + + let name = SDL_GetRendererName(renderer); + let name = if name.is_null() { + "unknown".into() + } else { + CStr::from_ptr(name).to_string_lossy().into_owned() + }; + info!( + "Renderer: {name} ({}, vsync {})", + current_video_driver(), + if vsync { "on" } else { "off" } + ); + + let mut r = Renderer { + window, + renderer, + texture: std::ptr::null_mut(), + spec: None, + target_id: target.id, + has_content: false, + }; + r.clear_black(); + r.verify_placement(target); + Ok(r) + } + } + + /// Confirms the compositor honoured the requested output. + /// + /// Nothing can force it on Wayland, so a mismatch is reported rather than + /// fought: an operator seeing this line knows to check KWin's window rules + /// or the output names. + fn verify_placement(&self, target: &DisplayInfo) { + // SAFETY: window is live. + let actual = unsafe { SDL_GetDisplayForWindow(self.window) }; + if actual.0 == 0 { + warn_!("could not confirm which display the window landed on: {}", sdl_error()); + return; + } + if actual.0 == self.target_id { + info!("Opened display: {} ({}x{})", target.name, target.width, target.height); + return; + } + let displays = enumerate_displays(); + let landed = displays + .iter() + .find(|d| d.id == actual.0) + .map(|d| d.name.clone()) + .unwrap_or_else(|| format!("display id {}", actual.0)); + warn_!( + "requested display {} but the compositor placed the window on {}; \ + check the output name with `video-output displays`", + target.name, landed + ); + } + + /// Creates or recreates the streaming texture when the incoming frame's + /// geometry, layout or colour space changes. + fn ensure_texture(&mut self, frame: &Frame) -> Result<(), String> { + let matches = self.spec.as_ref().is_some_and(|s| { + s.width == frame.width + && s.height == frame.height + && s.format == frame.format + && s.colorspace == frame.colorspace + }); + if matches { + return Ok(()); + } + + // SAFETY: destroying a possibly-null texture is a no-op in SDL; the + // property set is released on both paths. + unsafe { + if !self.texture.is_null() { + SDL_DestroyTexture(self.texture); + self.texture = std::ptr::null_mut(); + } + + let props = SDL_CreateProperties(); + if props.0 == 0 { + return Err(format!("SDL_CreateProperties failed: {}", sdl_error())); + } + SDL_SetNumberProperty( + props, + SDL_PROP_TEXTURE_CREATE_FORMAT_NUMBER, + sdl_format(frame.format).0 as i64, + ); + SDL_SetNumberProperty( + props, + SDL_PROP_TEXTURE_CREATE_ACCESS_NUMBER, + SDL_TEXTUREACCESS_STREAMING.0 as i64, + ); + SDL_SetNumberProperty(props, SDL_PROP_TEXTURE_CREATE_WIDTH_NUMBER, frame.width as i64); + SDL_SetNumberProperty(props, SDL_PROP_TEXTURE_CREATE_HEIGHT_NUMBER, frame.height as i64); + // Without this the shader would assume BT.601 studio range and + // shift every colour -- including the green a chroma keyer keys on. + SDL_SetNumberProperty( + props, + SDL_PROP_TEXTURE_CREATE_COLORSPACE_NUMBER, + sdl_colorspace(frame.colorspace).0 as i64, + ); + + let tex = SDL_CreateTextureWithProperties(self.renderer, props); + SDL_DestroyProperties(props); + if tex.is_null() { + return Err(format!("could not create a {}x{} texture: {}", frame.width, frame.height, sdl_error())); + } + SDL_SetTextureScaleMode(tex, SDL_SCALEMODE_LINEAR); + self.texture = tex; + + // Letterbox against the source geometry: the picture fills the + // output without ever being stretched off 16:9. + SDL_SetRenderLogicalPresentation( + self.renderer, + frame.width as i32, + frame.height as i32, + SDL_LOGICAL_PRESENTATION_LETTERBOX, + ); + } + + self.spec = Some(TextureSpec { + width: frame.width, + height: frame.height, + format: frame.format, + colorspace: frame.colorspace, + }); + Ok(()) + } + + /// Uploads a frame and presents it. + pub fn draw(&mut self, frame: &Frame) -> Result<(), String> { + self.ensure_texture(frame)?; + + // SAFETY: the texture matches the frame's geometry and layout, checked + // in `ensure_texture`; every slice below is bounds-checked by `plane`. + unsafe { + let ok = if frame.format == PixelFormat::I420 { + let y = frame.plane(0); + let u = frame.plane(1); + let v = frame.plane(2); + SDL_UpdateYUVTexture( + self.texture, + std::ptr::null(), + y.as_ptr(), + frame.layout.pitches[0] as i32, + u.as_ptr(), + frame.layout.pitches[1] as i32, + v.as_ptr(), + frame.layout.pitches[2] as i32, + ) + } else { + let p = frame.plane(0); + SDL_UpdateTexture( + self.texture, + std::ptr::null(), + p.as_ptr() as *const _, + frame.layout.pitches[0] as i32, + ) + }; + if !ok { + return Err(format!("texture upload failed: {}", sdl_error())); + } + + SDL_RenderClear(self.renderer); + SDL_RenderTexture(self.renderer, self.texture, std::ptr::null(), std::ptr::null()); + SDL_RenderPresent(self.renderer); + } + self.has_content = true; + Ok(()) + } + + /// Presents whatever is already uploaded. Used to keep the compositor fed + /// when the source is running slower than the display refresh. + pub fn repaint(&mut self) { + if !self.has_content || self.texture.is_null() { + self.clear_black(); + return; + } + // SAFETY: texture is live and matches the renderer. + unsafe { + SDL_RenderClear(self.renderer); + SDL_RenderTexture(self.renderer, self.texture, std::ptr::null(), std::ptr::null()); + SDL_RenderPresent(self.renderer); + } + } + + /// Blanks the output. What the screen shows whenever there is no signal. + pub fn clear_black(&mut self) { + // SAFETY: renderer is live for the lifetime of `self`. + unsafe { + SDL_SetRenderDrawColor(self.renderer, 0, 0, 0, 255); + SDL_RenderClear(self.renderer); + SDL_RenderPresent(self.renderer); + } + self.has_content = false; + } + + /// Drains the event queue. Returns false when the process should exit. + /// + /// Escape and Q are honoured so the appliance can be dismissed by hand + /// during setup; in normal operation the launcher sends a signal instead. + pub fn pump_events(&mut self) -> bool { + let mut ev: SDL_Event = unsafe { std::mem::zeroed() }; + // SAFETY: SDL_PollEvent fills the union we own. + unsafe { + while SDL_PollEvent(&mut ev) { + let t = SDL_EventType(ev.r#type); + if t == SDL_EVENT_QUIT || t == SDL_EVENT_WINDOW_CLOSE_REQUESTED { + info!("window close requested"); + return false; + } + if t == SDL_EVENT_KEY_DOWN { + let key = ev.key.key; + if key == SDLK_ESCAPE || key == SDLK_Q { + info!("exit key pressed"); + return false; + } + } + } + } + true + } +} + +impl Drop for Renderer { + fn drop(&mut self) { + // SAFETY: each handle is destroyed once, innermost first. + unsafe { + if !self.texture.is_null() { + SDL_DestroyTexture(self.texture); + } + if !self.renderer.is_null() { + SDL_DestroyRenderer(self.renderer); + } + if !self.window.is_null() { + SDL_DestroyWindow(self.window); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mapping is pure; it is worth pinning because a wrong entry here is a + /// silently mis-rendered picture rather than a crash. + #[test] + fn pixel_formats_map_to_the_matching_sdl_format() { + assert_eq!(sdl_format(PixelFormat::Yuy2).0, SDL_PIXELFORMAT_YUY2.0); + assert_eq!(sdl_format(PixelFormat::Uyvy).0, SDL_PIXELFORMAT_UYVY.0); + assert_eq!(sdl_format(PixelFormat::I420).0, SDL_PIXELFORMAT_IYUV.0); + assert_eq!(sdl_format(PixelFormat::Bgrx).0, SDL_PIXELFORMAT_XRGB8888.0); + } + + #[test] + fn colorspaces_map_to_the_matching_sdl_colorspace() { + 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); + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..33ef25f --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,350 @@ +//! CLI and profile-file parsing, including the invalid cases a launcher could +//! hand us at 09:55 on a Sunday. + +use std::path::PathBuf; +use video_output::cli::{ + parse_args, parse_args_with_loader, parse_fps, parse_size, settings_from_profile, Command, + ParseError, Settings, Source, +}; +use video_output::display::DisplaySelector; +use video_output::log::Level; + +fn args(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() +} + +fn run_settings(v: &[&str]) -> Settings { + match parse_args(&args(v)) { + Ok(Command::Run(s)) => *s, + other => panic!("expected Run, got {other:?}"), + } +} + +/// A loader that serves one in-memory profile, so tests never touch /etc. +fn loader(name: &'static str, body: &'static str) -> impl Fn(&str) -> Result<(PathBuf, String), ParseError> { + move |asked: &str| { + if asked == name { + Ok((PathBuf::from(format!("/test/{asked}.toml")), body.to_string())) + } else { + Err(ParseError(format!("no such profile {asked:?}"))) + } + } +} + +// --- subcommands ------------------------------------------------------------ + +#[test] +fn bare_subcommands_are_recognised() { + assert_eq!(parse_args(&args(&["displays"])).unwrap(), Command::ListDisplays); + assert_eq!(parse_args(&args(&["devices"])).unwrap(), Command::ListDevices); + assert_eq!(parse_args(&args(&["--help"])).unwrap(), Command::Help); + assert_eq!(parse_args(&args(&["-h"])).unwrap(), Command::Help); + assert_eq!(parse_args(&args(&["--version"])).unwrap(), Command::Version); + assert_eq!( + parse_args(&args(&["ndi-sources"])).unwrap(), + Command::ListNdiSources { timeout_secs: 5 } + ); + assert_eq!( + parse_args(&args(&["ndi-sources", "--timeout", "20"])).unwrap(), + Command::ListNdiSources { timeout_secs: 20 } + ); +} + +#[test] +fn no_arguments_is_an_error_with_usage() { + let e = parse_args(&[]).unwrap_err(); + assert!(e.0.contains("USAGE"), "should print usage, got: {}", e.0); +} + +// --- camera ----------------------------------------------------------------- + +#[test] +fn camera_defaults_match_the_atem() { + let s = run_settings(&["camera"]); + match s.source { + Source::Camera { device, width, height, fps_num, fps_den } => { + assert_eq!(device, "/dev/video0"); + assert_eq!((width, height), (1920, 1080)); + assert_eq!((fps_num, fps_den), (30000, 1001), "29.97 must stay exact"); + } + other => panic!("expected Camera, got {other:?}"), + } + assert_eq!(s.display, DisplaySelector::Primary); + assert!(s.vsync); + assert!(s.fullscreen); +} + +#[test] +fn camera_accepts_the_documented_invocation() { + let s = run_settings(&["camera", "--device", "/dev/video2", "--display", "HDMI-A-2"]); + match s.source { + Source::Camera { device, .. } => assert_eq!(device, "/dev/video2"), + other => panic!("expected Camera, got {other:?}"), + } + assert_eq!(s.display, DisplaySelector::Name("HDMI-A-2".into())); +} + +#[test] +fn camera_options_override_defaults() { + let s = run_settings(&[ + "camera", "--size", "1280x720", "--fps", "60", "--no-vsync", "--windowed", + "--log", "debug", "--stats", "0", "--display", "1", + ]); + match s.source { + Source::Camera { width, height, fps_num, fps_den, .. } => { + assert_eq!((width, height), (1280, 720)); + assert_eq!((fps_num, fps_den), (60, 1)); + } + other => panic!("expected Camera, got {other:?}"), + } + assert!(!s.vsync); + assert!(!s.fullscreen); + assert_eq!(s.log_level, Some(Level::Debug)); + assert_eq!(s.stats_interval, 0); + assert_eq!(s.display, DisplaySelector::Index(1)); +} + +// --- ndi -------------------------------------------------------------------- + +#[test] +fn ndi_defaults_to_the_first_source_found() { + let s = run_settings(&["ndi"]); + match s.source { + Source::Ndi { source, extra_ips } => { + assert_eq!(source, None); + assert!(extra_ips.is_empty()); + } + other => panic!("expected Ndi, got {other:?}"), + } +} + +#[test] +fn ndi_accepts_a_source_name_and_repeated_ip_hints() { + let s = run_settings(&[ + "ndi", "--source", "ProPresenter Lower Thirds", + "--extra-ip", "10.0.0.5", "--extra-ip", "10.0.0.6", + "--display", "HDMI-A-1", + ]); + match s.source { + Source::Ndi { source, extra_ips } => { + assert_eq!(source.as_deref(), Some("ProPresenter Lower Thirds")); + assert_eq!(extra_ips, vec!["10.0.0.5", "10.0.0.6"]); + } + other => panic!("expected Ndi, got {other:?}"), + } + assert_eq!(s.display, DisplaySelector::Name("HDMI-A-1".into())); +} + +// --- rejected input --------------------------------------------------------- + +#[test] +fn options_are_rejected_for_the_wrong_mode() { + assert!(parse_args(&args(&["camera", "--source", "x"])).is_err()); + assert!(parse_args(&args(&["camera", "--extra-ip", "1.2.3.4"])).is_err()); + assert!(parse_args(&args(&["ndi", "--device", "/dev/video0"])).is_err()); + assert!(parse_args(&args(&["ndi", "--size", "1920x1080"])).is_err()); + assert!(parse_args(&args(&["ndi", "--fps", "30"])).is_err()); +} + +#[test] +fn unknown_options_and_missing_values_are_rejected() { + assert!(parse_args(&args(&["camera", "--nonsense"])).is_err()); + assert!(parse_args(&args(&["camera", "--device"])).is_err()); + assert!(parse_args(&args(&["camera", "--display"])).is_err()); + assert!(parse_args(&args(&["--display", "HDMI-1"])).is_err(), "options need a command first"); +} + +#[test] +fn malformed_values_are_rejected() { + assert!(parse_args(&args(&["camera", "--size", "1920"])).is_err()); + assert!(parse_args(&args(&["camera", "--size", "0x1080"])).is_err()); + assert!(parse_args(&args(&["camera", "--size", "axb"])).is_err()); + assert!(parse_args(&args(&["camera", "--fps", "0"])).is_err()); + assert!(parse_args(&args(&["camera", "--fps", "-5"])).is_err()); + assert!(parse_args(&args(&["camera", "--fps", "abc"])).is_err()); + assert!(parse_args(&args(&["camera", "--log", "louder"])).is_err()); + assert!(parse_args(&args(&["camera", "--stats", "soon"])).is_err()); + assert!(parse_args(&args(&["camera", "--device", ""])).is_err()); +} + +#[test] +fn an_unknown_profile_name_lists_where_it_looked() { + let e = parse_args(&args(&["definitely-not-a-profile"])).unwrap_err(); + assert!(e.0.contains("/etc/video-output/definitely-not-a-profile.toml"), "got: {}", e.0); +} + +#[test] +fn profile_names_cannot_escape_the_search_path() { + assert!(parse_args(&args(&["../../etc/passwd"])).is_err()); + assert!(parse_args(&args(&["/etc/shadow"])).is_err()); +} + +// --- size and fps parsing --------------------------------------------------- + +#[test] +fn size_parsing_accepts_the_forms_people_type() { + assert_eq!(parse_size("1920x1080").unwrap(), (1920, 1080)); + assert_eq!(parse_size("1920X1080").unwrap(), (1920, 1080)); + assert_eq!(parse_size(" 1280 x 720 ").unwrap(), (1280, 720)); +} + +/// Broadcast rates must stay exact rationals. Sending 2997/100 to V4L2 makes a +/// UVC device round to 30 and free-run against the source. +#[test] +fn fps_parsing_snaps_decimals_to_exact_broadcast_rationals() { + assert_eq!(parse_fps("29.97").unwrap(), (30000, 1001)); + assert_eq!(parse_fps("59.94").unwrap(), (60000, 1001)); + assert_eq!(parse_fps("23.976").unwrap(), (24000, 1001)); + assert_eq!(parse_fps("30000/1001").unwrap(), (30000, 1001)); + assert_eq!(parse_fps("30").unwrap(), (30, 1)); + assert_eq!(parse_fps("25").unwrap(), (25, 1)); +} + +#[test] +fn fps_parsing_rejects_nonsense() { + assert!(parse_fps("0").is_err()); + assert!(parse_fps("0/30").is_err()); + assert!(parse_fps("30/0").is_err()); + assert!(parse_fps("-1").is_err()); + assert!(parse_fps("inf").is_err()); + assert!(parse_fps("").is_err()); +} + +// --- profiles --------------------------------------------------------------- + +#[test] +fn a_camera_profile_produces_the_same_settings_as_flags() { + let s = settings_from_profile( + r#" + # ATEM multiview feed + mode = "camera" + device = "/dev/video0" + display = "HXA BMD HDMI" + size = "1920x1080" + fps = "29.97" + "#, + ) + .unwrap(); + + match s.source { + Source::Camera { device, width, height, fps_num, fps_den } => { + assert_eq!(device, "/dev/video0"); + assert_eq!((width, height), (1920, 1080)); + assert_eq!((fps_num, fps_den), (30000, 1001)); + } + other => panic!("expected Camera, got {other:?}"), + } + assert_eq!(s.display, DisplaySelector::Name("HXA BMD HDMI".into())); +} + +#[test] +fn an_ndi_profile_accumulates_repeated_ip_hints() { + let s = settings_from_profile( + r#" + mode = "ndi" + source = "ProPresenter Lower Thirds" + display = "HDMI-A-1" + extra-ip = "10.0.0.5" + extra-ip = "10.0.0.6" + vsync = false + stats = 30 + "#, + ) + .unwrap(); + + match s.source { + Source::Ndi { source, extra_ips } => { + assert_eq!(source.as_deref(), Some("ProPresenter Lower Thirds")); + assert_eq!(extra_ips, vec!["10.0.0.5", "10.0.0.6"]); + } + other => panic!("expected Ndi, got {other:?}"), + } + assert!(!s.vsync); + assert_eq!(s.stats_interval, 30); +} + +#[test] +fn profile_keys_accept_underscores_and_leading_dashes() { + let s = settings_from_profile("mode = \"camera\"\n--log_level = \"warn\"\n").unwrap(); + assert_eq!(s.log_level, Some(Level::Warn)); +} + +#[test] +fn comments_and_blank_lines_are_ignored_but_hashes_in_values_survive() { + let s = settings_from_profile( + "\n# leading comment\nmode = \"ndi\" # trailing comment\n\nsource = \"Rig #2 Lower Thirds\"\n", + ) + .unwrap(); + match s.source { + Source::Ndi { source, .. } => assert_eq!(source.as_deref(), Some("Rig #2 Lower Thirds")), + other => panic!("expected Ndi, got {other:?}"), + } +} + +/// A typo must fail at startup rather than silently running the wrong config. +#[test] +fn unknown_profile_keys_are_rejected() { + let e = settings_from_profile("mode = \"camera\"\ndisplya = \"HDMI-1\"\n").unwrap_err(); + assert!(e.0.contains("displya"), "got: {}", e.0); +} + +#[test] +fn invalid_profiles_are_rejected() { + assert!(settings_from_profile("device = \"/dev/video0\"").is_err(), "missing mode"); + assert!(settings_from_profile("mode = \"telepathy\"").is_err(), "unknown mode"); + assert!(settings_from_profile("mode = \"camera\"\nthis line has no equals\n").is_err()); + assert!(settings_from_profile("mode = \"camera\"\n[section]\nkey = 1\n").is_err(), "no tables"); + assert!(settings_from_profile("mode = \"camera\"\ndevice = \"a\"\ndevice = \"b\"\n").is_err(), "duplicate key"); + assert!(settings_from_profile("mode = \"camera\"\nvsync = \"maybe\"\n").is_err()); + assert!(settings_from_profile("mode = \"camera\"\nfps = \"quick\"\n").is_err()); + assert!(settings_from_profile("mode = \"ndi\"\nsize = \"1920x1080\"\n").is_err(), "size is camera-only"); + assert!(settings_from_profile("mode = \"camera\"\nextra-ip = \"1.2.3.4\"\n").is_err(), "extra-ip is ndi-only"); +} + +#[test] +fn booleans_accept_the_usual_spellings() { + for (text, want) in [("true", true), ("yes", true), ("on", true), ("1", true), + ("false", false), ("no", false), ("off", false), ("0", false)] { + let s = settings_from_profile(&format!("mode = \"camera\"\nvsync = {text}\n")).unwrap(); + assert_eq!(s.vsync, want, "vsync = {text}"); + } +} + +// --- profile + flag interaction --------------------------------------------- + +#[test] +fn a_profile_name_resolves_through_the_loader() { + let load = loader("multiview", "mode = \"camera\"\ndevice = \"/dev/video0\"\ndisplay = \"BMD\"\n"); + match parse_args_with_loader(&args(&["multiview"]), &load).unwrap() { + Command::Run(s) => { + assert_eq!(s.display, DisplaySelector::Name("BMD".into())); + match &s.source { + Source::Camera { device, .. } => assert_eq!(device, "/dev/video0"), + other => panic!("expected Camera, got {other:?}"), + } + } + other => panic!("expected Run, got {other:?}"), + } +} + +/// Flags after a profile name win, so an operator can override one setting +/// without editing the file. +#[test] +fn flags_override_profile_values() { + let load = loader("multiview", "mode = \"camera\"\ndevice = \"/dev/video0\"\ndisplay = \"BMD\"\n"); + match parse_args_with_loader(&args(&["multiview", "--display", "Sceptre", "--no-vsync"]), &load).unwrap() { + Command::Run(s) => { + assert_eq!(s.display, DisplaySelector::Name("Sceptre".into())); + assert!(!s.vsync); + } + other => panic!("expected Run, got {other:?}"), + } +} + +#[test] +fn a_profile_error_names_the_file() { + let load = loader("broken", "mode = \"camera\"\nbogus = 1\n"); + let e = parse_args_with_loader(&args(&["broken"]), &load).unwrap_err(); + assert!(e.0.contains("/test/broken.toml"), "got: {}", e.0); + assert!(e.0.contains("bogus"), "got: {}", e.0); +} diff --git a/tests/data/generate.py b/tests/data/generate.py new file mode 100644 index 0000000..753ab8c --- /dev/null +++ b/tests/data/generate.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Regenerates the JPEG fixtures used by tests/decode.rs. + +Run from the repository root: python3 tests/data/generate.py + +Each fixture is a solid colour so the test can assert exact sample values +after decoding. Green is deliberate: it is the colour the ATEM keys on, and a +wrong YUV matrix or range shows up here before it shows up on stage. +""" +from PIL import Image +import pathlib + +OUT = pathlib.Path(__file__).parent +W, H = 64, 32 +GREEN = (0, 255, 0) + +# PIL subsampling codes: 0 = 4:4:4, 1 = 4:2:2, 2 = 4:2:0 +for name, subs in [("green-444.jpg", 0), ("green-422.jpg", 1), ("green-420.jpg", 2)]: + Image.new("RGB", (W, H), GREEN).save(OUT / name, "JPEG", quality=95, subsampling=subs) + +Image.new("L", (W, H), 128).save(OUT / "gray.jpg", "JPEG", quality=95) + +for p in sorted(OUT.glob("*.jpg")): + print(f"{p.name}: {p.stat().st_size} bytes") diff --git a/tests/data/gray.jpg b/tests/data/gray.jpg new file mode 100644 index 0000000000000000000000000000000000000000..83227054d2d701bc81eb2e32850028a7c06a47b7 GIT binary patch literal 354 zcmex=iF;N$`UAd82aiwDH{e}I9TgF%78fss*=;r|f^d7$B}U^9U>Kma2XGYcyl zJ5Vn-P{CFKpqB)&N!40_7Q41X+a?4ISBp0~6Vm3Pp?>CobercG`GQH0a_7 z72~9$CQdFfaS2H&RW)@DO)V2sGjj_|D`yv1H+K(Dui%i-u<(e;sN|H?wDgS3tm2Z= zvhs?`s^*r~w)T$Bu1S-pOr17;#>`oZ7B5-4Z25|nt2S-kvUS_`9Xod&I(+2lvEwIB zp1O4T%GGPvZ`{1~@X_NZPoF)1@$%KjPoKYh{r3IG&tLyru_&7MJHQ5RP9|0V$Y C1a1)k literal 0 HcmV?d00001 diff --git a/tests/data/green-420.jpg b/tests/data/green-420.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a26b1ae3c28b99566c359bad6daf5b4076ec9470 GIT binary patch literal 663 zcmex=iF;N$`UAd82aiwDF383NJD#LCRf%Eivc4pu@E@&5pWAP0j2g99_85(ASU zBeNjm|04|YKzFi&odL?6mQqXwbzED#l4gO`Kd};u4Zls%q*Qnp!5NX66=_R?aT2 zZtfnQUcn)uVc`*xQOPN(Y3Ui6S;Zx#W#tu>Rn0A}ZS5VMU6UqHnL2IyjG40*Enc#8 z+42=DS8dw7W$U)>J9h3mboj{8W5-XNJay^vm8;jT-?(|};iJb-o<4j2;^nK4pFV&2 z`tAFVpT9u3cne5k^_L*fUreAlU=!0ld(M2vX6_bamA33 Ui}s)T{3n=8V^ISxZ2$iz0EGz5hX4Qo literal 0 HcmV?d00001 diff --git a/tests/data/green-422.jpg b/tests/data/green-422.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d4243677ee39b8d3332072b92bbd8b6fb3d61c8d GIT binary patch literal 671 zcmex=iF;N$`UAd82aiwDF383NJD#LCRf%Eivc4pu@E@&5pWAP0j2g99_8A_J2k zBeNjm|04|YKzFi&odL?6mQqXwbzED#l4gO`Kd};u4Zls%q*Qnp!5NX66=_R?aT2 zZtfnQUcn)uVc`*xQOPN(Y3Ui6S;Zx#W#tu>Rn0A}ZS5VMU6UqHnL2IyjG40*Enc#8 z+42=DS8dw7W$U)>J9h3mboj{8W5-XNJay^vm8;jT-?(|};iJb-o<4j2;^nK4pFV&2 z`tAFVpT9u3cne5k^_L*fUreAlU=!0ld(M2vX6_bamA3XG(0GF}M9RL6T literal 0 HcmV?d00001 diff --git a/tests/data/green-444.jpg b/tests/data/green-444.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6b47dff1e1953fe4616d956eb4e319bc96ad50a8 GIT binary patch literal 688 zcmex=iF;N$`UAd82aiwDF383NJD#LCRf%Eivc4pu@E@&5pWAP0j2g99_8AOn*i zBeNjm|04|YKzFi&odL?6mQqXwbzED#l4gO`Kd};u4Zls%q*Qnp!5NX66=_R?aT2 zZtfnQUcn)uVc`*xQOPN(Y3Ui6S;Zx#W#tu>Rn0A}ZS5VMU6UqHnL2IyjG40*Enc#8 z+42=DS8dw7W$U)>J9h3mboj{8W5-XNJay^vm8;jT-?(|};iJb-o<4j2;^nK4pFV&2 z`tAFVpT9u3cne5k^%pP@7@0tEzz*^kBU3pLGYhh?DjKp0IR>&P778mFHFAhJ zO Vec { + let p = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/"); + std::fs::read(format!("{p}{name}")).unwrap_or_else(|e| panic!("reading {name}: {e}")) +} + +fn close(got: u8, want: u8, label: &str) { + let d = (got as i32 - want as i32).abs(); + assert!(d <= TOL, "{label}: got {got}, expected ~{want} (tolerance {TOL})"); +} + +#[test] +fn decodes_422_into_packed_yuy2() { + let mut d = MjpegDecoder::new().unwrap(); + let frame = d.decode(&fixture("green-422.jpg"), &NullPool, 1).unwrap(); + + assert_eq!((frame.width, frame.height), (W, H)); + assert_eq!(frame.format, PixelFormat::Yuy2, "4:2:2 must take the zero-conversion path"); + assert_eq!(frame.layout.total, (W * H * 2) as usize); + assert_eq!(frame.seq, 1); + + // YUY2 byte order is Y0 U Y1 V. + let p = frame.plane(0); + close(p[0], GREEN_Y, "Y0"); + close(p[1], GREEN_CB, "U"); + close(p[2], GREEN_Y, "Y1"); + close(p[3], GREEN_CR, "V"); + + // The whole frame is one colour, so the pattern must hold everywhere -- + // this catches a stride or interleave bug that only shows on later rows. + for (i, quad) in p.chunks_exact(4).enumerate() { + close(quad[0], GREEN_Y, &format!("Y0 at quad {i}")); + close(quad[1], GREEN_CB, &format!("U at quad {i}")); + close(quad[2], GREEN_Y, &format!("Y1 at quad {i}")); + close(quad[3], GREEN_CR, &format!("V at quad {i}")); + } +} + +#[test] +fn decodes_420_into_planar_i420() { + let mut d = MjpegDecoder::new().unwrap(); + let frame = d.decode(&fixture("green-420.jpg"), &NullPool, 2).unwrap(); + + assert_eq!(frame.format, PixelFormat::I420); + assert_eq!(frame.layout.total, (W * H * 3 / 2) as usize); + + let y = frame.plane(0); + let u = frame.plane(1); + let v = frame.plane(2); + assert_eq!(y.len(), (W * H) as usize); + assert_eq!(u.len(), (W / 2 * H / 2) as usize); + assert_eq!(v.len(), (W / 2 * H / 2) as usize); + + for (i, &s) in y.iter().enumerate() { + close(s, GREEN_Y, &format!("Y[{i}]")); + } + for (i, &s) in u.iter().enumerate() { + close(s, GREEN_CB, &format!("U[{i}]")); + } + for (i, &s) in v.iter().enumerate() { + close(s, GREEN_CR, &format!("V[{i}]")); + } +} + +#[test] +fn decodes_444_through_the_bgrx_fallback() { + let mut d = MjpegDecoder::new().unwrap(); + let frame = d.decode(&fixture("green-444.jpg"), &NullPool, 3).unwrap(); + + assert_eq!(frame.format, PixelFormat::Bgrx, "4:4:4 has no packed YUV equivalent"); + assert_eq!(frame.layout.total, (W * H * 4) as usize); + + // Byte order is B, G, R, X. + for (i, px) in frame.plane(0).chunks_exact(4).enumerate() { + close(px[0], 0, &format!("B at {i}")); + close(px[1], 255, &format!("G at {i}")); + close(px[2], 0, &format!("R at {i}")); + } +} + +#[test] +fn decodes_grayscale_through_the_bgrx_fallback() { + let mut d = MjpegDecoder::new().unwrap(); + let frame = d.decode(&fixture("gray.jpg"), &NullPool, 4).unwrap(); + + assert_eq!(frame.format, PixelFormat::Bgrx); + for px in frame.plane(0).chunks_exact(4) { + close(px[0], 128, "B"); + close(px[1], 128, "G"); + close(px[2], 128, "R"); + } +} + +#[test] +fn header_read_reports_dimensions_and_subsampling() { + let mut d = MjpegDecoder::new().unwrap(); + let info = d.read_header(&fixture("green-422.jpg")).unwrap(); + assert_eq!((info.width, info.height), (W, H)); + assert_eq!(info.subsamp_name(), "4:2:2"); + assert_eq!(info.colorspace_name(), "YCbCr"); + assert_eq!(info.target_format(), PixelFormat::Yuy2); +} + +/// The failure mode that actually happens on USB: a frame that stops early. +/// Every truncation length must be rejected without panicking or reading out +/// of bounds. +#[test] +fn every_truncation_of_a_real_jpeg_fails_cleanly() { + let full = fixture("green-422.jpg"); + let mut d = MjpegDecoder::new().unwrap(); + + for cut in 1..full.len() { + let partial = &full[..cut]; + // Must not panic. Success is acceptable only at the full length. + let _ = d.decode(partial, &NullPool, cut as u64); + } + + // The decoder must still work afterwards -- no poisoned state. + let frame = d.decode(&full, &NullPool, 999).unwrap(); + assert_eq!(frame.format, PixelFormat::Yuy2); + assert_eq!(frame.seq, 999); +} + +/// Bit flips inside the entropy-coded data are the other common USB fault. +#[test] +fn corrupted_payloads_never_panic_and_leave_the_decoder_usable() { + let full = fixture("green-422.jpg"); + let mut d = MjpegDecoder::new().unwrap(); + + for seed in 0..200u32 { + let mut bad = full.clone(); + let i = (seed as usize * 7 + 100) % bad.len(); + bad[i] ^= 0xFF; + let _ = d.decode(&bad, &NullPool, seed as u64); + } + + assert!(d.decode(&full, &NullPool, 1).is_ok(), "decoder must survive a burst of corruption"); +} + +#[test] +fn non_jpeg_input_is_rejected() { + let mut d = MjpegDecoder::new().unwrap(); + assert!(d.decode(b"not a jpeg at all, not even close", &NullPool, 0).is_err()); + assert!(d.decode(&[], &NullPool, 0).is_err()); + assert!(d.decode(&[0xFF; 1024], &NullPool, 0).is_err()); +} + +/// Decoding many frames through one decoder must not grow memory or drift -- +/// this is the steady state the appliance actually runs in. +#[test] +fn repeated_decodes_are_stable() { + let data = fixture("green-422.jpg"); + let mut d = MjpegDecoder::new().unwrap(); + for seq in 0..100 { + let f = d.decode(&data, &NullPool, seq).unwrap(); + assert_eq!(f.layout.total, (W * H * 2) as usize); + close(f.plane(0)[0], GREEN_Y, "Y across repeats"); + } +}