# 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.