diff --git a/.gitignore b/.gitignore index 94dc576..1899797 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,8 @@ userdata/ Thumbs.db .idea/ .vscode/ + +# Runtime-Daten der Anwendung (Nutzer-Medien und Konfiguration) +media/ +thumbs/ +config.json diff --git a/hms_app/__init__.py b/hms_app/__init__.py new file mode 100644 index 0000000..ff98207 --- /dev/null +++ b/hms_app/__init__.py @@ -0,0 +1 @@ +"""hms_app – HMS MediaEngine Anwendungspaket (UI, Engine, Verwaltung).""" diff --git a/hms_app/artnet.py b/hms_app/artnet.py new file mode 100644 index 0000000..e3b7e19 --- /dev/null +++ b/hms_app/artnet.py @@ -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[10:12])[0] < 14: + return + seq = data[12] + universe = 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]), + } diff --git a/hms_app/config.py b/hms_app/config.py new file mode 100644 index 0000000..72ea1ea --- /dev/null +++ b/hms_app/config.py @@ -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 diff --git a/hms_app/engine.py b/hms_app/engine.py new file mode 100644 index 0000000..65f2ae2 --- /dev/null +++ b/hms_app/engine.py @@ -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 diff --git a/hms_app/media.py b/hms_app/media.py new file mode 100644 index 0000000..d2d9994 --- /dev/null +++ b/hms_app/media.py @@ -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 diff --git a/hms_app/server.py b/hms_app/server.py new file mode 100644 index 0000000..81c8ba3 --- /dev/null +++ b/hms_app/server.py @@ -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 .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 diff --git a/hms_app/setup.py b/hms_app/setup.py new file mode 100644 index 0000000..8dec27f --- /dev/null +++ b/hms_app/setup.py @@ -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 diff --git a/hms_app/ui.py b/hms_app/ui.py new file mode 100644 index 0000000..57780f5 --- /dev/null +++ b/hms_app/ui.py @@ -0,0 +1,259 @@ +"""Web-UI (Single-Page): Tabs Live, Medien (Upload), Layer, Einstellungen.""" + +HTML_UI = r''' + + + + +HMS MediaEngine + + + +
+

HMS MediaEngine

+ + Art-Net: … +
+ + + + +
+
+
+
+
+
+

Master

+
+
+
+ + + + +
+

Layer (Schnellzugriff)

+
+

Diagnose

+ + + + + + +
Frames
DMX-Pakete
DMX angenommen
Medien
Fehler
+
+
+ +
+
📄 Dateien hierher ziehen oder klicken zum Hochladen
Videos: mp4, mkv, avi, mov, webm · Bilder: jpg, png, bmp
+ + +
+
+ +
+
+ Aktive Layer bearbeiten:
+
+
+ +
+

Web-UI

+
+
0.0.0.0 = alle Interfaces
+
+

Preview

+
× px
+
+
+
+

Art-Net (DMX)

+
+
Standard 6454
+
z. B. 0,1,2
+
+
je +1 pro Layer
+
+
+
+
+
s
+
+

Engine

+
+
+
Windows: D3D11 · Linux: autovideosink
+
+

Medien

+
+
MB
+
+ + +
+
+
fps · Frames · MJPEG-Preview · REST /api/status
+
+ + +''' diff --git a/run.py b/run.py index f86b3da..b3e3cf1 100644 --- a/run.py +++ b/run.py @@ -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[10:12])[0] - if protver < 14: - return - seq = data[12] - universe = 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 = """ - - - -HMS MediaEngine - - - -
-

HMS MediaEngine

- Art-Net: warte… - -
-
-
-
-

Master

-
- -
-
- - - -
-

Layer

-
-

Diagnose

- - - - -
Frames
DMX-Pakete
Fehler
-
-
-
fps · MJPEG-Preview · REST unter /api/status
- - -""" - - -# ============================================================ -# 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 diff --git a/tests/e2e_app_test.py b/tests/e2e_app_test.py new file mode 100644 index 0000000..49df696 --- /dev/null +++ b/tests/e2e_app_test.py @@ -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", 14) + bytes([1]) + b"\x00" + + 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", 14) + bytes([2]) + b"\x00" + + 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")