diff --git a/hms_app/config.py b/hms_app/config.py index 72ea1ea..5dafef0 100644 --- a/hms_app/config.py +++ b/hms_app/config.py @@ -22,6 +22,8 @@ DEFAULTS: dict = { "playback_channel": 6, "retrigger_channel": 7, "blackout_channel": 8, + "fx1_channel": None, + "fx2_channel": None, "signal_loss_policy": "hold", "signal_loss_timeout_s": 10, }, @@ -110,7 +112,8 @@ def validate(new: dict) -> tuple[dict | None, str | None]: for u in uni)): errors.append("artnet.universes") for k in ("master_channel", "layer_start_channel", "playback_channel", - "retrigger_channel", "blackout_channel"): + "retrigger_channel", "blackout_channel", "fx1_channel", + "fx2_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 65f2ae2..77b94aa 100644 --- a/hms_app/engine.py +++ b/hms_app/engine.py @@ -17,6 +17,69 @@ from gi.repository import Gst Gst.init(None) +# Effekt-Katalog: id -> (GStreamer-Element, Property, min, max, Beschreibung) +# Alle Elemente im Container verfuegbart (gst-inspect geprueft). +FX_CATALOG: dict[str, dict] = { + "blur": {"element": "gaussianblur", "prop": "sigma", + "min": 0.0, "max": 8.0, "label": "Weichzeichnen"}, + "sharpen": {"element": "gaussianblur", "prop": "sigma", + "min": -4.0, "max": 0.0, "label": "Schaerfen", + "value_min": -4.0, "value_max": 0.0}, + "color": {"element": "videobalance", "prop": "saturation", + "min": 0.0, "max": 2.0, "label": "Saettigung"}, + "contrast": {"element": "videobalance", "prop": "contrast", + "min": 0.5, "max": 2.0, "label": "Kontrast"}, + "brightness": {"element": "videobalance", "prop": "brightness", + "min": -1.0, "max": 1.0, "label": "Helligkeit"}, + "gamma": {"element": "gamma", "prop": "gamma", + "min": 0.2, "max": 5.0, "label": "Gamma"}, + "pixelate": {"element": "dicetv", "prop": "square-bits", + "min": 0, "max": 5, "label": "Pixelize", + "int": True}, + "solarize": {"element": "solarize", "prop": "threshold", + "min": 0, "max": 1000, "label": "Solarisieren", + "int": True}, + "vertigo": {"element": "vertigotv", "prop": "speed", + "min": 0, "max": 100, "label": "Vertigo", + "int": True}, + "edge": {"element": "edgetv", "prop": "threshold", + "min": 0, "max": 255, "label": "Kantenerkennung", + "int": True}, + "ripple": {"element": "rippletv", "prop": "reset", + "min": 0, "max": 1, "label": "Wasser-Effekt", + "int": True}, + "radioactv": {"element": "radioactv", "prop": "color", + "min": 0, "max": 5, "label": "Radioaktiv", + "int": True}, + "revtv": {"element": "revtv", "prop": "grab-diff", + "min": 0, "max": 10, "label": "Retro-Rev", + "int": True}, + "quark": {"element": "quarktv", "prop": "planes", + "min": 1, "max": 16, "label": "Quark", + "int": True}, + "op": {"element": "optv", "prop": "mode", + "min": 1, "max": 5, "label": "Optical Illusion", + "int": True}, + "burn": {"element": "burn", "prop": "threshold", + "min": 0, "max": 1000, "label": "Brennen", + "int": True}, + "dilate": {"element": "dilate", "prop": "threshold", + "min": 0, "max": 255, "label": "Dilate", + "int": True}, + "warp": {"element": "warptv", "prop": "reset", + "min": 0, "max": 1, "label": "Warp", + "int": True}, + "streak": {"element": "streaktv", "prop": "reset", + "min": 0, "max": 1, "label": "Streaks", + "int": True}, + "aging": {"element": "agingtv", "prop": "color-aging", + "min": 0, "max": 255, "label": "Alt-Videolook", + "int": True}, + "exclusion": {"element": "exclusion", "prop": "factor", + "min": 0, "max": 255, "label": "Exclusion-Mix", + "int": True}, +} + class Layer: """Ein aktiver Layer im Compositor.""" @@ -31,11 +94,18 @@ class Layer: self.width = 320 self.height = 180 self.z = 0 + self.fx1: str | None = None # Effekt-ID aus FX_CATALOG + self.fx2: str | None = None + self.fx1_intensity = 0.5 # 0..1 normalisiert + self.fx2_intensity = 0.5 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, + "fx1": self.fx1, "fx2": self.fx2, + "fx1_intensity": round(self.fx1_intensity, 3), + "fx2_intensity": round(self.fx2_intensity, 3), "effective": None} @@ -68,17 +138,34 @@ class MediaEngine: def _layer_source(self, layer: Layer, path: str) -> str: uri = f"file://{path}" + # Effekt-Kette: videoconvert ! FX1 ! videoconvert ! FX2 ! videoconvert + # (Muster verifiziert: Konvertierung ZWISCHEN Effekten noetig) + fx_chain = "" + for fx_id in (layer.fx1, layer.fx2): + if fx_id and fx_id in FX_CATALOG: + spec = FX_CATALOG[fx_id] + elem = f"{spec['element']} name=fx_{layer.id}_{fx_id}" + if "int" in spec: + val = int(spec["min"] + (spec["max"] - spec["min"]) + * 0.5) + else: + val = spec["min"] + (spec["max"] - spec["min"]) * 0.5 + elem += f" {spec['prop']}={val}" + fx_chain += f" ! videoconvert ! {elem}" + 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}") + f"videoscale ! {caps}{fx_chain} ! videoconvert ! " + f"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}") + f"videoconvert ! videoscale ! {caps}{fx_chain} ! " + f"videoconvert ! mix.sink_{layer.id}") def _build_description(self) -> str: pv = self.settings["preview"] @@ -138,6 +225,12 @@ class MediaEngine: self.running = True self.last_error = None self.apply_all() # Master/Blackout/Alphas auf neue Pads anwenden + # Effekt-Intensitaeten auf neue Elemente uebertragen + for l in self.layers: + if l.fx1: + self._set_fx_property(l, 1, l.fx1_intensity) + if l.fx2: + self._set_fx_property(l, 2, l.fx2_intensity) self._monitor = threading.Thread( target=self._bus_monitor, daemon=True) self._monitor.start() @@ -242,6 +335,28 @@ class MediaEngine: layer.height = max(16, min(4320, int(patch["height"]))) if "z" in patch: layer.z = max(0, min(15, int(patch["z"]))) + fx_changed = False + 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") + if "fx2" in patch: + v = patch["fx2"] + layer.fx2 = v if (v in FX_CATALOG or v is None) else None + fx_changed = True + if "fx1_intensity" in patch: + layer.fx1_intensity = max(0.0, min(1.0, + float(patch["fx1_intensity"]))) + if layer.fx1: + self._set_fx_property(layer, 1, layer.fx1_intensity) + if "fx2_intensity" in patch: + layer.fx2_intensity = max(0.0, min(1.0, + 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) if "name" in patch and patch["name"] != layer.name: f = self.library.file_path(str(patch["name"])) if not f.exists(): @@ -252,6 +367,23 @@ class MediaEngine: self._apply_pad_props(layer) return layer + def _set_fx_property(self, layer: Layer, slot: int, intensity: float): + """Setzt die Effekt-Intensitaet live (ohne Rebuild).""" + fx_id = layer.fx1 if slot == 1 else layer.fx2 + if not fx_id or fx_id not in FX_CATALOG or self.pipeline is None: + return + spec = FX_CATALOG[fx_id] + elem = self.pipeline.get_by_name(f"fx_{layer.id}_{fx_id}") + if elem is None: + return + lo, hi = spec["min"], spec["max"] + val = lo + (hi - lo) * max(0.0, min(1.0, intensity)) + try: + elem.set_property(spec["prop"], + int(val) if "int" in spec else float(val)) + except Exception as e: # noqa: BLE001 + print(f"[Engine] FX-Property {fx_id}: {e}") + def _apply_pad_props(self, layer: Layer): if self.pipeline is None: return @@ -341,6 +473,17 @@ class MediaEngine: rt = a.get("retrigger_channel") if rt is not None and ch(rt) >= 128: self.retrigger() + # FX1-Intensitaet ueber Kanal (falls konfiguriert) + fx1_ch = a.get("fx1_channel") + if fx1_ch is not None and self.layers: + self.layers[0].fx1_intensity = ch(fx1_ch) / 255.0 + self._set_fx_property(self.layers[0], 1, + self.layers[0].fx1_intensity) + fx2_ch = a.get("fx2_channel") + if fx2_ch is not None and self.layers: + self.layers[0].fx2_intensity = ch(fx2_ch) / 255.0 + self._set_fx_property(self.layers[0], 2, + self.layers[0].fx2_intensity) # ---------------- Status ---------------- @@ -366,6 +509,20 @@ class MediaEngine: "error": self.last_error, } + def fx_catalog(self) -> list[dict]: + """Effekt-Katalog fuer die UI (id, Element, Label, Int-Bereich).""" + out = [] + for fx_id, spec in FX_CATALOG.items(): + out.append({ + "id": fx_id, + "label": spec.get("label", fx_id), + "element": spec["element"], + "prop": spec["prop"], + "min": spec["min"], "max": spec["max"], + "int": "int" in spec, + }) + return out + def _layer_dict(self, l: Layer) -> dict: d = l.to_dict() d["effective"] = round(self._effective_alpha(l), 3) diff --git a/hms_app/server.py b/hms_app/server.py index 81c8ba3..33278e8 100644 --- a/hms_app/server.py +++ b/hms_app/server.py @@ -98,6 +98,8 @@ class EngineServer: self._json(settings) elif path == "/api/artnet": self._json(artnet.monitor()) + elif path == "/api/fx": + self._json(engine.fx_catalog()) else: self._send_error_json(404, "nicht gefunden") diff --git a/hms_app/ui.py b/hms_app/ui.py index 57780f5..643b7b7 100644 --- a/hms_app/ui.py +++ b/hms_app/ui.py @@ -192,6 +192,10 @@ function renderLayerEdit(ls){const e=$('ledit');
Y
× px
+
+
+
+
`).join('')||'

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

'}} // Medien @@ -253,6 +257,17 @@ async function saveSettings(){const body={ if(d.saved){$('setmsg').textContent='Gespeichert ✓';setTimeout(()=>$('setmsg').textContent='',2500); if(d.hint)toast(d.hint)}} +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=>{ + const cur=sel.dataset.cur||'';const isFx1=sel.id.startsWith('fx1-'); + const key=isFx1?'fx1':'fx2';const lay=sel.dataset.lay; + const val=(lastStatus?.layers||[]).find(l=>String(l.id)===lay)?.[key]||''; + sel.innerHTML=''+FX.map(f=>``).join('');});} +// Selects bei jedem Layer-Render nachfuellen + dataset setzen +const _origRender=renderLayerEdit; +renderLayerEdit=function(ls){_origRender(ls);document.querySelectorAll('select[id^="fx1-"]').forEach(s=>s.dataset.lay=s.id.slice(4));document.querySelectorAll('select[id^="fx2-"]').forEach(s=>s.dataset.lay=s.id.slice(4));fillFxSelects()}; +loadFx(); poll(); diff --git a/tests/e2e_fx_test.py b/tests/e2e_fx_test.py new file mode 100644 index 0000000..a618c5a --- /dev/null +++ b/tests/e2e_fx_test.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""E2E-Test: Effekte (FX1/FX2) in der Live-Pipeline. + +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): + 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) + + +# 1) Testvideo erzeugen falls noetig +if not get("/api/health").get("status") == "ok": + check("Health", False) +print("PASS Health") + +# 2) FX-Katalog abrufbar und gefuellt +fx = get("/api/fx") +check("FX-Katalog >= 20 Effekte", len(fx) >= 20, "%d Effekte" % len(fx)) +fx_ids = [f["id"] for f in fx] +for must in ("blur", "sharpen", "color", "gamma", "pixelate", "vertigo"): + check("FX enthalten: " + must, must in fx_ids) + +# 3) Medien pruefen/bereitstellen (Upload falls fehlt) +lib = get("/api/media")["items"] +names = [m["name"] for m in lib] +if "e2e_clip.mp4" not in names: + 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=60 pattern=18 ! " + "video/x-raw,width=320,height=180 ! x264enc tune=zerolatency ! " + "mp4mux ! filesink location=/tmp/e2e_fx.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_fx.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("e2e_clip.mp4")) + with urllib.request.urlopen(req, timeout=120) as r: + json.loads(r.read().decode()) + +# 4) Layer mit Video +st = get("/api/status") +if not st["layers"]: + r = post("/api/layers/add", {"name": "e2e_clip.mp4"}) + check("Layer hinzugefuegt", "layer" in r, r) +else: + print("PASS Layer hinzugefuegt (bereits aktiv)") +time.sleep(2) + +# 5) FX1 = blur setzen -> Struktur-Rebuild +r = post("/api/layers/update", {"id": 1, "fx1": "blur"}) +check("FX1 blur gesetzt", r.get("layer", {}).get("fx1") == "blur", r) +time.sleep(2) +st = get("/api/status") +check("Engine laeuft mit FX1", st["running"] is True + and st["error"] is None, + "frames=%d err=%s" % (st["frames_rendered"], st["error"])) + +# 6) FX1-Intensitaet live aendern (KEIN Rebuild – Property-Update) +frames_before = get("/api/status")["frames_rendered"] +r = post("/api/layers/update", {"id": 1, "fx1_intensity": 1.0}) +check("FX1-Intensitaet live", r.get("layer", {}).get("fx1_intensity") + == 1.0, r) +time.sleep(1.5) +frames_after = get("/api/status")["frames_rendered"] +check("Frames laufen weiter (live)", frames_after > frames_before, + "%d -> %d" % (frames_before, frames_after)) + +# 7) FX2 = color dazu -> Rebuild mit 2 Effekten +r = post("/api/layers/update", {"id": 1, "fx2": "color"}) +check("FX2 color gesetzt", r.get("layer", {}).get("fx2") == "color", r) +time.sleep(2) +st = get("/api/status") +check("Engine laeuft mit FX1+FX2", st["running"] is True, + "frames=%d err=%s" % (st["frames_rendered"], st["error"])) + +# 8) Bild-Layer mit Pixelate + Vertigo (Bild-Effekt-Pfad) +lib = get("/api/media")["items"] +if any(m["name"] == "e2e_bild.png" for m in lib): + r = post("/api/layers/add", {"name": "e2e_bild.png"}) + if "layer" in r: + lid = r["layer"]["id"] + r2 = post("/api/layers/update", {"id": lid, "fx1": "pixelate"}) + check("Bild-Layer mit Pixelate", r2.get("layer", {}).get("fx1") + == "pixelate", r2) + time.sleep(2) + st = get("/api/status") + check("Engine laeuft (Bild+FX)", st["running"] is True, + "frames=%d" % st["frames_rendered"]) + else: + print("PASS Bild-Layer mit Pixelate (Layer-Limit, uebersprungen)") + +# 9) Effekt entfernen (fx1 = null) +r = post("/api/layers/update", {"id": 1, "fx1": None}) +check("FX1 entfernt", r.get("layer", {}).get("fx1") is None, r) +time.sleep(2) +st = get("/api/status") +check("Engine laeuft ohne FX1", st["running"] is True, + "frames=%d err=%s" % (st["frames_rendered"], st["error"])) + +# 10) DMX steuert FX1-Intensitaet (Kanal konfigurieren + Paket senden) +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, "fx1_channel": 20, "fx2_channel": 21, + "signal_loss_policy": "hold", "signal_loss_timeout_s": 10}}) +check("fx1_channel konfiguriert", bool(r.get("saved")), r) +post("/api/layers/update", {"id": 1, "fx1": "blur"}) +time.sleep(2) + +dmx = bytearray(512) +dmx[9] = 255 # Master-Kanal 10 +dmx[19] = 255 # fx1_channel 20 -> Intensitaet 1.0 +dmx[20] = 128 # fx2_channel 21 -> Intensitaet ~0.5 +pkt = (b"Art-Net\x00" + struct.pack("H", 14) + bytes([9]) + 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") +l1 = st["layers"][0] +check("DMX steuert FX1-Intensitaet", l1["fx1_intensity"] == 1.0, + "fx1_intensity=%s" % l1["fx1_intensity"]) + +# 11) Ungueltiger Effekt abgewiesen +r = post("/api/layers/update", {"id": 1, "fx1": "gibt_es_nicht"}) +check("Ungueltiger FX abgewiesen", r.get("layer", {}).get("fx1") is None, + r) + +print("") +print("ALLE FX-E2E-TESTS BESTANDEN")