Files
hms-mediaengine/tests/e2e_fx_test.py
T
HMS MediaEngine Agent 10b988c33a 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)
2026-09-11 12:01:22 +02:00

172 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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")