Files
2026-08-22 20:34:42 +02:00

118 lines
4.3 KiB
Python

"""Bildschirmaufnahme und Versand als RTP-Strom.
Die eigentliche Arbeit macht ffmpeg: aufnehmen, in H.264 wandeln, in MPEG-TS
verpacken und als RTP verschicken - genau das Paketformat, das der
Wi-Fi-Display-Standard erwartet (Nutzlasttyp 33).
"""
import logging
import os
import shutil
import signal
import subprocess
from typing import List, Optional
from .formats import VideoFormat
log = logging.getLogger("wfd.streamer")
def screen_size(display: str) -> str:
"""Auflösung des Bildschirms - für die Aufnahme braucht ffmpeg sie."""
for command, pattern in (
(["xdpyinfo", "-display", display], "dimensions:"),
(["xrandr", "--display", display, "--current"], "*"),
):
if not shutil.which(command[0]):
continue
try:
output = subprocess.run(command, capture_output=True, text=True,
timeout=5).stdout
except (subprocess.SubprocessError, OSError):
continue
for line in output.splitlines():
if pattern not in line:
continue
for token in line.split():
if "x" in token and token.replace("x", "").isdigit():
return token
return "1920x1080"
class Streamer:
"""Hält den laufenden ffmpeg-Prozess."""
def __init__(self, source: str = "auto", display: Optional[str] = None,
bitrate_mbit: int = 8):
self.source = source
self.display = display or os.environ.get("DISPLAY", ":0")
self.bitrate_mbit = bitrate_mbit
self.process: Optional[subprocess.Popen] = None
self.command: List[str] = []
def build_command(self, host: str, port: int, fmt: VideoFormat) -> List[str]:
command = ["ffmpeg", "-hide_banner", "-loglevel", "warning", "-nostdin"]
mode = self.source
if mode == "auto":
mode = "x11" if os.environ.get("DISPLAY") else "test"
if mode == "x11":
command += ["-f", "x11grab", "-framerate", str(fmt.fps),
"-video_size", screen_size(self.display),
"-draw_mouse", "1", "-i", self.display]
elif mode == "kms":
# Für Wayland-Sitzungen; benötigt erweiterte Rechte.
command += ["-f", "kmsgrab", "-framerate", str(fmt.fps), "-i", "-",
"-vf", "hwdownload,format=bgr0"]
elif mode == "test":
command += ["-f", "lavfi", "-i",
f"testsrc=size={fmt.width}x{fmt.height}:rate={fmt.fps}"]
else:
raise ValueError(f"Unbekannte Bildquelle: {mode}")
# Auf die ausgehandelte Größe bringen, Seitenverhältnis erhalten.
scale = (f"scale={fmt.width}:{fmt.height}:force_original_aspect_ratio=decrease,"
f"pad={fmt.width}:{fmt.height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p")
rate = self.bitrate_mbit
command += [
"-vf", scale,
"-c:v", "libx264",
"-profile:v", "baseline", # ohne B-Bilder, das versteht jeder Empfänger
"-preset", "ultrafast",
"-tune", "zerolatency",
"-x264-params",
f"keyint={fmt.fps * 2}:min-keyint={fmt.fps * 2}:scenecut=0:bframes=0:nal-hrd=cbr",
"-b:v", f"{rate}M", "-minrate", f"{rate}M", "-maxrate", f"{rate}M",
"-bufsize", f"{max(1, rate // 4)}M",
"-r", str(fmt.fps),
"-muxdelay", "0", "-muxpreload", "0",
"-f", "rtp_mpegts",
f"rtp://{host}:{port}?pkt_size=1316",
]
return command
def start(self, host: str, port: int, fmt: VideoFormat):
if self.process:
return
self.command = self.build_command(host, port, fmt)
log.info("Starte Bildstrom: %s", " ".join(self.command))
self.process = subprocess.Popen(self.command)
def is_running(self) -> bool:
return self.process is not None and self.process.poll() is None
def stop(self):
if not self.process:
return
try:
self.process.send_signal(signal.SIGINT)
self.process.wait(timeout=4)
except (subprocess.TimeoutExpired, OSError):
try:
self.process.kill()
except OSError:
pass
self.process = None