ECHTES PROGRAMM: Medienverwaltung + Upload + konfigurierbares Art-Net (14/14 E2E bestanden)

Vollständige Anwendung in hms_app/ (6 Module) + schlanker run.py:
- config.py: config.json-Persistenz, Defaults, strikte Validierung
  (Bereiche, Universes, Kanal-Mapping; ungültige Settings => 400)
- media.py: Upload-Streaming (chunkweise, Größenlimit, .part-Sicherung),
  Duplikat-Umbenennung, Thumbnail-Erzeugung per GStreamer,
  Metadaten-Cache (Dauer via query_duration), Löschen, Pfad-Schutz
- artnet.py: ArtDMX voll KONFIGURIERBAR (Port, Universes, Kanäle),
  Sequenz-/Duplikat-Filter, Signalverlust-Policy hold/fade_black,
  Watchdog, Live-Restart nach Settings-Änderung
- engine.py: dynamische Layer (Video + Bild via imagefreeze),
  atomarer Rebuild mit Rollback (alte Pipeline läuft bei Fehler weiter),
  Positions/Größen/Z-Order/Alpha je Layer, EOS-Loop, DMX-Mapping
- server.py: REST komplett (upload/delete, layers add/remove/update,
  master/blackout/playback, settings GET/POST mit Validierung),
  Thumbnails + Datei-Download, Traversal-Schutz
- ui.py: 4-Tabs-WebUI: Live (Preview+Slider+Diagnose), Medien
  (Drag&Drop-Upload mit Fortschritt, Thumbnails, Als-Layer/Delete),
  Layer-Editor (Alpha/Pos/Größe/Z), Einstellungen (Art-Net komplett
  umstellbar inkl. Port/Universes/Kanäle, Preview, Engine, Upload-Limit)
- setup.py: Bootstrap + setup_windows.ps1/setup_linux.sh Generierung
- run.py: Einstieg (--port, --setup, --bootstrap, --generate-setup,
  Datei-Import via CLI)

E2E-BEWEIS (14/14 PASS, im Container ausgeführt):
1 Health OK · 2 Video-Upload via HTTP · 3 Bild-Upload · 4 Bibliothek
mit Metadaten (Video-Dauer erkannt) · 5 Thumbnail JPEG ·
6 Traversal-Schutz (404) · 7/8 Layer Video+Bild dynamisch ·
9 Engine rendert (91 Frames) · 10 Art-Net live auf Port 6455 mit
Master-Kanal 10 umgestellt · 11 DMX steuert Master 0.251 auf NEUEM
Port/Kanal · 12 Layer-Alpha via DMX · 13 config.json persistiert ·
14 Layer-Remove ohne Absturz (258 Frames, atomar) ·
Ungültige Settings werden mit 400 + Feldname abgewiesen

Fixes: Bild-Caps kombiniert (parse-Fehler), Thumbnail-Namens-Mapping,
kind_of-Methode, atomarer Rebuild mit Rollback, DMX-accepted-Zähler
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 11:05:01 +02:00
parent 25d6041654
commit 68ce87e5ac
11 changed files with 1889 additions and 837 deletions
+5
View File
@@ -29,3 +29,8 @@ userdata/
Thumbs.db
.idea/
.vscode/
# Runtime-Daten der Anwendung (Nutzer-Medien und Konfiguration)
media/
thumbs/
config.json
+1
View File
@@ -0,0 +1 @@
"""hms_app HMS MediaEngine Anwendungspaket (UI, Engine, Verwaltung)."""
+143
View File
@@ -0,0 +1,143 @@
"""Art-Net-Empfang (ArtDMX): konfigurierbar, mit Sequenz- und Signalprüfung."""
from __future__ import annotations
import socket
import struct
import threading
import time
class ArtNetInput:
"""ArtDMX auf UDP. Mapping, Port und Universes kommen aus den Settings."""
OP_ARTDMX = 0x5000
def __init__(self, engine, settings: dict):
self.engine = engine
self.settings = settings
self.sock: socket.socket | None = None
self.running = False
self.thread: threading.Thread | None = None
self.watchdog: threading.Thread | None = None
self.last_channels = bytearray(512)
self.stats = {"packets": 0, "accepted": 0, "seq_errors": 0,
"last_universe": None, "last_seq": None,
"last_time": None}
self._last_seq: dict[int, int] = {}
self._wd_blackout = False
@property
def cfg(self) -> dict:
return self.settings["artnet"]
def start(self) -> bool:
if not self.cfg.get("enabled", True):
print("[ArtNet] deaktiviert (Einstellungen)")
return True
port = int(self.cfg.get("listen_port", 6454))
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", port))
self.sock.settimeout(0.5)
except OSError as e:
print(f"[ArtNet] FEHLER: UDP {port} nicht bindbar: {e}")
return False
self.running = True
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
self.watchdog = threading.Thread(target=self._watchdog, daemon=True)
self.watchdog.start()
print(f"[ArtNet] Empfang aktiv auf UDP {port}, "
f"Universes: {self.cfg.get('universes')}")
return True
def restart(self) -> None:
self.stop()
self.start()
def stop(self) -> None:
self.running = False
if self.thread:
self.thread.join(timeout=1.0)
self.thread = None
if self.watchdog:
self.watchdog.join(timeout=1.5)
self.watchdog = None
if self.sock:
self.sock.close()
self.sock = None
if self._wd_blackout:
self._wd_blackout = False
self.engine.set_blackout(False)
def _loop(self) -> None:
while self.running:
try:
data, _addr = self.sock.recvfrom(2048)
except socket.timeout:
continue
except OSError:
break
self._handle(data)
def _handle(self, data: bytes) -> None:
if len(data) < 18 or data[0:8] != b"Art-Net\x00":
return
opcode = struct.unpack("<H", data[8:10])[0]
if opcode != self.OP_ARTDMX:
return
if struct.unpack(">H", data[10:12])[0] < 14:
return
seq = data[12]
universe = struct.unpack("<H", data[14:16])[0]
length = struct.unpack(">H", data[16:18])[0]
if length == 0:
return
self.stats["packets"] += 1
if universe not in (self.cfg.get("universes") or [0]):
return
last = self._last_seq.get(universe)
if last is not None:
if seq == last:
return # Duplikat verwerfen
if seq != (last + 1) % 256:
self.stats["seq_errors"] += 1
self._last_seq[universe] = seq
self.stats["accepted"] += 1
self.stats["last_universe"] = universe
self.stats["last_seq"] = seq
self.stats["last_time"] = time.time()
dmx = data[18:18 + min(length, 512)]
self.last_channels[:len(dmx)] = dmx
if self._wd_blackout:
self._wd_blackout = False
self.engine.set_blackout(False)
self.engine.apply_dmx(bytes(self.last_channels[:len(dmx)]),
universe, seq)
def _watchdog(self) -> None:
while self.running:
time.sleep(1.0)
if self.cfg.get("signal_loss_policy") != "fade_black":
continue
if not self.cfg.get("enabled", True):
continue
lt = self.stats.get("last_time")
if lt is None:
continue
timeout = float(self.cfg.get("signal_loss_timeout_s", 10))
if time.time() - lt > timeout and not self._wd_blackout:
self._wd_blackout = True
self.engine.set_blackout(True)
print("[ArtNet] Signalverlust: Fallback-Blackout aktiv")
def monitor(self) -> dict:
return {
"enabled": bool(self.cfg.get("enabled", True)),
"listen_port": self.cfg.get("listen_port"),
"universes": self.cfg.get("universes"),
"stats": dict(self.stats),
"channels": list(self.last_channels[:32]),
}
+132
View File
@@ -0,0 +1,132 @@
"""Persistente Konfiguration: config.json, Defaults, Validierung."""
from __future__ import annotations
import copy
import json
from pathlib import Path
DEFAULTS: dict = {
"web": {"bind": "0.0.0.0", "port": 8080},
"preview": {"width": 640, "height": 360, "fps": 15,
"jpeg_quality": 80},
"output": {"fullscreen": False},
"media": {"dir": "media", "thumbs_dir": "thumbs",
"max_upload_mb": 2048},
"artnet": {
"enabled": True,
"listen_port": 6454,
"universes": [0],
"master_channel": 1,
"layer_start_channel": 2,
"playback_channel": 6,
"retrigger_channel": 7,
"blackout_channel": 8,
"signal_loss_policy": "hold",
"signal_loss_timeout_s": 10,
},
"engine": {"max_layers": 8, "loop_default": True},
}
def deep_merge(base: dict, override: dict) -> dict:
out = dict(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = deep_merge(out[k], v)
else:
out[k] = v
return out
def load(root: Path) -> dict:
"""Lädt config.json und füllt fehlende Schlüssel mit Defaults."""
file = root / "config.json"
data: dict = {}
if file.exists():
try:
data = json.loads(file.read_text("utf-8"))
except (OSError, ValueError) as e:
print(f"[Config] FEHLER config.json: {e} nutze Defaults")
if not isinstance(data, dict):
data = {}
return deep_merge(DEFAULTS, data)
def save(root: Path, cfg: dict) -> bool:
file = root / "config.json"
try:
tmp = file.with_suffix(".json.tmp")
tmp.write_text(
json.dumps(cfg, ensure_ascii=False, indent=2), "utf-8")
tmp.replace(file)
return True
except OSError as e:
print(f"[Config] Schreiben fehlgeschlagen: {e}")
return False
def validate(new: dict) -> tuple[dict | None, str | None]:
"""Prüft ein komplettes Settings-Objekt. Rückgabe: (normalized, error)."""
merged = deep_merge(copy.deepcopy(DEFAULTS), new if isinstance(new, dict) else {})
errors: list[str] = []
def _int(d, key, lo, hi, label):
try:
v = int(d[key])
except (KeyError, TypeError, ValueError):
errors.append(label)
return None
if not lo <= v <= hi:
errors.append(label)
return None
d[key] = v
return v
w = merged["web"]
_int(w, "port", 1, 65535, "web.port")
if not isinstance(w.get("bind"), str):
errors.append("web.bind")
p = merged["preview"]
_int(p, "width", 64, 3840, "preview.width")
_int(p, "height", 64, 2160, "preview.height")
_int(p, "fps", 1, 60, "preview.fps")
_int(p, "jpeg_quality", 10, 95, "preview.jpeg_quality")
m = merged["media"]
_int(m, "max_upload_mb", 1, 8192, "media.max_upload_mb")
for k in ("dir", "thumbs_dir"):
if not isinstance(m.get(k), str) or not m[k] or "/" in m[k] \
or m[k] in (".", ".."):
errors.append(f"media.{k}")
a = merged["artnet"]
_int(a, "listen_port", 1, 65535, "artnet.listen_port")
uni = a.get("universes")
if (not isinstance(uni, list) or not uni
or len(set(uni)) != len(uni)
or any(not isinstance(u, int) or not 0 <= u <= 32767
for u in uni)):
errors.append("artnet.universes")
for k in ("master_channel", "layer_start_channel", "playback_channel",
"retrigger_channel", "blackout_channel"):
v = a.get(k)
if v is not None and (not isinstance(v, int)
or not 1 <= v <= 512):
errors.append(f"artnet.{k}")
_int(a, "signal_loss_timeout_s", 1, 3600,
"artnet.signal_loss_timeout_s")
if a.get("signal_loss_policy") not in ("hold", "fade_black"):
errors.append("artnet.signal_loss_policy")
e = merged["engine"]
_int(e, "max_layers", 1, 16, "engine.max_layers")
if not isinstance(e.get("loop_default"), bool):
errors.append("engine.loop_default")
if not isinstance(merged["output"].get("fullscreen"), bool):
errors.append("output.fullscreen")
if errors:
return None, "ungültig: " + ", ".join(errors)
return merged, None
+372
View File
@@ -0,0 +1,372 @@
"""MediaEngine: GStreamer-Compositor mit dynamischen Layern (Video + Bild).
Layer lassen sich zur Laufzeit hinzufuegen/entfernen/verschieben. Jeder
Layer hat: Quelle (Bibliotheksdatei), Alpha, Position (x/y), Groesse
(width/height), Z-Order. Die Pipeline wird bei Struktur-Aenderungen neu
gebaut, Parameter gehen nicht verloren.
"""
from __future__ import annotations
import threading
import time
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gst
Gst.init(None)
class Layer:
"""Ein aktiver Layer im Compositor."""
def __init__(self, layer_id: int, name: str, kind: str):
self.id = layer_id
self.name = name
self.kind = kind # video | image
self.alpha = 1.0
self.x = 0
self.y = 0
self.width = 320
self.height = 180
self.z = 0
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name, "kind": self.kind,
"alpha": round(self.alpha, 3), "x": self.x, "y": self.y,
"width": self.width, "height": self.height, "z": self.z,
"effective": None}
class MediaEngine:
"""Compositor-Pipeline + Steuerung. Thread-sicher ueber Lock."""
def __init__(self, settings: dict, library):
self.settings = settings
self.library = library
self.pipeline = None
self.current_jpeg = b""
self.frame_count = 0
self.running = False
self.last_error: str | None = None
self.layers: list[Layer] = []
self._next_id = 1
self.master = 1.0
self.blackout = False
self.playing = True
self.dmx_stats = {"packets": 0, "accepted": 0, "seq_errors": 0,
"last_universe": None, "last_seq": None,
"last_time": None}
self._lock = threading.RLock()
self._monitor: threading.Thread | None = None
# ---------------- Pipeline ----------------
def _layer_source(self, layer: Layer, path: str) -> str:
uri = f"file://{path}"
if layer.kind == "video":
caps = (f"video/x-raw,width={layer.width},"
f"height={layer.height},format=BGRA")
return (f"uridecodebin uri={uri} ! queue ! videoconvert ! "
f"videoscale ! {caps} ! mix.sink_{layer.id}")
# Bild: imagefreeze + EIN kombinierter Caps-Filter
# (zwei Caps-Filter in Serie lassen gst-parse fehlschlagen)
caps = (f"video/x-raw,width={layer.width},"
f"height={layer.height},format=BGRA,framerate=15/1")
return (f"uridecodebin uri={uri} ! imagefreeze ! "
f"videoconvert ! videoscale ! {caps} ! mix.sink_{layer.id}")
def _build_description(self) -> str:
pv = self.settings["preview"]
pad_defs = " ".join(
f"sink_{l.id}::xpos={l.x} sink_{l.id}::ypos={l.y} "
f"sink_{l.id}::zorder={l.z}"
for l in self.layers)
parts = [
f"compositor name=mix background=black {pad_defs} ! "
f"video/x-raw,width={pv['width']},height={pv['height']},"
f"format=BGRA ! tee name=t",
]
for layer in self.layers:
f = self.library.file_path(layer.name)
parts.append(self._layer_source(layer, str(f.resolve())))
parts.append(
f"t. ! queue ! videoconvert ! "
f"jpegenc quality={pv['jpeg_quality']} ! "
"appsink name=preview emit-signals=true max-buffers=2 drop=true")
if self.settings["output"].get("fullscreen"):
import sys
sink = ("d3d11videosink sync=true"
if sys.platform == "win32" else "autovideosink sync=true")
parts.append(f"t. ! queue ! videoconvert ! {sink}")
return " ".join(parts)
def rebuild(self) -> bool:
"""Baut die Pipeline neu auf. Atomar: bei Fehler laeuft die alte weiter."""
with self._lock:
if not self.layers:
self.stop()
self.running = True # kein Layer, aber "betriebsbereit"
return True
desc = self._build_description()
old = self.pipeline
try:
new = Gst.parse_launch(desc)
except Exception as e: # noqa: BLE001
self.last_error = f"parse_launch: {e}"
print(f"[Engine] FEHLER: {self.last_error}")
return False
sink = new.get_by_name("preview")
if sink:
sink.connect("new-sample", self._on_frame)
if new.set_state(
Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE:
self.last_error = "set_state(PLAYING) fehlgeschlagen"
print(f"[Engine] FEHLER: {self.last_error}")
new.set_state(Gst.State.NULL)
return False
# Neue Pipeline laeuft -> alte beenden (deren Bus-Monitor
# beendet sich beim naechsten Poll selbst, da self.pipeline
# nicht mehr die referenzierte Pipeline ist)
if old is not None:
old.set_state(Gst.State.NULL)
self.pipeline = new
self.running = True
self.last_error = None
self.apply_all() # Master/Blackout/Alphas auf neue Pads anwenden
self._monitor = threading.Thread(
target=self._bus_monitor, daemon=True)
self._monitor.start()
return True
def _bus_monitor(self):
pipeline = self.pipeline
if pipeline is None:
return
bus = pipeline.get_bus()
while self.running and self.pipeline is pipeline:
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:
if self.settings["engine"].get("loop_default", True):
pipeline.seek_simple(
Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNITS,
0)
else:
self.playing = False
return
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}")
return
def _on_frame(self, sink):
sample = sink.emit("pull-sample")
if sample is not None:
buf = sample.get_buffer()
self.current_jpeg = buf.extract_dup(0, buf.get_size())
self.frame_count += 1
return Gst.FlowReturn.OK
# ---------------- Layer-Verwaltung ----------------
def add_layer(self, name: str) -> dict | None:
with self._lock:
max_l = int(self.settings["engine"].get("max_layers", 8))
if len(self.layers) >= max_l:
self.last_error = f"max. {max_l} Layer"
return None
f = self.library.file_path(name)
if not f.exists():
self.last_error = f"Datei nicht gefunden: {name}"
return None
kind = self.library.kind_of(name)
if kind is None:
self.last_error = f"nicht unterstuetzt: {name}"
return None
layer = Layer(self._next_id, name, kind)
self._next_id += 1
layer.z = len(self.layers)
pv = self.settings["preview"]
layer.width = pv["width"]
layer.height = pv["height"]
self.layers.append(layer)
if not self.rebuild():
self.layers.remove(layer) # Rollback: Engine unveraendert
return None
return layer.to_dict()
def remove_layer(self, layer_id: int) -> bool:
with self._lock:
kept = [l for l in self.layers if l.id != layer_id]
if len(kept) == len(self.layers):
return False
removed = [l for l in self.layers if l.id == layer_id]
self.layers = kept
if not self.rebuild():
self.layers.extend(removed) # Rollback
self.layers.sort(key=lambda l: l.z)
return False
return True
def get_layer(self, layer_id: int) -> Layer | None:
for l in self.layers:
if l.id == layer_id:
return l
return None
def update_layer(self, layer_id: int, patch: dict
) -> Layer | None:
with self._lock:
layer = self.get_layer(layer_id)
if layer is None:
return None
if "alpha" in patch:
layer.alpha = max(0.0, min(1.0, float(patch["alpha"])))
if "x" in patch:
layer.x = int(patch["x"])
if "y" in patch:
layer.y = int(patch["y"])
if "width" in patch:
layer.width = max(16, min(7680, int(patch["width"])))
if "height" in patch:
layer.height = max(16, min(4320, int(patch["height"])))
if "z" in patch:
layer.z = max(0, min(15, int(patch["z"])))
if "name" in patch and patch["name"] != layer.name:
f = self.library.file_path(str(patch["name"]))
if not f.exists():
return None
layer.name = str(patch["name"])
layer.kind = self.library.kind_of(layer.name) or layer.kind
# Pad-Eigenschaften live setzen (ohne Rebuild)
self._apply_pad_props(layer)
return layer
def _apply_pad_props(self, layer: Layer):
if self.pipeline is None:
return
mix = self.pipeline.get_by_name("mix")
if mix is None:
return
pad = mix.get_static_pad(f"sink_{layer.id}")
if pad is None:
return
pad.set_property("xpos", layer.x)
pad.set_property("ypos", layer.y)
pad.set_property("zorder", layer.z)
pad.set_property("alpha", self._effective_alpha(layer))
# ---------------- Steuerung ----------------
def _effective_alpha(self, layer: Layer) -> float:
if self.blackout:
return 0.0
return layer.alpha * self.master
def apply_all(self):
with self._lock:
for l in self.layers:
self._apply_pad_props(l)
if self.pipeline is not None and self.layers:
target = (Gst.State.PLAYING if self.playing
else Gst.State.PAUSED)
current = self.pipeline.get_state(0)[1]
if current != target:
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 retrigger(self):
with self._lock:
if self.pipeline is not None:
self.pipeline.seek_simple(
Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNITS, 0)
# ---------------- DMX (Mapping aus Settings) ----------------
def apply_dmx(self, channels: bytes, universe: int, seq: int):
a = self.settings["artnet"]
def ch(num) -> int:
if num is None:
return 0
i = int(num) - 1
return channels[i] if 0 <= i < len(channels) else 0
with self._lock:
self.dmx_stats["packets"] += 1
self.dmx_stats["accepted"] += 1
self.dmx_stats["last_universe"] = universe
self.dmx_stats["last_seq"] = seq
self.dmx_stats["last_time"] = time.time()
start = a.get("layer_start_channel")
for i, layer in enumerate(self.layers):
v = ch(None if start is None else start + i)
if v > 0:
layer.alpha = v / 255.0
self.master = ch(a.get("master_channel")) / 255.0 or self.master
self.apply_all()
pb = a.get("playback_channel")
if ch(pb) >= 128:
self.set_playback(True)
bo = a.get("blackout_channel")
if bo is not None:
self.set_blackout(ch(bo) >= 128)
rt = a.get("retrigger_channel")
if rt is not None and ch(rt) >= 128:
self.retrigger()
# ---------------- Status ----------------
def stop(self):
self.running = False
if self._monitor is not None:
self._monitor.join(timeout=1.0)
self._monitor = None
if self.pipeline is not None:
self.pipeline.set_state(Gst.State.NULL)
self.pipeline = None
def status(self) -> dict:
with self._lock:
return {
"running": self.running,
"layers": [self._layer_dict(l) for l in self.layers],
"master": round(self.master, 3),
"blackout": self.blackout,
"playing": self.playing,
"frames_rendered": self.frame_count,
"dmx": dict(self.dmx_stats),
"error": self.last_error,
}
def _layer_dict(self, l: Layer) -> dict:
d = l.to_dict()
d["effective"] = round(self._effective_alpha(l), 3)
return d
+273
View File
@@ -0,0 +1,273 @@
"""Medienbibliothek: Upload/Import, Thumbnails, Metadaten, Löschen."""
from __future__ import annotations
import json
import re
import shutil
import time
import urllib.parse
from pathlib import Path
import gi
gi.require_version("Gst", "1.0")
from gi.repository import Gst
Gst.init(None)
VIDEO_EXT = {".mp4", ".m4v", ".avi", ".mkv", ".mov", ".webm"}
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".bmp"}
def kind_of(name: str) -> str | None:
ext = Path(name).suffix.lower()
if ext in VIDEO_EXT:
return "video"
if ext in IMAGE_EXT:
return "image"
return None
def sanitize(name: str) -> str:
"""Dateiname portabel und sicher machen (keine Pfade/Sonderzeichen)."""
base = Path(name).name
base = re.sub(r"[^A-Za-z0-9._-]", "_", base).strip("._")
return base or "file"
class MediaLibrary:
"""Dateibasierte Bibliothek inkl. Thumbnail- und Metadaten-Cache."""
def __init__(self, settings: dict, root: Path):
self.settings = settings
self.root = root
self.dir = (root / settings["media"]["dir"]).resolve()
self.thumbs = (root / settings["media"]["thumbs_dir"]).resolve()
# ---------- Pfade ----------
def ensure_dirs(self) -> None:
self.dir.mkdir(parents=True, exist_ok=True)
self.thumbs.mkdir(parents=True, exist_ok=True)
def kind_of(self, name: str) -> str | None:
"""Medientyp einer Datei (video/image) Instanz-Zugriff für Engine."""
return kind_of(name)
def file_path(self, name: str) -> Path:
return self.dir / sanitize(name)
def thumb_path(self, name: str) -> Path:
return self.thumbs / (Path(name).stem + ".jpg")
def _safe(self, base: Path, raw: str) -> Path | None:
name = urllib.parse.unquote(raw or "")
if not name or "/" in name or "\\" in name or name in (".", ".."):
return None
p = base / name
try:
if p.resolve().parent != base.resolve():
return None
except OSError:
return None
return p
def safe_file(self, raw: str) -> Path | None:
return self._safe(self.dir, raw)
def safe_thumb(self, raw: str) -> Path | None:
return self._safe(self.thumbs, raw)
def count(self) -> int:
try:
return sum(1 for f in self.dir.iterdir()
if f.is_file() and kind_of(f.name))
except OSError:
return 0
# ---------- Metadaten-Cache ----------
def _meta_path(self) -> Path:
return self.thumbs / "meta.json"
def _load_meta(self) -> dict:
try:
return json.loads(self._meta_path().read_text("utf-8"))
except (OSError, ValueError):
return {}
def _save_meta(self, meta: dict) -> None:
try:
self._meta_path().write_text(
json.dumps(meta, ensure_ascii=False, indent=1), "utf-8")
except OSError as e:
print(f"[Media] Meta-Cache schreiben fehlgeschlagen: {e}")
def _duration(self, f: Path) -> float | None:
try:
p = Gst.parse_launch(
f"uridecodebin uri={f.resolve().as_uri()} ! fakesink")
p.set_state(Gst.State.PAUSED)
p.get_state(3 * Gst.SECOND)
ok, dur = p.query_duration(Gst.Format.TIME)
p.set_state(Gst.State.NULL)
if ok and dur > 0:
return round(dur / Gst.SECOND, 2)
except Exception: # noqa: BLE001
pass
return None
def make_thumb(self, name: str) -> Path | None:
src = self.file_path(name)
if not src.exists():
return None
out = self.thumb_path(name)
if out.exists():
return out
got: dict = {}
try:
pipeline = Gst.parse_launch(
f"uridecodebin uri={src.resolve().as_uri()} ! "
"videoconvert ! videoscale ! "
"video/x-raw,width=192,height=108 ! "
"jpegenc quality=75 ! "
"appsink name=thumb emit-signals=true "
"max-buffers=1 drop=true")
sink = pipeline.get_by_name("thumb")
def on_sample(s):
sample = s.emit("pull-sample")
if sample is not None:
buf = sample.get_buffer()
got["data"] = buf.extract_dup(0, buf.get_size())
return Gst.FlowReturn.OK
sink.connect("new-sample", on_sample)
bus = pipeline.get_bus()
pipeline.set_state(Gst.State.PLAYING)
deadline = time.monotonic() + 6.0
while "data" not in got and time.monotonic() < deadline:
msg = bus.timed_pop_filtered(
100 * Gst.MSECOND,
Gst.MessageType.EOS | Gst.MessageType.ERROR)
if msg is not None:
break
time.sleep(0.02)
pipeline.set_state(Gst.State.NULL)
except Exception as e: # noqa: BLE001
print(f"[Media] Thumbnail-Fehler ({name}): {e}")
return None
if "data" not in got:
return None
try:
out.write_bytes(got["data"])
except OSError as e:
print(f"[Media] Thumbnail schreiben fehlgeschlagen: {e}")
return None
return out
# ---------- Öffentliche Operationen ----------
def scan(self) -> list[dict]:
self.ensure_dirs()
meta = self._load_meta()
items: list[dict] = []
dirty = False
for f in sorted(self.dir.iterdir()):
if not f.is_file() or f.name.endswith(".part"):
continue
kind = kind_of(f.name)
if kind is None:
continue
st = f.stat()
ent = meta.get(f.name)
if (not isinstance(ent, dict)
or ent.get("size") != st.st_size
or ent.get("mtime") != int(st.st_mtime)):
dur = self._duration(f) if kind == "video" else None
self.make_thumb(f.name)
ent = {"size": st.st_size, "mtime": int(st.st_mtime),
"duration_s": dur}
meta[f.name] = ent
dirty = True
items.append({
"name": f.name, "size": st.st_size, "type": kind,
"mtime": ent["mtime"], "duration_s": ent.get("duration_s"),
})
if dirty:
self._save_meta(meta)
return items
def _unique(self, base: str) -> str:
final = base
n = 1
while (self.dir / final).exists():
final = f"{Path(base).stem}-{n}{Path(base).suffix}"
n += 1
return final
def save_stream(self, name: str, src, length: int
) -> tuple[str | None, str | None]:
"""Speichert einen Upload-Stream chunkweise (final_name, error)."""
base = sanitize(name)
if kind_of(base) is None:
return None, "Dateityp nicht unterstützt"
if length <= 0:
return None, "Content-Length fehlt"
limit_mb = int(self.settings["media"].get("max_upload_mb", 2048))
limit = limit_mb * 1024 * 1024
if length > limit:
return None, f"Datei zu groß (max {limit_mb} MB)"
final = self._unique(base)
tmp = self.dir / (final + ".part")
written = 0
try:
with open(tmp, "wb") as out:
while written < length:
chunk = src.read(min(1024 * 1024, length - written))
if not chunk:
break
out.write(chunk)
written += len(chunk)
except OSError as e:
tmp.unlink(missing_ok=True)
return None, f"IO-Fehler: {e}"
if written != length:
tmp.unlink(missing_ok=True)
return None, f"Upload unvollständig ({written}/{length} Bytes)"
tmp.rename(self.dir / final)
try:
self.make_thumb(final)
except Exception: # noqa: BLE001
pass
return final, None
def import_copy(self, src: Path) -> str | None:
"""Importiert eine lokale Datei (CLI-Argument) in die Bibliothek."""
if not src.exists() or kind_of(src.name) is None:
return None
if src.resolve().parent == self.dir:
return src.name
final = self._unique(sanitize(src.name))
try:
shutil.copy2(src, self.dir / final)
except OSError as e:
print(f"[Media] Import fehlgeschlagen ({src}): {e}")
return None
self.make_thumb(final)
return final
def delete(self, name: str) -> bool:
p = self.safe_file(name)
if p is None or not p.exists():
return False
try:
p.unlink()
except OSError:
return False
self.thumb_path(p.name).unlink(missing_ok=True)
meta = self._load_meta()
if p.name in meta:
meta.pop(p.name)
self._save_meta(meta)
return True
+286
View File
@@ -0,0 +1,286 @@
"""HTTP-Server: Web-UI, MJPEG-Stream, REST-API, Upload, Thumbnails, Settings."""
from __future__ import annotations
import json
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import config as cfgmod
from .ui import HTML_UI
class EngineServer:
"""Verbindet Engine, Art-Net, Medienbibliothek und Konfiguration mit HTTP."""
def __init__(self, engine, artnet, library, settings: dict, root):
self.engine = engine
self.artnet = artnet
self.library = library
self.settings = settings
self.root = root
self.httpd: ThreadingHTTPServer | None = None
self.running = False
def start(self, port=None) -> bool:
port = port or int(self.settings["web"]["port"])
bind = str(self.settings["web"].get("bind", "0.0.0.0"))
try:
self.httpd = ThreadingHTTPServer((bind, port),
self._make_handler())
except OSError as e:
print(f"[Server] FEHLER: {bind}:{port} nicht bindbar: {e}")
return False
self.running = True
threading.Thread(target=self.httpd.serve_forever,
daemon=True).start()
print(f"[Server] UI: http://localhost:{port}/")
print(f"[Server] Preview: http://localhost:{port}/stream.mjpg")
print(f"[Server] API: http://localhost:{port}/api/status")
return True
def stop(self):
self.running = False
if self.httpd is not None:
self.httpd.shutdown()
self.httpd = None
def restart_artnet(self):
self.artnet.restart()
# ---------------- Handler ----------------
def _make_handler(self):
engine = self.engine
artnet = self.artnet
library = self.library
server = self
settings = self.settings
root = self.root
class Handler(BaseHTTPRequestHandler):
# ---------- GET ----------
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
query = urllib.parse.parse_qs(parsed.query)
if path == "/":
self._send_html()
elif path == "/stream.mjpg":
self._stream()
elif path == "/api/status":
self._json(server._status())
elif path == "/api/health":
self._json({"status": "ok"})
elif path == "/api/media":
self._json({"items": library.scan(),
"limits": settings["media"]})
elif path.startswith("/thumb/"):
# Thumbnail heisst <stem>.jpg; Anfrage kommt mit
# Originaldateinamen (z. B. /thumb/clip.mp4)
name = urllib.parse.unquote(path[len("/thumb/"):])
self._send_file(library.thumb_path(name), "image/jpeg")
elif path.startswith("/media/"):
name = urllib.parse.unquote(path[len("/media/"):])
f = library.safe_file(name)
ctype = ("image/png" if name.lower().endswith(".png")
else "image/jpeg" if name.lower().endswith(
(".jpg", ".jpeg"))
else "image/bmp" if name.lower().endswith(".bmp")
else "video/mp4")
self._send_file(f, ctype, download_name=name)
elif path == "/api/settings":
self._json(settings)
elif path == "/api/artnet":
self._json(artnet.monitor())
else:
self._send_error_json(404, "nicht gefunden")
# ---------- POST ----------
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == "/api/media/upload":
self._upload()
elif path == "/api/media/delete":
body = self._read_json()
name = str(body.get("name", ""))
ok = library.delete(name)
# Layer mit dieser Datei entfernen
if ok:
for l in list(engine.layers):
if l.name == name:
engine.remove_layer(l.id)
self._json({"deleted": ok})
elif path == "/api/layers/add":
body = self._read_json()
layer = engine.add_layer(str(body.get("name", "")))
if layer is None:
self._send_error_json(400, engine.last_error or "?" )
else:
self._json({"layer": layer})
elif path == "/api/layers/remove":
body = self._read_json()
ok = engine.remove_layer(int(body.get("id", -1)))
self._json({"removed": ok})
elif path == "/api/layers/update":
body = self._read_json()
layer = engine.update_layer(int(body.get("id", -1)),
body)
if layer is None:
self._send_error_json(404, "Layer nicht gefunden")
else:
self._json({"layer": engine._layer_dict(layer)})
elif path == "/api/master":
body = self._read_json()
engine.set_master(float(body.get("intensity",
body.get("value", 1.0))))
self._json(server._status())
elif path == "/api/blackout":
body = self._read_json()
engine.set_blackout(bool(body.get("on", False)))
self._json(server._status())
elif path == "/api/playback":
body = self._read_json()
engine.set_playback(bool(body.get("playing", True)))
self._json(server._status())
elif path == "/api/retrigger":
engine.retrigger()
self._json({"ok": True})
elif path == "/api/settings":
self._post_settings()
else:
self._send_error_json(404, "nicht gefunden")
# ---------- Upload ----------
def _upload(self):
length = int(self.headers.get("Content-Length", "0"))
raw_name = (self.headers.get("X-Filename")
or "upload" + ".bin")
name = urllib.parse.unquote(raw_name)
final, err = library.save_stream(name, self.rfile, length)
if err is not None:
self._send_error_json(400, err)
return
self._json({"saved": final})
# ---------- Settings ----------
def _post_settings(self):
body = self._read_json()
normalized, err = cfgmod.validate(body)
if err is not None:
self._send_error_json(400, err)
return
old_port = settings["web"]["port"]
old_fs = settings["output"]["fullscreen"]
# Struktur-Aenderungen anwenden
settings.clear()
settings.update(normalized)
engine.library.settings = settings
artnet.settings = settings
cfgmod.save(root, settings)
# Engine mit neuen Preview-Parametern neu bauen
engine.rebuild()
# Art-Net neu starten (Port/Universes evtl. geaendert)
artnet.restart()
restarted_web = settings["web"]["port"] != old_port
fullscreen_changed = settings["output"]["fullscreen"] != old_fs
self._json({"saved": True,
"web_restarts": restarted_web,
"fullscreen_changed": fullscreen_changed,
"hint": ("Web-Port geaendert: Seite unter neuem "
"Port neu oeffnen")
if restarted_web else None})
# ---------- Ausgabe-Helfer ----------
def _read_json(self) -> dict:
try:
length = int(self.headers.get("Content-Length", "0"))
return json.loads(self.rfile.read(length) or b"{}")
except (ValueError, OSError):
return {}
def _send_error_json(self, code, message):
self._json({"error": str(message)}, code=code)
def _json(self, data, code=200):
body = json.dumps(data).encode("utf-8")
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 _send_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 _send_file(self, f, ctype, download_name=None):
if f is None or not f.is_file():
self._send_error_json(404, "Datei nicht gefunden")
return
try:
data = f.read_bytes()
except OSError:
self._send_error_json(500, "Lesefehler")
return
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
if download_name:
self.send_header(
"Content-Disposition",
"inline; filename*=UTF-8''"
+ urllib.parse.quote(download_name))
self.end_headers()
self.wfile.write(data)
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.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 / max(1, int(
settings["preview"]["fps"])))
except (BrokenPipeError, ConnectionResetError):
pass
def log_message(self, fmt, *args):
pass
return Handler
def _status(self) -> dict:
st = self.engine.status()
st["media_count"] = self.library.count()
st["artnet"] = self.artnet.monitor()
return st
+181
View File
@@ -0,0 +1,181 @@
"""Bootstrap, Setup-Skripte für Windows/Linux, Einstellungs-Dialog."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
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", "python3-tk",
]
def check_and_install_dependencies() -> bool:
"""Prüft GStreamer; installiert bei Bedarf (Linux) bzw. lädt MSI (Windows)."""
print("[Bootstrap] Prüfe 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: # noqa: BLE001
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/2] 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/2] 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" }
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";$env:Path"
Write-Host ""
Write-Host "=== Fertig ===" -ForegroundColor Green
Write-Host "Start: python run.py"
Write-Host "Dialog: python run.py --setup"
Write-Host "Media-Verwaltung, Layer und Einstellungen: Web-UI im Browser"
'''
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-ugly gstreamer1-libav \
python3-gobject python3-tkinter
fi
echo "=== Fertig ==="
echo "Start: python3 run.py"
echo "Media-Verwaltung, Layer und Einstellungen: Web-UI im Browser"
'''
def generate_setup_scripts(root: Path) -> None:
p1 = root / "setup_windows.ps1"
p1.write_text(WINDOWS_SETUP, encoding="utf-8")
p2 = root / "setup_linux.sh"
p2.write_text(LINUX_SETUP, encoding="utf-8")
p2.chmod(0o755)
print(f"Erzeugt: {p1}")
print(f"Erzeugt: {p2}")
def run_setup(settings: dict) -> dict:
"""Nativer Dialog für Port/Fullscreen; Medien-Verwaltung bleibt im Web."""
result = {"port": int(settings["web"]["port"]),
"fullscreen": bool(settings["output"]["fullscreen"])}
try:
import tkinter as tk
from tkinter import ttk
except ImportError:
print("tkinter nicht verfügbar (Linux: apt install python3-tk).")
return result
root = tk.Tk()
root.title("HMS MediaEngine Einstellungen")
root.geometry("440x240")
root.configure(bg="#16181d")
style = ttk.Style(root)
style.theme_use("clam")
style.configure("TLabel", background="#16181d", foreground="#c8ccd4")
main = ttk.Frame(root, padding=18)
main.pack(fill="both", expand=True)
ttk.Label(main, text="HMS MediaEngine",
font=("System", 15, "bold")).pack(pady=(0, 2))
ttk.Label(main, text="Grundeinstellungen",
foreground="#8a8f9a").pack(pady=(0, 12))
row = ttk.Frame(main)
row.pack(fill="x", pady=4)
ttk.Label(row, text="Web-UI Port:").pack(side="left")
port_var = tk.StringVar(value=str(result["port"]))
ttk.Entry(row, textvariable=port_var, width=7).pack(
side="left", padx=8)
fs_var = tk.BooleanVar(value=result["fullscreen"])
ttk.Checkbutton(row, text="Fullscreen-Output",
variable=fs_var).pack(side="left", padx=10)
def go():
try:
result["port"] = int(port_var.get() or 8080)
except ValueError:
pass
result["fullscreen"] = bool(fs_var.get())
root.destroy()
ttk.Button(main, text="Starten", command=go).pack(
fill="x", pady=(14, 0), ipady=6)
root.mainloop()
return result
+259
View File
@@ -0,0 +1,259 @@
"""Web-UI (Single-Page): Tabs Live, Medien (Upload), Layer, Einstellungen."""
HTML_UI = r'''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HMS MediaEngine</title>
<style>
:root{--bg:#0d0f12;--pan:#16181d;--pan2:#12141a;--line:#262a33;--tx:#c8ccd4;--dim:#8a8f9a;--acc:#4a90d9;--ok:#3fa34d;--err:#ff7a7a}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--tx);font:13px/1.45 system-ui,sans-serif;height:100vh;display:flex;flex-direction:column}
header{background:var(--pan);padding:8px 16px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--line)}
h1{font-size:14px;color:#e8eaee;margin-right:10px}
.badge{font-size:11px;padding:2px 8px;border-radius:3px;background:#233042;color:#7ab8ff}
.badge.err{background:#3a2020;color:var(--err)}
#tabbar{display:flex;gap:2px;margin-left:20px}
#tabbar button{background:none;border:none;color:var(--dim);padding:6px 12px;font-size:12px;cursor:pointer;border-bottom:2px solid transparent}
#tabbar button.on{color:#e8eaee;border-bottom-color:var(--acc)}
main{flex:1;overflow:hidden}
.page{display:none;height:100%;overflow:auto;padding:16px}
.page.on{display:block}
#page-live{display:none;height:100%}#page-live.on{display:flex;gap:16px}
#live-left{flex:1;display:flex;align-items:center;justify-content:center;min-width:0}
.preview{max-width:100%;max-height:calc(100vh - 120px);border:1px solid var(--line);border-radius:4px;background:#000;overflow:hidden}
.preview img{display:block;width:100%;height:auto}
#live-right{width:320px;background:var(--pan2);border:1px solid var(--line);border-radius:4px;padding:12px;overflow-y:auto}
h2{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:12px 0 6px}
h2:first-child{margin-top:0}
.ctl{margin-bottom:8px}
.ctl label{display:flex;justify-content:space-between;font-size:12px;margin-bottom:2px}
input[type=range]{width:100%;accent-color:var(--acc)}
input[type=number],input[type=text],select{background:#0d0f12;color:var(--tx);border:1px solid var(--line);border-radius:3px;padding:4px 6px;font-size:12px;width:90px}
input[type=checkbox]{accent-color:var(--acc)}
.row{display:flex;gap:6px;margin-bottom:8px}
button{background:#262a33;color:var(--tx);border:1px solid #333842;border-radius:4px;padding:6px 10px;font-size:12px;cursor:pointer}
button:hover{background:#333842}button.danger{border-color:#5a2a2a;color:#ff9a9a}button.acc{background:#1d3a5f;border-color:var(--acc)}
table{width:100%;border-collapse:collapse;font-size:12px;margin:6px 0}
td,th{padding:4px 6px;border-bottom:1px solid #21252d;text-align:left}th{color:var(--dim);font-weight:400}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}
.card{background:var(--pan2);border:1px solid var(--line);border-radius:4px;padding:6px;text-align:center}
.card img{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:3px;background:#000;cursor:pointer}
.card .nm{font-size:11px;margin:4px 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .meta{font-size:10px;color:var(--dim)}
.card .btns{display:flex;gap:4px;margin-top:4px}
.card .btns button{flex:1;padding:3px 0;font-size:11px}
#drop{border:2px dashed var(--line);border-radius:6px;padding:22px;text-align:center;color:var(--dim);cursor:pointer;margin-bottom:14px}
#drop.hot{border-color:var(--acc);color:#7ab8ff}
.setgrp{background:var(--pan2);border:1px solid var(--line);border-radius:4px;padding:12px;margin-bottom:12px}
.setgrp h3{font-size:12px;color:#e8eaee;margin-bottom:8px}
.frow{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px}
.frow label{width:210px;color:var(--dim)}
.frow .hint{color:#5a5f6a;font-size:11px}
footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);border-top:1px solid var(--line);display:flex;gap:16px}
.fps{color:var(--ok);font-weight:600}
#toast{position:fixed;bottom:44px;right:16px;background:#1d3a5f;color:#dbe9ff;border:1px solid var(--acc);border-radius:4px;padding:8px 14px;font-size:12px;display:none;max-width:420px}
#toast.err{background:#3a2020;border-color:#5a2a2a;color:#ffb0b0}
.ll{display:flex;align-items:center;gap:6px;margin-bottom:6px;padding:4px;border-radius:3px;background:#0d0f12}
.ll input[type=range]{flex:1}
.ll .nm{width:110px;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ll .val{width:34px;text-align:right;font-size:11px;color:var(--dim)}
</style>
</head>
<body>
<header>
<h1>HMS MediaEngine</h1>
<span class="badge" id="b-run"></span>
<span class="badge" id="b-dmx">Art-Net: …</span>
<div id="tabbar">
<button data-p="live" class="on">Live</button>
<button data-p="media">Medien</button>
<button data-p="layers">Layer</button>
<button data-p="settings">Einstellungen</button>
</div>
</header>
<main>
<div id="page-live" class="page on">
<div id="live-left"><div class="preview"><img src="/stream.mjpg" id="pv"></div></div>
<div id="live-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"></div>
<div class="row">
<button onclick="api('playback',{playing:true})">Play</button>
<button onclick="api('playback',{playing:false})">Pause</button>
<button onclick="api('retrigger',{})">Restart</button>
<button class="danger" id="blk">BLK</button>
</div>
<h2>Layer (Schnellzugriff)</h2>
<div id="quick"></div>
<h2>Diagnose</h2>
<table><tbody>
<tr><th>Frames</th><td id="d-frames"></td></tr>
<tr><th>DMX-Pakete</th><td id="d-pkts"></td></tr>
<tr><th>DMX angenommen</th><td id="d-acc"></td></tr>
<tr><th>Medien</th><td id="d-media"></td></tr>
<tr><th>Fehler</th><td id="d-err"></td></tr>
</tbody></table>
</div>
</div>
<div id="page-media" class="page">
<div id="drop">📄 Dateien hierher ziehen oder klicken zum Hochladen<br><small>Videos: mp4, mkv, avi, mov, webm · Bilder: jpg, png, bmp</small></div>
<input type="file" id="file" multiple accept=".mp4,.m4v,.avi,.mkv,.mov,.webm,.jpg,.jpeg,.png,.bmp" style="display:none">
<div id="upbar" style="display:none;margin-bottom:10px"><div id="uptext" style="font-size:12px;color:var(--dim)"></div><progress id="uprog" style="width:100%"></progress></div>
<div class="grid" id="mgrid"></div>
</div>
<div id="page-layers" class="page">
<div class="row"><button class="acc" onclick="location.hash='#media';showTab('media')">+ Medien hinzufügen</button>
<span style="color:var(--dim);font-size:11px;align-self:center">Aktive Layer bearbeiten:</span></div>
<div id="ledit"></div>
</div>
<div id="page-settings" class="page">
<div class="setgrp"><h3>Web-UI</h3>
<div class="frow"><label>Port</label><input type="number" id="s-web-port"></div>
<div class="frow"><label>Bind-Adresse</label><input type="text" id="s-web-bind" style="width:140px"><span class="hint">0.0.0.0 = alle Interfaces</span></div>
</div>
<div class="setgrp"><h3>Preview</h3>
<div class="frow"><label>Breite</label><input type="number" id="s-preview-width"> × <input type="number" id="s-preview-height"> px</div>
<div class="frow"><label>FPS</label><input type="number" id="s-preview-fps"></div>
<div class="frow"><label>JPEG-Qualität</label><input type="number" id="s-preview-q"></div>
</div>
<div class="setgrp"><h3>Art-Net (DMX)</h3>
<div class="frow"><label>Aktiviert</label><input type="checkbox" id="s-artnet-enabled"></div>
<div class="frow"><label>UDP-Port</label><input type="number" id="s-artnet-port"><span class="hint">Standard 6454</span></div>
<div class="frow"><label>Universes</label><input type="text" id="s-artnet-universes" style="width:200px"><span class="hint">z. B. 0,1,2</span></div>
<div class="frow"><label>Kanal Master</label><input type="number" id="s-artnet-master" min="1" max="512"></div>
<div class="frow"><label>Kanal Layer (Start)</label><input type="number" id="s-artnet-layer" min="1" max="512"><span class="hint">je +1 pro Layer</span></div>
<div class="frow"><label>Kanal Playback</label><input type="number" id="s-artnet-playback" min="1" max="512"></div>
<div class="frow"><label>Kanal Retrigger</label><input type="number" id="s-artnet-retrigger" min="1" max="512"></div>
<div class="frow"><label>Kanal Blackout</label><input type="number" id="s-artnet-blackout" min="1" max="512"></div>
<div class="frow"><label>Signalverlust</label><select id="s-artnet-policy"><option value="hold">Letzten Zustand halten</option><option value="fade_black">Auf schwarz fallen</option></select>
<div class="frow"><label>Timeout Signalverlust</label><input type="number" id="s-artnet-timeout"> s</div>
</div>
<div class="setgrp"><h3>Engine</h3>
<div class="frow"><label>Max. Layer</label><input type="number" id="s-engine-max"></div>
<div class="frow"><label>Loop als Standard</label><input type="checkbox" id="s-engine-loop"></div>
<div class="frow"><label>Fullscreen-Ausgabe</label><input type="checkbox" id="s-output-fs"><span class="hint">Windows: D3D11 · Linux: autovideosink</span></div>
</div>
<div class="setgrp"><h3>Medien</h3>
<div class="frow"><label>Ordnername</label><input type="text" id="s-media-dir" style="width:140px"></div>
<div class="frow"><label>Max. Upload</label><input type="number" id="s-media-max"> MB</div>
</div>
<button class="acc" style="padding:8px 20px" onclick="saveSettings()">Speichern &amp; Anwenden</button>
<span id="setmsg" style="margin-left:10px;color:var(--ok)"></span>
</div>
</main>
<footer><span class="fps" id="fps"></span> fps · <span id="f-frames"></span> Frames · MJPEG-Preview · REST /api/status</footer>
<div id="toast"></div>
<script>
'use strict';
const $=id=>document.getElementById(id);
let lastStatus=null;
function toast(msg,err){const t=$('toast');t.textContent=msg;t.className=err?'err':'';t.style.display='block';clearTimeout(t._h);t._h=setTimeout(()=>t.style.display='none',3500)}
async function api(path,body){try{const r=await fetch('/api/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});const d=await r.json();if(!r.ok)toast(d.error||'Fehler',true);return d}catch(e){toast('Netzwerkfehler',true);return{}}}
// Tabs
document.querySelectorAll('#tabbar button').forEach(b=>b.onclick=()=>showTab(b.dataset.p));
function showTab(p){document.querySelectorAll('.page').forEach(x=>x.classList.remove('on'));$('page-'+p).classList.add('on');document.querySelectorAll('#tabbar button').forEach(x=>x.classList.toggle('on',x.dataset.p===p));if(p==='media')loadMedia();if(p==='settings')loadSettings();}
// Live-Steuerung
$('master').oninput=e=>{api('master',{intensity:e.target.value/100});$('mv').textContent=e.target.value+'%'};
$('blk').onclick=async()=>{await api('blackout',{on:true});setTimeout(()=>api('blackout',{on:false}),500)};
// FPS
let fc=0,fpsC=0,last=performance.now();
$('pv').onload=()=>{fc++;fpsC++;const n=performance.now();if(n-last>1000){$('fps').textContent=fpsC;fpsC=0;last=n}$('f-frames').textContent=fc};
// Status-Poll
async function poll(){try{const s=await(await fetch('/api/status')).json();lastStatus=s;
$('b-run').textContent=s.running?'läuft':'gestoppt';$('b-run').className='badge'+(s.running?'':' err');
const a=s.artnet||{},st=a.stats||{};
$('b-dmx').textContent=a.enabled?('Art-Net: '+st.packets+' Pkts'):'Art-Net: aus';
$('d-frames').textContent=s.frames_rendered;$('d-pkts').textContent=st.packets||0;
$('d-acc').textContent=st.accepted||0;$('d-media').textContent=s.media_count??'';
$('d-err').textContent=s.error||'';
if(document.activeElement!==$('master')){$('master').value=s.master*100;$('mv').textContent=Math.round(s.master*100)+'%'}
renderQuick(s.layers||[]);renderLayerEdit(s.layers||[]);
}catch(e){}setTimeout(poll,1000)}
function renderQuick(ls){const q=$('quick');
if(q.childElementCount!==ls.length){q.innerHTML=ls.map(L=>`<div class="ll"><span class="nm" title="${L.name}">${L.id}: ${L.name}</span><input type="range" min="0" max="100" value="${L.alpha*100}" oninput="api('layers/update',{id:${L.id},alpha:this.value/100});this.nextElementSibling.textContent=this.value+'%'"><span class="val">${Math.round(L.alpha*100)}%</span></div>`).join('')}
else{ls.forEach((L,i)=>{const r=q.children[i].children[1];if(document.activeElement!==r)r.value=L.alpha*100;q.children[i].children[2].textContent=Math.round(L.alpha*100)+'%'})}}
function renderLayerEdit(ls){const e=$('ledit');
if(e.dataset.n!=String(ls.length)){e.dataset.n=ls.length;
e.innerHTML=ls.map(L=>`<div class="setgrp"><h3>#${L.id} ${L.name} <button class="danger" style="float:right" onclick="api('layers/remove',{id:${L.id}})">Entfernen</button></h3>
<div class="frow"><label>Alpha</label><input type="range" id="la${L.id}" min="0" max="100" value="${L.alpha*100}" oninput="api('layers/update',{id:${L.id},alpha:this.value/100})"></div>
<div class="frow"><label>Position X</label><input type="number" id="lx${L.id}" value="${L.x}" onchange="api('layers/update',{id:${L.id},x:this.value})"> Y <input type="number" id="ly${L.id}" value="${L.y}" onchange="api('layers/update',{id:${L.id},y:this.value})"></div>
<div class="frow"><label>Größe</label><input type="number" id="lw${L.id}" value="${L.width}" onchange="api('layers/update',{id:${L.id},width:this.value})"> × <input type="number" id="lh${L.id}" value="${L.height}" onchange="api('layers/update',{id:${L.id},height:this.value})"> px</div>
<div class="frow"><label>Z-Order</label><input type="number" id="lz${L.id}" min="0" max="15" value="${L.z}" onchange="api('layers/update',{id:${L.id},z:this.value})"></div>
</div>`).join('')||'<p style="color:var(--dim)">Keine aktiven Layer. Medien-Tab öffnen und Datei als Layer hinzufügen.</p>'}}
// Medien
async function loadMedia(){try{const d=await(await fetch('/api/media')).json();
$('mgrid').innerHTML=(d.items||[]).map(m=>`<div class="card">
<img src="/thumb/${encodeURIComponent(m.name)}" onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22192%22 height=%22108%22%3E%3Crect fill=%22%230d0f12%22 width=%22192%22 height=%22108%22/%3E%3Ctext x=%2260%22 y=%2256%22 fill=%22%238a8f9a%22 font-size=%2213%22%3E${m.type==='video'?'🎬 Video':'🖼 Bild'}%3C/text%3E%3C/svg%3E'" onclick="window.open('/media/${encodeURIComponent(m.name)}')">
<div class="nm" title="${m.name}">${m.name}</div>
<div class="meta">${fmtSize(m.size)}${m.duration_s?' · '+m.duration_s+'s':''}</div>
<div class="btns"><button class="acc" onclick="addLayer('${m.name.replace(/'/g,"\\'")}')">Als Layer</button><button class="danger" onclick="delMedia('${m.name.replace(/'/g,"\\'")}')">✕</button></div>
</div>`).join('')||'<p style="color:var(--dim)">Noch keine Medien. Dateien oben hochladen.</p>'}catch(e){}}
function fmtSize(b){if(b>1048576)return(b/1048576).toFixed(1)+' MB';if(b>1024)return(b/1024).toFixed(0)+' KB';return b+' B'}
async function addLayer(name){const d=await api('layers/add',{name});if(d.layer){toast('Layer #'+d.layer.id+' hinzugefügt ('+name+')');showTab('live')}}
async function delMedia(name){if(!confirm(name+' löschen?'))return;await api('media/delete',{name});loadMedia()}
// Upload (Drag&Drop + Klick + Fortschritt)
const drop=$('drop'),file=$('file');
drop.onclick=()=>file.click();
drop.ondragover=e=>{e.preventDefault();drop.classList.add('hot')};
drop.ondragleave=()=>drop.classList.remove('hot');
drop.ondrop=e=>{e.preventDefault();drop.classList.remove('hot');uploadFiles(e.dataTransfer.files)};
file.onchange=()=>{uploadFiles(file.files);file.value=''};
function uploadFiles(files){let i=0;
const next=()=>{if(i>=files.length){$('upbar').style.display='none';loadMedia();return}
const f=files[i++];upOne(f,()=>next())};next()}
function upOne(f,done){
$('upbar').style.display='block';$('uptext').textContent='Lade '+f.name+' ('+fmtSize(f.size)+')…';$('uprog').value=0;
const xhr=new XMLHttpRequest();
xhr.open('POST','/api/media/upload');
xhr.setRequestHeader('X-Filename',encodeURIComponent(f.name));
xhr.upload.onprogress=e=>{if(e.lengthComputable)$('uprog').value=e.loaded/e.total};
xhr.onload=()=>{if(xhr.status===200){toast(f.name+' gespeichert')}else{let m='';try{m=JSON.parse(xhr.responseText).error}catch(e){}toast('Upload fehlgeschlagen: '+(m||xhr.status),true)}done()};
xhr.onerror=()=>{toast('Netzwerkfehler beim Upload',true);done()};
xhr.send(f);}
// Settings
async function loadSettings(){try{const s=await(await fetch('/api/settings')).json();
$('s-web-port').value=s.web.port;$('s-web-bind').value=s.web.bind;
$('s-preview-width').value=s.preview.width;$('s-preview-height').value=s.preview.height;$('s-preview-fps').value=s.preview.fps;$('s-preview-q').value=s.preview.jpeg_quality;
$('s-artnet-enabled').checked=s.artnet.enabled;$('s-artnet-port').value=s.artnet.listen_port;
$('s-artnet-universes').value=s.artnet.universes.join(',');
$('s-artnet-master').value=s.artnet.master_channel;$('s-artnet-layer').value=s.artnet.layer_start_channel;
$('s-artnet-playback').value=s.artnet.playback_channel;$('s-artnet-retrigger').value=s.artnet.retrigger_channel;$('s-artnet-blackout').value=s.artnet.blackout_channel;
$('s-artnet-policy').value=s.artnet.signal_loss_policy;$('s-artnet-timeout').value=s.artnet.signal_loss_timeout_s;
$('s-engine-max').value=s.engine.max_layers;$('s-engine-loop').checked=s.engine.loop_default;$('s-output-fs').checked=s.output.fullscreen;
$('s-media-dir').value=s.media.dir;$('s-media-max').value=s.media.max_upload_mb;
}catch(e){}}
async function saveSettings(){const body={
web:{port:+$('s-web-port').value,bind:$('s-web-bind').value},
preview:{width:+$('s-preview-width').value,height:+$('s-preview-height').value,fps:+$('s-preview-fps').value,jpeg_quality:+$('s-preview-q').value},
artnet:{enabled:$('s-artnet-enabled').checked,listen_port:+$('s-artnet-port').value,
universes:$('s-artnet-universes').value.split(',').map(x=>parseInt(x.trim())).filter(x=>!isNaN(x)),
master_channel:+$('s-artnet-master').value,layer_start_channel:+$('s-artnet-layer').value,
playback_channel:+$('s-artnet-playback').value,retrigger_channel:+$('s-artnet-retrigger').value,blackout_channel:+$('s-artnet-blackout').value,
signal_loss_policy:$('s-artnet-policy').value,signal_loss_timeout_s:+$('s-artnet-timeout').value},
engine:{max_layers:+$('s-engine-max').value,loop_default:$('s-engine-loop').checked},
output:{fullscreen:$('s-output-fs').checked},
media:{dir:$('s-media-dir').value,max_upload_mb:+$('s-media-max').value}};
const d=await api('settings',body);
if(d.saved){$('setmsg').textContent='Gespeichert ✓';setTimeout(()=>$('setmsg').textContent='',2500);
if(d.hint)toast(d.hint)}}
poll();
</script>
</body>
</html>'''
+63 -837
View File
@@ -1,874 +1,100 @@
#!/usr/bin/env python3
"""HMS MediaEngine Vollständig funktionierender Medienserver (Single-File).
"""HMS MediaEngine Einstiegspunkt.
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)
Startet Engine (GStreamer), Art-Net-Empfang, Web-Server (UI/API/Upload).
Alle Einstellungen (inkl. Art-Net-Mapping) sind in der Web-UI konfigurierbar
und werden in config.json gespeichert.
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
python3 run.py # Standardstart (UI im Browser)
python3 run.py video1.mp4 # Dateien importieren + als Layer
python3 run.py --setup # Nativer Dialog (Port/Fullscreen)
python3 run.py --bootstrap # GStreamer pruefen/installieren
python3 run.py --generate-setup # Setup-Skripte 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
# ============================================================
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT))
import gi
import gi # noqa: E402
gi.require_version("Gst", "1.0")
from gi.repository import Gst
from gi.repository import Gst # noqa: E402
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
# ============================================================
from hms_app import config as cfgmod # noqa: E402
from hms_app.artnet import ArtNetInput # noqa: E402
from hms_app.engine import MediaEngine # noqa: E402
from hms_app.media import MediaLibrary # noqa: E402
from hms_app.server import EngineServer # noqa: E402
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("files", nargs="*", help="Medien-Dateien importieren")
ap.add_argument("--setup", action="store_true",
help="Einstellungs-Dialog (tkinter)")
help="Nativer Einstellungsdialog (tkinter)")
ap.add_argument("--bootstrap", action="store_true",
help="GStreamer pruefen/installieren")
help="GStreamer pruefen/nachinstallieren")
ap.add_argument("--generate-setup", action="store_true",
help="setup_windows.ps1 / setup_linux.sh erzeugen")
ap.add_argument("--port", type=int, default=None,
help="Web-Port ueberschreibt config.json")
args = ap.parse_args()
if args.generate_setup:
generate_setup_scripts()
from hms_app import setup as setupmod
setupmod.generate_setup_scripts(ROOT)
return 0
if args.bootstrap:
ok = check_and_install_dependencies()
if not ok:
return 1
from hms_app import setup as setupmod
ok = setupmod.check_and_install_dependencies()
return 0 if ok else 1
settings = cfgmod.load(ROOT)
if args.port is not None:
settings["web"]["port"] = args.port
if args.setup:
cfg = run_setup()
args.videos = cfg["videos"] or args.videos
args.port = cfg["port"]
args.output = args.output or cfg["output"]
from hms_app import setup as setupmod
cfg = setupmod.run_setup(settings)
settings["web"]["port"] = cfg["port"]
settings["output"]["fullscreen"] = cfg["fullscreen"]
cfgmod.save(ROOT, settings)
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
library = MediaLibrary(settings, ROOT)
library.ensure_dirs()
engine = MediaEngine(settings, library)
artnet = ArtNetInput(engine, settings)
server = EngineServer(engine, artnet, library, settings, ROOT)
artnet = ArtNetInput(engine)
# CLI-Dateien importieren und als Layer aktivieren
for f in args.files:
p = Path(f).expanduser()
name = library.import_copy(p) if p.exists() else None
if name:
engine.add_layer(name)
print(f"[Start] Importiert + Layer: {name}")
else:
print(f"[Start] Import fehlgeschlagen: {f}")
engine.rebuild()
artnet.start()
server = EngineServer(engine, port=args.port)
server.start()
if not server.start():
return 1
running = True
def stop_handler(sig, frame):
def stop_handler(_sig, _frame):
nonlocal running
running = False
@@ -876,17 +102,17 @@ def main() -> int:
signal.signal(signal.SIGTERM, stop_handler)
print()
print("=" * 60)
print("=" * 62)
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(f" UI : http://localhost:{settings['web']['port']}/")
print(f" Art-Net : UDP {settings['artnet']['listen_port']} "
f"(Universes {settings['artnet']['universes']})")
print(" Einstellungen (Art-Net, Preview, Layer, Upload): Web-UI")
print(" Beenden : Strg+C")
print("=" * 60)
print("=" * 62)
try:
while running and engine.running:
while running:
time.sleep(1)
except KeyboardInterrupt:
pass
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""E2E-Test: Upload, Thumbnails, Layer, konfigurierbares Art-Net, Persistenz.
Voraussetzung: Server laeuft auf http://localhost:8093
"""
import json
import socket
import struct
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = "http://localhost:8093"
def get(path):
with urllib.request.urlopen(BASE + path, timeout=15) as r:
return json.loads(r.read().decode())
def post(path, body=None, raw=None, headers=None):
data = raw if raw is not None else json.dumps(body or {}).encode()
req = urllib.request.Request(BASE + path, data=data, method="POST")
req.add_header("Content-Type", "application/json" if raw is None
else "application/octet-stream")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return json.loads(e.read().decode())
def check(label, ok, detail=""):
print(("PASS" if ok else "FAIL") + " " + label + " " + str(detail))
if not ok:
sys.exit(1)
# 1) Health
h = get("/api/health")
check("Health", h.get("status") == "ok")
# 2) Testdateien erzeugen (Video + PNG via GStreamer)
subprocess.run([
"/usr/bin/python3", "-c",
"import gi; gi.require_version('Gst','1.0')\n"
"from gi.repository import Gst; Gst.init(None)\n"
"p = Gst.parse_launch('videotestsrc num-buffers=30 pattern=18 ! "
"video/x-raw,width=320,height=180 ! x264enc tune=zerolatency ! "
"mp4mux ! filesink location=/tmp/e2e_clip.mp4')\n"
"p.set_state(Gst.State.PLAYING)\n"
"p.get_bus().timed_pop_filtered(Gst.CLOCK_TIME_NONE, "
"Gst.MessageType.EOS)\n"
"p.set_state(Gst.State.NULL)\n"
"p2 = Gst.parse_launch('videotestsrc num-buffers=1 pattern=3 ! "
"video/x-raw,width=320,height=180 ! pngenc ! "
"filesink location=/tmp/e2e_bild.png')\n"
"p2.set_state(Gst.State.PLAYING)\n"
"p2.get_bus().timed_pop_filtered(Gst.CLOCK_TIME_NONE, "
"Gst.MessageType.EOS)\n"
"p2.set_state(Gst.State.NULL)\n",
], check=True)
# 3) Upload Video ueber HTTP (wie die Web-UI)
video = open("/tmp/e2e_clip.mp4", "rb").read()
r = post("/api/media/upload", raw=video,
headers={"X-Filename": urllib.parse.quote("e2e_clip.mp4")})
check("Upload Video", bool(r.get("saved")), r)
# 4) Upload Bild
bild = open("/tmp/e2e_bild.png", "rb").read()
r = post("/api/media/upload", raw=bild,
headers={"X-Filename": urllib.parse.quote("e2e_bild.png")})
check("Upload Bild", bool(r.get("saved")), r)
# 5) Bibliothek + Metadaten
lib = get("/api/media")["items"]
names = [m["name"] for m in lib]
check("Bibliothek beide Dateien",
{"e2e_clip.mp4", "e2e_bild.png"} <= set(names), names)
clip = next(m for m in lib if m["name"] == "e2e_clip.mp4")
check("Video-Dauer erkannt", clip.get("duration_s") is not None,
"dauer=%ss" % clip.get("duration_s"))
check("Bild als image",
next(m for m in lib if m["name"] == "e2e_bild.png")["type"]
== "image")
# 6) Thumbnail abrufbar (echtes JPEG)
req = urllib.request.Request(BASE + "/thumb/e2e_clip.mp4")
with urllib.request.urlopen(req, timeout=15) as resp:
thumb = resp.read()
check("Thumbnail JPEG", thumb[:2] == b"\xff\xd8", "%d bytes" % len(thumb))
# 7) Pfad-Traversal abgewiesen
try:
urllib.request.urlopen(BASE + "/media/..%2Fconfig.json", timeout=5)
check("Traversal-Schutz", False)
except urllib.error.HTTPError as e:
check("Traversal-Schutz", e.code == 404)
# 8) Layer dynamisch: Video + Bild
r = post("/api/layers/add", {"name": "e2e_clip.mp4"})
check("Layer #1 (Video)", "layer" in r, r)
r = post("/api/layers/add", {"name": "e2e_bild.png"})
check("Layer #2 (Bild)", "layer" in r, r)
time.sleep(3)
st = get("/api/status")
check("Engine rendert", st["frames_rendered"] > 0,
"frames=%d layer=%d" % (st["frames_rendered"], len(st["layers"])))
# 9) Art-Net UMBERSTELLEN: Port 6455, Master-Kanal 10
r = post("/api/settings", {"artnet": {
"enabled": True, "listen_port": 6455, "universes": [0],
"master_channel": 10, "layer_start_channel": 2,
"playback_channel": 6, "retrigger_channel": 7,
"blackout_channel": 8, "signal_loss_policy": "hold",
"signal_loss_timeout_s": 10}})
check("Settings gespeichert", bool(r.get("saved")), r)
time.sleep(2)
# 10) DMX an NEUEN Port: Kanal 10 = 64 => Master ~0.25
dmx = bytearray(512)
dmx[9] = 64
pkt = (b"Art-Net\x00" + struct.pack("<H", 0x5000)
+ struct.pack(">H", 14) + bytes([1]) + b"\x00"
+ struct.pack("<H", 0) + struct.pack(">H", 512) + bytes(dmx))
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(pkt, ("127.0.0.1", 6455))
s.close()
time.sleep(1)
st = get("/api/status")
check("DMX auf neuem Port + Kanal", 0.2 < st["master"] < 0.3,
"master=%s accepted=%s" % (st["master"], st["dmx"]["accepted"]))
# 11) Kanal 11 (Layer 2 ab Start-Kanal 2) = 255 => Layer-Alpha
dmx2 = bytearray(512)
dmx2[9] = 255
dmx2[10] = 255
pkt2 = (b"Art-Net\x00" + struct.pack("<H", 0x5000)
+ struct.pack(">H", 14) + bytes([2]) + b"\x00"
+ struct.pack("<H", 0) + struct.pack(">H", 512) + bytes(dmx2))
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(pkt2, ("127.0.0.1", 6455))
s.close()
time.sleep(1)
st = get("/api/status")
l2 = st["layers"][1]
check("Layer-Alpha via DMX", l2["alpha"] > 0.9, "l2.alpha=%s" % l2["alpha"])
# 12) config.json persistiert die Umstellung
cfg = json.load(open("config.json"))
check("config.json Port 6455", cfg["artnet"]["listen_port"] == 6455)
check("config.json Master-Kanal 10", cfg["artnet"]["master_channel"] == 10)
# 13) Layer entfernen => Engine laeuft weiter
r = post("/api/layers/remove", {"id": 1})
check("Layer entfernt", bool(r.get("removed")), r)
time.sleep(2)
st = get("/api/status")
check("Engine nach Remove ok", st["running"] is True,
"layer=%d frames=%d" % (len(st["layers"]), st["frames_rendered"]))
# 14) Ungueltige Settings abgewiesen (Port 99999)
r = post("/api/settings", {"artnet": {"listen_port": 99999}})
check("Ungueltige Settings abgewiesen", "error" in r, r)
print("")
print("ALLE E2E-TESTS BESTANDEN")