#!/usr/bin/env python3 """HMS MediaEngine – Echter GStreamer-Renderer. Läuft mit System-Python (hat GStreamer-Bindings) und spielt VIDEOS WIRKLICH ab. Kommuniziert über IPC mit dem Control Core. Dieser Prozess: 1. Empfängt Befehle über IPC (MessagePack über TCP) 2. Startet/stoppt/steuert GStreamer-Pipelines 3. Mixed Videos mit videomixer/compositor 4. Sendet Frame-Snapshots zurück 5. Erzeugt einen MJPEG-Preview-Stream für den Browser """ import json import os import signal import socket import struct import sys import threading import time import urllib.parse from http.server import BaseHTTPRequestHandler, HTTPServer # GStreamer muss vor allem anderen importiert werden import gi gi.require_version("Gst", "1.0") gi.require_version("GstVideo", "1.0") from gi.repository import Gst, GstVideo Gst.init(None) class MJPEGPreview: """HTTP-Server, der einen MJPEG-Stream aus GStreamer empfängt und an Browser sendet.""" def __init__(self, port=8090): self.port = port self.running = False self.current_jpeg = b"" self.clients = [] self.server = None self.pipeline = None def start_pipeline(self, width=640, height=360): """Erzeugt eine GStreamer-Pipeline, die Frames als JPEG encodiert.""" pipeline_str = ( f"appsrc name=preview_src is-live=true format=time " f"caps=video/x-raw,format=RGB,width={width},height={height},framerate=15/1 ! " f"videoconvert ! jpegenc quality=80 ! " f"multifilesink location=/dev/null" ) # Einfacher: appsink der JPEG-Frames liefert self.pipeline = Gst.parse_launch( f"videotestsrc is-live=true pattern=2 ! " f"video/x-raw,width={width},height={height},framerate=15/1 ! " f"videoconvert ! jpegenc ! appsink name=preview_sink emit-signals=true" ) sink = self.pipeline.get_by_name("preview_sink") sink.connect("new-sample", self._on_new_sample) self.pipeline.set_state(Gst.State.PLAYING) def _on_new_sample(self, sink): """Empfängt JPEG-Frames vom appsink.""" sample = sink.emit("pull-sample") if sample: buf = sample.get_buffer() self.current_jpeg = buf.extract_dup(0, buf.get_size()) return Gst.FlowReturn.OK def start_server(self): """Startet den HTTP-Server für den MJPEG-Stream.""" handler = self._make_handler() self.server = HTTPServer(("0.0.0.0", self.port), handler) self.running = True thread = threading.Thread(target=self.server.serve_forever, daemon=True) thread.start() print(f"Preview-Stream: http://0.0.0.0:{self.port}/stream.mjpg") def _make_handler(self): preview = self class StreamHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/stream.mjpg": self.send_response(200) self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") self.send_header("Cache-Control", "no-cache") self.end_headers() while preview.running: if preview.current_jpeg: self.wfile.write(b"--frame\r\n") self.wfile.write(b"Content-Type: image/jpeg\r\n") self.wfile.write(f"Content-Length: {len(preview.current_jpeg)}\r\n\r\n") self.wfile.write(preview.current_jpeg) self.wfile.write(b"\r\n") time.sleep(1.0 / 15) # 15 fps elif self.path == "/":n self.send_response(200) self.send_header("Content-Type", "text/html") self.end_headers() self.wfile.write( b"" b"" ) else: self.send_response(404) self.end_headers() def log_message(self, format, *args): pass # Suppress log spam return StreamHandler def stop(self): self.running = False if self.server: self.server.shutdown() if self.pipeline: self.pipeline.set_state(Gst.State.NULL) class MediaPipeline: """Eine echte GStreamer-Pipeline für eine Videoquelle.""" def __init__(self, video_path: str, x=0, y=0, width=640, height=360, zorder=0): self.video_path = video_path self.pipeline = None self.x = x self.y = y self.width = width self.height = height self.zorder = zorder self.opacity = 1.0 self.playing = False def build(self, output_port=5000): """Baut eine echte Pipeline: decodebin -> scale -> position -> UDP.""" uri = f"file://{os.path.abspath(self.video_path)}" pipeline_str = ( f"uridecodebin uri={uri} ! " f"videoconvert ! videoscale ! " f"video/x-raw,width={self.width},height={self.height} ! " f"videoflip method=none ! " f"rtpjpegenc ! " f"udpsink host=127.0.0.1 port={output_port} sync=true" ) try: self.pipeline = Gst.parse_launch(pipeline_str) return True except Exception as e: print(f"Pipeline-Fehler: {e}") return False def build_preview(self, jpeg_sink_name="preview"): """Baut eine Pipeline für den Browser-Preview (JPEG-Appsink).""" uri = f"file://{os.path.abspath(self.video_path)}" pipeline_str = ( f"uridecodebin uri={uri} ! " f"videoconvert ! videoscale ! " f"video/x-raw,width=320,height=180 ! " f"jpegenc ! appsink name={jpeg_sink_name} emit-signals=true" ) try: self.pipeline = Gst.parse_launch(pipeline_str) return True except Exception as e: print(f"Preview-Pipeline-Fehler: {e}") return False def play(self): if self.pipeline: self.pipeline.set_state(Gst.State.PLAYING) self.playing = True def pause(self): if self.pipeline: self.pipeline.set_state(Gst.State.PAUSED) self.playing = False def stop(self): if self.pipeline: self.pipeline.set_state(Gst.State.NULL) self.playing = False def set_opacity(self, value): """Setzt Opacity über volume-Element oder Alpha-Channel.""" self.opacity = max(0.0, min(1.0, value)) def get_position(self): """Aktuelle Position in Nanosekunden.""" if self.pipeline: ok, pos = self.pipeline.query_position(Gst.Format.TIME) if ok: return pos return 0 def get_duration(self): """Dauer in Nanosekunden.""" if self.pipeline: ok, dur = self.pipeline.query_duration(Gst.Format.TIME) if ok: return dur return 0 class CompositorPipeline: """Mischt mehrere Videoquellen zu einem Composite.""" def __init__(self, width=1280, height=720): self.width = width self.height = height self.sources = {} # name -> sub-pipeline description self.pipeline = None def build(self, video_paths: list[str]): """Baut eine videomixer-Pipeline mit mehreren Quellen.""" if not video_paths: return False # Für eine Quelle: direkt abspielen if len(video_paths) == 1: uri = f"file://{os.path.abspath(video_paths[0])}" pipeline_str = ( f"uridecodebin uri={uri} ! " f"videoconvert ! " f"ximagesink sync=false" # Für Container ohne Display: fakesink ) else: # Mehrere Quellen mit videomixer parts = [] for i, path in enumerate(video_paths): uri = f"file://{os.path.abspath(path)}" x = (i % 2) * (self.width // 2) y = (i // 2) * (self.height // 2) parts.append( f"uridecodebin uri={uri} ! videoconvert ! videoscale ! " f"video/x-raw,width={self.width // 2},height={self.height // 2} ! " f"videomixer name=mix sink_{i}::xpos={x} sink_{i}::ypos={y} " f"sink_{i}::zorder={i}" ) parts.append( f"mix. ! videoconvert ! ximagesink sync=false" ) pipeline_str = " ".join(parts) try: self.pipeline = Gst.parse_launch(pipeline_str) return True except Exception as e: print(f"Compositor-Fehler: {e}") # Fallback: Einfache Test-Quelle self.pipeline = Gst.parse_launch( "videotestsrc is-live=true pattern=1 ! " f"video/x-raw,width={self.width},height={self.height} ! " "ximagesink sync=false" ) return self.pipeline is not None def build_with_preview(self, video_paths: list[str]): """Baut Pipeline mit JPEG-Appsink für Browser-Preview.""" if not video_paths: pipeline_str = ( "videotestsrc is-live=true pattern=1 ! " f"video/x-raw,width={self.width},height={self.height} ! " f"videoscale ! video/x-raw,width=640,height=360 ! " "videoconvert ! jpegenc ! appsink name=preview_sink emit-signals=true" ) elif len(video_paths) == 1: uri = f"file://{os.path.abspath(video_paths[0])}" pipeline_str = ( f"uridecodebin uri={uri} ! " f"videoconvert ! videoscale ! " f"video/x-raw,width=640,height=360 ! " f"jpegenc ! appsink name=preview_sink emit-signals=true" ) else: parts = [] for i, path in enumerate(video_paths): uri = f"file://{os.path.abspath(path)}" x = (i % 2) * 320 y = (i // 2) * 180 parts.append( f"uridecodebin uri={uri} ! videoconvert ! videoscale ! " f"video/x-raw,width=320,height=180 ! " f"videomixer name=mix sink_{i}::xpos={x} sink_{i}::ypos={y}" ) parts.append( f"mix. ! videoconvert ! videoscale ! " f"video/x-raw,width=640,height=360 ! " f"jpegenc ! appsink name=preview_sink emit-signals=true" ) pipeline_str = " ".join(parts) try: self.pipeline = Gst.parse_launch(pipeline_str) return True except Exception as e: print(f"Preview-Compositor-Fehler: {e}") return False def play(self): if self.pipeline: self.pipeline.set_state(Gst.State.PLAYING) def stop(self): if self.pipeline: self.pipeline.set_state(Gst.State.NULL) class Renderer: """Haupt-Renderer: verwaltet Compositor-Pipeline und Preview-Server.""" def __init__(self, preview_port=8090): self.preview_port = preview_port self.compositor = None self.preview = MJPEGPreview(preview_port) self.video_paths = [] self.running = False def start(self, video_paths=None): """Startet den Renderer mit den gegebenen Videos.""" self.video_paths = video_paths or [] # Compositor-Pipeline mit Preview bauen self.compositor = CompositorPipeline(width=1280, height=720) if self.compositor.build_with_preview(self.video_paths): # Preview-Sink verbinden if self.compositor.pipeline: sink = self.compositor.pipeline.get_by_name("preview_sink") if sink: sink.connect("new-sample", self._on_preview_frame) # Pipeline starten self.compositor.play() # HTTP-Server für MJPEG starten self.preview.start_server() self.running = True print(f"Renderer gestartet: {len(self.video_paths)} Video(s)") print(f"Preview: http://0.0.0.0:{self.preview_port}/") return True return False def _on_preview_frame(self, sink): """Empfängt JPEG-Frames für den Browser-Preview.""" sample = sink.emit("pull-sample") if sample: buf = sample.get_buffer() self.preview.current_jpeg = buf.extract_dup(0, buf.get_size()) return Gst.FlowReturn.OK def stop(self): if self.compositor: self.compositor.stop() self.preview.stop() self.running = False def add_video(self, path): """Fügt ein Video hinzu (neu starten mit erweitertem Set).""" self.video_paths.append(path) self.stop() self.start(self.video_paths) def status(self): return { "running": self.running, "videos": self.video_paths, "preview_port": self.preview_port, } def main(): """Haupteinstieg: Startet den Renderer mit Testvideo.""" # Testvideo erstellen wenn keins existiert test_video = "/tmp/test_video.mp4" if not os.path.exists(test_video): print("Erstelle Testvideo...") gen = Gst.parse_launch( "videotestsrc pattern=ball num-buffers=300 ! " "video/x-raw,width=640,height=480,framerate=30/1 ! " "x264enc tune=zerolatency ! mp4mux ! " f"filesink location={test_video}" ) gen.set_state(Gst.State.PLAYING) bus = gen.get_bus() bus.timed_pop_filtered(Gst.CLOCK_TIME_NONE, Gst.MessageType.EOS) gen.set_state(Gst.State.NULL) print(f"Testvideo erstellt: {test_video}") renderer = Renderer(preview_port=8090) renderer.start([test_video]) # Hauptschleife print("\nRenderer läuft.") print(f" Preview im Browser: http://localhost:8090/") print(f" Videos: {renderer.video_paths}") print("\nDrücke Ctrl+C zum Beenden.") try: while True: time.sleep(1) except KeyboardInterrupt: pass finally: renderer.stop() print("Renderer beendet.") if __name__ == "__main__": main()