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

155 lines
5.1 KiB
Python
Raw Permalink 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.
"""Persistente Konfiguration: config.json, Defaults, Validierung."""
from __future__ import annotations
import copy
import json
from pathlib import Path
DEFAULTS: dict = {
"web": {"bind": "0.0.0.0", "port": 8080},
"preview": {"width": 640, "height": 360, "fps": 15,
"jpeg_quality": 80},
"output": {"fullscreen": False},
"media": {"dir": "media", "thumbs_dir": "thumbs",
"max_upload_mb": 2048},
"artnet": {
"enabled": True,
"listen_port": 6454,
"universes": [0],
"master_channel": 1,
"layer_start_channel": 2,
"playback_channel": 6,
"retrigger_channel": 7,
"blackout_channel": 8,
"fx1_channel": None,
"fx2_channel": None,
"cue_go_channel": None,
"signal_loss_policy": "hold",
"signal_loss_timeout_s": 10,
},
"engine": {"max_layers": 8, "loop_default": True,
"autostart_project": None},
"servers": [],
}
def deep_merge(base: dict, override: dict) -> dict:
out = dict(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = deep_merge(out[k], v)
else:
out[k] = v
return out
def load(root: Path) -> dict:
"""Lädt config.json und füllt fehlende Schlüssel mit Defaults."""
file = root / "config.json"
data: dict = {}
if file.exists():
try:
data = json.loads(file.read_text("utf-8"))
except (OSError, ValueError) as e:
print(f"[Config] FEHLER config.json: {e} nutze Defaults")
if not isinstance(data, dict):
data = {}
return deep_merge(DEFAULTS, data)
def save(root: Path, cfg: dict) -> bool:
file = root / "config.json"
try:
tmp = file.with_suffix(".json.tmp")
tmp.write_text(
json.dumps(cfg, ensure_ascii=False, indent=2), "utf-8")
tmp.replace(file)
return True
except OSError as e:
print(f"[Config] Schreiben fehlgeschlagen: {e}")
return False
def validate(new: dict) -> tuple[dict | None, str | None]:
"""Prüft ein komplettes Settings-Objekt. Rückgabe: (normalized, error)."""
merged = deep_merge(copy.deepcopy(DEFAULTS), new if isinstance(new, dict) else {})
errors: list[str] = []
def _int(d, key, lo, hi, label):
try:
v = int(d[key])
except (KeyError, TypeError, ValueError):
errors.append(label)
return None
if not lo <= v <= hi:
errors.append(label)
return None
d[key] = v
return v
w = merged["web"]
_int(w, "port", 1, 65535, "web.port")
if not isinstance(w.get("bind"), str):
errors.append("web.bind")
p = merged["preview"]
_int(p, "width", 64, 3840, "preview.width")
_int(p, "height", 64, 2160, "preview.height")
_int(p, "fps", 1, 60, "preview.fps")
_int(p, "jpeg_quality", 10, 95, "preview.jpeg_quality")
m = merged["media"]
_int(m, "max_upload_mb", 1, 8192, "media.max_upload_mb")
for k in ("dir", "thumbs_dir"):
if not isinstance(m.get(k), str) or not m[k] or "/" in m[k] \
or m[k] in (".", ".."):
errors.append(f"media.{k}")
a = merged["artnet"]
_int(a, "listen_port", 1, 65535, "artnet.listen_port")
uni = a.get("universes")
if (not isinstance(uni, list) or not uni
or len(set(uni)) != len(uni)
or any(not isinstance(u, int) or not 0 <= u <= 32767
for u in uni)):
errors.append("artnet.universes")
for k in ("master_channel", "layer_start_channel", "playback_channel",
"retrigger_channel", "blackout_channel", "fx1_channel",
"fx2_channel", "cue_go_channel"):
v = a.get(k)
if v is not None and (not isinstance(v, int)
or not 1 <= v <= 512):
errors.append(f"artnet.{k}")
_int(a, "signal_loss_timeout_s", 1, 3600,
"artnet.signal_loss_timeout_s")
if a.get("signal_loss_policy") not in ("hold", "fade_black"):
errors.append("artnet.signal_loss_policy")
e = merged["engine"]
_int(e, "max_layers", 1, 16, "engine.max_layers")
if not isinstance(e.get("loop_default"), bool):
errors.append("engine.loop_default")
ap = e.get("autostart_project")
if ap is not None and not isinstance(ap, str):
errors.append("engine.autostart_project")
sv = merged.get("servers")
if not isinstance(sv, list):
errors.append("servers")
else:
for item in sv:
if (not isinstance(item, dict)
or not isinstance(item.get("name"), str)
or not item.get("name", "").strip()
or not isinstance(item.get("url"), str)
or not item.get("url", "").startswith(
("http://", "https://"))):
errors.append("servers")
break
if not isinstance(merged["output"].get("fullscreen"), bool):
errors.append("output.fullscreen")
if errors:
return None, "ungültig: " + ", ".join(errors)
return merged, None