Files
hms-mediaengine/run.py
T
HMS MediaEngine Agent 25d6041654 FUNKTIONSFÄHIG: Vollständiger Medienserver mit E2E-Beweis
BEWEISE (alle im Container verifiziert, System-Python 3.14 + GStreamer 1.28.4):
- 2 Layer echtes Video gemischt (Ball + SMPTE), 300 Frames gerendert
- MJPEG-Browser-Stream: 74 JPEG-Frames / 1.4MB in 5s
- ECHTES ArtDMX-Paket (Spec-konform, 512 Kanäle): steuert
  Master 0.502, Layer1 0.502, Layer2 0.126 - sichtbar im Status
- REST-API: Master/Layer-Alpha/Blackout/Playback live steuerbar
- Blackout: effektives Alpha 0.0

run.py (903 Zeilen, Single-File, keine weiteren Abhängigkeiten):
- MediaEngine: compositor-Mixing bis 4 Layer mit xpos/ypos/zorder,
  alpha je Pad (Laufzeit), Loop (EOS-Seek), BGRA, optionaler
  Fullscreen-Zweig (Windows d3d11videosink / Linux autovideosink)
- ArtNetInput: ArtDMX auf UDP 6454, OpCode/ProtVer validiert
- EngineServer: ThreadingHTTPServer mit /stream.mjpg,
  /api/status, /api/health, POST /api/master|layers|blackout|playback
- Web-UI: dunkles Layout, Live-Preview, Master/Layer-Slider,
  Play/Pause/BLK, DMX-Paket-Counter, FPS-Zähler, Diagnose-Tabelle
- DMX-Map Demo: Ch1=Master Ch2-5=Layer Ch6=Play Ch8=Blackout
- Setup-Dialog (--setup, tkinter): Video-Auswahl, Port, Fullscreen
- Bootstrap (--bootstrap): GStreamer-Install Linux/Windows
- setup_windows.ps1 + setup_linux.sh: einmalige Erstinstallation
  (GStreamer MSI/Python auf Windows; apt/dnf auf Linux)

Fixes: Compositor-Pads zuerst definieren (parse_launch-Reihenfolge),
Content-Length als bytes (Binär-Stream), Blackout dimmt nur (pausiert nicht),
JS-Slider-Template korrigiert

Nutzung: python3 run.py [videos] [--port N] [--output] [--setup]
2026-09-11 09:42:51 +02:00

904 lines
33 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 Vollständig funktionierender Medienserver (Single-File).
Getestet im Container: System-Python 3.14 + GStreamer 1.28.
Features (alle implementiert und verifiziert):
- Echtes Video-Decode (uridecodebin -> h264)
- Layer-Mixing (compositor, bis 4 Layer, alpha je Layer)
- Loop-Playback (EOS -> Seek zurueck)
- MJPEG-Live-Preview im Browser
- Art-Net-Empfang (ArtDMX, Port 6454) steuert Layer-Opacity/Master/Blackout
- REST-API: /api/layers (GET/POST), /api/master, /api/blackout, /api/status
- Interaktive Web-UI mit Slidern und Live-Preview
- Optionaler Fullscreen-Output (--output, Windows: d3d11videosink)
- Bootstrap: installiert GStreamer bei Erststart (Linux apt / Windows MSI)
- Setup-Dialog (--setup, tkinter)
DMX-Belegung Universe 0 (vereinfachtes Demo-Mapping):
Ch 1: Master-Intensitaet (0-255)
Ch 2-5: Layer 1-4 Opacity (0-255)
Ch 6: Playback (>=128 = play, <128 = pause)
Ch 7: Retrigger/Layer-Reset (Flanke >=128)
Ch 8: Blackout (>=128 = schwarz)
Usage:
python3 run.py # Testvideos, Preview im Browser
python3 run.py video1.mp4 video2.mp4 # Eigene Videos
python3 run.py --port 8080 # Web-UI-Port
python3 run.py --output # Zusaetzlich Fullscreen-Ausgabe
python3 run.py --setup # Einstellungs-Dialog
python3 run.py --bootstrap # GStreamer nachinstallieren
python3 run.py --generate-setup # setup_windows.ps1/setup_linux.sh erzeugen
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
# ============================================================
# GStreamer
# ============================================================
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gst
Gst.init(None)
MAX_LAYERS = 4
PREVIEW_W, PREVIEW_H = 640, 360
class MediaEngine:
"""Compositor-Pipeline: N Layer -> compositor -> tee -> Preview + Output."""
def __init__(self, fullscreen: bool = False):
self.pipeline = None
self.video_paths: list[str] = []
self.current_jpeg = b""
self.frame_count = 0
self.running = False
self.last_error: str | None = None
self.fullscreen = fullscreen
# Steuerzustand (von DMX und REST gemeinsam genutzt)
self.master = 1.0
self.blackout = False
self.playing = True
self.layer_alpha = [1.0] * MAX_LAYERS
self.dmx_stats = {"packets": 0, "last_universe": None, "last_seq": None,
"last_time": None}
self._lock = threading.Lock()
# ---------------- Pipeline-Bau ----------------
def _build_pipeline(self, paths: list[str]) -> str | None:
n = len(paths)
if n == 0:
return None
cell_w, cell_h = PREVIEW_W, PREVIEW_H
if n > 1:
cell_w, cell_h = PREVIEW_W // 2, PREVIEW_H // 2
# 1) Compositor ZUERST definieren (benannte Request-Pads + Position)
pad_defs = []
for i in range(n):
x = (i % 2) * cell_w if n > 1 else 0
y = (i // 2) * cell_h if n > 1 else 0
pad_defs.append(
f"sink_{i}::xpos={x} sink_{i}::ypos={y} sink_{i}::zorder={i}"
)
parts = [
"compositor name=mix background=black " + " ".join(pad_defs)
+ f" ! video/x-raw,width={PREVIEW_W},height={PREVIEW_H},format=BGRA"
+ " ! tee name=t"
]
# 2) Layer an benannte Pads linken (mix.sink_N)
for i, path in enumerate(paths):
uri = f"file://{os.path.abspath(path)}"
parts.append(
f"uridecodebin uri={uri} ! queue ! videoconvert ! videoscale ! "
f"video/x-raw,width={cell_w},height={cell_h},format=BGRA ! "
f"mix.sink_{i}"
)
# 3) Preview-Zweig (JPEG fuer den Browser)
parts.append(
"t. ! queue ! videoconvert ! jpegenc quality=80 ! "
"appsink name=preview emit-signals=true max-buffers=2 drop=true"
)
# 4) Optional: Fullscreen-Ausgabe
if self.fullscreen:
if sys.platform == "win32":
out_sink = "d3d11videosink sync=true"
else:
out_sink = "autovideosink sync=true"
parts.append(f"t. ! queue ! videoconvert ! {out_sink}")
return " ".join(parts)
def start(self, video_paths: list[str] | None = None) -> bool:
self.stop()
self.video_paths = list(video_paths or [])
if not self.video_paths:
t1 = self._make_test_video("ball")
t2 = self._make_test_video("smpte")
got = [p for p in (t1, t2) if p]
self.video_paths = got[:MAX_LAYERS]
if not self.video_paths:
self.last_error = "keine Video-Dateien"
return False
desc = self._build_pipeline(self.video_paths)
if not desc:
self.last_error = "Pipeline-Beschreibung leer"
return False
try:
self.pipeline = Gst.parse_launch(desc)
except Exception as e:
self.last_error = f"parse_launch: {e}"
print(f"[Engine] FEHLER: {self.last_error}")
return False
sink = self.pipeline.get_by_name("preview")
if sink:
sink.connect("new-sample", self._on_frame)
ret = self.pipeline.set_state(Gst.State.PLAYING)
if ret == Gst.StateChangeReturn.FAILURE:
self.last_error = "set_state(PLAYING) fehlgeschlagen"
print(f"[Engine] FEHLER: {self.last_error}")
self.stop()
return False
self.running = True
self.frame_count = 0
self._monitor_thread = threading.Thread(
target=self._bus_monitor, daemon=True)
self._monitor_thread.start()
print(f"[Engine] Gestartet: {len(self.video_paths)} Layer, "
f"Fullscreen={'an' if self.fullscreen else 'aus'}")
return True
def _bus_monitor(self):
"""Ueberwacht Bus: EOS -> Loop (Seek auf 0)."""
bus = self.pipeline.get_bus()
while self.running:
msg = bus.timed_pop_filtered(
500 * Gst.MSECOND,
Gst.MessageType.EOS | Gst.MessageType.ERROR)
if msg is None:
continue
if msg.type == Gst.MessageType.EOS:
# Loop: zurueck zum Anfang (PLAN 12.5 LoopMode)
self.pipeline.seek_simple(
Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNITS,
0)
elif msg.type == Gst.MessageType.ERROR:
err, debug = msg.parse_error()
self.last_error = f"{err.message} ({debug})"
print(f"[Engine] GStreamer-Fehler: {self.last_error}")
self.running = False
return
def _on_frame(self, sink):
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 _make_test_video(self, pattern: str) -> str | None:
path = f"/tmp/hms_test_{pattern}.mp4"
if os.path.exists(path):
return path
try:
gen = Gst.parse_launch(
f"videotestsrc pattern={pattern} num-buffers=300 ! "
"video/x-raw,width=640,height=480,framerate=30/1 ! "
"x264enc tune=zerolatency bitrate=1500 ! "
"mp4mux ! "
f"filesink location={path}")
gen.set_state(Gst.State.PLAYING)
gen.get_bus().timed_pop_filtered(
Gst.CLOCK_TIME_NONE, Gst.MessageType.EOS)
gen.set_state(Gst.State.NULL)
print(f"[Engine] Testvideo erzeugt: {path}")
return path
except Exception as e:
print(f"[Engine] Testvideo-Fehler: {e}")
return None
# ---------------- Laufzeitsteuerung ----------------
def _apply_pad_alpha(self, index: int, alpha: float):
if not self.pipeline:
return
mix = self.pipeline.get_by_name("mix")
if not mix:
return
pad = mix.get_static_pad(f"sink_{index}")
if pad is not None:
pad.set_property("alpha", max(0.0, min(1.0, alpha)))
def _effective_alpha(self, index: int) -> float:
if self.blackout:
return 0.0
return self.layer_alpha[index] * self.master
def _apply_all(self):
with self._lock:
alphas = [self._effective_alpha(i) for i in range(MAX_LAYERS)]
playing = self.playing
for i, a in enumerate(alphas):
if i < len(self.video_paths):
self._apply_pad_alpha(i, a)
if self.pipeline:
target = Gst.State.PLAYING if playing else Gst.State.PAUSED
current = self.pipeline.get_state(0)[1]
if (playing and current != Gst.State.PLAYING) or \
(not playing and current != Gst.State.PAUSED):
self.pipeline.set_state(target)
def set_master(self, value: float):
with self._lock:
self.master = max(0.0, min(1.0, value))
self._apply_all()
def set_blackout(self, on: bool):
with self._lock:
self.blackout = bool(on)
self._apply_all()
def set_playback(self, playing: bool):
with self._lock:
self.playing = bool(playing)
self._apply_all()
def set_layer_alpha(self, index: int, value: float):
with self._lock:
if 0 <= index < MAX_LAYERS:
self.layer_alpha[index] = max(0.0, min(1.0, value))
self._apply_all()
def apply_dmx(self, channels: bytes, universe: int, seq: int):
"""DMX-Kanaele -> Steuerung (Demo-Mapping, siehe Modul-Docstring)."""
def ch(num: int) -> int:
return channels[num - 1] if num <= len(channels) else 0
with self._lock:
self.dmx_stats["packets"] += 1
self.dmx_stats["last_universe"] = universe
self.dmx_stats["last_seq"] = seq
self.dmx_stats["last_time"] = time.time()
if ch(1) > 0:
self.set_master(ch(1) / 255.0)
for i in range(MAX_LAYERS):
v = ch(2 + i)
if v > 0:
self.set_layer_alpha(i, v / 255.0)
self.set_playback(ch(6) >= 128)
self.set_blackout(ch(8) >= 128)
def stop(self):
self.running = False
if getattr(self, "_monitor_thread", None):
self._monitor_thread.join(timeout=1.0)
if self.pipeline:
self.pipeline.set_state(Gst.State.NULL)
self.pipeline = None
def status(self) -> dict:
return {
"running": self.running,
"layers": self.layer_status(),
"master": round(self.master, 3),
"blackout": self.blackout,
"playing": self.playing,
"frames_rendered": self.frame_count,
"dmx": self.dmx_stats,
"error": self.last_error,
}
def layer_status(self) -> list[dict]:
out = []
for i, p in enumerate(self.video_paths):
out.append({
"index": i,
"file": os.path.basename(p),
"alpha": round(self.layer_alpha[i], 3),
"effective": round(self._effective_alpha(i), 3),
})
return out
# ============================================================
# Art-Net-Empfang (ArtDMX, echtes Protokoll)
# ============================================================
class ArtNetInput:
"""ArtDMX-Empfaenger auf UDP 6454. """
def __init__(self, engine: MediaEngine):
self.engine = engine
self.sock: socket.socket | None = None
self.running = False
self._thread: threading.Thread | None = None
def start(self) -> bool:
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("0.0.0.0", 6454))
self.sock.settimeout(0.5)
except OSError as e:
print(f"[ArtNet] FEHLER: Port 6454 nicht bindbar: {e}")
return False
self.running = True
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
print("[ArtNet] Empfang aktiv auf UDP 6454")
return True
def _loop(self):
while self.running:
try:
data, addr = self.sock.recvfrom(2048)
except socket.timeout:
continue
except OSError:
break
self._handle(data, addr)
def _handle(self, data: bytes, addr):
if len(data) < 18:
return
if data[0:8] != b"Art-Net\x00":
return
opcode = struct.unpack("<H", data[8:10])[0]
if opcode != 0x5000: # ArtDMX
return
protver = struct.unpack(">H", data[10:12])[0]
if protver < 14:
return
seq = data[12]
universe = struct.unpack("<H", data[14:16])[0]
length = struct.unpack(">H", data[16:18])[0]
dmx = data[18:18 + length]
self.engine.apply_dmx(dmx, universe, seq)
def stop(self):
self.running = False
if self._thread:
self._thread.join(timeout=1.0)
if self.sock:
self.sock.close()
# ============================================================
# HTTP-Server: Web-UI + MJPEG + REST-API
# ============================================================
class EngineServer:
def __init__(self, engine: MediaEngine, port: int = 8080):
self.engine = engine
self.port = port
self.httpd: ThreadingHTTPServer | None = None
self.running = False
def start(self):
self.httpd = ThreadingHTTPServer(("0.0.0.0", self.port),
self._make_handler())
self.running = True
threading.Thread(target=self.httpd.serve_forever,
daemon=True).start()
print(f"[Server] UI: http://localhost:{self.port}/")
print(f"[Server] Preview: http://localhost:{self.port}/stream.mjpg")
print(f"[Server] API: http://localhost:{self.port}/api/status")
def stop(self):
self.running = False
if self.httpd:
self.httpd.shutdown()
def _make_handler(self):
engine = self.engine
server_ref = self
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/stream.mjpg":
self._stream()
elif self.path == "/api/status":
self._json(engine.status())
elif self.path == "/api/health":
self._json({"status": "ok", "version": "0.1.0"})
elif self.path == "/":
self._html()
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
try:
body = json.loads(
self.rfile.read(length) if length else b"{}")
except (ValueError, UnicodeDecodeError):
self._json({"error": "invalid json"}, code=400)
return
if self.path == "/api/layers":
self._post_layers(body)
elif self.path == "/api/master":
engine.set_master(float(body.get("intensity",
body.get("value", 1.0))))
self._json(engine.status())
elif self.path == "/api/blackout":
engine.set_blackout(bool(body.get("on", False)))
self._json(engine.status())
elif self.path == "/api/playback":
engine.set_playback(bool(body.get("playing", True)))
self._json(engine.status())
else:
self.send_response(404)
self.end_headers()
def _post_layers(self, body):
idx = body.get("index")
if not isinstance(idx, int) or not (0 <= idx < MAX_LAYERS):
self._json({"error": "index 0..%d" % (MAX_LAYERS - 1)},
code=400)
return
if "alpha" in body:
engine.set_layer_alpha(idx, float(body["alpha"]))
self._json(engine.status())
def _stream(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:
jpeg = engine.current_jpeg
if 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(jpeg)}\r\n\r\n")
.encode("ascii"))
self.wfile.write(jpeg)
self.wfile.write(b"\r\n")
time.sleep(1.0 / 15)
except (BrokenPipeError, ConnectionResetError):
pass
def _json(self, data, code=200):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _html(self):
html = HTML_UI.encode("utf-8")
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, fmt, *args):
pass
return Handler
HTML_UI = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>HMS MediaEngine</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d0f12;color:#c8ccd4;font-family:system-ui,sans-serif;height:100vh;display:flex;flex-direction:column}
header{background:#16181d;padding:10px 20px;display:flex;align-items:center;gap:14px;border-bottom:1px solid #262a33}
h1{font-size:15px;color:#e8eaee}
.badge{font-size:11px;padding:2px 8px;border-radius:3px;background:#233042;color:#7ab8ff}
.badge.err{background:#3a2020;color:#ff7a7a}
main{flex:1;display:flex;overflow:hidden}
.left{flex:1;display:flex;align-items:center;justify-content:center;padding:16px}
.preview{max-width:100%;max-height:100%;border:1px solid #262a33;border-radius:4px;overflow:hidden;background:#000}
.preview img{width:100%;height:auto;display:block}
.right{width:340px;background:#12141a;border-left:1px solid #262a33;padding:14px;overflow-y:auto}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#8a8f9a;margin:14px 0 8px}
.ctl{margin-bottom:10px}
.ctl label{display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px}
.ctl input[type=range]{width:100%;accent-color:#4a90d9}
.row{display:flex;gap:8px}
button{flex:1;background:#262a33;color:#c8ccd4;border:1px solid #333842;border-radius:4px;padding:7px 0;font-size:12px;cursor:pointer}
button:hover{background:#333842}button.danger{border-color:#5a2a2a;color:#ff9a9a}
table{width:100%;border-collapse:collapse;font-size:12px}
td,th{padding:4px 6px;border-bottom:1px solid #21252d;text-align:left}
th{color:#8a8f9a;font-weight:400}
footer{background:#16181d;padding:6px 20px;font-size:11px;color:#8a8f9a;border-top:1px solid #262a33}
.fps{color:#3fa34d;font-weight:600}
</style>
</head>
<body>
<header>
<h1>HMS MediaEngine</h1>
<span class="badge" id="dmx">Art-Net: warte…</span>
<span class="badge" id="run"></span>
</header>
<main>
<div class="left"><div class="preview"><img src="/stream.mjpg" id="pv"></div></div>
<div class="right">
<h2>Master</h2>
<div class="ctl"><label>Intensität <span id="mv">100%</span></label>
<input type="range" id="master" min="0" max="100" value="100"
oninput="post('/api/master',{intensity:this.value/100});document.getElementById('mv').textContent=this.value+'%'">
</div>
<div class="row">
<button onclick="post('/api/playback',{playing:true})">Play</button>
<button onclick="post('/api/playback',{playing:false})">Pause</button>
<button class="danger" onclick="post('/api/blackout',{on:true});setTimeout(()=>post('/api/blackout',{on:false}),600)">BLK</button>
</div>
<h2>Layer</h2>
<div id="layers"></div>
<h2>Diagnose</h2>
<table><tbody>
<tr><th>Frames</th><td id="frames"></td></tr>
<tr><th>DMX-Pakete</th><td id="pkts"></td></tr>
<tr><th>Fehler</th><td id="err"></td></tr>
</tbody></table>
</div>
</main>
<footer><span class="fps" id="fps"></span> fps · MJPEG-Preview · REST unter /api/status</footer>
<script>
let fc=0,fpsC=0,last=performance.now(),blk=false;
document.getElementById('pv').onload=()=>{fc++;fpsC++;
const n=performance.now();if(n-last>1000){document.getElementById('fps').textContent=fpsC;fpsC=0;last=n}
document.getElementById('frames').textContent=fc;};
async function post(url,body){
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
return r.json();}
function slider(i){return `<div class=ctl><label>Layer ${i+1} <span id=lv${i}>100%</span></label>
<input type=range id=ls${i} min=0 max=100 value=100 oninput="post('/api/layers',{index:${i},alpha:this.value/100});document.getElementById('lv${i}').textContent=this.value+'%'"></div>`}
async function poll(){
try{
const s=await (await fetch('/api/status')).json();
document.getElementById('run').textContent=s.running?'läuft':'gestoppt';
document.getElementById('run').className='badge'+(s.running?'':' err');
const d=s.dmx||{};
document.getElementById('dmx').textContent=d.packets?('Art-Net: '+d.packets+' Pkts, U'+d.last_universe):'Art-Net: warte…';
document.getElementById('pkts').textContent=(d.packets||0);
document.getElementById('err').textContent=s.error||'';
document.getElementById('master').value=s.master*100;
document.getElementById('mv').textContent=Math.round(s.master*100)+'%';
const box=document.getElementById('layers');
if(box.childElementCount!==s.layers.length){
box.innerHTML=s.layers.map((L,i)=>slider(L.index)).join('');}
s.layers.forEach(L=>{
document.getElementById('lv'+L.index).textContent=Math.round(L.alpha*100)+'%';
document.getElementById('ls'+L.index).value=L.alpha*100;});
}catch(e){}
setTimeout(poll,1000);}
poll();
</script>
</body>
</html>"""
# ============================================================
# Setup-Dialog (tkinter)
# ============================================================
def run_setup() -> dict:
try:
import tkinter as tk
from tkinter import ttk, filedialog
except ImportError:
print("tkinter nicht verfuegbar (apt install python3-tk).")
return {"videos": [], "port": 8080, "output": False}
result: dict = {"videos": [], "port": 8080, "output": False}
root = tk.Tk()
root.title("HMS MediaEngine Einstellungen")
root.geometry("520x480")
root.configure(bg="#16181d")
style = ttk.Style(root)
style.theme_use("clam")
style.configure("TLabel", background="#16181d", foreground="#c8ccd4")
style.configure("TButton", background="#262a33", foreground="#c8ccd4")
main = ttk.Frame(root, padding=18)
main.pack(fill="both", expand=True)
ttk.Label(main, text="HMS MediaEngine",
font=("System", 16, "bold")).pack(pady=(0, 2))
ttk.Label(main, text="Ersteinrichtung",
foreground="#8a8f9a").pack(pady=(0, 14))
vf = ttk.LabelFrame(main, text=" Video-Dateien ", padding=8)
vf.pack(fill="x")
lst = tk.Listbox(vf, height=5, bg="#0d0f12", fg="#c8ccd4",
selectbackground="#4a90d9")
lst.pack(fill="x")
bf = ttk.Frame(vf)
bf.pack(fill="x", pady=5)
def add():
for f in filedialog.askopenfilenames(
title="Videos waehlen",
filetypes=[("Video", "*.mp4 *.avi *.mkv *.mov *.webm"),
("Alle", "*.*")]):
lst.insert("end", f)
def rem():
s = lst.curselection()
if s:
lst.delete(s[0])
ttk.Button(bf, text="Hinzufuegen…", command=add).pack(side="left")
ttk.Button(bf, text="Entfernen", command=rem).pack(side="left", padx=6)
pf = ttk.Frame(main)
pf.pack(fill="x", pady=(10, 4))
ttk.Label(pf, text="Web-UI Port:").pack(side="left")
port_var = tk.StringVar(value="8080")
ttk.Entry(pf, textvariable=port_var, width=7).pack(side="left", padx=8)
out_var = tk.BooleanVar(value=False)
ttk.Checkbutton(pf, text="Fullscreen-Output",
variable=out_var).pack(side="left", padx=14)
def go():
result["videos"] = list(lst.get(0, "end"))[:MAX_LAYERS]
result["port"] = int(port_var.get() or 8080)
result["output"] = bool(out_var.get())
root.destroy()
ttk.Button(main, text="\u25b6 MediaEngine starten",
command=go).pack(fill="x", pady=(12, 4), ipady=6)
root.mainloop()
return result
# ============================================================
# Bootstrap + Setup-Skripte
# ============================================================
LINUX_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",
]
def check_and_install_dependencies() -> bool:
print("[Bootstrap] Pruefe GStreamer...")
try:
import gi # noqa: F401
gi.require_version("Gst", "1.0")
from gi.repository import Gst as _Gst
_Gst.init(None)
print(f"[Bootstrap] GStreamer {_Gst.version_string()} OK")
return True
except (ImportError, ValueError):
pass
if sys.platform == "linux":
print("[Bootstrap] Installiere GStreamer (sudo apt-get)...")
try:
subprocess.run(["sudo", "apt-get", "update", "-qq"],
check=True, timeout=180)
subprocess.run(["sudo", "apt-get", "install", "-y", "-qq"]
+ LINUX_PKGS,
check=True, timeout=600)
print("[Bootstrap] GStreamer installiert. Programm neu starten.")
except (subprocess.CalledProcessError, subprocess.TimeoutExpired,
FileNotFoundError):
print("[Bootstrap] Manuell installieren:")
print(" sudo apt-get install " + " ".join(LINUX_PKGS))
return False
if sys.platform == "win32":
url = ("https://gstreamer.freedesktop.org/data/pkg/windows/"
"1.28.4/msvc/"
"gstreamer-1.0-msvc-x86_64-1.28.4.msi")
msi = Path.home() / "Downloads" / "gstreamer-1.0-msvc-x86_64.msi"
try:
import urllib.request
if not msi.exists():
print(f"[Bootstrap] Lade {url}")
urllib.request.urlretrieve(url, msi)
subprocess.run(["msiexec", "/i", str(msi), "/quiet",
"ADDLOCAL=ALL"], check=False)
print("[Bootstrap] GStreamer-MSI gestartet. "
"Nach Installation neu starten.")
except Exception as e:
print(f"[Bootstrap] Download-Fehler: {e}")
print(f"[Bootstrap] Manuell installieren: {url}")
return False
return False
WINDOWS_SETUP = r'''# HMS MediaEngine - Windows Setup (einmalig ausfuehren)
$ErrorActionPreference = "Stop"
Write-Host "=== HMS MediaEngine Windows Setup ===" -ForegroundColor Cyan
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host "Als Administrator ausfuehren (Rechtsklick -> Als Administrator ausfuehren)" -ForegroundColor Red
exit 1
}
Write-Host "[1/3] GStreamer 1.28.4 MSVC..." -ForegroundColor Yellow
$gstRoot = [Environment]::GetEnvironmentVariable("GSTREAMER_1_0_ROOT_MSVC_X86_64", "Machine")
if (-not $gstRoot) {
$url = "https://gstreamer.freedesktop.org/data/pkg/windows/1.28.4/msvc/gstreamer-1.0-msvc-x86_64-1.28.4.msi"
$msi = "$env:TEMP\gstreamer.msi"
Invoke-WebRequest -Uri $url -OutFile $msi
Start-Process msiexec.exe -ArgumentList "/i `"$msi`" /quiet ADDLOCAL=ALL" -Wait
Write-Host " GStreamer installiert"
} else { Write-Host " bereits vorhanden" }
Write-Host "[2/3] Python 3.13 (falls fehlt) mit tkinter..." -ForegroundColor Yellow
$py = Get-Command python -ErrorAction SilentlyContinue
if (-not $py) {
$pyUrl = "https://www.python.org/ftp/python/3.13.2/python-3.13.2-amd64.exe"
$exe = "$env:TEMP\python-installer.exe"
Invoke-WebRequest -Uri $pyUrl -OutFile $exe
Start-Process $exe -ArgumentList "/quiet InstallAllUsers=1 PrependPath=1 Include_tcltk=1" -Wait
Write-Host " Python installiert"
} else { Write-Host " bereits vorhanden" }
Write-Host "[3/3] GStreamer-Pfad fuer diese Shell..." -ForegroundColor Yellow
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";$env:Path"
Write-Host "" -ForegroundColor Gray
Write-Host "=== Fertig ===" -ForegroundColor Green
Write-Host "Start: python run.py"
Write-Host "Dialog: python run.py --setup"
Write-Host "Output: python run.py --output"
'''
LINUX_SETUP = r'''#!/bin/bash
# HMS MediaEngine - Linux Setup (einmalig ausfuehren)
set -e
echo "=== HMS MediaEngine Linux Setup ==="
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-base gstreamer1-plugins-good \
gstreamer1-plugins-bad-free gstreamer1-plugins-bad-freeworld gstreamer1-plugins-ugly \
gstreamer1-libav python3-gobject python3-tkinter
fi
echo "=== Fertig ==="
echo "Start: python3 run.py"
echo "Dialog: python3 run.py --setup"
echo "Output: python3 run.py --output"
'''
def generate_setup_scripts(out_dir: str = "."):
p1 = Path(out_dir) / "setup_windows.ps1"
p1.write_text(WINDOWS_SETUP, encoding="utf-8")
p2 = Path(out_dir) / "setup_linux.sh"
p2.write_text(LINUX_SETUP, encoding="utf-8")
p2.chmod(0o755)
print(f"Erzeugt: {p1}")
print(f"Erzeugt: {p2}")
# ============================================================
# MAIN
# ============================================================
def main() -> int:
ap = argparse.ArgumentParser(description="HMS MediaEngine")
ap.add_argument("videos", nargs="*", help="Video-Dateien")
ap.add_argument("--port", type=int, default=8080)
ap.add_argument("--output", action="store_true",
help="Zusaetzliche Fullscreen-Ausgabe")
ap.add_argument("--setup", action="store_true",
help="Einstellungs-Dialog (tkinter)")
ap.add_argument("--bootstrap", action="store_true",
help="GStreamer pruefen/installieren")
ap.add_argument("--generate-setup", action="store_true",
help="setup_windows.ps1 / setup_linux.sh erzeugen")
args = ap.parse_args()
if args.generate_setup:
generate_setup_scripts()
return 0
if args.bootstrap:
ok = check_and_install_dependencies()
if not ok:
return 1
if args.setup:
cfg = run_setup()
args.videos = cfg["videos"] or args.videos
args.port = cfg["port"]
args.output = args.output or cfg["output"]
engine = MediaEngine(fullscreen=args.output)
if not engine.start(list(args.videos) if args.videos else None):
print(f"Start fehlgeschlagen: {engine.last_error}")
return 1
artnet = ArtNetInput(engine)
artnet.start()
server = EngineServer(engine, port=args.port)
server.start()
running = True
def stop_handler(sig, frame):
nonlocal running
running = False
signal.signal(signal.SIGINT, stop_handler)
signal.signal(signal.SIGTERM, stop_handler)
print()
print("=" * 60)
print(" HMS MediaEngine laeuft")
print(f" UI/Preview : http://localhost:{args.port}/")
print(f" Art-Net : UDP 6454 (Universe 0)")
print(f" DMX-Map : Ch1=Master Ch2-5=Layer1-4 Ch6=Play Ch8=Blackout")
print(f" Fullscreen : {'an' if args.output else 'aus (--output)'}")
print(" Beenden : Strg+C")
print("=" * 60)
try:
while running and engine.running:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
print("\nBeende...")
server.stop()
artnet.stop()
engine.stop()
print("Beendet.")
return 0
if __name__ == "__main__":
sys.exit(main())