Files
hms-mediaengine/hms_app/config.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

140 lines
4.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Persistente Konfiguration: config.json, Defaults, Validierung."""
from __future__ import annotations
import copy
import json
from pathlib import Path
DEFAULTS: dict = {
"web": {"bind": "0.0.0.0", "port": 8080},
"preview": {"width": 640, "height": 360, "fps": 15,
"jpeg_quality": 80},
"output": {"fullscreen": False},
"media": {"dir": "media", "thumbs_dir": "thumbs",
"max_upload_mb": 2048},
"artnet": {
"enabled": True,
"listen_port": 6454,
"universes": [0],
"master_channel": 1,
"layer_start_channel": 2,
"playback_channel": 6,
"retrigger_channel": 7,
"blackout_channel": 8,
"fx1_channel": None,
"fx2_channel": None,
"signal_loss_policy": "hold",
"signal_loss_timeout_s": 10,
},
"engine": {"max_layers": 8, "loop_default": True,
"autostart_project": None},
}
def deep_merge(base: dict, override: dict) -> dict:
out = dict(base)
for k, v in override.items():
if isinstance(v, dict) and isinstance(out.get(k), dict):
out[k] = deep_merge(out[k], v)
else:
out[k] = v
return out
def load(root: Path) -> dict:
"""Lädt config.json und füllt fehlende Schlüssel mit Defaults."""
file = root / "config.json"
data: dict = {}
if file.exists():
try:
data = json.loads(file.read_text("utf-8"))
except (OSError, ValueError) as e:
print(f"[Config] FEHLER config.json: {e} nutze Defaults")
if not isinstance(data, dict):
data = {}
return deep_merge(DEFAULTS, data)
def save(root: Path, cfg: dict) -> bool:
file = root / "config.json"
try:
tmp = file.with_suffix(".json.tmp")
tmp.write_text(
json.dumps(cfg, ensure_ascii=False, indent=2), "utf-8")
tmp.replace(file)
return True
except OSError as e:
print(f"[Config] Schreiben fehlgeschlagen: {e}")
return False
def validate(new: dict) -> tuple[dict | None, str | None]:
"""Prüft ein komplettes Settings-Objekt. Rückgabe: (normalized, error)."""
merged = deep_merge(copy.deepcopy(DEFAULTS), new if isinstance(new, dict) else {})
errors: list[str] = []
def _int(d, key, lo, hi, label):
try:
v = int(d[key])
except (KeyError, TypeError, ValueError):
errors.append(label)
return None
if not lo <= v <= hi:
errors.append(label)
return None
d[key] = v
return v
w = merged["web"]
_int(w, "port", 1, 65535, "web.port")
if not isinstance(w.get("bind"), str):
errors.append("web.bind")
p = merged["preview"]
_int(p, "width", 64, 3840, "preview.width")
_int(p, "height", 64, 2160, "preview.height")
_int(p, "fps", 1, 60, "preview.fps")
_int(p, "jpeg_quality", 10, 95, "preview.jpeg_quality")
m = merged["media"]
_int(m, "max_upload_mb", 1, 8192, "media.max_upload_mb")
for k in ("dir", "thumbs_dir"):
if not isinstance(m.get(k), str) or not m[k] or "/" in m[k] \
or m[k] in (".", ".."):
errors.append(f"media.{k}")
a = merged["artnet"]
_int(a, "listen_port", 1, 65535, "artnet.listen_port")
uni = a.get("universes")
if (not isinstance(uni, list) or not uni
or len(set(uni)) != len(uni)
or any(not isinstance(u, int) or not 0 <= u <= 32767
for u in uni)):
errors.append("artnet.universes")
for k in ("master_channel", "layer_start_channel", "playback_channel",
"retrigger_channel", "blackout_channel", "fx1_channel",
"fx2_channel"):
v = a.get(k)
if v is not None and (not isinstance(v, int)
or not 1 <= v <= 512):
errors.append(f"artnet.{k}")
_int(a, "signal_loss_timeout_s", 1, 3600,
"artnet.signal_loss_timeout_s")
if a.get("signal_loss_policy") not in ("hold", "fade_black"):
errors.append("artnet.signal_loss_policy")
e = merged["engine"]
_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")
if errors:
return None, "ungültig: " + ", ".join(errors)
return merged, None