Files
hms-mediaengine/_entwicklung/apps/renderer/hms_renderer/run_renderer.py
T
HMS MediaEngine Agent 362e089be0 AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell.
Jetzt ist sauber getrennt:

ROOT (was der Nutzer sieht und braucht):
- run.py                     = das Programm
- hms_app/                   = der Anwendungscode
- HMS MediaEngine.app        = macOS Doppelklick-Starter
- HMS-Start.vbs              = Windows Doppelklick-Starter
- HMS-Install.vbs             = Windows Erst-Installation
- HMS-Mac-Install.command     = macOS Homebrew-Installation
- HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix)
- installer_gui.py           = grafischer Installer
- launcher.pyw + launcher_core.py = interne Start-Logik
- LIESMICH.txt               = 10-Zeilen-Kurzanleitung
- .gitignore

_entwicklung/ (alles andere, NICHT benoetigt):
- packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/
  build/ fixture_profiles/
- PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md
- pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py

Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann
_entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne.

Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
2026-09-11 23:44:06 +02:00

413 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()