Files
HMS MediaEngine Agent 02765e6b34 CUE-SZENENLISTE PRO LAYER + macOS-SUPPORT (35/35 E2E, 123 Checks gesamt)
1) Cue-/Szenenliste JE Layer (35/35 E2E):
- Cue sichern = Snapshot (Quelle, Alpha, Position, Groesse, Z-Order,
  FX1/FX2 + Intensitaeten); Name + fade_ms + follow_ms pro Cue
- GO vor/zurueck (steps), Goto (Index), Wrap-around am Listenende
- FADE: Quellwechsel = dip-to-black (Phase1 runter, Wechsel bei 0,
  Phase2 hoch; E2E: alpha 0.49 -> 0.32 -> Ziel 0.8), sonst sanfter
  Tween von Alpha + FX-Intensitaeten; laufende Fades abbrechbar
- AUTO-FOLLOW: follow_ms schaltet nach Ablauf automatisch weiter
  (E2E: Cue mit 600ms -> Wrap zum naechsten)
- DMX CUE-GO: cue_go_channel (Settings + UI), steigende Flanke
  >=128 = GO auf allen Layern; ohne Flanke kein GO (E2E: 0->1,
  1==1 bei High-Halten, neue Flanke 1->0); Sequenzfilter aktiv
- Cue-Entfernen mit Indexkorrektur; Cues in Projekten
  speicher-/ladbar (3 Cues nach Projekt-Load erhalten)
- UI: Cue-Bereich je Layer (Cue sichern / GO / Zurueck / Goto /
  Loeschen, aktiver Cue markiert, Fade+Follow-Anzeige)
- Fehlerfaelle: unbekannter Layer 400, fehlendes Medium 400,
  unbekannter Generator 400

2) macOS (Apple Silicon + Intel) mit Skript:
- setup_macos.sh: Homebrew installiert python@3.12, python-tk,
  GStreamer + alle Plugin-Sets + PyGObject; Apple-Silicon-PATH-
  Hinweis (/opt/homebrew); Erklaerung warum Homebrew statt
  gstreamer-.pkg (keine PyGObject-Bindings in .pkg)
- Bootstrap (--bootstrap): darwin-Zweig mit brew-Installationspfad
- run.py --generate-setup erzeugt jetzt 3 Skripte
  (Windows .ps1 / Linux .sh / macOS .sh); Bash-Syntax geprueft

3) Gefundene + behobene FEHLER:
- WEB-UI-ABSTURZ (echter JS-Ternary-Klammerfehler in loadServers,
  seit Verteilungs-Commit unentdeckt, weil API-Tests den Browser-
  Code nie parsen): Klammerstruktur korrigiert + JS-Syntax nun per
  node --check verifizierbar (Syntax OK) + Remote-Buttons robust
  per data-Attribute (kein Quote-Escaping mehr)
- update_layer blockierte Quellwechsel Video->Generator (Cues
  brauchen beides): Name entscheidet jetzt den Typ, Wechsel in
  beide Richtungen
- Cue-E2E-Timing: dip-to-black hat 2 Phasen (Pruefung 0.2s/0.6s),
  Auto-Follow-Index-Fix, Art-Net-Sequenznummern im Test
  inkrementierend (Sequenzfilter verwirft Duplikate korrekt)

E2E-Bilanz: Cue 35/35 + Regressionen Projekte 25/25,
Verteilung 33/33, App 14/14, FX 16/16 = 123 Checks gruen
2026-09-11 13:34:43 +02:00

460 lines
21 KiB
Python

"""HTTP-Server: Web-UI, MJPEG-Stream, REST-API, Upload, Thumbnails, Settings."""
from __future__ import annotations
import copy
import json
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import config as cfgmod
from . import distribution
from .projects import ProjectStore
from .ui import HTML_UI
class EngineServer:
"""Verbindet Engine, Art-Net, Medienbibliothek und Konfiguration mit HTTP."""
def __init__(self, engine, artnet, library, settings: dict, root):
self.engine = engine
self.artnet = artnet
self.library = library
self.settings = settings
self.root = root
self.projects = ProjectStore(root)
self.httpd: ThreadingHTTPServer | None = None
self.running = False
def start(self, port=None) -> bool:
port = port or int(self.settings["web"]["port"])
bind = str(self.settings["web"].get("bind", "0.0.0.0"))
try:
self.httpd = ThreadingHTTPServer((bind, port),
self._make_handler())
except OSError as e:
print(f"[Server] FEHLER: {bind}:{port} nicht bindbar: {e}")
return False
self.running = True
threading.Thread(target=self.httpd.serve_forever,
daemon=True).start()
print(f"[Server] UI: http://localhost:{port}/")
print(f"[Server] Preview: http://localhost:{port}/stream.mjpg")
print(f"[Server] API: http://localhost:{port}/api/status")
return True
def stop(self):
self.running = False
if self.httpd is not None:
self.httpd.shutdown()
self.httpd = None
def restart_artnet(self):
self.artnet.restart()
# ---------------- Handler ----------------
def _make_handler(self):
engine = self.engine
artnet = self.artnet
library = self.library
server = self
settings = self.settings
root = self.root
class Handler(BaseHTTPRequestHandler):
# ---------- GET ----------
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
query = urllib.parse.parse_qs(parsed.query)
if path == "/":
self._send_html()
elif path == "/stream.mjpg":
self._stream()
elif path == "/api/status":
self._json(server._status())
elif path == "/api/health":
self._json({"status": "ok"})
elif path == "/api/media":
self._json({"items": library.scan(),
"limits": settings["media"]})
elif path.startswith("/thumb/"):
# Thumbnail heisst <stem>.jpg; Anfrage kommt mit
# Originaldateinamen (z. B. /thumb/clip.mp4)
name = urllib.parse.unquote(path[len("/thumb/"):])
self._send_file(library.thumb_path(name), "image/jpeg")
elif path.startswith("/media/"):
name = urllib.parse.unquote(path[len("/media/"):])
f = library.safe_file(name)
ctype = ("image/png" if name.lower().endswith(".png")
else "image/jpeg" if name.lower().endswith(
(".jpg", ".jpeg"))
else "image/bmp" if name.lower().endswith(".bmp")
else "video/mp4")
self._send_file(f, ctype, download_name=name)
elif path == "/api/settings":
self._json(settings)
elif path == "/api/artnet":
self._json(artnet.monitor())
elif path == "/api/fx":
self._json(engine.fx_catalog())
elif path == "/api/generators":
self._json({"items": engine.generators()})
elif path == "/api/layers/cues":
lid = int((query.get("id") or [-1])[0])
data = engine.layer_cues(lid)
if data is None:
self._send_error_json(404, engine.last_error)
else:
self._json(data)
elif path == "/api/servers":
out = []
for s_ in settings.get("servers", []):
entry = {"name": s_.get("name"),
"url": s_.get("url"),
"online": False, "items": []}
try:
data = distribution.list_media(
str(s_.get("url", "")))
entry["online"] = True
entry["items"] = data.get("items", [])
except Exception: # noqa: BLE001
pass
out.append(entry)
self._json({"servers": out})
elif path == "/api/projects":
self._json({"items": server.projects.list(),
"autostart": settings["engine"].get(
"autostart_project")})
else:
self._send_error_json(404, "nicht gefunden")
# ---------- POST ----------
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == "/api/media/upload":
self._upload()
elif path == "/api/media/delete":
body = self._read_json()
name = str(body.get("name", ""))
ok = library.delete(name)
# Layer mit dieser Datei entfernen
if ok:
for l in list(engine.layers):
if l.name == name:
engine.remove_layer(l.id)
self._json({"deleted": ok})
elif path == "/api/layers/add":
body = self._read_json()
layer = engine.add_layer(str(body.get("name", "")))
if layer is None:
self._send_error_json(400, engine.last_error or "?" )
else:
self._json({"layer": layer})
elif path == "/api/layers/cues/add":
body = self._read_json()
cue = engine.add_cue(int(body.get("id", -1)), body)
if cue is None:
self._send_error_json(400, engine.last_error or "?")
else:
self._json({"cue": cue})
elif path == "/api/layers/cues/remove":
body = self._read_json()
ok = engine.remove_cue(int(body.get("id", -1)),
int(body.get("cue_id", -1)))
if not ok:
self._send_error_json(
404, "Cue/Layer nicht gefunden")
else:
self._json({"removed": True})
elif path == "/api/layers/cues/go":
body = self._read_json()
r = engine.cue_go(int(body.get("id", -1)),
int(body.get("steps", 1)))
if r is None:
self._send_error_json(400, engine.last_error or "?")
else:
self._json(r)
elif path == "/api/layers/cues/goto":
body = self._read_json()
r = engine.cue_goto(int(body.get("id", -1)),
int(body.get("index", -1)))
if r is None:
self._send_error_json(400, engine.last_error or "?")
else:
self._json(r)
elif path == "/api/layers/add-generator":
body = self._read_json()
layer = engine.add_generator(
str(body.get("generator", "")))
if layer is None:
self._send_error_json(
400, engine.last_error or "unbekannter Generator")
else:
self._json({"layer": layer})
elif path == "/api/servers/add":
body = self._read_json()
name = str(body.get("name", "")).strip()
url = str(body.get("url", "")).strip()
if (not name or not url.startswith(
("http://", "https://"))):
self._send_error_json(
400, "Name und http(s)-URL erforderlich")
elif any(s_.get("name") == name
for s_ in settings.get("servers", [])):
self._send_error_json(400, "Name bereits vergeben")
elif not distribution.check(url):
self._json({"added": False,
"error": "Server nicht erreichbar "
"oder kein HMS MediaEngine"})
else:
settings.setdefault("servers", []).append(
{"name": name, "url": url})
cfgmod.save(root, settings)
self._json({"added": True, "name": name})
elif path == "/api/servers/remove":
body = self._read_json()
name = str(body.get("name", ""))
before = len(settings.get("servers", []))
settings["servers"] = [
s_ for s_ in settings.get("servers", [])
if s_.get("name") != name]
if len(settings["servers"]) == before:
self._send_error_json(404, "Server nicht gefunden")
else:
cfgmod.save(root, settings)
self._json({"removed": True})
elif path == "/api/servers/import":
body = self._read_json()
srv_name = str(body.get("server", ""))
media_name = str(body.get("media", ""))
url = None
for s_ in settings.get("servers", []):
if s_.get("name") == srv_name:
url = str(s_.get("url", ""))
break
if url is None:
self._send_error_json(404, "Server nicht gefunden")
elif not media_name:
self._send_error_json(400, "Medienname fehlt")
else:
ok, result = distribution.import_media(
url, media_name, library)
if ok:
self._json({"saved": result})
else:
self._send_error_json(400, str(result))
elif path == "/api/layers/remove":
body = self._read_json()
ok = engine.remove_layer(int(body.get("id", -1)))
self._json({"removed": ok})
elif path == "/api/layers/update":
body = self._read_json()
layer = engine.update_layer(int(body.get("id", -1)),
body)
if layer is None:
self._send_error_json(404, "Layer nicht gefunden")
else:
self._json({"layer": engine._layer_dict(layer)})
elif path == "/api/master":
body = self._read_json()
engine.set_master(float(body.get("intensity",
body.get("value", 1.0))))
self._json(server._status())
elif path == "/api/blackout":
body = self._read_json()
engine.set_blackout(bool(body.get("on", False)))
self._json(server._status())
elif path == "/api/playback":
body = self._read_json()
engine.set_playback(bool(body.get("playing", True)))
self._json(server._status())
elif path == "/api/retrigger":
engine.retrigger()
self._json({"ok": True})
elif path == "/api/projects/save":
body = self._read_json()
name = str(body.get("name", "")).strip()
if not name:
self._send_error_json(400, "Name fehlt")
else:
info = server.projects.save(
name, engine.export_state())
if info is None:
self._send_error_json(500, "Speichern fehlgeschlagen")
else:
self._json(info)
elif path == "/api/projects/load":
body = self._read_json()
name = str(body.get("name", ""))
doc = server.projects.load(name)
if doc is None:
self._send_error_json(404, "Projekt nicht gefunden")
else:
ok = engine.load_state(doc.get("state") or {})
self._json({"loaded": bool(ok),
"name": doc.get("name"),
"error": None if ok else engine.last_error})
elif path == "/api/projects/delete":
body = self._read_json()
ok = server.projects.delete(str(body.get("name", "")))
if (settings["engine"].get("autostart_project")
== str(body.get("name", ""))):
settings["engine"]["autostart_project"] = None
cfgmod.save(root, settings)
self._json({"deleted": bool(ok)})
elif path == "/api/projects/autostart":
body = self._read_json()
name = body.get("name")
if name and not server.projects.exists(str(name)):
self._send_error_json(404, "Projekt nicht gefunden")
else:
settings["engine"]["autostart_project"] = (
str(name) if name else None)
cfgmod.save(root, settings)
self._json({"autostart": settings["engine"][
"autostart_project"]})
elif path == "/api/settings":
self._post_settings()
else:
self._send_error_json(404, "nicht gefunden")
# ---------- Upload ----------
def _upload(self):
length = int(self.headers.get("Content-Length", "0"))
raw_name = (self.headers.get("X-Filename")
or "upload" + ".bin")
name = urllib.parse.unquote(raw_name)
final, err = library.save_stream(name, self.rfile, length)
if err is not None:
self._send_error_json(400, err)
return
self._json({"saved": final})
# ---------- Settings ----------
def _post_settings(self):
body = self._read_json()
# WICHTIG: auf aktuelle Settings mergen, damit nicht
# gesendete Felder (autostart_project, servers) erhalten
# bleiben und nicht auf Defaults zurueckfallen.
merged = cfgmod.deep_merge(copy.deepcopy(settings), body)
normalized, err = cfgmod.validate(merged)
if err is not None:
self._send_error_json(400, err)
return
old_port = settings["web"]["port"]
old_fs = settings["output"]["fullscreen"]
# Struktur-Aenderungen anwenden
settings.clear()
settings.update(normalized)
engine.library.settings = settings
artnet.settings = settings
cfgmod.save(root, settings)
# Engine mit neuen Preview-Parametern neu bauen
engine.rebuild()
# Art-Net neu starten (Port/Universes evtl. geaendert)
artnet.restart()
restarted_web = settings["web"]["port"] != old_port
fullscreen_changed = settings["output"]["fullscreen"] != old_fs
self._json({"saved": True,
"web_restarts": restarted_web,
"fullscreen_changed": fullscreen_changed,
"hint": ("Web-Port geaendert: Seite unter neuem "
"Port neu oeffnen")
if restarted_web else None})
# ---------- Ausgabe-Helfer ----------
def _read_json(self) -> dict:
try:
length = int(self.headers.get("Content-Length", "0"))
return json.loads(self.rfile.read(length) or b"{}")
except (ValueError, OSError):
return {}
def _send_error_json(self, code, message):
self._json({"error": str(message)}, code=code)
def _json(self, data, code=200):
body = json.dumps(data).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_html(self):
html = HTML_UI.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type",
"text/html; charset=utf-8")
self.send_header("Content-Length", str(len(html)))
self.end_headers()
self.wfile.write(html)
def _send_file(self, f, ctype, download_name=None):
if f is None or not f.is_file():
self._send_error_json(404, "Datei nicht gefunden")
return
try:
data = f.read_bytes()
except OSError:
self._send_error_json(500, "Lesefehler")
return
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
if download_name:
self.send_header(
"Content-Disposition",
"inline; filename*=UTF-8''"
+ urllib.parse.quote(download_name))
self.end_headers()
self.wfile.write(data)
def _stream(self):
self.send_response(200)
self.send_header(
"Content-Type",
"multipart/x-mixed-replace; boundary=frame")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
try:
while server.running:
jpeg = engine.current_jpeg
if jpeg:
self.wfile.write(b"--frame\r\n")
self.wfile.write(
b"Content-Type: image/jpeg\r\n")
self.wfile.write(
(f"Content-Length: {len(jpeg)}\r\n\r\n")
.encode("ascii"))
self.wfile.write(jpeg)
self.wfile.write(b"\r\n")
time.sleep(1.0 / max(1, int(
settings["preview"]["fps"])))
except (BrokenPipeError, ConnectionResetError):
pass
def log_message(self, fmt, *args):
pass
return Handler
def _status(self) -> dict:
st = self.engine.status()
st["media_count"] = self.library.count()
st["artnet"] = self.artnet.monitor()
return st