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:
+289
-9
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user