118 lines
4.3 KiB
Python
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
|