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
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 13:34:43 +02:00
parent 7e191ff6c9
commit 02765e6b34
9 changed files with 770 additions and 27 deletions
+2 -1
View File
@@ -24,6 +24,7 @@ DEFAULTS: dict = {
"blackout_channel": 8,
"fx1_channel": None,
"fx2_channel": None,
"cue_go_channel": None,
"signal_loss_policy": "hold",
"signal_loss_timeout_s": 10,
},
@@ -115,7 +116,7 @@ def validate(new: dict) -> tuple[dict | None, str | None]:
errors.append("artnet.universes")
for k in ("master_channel", "layer_start_channel", "playback_channel",
"retrigger_channel", "blackout_channel", "fx1_channel",
"fx2_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):
+289 -9
View File
@@ -118,6 +118,9 @@ class Layer:
self.fx2: str | None = None
self.fx1_intensity = 0.5 # 0..1 normalisiert
self.fx2_intensity = 0.5
self.cues: list[dict] = [] # Cue-Liste (Szenen) dieses Layers
self.cue_index: int = -1 # aktiver Cue (-1 = keiner)
self._fade_gen: int = 0 # Fade-Abbruch-Zaehler
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name, "kind": self.kind,
@@ -126,7 +129,10 @@ class Layer:
"fx1": self.fx1, "fx2": self.fx2,
"fx1_intensity": round(self.fx1_intensity, 3),
"fx2_intensity": round(self.fx2_intensity, 3),
"effective": None}
"effective": None,
"cue_index": self.cue_index,
"cues": [{"cue_id": c["cue_id"], "name": c["name"]}
for c in self.cues]}
class MediaEngine:
@@ -153,6 +159,7 @@ class MediaEngine:
"last_time": None}
self._lock = threading.RLock()
self._monitor: threading.Thread | None = None
self._cue_go_last = False # DMX-Flankenerkennung Cue-GO
# ---------------- Pipeline ----------------
@@ -351,6 +358,264 @@ class MediaEngine:
return [{"id": g, "label": lbl}
for g, lbl in GENERATORS.items()]
# ---------------- Cue-Liste (Szenen) je Layer ----------------
def _capture_state(self, layer: Layer) -> dict:
return {
"name": layer.name, "kind": layer.kind,
"alpha": layer.alpha, "x": layer.x, "y": layer.y,
"width": layer.width, "height": layer.height, "z": layer.z,
"fx1": layer.fx1, "fx2": layer.fx2,
"fx1_intensity": layer.fx1_intensity,
"fx2_intensity": layer.fx2_intensity,
}
def _sanitize_cues(self, raw) -> list[dict]:
"""Validiert Cue-Liste (Projekt-Load): unbrauchbare Cues fliegen raus."""
out: list[dict] = []
if not isinstance(raw, list):
return out
for item in raw:
if (not isinstance(item, dict)
or not isinstance(item.get("state"), dict)):
continue
state = dict(item["state"])
kind = str(state.get("kind", ""))
name = str(state.get("name", ""))
if kind == "generator":
if name not in GENERATORS:
continue
else:
if (not self.library.file_path(name).exists()
or self.library.kind_of(name) is None):
continue
state["alpha"] = max(0.0, min(1.0,
float(state.get("alpha", 1.0))))
for k in ("fx1_intensity", "fx2_intensity"):
state[k] = max(0.0, min(1.0, float(state.get(k, 0.5))))
state["x"] = int(state.get("x", 0))
state["y"] = int(state.get("y", 0))
state["width"] = max(16, min(7680, int(state.get("width", 640))))
state["height"] = max(16, min(4320,
int(state.get("height", 360))))
state["z"] = max(0, min(15, int(state.get("z", 0))))
state["fx1"] = (state.get("fx1")
if state.get("fx1") in FX_CATALOG else None)
state["fx2"] = (state.get("fx2")
if state.get("fx2") in FX_CATALOG else None)
out.append({
"cue_id": int(item.get("cue_id", len(out) + 1)),
"name": str(item.get("name")
or ("Cue %d" % (len(out) + 1))),
"fade_ms": max(0, min(10000,
int(item.get("fade_ms", 500)))),
"follow_ms": max(0, min(600000,
int(item.get("follow_ms", 0)))),
"state": state,
})
return out
def layer_cues(self, layer_id: int) -> dict | None:
with self._lock:
layer = self.get_layer(layer_id)
if layer is None:
self.last_error = "Layer nicht gefunden"
return None
return {"id": layer.id, "index": layer.cue_index,
"cues": [dict(c) for c in layer.cues]}
def add_cue(self, layer_id: int, body: dict) -> dict | None:
"""Legt einen Cue an; ohne state wird der AKTUELLE Zustand gesichert."""
with self._lock:
layer = self.get_layer(layer_id)
if layer is None:
self.last_error = "Layer nicht gefunden"
return None
state = dict(self._capture_state(layer))
incoming = body.get("state")
if isinstance(incoming, dict):
for k in state:
if incoming.get(k) is not None:
state[k] = incoming[k]
if (str(state.get("kind")) == "generator"
and state.get("name") not in GENERATORS):
self.last_error = "unbekannter Generator"
return None
if str(state.get("kind")) != "generator":
nm = str(state.get("name", ""))
if (not self.library.file_path(nm).exists()
or self.library.kind_of(nm) is None):
self.last_error = "Medium nicht gefunden: " + nm
return None
next_id = (layer.cues[-1]["cue_id"] + 1
if layer.cues else 1)
cue = {
"cue_id": next_id,
"name": str(body.get("name")
or ("Cue %d" % (len(layer.cues) + 1))),
"fade_ms": max(0, min(10000,
int(body.get("fade_ms", 500)))),
"follow_ms": max(0, min(600000,
int(body.get("follow_ms", 0)))),
"state": state,
}
layer.cues.append(cue)
return dict(cue)
def remove_cue(self, layer_id: int, cue_id: int) -> bool:
with self._lock:
layer = self.get_layer(layer_id)
if layer is None:
return False
pos = next((i for i, c in enumerate(layer.cues)
if c["cue_id"] == int(cue_id)), None)
if pos is None:
return False
layer.cues.pop(pos)
if layer.cue_index == pos:
layer.cue_index = -1
elif layer.cue_index > pos:
layer.cue_index -= 1
return True
def cue_go(self, layer_id: int, steps: int = 1) -> dict | None:
"""GO: weiterschalten (Wrap-around am Ende; steps<0 = zurueck)."""
with self._lock:
layer = self.get_layer(layer_id)
if layer is None:
self.last_error = "Layer nicht gefunden"
return None
if not layer.cues:
self.last_error = "keine Cues vorhanden"
return None
n = len(layer.cues)
idx = layer.cue_index if layer.cue_index >= 0 else -steps
target = (idx + int(steps)) % n
return self._goto_cue_locked(layer, target)
def cue_goto(self, layer_id: int, index: int) -> dict | None:
with self._lock:
layer = self.get_layer(layer_id)
if layer is None:
self.last_error = "Layer nicht gefunden"
return None
if not layer.cues or not 0 <= int(index) < len(layer.cues):
self.last_error = "Cue-Index ungueltig"
return None
return self._goto_cue_locked(layer, int(index))
def _goto_cue_locked(self, layer: Layer, index: int) -> dict:
cue = layer.cues[index]
layer.cue_index = index
layer._fade_gen += 1 # laufende Fades abbrechen
state = dict(cue.get("state") or {})
fade_ms = int(cue.get("fade_ms", 0))
discrete = {"name": state["name"]} if "name" in state else {}
params = {k: state[k] for k in
("alpha", "fx1", "fx2", "fx1_intensity",
"fx2_intensity", "x", "y", "width", "height", "z")
if k in state}
target_alpha = float(params.get("alpha", layer.alpha))
needs_swap = (
(bool(discrete) and discrete.get("name") != layer.name)
or ("fx1" in params and params["fx1"] != layer.fx1)
or ("fx2" in params and params["fx2"] != layer.fx2))
result = {"index": index, "cue": cue["name"], "fade_ms": fade_ms}
if fade_ms <= 0:
if not self.update_layer(layer.id, {**discrete, **params}):
self.last_error = "Cue-Anwendung fehlgeschlagen"
return result
self._schedule_follow(layer, index)
return result
gen = layer._fade_gen
threading.Thread(
target=self._fade_worker,
args=(layer, gen, discrete, params, target_alpha,
fade_ms, needs_swap, index),
daemon=True).start()
return result
def _fade_worker(self, layer: Layer, gen: int, discrete: dict,
params: dict, target_alpha: float, fade_ms: int,
needs_swap: bool, index: int):
"""Fade: Quelle wechselt -> dip-to-black; sonst sanfter Tween."""
def alive():
return layer._fade_gen == gen
start_alpha = layer.alpha
if needs_swap:
half = max(fade_ms / 2000.0, 0.05)
t0 = time.monotonic()
while time.monotonic() - t0 < half:
if not alive():
return
f = (time.monotonic() - t0) / half
with self._lock:
layer.alpha = max(0.0, start_alpha * (1.0 - f))
self._apply_pad_props(layer)
time.sleep(0.02)
swap = dict(discrete)
for k in ("fx1", "fx2"):
if k in params:
swap[k] = params[k]
with self._lock:
if not self.update_layer(layer.id, swap):
return
layer.alpha = 0.0
self._apply_pad_props(layer)
start_alpha = 0.0
dur = half
else:
with self._lock:
patch = {k: params[k]
for k in ("x", "y", "width", "height", "z")
if k in params}
if patch:
self.update_layer(layer.id, patch)
dur = max(fade_ms / 1000.0, 0.05)
s1, s2 = layer.fx1_intensity, layer.fx2_intensity
t1 = float(params.get("fx1_intensity", s1))
t2 = float(params.get("fx2_intensity", s2))
k1 = "fx1_intensity" in params and layer.fx1
k2 = "fx2_intensity" in params and layer.fx2
t0 = time.monotonic()
while time.monotonic() - t0 < dur:
if not alive():
return
f = (time.monotonic() - t0) / dur
with self._lock:
layer.alpha = start_alpha + (target_alpha - start_alpha) * f
self._apply_pad_props(layer)
if k1:
layer.fx1_intensity = s1 + (t1 - s1) * f
self._set_fx_property(layer, 1, layer.fx1_intensity)
if k2:
layer.fx2_intensity = s2 + (t2 - s2) * f
self._set_fx_property(layer, 2, layer.fx2_intensity)
time.sleep(0.02)
with self._lock:
layer.alpha = target_alpha
self._apply_pad_props(layer)
self._schedule_follow(layer, index)
def _schedule_follow(self, layer: Layer, index: int):
follow_ms = int(layer.cues[index].get("follow_ms", 0))
if follow_ms <= 0:
return
gen = layer._fade_gen
def fire():
with self._lock:
if (layer._fade_gen == gen
and layer.cue_index == index
and self.get_layer(layer.id) is layer):
self.cue_go(layer.id, 1)
t = threading.Timer(follow_ms / 1000.0, fire)
t.daemon = True
t.start()
def remove_layer(self, layer_id: int) -> bool:
with self._lock:
kept = [l for l in self.layers if l.id != layer_id]
@@ -392,7 +657,7 @@ class MediaEngine:
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")
fx_changed = True
if "fx2" in patch:
v = patch["fx2"]
layer.fx2 = v if (v in FX_CATALOG or v is None) else None
@@ -407,15 +672,14 @@ class MediaEngine:
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)
src_changed = False
if "name" in patch and patch["name"] != layer.name:
new_name = str(patch["name"])
if layer.kind == "generator":
if new_name not in GENERATORS:
return None
if new_name in GENERATORS:
# Generator als Quelle (erlaubt Wechsel in BEIDE
# Richtungen: Video -> Generator und zurueck)
layer.name = new_name
layer.kind = "generator"
else:
f = self.library.file_path(new_name)
if not f.exists():
@@ -423,7 +687,9 @@ class MediaEngine:
layer.name = new_name
layer.kind = (self.library.kind_of(new_name)
or layer.kind)
# Quell-Wechsel braucht einen Pipeline-Rebuild
src_changed = True
if src_changed or fx_changed:
# Struktur geaendert -> atomarer Rebuild (Rollback drin)
if not self.rebuild():
return None
return self.get_layer(layer.id)
@@ -548,6 +814,15 @@ class MediaEngine:
self.layers[0].fx2_intensity = ch(fx2_ch) / 255.0
self._set_fx_property(self.layers[0], 2,
self.layers[0].fx2_intensity)
# Cue-GO: steigende Flanke -> alle Layer einen Cue weiter
cue_ch = a.get("cue_go_channel")
if cue_ch is not None:
rising = ch(cue_ch) >= 128
was = self._cue_go_last
self._cue_go_last = rising
if rising and not was:
for l in list(self.layers):
self.cue_go(l.id, 1)
# ---------------- Status ----------------
@@ -584,6 +859,8 @@ class MediaEngine:
"fx1": l.fx1, "fx2": l.fx2,
"fx1_intensity": l.fx1_intensity,
"fx2_intensity": l.fx2_intensity,
"cues": [dict(c) for c in l.cues],
"cue_index": l.cue_index,
} for l in self.layers],
"master": self.master,
}
@@ -632,6 +909,9 @@ class MediaEngine:
float(raw.get("fx1_intensity", 0.5))))
layer.fx2_intensity = max(0.0, min(1.0,
float(raw.get("fx2_intensity", 0.5))))
layer.cues = self._sanitize_cues(raw.get("cues"))
ci = int(raw.get("cue_index", -1))
layer.cue_index = ci if -1 <= ci < len(layer.cues) else -1
self.layers.append(layer)
ok_count += 1
m = state.get("master")
+39
View File
@@ -106,6 +106,13 @@ class EngineServer:
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", []):
@@ -153,6 +160,38 @@ class EngineServer:
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(
+67
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
@@ -42,6 +43,32 @@ def check_and_install_dependencies() -> bool:
print(" sudo apt-get install " + " ".join(LINUX_PKGS))
return False
if sys.platform == "darwin":
pkgs = ["python@3.12", "python-tk", "gstreamer",
"gst-plugins-base", "gst-plugins-good",
"gst-plugins-bad", "gst-plugins-ugly",
"gst-libav", "pygobject"]
brew = shutil.which("brew")
if brew:
print("[Bootstrap] Installiere GStreamer + PyGObject "
"via Homebrew (dauert evtl. einige Minuten)...")
try:
subprocess.run([brew, "install"] + pkgs,
check=False, timeout=1800)
print("[Bootstrap] Fertig. Programm mit brew-python3 "
"neu starten.")
except subprocess.TimeoutExpired:
print("[Bootstrap] Timeout. Manuell: brew install "
+ " ".join(pkgs))
else:
print("[Bootstrap] Homebrew nicht gefunden.")
print(" Erst Homebrew installieren:")
print(' /bin/bash -c "$(curl -fsSL '
'https://raw.githubusercontent.com/Homebrew/'
'install/HEAD/install.sh)"')
print(" Danach: ./setup_macos.sh")
return False
if sys.platform == "win32":
url = ("https://gstreamer.freedesktop.org/data/pkg/windows/"
"1.28.4/msvc/"
@@ -64,6 +91,42 @@ def check_and_install_dependencies() -> bool:
return False
MACOS_SETUP = r'''#!/bin/bash
# HMS MediaEngine - macOS Setup (Apple Silicon & Intel, einmalig ausfuehren)
set -e
echo "=== HMS MediaEngine macOS Setup ==="
echo "Architektur: $(uname -m)"
if ! command -v brew >/dev/null; then
echo "Homebrew fehlt. Zunaechst installieren:"
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
echo "Dann dieses Skript erneut ausfuehren."
exit 1
fi
echo "[1/2] Installiere GStreamer, PyGObject und tkinter (brew)..."
brew install python@3.12 python-tk gstreamer gst-plugins-base \
gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav pygobject
echo "[2/2] Umgebung pruefen..."
if [ "$(uname -m)" = "arm64" ] && [ -f /opt/homebrew/bin/brew ]; then
echo "Apple Silicon: Homebrew-Umgebung einbinden mit:"
echo ' eval "$(/opt/homebrew/bin/brew shellenv)"'
fi
echo "Pruefung:"
python3 -c "import gi; gi.require_version('Gst','1.0'); from gi.repository import Gst; Gst.init(None); print('GStreamer OK:', Gst.version_string())" || \
{ echo "PyGObject fehlt noch -> brew python3 verwenden (PATH!)"; exit 1; }
echo ""
echo "Hinweis: Die .pkg-Pakete von gstreamer.freedesktop.org/data/pkg/macos/"
echo "enthalten KEINE PyGObject-Bindings - Homebrew ist der empfohlene Weg."
echo ""
echo "=== Fertig ==="
echo "Start: python3 run.py"
echo "Dialog: python3 run.py --setup"
'''
WINDOWS_SETUP = r'''# HMS MediaEngine - Windows Setup (einmalig ausfuehren)
$ErrorActionPreference = "Stop"
Write-Host "=== HMS MediaEngine Windows Setup ===" -ForegroundColor Cyan
@@ -128,8 +191,12 @@ def generate_setup_scripts(root: Path) -> None:
p2 = root / "setup_linux.sh"
p2.write_text(LINUX_SETUP, encoding="utf-8")
p2.chmod(0o755)
p3 = root / "setup_macos.sh"
p3.write_text(MACOS_SETUP, encoding="utf-8")
p3.chmod(0o755)
print(f"Erzeugt: {p1}")
print(f"Erzeugt: {p2}")
print(f"Erzeugt: {p3}")
def run_setup(settings: dict) -> dict:
+37 -4
View File
@@ -147,6 +147,7 @@ footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);bo
<div class="frow"><label>Kanal Playback</label><input type="number" id="s-artnet-playback" min="1" max="512"></div>
<div class="frow"><label>Kanal Retrigger</label><input type="number" id="s-artnet-retrigger" min="1" max="512"></div>
<div class="frow"><label>Kanal Blackout</label><input type="number" id="s-artnet-blackout" min="1" max="512"></div>
<div class="frow"><label>Kanal Cue-GO</label><input type="number" id="s-artnet-cuego" min="1" max="512"><span class="hint">Flanke ≥128 = nächster Cue je Layer</span></div>
<div class="frow"><label>Signalverlust</label><select id="s-artnet-policy"><option value="hold">Letzten Zustand halten</option><option value="fade_black">Auf schwarz fallen</option></select>
<div class="frow"><label>Timeout Signalverlust</label><input type="number" id="s-artnet-timeout"> s</div>
</div>
@@ -217,7 +218,16 @@ function renderLayerEdit(ls){const e=$('ledit');
<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>'}}
<h2 style="margin-top:10px">Cue-Liste (Szenen)</h2>
<div id="cues-${L.id}"></div>
<div class="row">
<button class="acc" onclick="addCue(${L.id})">Cue sichern</button>
<button onclick="cueGo(${L.id},1)">GO ▶</button>
<button onclick="cueGo(${L.id},-1)">◀ Zurück</button>
</div>
</div>`).join('')||'<p style="color:var(--dim)">Keine aktiven Layer. Medien-Tab öffnen und Datei als Layer hinzufügen.</p>'}
ls.forEach(renderCues);
}
// Medien
async function loadMedia(){try{const d=await(await fetch('/api/media')).json();
@@ -260,7 +270,7 @@ async function loadSettings(){try{const s=await(await fetch('/api/settings')).js
$('s-artnet-enabled').checked=s.artnet.enabled;$('s-artnet-port').value=s.artnet.listen_port;
$('s-artnet-universes').value=s.artnet.universes.join(',');
$('s-artnet-master').value=s.artnet.master_channel;$('s-artnet-layer').value=s.artnet.layer_start_channel;
$('s-artnet-playback').value=s.artnet.playback_channel;$('s-artnet-retrigger').value=s.artnet.retrigger_channel;$('s-artnet-blackout').value=s.artnet.blackout_channel;
$('s-artnet-playback').value=s.artnet.playback_channel;$('s-artnet-retrigger').value=s.artnet.retrigger_channel;$('s-artnet-blackout').value=s.artnet.blackout_channel;$('s-artnet-cuego').value=s.artnet.cue_go_channel??'';
$('s-artnet-policy').value=s.artnet.signal_loss_policy;$('s-artnet-timeout').value=s.artnet.signal_loss_timeout_s;
$('s-engine-max').value=s.engine.max_layers;$('s-engine-loop').checked=s.engine.loop_default;$('s-output-fs').checked=s.output.fullscreen;
$('s-media-dir').value=s.media.dir;$('s-media-max').value=s.media.max_upload_mb;
@@ -271,7 +281,7 @@ async function saveSettings(){const body={
artnet:{enabled:$('s-artnet-enabled').checked,listen_port:+$('s-artnet-port').value,
universes:$('s-artnet-universes').value.split(',').map(x=>parseInt(x.trim())).filter(x=>!isNaN(x)),
master_channel:+$('s-artnet-master').value,layer_start_channel:+$('s-artnet-layer').value,
playback_channel:+$('s-artnet-playback').value,retrigger_channel:+$('s-artnet-retrigger').value,blackout_channel:+$('s-artnet-blackout').value,
playback_channel:+$('s-artnet-playback').value,retrigger_channel:+$('s-artnet-retrigger').value,blackout_channel:+$('s-artnet-blackout').value,cue_go_channel:+$('s-artnet-cuego').value||null,
signal_loss_policy:$('s-artnet-policy').value,signal_loss_timeout_s:+$('s-artnet-timeout').value},
engine:{max_layers:+$('s-engine-max').value,loop_default:$('s-engine-loop').checked,autostart_project:$('s-autostart').value||null},
output:{fullscreen:$('s-output-fs').checked},
@@ -311,10 +321,33 @@ async function loadServers(){try{const d=await(await fetch('/api/servers')).json
// Medien-Tab: Remote-Medien
const rg=$('rgrid');if(rg)rg.innerHTML=servers.map(S=>
`<div class="setgrp"><h3>${S.name} <span class="badge${S.online?'':' err'}">${S.online?'online':'offline'}</span></h3>`+
(S.online?(S.items||[]).map(m=>`<div class="frow"><label>${m.name} <small style="color:var(--dim)">${fmtSize(m.size)}</small></label><button class="acc" onclick="importRemote('${S.name}','${m.name.replace(/'/g,"\\'")}')">Ins Archiv ziehen</button></div>`).join('')||'<p style="color:var(--dim);font-size:11px">Keine Medien auf dem Server.</p>'):'')+'</div>'
(S.online?(S.items||[]).map(m=>`<div class="frow"><label>${m.name} <small style="color:var(--dim)">${fmtSize(m.size)}</small></label><button class="acc" data-srv="${S.name}" data-m="${m.name}" onclick="importRemote(this.dataset.srv,this.dataset.m)">Ins Archiv ziehen</button></div>`).join('')||'<p style="color:var(--dim);font-size:11px">Keine Medien auf dem Server.</p>':'')+'</div>'
).join('')||'<p style="color:var(--dim);font-size:11px">Noch keine Server. In den Einstellungen registrieren.</p>';
}catch(e){}}
// ---------- Cue-Listen (Szenen) pro Layer ----------
function cueRow(L,c,i){return `<div class="frow" style="padding:2px 0">
<label>${i+1}. ${c.name} <small style="color:var(--dim)">${c.fade_ms}ms Fade${c.follow_ms?' · Auto '+Math.round(c.follow_ms/1000)+'s':''}</small></label>
${i===L.cue_index?'<span class="badge">aktiv</span>':''}
<button onclick="cueGoto(${L.id},${i})">Goto</button>
<button class="danger" onclick="delCue(${L.id},${c.cue_id})">✕</button></div>`}
function renderCues(L){const box=$('cues-'+L.id);if(!box)return;
const ls=(L.cues||[]);
if(ls.length===0){box.innerHTML='<p style="color:var(--dim);font-size:11px">Noch keine Cues. Button unten sichert den aktuellen Zustand als Cue.</p>';return}
box.innerHTML=ls.map((c,i)=>cueRow(L,c,i)).join('')}
async function addCue(id){const nm=prompt('Cue-Name:','Cue');if(nm===null)return;
const fm=prompt('Fade-Zeit in ms (0 = harter Schnitt):','500');if(fm===null)return;
const fo=prompt('Auto-Follow in ms (0 = aus, sonst Weiter nach Ablauf):','0');if(fo===null)return;
const d=await api('layers/cues/add',{id,name:nm||undefined,fade_ms:+fm||0,follow_ms:+fo||0});
if(d.cue)toast('Cue gesichert: '+d.cue.name);else if(d.error)toast(d.error,true)}
async function cueGo(id,steps){const d=await api('layers/cues/go',{id,steps});
if(d.cue)toast('GO → '+d.cue);else if(d.error)toast(d.error,true)}
async function cueGoto(id,index){const d=await api('layers/cues/goto',{id,index});
if(d.cue)toast('Goto: '+d.cue)}
async function delCue(id,cueId){if(!confirm('Cue entfernen?'))return;
await api('layers/cues/remove',{id,cue_id:cueId});
poll()}
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=>{
+4 -5
View File
@@ -9,10 +9,9 @@ if command -v apt-get >/dev/null; then
gstreamer1.0-libav python3-gst-1.0 gir1.2-gst-1.0 python3-tk xvfb
elif command -v dnf >/dev/null; then
sudo dnf install -y gstreamer1 gstreamer1-plugins-base gstreamer1-plugins-good \
gstreamer1-plugins-bad-free gstreamer1-plugins-bad-freeworld gstreamer1-plugins-ugly \
gstreamer1-libav python3-gobject python3-tkinter
gstreamer1-plugins-bad-free gstreamer1-plugins-ugly gstreamer1-libav \
python3-gobject python3-tkinter
fi
echo "=== Fertig ==="
echo "Start: python3 run.py"
echo "Dialog: python3 run.py --setup"
echo "Output: python3 run.py --output"
echo "Start: python3 run.py"
echo "Media-Verwaltung, Layer und Einstellungen: Web-UI im Browser"
Executable
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# HMS MediaEngine - macOS Setup (Apple Silicon & Intel, einmalig ausfuehren)
set -e
echo "=== HMS MediaEngine macOS Setup ==="
echo "Architektur: $(uname -m)"
if ! command -v brew >/dev/null; then
echo "Homebrew fehlt. Zunaechst installieren:"
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
echo "Dann dieses Skript erneut ausfuehren."
exit 1
fi
echo "[1/2] Installiere GStreamer, PyGObject und tkinter (brew)..."
brew install python@3.12 python-tk gstreamer gst-plugins-base \
gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav pygobject
echo "[2/2] Umgebung pruefen..."
if [ "$(uname -m)" = "arm64" ] && [ -f /opt/homebrew/bin/brew ]; then
echo "Apple Silicon: Homebrew-Umgebung einbinden mit:"
echo ' eval "$(/opt/homebrew/bin/brew shellenv)"'
fi
echo "Pruefung:"
python3 -c "import gi; gi.require_version('Gst','1.0'); from gi.repository import Gst; Gst.init(None); print('GStreamer OK:', Gst.version_string())" || \
{ echo "PyGObject fehlt noch -> brew python3 verwenden (PATH!)"; exit 1; }
echo ""
echo "Hinweis: Die .pkg-Pakete von gstreamer.freedesktop.org/data/pkg/macos/"
echo "enthalten KEINE PyGObject-Bindings - Homebrew ist der empfohlene Weg."
echo ""
echo "=== Fertig ==="
echo "Start: python3 run.py"
echo "Dialog: python3 run.py --setup"
+6 -8
View File
@@ -8,7 +8,7 @@ if (-not $isAdmin) {
exit 1
}
Write-Host "[1/3] GStreamer 1.28.4 MSVC..." -ForegroundColor Yellow
Write-Host "[1/2] GStreamer 1.28.4 MSVC..." -ForegroundColor Yellow
$gstRoot = [Environment]::GetEnvironmentVariable("GSTREAMER_1_0_ROOT_MSVC_X86_64", "Machine")
if (-not $gstRoot) {
$url = "https://gstreamer.freedesktop.org/data/pkg/windows/1.28.4/msvc/gstreamer-1.0-msvc-x86_64-1.28.4.msi"
@@ -18,7 +18,7 @@ if (-not $gstRoot) {
Write-Host " GStreamer installiert"
} else { Write-Host " bereits vorhanden" }
Write-Host "[2/3] Python 3.13 (falls fehlt) mit tkinter..." -ForegroundColor Yellow
Write-Host "[2/2] Python 3.13 (falls fehlt) mit tkinter..." -ForegroundColor Yellow
$py = Get-Command python -ErrorAction SilentlyContinue
if (-not $py) {
$pyUrl = "https://www.python.org/ftp/python/3.13.2/python-3.13.2-amd64.exe"
@@ -28,11 +28,9 @@ if (-not $py) {
Write-Host " Python installiert"
} else { Write-Host " bereits vorhanden" }
Write-Host "[3/3] GStreamer-Pfad fuer diese Shell..." -ForegroundColor Yellow
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";$env:Path"
Write-Host "" -ForegroundColor Gray
Write-Host ""
Write-Host "=== Fertig ===" -ForegroundColor Green
Write-Host "Start: python run.py"
Write-Host "Dialog: python run.py --setup"
Write-Host "Output: python run.py --output"
Write-Host "Start: python run.py"
Write-Host "Dialog: python run.py --setup"
Write-Host "Media-Verwaltung, Layer und Einstellungen: Web-UI im Browser"
+293
View File
@@ -0,0 +1,293 @@
#!/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()