413 lines
14 KiB
Python
413 lines
14 KiB
Python
|
|
#!/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"<html><body style='margin:0;background:#000'>"
|
|||
|
|
b"<img src='/stream.mjpg' style='width:100vw;height:100vh;object-fit:contain' /></body></html>"
|
|||
|
|
)
|
|||
|
|
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()
|