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:
+2
-1
@@ -24,6 +24,7 @@ DEFAULTS: dict = {
|
|||||||
"blackout_channel": 8,
|
"blackout_channel": 8,
|
||||||
"fx1_channel": None,
|
"fx1_channel": None,
|
||||||
"fx2_channel": None,
|
"fx2_channel": None,
|
||||||
|
"cue_go_channel": None,
|
||||||
"signal_loss_policy": "hold",
|
"signal_loss_policy": "hold",
|
||||||
"signal_loss_timeout_s": 10,
|
"signal_loss_timeout_s": 10,
|
||||||
},
|
},
|
||||||
@@ -115,7 +116,7 @@ def validate(new: dict) -> tuple[dict | None, str | None]:
|
|||||||
errors.append("artnet.universes")
|
errors.append("artnet.universes")
|
||||||
for k in ("master_channel", "layer_start_channel", "playback_channel",
|
for k in ("master_channel", "layer_start_channel", "playback_channel",
|
||||||
"retrigger_channel", "blackout_channel", "fx1_channel",
|
"retrigger_channel", "blackout_channel", "fx1_channel",
|
||||||
"fx2_channel"):
|
"fx2_channel", "cue_go_channel"):
|
||||||
v = a.get(k)
|
v = a.get(k)
|
||||||
if v is not None and (not isinstance(v, int)
|
if v is not None and (not isinstance(v, int)
|
||||||
or not 1 <= v <= 512):
|
or not 1 <= v <= 512):
|
||||||
|
|||||||
+289
-9
@@ -118,6 +118,9 @@ class Layer:
|
|||||||
self.fx2: str | None = None
|
self.fx2: str | None = None
|
||||||
self.fx1_intensity = 0.5 # 0..1 normalisiert
|
self.fx1_intensity = 0.5 # 0..1 normalisiert
|
||||||
self.fx2_intensity = 0.5
|
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:
|
def to_dict(self) -> dict:
|
||||||
return {"id": self.id, "name": self.name, "kind": self.kind,
|
return {"id": self.id, "name": self.name, "kind": self.kind,
|
||||||
@@ -126,7 +129,10 @@ class Layer:
|
|||||||
"fx1": self.fx1, "fx2": self.fx2,
|
"fx1": self.fx1, "fx2": self.fx2,
|
||||||
"fx1_intensity": round(self.fx1_intensity, 3),
|
"fx1_intensity": round(self.fx1_intensity, 3),
|
||||||
"fx2_intensity": round(self.fx2_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:
|
class MediaEngine:
|
||||||
@@ -153,6 +159,7 @@ class MediaEngine:
|
|||||||
"last_time": None}
|
"last_time": None}
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._monitor: threading.Thread | None = None
|
self._monitor: threading.Thread | None = None
|
||||||
|
self._cue_go_last = False # DMX-Flankenerkennung Cue-GO
|
||||||
|
|
||||||
# ---------------- Pipeline ----------------
|
# ---------------- Pipeline ----------------
|
||||||
|
|
||||||
@@ -351,6 +358,264 @@ class MediaEngine:
|
|||||||
return [{"id": g, "label": lbl}
|
return [{"id": g, "label": lbl}
|
||||||
for g, lbl in GENERATORS.items()]
|
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:
|
def remove_layer(self, layer_id: int) -> bool:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
kept = [l for l in self.layers if l.id != layer_id]
|
kept = [l for l in self.layers if l.id != layer_id]
|
||||||
@@ -392,7 +657,7 @@ class MediaEngine:
|
|||||||
if "fx1" in patch:
|
if "fx1" in patch:
|
||||||
v = patch["fx1"]
|
v = patch["fx1"]
|
||||||
layer.fx1 = v if (v in FX_CATALOG or v is None) else None
|
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:
|
if "fx2" in patch:
|
||||||
v = patch["fx2"]
|
v = patch["fx2"]
|
||||||
layer.fx2 = v if (v in FX_CATALOG or v is None) else None
|
layer.fx2 = v if (v in FX_CATALOG or v is None) else None
|
||||||
@@ -407,15 +672,14 @@ class MediaEngine:
|
|||||||
float(patch["fx2_intensity"])))
|
float(patch["fx2_intensity"])))
|
||||||
if layer.fx2:
|
if layer.fx2:
|
||||||
self._set_fx_property(layer, 2, layer.fx2_intensity)
|
self._set_fx_property(layer, 2, layer.fx2_intensity)
|
||||||
if fx_changed:
|
src_changed = False
|
||||||
self.rebuild() # Struktur geaendert -> Pipeline neu
|
|
||||||
return self.get_layer(layer.id)
|
|
||||||
if "name" in patch and patch["name"] != layer.name:
|
if "name" in patch and patch["name"] != layer.name:
|
||||||
new_name = str(patch["name"])
|
new_name = str(patch["name"])
|
||||||
if layer.kind == "generator":
|
if new_name in GENERATORS:
|
||||||
if new_name not in GENERATORS:
|
# Generator als Quelle (erlaubt Wechsel in BEIDE
|
||||||
return None
|
# Richtungen: Video -> Generator und zurueck)
|
||||||
layer.name = new_name
|
layer.name = new_name
|
||||||
|
layer.kind = "generator"
|
||||||
else:
|
else:
|
||||||
f = self.library.file_path(new_name)
|
f = self.library.file_path(new_name)
|
||||||
if not f.exists():
|
if not f.exists():
|
||||||
@@ -423,7 +687,9 @@ class MediaEngine:
|
|||||||
layer.name = new_name
|
layer.name = new_name
|
||||||
layer.kind = (self.library.kind_of(new_name)
|
layer.kind = (self.library.kind_of(new_name)
|
||||||
or layer.kind)
|
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():
|
if not self.rebuild():
|
||||||
return None
|
return None
|
||||||
return self.get_layer(layer.id)
|
return self.get_layer(layer.id)
|
||||||
@@ -548,6 +814,15 @@ class MediaEngine:
|
|||||||
self.layers[0].fx2_intensity = ch(fx2_ch) / 255.0
|
self.layers[0].fx2_intensity = ch(fx2_ch) / 255.0
|
||||||
self._set_fx_property(self.layers[0], 2,
|
self._set_fx_property(self.layers[0], 2,
|
||||||
self.layers[0].fx2_intensity)
|
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 ----------------
|
# ---------------- Status ----------------
|
||||||
|
|
||||||
@@ -584,6 +859,8 @@ class MediaEngine:
|
|||||||
"fx1": l.fx1, "fx2": l.fx2,
|
"fx1": l.fx1, "fx2": l.fx2,
|
||||||
"fx1_intensity": l.fx1_intensity,
|
"fx1_intensity": l.fx1_intensity,
|
||||||
"fx2_intensity": l.fx2_intensity,
|
"fx2_intensity": l.fx2_intensity,
|
||||||
|
"cues": [dict(c) for c in l.cues],
|
||||||
|
"cue_index": l.cue_index,
|
||||||
} for l in self.layers],
|
} for l in self.layers],
|
||||||
"master": self.master,
|
"master": self.master,
|
||||||
}
|
}
|
||||||
@@ -632,6 +909,9 @@ class MediaEngine:
|
|||||||
float(raw.get("fx1_intensity", 0.5))))
|
float(raw.get("fx1_intensity", 0.5))))
|
||||||
layer.fx2_intensity = max(0.0, min(1.0,
|
layer.fx2_intensity = max(0.0, min(1.0,
|
||||||
float(raw.get("fx2_intensity", 0.5))))
|
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)
|
self.layers.append(layer)
|
||||||
ok_count += 1
|
ok_count += 1
|
||||||
m = state.get("master")
|
m = state.get("master")
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ class EngineServer:
|
|||||||
self._json(engine.fx_catalog())
|
self._json(engine.fx_catalog())
|
||||||
elif path == "/api/generators":
|
elif path == "/api/generators":
|
||||||
self._json({"items": engine.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":
|
elif path == "/api/servers":
|
||||||
out = []
|
out = []
|
||||||
for s_ in settings.get("servers", []):
|
for s_ in settings.get("servers", []):
|
||||||
@@ -153,6 +160,38 @@ class EngineServer:
|
|||||||
self._send_error_json(400, engine.last_error or "?" )
|
self._send_error_json(400, engine.last_error or "?" )
|
||||||
else:
|
else:
|
||||||
self._json({"layer": layer})
|
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":
|
elif path == "/api/layers/add-generator":
|
||||||
body = self._read_json()
|
body = self._read_json()
|
||||||
layer = engine.add_generator(
|
layer = engine.add_generator(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -42,6 +43,32 @@ def check_and_install_dependencies() -> bool:
|
|||||||
print(" sudo apt-get install " + " ".join(LINUX_PKGS))
|
print(" sudo apt-get install " + " ".join(LINUX_PKGS))
|
||||||
return False
|
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":
|
if sys.platform == "win32":
|
||||||
url = ("https://gstreamer.freedesktop.org/data/pkg/windows/"
|
url = ("https://gstreamer.freedesktop.org/data/pkg/windows/"
|
||||||
"1.28.4/msvc/"
|
"1.28.4/msvc/"
|
||||||
@@ -64,6 +91,42 @@ def check_and_install_dependencies() -> bool:
|
|||||||
return False
|
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)
|
WINDOWS_SETUP = r'''# HMS MediaEngine - Windows Setup (einmalig ausfuehren)
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
Write-Host "=== HMS MediaEngine Windows Setup ===" -ForegroundColor Cyan
|
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 = root / "setup_linux.sh"
|
||||||
p2.write_text(LINUX_SETUP, encoding="utf-8")
|
p2.write_text(LINUX_SETUP, encoding="utf-8")
|
||||||
p2.chmod(0o755)
|
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: {p1}")
|
||||||
print(f"Erzeugt: {p2}")
|
print(f"Erzeugt: {p2}")
|
||||||
|
print(f"Erzeugt: {p3}")
|
||||||
|
|
||||||
|
|
||||||
def run_setup(settings: dict) -> dict:
|
def run_setup(settings: dict) -> dict:
|
||||||
|
|||||||
+37
-4
@@ -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 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 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 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>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 class="frow"><label>Timeout Signalverlust</label><input type="number" id="s-artnet-timeout"> s</div>
|
||||||
</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>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>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 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
|
// Medien
|
||||||
async function loadMedia(){try{const d=await(await fetch('/api/media')).json();
|
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-enabled').checked=s.artnet.enabled;$('s-artnet-port').value=s.artnet.listen_port;
|
||||||
$('s-artnet-universes').value=s.artnet.universes.join(',');
|
$('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-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-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-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;
|
$('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,
|
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)),
|
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,
|
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},
|
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},
|
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},
|
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
|
// Medien-Tab: Remote-Medien
|
||||||
const rg=$('rgrid');if(rg)rg.innerHTML=servers.map(S=>
|
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>`+
|
`<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>';
|
).join('')||'<p style="color:var(--dim);font-size:11px">Noch keine Server. In den Einstellungen registrieren.</p>';
|
||||||
}catch(e){}}
|
}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=[];
|
let FX=[];
|
||||||
async function loadFx(){try{FX=await(await fetch('/api/fx')).json();fillFxSelects()}catch(e){}}
|
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=>{
|
function fillFxSelects(){document.querySelectorAll('select[id^="fx1-"],select[id^="fx2-"]').forEach(sel=>{
|
||||||
|
|||||||
+4
-5
@@ -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
|
gstreamer1.0-libav python3-gst-1.0 gir1.2-gst-1.0 python3-tk xvfb
|
||||||
elif command -v dnf >/dev/null; then
|
elif command -v dnf >/dev/null; then
|
||||||
sudo dnf install -y gstreamer1 gstreamer1-plugins-base gstreamer1-plugins-good \
|
sudo dnf install -y gstreamer1 gstreamer1-plugins-base gstreamer1-plugins-good \
|
||||||
gstreamer1-plugins-bad-free gstreamer1-plugins-bad-freeworld gstreamer1-plugins-ugly \
|
gstreamer1-plugins-bad-free gstreamer1-plugins-ugly gstreamer1-libav \
|
||||||
gstreamer1-libav python3-gobject python3-tkinter
|
python3-gobject python3-tkinter
|
||||||
fi
|
fi
|
||||||
echo "=== Fertig ==="
|
echo "=== Fertig ==="
|
||||||
echo "Start: python3 run.py"
|
echo "Start: python3 run.py"
|
||||||
echo "Dialog: python3 run.py --setup"
|
echo "Media-Verwaltung, Layer und Einstellungen: Web-UI im Browser"
|
||||||
echo "Output: python3 run.py --output"
|
|
||||||
|
|||||||
Executable
+33
@@ -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
@@ -8,7 +8,7 @@ if (-not $isAdmin) {
|
|||||||
exit 1
|
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")
|
$gstRoot = [Environment]::GetEnvironmentVariable("GSTREAMER_1_0_ROOT_MSVC_X86_64", "Machine")
|
||||||
if (-not $gstRoot) {
|
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"
|
$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"
|
Write-Host " GStreamer installiert"
|
||||||
} else { Write-Host " bereits vorhanden" }
|
} 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
|
$py = Get-Command python -ErrorAction SilentlyContinue
|
||||||
if (-not $py) {
|
if (-not $py) {
|
||||||
$pyUrl = "https://www.python.org/ftp/python/3.13.2/python-3.13.2-amd64.exe"
|
$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"
|
Write-Host " Python installiert"
|
||||||
} else { Write-Host " bereits vorhanden" }
|
} 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"
|
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";$env:Path"
|
||||||
|
Write-Host ""
|
||||||
Write-Host "" -ForegroundColor Gray
|
|
||||||
Write-Host "=== Fertig ===" -ForegroundColor Green
|
Write-Host "=== Fertig ===" -ForegroundColor Green
|
||||||
Write-Host "Start: python run.py"
|
Write-Host "Start: python run.py"
|
||||||
Write-Host "Dialog: python run.py --setup"
|
Write-Host "Dialog: python run.py --setup"
|
||||||
Write-Host "Output: python run.py --output"
|
Write-Host "Media-Verwaltung, Layer und Einstellungen: Web-UI im Browser"
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user