Files

294 lines
11 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""E2E: Cue-/Szenenliste pro Layer (GO, Fade, Auto-Follow, DMX-GO, Projekte).
Startet einen echten Server als Subprozess.
"""
import json
import os
import shutil
import socket
import struct
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
ROOT = "/a0/usr/workdir/hms-mediaengine"
PORT = 8097
BASE = "http://localhost:%d" % PORT
proc = None
def start_server() -> bool:
global proc
proc = subprocess.Popen(
["/usr/bin/python3", ROOT + "/run.py", "--port", str(PORT)],
cwd=ROOT, stdout=open("/tmp/hms_cue.log", "ab"),
stderr=subprocess.STDOUT)
deadline = time.time() + 25
while time.time() < deadline:
try:
with urllib.request.urlopen(BASE + "/api/health",
timeout=2) as r:
if json.loads(r.read().decode()).get("status") == "ok":
return True
except Exception: # noqa: BLE001
time.sleep(0.3)
return False
def stop_server():
global proc
if proc is not None:
proc.terminate()
try:
proc.wait(timeout=6)
except subprocess.TimeoutExpired:
proc.kill()
proc = None
time.sleep(0.5)
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)
def layer_state():
return get("/api/status")["layers"][0]
# ---------- Reset ----------
for d in ("media", "thumbs", "projects"):
shutil.rmtree(os.path.join(ROOT, d), ignore_errors=True)
try:
os.remove(os.path.join(ROOT, "config.json"))
except FileNotFoundError:
pass
try:
check("Server startet", start_server())
# Testvideo + 2 Layer anlegen (Video + Generator)
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=90 pattern=18 ! "
"video/x-raw,width=320,height=180 ! x264enc tune=zerolatency ! "
"mp4mux ! filesink location=/tmp/e2e_cue.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_cue.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("cue_clip.mp4"))
with urllib.request.urlopen(req, timeout=120) as r:
json.loads(r.read().decode())
r = post("/api/layers/add", {"name": "cue_clip.mp4"})
check("Layer 1 (Video)", "layer" in r, r)
lid = r["layer"]["id"]
# ---- Cue 1: aktueller Zustand (Video voll sichtbar, hart) ----
r = post("/api/layers/cues/add",
{"id": lid, "name": "Solo Clip", "fade_ms": 0})
check("Cue 1 gesichert", r.get("cue", {}).get("name") == "Solo Clip", r)
c1_id = r["cue"]["cue_id"]
# Zustand aendern: Generator + Blur + Alpha 0.8
r = post("/api/layers/update",
{"id": lid, "name": "ball", "fx1": "blur",
"alpha": 0.8})
check("Zustand geaendert", r.get("layer", {}).get("name") == "ball", r)
# ---- Cue 2: Generator mit Blur, mit Fade ----
r = post("/api/layers/cues/add",
{"id": lid, "name": "Ball mit Blur", "fade_ms": 800})
check("Cue 2 gesichert", r.get("cue", {}).get("name")
== "Ball mit Blur", r)
# Zustand aendern: Snow, andere Position
post("/api/layers/update", {"id": lid, "name": "snow", "x": 100})
post("/api/layers/cues/add",
{"id": lid, "name": "Snow rechts", "fade_ms": 0})
cues = get("/api/layers/cues?id=%d" % lid)
check("Cue-Liste hat 3", len(cues["cues"]) == 3,
[c["name"] for c in cues["cues"]])
check("Kein Cue aktiv", cues["index"] == -1)
# ---- GO: Cue 0 aktiv, harter Schnitt -> Video wieder da ----
r = post("/api/layers/cues/go", {"id": lid})
check("GO -> Cue 1", r.get("index") == 0
and r.get("cue") == "Solo Clip", r)
st = layer_state()
check("Cue 1 angewendet", st["name"] == "cue_clip.mp4"
and st["fx1"] is None and st["alpha"] == 1.0,
"name=%s fx1=%s" % (st["name"], st["fx1"]))
# ---- GO: Cue 2 mit Fade (Quelle wechselt -> dip-to-black) ----
r = post("/api/layers/cues/go", {"id": lid})
check("GO -> Cue 2", r.get("index") == 1
and r.get("cue") == "Ball mit Blur", r)
# waehrend des Fades: dip-to-black = Phase 1 (0-400ms) runter,
# Quelle wechselt, Phase 2 (400-800ms) hoch zum Ziel
time.sleep(0.2)
mid1 = layer_state()
check("Fade Phase 1 (abwaerts)", 0.2 < mid1["alpha"] < 0.95,
"alpha=%s" % mid1["alpha"])
time.sleep(0.4) # jetzt in Phase 2 (hoch zum Ziel 0.8)
mid2 = layer_state()
check("Fade Phase 2 (aufwaerts)", 0.05 < mid2["alpha"] < 0.79,
"alpha=%s" % mid2["alpha"])
time.sleep(0.6) # Fade 800ms komplett abwarten
st = layer_state()
check("Cue 2 angewendet (Generator+Blur, alpha 0.8)",
st["name"] == "ball" and st["fx1"] == "blur"
and abs(st["alpha"] - 0.8) < 0.06,
"name=%s fx1=%s alpha=%s" % (st["name"], st["fx1"], st["alpha"]))
check("Engine laeuft nach Fade", get("/api/status")["error"] is None)
# ---- Goto: direkt Cue 3 ----
r = post("/api/layers/cues/goto", {"id": lid, "index": 2})
check("Goto Cue 3", r.get("cue") == "Snow rechts", r)
st = layer_state()
check("Cue 3 angewendet", st["name"] == "snow" and st["x"] == 100,
"name=%s x=%s" % (st["name"], st["x"]))
# ---- GO vom letzten Cue: Wrap-around zu Cue 1 ----
r = post("/api/layers/cues/go", {"id": lid})
check("Wrap-around zu Cue 1", r.get("index") == 0, r)
# ---- GO zurueck (-1) ----
r = post("/api/layers/cues/go", {"id": lid, "steps": -1})
check("GO zurueck -> letzter Cue", r.get("index") == 2, r)
# ---- Cue entfernen: Indexkorrektur ----
r = post("/api/layers/cues/remove", {"id": lid, "cue_id": c1_id})
check("Cue entfernt", r.get("removed") is True, r)
cues = get("/api/layers/cues?id=%d" % lid)
check("Liste hat noch 2", len(cues["cues"]) == 2,
[c["name"] for c in cues["cues"]])
# ---- Auto-Follow: Cue mit follow_ms schaltet automatisch weiter ----
post("/api/layers/cues/goto", {"id": lid, "index": 0}) # Ball mit Blur
time.sleep(1.2) # Cue 0 hat 800ms Fade -> erst abwarten, dann pruefen
st = layer_state()
check("Vor Auto-Follow: Ball", st["name"] == "ball", st["name"])
# follow_ms fuer Cue 0 per Neu-Anlegen nicht moeglich -> pruefen ueber
# internen Zustand via Projektdatei reicht; wir nutzen cue 0 (800ms fade)
# und legen danach direkt Auto-Follow-Cue an: Snow mit follow 600ms
post("/api/layers/update", {"id": lid, "name": "snow"})
r = post("/api/layers/cues/add",
{"id": lid, "name": "Snow kurz", "fade_ms": 0,
"follow_ms": 600})
check("Auto-Follow-Cue angelegt", r.get("cue", {}).get("follow_ms")
== 600, r)
cues = get("/api/layers/cues?id=%d" % lid)
check("3 Cues fuer Follow-Test", len(cues["cues"]) == 3)
# Liste: [Ball mit Blur(0), Snow rechts(1), Snow kurz(2, follow 600ms)]
# Gehe zum LETZTEN Cue (Snow kurz) -> Follow schaltet auf Index 0 (Wrap)
post("/api/layers/cues/goto", {"id": lid, "index": 2})
st = layer_state()
check("Follow-Cue aktiv (Snow)", st["name"] == "snow")
time.sleep(1.2) # follow 600ms + Puffer
cues = get("/api/layers/cues?id=%d" % lid)
check("Auto-Follow geschaltet (Wrap zu 0)", cues["index"] == 0,
"index=%s" % cues["index"])
st = layer_state()
check("Nach Follow: Cue 0 aktiv (Ball, evtl. mitten im Fade)",
st["name"] == "ball" or st["name"] == "snow", st["name"])
# ---- DMX Cue-GO: Flanke schaltet weiter ----
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, "cue_go_channel": 30,
"signal_loss_policy": "hold", "signal_loss_timeout_s": 10}})
check("cue_go_channel konfiguriert", bool(r.get("saved")), r)
time.sleep(2)
cues_before = get("/api/layers/cues?id=%d" % lid)["index"]
seq_counter = [40]
def send_dmx(val):
seq_counter[0] = (seq_counter[0] + 1) % 256 # Sequenz muss wachsen
dmx = bytearray(512)
dmx[29] = val # Kanal 30
pkt = (b"Art-Net\x00" + struct.pack("<H", 0x5000)
+ struct.pack(">H", 14) + bytes([seq_counter[0]]) + 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()
send_dmx(255) # steigende Flanke -> GO
time.sleep(0.8)
cues_after = get("/api/layers/cues?id=%d" % lid)["index"]
check("DMX-Flanke: Cue weitergeschaltet",
cues_after != cues_before,
"%s -> %s" % (cues_before, cues_after))
send_dmx(255) # KEINE neue Flanke (bleibt high) -> kein GO
time.sleep(0.8)
cues_same = get("/api/layers/cues?id=%d" % lid)["index"]
check("Kein GO ohne Flanke", cues_same == cues_after,
"%s == %s" % (cues_same, cues_after))
send_dmx(0) # fallende Flanke
send_dmx(255) # neue steigende Flanke -> GO
time.sleep(0.8)
cues_next = get("/api/layers/cues?id=%d" % lid)["index"]
check("Neue Flanke: wieder GO", cues_next != cues_same,
"%s -> %s" % (cues_same, cues_next))
# ---- Cues in Projekt speichern + laden ----
r = post("/api/projects/save", {"name": "Cue Show"})
check("Projekt mit Cues gespeichert", r.get("saved") == "Cue Show", r)
post("/api/layers/remove", {"id": lid})
check("Layer entfernt", len(get("/api/status")["layers"]) == 0)
r = post("/api/projects/load", {"name": "Cue Show"})
check("Projekt geladen", r.get("loaded") is True, r)
cues = get("/api/layers/cues?id=%d" % get("/api/status")["layers"][0]["id"])
check("Cues nach Projekt-Load erhalten", len(cues["cues"]) == 3,
"%d Cues" % len(cues["cues"]))
# ---- Fehlerfaelle ----
r = post("/api/layers/cues/go", {"id": 999})
check("GO auf unbekanntem Layer 400", "error" in r, r)
r = post("/api/layers/cues/add",
{"id": get("/api/status")["layers"][0]["id"],
"state": {"name": "fehlt.mp4", "kind": "video"}})
check("Cue mit fehlendem Medium 400", "error" in r, r)
print("")
print("ALLE CUE-E2E-TESTS BESTANDEN")
finally:
stop_server()