From 81fb4d6094c56ed2e97c83051af3c01e79b229e1 Mon Sep 17 00:00:00 2001 From: Thomas Junginger Date: Tue, 10 Nov 2026 11:17:41 +0100 Subject: [PATCH] Initial release: Raspberry Pi NDI Gateway --- README.md | 333 +++++++++++++++ install-trixie-ndi-pi.sh | 903 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 1236 insertions(+) create mode 100644 README.md create mode 100644 install-trixie-ndi-pi.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..362bbce --- /dev/null +++ b/README.md @@ -0,0 +1,333 @@ +# Raspberry Pi NDI Gateway + +**Work in Progress – erste öffentliche Veröffentlichung** + +Ein Installationsskript für einen Raspberry Pi 4 (ARM64) mit Raspberry Pi OS / Debian 13 „Trixie“. Das Projekt richtet einen headless NDI-Gateway mit GStreamer ein: + +- USB-HDMI-Capture → NDI +- NDI → HDMI über DRM/KMS +- optionales Capture-Audio → NDI + +Der aktuelle Stand ist ausdrücklich **nicht** als vollständig getestete oder produktionsreife Lösung zu verstehen. Siehe [Bekannte Einschränkungen](#bekannte-einschränkungen). + +## Funktionsumfang des aktuellen Skripts + +| Bereich | Implementierter Stand | +|---|---| +| Betriebssystem | Debian 13 / Raspberry Pi OS Trixie, ARM64 | +| GStreamer | Debian-/Raspberry-Pi-OS-Systempakete | +| NDI-Plugin | `gst-plugin-ndi` aus `gst-plugins-rs`, standardmäßig Tag `0.14.5` | +| NDI-Runtime | Vorhandene NDI-6-Runtime wird gesucht; andernfalls wird ein SDK-Archiv benötigt | +| Capture-Video | Automatische oder manuelle V4L2-Geräteauswahl | +| Capture-Formate | MJPEG und YUYV, sofern vom Gerät angeboten | +| Capture-Audio | Optional; automatische oder manuelle ALSA-Geräteauswahl | +| NDI-Empfang | NDI-Quelle per Name oder direkter IP:Port-Adresse | +| HDMI-Video | Direkte Ausgabe über `kmssink` / DRM-KMS, ohne Desktop | +| HDMI-Audio | **Noch nicht implementiert**; empfangenes NDI-Audio wird aktuell verworfen | +| Dienste | `ndi-capture.service` und `ndi-display.service` | +| Diagnose | `/opt/ndi-pi/bin/ndi-pi-diagnose` | + +## Architektur + +### Capture: USB-Capture → NDI + +```text +USB-HDMI-Capture + ├── V4L2-Video + │ ├── MJPEG → jpegdec ───────────┐ + │ └── YUYV → videoconvert ───────┤ + │ ▼ + │ kurze Queue + │ │ + └── USB-Audio → ALSA → convert/resample + │ + ▼ + ndisinkcombiner + │ + ndisink + │ + NDI OUT +``` + +Wenn kein passendes Capture-Audiogerät gefunden wird oder `ENABLE_AUDIO=false` gesetzt ist, wird eine Video-only-Pipeline gestartet. Die Audiozuordnung erfolgt anhand des USB-Gerätepfads; wenn keine eindeutige Übereinstimmung gefunden wird, kann derzeit ersatzweise das erste gefundene USB-Audiogerät gewählt werden. + +### Display: NDI → HDMI + +```text +NDI IN + │ + ndisrc + │ +ndisrcdemux + │ + ├── Video → queue → videoscale → kmssink → HDMI + │ + └── Audio → queue → fakesink (wird verworfen) +``` + +Die Videoausgabe verwendet DRM/KMS und benötigt daher keine Desktop-Umgebung wie X11 oder Wayland. Wenn die Live-Pipeline beendet wird, versucht der Dienst einen schwarzen Standby-Bildschirm mit Statusanzeige zu starten und die NDI-Verbindung erneut aufzubauen. + +## Voraussetzungen + +- Raspberry Pi 4 mit 64-Bit-ARM-System +- Raspberry Pi OS / Debian 13 „Trixie“ +- Netzwerkverbindung; Ethernet wird empfohlen +- USB-HDMI-Capture-Gerät mit Linux-V4L2-Unterstützung +- HDMI-Display am Pi für die lokale Ausgabe +- NDI-kompatible Gegenstelle im Netzwerk +- Root-/sudo-Rechte für die Installation + +Das Skript prüft Architektur und Debian-Codename. Andere Raspberry-Pi-Modelle, Betriebssystemversionen oder 32-Bit-Systeme sind nicht als unterstützt anzusehen. + +## Installation + +1. Repository auf dem Raspberry Pi klonen. +2. Ein offizielles NDI-SDK-Archiv lokal bereitstellen oder eine Download-URL angeben. +3. Installationsskript mit Root-Rechten ausführen. + +Beispiel mit lokal heruntergeladenem SDK-Archiv: + +```bash +sudo NDI_SDK_TARBALL=/pfad/zum/ndi-sdk.tar.gz ./install-trixie-ndi-pi-v18.sh +``` + +Alternativ kann die URL über die Umgebungsvariable übergeben werden: + +```bash +sudo NDI_SDK_URL="https://" ./install-trixie-ndi-pi-v18.sh +``` + +`` ist durch die aktuelle Download-URL des NDI-Herstellers zu ersetzen. Die URL ist absichtlich nicht im Skript fest hinterlegt. + +Wenn die NDI-6-Runtime bereits an einem der vom Skript geprüften Orte installiert ist, wird kein SDK-Archiv benötigt: + +- `/usr/local/lib` +- `/usr/lib/aarch64-linux-gnu` + +Das Skript installiert Systemabhängigkeiten, baut das NDI-GStreamer-Plugin und legt Konfiguration, Hilfsskripte und systemd-Units an. Es aktiviert und startet anschließend beide Dienste. + +> **Hinweis:** Das Installationsskript sollte vor dem Einsatz geprüft werden. Es verändert Systempakete, installiert Bibliotheken und schreibt Dateien unter `/opt/ndi-pi`, `/etc` und `/etc/systemd/system`. + +## Warum GStreamer aus den Distributionspaketen kommt + +GStreamer selbst wird nicht aus einem Git-Repository gebaut. Das Skript installiert die GStreamer-Komponenten aus den Debian-/Raspberry-Pi-OS-Paketen. Das zusätzliche NDI-Plugin wird separat aus `gst-plugins-rs` gebaut. + +Standardmäßig verwendet das Skript den Tag `0.14.5`: + +```text +Debian / Raspberry Pi OS GStreamer + │ + └── gst-plugin-ndi (gst-plugins-rs 0.14.5) + │ + └── NDI 6 Runtime +``` + +Der Plugin-Tag kann über `NDI_GST_TAG` überschrieben werden: + +```bash +sudo NDI_GST_TAG=0.14.5 NDI_SDK_TARBALL=/pfad/zum/ndi-sdk.tar.gz ./install-trixie-ndi-pi-v18.sh +``` + +## Konfiguration + +Die zentrale Konfigurationsdatei lautet: + +```text +/etc/ndi-pi.conf +``` + +Eine neue Installation legt sie mit folgenden wesentlichen Standardwerten an: + +```ini +PI_NAME= +NDI_CAPTURE_NAME=-Capture +NDI_DISPLAY_SOURCE=PC01-Capture +NDI_DISPLAY_ADDRESS=auto +NDI_USE_DIRECT_ADDRESS=true + +CAPTURE_DEVICE=auto +CAPTURE_WIDTH=1920 +CAPTURE_HEIGHT=1080 +CAPTURE_FPS=30 +CAPTURE_FORMAT=MJPEG +CAPTURE_AUTO_FALLBACK=true +CAPTURE_QUEUE_BUFFERS=2 + +ENABLE_AUDIO=true +CAPTURE_AUDIO_DEVICE=auto +CAPTURE_AUDIO_RATE=48000 +CAPTURE_AUDIO_CHANNELS=2 + +ENABLE_HDMI_AUDIO=false +HDMI_AUDIO_DEVICE=auto +KMS_DRIVER=vc4 +KMS_CONNECTOR_ID=auto + +NDI_MAX_QUEUE=0 +DISPLAY_WIDTH=1920 +DISPLAY_HEIGHT=1080 +DISPLAY_FPS=60 +``` + +`` steht hier nur als Platzhalter für den vom Installer ermittelten Hostnamen. Die tatsächliche Datei enthält konkrete Werte. + +### Wichtige Einstellungen + +| Einstellung | Bedeutung | +|---|---| +| `NDI_CAPTURE_NAME` | Name der vom Pi gesendeten NDI-Quelle | +| `NDI_DISPLAY_SOURCE` | Name der NDI-Quelle, die der Pi empfangen soll | +| `NDI_DISPLAY_ADDRESS` | `auto` für mDNS-Auflösung oder feste Adresse im Format `IP:Port` | +| `NDI_USE_DIRECT_ADDRESS` | Bei `true` wird die aufgelöste bzw. konfigurierte IP:Port-Adresse für `ndisrc` verwendet | +| `CAPTURE_DEVICE` | `auto` oder ein konkreter V4L2-Gerätepfad | +| `CAPTURE_WIDTH`, `CAPTURE_HEIGHT`, `CAPTURE_FPS` | Bevorzugter Capture-Modus | +| `CAPTURE_FORMAT` | `MJPEG`, `YUYV` oder `auto` | +| `CAPTURE_AUTO_FALLBACK` | Erlaubt die Suche nach einem anderen unterstützten Modus | +| `CAPTURE_QUEUE_BUFFERS` | Anzahl der Videobuffer in der Capture-Queue | +| `ENABLE_AUDIO` | Aktiviert/deaktiviert Capture-Audio → NDI | +| `CAPTURE_AUDIO_DEVICE` | `auto` oder ALSA-Gerät, z. B. `plughw:2,0` | +| `KMS_DRIVER` | DRM/KMS-Treiber, Standard `vc4` | +| `KMS_CONNECTOR_ID` | `auto` oder eine feste DRM-Connector-ID | +| `DISPLAY_WIDTH`, `DISPLAY_HEIGHT`, `DISPLAY_FPS` | Auflösung und Framerate des Standby-Bildes | +| `NDI_MAX_QUEUE` | NDI-Empfangsqueue; Standardwert `0` | + +`ENABLE_HDMI_AUDIO` und `HDMI_AUDIO_DEVICE` sind derzeit vorbereitete Konfigurationswerte, werden von der aktuellen Display-Pipeline aber **noch nicht umgesetzt**. Sie einzuschalten aktiviert daher keine HDMI-Audioausgabe. + +Bestehende Konfigurationen werden vom Installer ergänzt, ohne vorhandene Werte generell zu überschreiben. Bei älteren Konfigurationsdateien sollten die benötigten Capture-Werte kontrolliert und gegebenenfalls ergänzt werden. + +## Capture-Geräteauswahl und Videoformate + +Mit: + +```ini +CAPTURE_DEVICE=auto +``` + +untersucht das Skript `/dev/video*` und berücksichtigt Geräte mit V4L2-Video-Capture-Funktion und einem unterstützten Modus. + +Für eine feste Installation kann ein konkreter Pfad eingetragen werden, vorzugsweise ein stabiler Symlink: + +```ini +CAPTURE_DEVICE=/dev/v4l/by-id/ +``` + +Die Modusauswahl berücksichtigt die tatsächlich vom Gerät gemeldeten Formate. Die gewünschte Kombination aus Auflösung und Framerate wird zuerst versucht. Wenn `CAPTURE_AUTO_FALLBACK=true` gesetzt ist, folgt eine festgelegte Fallback-Liste. + +**Wichtig zur Formatpriorität:** Bei `CAPTURE_FORMAT=auto` ist die aktuelle Fallback-Reihenfolge YUYV vor MJPEG. Auch die Gerätebewertung bevorzugt YUYV. Das unterscheidet sich von einer MJPEG-first-Strategie. Bei einer neuen Installation steht `CAPTURE_FORMAT=MJPEG` als Standard in der Konfiguration; damit wird die Auswahl auf MJPEG beschränkt, sofern ein passender Modus vorhanden ist und ein Fallback nicht durch die Formatvorgabe ausgeschlossen wird. + +MJPEG wird mit `jpegdec` dekodiert. Der aktuelle Code verwendet bewusst nicht `v4l2jpegdec`. YUYV wird über `videoconvert` in UYVY konvertiert, bevor es an den NDI-Sink geht. + +## Audio + +### Capture-Audio → NDI + +Bei `ENABLE_AUDIO=true` versucht das Skript, ein ALSA-Gerät zu finden, das zum ausgewählten USB-Capture-Gerät gehört. Alternativ kann ein ALSA-Gerät fest vorgegeben werden: + +```ini +CAPTURE_AUDIO_DEVICE=plughw:2,0 +``` + +Die automatische Zuordnung ist heuristisch und sollte mit der konkreten Capture-Hardware überprüft werden. Wird kein Gerät gefunden, startet die Capture-Pipeline als Video-only. + +### NDI-Audio → HDMI + +Diese Richtung ist im aktuellen Skript **noch nicht implementiert**. Der Audio-Pad von `ndisrcdemux` wird derzeit an `fakesink` angeschlossen und damit verworfen. Es gibt aktuell keine ALSA-Wiedergabepipeline für den NDI-Ton. + +## NDI-Discovery und Netzwerk + +Der Display-Dienst verwendet standardmäßig: + +```ini +NDI_DISPLAY_SOURCE=PC01-Capture +NDI_DISPLAY_ADDRESS=auto +NDI_USE_DIRECT_ADDRESS=true +``` + +Bei `auto` versucht das Skript, den NDI-Dienst über Avahi/mDNS (`_ndi._tcp`) aufzulösen und die ermittelte IP-Adresse samt Port an `ndisrc` zu übergeben. Eine feste Adresse kann so angegeben werden: + +```ini +NDI_DISPLAY_ADDRESS=192.168.1.50:5961 +``` + +Der tatsächliche Port ist abhängig von der NDI-Quelle und muss passend ermittelt werden. Wenn keine direkte Adresse verfügbar ist, verwendet der initiale Start `ndi-name` als Fallback. Die Wiederverbindungslogik ist derzeit noch eingeschränkt; siehe [Bekannte Einschränkungen](#bekannte-einschränkungen). + +Für Discovery über VLAN- oder Subnetzgrenzen hinweg kann eine gesonderte NDI-Discovery-Server-Konfiguration erforderlich sein. Das wird vom Installationsskript nicht eingerichtet. + +## systemd-Dienste und Logs + +Es werden zwei getrennte Dienste angelegt: + +```text +ndi-capture.service +ndi-display.service +``` + +Status prüfen: + +```bash +systemctl status ndi-capture +systemctl status ndi-display +``` + +Logs verfolgen: + +```bash +journalctl -u ndi-capture -f +journalctl -u ndi-display -f +``` + +Beide Dienste verwenden `Restart=always` und aktuell `RestartSec=1`. + +Nach einer Änderung an `/etc/ndi-pi.conf`: + +```bash +sudo systemctl restart ndi-capture ndi-display +``` + +## Diagnose + +Das Diagnoseprogramm liegt unter: + +```bash +sudo /opt/ndi-pi/bin/ndi-pi-diagnose +``` + +Es sammelt unter anderem: + +- Kernel- und Betriebssysteminformationen +- GStreamer-Version und ausgewählte Plugin-Informationen +- V4L2-Geräte und gemeldete Capture-Formate +- ALSA-Aufnahme- und Wiedergabegeräte +- DRM-Geräte und Connector-Status +- NDI-Konfiguration und NDI/mDNS-Discovery +- systemd-Status und letzte Service-Logs + +Für Fehlerberichte ist die vollständige Ausgabe hilfreich. Zugangsdaten oder andere private Netzwerkinformationen sollten vor dem Veröffentlichen entfernt werden. + +## Bekannte Einschränkungen + +Der folgende Abschnitt beschreibt bewusst den Stand des aktuellen Skripts und ist Teil der WIP-Kennzeichnung. + +1. **NDI-Audio wird am HDMI-Ausgang nicht wiedergegeben.** Der Empfangs-Audiozweig endet aktuell in `fakesink`. +2. **Automatische USB-Audiozuordnung ist nicht garantiert.** Wenn die Zuordnung zum Video-Gerät nicht gelingt, kann ein anderes erstes USB-Audiogerät als Fallback ausgewählt werden. +3. **Wiederverbindung nach NDI-Ausfall ist eingeschränkt.** Die Standby-Schleife prüft eine direkte IP:Port-Adresse. Wenn nur der Namens-Fallback aktiv ist oder mDNS die Quelle nicht auflösen kann, wird die Quelle nicht in allen Fällen erneut gefunden. +4. **Signalbehandlung des Display-Helfers muss weiter getestet werden.** Das aktuelle Trap räumt den Standby-Prozess auf; das Verhalten beim Beenden durch systemd sollte auf dem Zielgerät überprüft werden. +5. **Ältere Konfigurationsdateien benötigen Prüfung.** Die Migration ergänzt einige Schlüssel, aber nicht alle möglichen Capture-Parameter. Fehlende Werte wie `CAPTURE_WIDTH` oder `CAPTURE_FPS` können bei älteren Konfigurationen problematisch sein. +6. **NDI-Runtime-Pfad in systemd:** Der Installer erkennt die Runtime auch unter `/usr/lib/aarch64-linux-gnu`; die erzeugten systemd-Units setzen aktuell jedoch fest `NDI_RUNTIME_DIR_V6=/usr/local/lib`. Bei einer Runtime-Installation im Multiarch-Verzeichnis muss das geprüft werden. +7. **Keine vollständige Hardware-/Laufzeitvalidierung durch Syntaxprüfung:** Ein erfolgreicher Bash-Syntaxcheck bestätigt weder GStreamer-Pipeline-Negotiation noch NDI-Discovery, DRM/KMS-Ausgabe oder das Verhalten einer konkreten Capture-Karte. + +Weitere bekannte Entwicklungsaufgaben sind eine umfassendere Plugin-Prüfung, robustere Installer-Rollback-Strategien und eine Prüfung des SDK-Archivs anhand eines verifizierten Hashes bzw. einer Signatur. + +## Sicherheit und Lizenzhinweise + +Das NDI SDK bzw. die NDI-Runtime wird vom NDI-Hersteller bereitgestellt und unterliegt dessen Lizenz- und Distributionsbedingungen. Das SDK-Archiv und die Runtime-Bibliotheken sollten nicht ohne Prüfung dieser Bedingungen in ein öffentliches Repository eingecheckt oder weiterverteilt werden. + +Das Repository enthält das Installationsskript und die Projektdateien; Nutzer müssen die NDI-Runtime entsprechend den geltenden Herstellerbedingungen selbst beziehen bzw. bereitstellen. + +Das Installationsskript entpackt ein bereitgestelltes SDK-Archiv mit Root-Rechten. Verwende nur ein vertrauenswürdiges Archiv aus einer offiziellen Quelle. Eine automatisierte Hash-/Signaturprüfung des Archivs ist im aktuellen Skript noch nicht implementiert. + +## Projektstatus + +Dieses Projekt wird aktiv entwickelt. Hardwarekombinationen, Capture-Modi, NDI-Versionen und HDMI-Ausgabeverhalten können sich unterscheiden. Rückmeldungen mit Diagnoseausgabe, Pi-Modell, OS-Version und genauer Capture-Hardware sind willkommen. + +**Vor einem produktiven Einsatz sind Tests mit der eigenen Hardware und Netzwerkinfrastruktur erforderlich.** diff --git a/install-trixie-ndi-pi.sh b/install-trixie-ndi-pi.sh new file mode 100644 index 0000000..47dfd43 --- /dev/null +++ b/install-trixie-ndi-pi.sh @@ -0,0 +1,903 @@ +#!/usr/bin/env bash +# NDI Pi Gateway installer for Raspberry Pi OS Trixie / Debian 13 ARM64. +# V17: Adds configurable capture buffering and aspect-preserving HDMI canvas fitting. Integrates validated direct NDI BGRx/BGRA-to-KMS display path without videoconvert. +# Restores the manually verified NDI -> ndisrcdemux -> BGRA -> KMS live pipeline. +# Standby has explicit HDMI caps and status text; service startup tries live first +# and only grabs KMS for standby when the live pipeline is unavailable. +# Capture mode selection and NDI latency settings are unchanged; only capture queue buffering is tuned. +# Updated: installed NDI 6.x runtimes are detected without requiring the SDK archive. +set -Eeuo pipefail + +# Raspberry Pi 4 / Raspberry Pi OS Trixie 64-bit +# NDI Gateway: +# USB capture + USB audio -> NDI +# NDI video -> HDMI/DRM +# NDI audio -> HDMI ALSA +# +# NDI receive design: +# - Prefer a direct NDI sender address (IP:port) when configured/resolved. +# - NDI source discovery is still used to find the service and its actual port. +# - ndi-name remains a fallback when a direct address cannot be resolved. +# +# Design: +# - GStreamer itself comes from Debian/Raspberry Pi OS. +# - The NDI GStreamer plugin is built from the gst-plugins-rs 0.14 branch/tag, +# because Debian Trixie provides GStreamer 1.26 while the NDI plugin is not +# provided as a matching Debian package. +# - NDI SDK is downloaded from the official NDI developer distribution. +# +# Environment overrides: +# NDI_SDK_URL Override the NDI SDK archive URL. +# NDI_SDK_TARBALL Use a local SDK archive instead of downloading one. +# NDI_GST_TAG Plugin tag, default 0.14.5. +# +# Existing /etc/ndi-pi.conf is preserved and migrated by adding missing keys. +# +# The script is intentionally conservative: it never assumes /dev/video0, +# never assumes an UGREEN device, and treats audio as optional. + +set -Eeuo pipefail + +INSTALL_DIR=/opt/ndi-pi +SRC_DIR="${INSTALL_DIR}/src" +PLUGIN_DIR="/usr/lib/aarch64-linux-gnu/gstreamer-1.0" +CONF="/etc/ndi-pi.conf" + +# NDI 6.x is the current SDK line. The exact archive URL may change, so the +# default is intentionally empty: the installer asks for NDI_SDK_URL or a +# locally supplied archive. This avoids hard-coding a vendor download URL. +SDK_URL="${NDI_SDK_URL:-}" +SDK_TARBALL="${NDI_SDK_TARBALL:-${INSTALL_DIR}/ndi-sdk.tar.gz}" + +PLUGIN_TAG="${NDI_GST_TAG:-0.14.5}" +PLUGIN_REPO="https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs.git" + +log() { echo "[ndi-pi] $*"; } +warn() { echo "[ndi-pi] WARNING: $*" >&2; } +die() { echo "[ndi-pi] ERROR: $*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "Bitte mit sudo/root ausführen." +[[ "$(dpkg --print-architecture)" == "arm64" ]] || die "Dieses Skript ist für ARM64." + +. /etc/os-release +[[ "${VERSION_CODENAME:-}" == "trixie" ]] || die "Dieses Skript erwartet Debian/Raspberry Pi OS Trixie." + +mkdir -p "$INSTALL_DIR" "$SRC_DIR" "$PLUGIN_DIR" + +log "Installiere System-GStreamer, ALSA, V4L2, DRM und Build-Abhängigkeiten..." +apt-get update +# gstreamer1.0-plugins-good provides gst-device-monitor-1.0 on Debian Trixie. +DEBIAN_FRONTEND=noninteractive apt-get install -y \ + ca-certificates curl wget git build-essential pkg-config \ + rustc cargo \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ + gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \ + gstreamer1.0-libav gstreamer1.0-alsa \ + alsa-utils alsa-ucm-conf \ + v4l-utils libdrm-tests libdrm-dev libudev-dev libglib2.0-dev \ + avahi-daemon avahi-utils libavahi-client3 libavahi-common3 + +systemctl enable --now avahi-daemon || true + +# --------------------------------------------------------------------------- +# NDI SDK / already-installed NDI runtime +# --------------------------------------------------------------------------- +# The SDK archive is NOT required when libndi.so.6 is already installed. +# This is important for manual NDI SDK installations on Raspberry Pi. +# +# Accepted examples: +# /usr/local/lib/libndi.so.6 +# /usr/local/lib/libndi.so.6.3.2 +# /usr/lib/aarch64-linux-gnu/libndi.so.6* +# +# If libndi.so.6 is a regular file while a versioned libndi.so.6.x.y exists, +# normalize it to a symlink. This also removes the ldconfig warning: +# "libndi.so.6 is not a symbolic link" + +NDI_LIB_DIR_INSTALLED="" +NDI_LIB_INSTALLED="" + +find_installed_ndi() { + local candidate + for candidate in \ + /usr/local/lib/libndi.so.6.* \ + /usr/local/lib/libndi.so.6 \ + /usr/lib/aarch64-linux-gnu/libndi.so.6.* \ + /usr/lib/aarch64-linux-gnu/libndi.so.6 + do + if [[ -f "$candidate" ]]; then + NDI_LIB_INSTALLED="$candidate" + NDI_LIB_DIR_INSTALLED="$(dirname "$candidate")" + return 0 + fi + done + return 1 +} + +normalize_installed_ndi() { + local libdir="$1" + local major="$2" + local major_link="${libdir}/libndi.so.${major}" + local unversioned="${libdir}/libndi.so" + local versioned="" + + # Prefer the most specific versioned file, e.g. libndi.so.6.3.2. + versioned="$(find "$libdir" -maxdepth 1 -type f \ + -name "libndi.so.${major}.*" -print | sort -V | tail -n 1)" + + if [[ -n "$versioned" ]]; then + if [[ -e "$major_link" && ! -L "$major_link" ]]; then + log "Korrigiere ${major_link}: reguläre Datei -> Sicherung + Symlink" + mv -f "$major_link" "${major_link}.manual-backup.$(date +%Y%m%d-%H%M%S)" + fi + ln -sfn "$versioned" "$major_link" + elif [[ -f "$major_link" && ! -L "$major_link" ]]; then + # A standalone libndi.so.6 is still usable. Do not overwrite it. + warn "${major_link} ist eine reguläre Datei; kein versioniertes Ziel gefunden." + fi + + # Only replace the unversioned link when it is absent/broken or already a link. + if [[ -L "$unversioned" || ! -e "$unversioned" ]]; then + ln -sfn "$major_link" "$unversioned" + fi + + echo "$libdir" > /etc/ld.so.conf.d/ndi.conf + ldconfig +} + +if find_installed_ndi; then + log "Bereits installiertes NDI Runtime gefunden: $NDI_LIB_INSTALLED" + + # Extract the SONAME major from libndi.so.6 / libndi.so.6.x.y. + NDI_MAJOR="$(basename "$NDI_LIB_INSTALLED" | sed -n 's/^libndi\.so\.\([0-9][0-9]*\).*$/\1/p')" + [[ "$NDI_MAJOR" == "6" ]] || die "Gefundene NDI Bibliothek ist nicht NDI 6.x: $NDI_LIB_INSTALLED" + + normalize_installed_ndi "$NDI_LIB_DIR_INSTALLED" "$NDI_MAJOR" + + # NDI_RUNTIME_DIR_V6 must point to the directory containing libndi.so.6. + NDI_RUNTIME_DIR_V6="$NDI_LIB_DIR_INSTALLED" + export NDI_RUNTIME_DIR_V6 + log "NDI_RUNTIME_DIR_V6=${NDI_RUNTIME_DIR_V6}" +else + if [[ ! -s "$SDK_TARBALL" ]]; then + if [[ -z "$SDK_URL" ]]; then + die "Kein NDI 6.x Runtime und kein SDK-Archiv gefunden. +Wenn NDI bereits manuell installiert ist, muss libndi.so.6 unter +/usr/local/lib oder /usr/lib/aarch64-linux-gnu vorhanden sein. +Alternativ: +NDI_SDK_URL= ./install.sh +oder +NDI_SDK_TARBALL=/pfad/zum/SDK.tar.gz ./install.sh" + fi + log "Lade NDI SDK von der angegebenen NDI-URL..." + curl -fL --retry 3 --connect-timeout 15 -o "$SDK_TARBALL" "$SDK_URL" + fi + + TMP_SDK="$(mktemp -d)" + trap 'rm -rf "$TMP_SDK"' EXIT + tar -xzf "$SDK_TARBALL" -C "$TMP_SDK" + + SDK_ROOT="$(find "$TMP_SDK" -maxdepth 4 -type d -name 'NDI SDK for Linux' -print -quit)" + [[ -n "$SDK_ROOT" ]] || die "NDI SDK Verzeichnis nicht gefunden." + + NDI_LIB_DIR="$SDK_ROOT/lib/aarch64-rpi4-linux-gnueabi" + if [[ ! -d "$NDI_LIB_DIR" ]]; then + NDI_LIB_DIR="$(find "$SDK_ROOT/lib" -maxdepth 2 -type d \( -name '*aarch64*rpi4*' -o -name '*aarch64*' \) -print -quit)" + fi + [[ -n "${NDI_LIB_DIR:-}" && -d "$NDI_LIB_DIR" ]] || die "Keine passende NDI ARM64/RPi4 Bibliothek im SDK gefunden." + + NDI_LIB="$(find "$NDI_LIB_DIR" -maxdepth 1 -type f -name 'libndi.so.*' -print | sort -V | tail -n 1)" + [[ -n "$NDI_LIB" ]] || die "libndi.so.* nicht im SDK gefunden." + + install -m 0755 "$NDI_LIB" /usr/local/lib/"$(basename "$NDI_LIB")" + NDI_BASENAME="$(basename "$NDI_LIB")" + NDI_MAJOR="$(printf '%s\n' "$NDI_BASENAME" | sed -n 's/.*\.so\.\([0-9][0-9]*\).*/\1/p')" + [[ -n "$NDI_MAJOR" ]] || NDI_MAJOR=6 + ln -sfn "/usr/local/lib/$NDI_BASENAME" "/usr/local/lib/libndi.so.${NDI_MAJOR}" + ln -sfn "/usr/local/lib/libndi.so.${NDI_MAJOR}" /usr/local/lib/libndi.so + echo /usr/local/lib > /etc/ld.so.conf.d/ndi.conf + ldconfig + + NDI_RUNTIME_DIR_V6=/usr/local/lib + export NDI_RUNTIME_DIR_V6 +fi + +# --------------------------------------------------------------------------- +# Build the NDI GStreamer plugin +# --------------------------------------------------------------------------- +cd "$SRC_DIR" +if [[ ! -d gst-plugins-rs ]]; then + git clone --depth 1 --branch "$PLUGIN_TAG" "$PLUGIN_REPO" gst-plugins-rs +fi + +cd gst-plugins-rs +git fetch --depth 1 origin "refs/tags/${PLUGIN_TAG}:refs/tags/${PLUGIN_TAG}" || true +git checkout -q "$PLUGIN_TAG" + +if [[ -x /root/.cargo/bin/rustc ]]; then + export PATH="/root/.cargo/bin:$PATH" +fi + +log "Baue gst-plugin-ndi ${PLUGIN_TAG} gegen das installierte GStreamer..." +cargo build -p gst-plugin-ndi --release + +PLUGIN_SO="$(find target -type f -name 'libgstndi.so' -not -path '*/deps/*' -print -quit)" +[[ -n "$PLUGIN_SO" ]] || die "libgstndi.so wurde nicht erzeugt." +install -m 0755 "$PLUGIN_SO" "$PLUGIN_DIR/libgstndi.so" +rm -f /root/.cache/gstreamer-1.0/registry.*.bin 2>/dev/null || true + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +HOSTNAME_DEFAULT="$(hostname -s | tr '[:lower:]' '[:upper:]')" +if [[ ! -f "$CONF" ]]; then + cat > "$CONF" < HDMI audio. If no HDMI ALSA device is present, video continues. +ENABLE_HDMI_AUDIO=false +KMS_DRIVER=vc4 +KMS_CONNECTOR_ID=auto +HDMI_AUDIO_DEVICE=auto + +# NDI receive queue. 0 = minimal latency (validated setting, do not raise +# without a documented reason - see NDI-Pi-Gateway-Dokumentation.md, §7). +NDI_MAX_QUEUE=0 + +# Direct NDI receive: when true, resolve the advertised NDI service via mDNS +# and feed its actual IP:port to ndisrc url-address. +NDI_USE_DIRECT_ADDRESS=true +EOF +fi +chmod 0644 "$CONF" + +# Migrate an existing configuration without overwriting user settings. +ensure_conf_key() { + local key="$1" value="$2" + if ! grep -qE "^[[:space:]]*${key}=" "$CONF"; then + printf '\n%s=%s\n' "$key" "$value" >> "$CONF" + log "Konfiguration ergänzt: ${key}=${value}" + fi +} +ensure_conf_key NDI_DISPLAY_ADDRESS auto +ensure_conf_key NDI_USE_DIRECT_ADDRESS true +ensure_conf_key KMS_DRIVER vc4 +ensure_conf_key KMS_CONNECTOR_ID auto +ensure_conf_key ENABLE_HDMI_AUDIO false +if grep -qE '^NDI_MAX_QUEUE=(4|10)$' "$CONF"; then + sed -i -E 's/^NDI_MAX_QUEUE=(4|10)$/NDI_MAX_QUEUE=0/' "$CONF" + log "Fehlerhaften Zwischenstand NDI_MAX_QUEUE=4/10 auf 0 zurückgesetzt (validierte Latenz-Einstellung)." +fi +ensure_conf_key NDI_MAX_QUEUE 0 +ensure_conf_key CAPTURE_QUEUE_BUFFERS 2 +ensure_conf_key DISPLAY_WIDTH 1920 +ensure_conf_key DISPLAY_HEIGHT 1080 +ensure_conf_key DISPLAY_FPS 60 +ensure_conf_key NDI_USE_DIRECT_ADDRESS true + +install -d -m 0755 "$INSTALL_DIR/bin" + +# --------------------------------------------------------------------------- +# Capture helper +# --------------------------------------------------------------------------- +cat > "$INSTALL_DIR/bin/ndi-capture.sh" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +source /etc/ndi-pi.conf + +log() { echo "[ndi-capture] $*"; } +warn() { echo "[ndi-capture] WARNING: $*" >&2; } + +is_video_capture() { + local d="$1" caps + caps="$(v4l2-ctl -d "$d" --all 2>/dev/null || true)" + grep -qiE 'Video Capture' <<<"$caps" +} + +fmt_caps() { + v4l2-ctl -d "$1" --list-formats-ext 2>/dev/null || true +} + +supports_mode() { + local d="$1" fmt="$2" w="$3" h="$4" fps="$5" + local caps block + caps="$(fmt_caps "$d")" + [[ -n "$caps" ]] || return 1 + + # Extract the complete format block. Trixie's v4l2-ctl output can use + # either [N]: 'MJPG' or a Pixel Format heading depending on the driver. + block="$(awk -v wanted="$fmt" ' + function isfmt(line) { + if (wanted == "MJPEG") return line ~ /MJPG|MJPEG|Motion-JPEG/ + return line ~ /YUYV|YUY2|YUV 4:2:2/ + } + isfmt($0) { if (found) exit; found=1; print; next } + found { + if ($0 ~ /^[[:space:]]*\[[0-9]+\]:/ || $0 ~ /^[[:space:]]*Pixel Format:/) exit + print + } + ' <<<"$caps")" + + [[ -n "$block" ]] || return 1 + grep -qE "${w}[[:space:]]*[x×][[:space:]]*${h}" <<<"$block" || return 1 + + awk -v wanted="$fps" ' + { + line=$0 + while (match(line, /([0-9]+([.][0-9]+)?)[[:space:]]*([/][[:space:]]*[0-9]+)?[[:space:]]*fps/)) { + val=substr(line, RSTART, RLENGTH) + gsub(/fps/, "", val); gsub(/[[:space:]]/, "", val) + if (index(val, "/")) { + split(val,a,"/") + if (a[2] != 0 && (a[1]/a[2]) >= wanted-0.01 && (a[1]/a[2]) <= wanted+0.01) ok=1 + } else if ((val+0) >= wanted-0.01 && (val+0) <= wanted+0.01) { + ok=1 + } + line=substr(line, RSTART+RLENGTH) + } + } + END { exit(ok ? 0 : 1) } + ' <<<"$block" +} + + +mode_score() { + local fmt="$1" w="$2" h="$3" fps="$4" + local score=0 + [[ "$fmt" == "YUYV" ]] && score=$((score + 1000000)) + score=$((score + w*h)) + score=$((score + fps*10000)) + echo "$score" +} + +select_mode() { + local d="$1" + local wanted_fmt="${CAPTURE_FORMAT:-auto}" + local desired_w="${CAPTURE_WIDTH:-1920}" + local desired_h="${CAPTURE_HEIGHT:-1080}" + local desired_fps="${CAPTURE_FPS:-60}" + local candidates=( + "YUYV|1920|1080|60" + "YUYV|1920|1080|50" + "YUYV|1920|1080|30" + "YUYV|1280|720|60" + "YUYV|1280|720|50" + "YUYV|1280|720|30" + "YUYV|720|480|60" + "YUYV|720|480|30" + "MJPEG|1920|1080|60" + "MJPEG|1920|1080|50" + "MJPEG|1920|1080|30" + "MJPEG|1280|720|60" + "MJPEG|1280|720|50" + "MJPEG|1280|720|30" + ) + + # First try the user's exact preferred resolution/fps. + local c fmt w h fps + for c in "${candidates[@]}"; do + IFS='|' read -r fmt w h fps <<<"$c" + [[ "$wanted_fmt" != "auto" && "$fmt" != "$wanted_fmt" ]] && continue + if supports_mode "$d" "$fmt" "$desired_w" "$desired_h" "$desired_fps"; then + echo "$fmt|$desired_w|$desired_h|$desired_fps" + return 0 + fi + done + + [[ "${CAPTURE_AUTO_FALLBACK:-true}" == "true" ]] || return 1 + + # Then use the ordered practical fallback list above. + for c in "${candidates[@]}"; do + IFS='|' read -r fmt w h fps <<<"$c" + [[ "$wanted_fmt" != "auto" && "$fmt" != "$wanted_fmt" ]] && continue + if supports_mode "$d" "$fmt" "$w" "$h" "$fps"; then + echo "$fmt|$w|$h|$fps" + return 0 + fi + done + return 1 +} + +usb_key() { + local p="$1" x base + p="$(readlink -f "$p" 2>/dev/null || true)" + [[ -n "$p" ]] || return 1 + IFS='/' read -ra parts <<<"${p#/}" + for x in "${parts[@]}"; do + if [[ "$x" =~ ^[0-9]+-[0-9]+([.][0-9]+)*$ ]]; then + base="$x" + fi + done + [[ -n "${base:-}" ]] && echo "$base" +} + +score_device() { + local d="$1" mode="$2" score=0 bus fmt w h fps + IFS='|' read -r fmt w h fps <<<"$mode" + + # Keep the generic USB preference, but score the ACTUAL mode selected by + # select_mode(). In auto mode YUYV is intentionally preferred over MJPEG. + bus="$(udevadm info -q property -n "$d" 2>/dev/null | awk -F= '$1=="ID_BUS"{print $2; exit}')" + [[ "$bus" == "usb" ]] && score=$((score + 5000000)) + [[ "$fmt" == "YUYV" ]] && score=$((score + 1000000)) + [[ "$fmt" == "MJPEG" ]] && score=$((score + 500000)) + [[ "$w" == "$CAPTURE_WIDTH" ]] && [[ "$h" == "$CAPTURE_HEIGHT" ]] && score=$((score + 100000)) + [[ "$fps" == "$CAPTURE_FPS" ]] && score=$((score + 10000)) + score=$((score + w*h)) + score=$((score + fps*100)) + echo "$score" +} + +pick_capture() { + if [[ "${CAPTURE_DEVICE:-auto}" != "auto" ]]; then + echo "$CAPTURE_DEVICE" + return + fi + + local best="" best_score=-1 d caps mode score + shopt -s nullglob + for d in /dev/video*; do + [[ -e "$d" ]] || continue + is_video_capture "$d" || continue + caps="$(fmt_caps "$d")" + mode="$(select_mode "$d" 2>/dev/null || true)" + [[ -n "$mode" ]] || continue + score="$(score_device "$d" "$mode")" + [[ "$score" -gt "$best_score" ]] && { best="$d"; best_score="$score"; } + done + shopt -u nullglob + [[ -n "$best" ]] && echo "$best" +} + +find_audio_capture() { + [[ "${ENABLE_AUDIO:-true}" == "true" ]] || return 1 + [[ "${CAPTURE_AUDIO_DEVICE:-auto}" != "auto" ]] && { echo "$CAPTURE_AUDIO_DEVICE"; return 0; } + + local vkey card idx path skey best="" fallback="" + vkey="$(usb_key "$(udevadm info -q path -n "$DEV" 2>/dev/null || true)")" || true + + # Prefer a USB sound card on the same USB device as the selected video node. + while read -r idx _; do + [[ "$idx" =~ ^[0-9]+$ ]] || continue + path="$(readlink -f "/sys/class/sound/card${idx}/device" 2>/dev/null || true)" + [[ -n "$path" ]] || continue + skey="$(usb_key "$path" 2>/dev/null || true)" + if [[ -n "$vkey" && -n "$skey" && "$vkey" == "$skey" ]]; then + best="plughw:${idx},0" + break + fi + if [[ -n "$skey" && -z "$fallback" ]]; then + fallback="plughw:${idx},0" + fi + done < <(grep -E '^[[:space:]]*[0-9]+[[:space:]]+\[' /proc/asound/cards 2>/dev/null | sed -E 's/^[[:space:]]*([0-9]+).*/\1/') + + [[ -n "$best" ]] && { echo "$best"; return 0; } + [[ -n "$fallback" ]] && { echo "$fallback"; return 0; } + return 1 +} + +DEV="$(pick_capture)" || { + warn "Kein geeignetes V4L2-Capture-Gerät gefunden; systemd startet später erneut." + for d in /dev/video*; do + [[ -e "$d" ]] || continue + warn "V4L2-Info für $d:" + v4l2-ctl -d "$d" --all 2>&1 | sed 's/^/[ndi-capture] /' || true + v4l2-ctl -d "$d" --list-formats-ext 2>&1 | sed 's/^/[ndi-capture] /' || true + done + exit 1 +} + +MODE="$(select_mode "$DEV")" || { + warn "Kein unterstützter Capture-Modus auf $DEV." + fmt_caps "$DEV" | sed 's/^/[ndi-capture] /' >&2 || true + exit 1 +} +IFS='|' read -r FMT WIDTH HEIGHT FPS <<<"$MODE" + +AUDIO_DEV="$(find_audio_capture || true)" + +# MJPEG wird mit dem normalen Software-Decoder jpegdec dekodiert. +# v4l2jpegdec wird bewusst NICHT verwendet: die Testreihe zeigte dort +# wiederholt "slow copy into buffer" und sichtbare Micro-Ruckler. +log "Capture: $DEV, $FMT ${WIDTH}x${HEIGHT}@${FPS}" +[[ -n "$AUDIO_DEV" ]] && log "Capture-Audio: $AUDIO_DEV" || warn "Kein passendes USB-Audio gefunden; Video-only." + +if [[ "$FMT" == "MJPEG" ]]; then + VIDEO_CAPS="image/jpeg,width=${WIDTH},height=${HEIGHT},framerate=${FPS}/1" +else + VIDEO_CAPS="video/x-raw,format=YUY2,width=${WIDTH},height=${HEIGHT},framerate=${FPS}/1" +fi + +# MJPEG: v4l2src(io-mode=2) -> jpegdec -> konfigurierbare kurze Queue -> ndisink. +# Die Queue puffert kurze Decoder-/Scheduling-Schwankungen; leaky downstream +# verhindert, dass bei Überlastung zunehmend alte Frames ausgegeben werden. +CAPTURE_QUEUE_BUFFERS="${CAPTURE_QUEUE_BUFFERS:-2}" +if ! [[ "$CAPTURE_QUEUE_BUFFERS" =~ ^[1-9][0-9]*$ ]]; then + warn "CAPTURE_QUEUE_BUFFERS muss eine positive ganze Zahl sein; verwende 2." + CAPTURE_QUEUE_BUFFERS=2 +fi +CAPTURE_QUEUE=(queue "max-size-buffers=${CAPTURE_QUEUE_BUFFERS}" max-size-time=0 max-size-bytes=0 leaky=downstream) +# UYVY ist nur beim YUYV-Fallback nötig, weil ndisink YUY2/YUYV nicht direkt annimmt. +if [[ -n "$AUDIO_DEV" ]]; then + if [[ "$FMT" == "MJPEG" ]]; then + exec gst-launch-1.0 -e \ + ndisinkcombiner name=combiner ! \ + ndisink ndi-name="${NDI_CAPTURE_NAME}" sync=false qos=true \ + v4l2src device="$DEV" io-mode=2 ! \ + "$VIDEO_CAPS" ! \ + jpegdec ! "${CAPTURE_QUEUE[@]}" ! combiner.video \ + alsasrc device="$AUDIO_DEV" do-timestamp=true ! \ + queue max-size-time=100000000 max-size-buffers=0 max-size-bytes=0 ! \ + audioconvert ! audioresample ! \ + audio/x-raw,format=F32LE,rate="${CAPTURE_AUDIO_RATE:-48000}",channels="${CAPTURE_AUDIO_CHANNELS:-2}" ! \ + combiner.audio + else + exec gst-launch-1.0 -e \ + ndisinkcombiner name=combiner ! \ + ndisink ndi-name="${NDI_CAPTURE_NAME}" sync=false qos=true \ + v4l2src device="$DEV" io-mode=2 ! \ + "$VIDEO_CAPS" ! \ + videoconvert ! video/x-raw,format=UYVY ! "${CAPTURE_QUEUE[@]}" ! combiner.video \ + alsasrc device="$AUDIO_DEV" do-timestamp=true ! \ + queue max-size-time=100000000 max-size-buffers=0 max-size-bytes=0 ! \ + audioconvert ! audioresample ! \ + audio/x-raw,format=F32LE,rate="${CAPTURE_AUDIO_RATE:-48000}",channels="${CAPTURE_AUDIO_CHANNELS:-2}" ! \ + combiner.audio + fi +else + if [[ "$FMT" == "MJPEG" ]]; then + exec gst-launch-1.0 -e \ + v4l2src device="$DEV" io-mode=2 ! \ + "$VIDEO_CAPS" ! \ + jpegdec ! \ + "${CAPTURE_QUEUE[@]}" ! \ + ndisink ndi-name="${NDI_CAPTURE_NAME}" sync=false qos=true + else + exec gst-launch-1.0 -e \ + v4l2src device="$DEV" io-mode=2 ! \ + "$VIDEO_CAPS" ! \ + videoconvert ! video/x-raw,format=UYVY ! \ + "${CAPTURE_QUEUE[@]}" ! \ + ndisink ndi-name="${NDI_CAPTURE_NAME}" sync=false qos=true + fi +fi +EOF +chmod 0755 "$INSTALL_DIR/bin/ndi-capture.sh" + +# --------------------------------------------------------------------------- +# Display helper +# --------------------------------------------------------------------------- +cat > "$INSTALL_DIR/bin/ndi-display.sh" <<'EOF' +#!/usr/bin/env bash +set -Eeuo pipefail +source /etc/ndi-pi.conf + +log(){ echo "[ndi-display] $*"; } +warn(){ echo "[ndi-display] WARN: $*" >&2; } + +find_kms_connector() { + local driver="${KMS_DRIVER:-vc4}" wanted="${KMS_CONNECTOR_ID:-auto}" line + if [[ "$wanted" != "auto" && "$wanted" =~ ^[0-9]+$ ]]; then echo "$wanted"; return 0; fi + command -v modetest >/dev/null 2>&1 || return 1 + line="$(modetest -M "$driver" -c 2>/dev/null | awk '$3 == "connected" {print $1; exit}')" + [[ "$line" =~ ^[0-9]+$ ]] || return 1 + echo "$line" +} + +KMS_CONNECTOR="$(find_kms_connector || true)" +if [[ -z "$KMS_CONNECTOR" ]]; then + warn "Kein verbundener ${KMS_DRIVER:-vc4}-HDMI-Connector gefunden." + exit 1 +fi +log "DRM/KMS: driver=${KMS_DRIVER:-vc4}, connector-id=${KMS_CONNECTOR}" + +resolve_ndi_address() { + local source="$1" + command -v avahi-browse >/dev/null 2>&1 || return 1 + timeout 8 avahi-browse -rtp _ndi._tcp 2>/dev/null | + awk -F';' -v wanted="$source" '$1 == "=" && $4 == wanted && $8 ~ /^[0-9]+([.][0-9]+){3}$/ && $9 ~ /^[0-9]+$/ {print $8 ":" $9; exit}' | head -n 1 +} + +NDI_ADDRESS="${NDI_DISPLAY_ADDRESS:-auto}" +if [[ "${NDI_USE_DIRECT_ADDRESS:-true}" == "true" && ( -z "$NDI_ADDRESS" || "$NDI_ADDRESS" == "auto" ) ]]; then + NDI_ADDRESS="$(resolve_ndi_address "$NDI_DISPLAY_SOURCE" || true)" + [[ -n "$NDI_ADDRESS" ]] && log "NDI-Service aufgelöst: ${NDI_DISPLAY_SOURCE} -> ${NDI_ADDRESS}" || true +elif [[ "${NDI_USE_DIRECT_ADDRESS:-true}" == "true" && -n "$NDI_ADDRESS" && "$NDI_ADDRESS" != "auto" ]]; then + log "Verwende konfigurierte NDI-Adresse: ${NDI_ADDRESS}" +fi + +if [[ "${NDI_USE_DIRECT_ADDRESS:-true}" == "true" && -n "$NDI_ADDRESS" && "$NDI_ADDRESS" != "auto" ]]; then + log "Verwende ndisrc url-address=${NDI_ADDRESS}" + NDI_SRC_ARGS=(ndisrc "url-address=${NDI_ADDRESS}" "color-format=bgrx-bgra" "max-queue-length=${NDI_MAX_QUEUE:-0}" "connect-timeout=10000" "timeout=5000") +else + log "Verwende ndisrc ndi-name=${NDI_DISPLAY_SOURCE} (Fallback)" + NDI_SRC_ARGS=(ndisrc "ndi-name=${NDI_DISPLAY_SOURCE}" "color-format=bgrx-bgra" "max-queue-length=${NDI_MAX_QUEUE:-0}" "connect-timeout=10000" "timeout=5000") +fi + +WIDTH="${DISPLAY_WIDTH:-1920}" +HEIGHT="${DISPLAY_HEIGHT:-1080}" +FPS="${DISPLAY_FPS:-60}" +STANDBY_PID="" + +# Keep the standby self-contained and explicitly sized. Without a capsfilter, +# videotestsrc negotiates its own small default size and kmssink can display that +# image only in the middle of the HDMI output. The explicit caps below make the +# standby a real fullscreen framebuffer at the configured HDMI mode. +HOST_SHORT="$(hostname -s 2>/dev/null || hostname)" +HOST_SHORT="${HOST_SHORT^^}" +LOCAL_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" +LOCAL_IP="${LOCAL_IP:-unknown}" +STANDBY_TEXT="NO SIGNAL ${HOST_SHORT} IP:${LOCAL_IP} HDMI:${WIDTH}x${HEIGHT}@${FPS} NDI:${NDI_DISPLAY_SOURCE}" + + + +stop_standby() { + if [[ -n "${STANDBY_PID:-}" ]]; then + kill "$STANDBY_PID" 2>/dev/null || true + wait "$STANDBY_PID" 2>/dev/null || true + STANDBY_PID="" + fi +} + +start_standby() { + stop_standby + log "Aktiviere schwarzen HDMI-Standby (Fullscreen ${WIDTH}x${HEIGHT}@${FPS})." + + env NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ + gst-launch-1.0 -q \ + videotestsrc pattern=black is-live=true ! \ + "video/x-raw,format=BGRA,width=${WIDTH},height=${HEIGHT},framerate=${FPS}/1" ! \ + textoverlay text="${STANDBY_TEXT}" \ + halignment=center valignment=center \ + font-desc="Sans Bold, 32" \ + shaded-background=true \ + ! video/x-raw,format=BGRA ! \ + kmssink driver-name="${KMS_DRIVER:-vc4}" connector-id="$KMS_CONNECTOR" sync=false \ + >/tmp/ndi-display-standby.log 2>&1 & + STANDBY_PID=$! + + sleep 0.8 + if ! kill -0 "$STANDBY_PID" 2>/dev/null; then + warn "HDMI-Standby konnte nicht gestartet werden. Ausgabe:" + sed 's/^/[ndi-standby] /' /tmp/ndi-display-standby.log >&2 || true + STANDBY_PID="" + return 1 + fi + + log "HDMI-Standby ist fullscreen aktiv (PID=${STANDBY_PID})." +} + +cleanup() { + stop_standby +} +trap cleanup EXIT INT TERM + +# Important for service restarts: do not first grab the DRM connector with a +# standby kmssink and then immediately release it for the live kmssink. When the +# NDI source is available, go directly to the verified live path. Only if that +# path exits do we acquire KMS for the standby screen. +while true; do + log "Starte NDI-Livepipeline für ${NDI_DISPLAY_SOURCE}." + + set +e + env NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ + gst-launch-1.0 -e \ + "${NDI_SRC_ARGS[@]}" ! \ + ndisrcdemux name=d \ + d.video ! \ + queue max-size-buffers=2 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ + videoscale add-borders=true ! \ + "video/x-raw,width=${WIDTH},height=${HEIGHT},pixel-aspect-ratio=1/1" ! \ + kmssink driver-name="${KMS_DRIVER:-vc4}" connector-id="$KMS_CONNECTOR" sync=false \ + d.audio ! queue ! fakesink sync=false + rc=$? + set -e + + warn "NDI-Livepipeline beendet (rc=$rc). Rückkehr zu schwarzem Standby." + start_standby || true + + # Keep the fullscreen standby active while the source is unavailable. Do not + # repeatedly tear down/recreate kmssink: that was the visible flicker reported + # during service restarts/source outages. Retry only after the NDI service can + # be resolved/reached again. + while true; do + sleep 2 + candidate="$NDI_ADDRESS" + if [[ "${NDI_USE_DIRECT_ADDRESS:-true}" == "true" && ( -z "$candidate" || "$candidate" == "auto" ) ]]; then + candidate="$(resolve_ndi_address "$NDI_DISPLAY_SOURCE" || true)" + fi + + if [[ -n "$candidate" && "$candidate" != "auto" && "$candidate" == *:* ]]; then + host="${candidate%:*}" + port="${candidate##*:}" + if [[ "$port" =~ ^[0-9]+$ ]] && timeout 1 bash -c "/dev/null; then + log "NDI-Service wieder erreichbar (${candidate}); beende Standby und versuche Live erneut." + NDI_ADDRESS="$candidate" + if [[ "${NDI_USE_DIRECT_ADDRESS:-true}" == "true" ]]; then + NDI_SRC_ARGS=(ndisrc "url-address=${NDI_ADDRESS}" "color-format=bgrx-bgra" "max-queue-length=${NDI_MAX_QUEUE:-0}" "connect-timeout=10000" "timeout=5000") + fi + stop_standby + break + fi + fi + done +done +EOF +chmod 0755 "$INSTALL_DIR/bin/ndi-display.sh" + +# --------------------------------------------------------------------------- +# Diagnostic tool +# --------------------------------------------------------------------------- +cat > "$INSTALL_DIR/bin/ndi-pi-diagnose" <<'EOF' +#!/usr/bin/env bash +set -u +source /etc/ndi-pi.conf 2>/dev/null || true + +echo "=== NDI Pi Diagnose ===" +echo "Kernel: $(uname -a)" +echo "OS: $(. /etc/os-release; echo "${PRETTY_NAME:-unknown}")" +echo +echo "--- GStreamer ---" +gst-launch-1.0 --version || true +gst-inspect-1.0 ndisink ndisinkcombiner ndisrc ndisrcdemux kmssink 2>&1 | grep -E '^(Factory Details| Rank| Long-name| Description| Version| Author|Pad Templates| SINK| SRC| AUDIO| VIDEO)' || true +echo +echo "--- V4L2 ---" +v4l2-ctl --list-devices || true +for d in /dev/video*; do + [[ -e "$d" ]] || continue + echo "### $d" + v4l2-ctl -d "$d" --all 2>/dev/null | grep -E 'Card type|Driver name|Bus info|Capabilities' || true + v4l2-ctl -d "$d" --list-formats-ext 2>/dev/null | head -80 || true +done +echo +echo "--- ALSA capture ---" +arecord -l || true +echo +echo "--- ALSA playback ---" +aplay -l || true +echo +echo "--- DRM ---" +ls -l /dev/dri 2>/dev/null || true +cat /sys/class/drm/*/status 2>/dev/null || true +echo +echo "--- NDI configuration ---" +grep -E '^(NDI_DISPLAY_SOURCE|NDI_DISPLAY_ADDRESS|NDI_USE_DIRECT_ADDRESS|KMS_DRIVER|KMS_CONNECTOR_ID|ENABLE_HDMI_AUDIO|NDI_MAX_QUEUE)=' /etc/ndi-pi.conf 2>/dev/null || true +echo +echo "--- NDI discovery ---" +if command -v gst-device-monitor-1.0 >/dev/null 2>&1; then + NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ + gst-device-monitor-1.0 -f Source/Network:application/x-ndi 2>&1 | head -120 || true +else + echo "gst-device-monitor-1.0 nicht verfügbar" +fi +echo +echo "--- NDI mDNS services ---" +avahi-browse -rtp _ndi._tcp 2>&1 | head -80 || true +echo +echo "--- Services ---" +systemctl --no-pager --full status ndi-capture.service ndi-display.service || true +echo +echo "--- ndi-capture.service journal (letzte 80 Zeilen) ---" +journalctl -u ndi-capture.service -n 80 --no-pager || true +echo +echo "--- ndi-display.service journal (letzte 80 Zeilen) ---" +journalctl -u ndi-display.service -n 80 --no-pager || true +EOF +chmod 0755 "$INSTALL_DIR/bin/ndi-pi-diagnose" + +# --------------------------------------------------------------------------- +# systemd +# --------------------------------------------------------------------------- +cat > /etc/systemd/system/ndi-capture.service <<'EOF' +[Unit] +Description=NDI Pi Gateway - USB Capture + Audio to NDI +After=network-online.target sound.target +Wants=network-online.target sound.target +ConditionPathExists=/etc/ndi-pi.conf + +[Service] +Type=simple +Environment=NDI_RUNTIME_DIR_V6=/usr/local/lib +ExecStart=/opt/ndi-pi/bin/ndi-capture.sh +Restart=always +RestartSec=1 +Nice=-5 +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target +EOF + +cat > /etc/systemd/system/ndi-display.service <<'EOF' +[Unit] +Description=NDI Pi Gateway - NDI Video + Audio to HDMI/DRM +After=network-online.target sound.target +Wants=network-online.target sound.target +ConditionPathExists=/etc/ndi-pi.conf + +[Service] +Type=simple +Environment=NDI_RUNTIME_DIR_V6=/usr/local/lib +ExecStart=/opt/ndi-pi/bin/ndi-display.sh +Restart=always +RestartSec=1 +Nice=-5 +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable ndi-capture.service ndi-display.service + +log "Prüfe NDI-GStreamer-Plugin..." +NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ + gst-inspect-1.0 ndisink >/dev/null +NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ + gst-inspect-1.0 ndisrc >/dev/null + +log "NDI Runtime:" +ldconfig -p | grep -E 'libndi\.so\.6(\.|$)' || true +ls -l "${NDI_RUNTIME_DIR_V6:-/usr/local/lib}"/libndi.so* 2>/dev/null || true + +systemctl restart ndi-capture.service +systemctl restart ndi-display.service + +echo +log "Installation abgeschlossen." +echo +echo "Konfiguration: $CONF" +echo " sudo nano $CONF" +echo +echo "NDI Display:" +echo " NDI_DISPLAY_SOURCE=${NDI_DISPLAY_SOURCE:-}" +echo " NDI_DISPLAY_ADDRESS=${NDI_DISPLAY_ADDRESS:-auto}" +echo " NDI_USE_DIRECT_ADDRESS=${NDI_USE_DIRECT_ADDRESS:-true}" +echo +echo "Diagnose:" +echo " sudo /opt/ndi-pi/bin/ndi-pi-diagnose" +echo +echo "Nach Änderungen:" +echo " sudo systemctl restart ndi-capture ndi-display" +echo +echo "Logs:" +echo " journalctl -u ndi-capture -f" +echo " journalctl -u ndi-display -f" +echo +echo "NDI-Quellen:" +if command -v gst-device-monitor-1.0 >/dev/null 2>&1; then + NDI_RUNTIME_DIR_V6="${NDI_RUNTIME_DIR_V6:-/usr/local/lib}" \ + gst-device-monitor-1.0 -f Source/Network:application/x-ndi || true +else + log "gst-device-monitor-1.0 nicht verfügbar; NDI-Quellen werden nicht aufgelistet." +fi