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:
@@ -34,3 +34,6 @@ Thumbs.db
|
||||
media/
|
||||
thumbs/
|
||||
config.json
|
||||
|
||||
# Projekte (Nutzerdaten)
|
||||
projects/
|
||||
|
||||
+5
-1
@@ -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")
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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=>{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user