70502458d8
- 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
119 lines
3.6 KiB
Python
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
|