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');
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();