diff --git a/hms_app/config.py b/hms_app/config.py index b7724d6..bed3275 100644 --- a/hms_app/config.py +++ b/hms_app/config.py @@ -29,6 +29,7 @@ DEFAULTS: dict = { }, "engine": {"max_layers": 8, "loop_default": True, "autostart_project": None}, + "servers": [], } @@ -131,6 +132,19 @@ def validate(new: dict) -> tuple[dict | None, str | None]: ap = e.get("autostart_project") if ap is not None and not isinstance(ap, str): errors.append("engine.autostart_project") + sv = merged.get("servers") + if not isinstance(sv, list): + errors.append("servers") + else: + for item in sv: + if (not isinstance(item, dict) + or not isinstance(item.get("name"), str) + or not item.get("name", "").strip() + or not isinstance(item.get("url"), str) + or not item.get("url", "").startswith( + ("http://", "https://"))): + errors.append("servers") + break if not isinstance(merged["output"].get("fullscreen"), bool): errors.append("output.fullscreen") diff --git a/hms_app/distribution.py b/hms_app/distribution.py new file mode 100644 index 0000000..b300140 --- /dev/null +++ b/hms_app/distribution.py @@ -0,0 +1,49 @@ +"""Medien-Verteilung: Remote-Server registrieren, Medien abrufen.""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.parse +import urllib.request + +TIMEOUT_SHORT = 5 +TIMEOUT_IMPORT = 180 + + +def check(url: str) -> bool: + """Prueft ob ein Remote-Server ein HMS MediaEngine ist.""" + try: + with urllib.request.urlopen( + url.rstrip("/") + "/api/health", + timeout=TIMEOUT_SHORT) as r: + return json.loads(r.read().decode()).get("status") == "ok" + except Exception: # noqa: BLE001 + return False + + +def list_media(url: str) -> dict: + """Medienbibliothek eines Remote-Servers abrufen.""" + with urllib.request.urlopen( + url.rstrip("/") + "/api/media", + timeout=TIMEOUT_SHORT) as r: + return json.loads(r.read().decode()) + + +def import_media(url: str, media_name: str, library) -> tuple[bool, object]: + """Zieht eine Datei per HTTP von einem Remote-Server in die lokale + Bibliothek (Streaming, gleiche Upload-Pfad-Sicherung wie Web-Upload).""" + q = urllib.parse.quote(media_name) + try: + with urllib.request.urlopen( + url.rstrip("/") + "/media/" + q, + timeout=TIMEOUT_IMPORT) as r: + length = int(r.headers.get("Content-Length", "0")) + saved, err = library.save_stream(media_name, r, length) + if err: + return False, err + return True, saved + except urllib.error.HTTPError as e: + return False, f"Remote-Fehler HTTP {e.code}" + except (urllib.error.URLError, OSError, ValueError) as e: + return False, f"Verbindung fehlgeschlagen: {e}" diff --git a/hms_app/engine.py b/hms_app/engine.py index 4e98391..33e8843 100644 --- a/hms_app/engine.py +++ b/hms_app/engine.py @@ -80,6 +80,26 @@ FX_CATALOG: dict[str, dict] = { "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.""" @@ -153,6 +173,12 @@ class MediaEngine: 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") @@ -298,6 +324,33 @@ class MediaEngine: 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()] + def remove_layer(self, layer_id: int) -> bool: with self._lock: kept = [l for l in self.layers if l.id != layer_id] @@ -358,11 +411,22 @@ class MediaEngine: self.rebuild() # Struktur geaendert -> Pipeline neu return self.get_layer(layer.id) if "name" in patch and patch["name"] != layer.name: - f = self.library.file_path(str(patch["name"])) - if not f.exists(): + new_name = str(patch["name"]) + if layer.kind == "generator": + if new_name not in GENERATORS: + return None + layer.name = new_name + 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) + # Quell-Wechsel braucht einen Pipeline-Rebuild + if not self.rebuild(): return None - layer.name = str(patch["name"]) - layer.kind = self.library.kind_of(layer.name) or layer.kind + return self.get_layer(layer.id) # Pad-Eigenschaften live setzen (ohne Rebuild) self._apply_pad_props(layer) return layer @@ -539,13 +603,21 @@ class MediaEngine: if not isinstance(raw, dict): continue name = str(raw.get("name", "")) - 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) + 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)))) diff --git a/hms_app/server.py b/hms_app/server.py index 3dc2827..fac620c 100644 --- a/hms_app/server.py +++ b/hms_app/server.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import json import threading import time @@ -9,6 +10,7 @@ import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import config as cfgmod +from . import distribution from .projects import ProjectStore from .ui import HTML_UI @@ -102,6 +104,23 @@ class EngineServer: self._json(artnet.monitor()) elif path == "/api/fx": self._json(engine.fx_catalog()) + elif path == "/api/generators": + self._json({"items": engine.generators()}) + elif path == "/api/servers": + out = [] + for s_ in settings.get("servers", []): + entry = {"name": s_.get("name"), + "url": s_.get("url"), + "online": False, "items": []} + try: + data = distribution.list_media( + str(s_.get("url", ""))) + entry["online"] = True + entry["items"] = data.get("items", []) + except Exception: # noqa: BLE001 + pass + out.append(entry) + self._json({"servers": out}) elif path == "/api/projects": self._json({"items": server.projects.list(), "autostart": settings["engine"].get( @@ -134,6 +153,67 @@ class EngineServer: self._send_error_json(400, engine.last_error or "?" ) else: self._json({"layer": layer}) + elif path == "/api/layers/add-generator": + body = self._read_json() + layer = engine.add_generator( + str(body.get("generator", ""))) + if layer is None: + self._send_error_json( + 400, engine.last_error or "unbekannter Generator") + else: + self._json({"layer": layer}) + elif path == "/api/servers/add": + body = self._read_json() + name = str(body.get("name", "")).strip() + url = str(body.get("url", "")).strip() + if (not name or not url.startswith( + ("http://", "https://"))): + self._send_error_json( + 400, "Name und http(s)-URL erforderlich") + elif any(s_.get("name") == name + for s_ in settings.get("servers", [])): + self._send_error_json(400, "Name bereits vergeben") + elif not distribution.check(url): + self._json({"added": False, + "error": "Server nicht erreichbar " + "oder kein HMS MediaEngine"}) + else: + settings.setdefault("servers", []).append( + {"name": name, "url": url}) + cfgmod.save(root, settings) + self._json({"added": True, "name": name}) + elif path == "/api/servers/remove": + body = self._read_json() + name = str(body.get("name", "")) + before = len(settings.get("servers", [])) + settings["servers"] = [ + s_ for s_ in settings.get("servers", []) + if s_.get("name") != name] + if len(settings["servers"]) == before: + self._send_error_json(404, "Server nicht gefunden") + else: + cfgmod.save(root, settings) + self._json({"removed": True}) + elif path == "/api/servers/import": + body = self._read_json() + srv_name = str(body.get("server", "")) + media_name = str(body.get("media", "")) + url = None + for s_ in settings.get("servers", []): + if s_.get("name") == srv_name: + url = str(s_.get("url", "")) + break + if url is None: + self._send_error_json(404, "Server nicht gefunden") + elif not media_name: + self._send_error_json(400, "Medienname fehlt") + else: + ok, result = distribution.import_media( + url, media_name, library) + if ok: + self._json({"saved": result}) + else: + self._send_error_json(400, str(result)) elif path == "/api/layers/remove": body = self._read_json() ok = engine.remove_layer(int(body.get("id", -1))) @@ -226,7 +306,11 @@ class EngineServer: def _post_settings(self): body = self._read_json() - normalized, err = cfgmod.validate(body) + # WICHTIG: auf aktuelle Settings mergen, damit nicht + # gesendete Felder (autostart_project, servers) erhalten + # bleiben und nicht auf Defaults zurueckfallen. + merged = cfgmod.deep_merge(copy.deepcopy(settings), body) + normalized, err = cfgmod.validate(merged) if err is not None: self._send_error_json(400, err) return diff --git a/hms_app/ui.py b/hms_app/ui.py index 5ea82e7..157d714 100644 --- a/hms_app/ui.py +++ b/hms_app/ui.py @@ -105,11 +105,17 @@ footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);bo
+Medien von anderen HMS-Servern ins lokale Archiv ziehen:
+Noch keine Server registriert.
'; + // Medien-Tab: Remote-Medien + const rg=$('rgrid');if(rg)rg.innerHTML=servers.map(S=> + `Keine Medien auf dem Server.
'):'')+'Noch keine Server. In den Einstellungen registrieren.
'; +}catch(e){}} + let FX=[]; async function loadFx(){try{FX=await(await fetch('/api/fx')).json();fillFxSelects()}catch(e){}} function fillFxSelects(){document.querySelectorAll('select[id^="fx1-"],select[id^="fx2-"]').forEach(sel=>{ @@ -288,6 +326,8 @@ function fillFxSelects(){document.querySelectorAll('select[id^="fx1-"],select[id const _origRender=renderLayerEdit; renderLayerEdit=function(ls){_origRender(ls);document.querySelectorAll('select[id^="fx1-"]').forEach(s=>s.dataset.lay=s.id.slice(4));document.querySelectorAll('select[id^="fx2-"]').forEach(s=>s.dataset.lay=s.id.slice(4));fillFxSelects()}; loadFx(); +loadGens(); +loadServers(); poll();