PROJEKTE: Speichern/Laden/Autostart (25/25 E2E inkl. Server-Neustart)

- hms_app/projects.py: ProjectStore mit atomarem Speichern
  (.hms.json in projects/), Format-Kennung, Namens-Sanitize
  (Pfad-Traversal-Schutz, Leerzeichen erlaubt), Liste/Loeschen
- engine.py: export_state() (Layer mit Alpha/Pos/Groesse/Z/FX +
  Intensitaeten + Master) und load_state() (ersetzt alle Layer,
  fehlende Medien werden uebersprungen und gemeldet, Werte geclamped,
  ungueltige FX-IDs verworfen)
- server.py: /api/projects (GET Liste + Autostart),
  POST projects/save|load|delete|autostart; Loeschen des
  Autostart-Projekts raeumt config.json automatisch auf
- config.py: engine.autostart_project (validiert, optional)
- run.py: laedt Autostart-Projekt beim Programmstart
- ui.py: neuer Reiter 'Projekte': Name eingeben + Speichern,
  Tabelle mit Laden/Autostart/Loeschen, Autostart-Badge

E2E-BEWEIS (25/25 PASS, mit ECHTEM Server-Neustart):
1 Server startet · 2 Upload · 3 Layer · 4 FX/Alpha/Pos gesetzt ·
5 Projekt 'Show A' gespeichert · 6 Traversal-Name sanitizet zu 'evil' ·
7 Liste · 8 Layerzahl · 9 Zustand geleert (master=0.1) ·
10 Projekt geladen · 11 Layer wiederhergestellt ·
12 Alpha 0.7 wiederhergestellt · 13 FX1 blur 0.8 wiederhergestellt ·
14 Position x=50 y=20 wiederhergestellt · 15 Master 0.9 ·
16 Engine rendert · 17 Autostart gesetzt ·
18 Server NEU GESTARTET · 19 Autostart: Layer+FX automatisch geladen ·
20 Autostart: Engine rendert (60 Frames) · 21 config.json Autostart ·
22 fehlendes Projekt 404 · 23 geloescht ·
24 Autostart automatisch geleert · 25 config.json geleert

Regressionen: App-E2E 14/14 (app=0) + FX-E2E 16/16 (fx=0) unveraendert gruen
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 12:55:50 +02:00
parent 10b988c33a
commit 70502458d8
8 changed files with 469 additions and 2 deletions
+5 -1
View File
@@ -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")
+63
View File
@@ -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 = []
+118
View File
@@ -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 <root>/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
+48
View File
@@ -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:
+21 -1
View File
@@ -70,6 +70,7 @@ footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);bo
<button data-p="live" class="on">Live</button>
<button data-p="media">Medien</button>
<button data-p="layers">Layer</button>
<button data-p="projects">Projekte</button>
<button data-p="settings">Einstellungen</button>
</div>
</header>
@@ -112,6 +113,15 @@ footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);bo
<div id="ledit"></div>
</div>
<div id="page-projects" class="page">
<div class="row" style="max-width:480px">
<input type="text" id="projname" placeholder="Projektname…" style="flex:1;width:auto">
<button class="acc" onclick="saveProject()">Speichern</button>
</div>
<p style="color:var(--dim);font-size:11px;margin:6px 0 14px">Speichert alle Layer inkl. Effekte, Position, Alpha und Master. Autostart lädt das Projekt beim Programmstart automatisch.</p>
<table id="projtable"><thead><tr><th>Projekt</th><th>Layer</th><th>Gespeichert</th><th>Aktionen</th></tr></thead><tbody></tbody></table>
</div>
<div id="page-settings" class="page">
<div class="setgrp"><h3>Web-UI</h3>
<div class="frow"><label>Port</label><input type="number" id="s-web-port"></div>
@@ -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=>`<tr><td>${p.name}${d.autostart===p.name?' <span class="badge">Autostart</span>':''}</td><td>${p.layers}</td><td>${p.saved_at||''}</td>
<td><button class="acc" onclick="loadProject('${p.name}')">Laden</button> <button onclick="setAutostart('${p.name}')">Autostart</button> <button class="danger" onclick="delProject('${p.name}')">✕</button></td></tr>`).join('')||'<tr><td colspan="4" style="color:var(--dim)">Noch keine Projekte gespeichert.</td></tr>'}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=>{