diff --git a/apps/renderer/hms_renderer/run_renderer.py b/apps/renderer/hms_renderer/run_renderer.py
new file mode 100644
index 0000000..0cdf995
--- /dev/null
+++ b/apps/renderer/hms_renderer/run_renderer.py
@@ -0,0 +1,412 @@
+#!/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()
diff --git a/run.py b/run.py
new file mode 100644
index 0000000..99b344a
--- /dev/null
+++ b/run.py
@@ -0,0 +1,706 @@
+#!/usr/bin/env python3
+"""HMS MediaEngine – Vollständig funktionierender Server.
+
+Startet: GStreamer-Renderer (echtes Video) + MJPEG-Preview + Web-UI + REST-API.
+Läuft mit System-Python (3.14) das GStreamer-Bindings hat.
+
+Usage:
+ python3 run.py # Standardstart mit Testvideo
+ python3 run.py video1.mp4 video2.mp4 # Eigene Videos
+ python3 run.py --port 8080 # Anderer Web-UI Port
+ python3 run.py --setup # Einstellungs-Dialog
+"""
+
+import argparse
+import json
+import os
+import signal
+import subprocess
+import sys
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from pathlib import Path
+
+# ============================================================
+# GStreamer Renderer (echtes Video-Playback)
+# ============================================================
+
+import gi
+gi.require_version("Gst", "1.0")
+from gi.repository import Gst
+
+Gst.init(None)
+
+
+class VideoRenderer:
+ """Spielt echte Videos ab und liefert JPEG-Frames für den Browser-Preview."""
+
+ def __init__(self):
+ self.pipeline = None
+ self.current_jpeg = b""
+ self.video_paths = []
+ self.frame_count = 0
+ self.running = False
+ self.last_error = None
+
+ def start(self, video_paths: list[str] | None = None):
+ """Startet Video-Playback mit Browser-Preview."""
+ self.stop()
+ self.video_paths = video_paths or []
+ self.frame_count = 0
+
+ if not self.video_paths:
+ # Testvideo generieren wenn keins gegeben
+ test = self._make_test_video()
+ if test:
+ self.video_paths = [test]
+
+ if not self.video_paths:
+ # Fallback: Testpattern direkt streamen
+ pipeline_str = (
+ "videotestsrc is-live=true pattern=0 ! "
+ "video/x-raw,width=640,height=360,framerate=15/1 ! "
+ "videoconvert ! jpegenc ! appsink name=preview emit-signals=true max-buffers=2 drop=true"
+ )
+ elif len(self.video_paths) == 1:
+ pipeline_str = self._single_video_pipeline(self.video_paths[0])
+ else:
+ pipeline_str = self._multi_video_pipeline(self.video_paths)
+
+ try:
+ self.pipeline = Gst.parse_launch(pipeline_str)
+ sink = self.pipeline.get_by_name("preview")
+ if sink:
+ sink.connect("new-sample", self._on_frame)
+ self.pipeline.set_state(Gst.State.PLAYING)
+ self.running = True
+ print(f"[Renderer] Gestartet: {len(self.video_paths)} Video(s)")
+ return True
+ except Exception as e:
+ self.last_error = str(e)
+ print(f"[Renderer] FEHLER: {e}")
+ return False
+
+ def _single_video_pipeline(self, path: str) -> str:
+ uri = f"file://{os.path.abspath(path)}"
+ return (
+ f"uridecodebin uri={uri} ! "
+ "queue ! videoconvert ! videoscale ! "
+ "video/x-raw,width=640,height=360 ! "
+ "jpegenc quality=80 ! "
+ "appsink name=preview emit-signals=true max-buffers=2 drop=true"
+ )
+
+ def _multi_video_pipeline(self, paths: list[str]) -> str:
+ """Mischt mehrere Videos in ein 2x2 Grid."""
+ parts = []
+ n = min(len(paths), 4)
+ w, h = 320, 180
+ for i in range(n):
+ uri = f"file://{os.path.abspath(paths[i])}"
+ x = (i % 2) * w
+ y = (i // 2) * h
+ parts.append(
+ f"uridecodebin uri={uri} ! queue ! videoconvert ! videoscale ! "
+ f"video/x-raw,width={w},height={h} ! "
+ f"videomixer name=mix sink_{i}::xpos={x} sink_{i}::ypos={y} sink_{i}::zorder={i}"
+ )
+ parts.append(
+ f"mix. ! videoconvert ! videoscale ! "
+ f"video/x-raw,width=640,height=360 ! "
+ "jpegenc quality=80 ! "
+ "appsink name=preview emit-signals=true max-buffers=2 drop=true"
+ )
+ return " ".join(parts)
+
+ def _make_test_video(self) -> str | None:
+ """Erstellt ein Testvideo mit animiertem Ball."""
+ test_path = "/tmp/hms_test_video.mp4"
+ if os.path.exists(test_path):
+ return test_path
+ try:
+ gen = Gst.parse_launch(
+ "videotestsrc pattern=ball num-buffers=900 ! "
+ "video/x-raw,width=640,height=480,framerate=30/1 ! "
+ "x264enc tune=zerolatency bitrate=2000 ! "
+ "mp4mux ! "
+ f"filesink location={test_path}"
+ )
+ 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"[Renderer] Testvideo erstellt: {test_path}")
+ return test_path
+ except Exception as e:
+ print(f"[Renderer] Testvideo-Fehler: {e}")
+ return None
+
+ def _on_frame(self, sink):
+ """Empfängt JPEG-Frames."""
+ sample = sink.emit("pull-sample")
+ if sample:
+ buf = sample.get_buffer()
+ self.current_jpeg = buf.extract_dup(0, buf.get_size())
+ self.frame_count += 1
+ return Gst.FlowReturn.OK
+
+ def stop(self):
+ if self.pipeline:
+ self.pipeline.set_state(Gst.State.NULL)
+ self.pipeline = None
+ self.running = False
+
+ def get_status(self) -> dict:
+ return {
+ "running": self.running,
+ "videos": self.video_paths,
+ "frames_rendered": self.frame_count,
+ "error": self.last_error,
+ }
+
+
+# ============================================================
+# Kombinierter HTTP-Server (Web-UI + MJPEG-Stream + REST-API)
+# ============================================================
+
+
+class MediaEngineServer:
+ """HTTP-Server: liefert MJPEG-Stream + REST-API + einfache Web-UI."""
+
+ def __init__(self, renderer: VideoRenderer, port=8080, webui_dir=None):
+ self.renderer = renderer
+ self.port = port
+ self.webui_dir = webui_dir
+ self.server = None
+ self.running = False
+
+ def start(self):
+ 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"[Server] Web-UI: http://0.0.0.0:{self.port}")
+ print(f"[Server] Preview: http://0.0.0.0:{self.port}/stream.mjpg")
+ print(f"[Server] API: http://0.0.0.0:{self.port}/api/status")
+
+ def stop(self):
+ self.running = False
+ if self.server:
+ self.server.shutdown()
+
+ def _make_handler(self):
+ renderer = self.renderer
+ webui_dir = self.webui_dir
+ server_ref = self
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == "/stream.mjpg":
+ self._stream_mjpeg()
+ elif self.path == "/api/status":
+ self._json_response(renderer.get_status())
+ elif self.path == "/api/health":
+ self._json_response({"status": "ok", "version": "0.1.0"})
+ elif self.path == "/":
+ self._serve_ui()
+ else:
+ self.send_response(404)
+ self.end_headers()
+
+ def _stream_mjpeg(self):
+ 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()
+ try:
+ while server_ref.running:
+ if renderer.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(renderer.current_jpeg)}\r\n\r\n".encode("ascii")
+ )
+ self.wfile.write(renderer.current_jpeg)
+ self.wfile.write(b"\r\n")
+ time.sleep(1.0 / 15)
+ except (BrokenPipeError, ConnectionResetError):
+ pass
+
+ def _json_response(self, data):
+ body = json.dumps(data).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _serve_ui(self):
+ # Einfache HTML-UI mit Live-Preview
+ html = b"""
+
+
+ HMS MediaEngine
+
+
+
+
+ HMS MediaEngine v0.1.0
+ Renderer: LIVE
+
+
+
+

+
+
+
+
+
+"""
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(html)))
+ self.end_headers()
+ self.wfile.write(html)
+
+ def log_message(self, format, *args):
+ pass
+
+ return Handler
+
+
+# ============================================================
+# Setup-Dialog (tkinter, ohne Browser)
+# ============================================================
+
+
+def run_setup():
+ """Öffnet einen nativen Einstellungs-Dialog."""
+ try:
+ import tkinter as tk
+ from tkinter import ttk, filedialog, messagebox
+ except ImportError:
+ print("tkinter nicht verfügbar. Installiere: apt install python3-tk")
+ return
+
+ class SetupDialog:
+ def __init__(self):
+ self.root = tk.Tk()
+ self.root.title("HMS MediaEngine – Einstellungen")
+ self.root.geometry("480x520")
+ self.root.configure(bg="#16181d")
+ self.root.resizable(False, False)
+
+ # Dark Theme
+ style = ttk.Style()
+ style.theme_use("clam")
+ style.configure("TLabel", background="#16181d", foreground="#c8ccd4", font=("System", 10))
+ style.configure("TButton", background="#262a33", foreground="#c8ccd4")
+ style.configure("TEntry", fieldbackground="#0d0f12", foreground="#e8eaee")
+
+ self.build_ui()
+
+ def build_ui(self):
+ main = ttk.Frame(self.root, padding=20)
+ main.pack(fill="both", expand=True)
+
+ # Titel
+ ttk.Label(main, text="HMS MediaEngine", font=("System", 18, "bold")).pack(pady=(0, 4))
+ ttk.Label(main, text="Ersteinrichtung", foreground="#8a8f9a").pack(pady=(0, 20))
+
+ # Video-Auswahl
+ video_frame = ttk.LabelFrame(main, text=" Video-Dateien ", padding=10)
+ video_frame.pack(fill="x", pady=(0, 10))
+ self.video_list = tk.Listbox(video_frame, height=4, bg="#0d0f12", fg="#c8ccd4", selectbackground="#4a90d9")
+ self.video_list.pack(fill="x")
+ btn_frame = ttk.Frame(video_frame)
+ btn_frame.pack(fill="x", pady=5)
+ ttk.Button(btn_frame, text="Video hinzufügen…", command=self.add_video).pack(side="left", padx=(0, 5))
+ ttk.Button(btn_frame, text="Entfernen", command=self.remove_video).pack(side="left")
+
+ # Port
+ port_frame = ttk.Frame(main)
+ port_frame.pack(fill="x", pady=(0, 10))
+ ttk.Label(port_frame, text="Web-UI Port:").pack(side="left")
+ self.port_var = tk.StringVar(value="8080")
+ ttk.Entry(port_frame, textvariable=self.port_var, width=8).pack(side="left", padx=8)
+
+ # Netzwerk
+ ttk.Label(main, text="Netzwerk-Modus:").pack(anchor="w")
+ self.network_var = tk.StringVar(value="local")
+ net_frame = ttk.Frame(main)
+ net_frame.pack(fill="x", pady=(0, 15))
+ ttk.Radiobutton(net_frame, text="Nur dieser Rechner", variable=self.network_var, value="local").pack(side="left")
+ ttk.Radiobutton(net_frame, text="Show-LAN (alle Geräte)", variable=self.network_var, value="lan").pack(side="left", padx=10)
+
+ # Start-Button
+ self.start_btn = ttk.Button(main, text="▶ MediaEngine starten", command=self.start_engine)
+ self.start_btn.pack(fill="x", pady=(10, 5), ipady=6)
+
+ self.status_label = ttk.Label(main, text="", foreground="#3fa34d")
+ self.status_label.pack()
+
+ def add_video(self):
+ files = filedialog.askopenfilenames(
+ title="Video-Dateien wählen",
+ filetypes=[("Video", "*.mp4 *.avi *.mkv *.mov *.webm"), ("Alle", "*.*")]
+ )
+ for f in files:
+ self.video_list.insert("end", f)
+
+ def remove_video(self):
+ sel = self.video_list.curselection()
+ if sel:
+ self.video_list.delete(sel[0])
+
+ def start_engine(self):
+ videos = list(self.video_list.get(0, "end"))
+ port = int(self.port_var.get() or "8080")
+ self.root.destroy()
+ self.result = {"videos": videos, "port": port}
+
+ dialog = SetupDialog()
+ dialog.root.mainloop()
+ return getattr(dialog, "result", {"videos": [], "port": 8080})
+
+
+# ============================================================
+# Bootstrap: Prüft und installiert Abhängigkeiten
+# ============================================================
+
+
+def check_and_install_dependencies() -> bool:
+ """Prüft ob GStreamer und Python verfügbar sind; installiert falls nötig."""
+ print("[Bootstrap] Prüfe Abhängigkeiten...")
+
+ # GStreamer prüfen
+ try:
+ import gi
+ gi.require_version("Gst", "1.0")
+ from gi.repository import Gst
+ Gst.init(None)
+ print(f"[Bootstrap] GStreamer {Gst.version_string()} ✓")
+ return True
+ except ImportError:
+ pass
+
+ # Installieren je nach OS
+ if sys.platform == "linux":
+ print("[Bootstrap] Installiere GStreamer (sudo erforderlich)...")
+ pkgs = [
+ "gstreamer1.0-tools", "gstreamer1.0-plugins-base",
+ "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad",
+ "gstreamer1.0-plugins-ugly", "gstreamer1.0-libav",
+ "python3-gst-1.0", "gir1.2-gst-1.0",
+ ]
+ cmd = ["sudo", "apt-get", "install", "-y"] + pkgs
+ try:
+ subprocess.run(cmd, check=True, timeout=300)
+ print("[Bootstrap] GStreamer installiert ✓")
+ return True
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
+ print("[Bootstrap] MANUELLE INSTALLATION NÖTIG:")
+ print(f" sudo apt-get install {' '.join(pkgs)}")
+ return False
+
+ elif sys.platform == "win32":
+ print("[Bootstrap] Windows: GStreamer muss manuell installiert werden.")
+ print("[Bootstrap] Lade Installer herunter...")
+ url = "https://gstreamer.freedesktop.org/data/pkg/windows/1.28.4/msvc/gstreamer-1.0-msvc-x86_64-1.28.4.msi"
+ try:
+ import urllib.request
+ msi_path = Path.home() / "Downloads" / "gstreamer-1.0-msvc-x86_64-1.28.4.msi"
+ msi_path.parent.mkdir(exist_ok=True)
+ if not msi_path.exists():
+ print(f"[Bootstrap] Lade {url}...")
+ urllib.request.urlretrieve(url, msi_path)
+ print(f"[Bootstrap] GStreamer-Installer: {msi_path}")
+ subprocess.run(["msiexec", "/i", str(msi_path), "/quiet"], check=False)
+ print("[Bootstrap] GStreamer-MSI gestartet. Bitte Installation abschließen.")
+ return False # Nach Installation neu starten
+ except Exception as e:
+ print(f"[Bootstrap] Download-Fehler: {e}")
+ print(f"[Bootstrap] Bitte manuell installieren: {url}")
+ return False
+
+ return False
+
+
+# ============================================================
+# Windows Setup-Skript Generator
+# ============================================================
+
+
+def generate_windows_setup_script(output_dir: str = "."):
+ """Erstellt setup_windows.ps1 für die Windows-Installation."""
+ script = r'''# HMS MediaEngine – Windows Setup
+# Führt EINMALIG die komplette Installation durch.
+# Danach startet hms-mediaengine.exe direkt.
+
+$ErrorActionPreference = "Stop"
+Write-Host "=== HMS MediaEngine Windows Setup ===" -ForegroundColor Cyan
+
+# 1. Prüfe Admin-Rechte
+$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
+if (-not $isAdmin) {
+ Write-Host "FEHLER: Als Administrator ausführen (Rechtsklick -> Als Administrator ausführen)" -ForegroundColor Red
+ exit 1
+}
+
+# 2. Python 3.13 installieren (wenn nicht vorhanden)
+Write-Host "[1/5] Prüfe Python..." -ForegroundColor Yellow
+$pythonVersion = python --version 2>$null
+if ($pythonVersion -notmatch "3.13") {
+ Write-Host " Installiere Python 3.13..."
+ $pythonUrl = "https://www.python.org/ftp/python/3.13.2/python-3.13.2-amd64.exe"
+ $pythonExe = "$env:TEMP\python-installer.exe"
+ Invoke-WebRequest -Uri $pythonUrl -OutFile $pythonExe
+ Start-Process -FilePath $pythonExe -ArgumentList "/quiet InstallAllUsers=1 PrependPath=1" -Wait
+ Write-Host " Python 3.13 installiert ✓"
+} else {
+ Write-Host " Python bereits vorhanden ✓"
+}
+
+# 3. GStreamer installieren
+Write-Host "[2/5] Prüfe GStreamer..." -ForegroundColor Yellow
+$gstRoot = [Environment]::GetEnvironmentVariable("GSTREAMER_1_0_ROOT_MSVC_X86_64", "Machine")
+if (-not $gstRoot) {
+ Write-Host " Installiere GStreamer 1.28.4 MSVC..."
+ $gstUrl = "https://gstreamer.freedesktop.org/data/pkg/windows/1.28.4/msvc/gstreamer-1.0-msvc-x86_64-1.28.4.msi"
+ $gstMsi = "$env:TEMP\gstreamer.msi"
+ Invoke-WebRequest -Uri $gstUrl -OutFile $gstMsi
+ Start-Process -FilePath "msiexec.exe" -ArgumentList "/i `"$gstMsi`" /quiet ADDLOCAL=ALL" -Wait
+ Write-Host " GStreamer installiert ✓"
+ # Umgebungsvariable aktualisieren
+ $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";$env:Path"
+} else {
+ Write-Host " GStreamer bereits vorhanden ✓"
+}
+
+# 4. uv (Python-Paketmanager) installieren
+Write-Host "[3/5] Installiere uv..." -ForegroundColor Yellow
+$uvInstalled = Get-Command uv -ErrorAction SilentlyContinue
+if (-not $uvInstalled) {
+ irm https://astral.sh/uv/install.ps1 | iex
+ $env:Path = "$env:USERPROFILE\.local\bin;$env:Path"
+ Write-Host " uv installiert ✓"
+} else {
+ Write-Host " uv bereits vorhanden ✓"
+}
+
+# 5. Projekt-Abhängigkeiten
+Write-Host "[4/5] Installiere Projekt-Abhängigkeiten..." -ForegroundColor Yellow
+$repoDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+Set-Location $repoDir
+uv sync
+Write-Host " Abhängigkeiten installiert ✓"
+
+# 6. Frontend bauen
+Write-Host "[5/5] Baue Web-Frontend..." -ForegroundColor Yellow
+Push-Location apps\web
+corepack enable pnpm 2>$null
+pnpm install --frozen-lockfile 2>$null
+pnpm build
+Pop-Location
+Write-Host " Frontend gebaut ✓"
+
+Write-Host ""
+Write-Host "=== Installation abgeschlossen ===" -ForegroundColor Green
+Write-Host ""
+Write-Host "Starten mit:"
+Write-Host " .\run.py"
+Write-Host ""
+Write-Host "Oder Einstellungen öffnen:"
+Write-Host " .\run.py --setup"
+Write-Host ""
+'''
+ path = Path(output_dir) / "setup_windows.ps1"
+ path.write_text(script, encoding="utf-8")
+ print(f"Windows-Setup-Skript: {path}")
+
+
+# ============================================================
+# Linux Setup-Skript Generator
+# ============================================================
+
+
+def generate_linux_setup_script(output_dir: str = "."):
+ """Erstellt setup_linux.sh für die Linux-Installation."""
+ script = r'''#!/bin/bash
+# HMS MediaEngine – Linux Setup
+set -e
+
+echo "=== HMS MediaEngine Linux Setup ==="
+
+# 1. System-Pakete
+echo "[1/4] Installiere System-Pakete..."
+if command -v apt-get &>/dev/null; then
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq \
+ gstreamer1.0-tools gstreamer1.0-plugins-base \
+ gstreamer1.0-plugins-good gstreamer1.0-plugins-bad \
+ gstreamer1.0-plugins-ugly gstreamer1.0-libav \
+ python3-gst-1.0 gir1.2-gst-1.0 \
+ python3-tk xvfb
+elif command -v dnf &>/dev/null; then
+ sudo dnf install -y gstreamer1 gstreamer1-plugins-* python3-gstreamer python3-tkinter
+fi
+echo " System-Pakete installiert ✓"
+
+# 2. uv installieren
+echo "[2/4] Installiere uv..."
+if ! command -v uv &>/dev/null; then
+ curl -LsSf https://astral.sh/uv/install.sh | sh
+ export PATH="$HOME/.local/bin:$PATH"
+fi
+echo " uv ✓"
+
+# 3. Projekt-Abhängigkeiten
+echo "[3/4] Installiere Python-Abhängigkeiten..."
+REPO_DIR="$(dirname "$(realpath "$0")")"
+cd "$REPO_DIR"
+uv sync
+echo " Abhängigkeiten ✓"
+
+# 4. Frontend
+echo "[4/4] Baue Frontend..."
+if command -v pnpm &>/dev/null || command -v npx &>/dev/null; then
+ cd apps/web
+ (pnpm install --frozen-lockfile 2>/dev/null || npx pnpm install) && \
+ (pnpm build 2>/dev/null || npx pnpm build)
+ cd ../..
+ echo " Frontend ✓"
+else
+ echo " WARNUNG: pnpm nicht gefunden. Frontend wird nicht gebaut."
+ echo " Installiere mit: npm install -g pnpm"
+fi
+
+echo ""
+echo "=== Installation abgeschlossen ==="
+echo "Starten: ./run.py"
+echo "Einstellungen: ./run.py --setup"
+echo ""
+'''
+ path = Path(output_dir) / "setup_linux.sh"
+ path.write_text(script, encoding="utf-8")
+ path.chmod(0o755)
+ print(f"Linux-Setup-Skript: {path}")
+
+
+# ============================================================
+# MAIN
+# ============================================================
+
+
+def main():
+ parser = argparse.ArgumentParser(description="HMS MediaEngine")
+ parser.add_argument("videos", nargs="*", help="Video-Dateien zum Abspielen")
+ parser.add_argument("--port", type=int, default=8080, help="Web-UI Port (default: 8080)")
+ parser.add_argument("--setup", action="store_true", help="Einstellungs-Dialog öffnen")
+ parser.add_argument("--bootstrap", action="store_true", help="Abhängigkeiten prüfen/installieren")
+ parser.add_argument("--generate-setup", action="store_true", help="Setup-Skripte für Windows/Linux generieren")
+ args = parser.parse_args()
+
+ if args.generate_setup:
+ generate_windows_setup_script()
+ generate_linux_setup_script()
+ print("Setup-Skripte generiert.")
+ return 0
+
+ if args.bootstrap:
+ if not check_and_install_dependencies():
+ print("\nAbhängigkeiten nicht vollständig. Setup-Skript ausführen:")
+ if sys.platform == "win32":
+ print(" .\\setup_windows.ps1")
+ else:
+ print(" ./setup_linux.sh")
+ return 1
+
+ if args.setup:
+ config = run_setup()
+ args.videos = config.get("videos", args.videos)
+ args.port = config.get("port", args.port)
+
+ # Renderer starten
+ renderer = VideoRenderer()
+ video_paths = args.videos if args.videos else None
+ if not renderer.start(video_paths):
+ print("Renderer konnte nicht gestartet werden.")
+ return 1
+
+ # HTTP-Server starten
+ server = MediaEngineServer(renderer, port=args.port)
+ server.start()
+
+ # Signal-Handler
+ running = True
+ def signal_handler(sig, frame):
+ nonlocal running
+ running = False
+
+ signal.signal(signal.SIGINT, signal_handler)
+ signal.signal(signal.SIGTERM, signal_handler)
+
+ print("\n" + "=" * 60)
+ print(" HMS MediaEngine läuft!")
+ print(f" Web-UI: http://localhost:{args.port}")
+ print(f" Preview: http://localhost:{args.port}/stream.mjpg")
+ print(f" API: http://localhost:{args.port}/api/status")
+ print(f" Videos: {len(renderer.video_paths)} aktiv")
+ print(f" Frames: {renderer.frame_count}")
+ print(" Strg+C zum Beenden")
+ print("=" * 60 + "\n")
+
+ try:
+ while running:
+ time.sleep(1)
+ if renderer.frame_count % 150 == 0 and renderer.frame_count > 0:
+ pass # Status-Update alle 10 Sekunden bei 15fps
+ except KeyboardInterrupt:
+ pass
+ finally:
+ print("\nBeende...")
+ renderer.stop()
+ server.stop()
+ print("Beendet.")
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())