Files
hms-mediaengine/hms_app/projects.py
T
HMS MediaEngine Agent 70502458d8 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
2026-09-11 12:55:50 +02:00

119 lines
3.6 KiB
Python

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