"""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