02765e6b34
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
945 lines
38 KiB
Python
945 lines
38 KiB
Python
"""MediaEngine: GStreamer-Compositor mit dynamischen Layern (Video + Bild).
|
|
|
|
Layer lassen sich zur Laufzeit hinzufuegen/entfernen/verschieben. Jeder
|
|
Layer hat: Quelle (Bibliotheksdatei), Alpha, Position (x/y), Groesse
|
|
(width/height), Z-Order. Die Pipeline wird bei Struktur-Aenderungen neu
|
|
gebaut, Parameter gehen nicht verloren.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
|
|
import gi
|
|
gi.require_version("Gst", "1.0")
|
|
from gi.repository import Gst
|
|
|
|
Gst.init(None)
|
|
|
|
# Effekt-Katalog: id -> (GStreamer-Element, Property, min, max, Beschreibung)
|
|
# Alle Elemente im Container verfuegbart (gst-inspect geprueft).
|
|
FX_CATALOG: dict[str, dict] = {
|
|
"blur": {"element": "gaussianblur", "prop": "sigma",
|
|
"min": 0.0, "max": 8.0, "label": "Weichzeichnen"},
|
|
"sharpen": {"element": "gaussianblur", "prop": "sigma",
|
|
"min": -4.0, "max": 0.0, "label": "Schaerfen",
|
|
"value_min": -4.0, "value_max": 0.0},
|
|
"color": {"element": "videobalance", "prop": "saturation",
|
|
"min": 0.0, "max": 2.0, "label": "Saettigung"},
|
|
"contrast": {"element": "videobalance", "prop": "contrast",
|
|
"min": 0.5, "max": 2.0, "label": "Kontrast"},
|
|
"brightness": {"element": "videobalance", "prop": "brightness",
|
|
"min": -1.0, "max": 1.0, "label": "Helligkeit"},
|
|
"gamma": {"element": "gamma", "prop": "gamma",
|
|
"min": 0.2, "max": 5.0, "label": "Gamma"},
|
|
"pixelate": {"element": "dicetv", "prop": "square-bits",
|
|
"min": 0, "max": 5, "label": "Pixelize",
|
|
"int": True},
|
|
"solarize": {"element": "solarize", "prop": "threshold",
|
|
"min": 0, "max": 1000, "label": "Solarisieren",
|
|
"int": True},
|
|
"vertigo": {"element": "vertigotv", "prop": "speed",
|
|
"min": 0, "max": 100, "label": "Vertigo",
|
|
"int": True},
|
|
"edge": {"element": "edgetv", "prop": "threshold",
|
|
"min": 0, "max": 255, "label": "Kantenerkennung",
|
|
"int": True},
|
|
"ripple": {"element": "rippletv", "prop": "reset",
|
|
"min": 0, "max": 1, "label": "Wasser-Effekt",
|
|
"int": True},
|
|
"radioactv": {"element": "radioactv", "prop": "color",
|
|
"min": 0, "max": 5, "label": "Radioaktiv",
|
|
"int": True},
|
|
"revtv": {"element": "revtv", "prop": "grab-diff",
|
|
"min": 0, "max": 10, "label": "Retro-Rev",
|
|
"int": True},
|
|
"quark": {"element": "quarktv", "prop": "planes",
|
|
"min": 1, "max": 16, "label": "Quark",
|
|
"int": True},
|
|
"op": {"element": "optv", "prop": "mode",
|
|
"min": 1, "max": 5, "label": "Optical Illusion",
|
|
"int": True},
|
|
"burn": {"element": "burn", "prop": "threshold",
|
|
"min": 0, "max": 1000, "label": "Brennen",
|
|
"int": True},
|
|
"dilate": {"element": "dilate", "prop": "threshold",
|
|
"min": 0, "max": 255, "label": "Dilate",
|
|
"int": True},
|
|
"warp": {"element": "warptv", "prop": "reset",
|
|
"min": 0, "max": 1, "label": "Warp",
|
|
"int": True},
|
|
"streak": {"element": "streaktv", "prop": "reset",
|
|
"min": 0, "max": 1, "label": "Streaks",
|
|
"int": True},
|
|
"aging": {"element": "agingtv", "prop": "color-aging",
|
|
"min": 0, "max": 255, "label": "Alt-Videolook",
|
|
"int": True},
|
|
"exclusion": {"element": "exclusion", "prop": "factor",
|
|
"min": 0, "max": 255, "label": "Exclusion-Mix",
|
|
"int": True},
|
|
}
|
|
|
|
# Generator-Katalog (prozedurale Quellen, verifiziert per gst-launch):
|
|
# MADRIX-artige Inhalte: Generator-Layer + Effekte kombinierbar.
|
|
GENERATORS: dict[str, str] = {
|
|
"ball": "Springender Ball (animiert)",
|
|
"gradient": "Farbverlauf",
|
|
"pinwheel": "Pinwheel (animiert)",
|
|
"zone-plate": "Zonenplatte (animiert)",
|
|
"checkers-2": "Schachbrett",
|
|
"snow": "Rauschen (animiert)",
|
|
"circular": "Kreismuster",
|
|
"spokes": "Speichen",
|
|
"smpte": "SMPTE-Farbbalken",
|
|
"gamut": "Farbraum-Gamut",
|
|
"red": "Vollrot",
|
|
"green": "Vollgrün",
|
|
"blue": "Vollblau",
|
|
"black": "Schwarz",
|
|
"white": "Weiß",
|
|
}
|
|
|
|
|
|
class Layer:
|
|
"""Ein aktiver Layer im Compositor."""
|
|
|
|
def __init__(self, layer_id: int, name: str, kind: str):
|
|
self.id = layer_id
|
|
self.name = name
|
|
self.kind = kind # video | image
|
|
self.alpha = 1.0
|
|
self.x = 0
|
|
self.y = 0
|
|
self.width = 320
|
|
self.height = 180
|
|
self.z = 0
|
|
self.fx1: str | None = None # Effekt-ID aus FX_CATALOG
|
|
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,
|
|
"alpha": round(self.alpha, 3), "x": self.x, "y": self.y,
|
|
"width": self.width, "height": self.height, "z": self.z,
|
|
"fx1": self.fx1, "fx2": self.fx2,
|
|
"fx1_intensity": round(self.fx1_intensity, 3),
|
|
"fx2_intensity": round(self.fx2_intensity, 3),
|
|
"effective": None,
|
|
"cue_index": self.cue_index,
|
|
"cues": [{"cue_id": c["cue_id"], "name": c["name"]}
|
|
for c in self.cues]}
|
|
|
|
|
|
class MediaEngine:
|
|
"""Compositor-Pipeline + Steuerung. Thread-sicher ueber Lock."""
|
|
|
|
def __init__(self, settings: dict, library):
|
|
self.settings = settings
|
|
self.library = library
|
|
self.pipeline = None
|
|
self.current_jpeg = b""
|
|
self.frame_count = 0
|
|
self.running = False
|
|
self.last_error: str | None = None
|
|
|
|
self.layers: list[Layer] = []
|
|
self._next_id = 1
|
|
|
|
self.master = 1.0
|
|
self.blackout = False
|
|
self.playing = True
|
|
|
|
self.dmx_stats = {"packets": 0, "accepted": 0, "seq_errors": 0,
|
|
"last_universe": None, "last_seq": None,
|
|
"last_time": None}
|
|
self._lock = threading.RLock()
|
|
self._monitor: threading.Thread | None = None
|
|
self._cue_go_last = False # DMX-Flankenerkennung Cue-GO
|
|
|
|
# ---------------- Pipeline ----------------
|
|
|
|
def _layer_source(self, layer: Layer, path: str) -> str:
|
|
uri = f"file://{path}"
|
|
# Effekt-Kette: videoconvert ! FX1 ! videoconvert ! FX2 ! videoconvert
|
|
# (Muster verifiziert: Konvertierung ZWISCHEN Effekten noetig)
|
|
fx_chain = ""
|
|
for fx_id in (layer.fx1, layer.fx2):
|
|
if fx_id and fx_id in FX_CATALOG:
|
|
spec = FX_CATALOG[fx_id]
|
|
elem = f"{spec['element']} name=fx_{layer.id}_{fx_id}"
|
|
if "int" in spec:
|
|
val = int(spec["min"] + (spec["max"] - spec["min"])
|
|
* 0.5)
|
|
else:
|
|
val = spec["min"] + (spec["max"] - spec["min"]) * 0.5
|
|
elem += f" {spec['prop']}={val}"
|
|
fx_chain += f" ! videoconvert ! {elem}"
|
|
|
|
if layer.kind == "generator":
|
|
caps = (f"video/x-raw,width={layer.width},"
|
|
f"height={layer.height},framerate=15/1")
|
|
return (f"videotestsrc is-live=true pattern={layer.name} ! "
|
|
f"{caps}{fx_chain} ! videoconvert ! mix.sink_{layer.id}")
|
|
|
|
if layer.kind == "video":
|
|
caps = (f"video/x-raw,width={layer.width},"
|
|
f"height={layer.height},format=BGRA")
|
|
return (f"uridecodebin uri={uri} ! queue ! videoconvert ! "
|
|
f"videoscale ! {caps}{fx_chain} ! videoconvert ! "
|
|
f"mix.sink_{layer.id}")
|
|
# Bild: imagefreeze + EIN kombinierter Caps-Filter
|
|
# (zwei Caps-Filter in Serie lassen gst-parse fehlschlagen)
|
|
caps = (f"video/x-raw,width={layer.width},"
|
|
f"height={layer.height},format=BGRA,framerate=15/1")
|
|
return (f"uridecodebin uri={uri} ! imagefreeze ! "
|
|
f"videoconvert ! videoscale ! {caps}{fx_chain} ! "
|
|
f"videoconvert ! mix.sink_{layer.id}")
|
|
|
|
def _build_description(self) -> str:
|
|
pv = self.settings["preview"]
|
|
pad_defs = " ".join(
|
|
f"sink_{l.id}::xpos={l.x} sink_{l.id}::ypos={l.y} "
|
|
f"sink_{l.id}::zorder={l.z}"
|
|
for l in self.layers)
|
|
parts = [
|
|
f"compositor name=mix background=black {pad_defs} ! "
|
|
f"video/x-raw,width={pv['width']},height={pv['height']},"
|
|
f"format=BGRA ! tee name=t",
|
|
]
|
|
for layer in self.layers:
|
|
f = self.library.file_path(layer.name)
|
|
parts.append(self._layer_source(layer, str(f.resolve())))
|
|
parts.append(
|
|
f"t. ! queue ! videoconvert ! "
|
|
f"jpegenc quality={pv['jpeg_quality']} ! "
|
|
"appsink name=preview emit-signals=true max-buffers=2 drop=true")
|
|
if self.settings["output"].get("fullscreen"):
|
|
import sys
|
|
sink = ("d3d11videosink sync=true"
|
|
if sys.platform == "win32" else "autovideosink sync=true")
|
|
parts.append(f"t. ! queue ! videoconvert ! {sink}")
|
|
return " ".join(parts)
|
|
|
|
def rebuild(self) -> bool:
|
|
"""Baut die Pipeline neu auf. Atomar: bei Fehler laeuft die alte weiter."""
|
|
with self._lock:
|
|
if not self.layers:
|
|
self.stop()
|
|
self.running = True # kein Layer, aber "betriebsbereit"
|
|
return True
|
|
desc = self._build_description()
|
|
old = self.pipeline
|
|
try:
|
|
new = Gst.parse_launch(desc)
|
|
except Exception as e: # noqa: BLE001
|
|
self.last_error = f"parse_launch: {e}"
|
|
print(f"[Engine] FEHLER: {self.last_error}")
|
|
return False
|
|
sink = new.get_by_name("preview")
|
|
if sink:
|
|
sink.connect("new-sample", self._on_frame)
|
|
if new.set_state(
|
|
Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE:
|
|
self.last_error = "set_state(PLAYING) fehlgeschlagen"
|
|
print(f"[Engine] FEHLER: {self.last_error}")
|
|
new.set_state(Gst.State.NULL)
|
|
return False
|
|
# Neue Pipeline laeuft -> alte beenden (deren Bus-Monitor
|
|
# beendet sich beim naechsten Poll selbst, da self.pipeline
|
|
# nicht mehr die referenzierte Pipeline ist)
|
|
if old is not None:
|
|
old.set_state(Gst.State.NULL)
|
|
self.pipeline = new
|
|
self.running = True
|
|
self.last_error = None
|
|
self.apply_all() # Master/Blackout/Alphas auf neue Pads anwenden
|
|
# Effekt-Intensitaeten auf neue Elemente uebertragen
|
|
for l in self.layers:
|
|
if l.fx1:
|
|
self._set_fx_property(l, 1, l.fx1_intensity)
|
|
if l.fx2:
|
|
self._set_fx_property(l, 2, l.fx2_intensity)
|
|
self._monitor = threading.Thread(
|
|
target=self._bus_monitor, daemon=True)
|
|
self._monitor.start()
|
|
return True
|
|
|
|
def _bus_monitor(self):
|
|
pipeline = self.pipeline
|
|
if pipeline is None:
|
|
return
|
|
bus = pipeline.get_bus()
|
|
while self.running and self.pipeline is pipeline:
|
|
msg = bus.timed_pop_filtered(
|
|
500 * Gst.MSECOND,
|
|
Gst.MessageType.EOS | Gst.MessageType.ERROR)
|
|
if msg is None:
|
|
continue
|
|
if msg.type == Gst.MessageType.EOS:
|
|
if self.settings["engine"].get("loop_default", True):
|
|
pipeline.seek_simple(
|
|
Gst.Format.TIME,
|
|
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNITS,
|
|
0)
|
|
else:
|
|
self.playing = False
|
|
return
|
|
elif msg.type == Gst.MessageType.ERROR:
|
|
err, debug = msg.parse_error()
|
|
self.last_error = f"{err.message} ({debug})"
|
|
print(f"[Engine] GStreamer-Fehler: {self.last_error}")
|
|
return
|
|
|
|
def _on_frame(self, sink):
|
|
sample = sink.emit("pull-sample")
|
|
if sample is not None:
|
|
buf = sample.get_buffer()
|
|
self.current_jpeg = buf.extract_dup(0, buf.get_size())
|
|
self.frame_count += 1
|
|
return Gst.FlowReturn.OK
|
|
|
|
# ---------------- Layer-Verwaltung ----------------
|
|
|
|
def add_layer(self, name: str) -> dict | None:
|
|
with self._lock:
|
|
max_l = int(self.settings["engine"].get("max_layers", 8))
|
|
if len(self.layers) >= max_l:
|
|
self.last_error = f"max. {max_l} Layer"
|
|
return None
|
|
f = self.library.file_path(name)
|
|
if not f.exists():
|
|
self.last_error = f"Datei nicht gefunden: {name}"
|
|
return None
|
|
kind = self.library.kind_of(name)
|
|
if kind is None:
|
|
self.last_error = f"nicht unterstuetzt: {name}"
|
|
return None
|
|
layer = Layer(self._next_id, name, kind)
|
|
self._next_id += 1
|
|
layer.z = len(self.layers)
|
|
pv = self.settings["preview"]
|
|
layer.width = pv["width"]
|
|
layer.height = pv["height"]
|
|
self.layers.append(layer)
|
|
if not self.rebuild():
|
|
self.layers.remove(layer) # Rollback: Engine unveraendert
|
|
return None
|
|
return layer.to_dict()
|
|
|
|
def add_generator(self, gen_id: str) -> dict | None:
|
|
"""Fuegt einen Generator-Layer hinzu (prozedurale Quelle)."""
|
|
with self._lock:
|
|
if gen_id not in GENERATORS:
|
|
self.last_error = f"unbekannter Generator: {gen_id}"
|
|
return None
|
|
max_l = int(self.settings["engine"].get("max_layers", 8))
|
|
if len(self.layers) >= max_l:
|
|
self.last_error = f"max. {max_l} Layer"
|
|
return None
|
|
layer = Layer(self._next_id, gen_id, "generator")
|
|
self._next_id += 1
|
|
pv = self.settings["preview"]
|
|
layer.width = pv["width"]
|
|
layer.height = pv["height"]
|
|
layer.z = len(self.layers)
|
|
self.layers.append(layer)
|
|
if not self.rebuild():
|
|
self.layers.remove(layer)
|
|
return None
|
|
return layer.to_dict()
|
|
|
|
def generators(self) -> list[dict]:
|
|
"""Generator-Katalog fuer die UI."""
|
|
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]
|
|
if len(kept) == len(self.layers):
|
|
return False
|
|
removed = [l for l in self.layers if l.id == layer_id]
|
|
self.layers = kept
|
|
if not self.rebuild():
|
|
self.layers.extend(removed) # Rollback
|
|
self.layers.sort(key=lambda l: l.z)
|
|
return False
|
|
return True
|
|
|
|
def get_layer(self, layer_id: int) -> Layer | None:
|
|
for l in self.layers:
|
|
if l.id == layer_id:
|
|
return l
|
|
return None
|
|
|
|
def update_layer(self, layer_id: int, patch: dict
|
|
) -> Layer | None:
|
|
with self._lock:
|
|
layer = self.get_layer(layer_id)
|
|
if layer is None:
|
|
return None
|
|
if "alpha" in patch:
|
|
layer.alpha = max(0.0, min(1.0, float(patch["alpha"])))
|
|
if "x" in patch:
|
|
layer.x = int(patch["x"])
|
|
if "y" in patch:
|
|
layer.y = int(patch["y"])
|
|
if "width" in patch:
|
|
layer.width = max(16, min(7680, int(patch["width"])))
|
|
if "height" in patch:
|
|
layer.height = max(16, min(4320, int(patch["height"])))
|
|
if "z" in patch:
|
|
layer.z = max(0, min(15, int(patch["z"])))
|
|
fx_changed = False
|
|
if "fx1" in patch:
|
|
v = patch["fx1"]
|
|
layer.fx1 = v if (v in FX_CATALOG or v is None) else None
|
|
fx_changed = True
|
|
if "fx2" in patch:
|
|
v = patch["fx2"]
|
|
layer.fx2 = v if (v in FX_CATALOG or v is None) else None
|
|
fx_changed = True
|
|
if "fx1_intensity" in patch:
|
|
layer.fx1_intensity = max(0.0, min(1.0,
|
|
float(patch["fx1_intensity"])))
|
|
if layer.fx1:
|
|
self._set_fx_property(layer, 1, layer.fx1_intensity)
|
|
if "fx2_intensity" in patch:
|
|
layer.fx2_intensity = max(0.0, min(1.0,
|
|
float(patch["fx2_intensity"])))
|
|
if layer.fx2:
|
|
self._set_fx_property(layer, 2, layer.fx2_intensity)
|
|
src_changed = False
|
|
if "name" in patch and patch["name"] != layer.name:
|
|
new_name = str(patch["name"])
|
|
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():
|
|
return None
|
|
layer.name = new_name
|
|
layer.kind = (self.library.kind_of(new_name)
|
|
or layer.kind)
|
|
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)
|
|
# Pad-Eigenschaften live setzen (ohne Rebuild)
|
|
self._apply_pad_props(layer)
|
|
return layer
|
|
|
|
def _set_fx_property(self, layer: Layer, slot: int, intensity: float):
|
|
"""Setzt die Effekt-Intensitaet live (ohne Rebuild)."""
|
|
fx_id = layer.fx1 if slot == 1 else layer.fx2
|
|
if not fx_id or fx_id not in FX_CATALOG or self.pipeline is None:
|
|
return
|
|
spec = FX_CATALOG[fx_id]
|
|
elem = self.pipeline.get_by_name(f"fx_{layer.id}_{fx_id}")
|
|
if elem is None:
|
|
return
|
|
lo, hi = spec["min"], spec["max"]
|
|
val = lo + (hi - lo) * max(0.0, min(1.0, intensity))
|
|
try:
|
|
elem.set_property(spec["prop"],
|
|
int(val) if "int" in spec else float(val))
|
|
except Exception as e: # noqa: BLE001
|
|
print(f"[Engine] FX-Property {fx_id}: {e}")
|
|
|
|
def _apply_pad_props(self, layer: Layer):
|
|
if self.pipeline is None:
|
|
return
|
|
mix = self.pipeline.get_by_name("mix")
|
|
if mix is None:
|
|
return
|
|
pad = mix.get_static_pad(f"sink_{layer.id}")
|
|
if pad is None:
|
|
return
|
|
pad.set_property("xpos", layer.x)
|
|
pad.set_property("ypos", layer.y)
|
|
pad.set_property("zorder", layer.z)
|
|
pad.set_property("alpha", self._effective_alpha(layer))
|
|
|
|
# ---------------- Steuerung ----------------
|
|
|
|
def _effective_alpha(self, layer: Layer) -> float:
|
|
if self.blackout:
|
|
return 0.0
|
|
return layer.alpha * self.master
|
|
|
|
def apply_all(self):
|
|
with self._lock:
|
|
for l in self.layers:
|
|
self._apply_pad_props(l)
|
|
if self.pipeline is not None and self.layers:
|
|
target = (Gst.State.PLAYING if self.playing
|
|
else Gst.State.PAUSED)
|
|
current = self.pipeline.get_state(0)[1]
|
|
if current != target:
|
|
self.pipeline.set_state(target)
|
|
|
|
def set_master(self, value: float):
|
|
with self._lock:
|
|
self.master = max(0.0, min(1.0, value))
|
|
self.apply_all()
|
|
|
|
def set_blackout(self, on: bool):
|
|
with self._lock:
|
|
self.blackout = bool(on)
|
|
self.apply_all()
|
|
|
|
def set_playback(self, playing: bool):
|
|
with self._lock:
|
|
self.playing = bool(playing)
|
|
self.apply_all()
|
|
|
|
def retrigger(self):
|
|
with self._lock:
|
|
if self.pipeline is not None:
|
|
self.pipeline.seek_simple(
|
|
Gst.Format.TIME,
|
|
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNITS, 0)
|
|
|
|
# ---------------- DMX (Mapping aus Settings) ----------------
|
|
|
|
def apply_dmx(self, channels: bytes, universe: int, seq: int):
|
|
a = self.settings["artnet"]
|
|
|
|
def ch(num) -> int:
|
|
if num is None:
|
|
return 0
|
|
i = int(num) - 1
|
|
return channels[i] if 0 <= i < len(channels) else 0
|
|
|
|
with self._lock:
|
|
self.dmx_stats["packets"] += 1
|
|
self.dmx_stats["accepted"] += 1
|
|
self.dmx_stats["last_universe"] = universe
|
|
self.dmx_stats["last_seq"] = seq
|
|
self.dmx_stats["last_time"] = time.time()
|
|
|
|
start = a.get("layer_start_channel")
|
|
for i, layer in enumerate(self.layers):
|
|
v = ch(None if start is None else start + i)
|
|
if v > 0:
|
|
layer.alpha = v / 255.0
|
|
self.master = ch(a.get("master_channel")) / 255.0 or self.master
|
|
self.apply_all()
|
|
|
|
pb = a.get("playback_channel")
|
|
if ch(pb) >= 128:
|
|
self.set_playback(True)
|
|
bo = a.get("blackout_channel")
|
|
if bo is not None:
|
|
self.set_blackout(ch(bo) >= 128)
|
|
rt = a.get("retrigger_channel")
|
|
if rt is not None and ch(rt) >= 128:
|
|
self.retrigger()
|
|
# FX1-Intensitaet ueber Kanal (falls konfiguriert)
|
|
fx1_ch = a.get("fx1_channel")
|
|
if fx1_ch is not None and self.layers:
|
|
self.layers[0].fx1_intensity = ch(fx1_ch) / 255.0
|
|
self._set_fx_property(self.layers[0], 1,
|
|
self.layers[0].fx1_intensity)
|
|
fx2_ch = a.get("fx2_channel")
|
|
if fx2_ch is not None and self.layers:
|
|
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 ----------------
|
|
|
|
def stop(self):
|
|
self.running = False
|
|
if self._monitor is not None:
|
|
self._monitor.join(timeout=1.0)
|
|
self._monitor = None
|
|
if self.pipeline is not None:
|
|
self.pipeline.set_state(Gst.State.NULL)
|
|
self.pipeline = None
|
|
|
|
def status(self) -> dict:
|
|
with self._lock:
|
|
return {
|
|
"running": self.running,
|
|
"layers": [self._layer_dict(l) for l in self.layers],
|
|
"master": round(self.master, 3),
|
|
"blackout": self.blackout,
|
|
"playing": self.playing,
|
|
"frames_rendered": self.frame_count,
|
|
"dmx": dict(self.dmx_stats),
|
|
"error": self.last_error,
|
|
}
|
|
|
|
def export_state(self) -> dict:
|
|
"""Kompletten Showzustand exportieren (fuer Projektdatei)."""
|
|
with self._lock:
|
|
return {
|
|
"layers": [{
|
|
"name": l.name, "kind": l.kind,
|
|
"alpha": l.alpha, "x": l.x, "y": l.y,
|
|
"width": l.width, "height": l.height, "z": l.z,
|
|
"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,
|
|
}
|
|
|
|
def load_state(self, state: dict) -> bool:
|
|
"""Showzustand aus Projektdatei laden (ersetzt alle Layer)."""
|
|
if not isinstance(state, dict) or not isinstance(
|
|
state.get("layers"), list):
|
|
self.last_error = "ungueltiger Projekt-Zustand"
|
|
return False
|
|
with self._lock:
|
|
max_l = int(self.settings["engine"].get("max_layers", 8))
|
|
self.layers = []
|
|
self._next_id = 1
|
|
ok_count = 0
|
|
for raw in state["layers"][:max_l]:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
name = str(raw.get("name", ""))
|
|
kind_raw = str(raw.get("kind", ""))
|
|
if kind_raw == "generator":
|
|
if name not in GENERATORS:
|
|
print(f"[Projects] WARNUNG: unbekannter Generator, "
|
|
f"Layer uebersprungen: {name}")
|
|
continue
|
|
layer = Layer(self._next_id, name, "generator")
|
|
else:
|
|
f = self.library.file_path(name)
|
|
kind = self.library.kind_of(name)
|
|
if not f.exists() or kind is None:
|
|
print(f"[Projects] WARNUNG: Medium fehlt, Layer "
|
|
f"uebersprungen: {name}")
|
|
continue
|
|
layer = Layer(self._next_id, name, kind)
|
|
self._next_id += 1
|
|
layer.alpha = max(0.0, min(1.0,
|
|
float(raw.get("alpha", 1.0))))
|
|
layer.x = int(raw.get("x", 0))
|
|
layer.y = int(raw.get("y", 0))
|
|
layer.width = max(16, min(7680, int(raw.get("width", 640))))
|
|
layer.height = max(16, min(4320, int(raw.get("height", 360))))
|
|
layer.z = max(0, min(15, int(raw.get("z", ok_count))))
|
|
layer.fx1 = raw.get("fx1") if raw.get("fx1") in FX_CATALOG else None
|
|
layer.fx2 = raw.get("fx2") if raw.get("fx2") in FX_CATALOG else None
|
|
layer.fx1_intensity = max(0.0, min(1.0,
|
|
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")
|
|
if isinstance(m, (int, float)):
|
|
self.master = max(0.0, min(1.0, float(m)))
|
|
self.playing = True
|
|
self.blackout = False
|
|
rebuilt = self.rebuild()
|
|
print(f"[Projects] {ok_count} Layer geladen "
|
|
f"(Rebuild {'OK' if rebuilt else 'FEHLER'})")
|
|
return rebuilt
|
|
|
|
def fx_catalog(self) -> list[dict]:
|
|
"""Effekt-Katalog fuer die UI (id, Element, Label, Int-Bereich)."""
|
|
out = []
|
|
for fx_id, spec in FX_CATALOG.items():
|
|
out.append({
|
|
"id": fx_id,
|
|
"label": spec.get("label", fx_id),
|
|
"element": spec["element"],
|
|
"prop": spec["prop"],
|
|
"min": spec["min"], "max": spec["max"],
|
|
"int": "int" in spec,
|
|
})
|
|
return out
|
|
|
|
def _layer_dict(self, l: Layer) -> dict:
|
|
d = l.to_dict()
|
|
d["effective"] = round(self._effective_alpha(l), 3)
|
|
return d
|