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
+

Remote-Server

+

Medien von anderen HMS-Servern ins lokale Archiv ziehen:

+
-
- Aktive Layer bearbeiten:
+
+ + + +
@@ -147,8 +153,13 @@ footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);bo

Engine

+
lädt beim Programmstart
Windows: D3D11 · Linux: autovideosink
+

Remote-Server (Medien-Verteilung)

+
+
+

Medien

MB
@@ -169,7 +180,7 @@ async function api(path,body){try{const r=await fetch('/api/'+path,{method:'POST // Tabs document.querySelectorAll('#tabbar button').forEach(b=>b.onclick=()=>showTab(b.dataset.p)); -function showTab(p){document.querySelectorAll('.page').forEach(x=>x.classList.remove('on'));$('page-'+p).classList.add('on');document.querySelectorAll('#tabbar button').forEach(x=>x.classList.toggle('on',x.dataset.p===p));if(p==='media')loadMedia();if(p==='settings')loadSettings();if(p==='projects')loadProjects();} +function showTab(p){document.querySelectorAll('.page').forEach(x=>x.classList.remove('on'));$('page-'+p).classList.add('on');document.querySelectorAll('#tabbar button').forEach(x=>x.classList.toggle('on',x.dataset.p===p));if(p==='media'){loadMedia();loadServers()}if(p==='settings')loadSettings();if(p==='projects')loadProjects();} // Live-Steuerung $('master').oninput=e=>{api('master',{intensity:e.target.value/100});$('mv').textContent=e.target.value+'%'}; @@ -242,6 +253,8 @@ function upOne(f,done){ // Settings async function loadSettings(){try{const s=await(await fetch('/api/settings')).json(); + try{const pr=await(await fetch('/api/projects')).json();const sel=$('s-autostart');sel.innerHTML=''+(pr.items||[]).map(p=>``).join('')}catch(e){} + loadServers(); $('s-web-port').value=s.web.port;$('s-web-bind').value=s.web.bind; $('s-preview-width').value=s.preview.width;$('s-preview-height').value=s.preview.height;$('s-preview-fps').value=s.preview.fps;$('s-preview-q').value=s.preview.jpeg_quality; $('s-artnet-enabled').checked=s.artnet.enabled;$('s-artnet-port').value=s.artnet.listen_port; @@ -260,7 +273,7 @@ async function saveSettings(){const body={ 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, 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}, + 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}, media:{dir:$('s-media-dir').value,max_upload_mb:+$('s-media-max').value}}; const d=await api('settings',body); @@ -277,6 +290,31 @@ async function loadProject(name){const d=await api('projects/load',{name});if(d. async function delProject(name){if(!confirm(name+' löschen?'))return;await api('projects/delete',{name});loadProjects()} async function setAutostart(name){const d=await api('projects/autostart',{name});if(d.autostart){toast('Autostart aktiv: '+d.autostart);loadProjects()}} +// ---------- Generatoren ---------- +let GENS=[]; +async function loadGens(){try{const d=await(await fetch('/api/generators')).json();GENS=d.items||[];$('gensel').innerHTML=''+GENS.map(g=>``).join('')}catch(e){}} +async function addGen(){const g=$('gensel').value;if(!g){toast('Generator wählen',true);return} + const d=await api('layers/add-generator',{generator:g});if(d.layer){toast('Generator-Layer: '+g);$('gensel').value=''}else if(d.error)toast(d.error,true)} + +// ---------- Remote-Server ---------- +async function addServer(){const name=$('srv-name').value.trim(),url=$('srv-url').value.trim(); + if(!name||!url){toast('Name und URL eingeben',true);return} + const d=await api('servers/add',{name,url});if(d.added){toast('Server hinzugefügt: '+name);$('srv-name').value='';$('srv-url').value='';loadServers()} + else toast(d.error||'Server nicht erreichbar oder kein HMS MediaEngine',true)} +async function delServer(name){if(!confirm(name+' entfernen?'))return;await api('servers/remove',{name});loadServers()} +async function importRemote(srv,media){toast('Importiere '+media+' von '+srv+'…'); + const d=await api('servers/import',{server:srv,media});if(d.saved){toast('Importiert: '+d.saved);loadMedia()}else toast(d.error||'Import fehlgeschlagen',true)} + +async function loadServers(){try{const d=await(await fetch('/api/servers')).json();const servers=d.servers||[]; + // Einstellungen: Server-Liste + const sl=$('srvlist');if(sl)sl.innerHTML=servers.map(S=>`
${S.online?'online':'offline'}
`).join('')||'

Noch keine Server registriert.

'; + // Medien-Tab: Remote-Medien + const rg=$('rgrid');if(rg)rg.innerHTML=servers.map(S=> + `

${S.name} ${S.online?'online':'offline'}

`+ + (S.online?(S.items||[]).map(m=>`
`).join('')||'

Keine Medien auf dem Server.

'):'')+'
' + ).join('')||'

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(); diff --git a/run.py b/run.py index ced973d..7931bb3 100644 --- a/run.py +++ b/run.py @@ -48,6 +48,9 @@ def main() -> int: help="setup_windows.ps1 / setup_linux.sh erzeugen") ap.add_argument("--port", type=int, default=None, help="Web-Port ueberschreibt config.json") + ap.add_argument("--data-root", type=str, default=None, + help="Eigener Datenordner fuer mehrere Instanzen " + "(config/media/projekte dort statt neben run.py)") args = ap.parse_args() if args.generate_setup: @@ -60,7 +63,12 @@ def main() -> int: ok = setupmod.check_and_install_dependencies() return 0 if ok else 1 - settings = cfgmod.load(ROOT) + DATA_ROOT = (Path(args.data_root).expanduser().resolve() + if args.data_root else ROOT) + if DATA_ROOT != ROOT: + DATA_ROOT.mkdir(parents=True, exist_ok=True) + + settings = cfgmod.load(DATA_ROOT) if args.port is not None: settings["web"]["port"] = args.port @@ -69,13 +77,13 @@ def main() -> int: cfg = setupmod.run_setup(settings) settings["web"]["port"] = cfg["port"] settings["output"]["fullscreen"] = cfg["fullscreen"] - cfgmod.save(ROOT, settings) + cfgmod.save(DATA_ROOT, settings) - library = MediaLibrary(settings, ROOT) + library = MediaLibrary(settings, DATA_ROOT) library.ensure_dirs() engine = MediaEngine(settings, library) artnet = ArtNetInput(engine, settings) - server = EngineServer(engine, artnet, library, settings, ROOT) + server = EngineServer(engine, artnet, library, settings, DATA_ROOT) # CLI-Dateien importieren und als Layer aktivieren for f in args.files: @@ -93,7 +101,7 @@ def main() -> int: auto = settings["engine"].get("autostart_project") if auto: from hms_app.projects import ProjectStore - doc = ProjectStore(ROOT).load(str(auto)) + doc = ProjectStore(DATA_ROOT).load(str(auto)) if doc is not None: if engine.load_state(doc.get("state") or {}): print(f"[Start] Autostart-Projekt geladen: {auto}") diff --git a/tests/e2e_distribution_test.py b/tests/e2e_distribution_test.py new file mode 100644 index 0000000..69daa39 --- /dev/null +++ b/tests/e2e_distribution_test.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""E2E: Generatoren, Remote-Server-Medienverteilung, Autostart-Merge. + +Startet ZWEI echte Server: A (lokal) und B (Quelle). Server A +registriert B und zieht Medien von B ins lokale Archiv. +""" + +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +ROOT = "/a0/usr/workdir/hms-mediaengine" +DATA_B = "/tmp/hms_server_b_data" +PORT_A = 8095 +PORT_B = 8096 +BASE_A = "http://localhost:%d" % PORT_A +BASE_B = "http://localhost:%d" % PORT_B + +procs = {} + + +def start_server(tag: str, base: str, port: int, + data_root: str | None = None) -> bool: + cmd = ["/usr/bin/python3", ROOT + "/run.py", + "--port", str(port)] + if data_root: + cmd += ["--data-root", data_root] + procs[tag] = subprocess.Popen( + cmd, + cwd=ROOT, stdout=open("/tmp/hms_e2e_%s.log" % tag, "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_all(): + for p in procs.values(): + p.terminate() + for p in procs.values(): + try: + p.wait(timeout=6) + except subprocess.TimeoutExpired: + p.kill() + procs.clear() + time.sleep(0.5) + + +def get(base, path): + with urllib.request.urlopen(base + path, timeout=15) as r: + return json.loads(r.read().decode()) + + +def post(base, path, body=None, raw=None, headers=None): + data = raw if raw is not None else json.dumps(body or {}).encode() + req = urllib.request.Request(base + path, data=data, method="POST") + req.add_header("Content-Type", "application/json" if raw is None + else "application/octet-stream") + for k, v in (headers or {}).items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=180) 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) + + +# ---------- 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: + # ---------- Server B (Quelle) ---------- + shutil.rmtree(DATA_B, ignore_errors=True) + check("Server B startet (eigener Datenordner)", + start_server("B", BASE_B, PORT_B, data_root=DATA_B)) + + # Testvideo auf B erzeugen + hochladen + 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=60 pattern=18 ! " + "video/x-raw,width=320,height=180 ! x264enc tune=zerolatency ! " + "mp4mux ! filesink location=/tmp/e2e_dist.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_dist.mp4", "rb").read() + r = post(BASE_B, "/api/media/upload", raw=data, + headers={"X-Filename": urllib.parse.quote("clip_vom_server_B.mp4")}) + check("B: Video hochgeladen", bool(r.get("saved")), r) + + # ---------- Server A (Hauptserver) ---------- + check("Server A startet", start_server("A", BASE_A, PORT_A)) + + # 1) Generatoren: Katalog + Layer + FX + gens = get(BASE_A, "/api/generators")["items"] + check("Generator-Katalog 15", len(gens) == 15, "%d" % len(gens)) + gen_ids = [g["id"] for g in gens] + check("Ball + Snow vorhanden", "ball" in gen_ids and "snow" in gen_ids) + + r = post(BASE_A, "/api/layers/add-generator", {"generator": "ball"}) + check("Generator-Layer ball", r.get("layer", {}).get("kind") + == "generator", r) + gid = r.get("layer", {}).get("id") + r = post(BASE_A, "/api/layers/update", + {"id": gid, "fx1": "blur", "fx1_intensity": 0.6}) + check("Generator + FX kombiniert", r.get("layer", {}).get("fx1") + == "blur", r) + time.sleep(2) + st = get(BASE_A, "/api/status") + check("Engine rendert Generator+FX", st["frames_rendered"] > 0 + and st["error"] is None, + "frames=%d err=%s" % (st["frames_rendered"], st["error"])) + + # Generator wechseln (Pattern-Tausch ohne neuen Layer) + r = post(BASE_A, "/api/layers/update", {"id": gid, "name": "snow"}) + check("Generator-Wechsel ball->snow", r.get("layer", {}).get("name") + == "snow", r) + time.sleep(1.5) + st = get(BASE_A, "/api/status") + check("Engine nach Generator-Wechsel", st["running"] is True, + "err=%s" % st["error"]) + + # Ungueltiger Generator abgewiesen + r = post(BASE_A, "/api/layers/add-generator", {"generator": "keins"}) + check("Ungueltiger Generator 400", "error" in r, r) + + # 2) Remote-Server-Verteilung: A registriert B + r = post(BASE_A, "/api/servers/add", + {"name": "Node-B", "url": BASE_B}) + check("Server B registriert", r.get("added") is True, r) + + # Duplikat-Name abgewiesen + r = post(BASE_A, "/api/servers/add", + {"name": "Node-B", "url": BASE_B}) + check("Duplikat-Name abgewiesen", "error" in r, r) + + # Ungueltige URL abgewiesen + r = post(BASE_A, "/api/servers/add", + {"name": "X", "url": "ftp://kein-http"}) + check("Nicht-HTTP abgewiesen", "error" in r, r) + + # Server-Liste: B ist online und liefert Medien + srv = get(BASE_A, "/api/servers")["servers"] + check("Server-Liste 1 Eintrag", len(srv) == 1, srv) + check("Node-B online", srv[0]["online"] is True, srv[0]["name"]) + check("B-Medien sichtbar", + any(m["name"] == "clip_vom_server_B.mp4" + for m in srv[0]["items"]), + [m["name"] for m in srv[0]["items"]]) + + # Medium von B nach A ziehen + r = post(BASE_A, "/api/servers/import", + {"server": "Node-B", "media": "clip_vom_server_B.mp4"}) + check("Import von B nach A (Originalname, getrennte Ordner)", + r.get("saved") == "clip_vom_server_B.mp4", r) + # Datei-Gleichheit pruefen (SHA von Quelle und Kopie) + import hashlib + def sha(path): + return hashlib.sha256(open(path, "rb").read()).hexdigest()[:16] + src = os.path.join(DATA_B, "media", "clip_vom_server_B.mp4") + dst = os.path.join(ROOT, "media", "clip_vom_server_B.mp4") + check("Importierte Datei byte-identisch", sha(src) == sha(dst), + "sha=%s" % sha(dst)) + lib_a = get(BASE_A, "/api/media")["items"] + check("Datei liegt in A-Archiv", + any(m["name"] == "clip_vom_server_B.mp4" for m in lib_a), + [m["name"] for m in lib_a]) + # Import in A als Layer nutzbar + r = post(BASE_A, "/api/layers/add", + {"name": "clip_vom_server_B.mp4"}) + check("Importiertes als Layer", "layer" in r, r) + time.sleep(1.5) + st = get(BASE_A, "/api/status") + check("Engine rendert mit Import", st["frames_rendered"] > 0 + and st["error"] is None, "frames=%d" % st["frames_rendered"]) + + # Import von unregistriertem Namen -> 404 + r = post(BASE_A, "/api/servers/import", + {"server": "gibts_nicht", "media": "x.mp4"}) + check("Unbekannter Server 404", "error" in r, r) + + # Offline-Server: B stoppen, Liste muss offline zeigen + procs["B"].terminate() + procs["B"].wait(timeout=6) + time.sleep(0.5) + srv = get(BASE_A, "/api/servers")["servers"] + check("B nach Stop offline", srv[0]["online"] is False) + r = post(BASE_A, "/api/servers/remove", {"name": "Node-B"}) + check("Server entfernt", r.get("removed") is True, r) + cfg = json.load(open(os.path.join(ROOT, "config.json"))) + check("config.json Server entfernt", cfg.get("servers") == []) + + # 3) Autostart + Settings-Merge: Autostart darf beim + # Speichern der Einstellungen NICHT verloren gehen + post(BASE_A, "/api/projects/save", {"name": "Autostart Test"}) + r = post(BASE_A, "/api/projects/autostart", {"name": "Autostart Test"}) + check("Autostart gesetzt", r.get("autostart") == "Autostart Test", r) + # Einstellungen OHNE autostart_project speichern (UI-Merge-Fall) + r = post(BASE_A, "/api/settings", {"preview": {"fps": 20}}) + check("Settings gespeichert (Merge)", bool(r.get("saved")), r) + cfg = json.load(open(os.path.join(ROOT, "config.json"))) + check("Autostart UEBERLEBT Settings-Save", + cfg["engine"]["autostart_project"] == "Autostart Test", + cfg["engine"]["autostart_project"]) + check("FPS uebernommen", cfg["preview"]["fps"] == 20) + st = get(BASE_A, "/api/status") + check("Server laeuft weiter", st["running"] is True) + + # Autostart via Settings-Endpunkt abwaehlen (NULL akzeptiert) + r = post(BASE_A, "/api/settings", + {"engine": {"autostart_project": None}}) + check("Autostart via Settings abgewaehlt", + bool(r.get("saved")) + and json.load(open(os.path.join( + ROOT, "config.json")))["engine"]["autostart_project"] is None) + + # Generatoren in Projekten: speichern + laden + post(BASE_A, "/api/layers/add-generator", {"generator": "gradient"}) + r = post(BASE_A, "/api/projects/save", {"name": "Mit Generator"}) + check("Projekt mit Generator gespeichert", r.get("saved") + == "Mit Generator", r) + r = post(BASE_A, "/api/projects/load", {"name": "Mit Generator"}) + check("Projekt mit Generator geladen", r.get("loaded") is True, r) + st = get(BASE_A, "/api/status") + check("Generator nach Projekt-Load aktiv", + any(l["kind"] == "generator" and l["name"] == "gradient" + for l in st["layers"]), + [(l["kind"], l["name"]) for l in st["layers"]]) + + print("") + print("ALLE VERTEILUNGS/GENERATOR-E2E-TESTS BESTANDEN") +finally: + stop_all()