diff --git a/.gitignore b/.gitignore index 1899797..19f6f44 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ Thumbs.db media/ thumbs/ config.json + +# Projekte (Nutzerdaten) +projects/ diff --git a/hms_app/config.py b/hms_app/config.py index 5dafef0..b7724d6 100644 --- a/hms_app/config.py +++ b/hms_app/config.py @@ -27,7 +27,8 @@ DEFAULTS: dict = { "signal_loss_policy": "hold", "signal_loss_timeout_s": 10, }, - "engine": {"max_layers": 8, "loop_default": True}, + "engine": {"max_layers": 8, "loop_default": True, + "autostart_project": None}, } @@ -127,6 +128,9 @@ def validate(new: dict) -> tuple[dict | None, str | None]: _int(e, "max_layers", 1, 16, "engine.max_layers") if not isinstance(e.get("loop_default"), bool): errors.append("engine.loop_default") + ap = e.get("autostart_project") + if ap is not None and not isinstance(ap, str): + errors.append("engine.autostart_project") if not isinstance(merged["output"].get("fullscreen"), bool): errors.append("output.fullscreen") diff --git a/hms_app/engine.py b/hms_app/engine.py index 77b94aa..4e98391 100644 --- a/hms_app/engine.py +++ b/hms_app/engine.py @@ -509,6 +509,69 @@ class MediaEngine: "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, + } 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", "")) + 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)))) + 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 = [] diff --git a/hms_app/projects.py b/hms_app/projects.py new file mode 100644 index 0000000..77f4550 --- /dev/null +++ b/hms_app/projects.py @@ -0,0 +1,118 @@ +"""Projekt-Speicherung: Layer-/FX-Setup als Datei in projects/. + +Ein Projekt enthält den kompletten Showzustand (Layer mit Position, +Alpha, Z-Order, Effekte + Intensitaeten, Master) und kann beim +Programmstart automatisch geladen werden (Autostart). +""" + +from __future__ import annotations + +import json +import re +import time +from pathlib import Path + +FORMAT = "hms-project" +VERSION = 1 +SUFFIX = ".hms.json" + + +def sanitize(name: str) -> str: + """Projektname sicher: keine Pfade/Sonderzeichen, Leerzeichen erlaubt.""" + base = Path(str(name)).name # schneidet Pfadanteile ab (Traversal-Schutz) + base = re.sub(r"[^A-Za-z0-9._ -]", "_", base).strip("._ ") + base = re.sub(r"\s+", " ", base) # Mehrfach-Leerzeichen zusammenfassen + return base or "projekt" + + +class ProjectStore: + """Dateibasierter Projektspeicher in /projects/.""" + + def __init__(self, root: Path): + self.dir = Path(root) / "projects" + + def _ensure(self) -> None: + self.dir.mkdir(parents=True, exist_ok=True) + + def path(self, name: str) -> Path: + return self.dir / (sanitize(name) + SUFFIX) + + # ---------------- Speichern ---------------- + + def save(self, name: str, state: dict) -> dict | None: + """Speichert den Zustand atomar. Rueckgabe: Info-Dict oder None.""" + self._ensure() + final = sanitize(name) + if not final: + return None + doc = { + "format": FORMAT, + "version": VERSION, + "name": final, + "saved_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "saved_at_epoch": time.time(), + "state": state, + } + target = self.dir / (final + SUFFIX) + tmp = target.with_suffix(SUFFIX + ".tmp") + try: + tmp.write_text( + json.dumps(doc, ensure_ascii=False, indent=2), "utf-8") + tmp.replace(target) + except OSError as e: + print(f"[Projects] Schreiben fehlgeschlagen: {e}") + tmp.unlink(missing_ok=True) + return None + return { + "saved": final, + "saved_at": doc["saved_at"], + "layers": len(state.get("layers", [])), + } + + # ---------------- Laden ---------------- + + def load(self, name: str) -> dict | None: + """Liest ein Projekt. Rueckgabe: geparstes Dokument oder None.""" + p = self.path(name) + if not p.is_file(): + return None + try: + doc = json.loads(p.read_text("utf-8")) + except (OSError, ValueError) as e: + print(f"[Projects] Lesefehler ({p.name}): {e}") + return None + if not isinstance(doc, dict) or doc.get("format") != FORMAT: + return None + return doc + + def exists(self, name: str) -> bool: + return self.load(name) is not None + + # ---------------- Liste / Loeschen ---------------- + + def list(self) -> list[dict]: + self._ensure() + items: list[dict] = [] + for f in sorted(self.dir.glob("*" + SUFFIX)): + try: + doc = json.loads(f.read_text("utf-8")) + except (OSError, ValueError): + continue + if not isinstance(doc, dict) or doc.get("format") != FORMAT: + continue + items.append({ + "name": str(doc.get("name", f.stem)), + "saved_at": doc.get("saved_at"), + "layers": len((doc.get("state") or {}).get("layers", [])), + }) + return items + + def delete(self, name: str) -> bool: + p = self.path(name) + if not p.is_file(): + return False + try: + p.unlink() + return True + except OSError: + return False diff --git a/hms_app/server.py b/hms_app/server.py index 33278e8..3dc2827 100644 --- a/hms_app/server.py +++ b/hms_app/server.py @@ -9,6 +9,7 @@ import urllib.parse from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import config as cfgmod +from .projects import ProjectStore from .ui import HTML_UI @@ -21,6 +22,7 @@ class EngineServer: self.library = library self.settings = settings self.root = root + self.projects = ProjectStore(root) self.httpd: ThreadingHTTPServer | None = None self.running = False @@ -100,6 +102,10 @@ class EngineServer: self._json(artnet.monitor()) elif path == "/api/fx": self._json(engine.fx_catalog()) + elif path == "/api/projects": + self._json({"items": server.projects.list(), + "autostart": settings["engine"].get( + "autostart_project")}) else: self._send_error_json(404, "nicht gefunden") @@ -156,6 +162,48 @@ class EngineServer: elif path == "/api/retrigger": engine.retrigger() self._json({"ok": True}) + elif path == "/api/projects/save": + body = self._read_json() + name = str(body.get("name", "")).strip() + if not name: + self._send_error_json(400, "Name fehlt") + else: + info = server.projects.save( + name, engine.export_state()) + if info is None: + self._send_error_json(500, "Speichern fehlgeschlagen") + else: + self._json(info) + elif path == "/api/projects/load": + body = self._read_json() + name = str(body.get("name", "")) + doc = server.projects.load(name) + if doc is None: + self._send_error_json(404, "Projekt nicht gefunden") + else: + ok = engine.load_state(doc.get("state") or {}) + self._json({"loaded": bool(ok), + "name": doc.get("name"), + "error": None if ok else engine.last_error}) + elif path == "/api/projects/delete": + body = self._read_json() + ok = server.projects.delete(str(body.get("name", ""))) + if (settings["engine"].get("autostart_project") + == str(body.get("name", ""))): + settings["engine"]["autostart_project"] = None + cfgmod.save(root, settings) + self._json({"deleted": bool(ok)}) + elif path == "/api/projects/autostart": + body = self._read_json() + name = body.get("name") + if name and not server.projects.exists(str(name)): + self._send_error_json(404, "Projekt nicht gefunden") + else: + settings["engine"]["autostart_project"] = ( + str(name) if name else None) + cfgmod.save(root, settings) + self._json({"autostart": settings["engine"][ + "autostart_project"]}) elif path == "/api/settings": self._post_settings() else: diff --git a/hms_app/ui.py b/hms_app/ui.py index 643b7b7..5ea82e7 100644 --- a/hms_app/ui.py +++ b/hms_app/ui.py @@ -70,6 +70,7 @@ footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);bo + @@ -112,6 +113,15 @@ footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);bo
+
+
+ + +
+

Speichert alle Layer inkl. Effekte, Position, Alpha und Master. Autostart lädt das Projekt beim Programmstart automatisch.

+
ProjektLayerGespeichertAktionen
+
+

Web-UI

@@ -159,7 +169,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();} +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();} // Live-Steuerung $('master').oninput=e=>{api('master',{intensity:e.target.value/100});$('mv').textContent=e.target.value+'%'}; @@ -257,6 +267,16 @@ async function saveSettings(){const body={ if(d.saved){$('setmsg').textContent='Gespeichert ✓';setTimeout(()=>$('setmsg').textContent='',2500); if(d.hint)toast(d.hint)}} +async function loadProjects(){try{const d=await(await fetch('/api/projects')).json(); + const tb=document.querySelector('#projtable tbody'); + tb.innerHTML=(d.items||[]).map(p=>`${p.name}${d.autostart===p.name?' Autostart':''}${p.layers}${p.saved_at||''} + `).join('')||'Noch keine Projekte gespeichert.'}catch(e){}} +async function saveProject(){const name=$('projname').value.trim();if(!name){toast('Bitte Projektnamen eingeben',true);return} + const d=await api('projects/save',{name});if(d.saved){toast('Projekt gespeichert: '+d.saved);$('projname').value='';loadProjects()}} +async function loadProject(name){const d=await api('projects/load',{name});if(d.loaded){toast('Projekt geladen: '+name);showTab('live')}else toast(d.error||'Laden fehlgeschlagen',true)} +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()}} + 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=>{ diff --git a/run.py b/run.py index b3e3cf1..ced973d 100644 --- a/run.py +++ b/run.py @@ -88,6 +88,20 @@ def main() -> int: print(f"[Start] Import fehlgeschlagen: {f}") engine.rebuild() + + # Autostart-Projekt laden (falls konfiguriert und vorhanden) + auto = settings["engine"].get("autostart_project") + if auto: + from hms_app.projects import ProjectStore + doc = ProjectStore(ROOT).load(str(auto)) + if doc is not None: + if engine.load_state(doc.get("state") or {}): + print(f"[Start] Autostart-Projekt geladen: {auto}") + else: + print(f"[Start] Autostart-Projekt fehlgeschlagen: {auto}") + else: + print(f"[Start] Autostart-Projekt nicht gefunden: {auto}") + artnet.start() if not server.start(): return 1 diff --git a/tests/e2e_projects_test.py b/tests/e2e_projects_test.py new file mode 100644 index 0000000..d3cbb2f --- /dev/null +++ b/tests/e2e_projects_test.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""E2E: Projekt speichern/laden/loeschen/autostart. + +Startet ECHTEN Server als Subprozess, macht einen NEUSTART und beweist, +dass das Autostart-Projekt beim Programmstart geladen wird. +""" + +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" +PORT = 8094 +BASE = "http://localhost:%d" % PORT +LOG = "/tmp/hms_proj.log" + +server_proc = None + + +def start_server() -> bool: + global server_proc + server_proc = subprocess.Popen( + ["/usr/bin/python3", ROOT + "/run.py", "--port", str(PORT)], + cwd=ROOT, stdout=open(LOG, "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: + time.sleep(0.3) + return False + + +def stop_server(): + global server_proc + if server_proc is not None: + server_proc.terminate() + try: + server_proc.wait(timeout=6) + except subprocess.TimeoutExpired: + server_proc.kill() + server_proc = None + time.sleep(0.5) + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=15) as r: + return json.loads(r.read().decode()) + + +def post(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=120) 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 Runtime-Daten ---------- +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: + # ---------- Serverstart 1 ---------- + check("Server startet", start_server()) + + # Testvideo 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_proj.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_proj.mp4", "rb").read() + r = post("/api/media/upload", raw=data, + headers={"X-Filename": urllib.parse.quote("e2e_clip.mp4")}) + check("Upload", bool(r.get("saved")), r) + + # Showzustand aufbauen: Layer + FX + Position + Master + r = post("/api/layers/add", {"name": "e2e_clip.mp4"}) + check("Layer hinzugefuegt", "layer" in r, r) + r = post("/api/layers/update", {"id": 1, "alpha": 0.7, "x": 50, + "y": 20, "fx1": "blur", + "fx1_intensity": 0.8}) + check("FX/Alpha/Pos gesetzt", r.get("layer", {}).get("fx1") == "blur", + r) + post("/api/master", {"intensity": 0.9}) + + # Projekt speichern + r = post("/api/projects/save", {"name": "Show A"}) + check("Projekt gespeichert", r.get("saved") == "Show A", r) + + # Pfad-Traversal im Projektnamen wird sanitizet + r = post("/api/projects/save", {"name": "../../evil"}) + check("Projektname sanitizet", r.get("saved") == "evil", r) + + pr = get("/api/projects") + names = [p["name"] for p in pr["items"]] + check("Liste enthaelt beide", {"Show A", "evil"} <= set(names), names) + showa = next(p for p in pr["items"] if p["name"] == "Show A") + check("Layerzahl in Liste", showa["layers"] == 1, showa) + + # Zustand zerstoeren + post("/api/layers/remove", {"id": 1}) + post("/api/master", {"intensity": 0.1}) + st = get("/api/status") + check("Zustand geleert", len(st["layers"]) == 0 + and st["master"] < 0.2, "master=%s" % st["master"]) + + # Projekt laden -> vollstaendig wiederhergestellt + r = post("/api/projects/load", {"name": "Show A"}) + check("Projekt geladen", r.get("loaded") is True, r) + st = get("/api/status") + check("Layer wiederhergestellt", len(st["layers"]) == 1, + "%d Layer" % len(st["layers"])) + l1 = st["layers"][0] + check("Alpha wiederhergestellt", abs(l1["alpha"] - 0.7) < 0.02, + l1["alpha"]) + check("FX1 wiederhergestellt", l1["fx1"] == "blur" + and abs(l1["fx1_intensity"] - 0.8) < 0.03, + "%s %s" % (l1["fx1"], l1["fx1_intensity"])) + check("Position wiederhergestellt", l1["x"] == 50 and l1["y"] == 20, + "x=%s y=%s" % (l1["x"], l1["y"])) + check("Master wiederhergestellt", abs(st["master"] - 0.9) < 0.02, + st["master"]) + check("Engine rendert nach Load", st["running"] is True, + "err=%s" % st["error"]) + + # Autostart setzen + r = post("/api/projects/autostart", {"name": "Show A"}) + check("Autostart gesetzt", r.get("autostart") == "Show A", r) + + # ---------- ECHTER SERVER-NEUSTART: Autostart-Beweis ---------- + stop_server() + check("Server neu gestartet", start_server()) + st = get("/api/status") + check("Autostart: Layer geladen", len(st["layers"]) == 1 + and st["layers"][0]["fx1"] == "blur", + "%d Layer fx1=%s" % (len(st["layers"]), + st["layers"][0]["fx1"] if st["layers"] + else "-")) + time.sleep(3) + st = get("/api/status") + check("Autostart: Engine rendert", st["frames_rendered"] > 0, + "frames=%d" % st["frames_rendered"]) + cfg = json.load(open(os.path.join(ROOT, "config.json"))) + check("config.json Autostart", + cfg["engine"]["autostart_project"] == "Show A") + + # Nicht vorhandenes Projekt -> 404 + r = post("/api/projects/load", {"name": "gibts_nicht"}) + check("404 bei fehlendem Projekt", "error" in r, r) + + # Loeschen des Autostart-Projekts raeumt Autostart weg + r = post("/api/projects/delete", {"name": "Show A"}) + check("Projekt geloescht", r.get("deleted") is True, r) + pr = get("/api/projects") + check("Autostart automatisch geleert", pr.get("autostart") is None, pr) + cfg = json.load(open(os.path.join(ROOT, "config.json"))) + check("config.json Autostart geleert", + cfg["engine"]["autostart_project"] is None) + + print("") + print("ALLE PROJEKT-E2E-TESTS BESTANDEN") +finally: + stop_server()