From 02765e6b34059eb54c20db124255bde53462dc93 Mon Sep 17 00:00:00 2001 From: HMS MediaEngine Agent Date: Fri, 11 Sep 2026 13:34:43 +0200 Subject: [PATCH] CUE-SZENENLISTE PRO LAYER + macOS-SUPPORT (35/35 E2E, 123 Checks gesamt) 1) Cue-/Szenenliste JE Layer (35/35 E2E): - Cue sichern = Snapshot (Quelle, Alpha, Position, Groesse, Z-Order, FX1/FX2 + Intensitaeten); Name + fade_ms + follow_ms pro Cue - GO vor/zurueck (steps), Goto (Index), Wrap-around am Listenende - FADE: Quellwechsel = dip-to-black (Phase1 runter, Wechsel bei 0, Phase2 hoch; E2E: alpha 0.49 -> 0.32 -> Ziel 0.8), sonst sanfter Tween von Alpha + FX-Intensitaeten; laufende Fades abbrechbar - AUTO-FOLLOW: follow_ms schaltet nach Ablauf automatisch weiter (E2E: Cue mit 600ms -> Wrap zum naechsten) - DMX CUE-GO: cue_go_channel (Settings + UI), steigende Flanke >=128 = GO auf allen Layern; ohne Flanke kein GO (E2E: 0->1, 1==1 bei High-Halten, neue Flanke 1->0); Sequenzfilter aktiv - Cue-Entfernen mit Indexkorrektur; Cues in Projekten speicher-/ladbar (3 Cues nach Projekt-Load erhalten) - UI: Cue-Bereich je Layer (Cue sichern / GO / Zurueck / Goto / Loeschen, aktiver Cue markiert, Fade+Follow-Anzeige) - Fehlerfaelle: unbekannter Layer 400, fehlendes Medium 400, unbekannter Generator 400 2) macOS (Apple Silicon + Intel) mit Skript: - setup_macos.sh: Homebrew installiert python@3.12, python-tk, GStreamer + alle Plugin-Sets + PyGObject; Apple-Silicon-PATH- Hinweis (/opt/homebrew); Erklaerung warum Homebrew statt gstreamer-.pkg (keine PyGObject-Bindings in .pkg) - Bootstrap (--bootstrap): darwin-Zweig mit brew-Installationspfad - run.py --generate-setup erzeugt jetzt 3 Skripte (Windows .ps1 / Linux .sh / macOS .sh); Bash-Syntax geprueft 3) Gefundene + behobene FEHLER: - WEB-UI-ABSTURZ (echter JS-Ternary-Klammerfehler in loadServers, seit Verteilungs-Commit unentdeckt, weil API-Tests den Browser- Code nie parsen): Klammerstruktur korrigiert + JS-Syntax nun per node --check verifizierbar (Syntax OK) + Remote-Buttons robust per data-Attribute (kein Quote-Escaping mehr) - update_layer blockierte Quellwechsel Video->Generator (Cues brauchen beides): Name entscheidet jetzt den Typ, Wechsel in beide Richtungen - Cue-E2E-Timing: dip-to-black hat 2 Phasen (Pruefung 0.2s/0.6s), Auto-Follow-Index-Fix, Art-Net-Sequenznummern im Test inkrementierend (Sequenzfilter verwirft Duplikate korrekt) E2E-Bilanz: Cue 35/35 + Regressionen Projekte 25/25, Verteilung 33/33, App 14/14, FX 16/16 = 123 Checks gruen --- hms_app/config.py | 3 +- hms_app/engine.py | 298 ++++++++++++++++++++++++++++++++++++++++-- hms_app/server.py | 39 ++++++ hms_app/setup.py | 67 ++++++++++ hms_app/ui.py | 41 +++++- setup_linux.sh | 9 +- setup_macos.sh | 33 +++++ setup_windows.ps1 | 14 +- tests/e2e_cue_test.py | 293 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 770 insertions(+), 27 deletions(-) create mode 100755 setup_macos.sh create mode 100644 tests/e2e_cue_test.py diff --git a/hms_app/config.py b/hms_app/config.py index bed3275..d680cf0 100644 --- a/hms_app/config.py +++ b/hms_app/config.py @@ -24,6 +24,7 @@ DEFAULTS: dict = { "blackout_channel": 8, "fx1_channel": None, "fx2_channel": None, + "cue_go_channel": None, "signal_loss_policy": "hold", "signal_loss_timeout_s": 10, }, @@ -115,7 +116,7 @@ def validate(new: dict) -> tuple[dict | None, str | None]: errors.append("artnet.universes") for k in ("master_channel", "layer_start_channel", "playback_channel", "retrigger_channel", "blackout_channel", "fx1_channel", - "fx2_channel"): + "fx2_channel", "cue_go_channel"): v = a.get(k) if v is not None and (not isinstance(v, int) or not 1 <= v <= 512): diff --git a/hms_app/engine.py b/hms_app/engine.py index 33e8843..caf106c 100644 --- a/hms_app/engine.py +++ b/hms_app/engine.py @@ -118,6 +118,9 @@ class Layer: self.fx2: str | None = None self.fx1_intensity = 0.5 # 0..1 normalisiert self.fx2_intensity = 0.5 + self.cues: list[dict] = [] # Cue-Liste (Szenen) dieses Layers + self.cue_index: int = -1 # aktiver Cue (-1 = keiner) + self._fade_gen: int = 0 # Fade-Abbruch-Zaehler def to_dict(self) -> dict: return {"id": self.id, "name": self.name, "kind": self.kind, @@ -126,7 +129,10 @@ class Layer: "fx1": self.fx1, "fx2": self.fx2, "fx1_intensity": round(self.fx1_intensity, 3), "fx2_intensity": round(self.fx2_intensity, 3), - "effective": None} + "effective": None, + "cue_index": self.cue_index, + "cues": [{"cue_id": c["cue_id"], "name": c["name"]} + for c in self.cues]} class MediaEngine: @@ -153,6 +159,7 @@ class MediaEngine: "last_time": None} self._lock = threading.RLock() self._monitor: threading.Thread | None = None + self._cue_go_last = False # DMX-Flankenerkennung Cue-GO # ---------------- Pipeline ---------------- @@ -351,6 +358,264 @@ class MediaEngine: return [{"id": g, "label": lbl} for g, lbl in GENERATORS.items()] + # ---------------- Cue-Liste (Szenen) je Layer ---------------- + + def _capture_state(self, layer: Layer) -> dict: + return { + "name": layer.name, "kind": layer.kind, + "alpha": layer.alpha, "x": layer.x, "y": layer.y, + "width": layer.width, "height": layer.height, "z": layer.z, + "fx1": layer.fx1, "fx2": layer.fx2, + "fx1_intensity": layer.fx1_intensity, + "fx2_intensity": layer.fx2_intensity, + } + + def _sanitize_cues(self, raw) -> list[dict]: + """Validiert Cue-Liste (Projekt-Load): unbrauchbare Cues fliegen raus.""" + out: list[dict] = [] + if not isinstance(raw, list): + return out + for item in raw: + if (not isinstance(item, dict) + or not isinstance(item.get("state"), dict)): + continue + state = dict(item["state"]) + kind = str(state.get("kind", "")) + name = str(state.get("name", "")) + if kind == "generator": + if name not in GENERATORS: + continue + else: + if (not self.library.file_path(name).exists() + or self.library.kind_of(name) is None): + continue + state["alpha"] = max(0.0, min(1.0, + float(state.get("alpha", 1.0)))) + for k in ("fx1_intensity", "fx2_intensity"): + state[k] = max(0.0, min(1.0, float(state.get(k, 0.5)))) + state["x"] = int(state.get("x", 0)) + state["y"] = int(state.get("y", 0)) + state["width"] = max(16, min(7680, int(state.get("width", 640)))) + state["height"] = max(16, min(4320, + int(state.get("height", 360)))) + state["z"] = max(0, min(15, int(state.get("z", 0)))) + state["fx1"] = (state.get("fx1") + if state.get("fx1") in FX_CATALOG else None) + state["fx2"] = (state.get("fx2") + if state.get("fx2") in FX_CATALOG else None) + out.append({ + "cue_id": int(item.get("cue_id", len(out) + 1)), + "name": str(item.get("name") + or ("Cue %d" % (len(out) + 1))), + "fade_ms": max(0, min(10000, + int(item.get("fade_ms", 500)))), + "follow_ms": max(0, min(600000, + int(item.get("follow_ms", 0)))), + "state": state, + }) + return out + + def layer_cues(self, layer_id: int) -> dict | None: + with self._lock: + layer = self.get_layer(layer_id) + if layer is None: + self.last_error = "Layer nicht gefunden" + return None + return {"id": layer.id, "index": layer.cue_index, + "cues": [dict(c) for c in layer.cues]} + + def add_cue(self, layer_id: int, body: dict) -> dict | None: + """Legt einen Cue an; ohne state wird der AKTUELLE Zustand gesichert.""" + with self._lock: + layer = self.get_layer(layer_id) + if layer is None: + self.last_error = "Layer nicht gefunden" + return None + state = dict(self._capture_state(layer)) + incoming = body.get("state") + if isinstance(incoming, dict): + for k in state: + if incoming.get(k) is not None: + state[k] = incoming[k] + if (str(state.get("kind")) == "generator" + and state.get("name") not in GENERATORS): + self.last_error = "unbekannter Generator" + return None + if str(state.get("kind")) != "generator": + nm = str(state.get("name", "")) + if (not self.library.file_path(nm).exists() + or self.library.kind_of(nm) is None): + self.last_error = "Medium nicht gefunden: " + nm + return None + next_id = (layer.cues[-1]["cue_id"] + 1 + if layer.cues else 1) + cue = { + "cue_id": next_id, + "name": str(body.get("name") + or ("Cue %d" % (len(layer.cues) + 1))), + "fade_ms": max(0, min(10000, + int(body.get("fade_ms", 500)))), + "follow_ms": max(0, min(600000, + int(body.get("follow_ms", 0)))), + "state": state, + } + layer.cues.append(cue) + return dict(cue) + + def remove_cue(self, layer_id: int, cue_id: int) -> bool: + with self._lock: + layer = self.get_layer(layer_id) + if layer is None: + return False + pos = next((i for i, c in enumerate(layer.cues) + if c["cue_id"] == int(cue_id)), None) + if pos is None: + return False + layer.cues.pop(pos) + if layer.cue_index == pos: + layer.cue_index = -1 + elif layer.cue_index > pos: + layer.cue_index -= 1 + return True + + def cue_go(self, layer_id: int, steps: int = 1) -> dict | None: + """GO: weiterschalten (Wrap-around am Ende; steps<0 = zurueck).""" + with self._lock: + layer = self.get_layer(layer_id) + if layer is None: + self.last_error = "Layer nicht gefunden" + return None + if not layer.cues: + self.last_error = "keine Cues vorhanden" + return None + n = len(layer.cues) + idx = layer.cue_index if layer.cue_index >= 0 else -steps + target = (idx + int(steps)) % n + return self._goto_cue_locked(layer, target) + + def cue_goto(self, layer_id: int, index: int) -> dict | None: + with self._lock: + layer = self.get_layer(layer_id) + if layer is None: + self.last_error = "Layer nicht gefunden" + return None + if not layer.cues or not 0 <= int(index) < len(layer.cues): + self.last_error = "Cue-Index ungueltig" + return None + return self._goto_cue_locked(layer, int(index)) + + def _goto_cue_locked(self, layer: Layer, index: int) -> dict: + cue = layer.cues[index] + layer.cue_index = index + layer._fade_gen += 1 # laufende Fades abbrechen + state = dict(cue.get("state") or {}) + fade_ms = int(cue.get("fade_ms", 0)) + discrete = {"name": state["name"]} if "name" in state else {} + params = {k: state[k] for k in + ("alpha", "fx1", "fx2", "fx1_intensity", + "fx2_intensity", "x", "y", "width", "height", "z") + if k in state} + target_alpha = float(params.get("alpha", layer.alpha)) + needs_swap = ( + (bool(discrete) and discrete.get("name") != layer.name) + or ("fx1" in params and params["fx1"] != layer.fx1) + or ("fx2" in params and params["fx2"] != layer.fx2)) + result = {"index": index, "cue": cue["name"], "fade_ms": fade_ms} + if fade_ms <= 0: + if not self.update_layer(layer.id, {**discrete, **params}): + self.last_error = "Cue-Anwendung fehlgeschlagen" + return result + self._schedule_follow(layer, index) + return result + gen = layer._fade_gen + threading.Thread( + target=self._fade_worker, + args=(layer, gen, discrete, params, target_alpha, + fade_ms, needs_swap, index), + daemon=True).start() + return result + + def _fade_worker(self, layer: Layer, gen: int, discrete: dict, + params: dict, target_alpha: float, fade_ms: int, + needs_swap: bool, index: int): + """Fade: Quelle wechselt -> dip-to-black; sonst sanfter Tween.""" + def alive(): + return layer._fade_gen == gen + + start_alpha = layer.alpha + if needs_swap: + half = max(fade_ms / 2000.0, 0.05) + t0 = time.monotonic() + while time.monotonic() - t0 < half: + if not alive(): + return + f = (time.monotonic() - t0) / half + with self._lock: + layer.alpha = max(0.0, start_alpha * (1.0 - f)) + self._apply_pad_props(layer) + time.sleep(0.02) + swap = dict(discrete) + for k in ("fx1", "fx2"): + if k in params: + swap[k] = params[k] + with self._lock: + if not self.update_layer(layer.id, swap): + return + layer.alpha = 0.0 + self._apply_pad_props(layer) + start_alpha = 0.0 + dur = half + else: + with self._lock: + patch = {k: params[k] + for k in ("x", "y", "width", "height", "z") + if k in params} + if patch: + self.update_layer(layer.id, patch) + dur = max(fade_ms / 1000.0, 0.05) + + s1, s2 = layer.fx1_intensity, layer.fx2_intensity + t1 = float(params.get("fx1_intensity", s1)) + t2 = float(params.get("fx2_intensity", s2)) + k1 = "fx1_intensity" in params and layer.fx1 + k2 = "fx2_intensity" in params and layer.fx2 + t0 = time.monotonic() + while time.monotonic() - t0 < dur: + if not alive(): + return + f = (time.monotonic() - t0) / dur + with self._lock: + layer.alpha = start_alpha + (target_alpha - start_alpha) * f + self._apply_pad_props(layer) + if k1: + layer.fx1_intensity = s1 + (t1 - s1) * f + self._set_fx_property(layer, 1, layer.fx1_intensity) + if k2: + layer.fx2_intensity = s2 + (t2 - s2) * f + self._set_fx_property(layer, 2, layer.fx2_intensity) + time.sleep(0.02) + with self._lock: + layer.alpha = target_alpha + self._apply_pad_props(layer) + self._schedule_follow(layer, index) + + def _schedule_follow(self, layer: Layer, index: int): + follow_ms = int(layer.cues[index].get("follow_ms", 0)) + if follow_ms <= 0: + return + gen = layer._fade_gen + + def fire(): + with self._lock: + if (layer._fade_gen == gen + and layer.cue_index == index + and self.get_layer(layer.id) is layer): + self.cue_go(layer.id, 1) + + t = threading.Timer(follow_ms / 1000.0, fire) + t.daemon = True + t.start() + def remove_layer(self, layer_id: int) -> bool: with self._lock: kept = [l for l in self.layers if l.id != layer_id] @@ -392,7 +657,7 @@ class MediaEngine: if "fx1" in patch: v = patch["fx1"] layer.fx1 = v if (v in FX_CATALOG or v is None) else None - fx_changed = layer.fx1 != patch.get("fx1_prev") + fx_changed = True if "fx2" in patch: v = patch["fx2"] layer.fx2 = v if (v in FX_CATALOG or v is None) else None @@ -407,15 +672,14 @@ class MediaEngine: float(patch["fx2_intensity"]))) if layer.fx2: self._set_fx_property(layer, 2, layer.fx2_intensity) - if fx_changed: - self.rebuild() # Struktur geaendert -> Pipeline neu - return self.get_layer(layer.id) + src_changed = False if "name" in patch and patch["name"] != layer.name: new_name = str(patch["name"]) - if layer.kind == "generator": - if new_name not in GENERATORS: - return None + if new_name in GENERATORS: + # Generator als Quelle (erlaubt Wechsel in BEIDE + # Richtungen: Video -> Generator und zurueck) layer.name = new_name + layer.kind = "generator" else: f = self.library.file_path(new_name) if not f.exists(): @@ -423,7 +687,9 @@ class MediaEngine: layer.name = new_name layer.kind = (self.library.kind_of(new_name) or layer.kind) - # Quell-Wechsel braucht einen Pipeline-Rebuild + src_changed = True + if src_changed or fx_changed: + # Struktur geaendert -> atomarer Rebuild (Rollback drin) if not self.rebuild(): return None return self.get_layer(layer.id) @@ -548,6 +814,15 @@ class MediaEngine: self.layers[0].fx2_intensity = ch(fx2_ch) / 255.0 self._set_fx_property(self.layers[0], 2, self.layers[0].fx2_intensity) + # Cue-GO: steigende Flanke -> alle Layer einen Cue weiter + cue_ch = a.get("cue_go_channel") + if cue_ch is not None: + rising = ch(cue_ch) >= 128 + was = self._cue_go_last + self._cue_go_last = rising + if rising and not was: + for l in list(self.layers): + self.cue_go(l.id, 1) # ---------------- Status ---------------- @@ -584,6 +859,8 @@ class MediaEngine: "fx1": l.fx1, "fx2": l.fx2, "fx1_intensity": l.fx1_intensity, "fx2_intensity": l.fx2_intensity, + "cues": [dict(c) for c in l.cues], + "cue_index": l.cue_index, } for l in self.layers], "master": self.master, } @@ -632,6 +909,9 @@ class MediaEngine: float(raw.get("fx1_intensity", 0.5)))) layer.fx2_intensity = max(0.0, min(1.0, float(raw.get("fx2_intensity", 0.5)))) + layer.cues = self._sanitize_cues(raw.get("cues")) + ci = int(raw.get("cue_index", -1)) + layer.cue_index = ci if -1 <= ci < len(layer.cues) else -1 self.layers.append(layer) ok_count += 1 m = state.get("master") diff --git a/hms_app/server.py b/hms_app/server.py index fac620c..27a6385 100644 --- a/hms_app/server.py +++ b/hms_app/server.py @@ -106,6 +106,13 @@ class EngineServer: self._json(engine.fx_catalog()) elif path == "/api/generators": self._json({"items": engine.generators()}) + elif path == "/api/layers/cues": + lid = int((query.get("id") or [-1])[0]) + data = engine.layer_cues(lid) + if data is None: + self._send_error_json(404, engine.last_error) + else: + self._json(data) elif path == "/api/servers": out = [] for s_ in settings.get("servers", []): @@ -153,6 +160,38 @@ class EngineServer: self._send_error_json(400, engine.last_error or "?" ) else: self._json({"layer": layer}) + elif path == "/api/layers/cues/add": + body = self._read_json() + cue = engine.add_cue(int(body.get("id", -1)), body) + if cue is None: + self._send_error_json(400, engine.last_error or "?") + else: + self._json({"cue": cue}) + elif path == "/api/layers/cues/remove": + body = self._read_json() + ok = engine.remove_cue(int(body.get("id", -1)), + int(body.get("cue_id", -1))) + if not ok: + self._send_error_json( + 404, "Cue/Layer nicht gefunden") + else: + self._json({"removed": True}) + elif path == "/api/layers/cues/go": + body = self._read_json() + r = engine.cue_go(int(body.get("id", -1)), + int(body.get("steps", 1))) + if r is None: + self._send_error_json(400, engine.last_error or "?") + else: + self._json(r) + elif path == "/api/layers/cues/goto": + body = self._read_json() + r = engine.cue_goto(int(body.get("id", -1)), + int(body.get("index", -1))) + if r is None: + self._send_error_json(400, engine.last_error or "?") + else: + self._json(r) elif path == "/api/layers/add-generator": body = self._read_json() layer = engine.add_generator( diff --git a/hms_app/setup.py b/hms_app/setup.py index 8dec27f..06d8c12 100644 --- a/hms_app/setup.py +++ b/hms_app/setup.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shutil import subprocess import sys from pathlib import Path @@ -42,6 +43,32 @@ def check_and_install_dependencies() -> bool: print(" sudo apt-get install " + " ".join(LINUX_PKGS)) return False + if sys.platform == "darwin": + pkgs = ["python@3.12", "python-tk", "gstreamer", + "gst-plugins-base", "gst-plugins-good", + "gst-plugins-bad", "gst-plugins-ugly", + "gst-libav", "pygobject"] + brew = shutil.which("brew") + if brew: + print("[Bootstrap] Installiere GStreamer + PyGObject " + "via Homebrew (dauert evtl. einige Minuten)...") + try: + subprocess.run([brew, "install"] + pkgs, + check=False, timeout=1800) + print("[Bootstrap] Fertig. Programm mit brew-python3 " + "neu starten.") + except subprocess.TimeoutExpired: + print("[Bootstrap] Timeout. Manuell: brew install " + + " ".join(pkgs)) + else: + print("[Bootstrap] Homebrew nicht gefunden.") + print(" Erst Homebrew installieren:") + print(' /bin/bash -c "$(curl -fsSL ' + 'https://raw.githubusercontent.com/Homebrew/' + 'install/HEAD/install.sh)"') + print(" Danach: ./setup_macos.sh") + return False + if sys.platform == "win32": url = ("https://gstreamer.freedesktop.org/data/pkg/windows/" "1.28.4/msvc/" @@ -64,6 +91,42 @@ def check_and_install_dependencies() -> bool: return False +MACOS_SETUP = r'''#!/bin/bash +# HMS MediaEngine - macOS Setup (Apple Silicon & Intel, einmalig ausfuehren) +set -e +echo "=== HMS MediaEngine macOS Setup ===" +echo "Architektur: $(uname -m)" + +if ! command -v brew >/dev/null; then + echo "Homebrew fehlt. Zunaechst installieren:" + echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + echo "Dann dieses Skript erneut ausfuehren." + exit 1 +fi + +echo "[1/2] Installiere GStreamer, PyGObject und tkinter (brew)..." +brew install python@3.12 python-tk gstreamer gst-plugins-base \ + gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav pygobject + +echo "[2/2] Umgebung pruefen..." +if [ "$(uname -m)" = "arm64" ] && [ -f /opt/homebrew/bin/brew ]; then + echo "Apple Silicon: Homebrew-Umgebung einbinden mit:" + echo ' eval "$(/opt/homebrew/bin/brew shellenv)"' +fi +echo "Pruefung:" +python3 -c "import gi; gi.require_version('Gst','1.0'); from gi.repository import Gst; Gst.init(None); print('GStreamer OK:', Gst.version_string())" || \ + { echo "PyGObject fehlt noch -> brew python3 verwenden (PATH!)"; exit 1; } + +echo "" +echo "Hinweis: Die .pkg-Pakete von gstreamer.freedesktop.org/data/pkg/macos/" +echo "enthalten KEINE PyGObject-Bindings - Homebrew ist der empfohlene Weg." +echo "" +echo "=== Fertig ===" +echo "Start: python3 run.py" +echo "Dialog: python3 run.py --setup" +''' + + WINDOWS_SETUP = r'''# HMS MediaEngine - Windows Setup (einmalig ausfuehren) $ErrorActionPreference = "Stop" Write-Host "=== HMS MediaEngine Windows Setup ===" -ForegroundColor Cyan @@ -128,8 +191,12 @@ def generate_setup_scripts(root: Path) -> None: p2 = root / "setup_linux.sh" p2.write_text(LINUX_SETUP, encoding="utf-8") p2.chmod(0o755) + p3 = root / "setup_macos.sh" + p3.write_text(MACOS_SETUP, encoding="utf-8") + p3.chmod(0o755) print(f"Erzeugt: {p1}") print(f"Erzeugt: {p2}") + print(f"Erzeugt: {p3}") def run_setup(settings: dict) -> dict: diff --git a/hms_app/ui.py b/hms_app/ui.py index 157d714..66db63a 100644 --- a/hms_app/ui.py +++ b/hms_app/ui.py @@ -147,6 +147,7 @@ footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);bo
+
Flanke ≥128 = nächster Cue je Layer
s
@@ -217,7 +218,16 @@ function renderLayerEdit(ls){const e=$('ledit');
- `).join('')||'

Keine aktiven Layer. Medien-Tab öffnen und Datei als Layer hinzufügen.

'}} +

Cue-Liste (Szenen)

+
+
+ + + +
+ `).join('')||'

Keine aktiven Layer. Medien-Tab öffnen und Datei als Layer hinzufügen.

'} + ls.forEach(renderCues); +} // Medien async function loadMedia(){try{const d=await(await fetch('/api/media')).json(); @@ -260,7 +270,7 @@ async function loadSettings(){try{const s=await(await fetch('/api/settings')).js $('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-playback').value=s.artnet.playback_channel;$('s-artnet-retrigger').value=s.artnet.retrigger_channel;$('s-artnet-blackout').value=s.artnet.blackout_channel;$('s-artnet-cuego').value=s.artnet.cue_go_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; @@ -271,7 +281,7 @@ async function saveSettings(){const body={ 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, + playback_channel:+$('s-artnet-playback').value,retrigger_channel:+$('s-artnet-retrigger').value,blackout_channel:+$('s-artnet-blackout').value,cue_go_channel:+$('s-artnet-cuego').value||null, 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,autostart_project:$('s-autostart').value||null}, output:{fullscreen:$('s-output-fs').checked}, @@ -311,10 +321,33 @@ async function loadServers(){try{const d=await(await fetch('/api/servers')).json // Medien-Tab: Remote-Medien const rg=$('rgrid');if(rg)rg.innerHTML=servers.map(S=> `

${S.name} ${S.online?'online':'offline'}

`+ - (S.online?(S.items||[]).map(m=>`
`).join('')||'

Keine Medien auf dem Server.

'):'')+'
' + (S.online?(S.items||[]).map(m=>`
`).join('')||'

Keine Medien auf dem Server.

':'')+'' ).join('')||'

Noch keine Server. In den Einstellungen registrieren.

'; }catch(e){}} +// ---------- Cue-Listen (Szenen) pro Layer ---------- +function cueRow(L,c,i){return `
+ + ${i===L.cue_index?'aktiv':''} + +
`} +function renderCues(L){const box=$('cues-'+L.id);if(!box)return; + const ls=(L.cues||[]); + if(ls.length===0){box.innerHTML='

Noch keine Cues. Button unten sichert den aktuellen Zustand als Cue.

';return} + box.innerHTML=ls.map((c,i)=>cueRow(L,c,i)).join('')} +async function addCue(id){const nm=prompt('Cue-Name:','Cue');if(nm===null)return; + const fm=prompt('Fade-Zeit in ms (0 = harter Schnitt):','500');if(fm===null)return; + const fo=prompt('Auto-Follow in ms (0 = aus, sonst Weiter nach Ablauf):','0');if(fo===null)return; + const d=await api('layers/cues/add',{id,name:nm||undefined,fade_ms:+fm||0,follow_ms:+fo||0}); + if(d.cue)toast('Cue gesichert: '+d.cue.name);else if(d.error)toast(d.error,true)} +async function cueGo(id,steps){const d=await api('layers/cues/go',{id,steps}); + if(d.cue)toast('GO → '+d.cue);else if(d.error)toast(d.error,true)} +async function cueGoto(id,index){const d=await api('layers/cues/goto',{id,index}); + if(d.cue)toast('Goto: '+d.cue)} +async function delCue(id,cueId){if(!confirm('Cue entfernen?'))return; + await api('layers/cues/remove',{id,cue_id:cueId}); + poll()} + let FX=[]; async function loadFx(){try{FX=await(await fetch('/api/fx')).json();fillFxSelects()}catch(e){}} function fillFxSelects(){document.querySelectorAll('select[id^="fx1-"],select[id^="fx2-"]').forEach(sel=>{ diff --git a/setup_linux.sh b/setup_linux.sh index 323fecd..f4e1b79 100755 --- a/setup_linux.sh +++ b/setup_linux.sh @@ -9,10 +9,9 @@ if command -v apt-get >/dev/null; then 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 + gstreamer1-plugins-bad-free 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" +echo "Start: python3 run.py" +echo "Media-Verwaltung, Layer und Einstellungen: Web-UI im Browser" diff --git a/setup_macos.sh b/setup_macos.sh new file mode 100755 index 0000000..1a55c87 --- /dev/null +++ b/setup_macos.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# HMS MediaEngine - macOS Setup (Apple Silicon & Intel, einmalig ausfuehren) +set -e +echo "=== HMS MediaEngine macOS Setup ===" +echo "Architektur: $(uname -m)" + +if ! command -v brew >/dev/null; then + echo "Homebrew fehlt. Zunaechst installieren:" + echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' + echo "Dann dieses Skript erneut ausfuehren." + exit 1 +fi + +echo "[1/2] Installiere GStreamer, PyGObject und tkinter (brew)..." +brew install python@3.12 python-tk gstreamer gst-plugins-base \ + gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav pygobject + +echo "[2/2] Umgebung pruefen..." +if [ "$(uname -m)" = "arm64" ] && [ -f /opt/homebrew/bin/brew ]; then + echo "Apple Silicon: Homebrew-Umgebung einbinden mit:" + echo ' eval "$(/opt/homebrew/bin/brew shellenv)"' +fi +echo "Pruefung:" +python3 -c "import gi; gi.require_version('Gst','1.0'); from gi.repository import Gst; Gst.init(None); print('GStreamer OK:', Gst.version_string())" || \ + { echo "PyGObject fehlt noch -> brew python3 verwenden (PATH!)"; exit 1; } + +echo "" +echo "Hinweis: Die .pkg-Pakete von gstreamer.freedesktop.org/data/pkg/macos/" +echo "enthalten KEINE PyGObject-Bindings - Homebrew ist der empfohlene Weg." +echo "" +echo "=== Fertig ===" +echo "Start: python3 run.py" +echo "Dialog: python3 run.py --setup" diff --git a/setup_windows.ps1 b/setup_windows.ps1 index a864361..3e40735 100644 --- a/setup_windows.ps1 +++ b/setup_windows.ps1 @@ -8,7 +8,7 @@ if (-not $isAdmin) { exit 1 } -Write-Host "[1/3] GStreamer 1.28.4 MSVC..." -ForegroundColor Yellow +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" @@ -18,7 +18,7 @@ if (-not $gstRoot) { Write-Host " GStreamer installiert" } else { Write-Host " bereits vorhanden" } -Write-Host "[2/3] Python 3.13 (falls fehlt) mit tkinter..." -ForegroundColor Yellow +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" @@ -28,11 +28,9 @@ if (-not $py) { 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 "" 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" +Write-Host "Start: python run.py" +Write-Host "Dialog: python run.py --setup" +Write-Host "Media-Verwaltung, Layer und Einstellungen: Web-UI im Browser" diff --git a/tests/e2e_cue_test.py b/tests/e2e_cue_test.py new file mode 100644 index 0000000..7886dee --- /dev/null +++ b/tests/e2e_cue_test.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""E2E: Cue-/Szenenliste pro Layer (GO, Fade, Auto-Follow, DMX-GO, Projekte). + +Startet einen echten Server als Subprozess. +""" + +import json +import os +import shutil +import socket +import struct +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +ROOT = "/a0/usr/workdir/hms-mediaengine" +PORT = 8097 +BASE = "http://localhost:%d" % PORT + +proc = None + + +def start_server() -> bool: + global proc + proc = subprocess.Popen( + ["/usr/bin/python3", ROOT + "/run.py", "--port", str(PORT)], + cwd=ROOT, stdout=open("/tmp/hms_cue.log", "ab"), + stderr=subprocess.STDOUT) + deadline = time.time() + 25 + while time.time() < deadline: + try: + with urllib.request.urlopen(BASE + "/api/health", + timeout=2) as r: + if json.loads(r.read().decode()).get("status") == "ok": + return True + except Exception: # noqa: BLE001 + time.sleep(0.3) + return False + + +def stop_server(): + global proc + if proc is not None: + proc.terminate() + try: + proc.wait(timeout=6) + except subprocess.TimeoutExpired: + proc.kill() + proc = None + time.sleep(0.5) + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=15) as r: + return json.loads(r.read().decode()) + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, method="POST") + req.add_header("Content-Type", "application/json") + 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) + + +def layer_state(): + return get("/api/status")["layers"][0] + + +# ---------- Reset ---------- +for d in ("media", "thumbs", "projects"): + shutil.rmtree(os.path.join(ROOT, d), ignore_errors=True) +try: + os.remove(os.path.join(ROOT, "config.json")) +except FileNotFoundError: + pass + +try: + check("Server startet", start_server()) + + # Testvideo + 2 Layer anlegen (Video + Generator) + 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=90 pattern=18 ! " + "video/x-raw,width=320,height=180 ! x264enc tune=zerolatency ! " + "mp4mux ! filesink location=/tmp/e2e_cue.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", + ], check=True) + data = open("/tmp/e2e_cue.mp4", "rb").read() + req = urllib.request.Request(BASE + "/api/media/upload", + data=data, method="POST") + req.add_header("Content-Type", "application/octet-stream") + req.add_header("X-Filename", urllib.parse.quote("cue_clip.mp4")) + with urllib.request.urlopen(req, timeout=120) as r: + json.loads(r.read().decode()) + + r = post("/api/layers/add", {"name": "cue_clip.mp4"}) + check("Layer 1 (Video)", "layer" in r, r) + lid = r["layer"]["id"] + + # ---- Cue 1: aktueller Zustand (Video voll sichtbar, hart) ---- + r = post("/api/layers/cues/add", + {"id": lid, "name": "Solo Clip", "fade_ms": 0}) + check("Cue 1 gesichert", r.get("cue", {}).get("name") == "Solo Clip", r) + c1_id = r["cue"]["cue_id"] + + # Zustand aendern: Generator + Blur + Alpha 0.8 + r = post("/api/layers/update", + {"id": lid, "name": "ball", "fx1": "blur", + "alpha": 0.8}) + check("Zustand geaendert", r.get("layer", {}).get("name") == "ball", r) + + # ---- Cue 2: Generator mit Blur, mit Fade ---- + r = post("/api/layers/cues/add", + {"id": lid, "name": "Ball mit Blur", "fade_ms": 800}) + check("Cue 2 gesichert", r.get("cue", {}).get("name") + == "Ball mit Blur", r) + + # Zustand aendern: Snow, andere Position + post("/api/layers/update", {"id": lid, "name": "snow", "x": 100}) + post("/api/layers/cues/add", + {"id": lid, "name": "Snow rechts", "fade_ms": 0}) + + cues = get("/api/layers/cues?id=%d" % lid) + check("Cue-Liste hat 3", len(cues["cues"]) == 3, + [c["name"] for c in cues["cues"]]) + check("Kein Cue aktiv", cues["index"] == -1) + + # ---- GO: Cue 0 aktiv, harter Schnitt -> Video wieder da ---- + r = post("/api/layers/cues/go", {"id": lid}) + check("GO -> Cue 1", r.get("index") == 0 + and r.get("cue") == "Solo Clip", r) + st = layer_state() + check("Cue 1 angewendet", st["name"] == "cue_clip.mp4" + and st["fx1"] is None and st["alpha"] == 1.0, + "name=%s fx1=%s" % (st["name"], st["fx1"])) + + # ---- GO: Cue 2 mit Fade (Quelle wechselt -> dip-to-black) ---- + r = post("/api/layers/cues/go", {"id": lid}) + check("GO -> Cue 2", r.get("index") == 1 + and r.get("cue") == "Ball mit Blur", r) + # waehrend des Fades: dip-to-black = Phase 1 (0-400ms) runter, + # Quelle wechselt, Phase 2 (400-800ms) hoch zum Ziel + time.sleep(0.2) + mid1 = layer_state() + check("Fade Phase 1 (abwaerts)", 0.2 < mid1["alpha"] < 0.95, + "alpha=%s" % mid1["alpha"]) + time.sleep(0.4) # jetzt in Phase 2 (hoch zum Ziel 0.8) + mid2 = layer_state() + check("Fade Phase 2 (aufwaerts)", 0.05 < mid2["alpha"] < 0.79, + "alpha=%s" % mid2["alpha"]) + time.sleep(0.6) # Fade 800ms komplett abwarten + st = layer_state() + check("Cue 2 angewendet (Generator+Blur, alpha 0.8)", + st["name"] == "ball" and st["fx1"] == "blur" + and abs(st["alpha"] - 0.8) < 0.06, + "name=%s fx1=%s alpha=%s" % (st["name"], st["fx1"], st["alpha"])) + check("Engine laeuft nach Fade", get("/api/status")["error"] is None) + + # ---- Goto: direkt Cue 3 ---- + r = post("/api/layers/cues/goto", {"id": lid, "index": 2}) + check("Goto Cue 3", r.get("cue") == "Snow rechts", r) + st = layer_state() + check("Cue 3 angewendet", st["name"] == "snow" and st["x"] == 100, + "name=%s x=%s" % (st["name"], st["x"])) + + # ---- GO vom letzten Cue: Wrap-around zu Cue 1 ---- + r = post("/api/layers/cues/go", {"id": lid}) + check("Wrap-around zu Cue 1", r.get("index") == 0, r) + + # ---- GO zurueck (-1) ---- + r = post("/api/layers/cues/go", {"id": lid, "steps": -1}) + check("GO zurueck -> letzter Cue", r.get("index") == 2, r) + + # ---- Cue entfernen: Indexkorrektur ---- + r = post("/api/layers/cues/remove", {"id": lid, "cue_id": c1_id}) + check("Cue entfernt", r.get("removed") is True, r) + cues = get("/api/layers/cues?id=%d" % lid) + check("Liste hat noch 2", len(cues["cues"]) == 2, + [c["name"] for c in cues["cues"]]) + + # ---- Auto-Follow: Cue mit follow_ms schaltet automatisch weiter ---- + post("/api/layers/cues/goto", {"id": lid, "index": 0}) # Ball mit Blur + time.sleep(1.2) # Cue 0 hat 800ms Fade -> erst abwarten, dann pruefen + st = layer_state() + check("Vor Auto-Follow: Ball", st["name"] == "ball", st["name"]) + # follow_ms fuer Cue 0 per Neu-Anlegen nicht moeglich -> pruefen ueber + # internen Zustand via Projektdatei reicht; wir nutzen cue 0 (800ms fade) + # und legen danach direkt Auto-Follow-Cue an: Snow mit follow 600ms + post("/api/layers/update", {"id": lid, "name": "snow"}) + r = post("/api/layers/cues/add", + {"id": lid, "name": "Snow kurz", "fade_ms": 0, + "follow_ms": 600}) + check("Auto-Follow-Cue angelegt", r.get("cue", {}).get("follow_ms") + == 600, r) + cues = get("/api/layers/cues?id=%d" % lid) + check("3 Cues fuer Follow-Test", len(cues["cues"]) == 3) + # Liste: [Ball mit Blur(0), Snow rechts(1), Snow kurz(2, follow 600ms)] + # Gehe zum LETZTEN Cue (Snow kurz) -> Follow schaltet auf Index 0 (Wrap) + post("/api/layers/cues/goto", {"id": lid, "index": 2}) + st = layer_state() + check("Follow-Cue aktiv (Snow)", st["name"] == "snow") + time.sleep(1.2) # follow 600ms + Puffer + cues = get("/api/layers/cues?id=%d" % lid) + check("Auto-Follow geschaltet (Wrap zu 0)", cues["index"] == 0, + "index=%s" % cues["index"]) + st = layer_state() + check("Nach Follow: Cue 0 aktiv (Ball, evtl. mitten im Fade)", + st["name"] == "ball" or st["name"] == "snow", st["name"]) + + # ---- DMX Cue-GO: Flanke schaltet weiter ---- + 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, "cue_go_channel": 30, + "signal_loss_policy": "hold", "signal_loss_timeout_s": 10}}) + check("cue_go_channel konfiguriert", bool(r.get("saved")), r) + time.sleep(2) + cues_before = get("/api/layers/cues?id=%d" % lid)["index"] + + seq_counter = [40] + + def send_dmx(val): + seq_counter[0] = (seq_counter[0] + 1) % 256 # Sequenz muss wachsen + dmx = bytearray(512) + dmx[29] = val # Kanal 30 + pkt = (b"Art-Net\x00" + struct.pack("H", 14) + bytes([seq_counter[0]]) + 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() + + send_dmx(255) # steigende Flanke -> GO + time.sleep(0.8) + cues_after = get("/api/layers/cues?id=%d" % lid)["index"] + check("DMX-Flanke: Cue weitergeschaltet", + cues_after != cues_before, + "%s -> %s" % (cues_before, cues_after)) + send_dmx(255) # KEINE neue Flanke (bleibt high) -> kein GO + time.sleep(0.8) + cues_same = get("/api/layers/cues?id=%d" % lid)["index"] + check("Kein GO ohne Flanke", cues_same == cues_after, + "%s == %s" % (cues_same, cues_after)) + send_dmx(0) # fallende Flanke + send_dmx(255) # neue steigende Flanke -> GO + time.sleep(0.8) + cues_next = get("/api/layers/cues?id=%d" % lid)["index"] + check("Neue Flanke: wieder GO", cues_next != cues_same, + "%s -> %s" % (cues_same, cues_next)) + + # ---- Cues in Projekt speichern + laden ---- + r = post("/api/projects/save", {"name": "Cue Show"}) + check("Projekt mit Cues gespeichert", r.get("saved") == "Cue Show", r) + post("/api/layers/remove", {"id": lid}) + check("Layer entfernt", len(get("/api/status")["layers"]) == 0) + r = post("/api/projects/load", {"name": "Cue Show"}) + check("Projekt geladen", r.get("loaded") is True, r) + cues = get("/api/layers/cues?id=%d" % get("/api/status")["layers"][0]["id"]) + check("Cues nach Projekt-Load erhalten", len(cues["cues"]) == 3, + "%d Cues" % len(cues["cues"])) + + # ---- Fehlerfaelle ---- + r = post("/api/layers/cues/go", {"id": 999}) + check("GO auf unbekanntem Layer 400", "error" in r, r) + r = post("/api/layers/cues/add", + {"id": get("/api/status")["layers"][0]["id"], + "state": {"name": "fehlt.mp4", "kind": "video"}}) + check("Cue mit fehlendem Medium 400", "error" in r, r) + + print("") + print("ALLE CUE-E2E-TESTS BESTANDEN") +finally: + stop_server()