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:
+4
-1
@@ -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):
|
||||
|
||||
+159
-2
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -192,6 +192,10 @@ function renderLayerEdit(ls){const e=$('ledit');
|
||||
<div class="frow"><label>Position X</label><input type="number" id="lx${L.id}" value="${L.x}" onchange="api('layers/update',{id:${L.id},x:this.value})"> Y <input type="number" id="ly${L.id}" value="${L.y}" onchange="api('layers/update',{id:${L.id},y:this.value})"></div>
|
||||
<div class="frow"><label>Größe</label><input type="number" id="lw${L.id}" value="${L.width}" onchange="api('layers/update',{id:${L.id},width:this.value})"> × <input type="number" id="lh${L.id}" value="${L.height}" onchange="api('layers/update',{id:${L.id},height:this.value})"> px</div>
|
||||
<div class="frow"><label>Z-Order</label><input type="number" id="lz${L.id}" min="0" max="15" value="${L.z}" onchange="api('layers/update',{id:${L.id},z:this.value})"></div>
|
||||
<div class="frow"><label>Effekt 1</label><select id="fx1-${L.id}" onchange="api('layers/update',{id:${L.id},fx1:this.value||null})"></select></div>
|
||||
<div class="frow"><label>FX1 Intensität</label><input type="range" id="fx1i-${L.id}" min="0" max="100" value="${Math.round((L.fx1_intensity??0.5)*100)}" oninput="api('layers/update',{id:${L.id},fx1_intensity:this.value/100})"></div>
|
||||
<div class="frow"><label>Effekt 2</label><select id="fx2-${L.id}" onchange="api('layers/update',{id:${L.id},fx2:this.value||null})"></select></div>
|
||||
<div class="frow"><label>FX2 Intensität</label><input type="range" id="fx2i-${L.id}" min="0" max="100" value="${Math.round((L.fx2_intensity??0.5)*100)}" oninput="api('layers/update',{id:${L.id},fx2_intensity:this.value/100})"></div>
|
||||
</div>`).join('')||'<p style="color:var(--dim)">Keine aktiven Layer. Medien-Tab öffnen und Datei als Layer hinzufügen.</p>'}}
|
||||
|
||||
// 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='<option value="">– keiner –</option>'+FX.map(f=>`<option value="${f.id}"${f.id===val?' selected':''}>${f.label}</option>`).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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -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", 0x5000)
|
||||
+ struct.pack(">H", 14) + bytes([9]) + b"\x00"
|
||||
+ struct.pack("<H", 0) + 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")
|
||||
Reference in New Issue
Block a user