EFFECTS LIVE: 21 Effekte in der Pipeline (FX1/FX2 je Layer, 16/16 E2E)

Echte GPU-nahe GStreamer-Effekte, zur Laufzeit pro Layer schaltbar:
- FX_CATALOG: 21 verfuegbare Effekte (gst-inspect verifiziert):
  blur, sharpen, color, contrast, brightness, gamma, pixelate,
  solarize, vertigo, edge, ripple, radioactv, revtv, quark, op,
  burn, dilate, warp, streak, aging, exclusion
- FX1 + FX2 pro Layer (Kette: videoconvert ! FX1 ! videoconvert !
  FX2 ! videoconvert - Muster gegen gst-launch verifiziert)
- Intensitaet je FX live regelbar OHNE Rebuild
  (Property-Update direkt am Element, Frames laufen weiter 118->120)
- Strukturwechsel (FX waehlen/entfernen) -> atomarer Rebuild mit
  Rollback; Intensitaeten werden auf neue Pipeline uebertragen
- DMX: fx1_channel/fx2_channel optional konfigurierbar -> steuert
  FX-Intensitaet live (E2E: Kanal 20 = 255 -> Intensitaet 1.0)
- UI: Layer-Editor mit FX1/FX2-Dropdown (Katalog via /api/fx)
  + Intensitaets-Slider je FX
- /api/fx: Effekt-Katalog mit Label/Bereich fuer die UI
- config.py: fx1_channel/fx2_channel in Validierung (optional, 1-512)
- Ungueltige FX-IDs werden abgewiesen (fallback None)

E2E-BEWEIS (16/16 PASS, im Container):
1 Health · 2 Katalog 21 Effekte · 3-8 alle Kern-FX enthalten ·
9 Layer · 10 FX1 blur -> Rebuild laeuft (118 Frames) ·
11 Intensitaet live ohne Ruckler · 12 FX2 color dazu (161 Frames) ·
13 FX entfernen ok (180 Frames) · 14 fx1_channel konfiguriert ·
15 DMX steuert FX-Intensitaet auf 1.0 · 16 ungueltiger FX abgewiesen

Regression: kompletter App-E2E weiterhin 14/14 PASS (app=0 fx=0)
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 12:01:22 +02:00
parent 68ce87e5ac
commit 10b988c33a
5 changed files with 351 additions and 3 deletions
+159 -2
View File
@@ -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)