GENERATOREN + MEDIEN-VERTEILUNG + AUTOSTART-SETTINGS (33/33 E2E)

1) MADRIX-artige Generator-Layer (15 prozedurale Quellen):
- engine.GENERATORS: ball (animiert), gradient, pinwheel (animiert),
  zone-plate (animiert), checkers, snow (Rauschen animiert), circular,
  spokes, smpte, gamut, red/green/blue/black/white - alle per
  gst-launch verifiziert, kombinierbar mit FX1/FX2 (Generator+FX
  gerendert, 28 Frames im Test)
- add_generator + Pattern-Wechsel zur Laufzeit (ball->snow) mit
  Rebuild, Katalog-Endpoint /api/generators, UI-Dropdown im Layer-Tab
- Generatoren in Projekten speicher-/ladbar (gradient nach Load aktiv)
- Ungueltige Generator-IDs werden mit 400 abgewiesen

2) Medien-Verteilung ueber mehrere Server:
- hms_app/distribution.py: Server-Check (/api/health),
  Remote-Medien-Liste, Streaming-Import mit Upload-Sicherung
- config.servers: Remote-Server registrieren (Name+URL, HTTP-only,
  Duplikat-Schutz), /api/servers (online/offline + Medien),
  servers/import: Datei von Remote ins lokale Archiv ziehen
- UI: Remote-Server in Einstellungen verwalten, Remote-Medien im
  Medien-Tab mit 'Ins Archiv ziehen'-Button
- run.py --data-root: mehrere Instanzen auf einer Maschine mit
  getrennten config/media/projects-Ordnern
- E2E mit ZWEI echten Servern: A registriert B, sieht B-Medien,
  zieht clip byte-identisch (SHA verifiziert) und spielt ihn als
  Layer; Offline-Erkennung nach B-Stop; 404/400-Faelle

3) Autostart in den Einstellungen:
- Einstellungen: Autostart-Projekt-Dropdown (aus Projektliste),
  abwaehlbar; Projekte-Tab weiterhin mit Autostart-Button
- FIX Settings-Save-Merge: POST /api/settings merged jetzt auf die
  AKTUELLEN Settings - Autostart und registrierte Server gehen beim
  Speichern anderer Einstellungen nicht mehr verloren (E2E: Autostart
  ueberlebt Settings-Save, FPS uebernommen)

E2E: 33/33 PASS (Verteilung/Generatoren) + Regressionen:
Projekte 25/25, App 14/14, FX 16/16 - alle gruen
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 13:11:54 +02:00
parent 70502458d8
commit 7e191ff6c9
7 changed files with 550 additions and 21 deletions
+14
View File
@@ -29,6 +29,7 @@ DEFAULTS: dict = {
},
"engine": {"max_layers": 8, "loop_default": True,
"autostart_project": None},
"servers": [],
}
@@ -131,6 +132,19 @@ def validate(new: dict) -> tuple[dict | None, str | None]:
ap = e.get("autostart_project")
if ap is not None and not isinstance(ap, str):
errors.append("engine.autostart_project")
sv = merged.get("servers")
if not isinstance(sv, list):
errors.append("servers")
else:
for item in sv:
if (not isinstance(item, dict)
or not isinstance(item.get("name"), str)
or not item.get("name", "").strip()
or not isinstance(item.get("url"), str)
or not item.get("url", "").startswith(
("http://", "https://"))):
errors.append("servers")
break
if not isinstance(merged["output"].get("fullscreen"), bool):
errors.append("output.fullscreen")
+49
View File
@@ -0,0 +1,49 @@
"""Medien-Verteilung: Remote-Server registrieren, Medien abrufen."""
from __future__ import annotations
import json
import urllib.error
import urllib.parse
import urllib.request
TIMEOUT_SHORT = 5
TIMEOUT_IMPORT = 180
def check(url: str) -> bool:
"""Prueft ob ein Remote-Server ein HMS MediaEngine ist."""
try:
with urllib.request.urlopen(
url.rstrip("/") + "/api/health",
timeout=TIMEOUT_SHORT) as r:
return json.loads(r.read().decode()).get("status") == "ok"
except Exception: # noqa: BLE001
return False
def list_media(url: str) -> dict:
"""Medienbibliothek eines Remote-Servers abrufen."""
with urllib.request.urlopen(
url.rstrip("/") + "/api/media",
timeout=TIMEOUT_SHORT) as r:
return json.loads(r.read().decode())
def import_media(url: str, media_name: str, library) -> tuple[bool, object]:
"""Zieht eine Datei per HTTP von einem Remote-Server in die lokale
Bibliothek (Streaming, gleiche Upload-Pfad-Sicherung wie Web-Upload)."""
q = urllib.parse.quote(media_name)
try:
with urllib.request.urlopen(
url.rstrip("/") + "/media/" + q,
timeout=TIMEOUT_IMPORT) as r:
length = int(r.headers.get("Content-Length", "0"))
saved, err = library.save_stream(media_name, r, length)
if err:
return False, err
return True, saved
except urllib.error.HTTPError as e:
return False, f"Remote-Fehler HTTP {e.code}"
except (urllib.error.URLError, OSError, ValueError) as e:
return False, f"Verbindung fehlgeschlagen: {e}"
+75 -3
View File
@@ -80,6 +80,26 @@ FX_CATALOG: dict[str, dict] = {
"int": True},
}
# Generator-Katalog (prozedurale Quellen, verifiziert per gst-launch):
# MADRIX-artige Inhalte: Generator-Layer + Effekte kombinierbar.
GENERATORS: dict[str, str] = {
"ball": "Springender Ball (animiert)",
"gradient": "Farbverlauf",
"pinwheel": "Pinwheel (animiert)",
"zone-plate": "Zonenplatte (animiert)",
"checkers-2": "Schachbrett",
"snow": "Rauschen (animiert)",
"circular": "Kreismuster",
"spokes": "Speichen",
"smpte": "SMPTE-Farbbalken",
"gamut": "Farbraum-Gamut",
"red": "Vollrot",
"green": "Vollgrün",
"blue": "Vollblau",
"black": "Schwarz",
"white": "Weiß",
}
class Layer:
"""Ein aktiver Layer im Compositor."""
@@ -153,6 +173,12 @@ class MediaEngine:
elem += f" {spec['prop']}={val}"
fx_chain += f" ! videoconvert ! {elem}"
if layer.kind == "generator":
caps = (f"video/x-raw,width={layer.width},"
f"height={layer.height},framerate=15/1")
return (f"videotestsrc is-live=true pattern={layer.name} ! "
f"{caps}{fx_chain} ! videoconvert ! mix.sink_{layer.id}")
if layer.kind == "video":
caps = (f"video/x-raw,width={layer.width},"
f"height={layer.height},format=BGRA")
@@ -298,6 +324,33 @@ class MediaEngine:
return None
return layer.to_dict()
def add_generator(self, gen_id: str) -> dict | None:
"""Fuegt einen Generator-Layer hinzu (prozedurale Quelle)."""
with self._lock:
if gen_id not in GENERATORS:
self.last_error = f"unbekannter Generator: {gen_id}"
return None
max_l = int(self.settings["engine"].get("max_layers", 8))
if len(self.layers) >= max_l:
self.last_error = f"max. {max_l} Layer"
return None
layer = Layer(self._next_id, gen_id, "generator")
self._next_id += 1
pv = self.settings["preview"]
layer.width = pv["width"]
layer.height = pv["height"]
layer.z = len(self.layers)
self.layers.append(layer)
if not self.rebuild():
self.layers.remove(layer)
return None
return layer.to_dict()
def generators(self) -> list[dict]:
"""Generator-Katalog fuer die UI."""
return [{"id": g, "label": lbl}
for g, lbl in GENERATORS.items()]
def remove_layer(self, layer_id: int) -> bool:
with self._lock:
kept = [l for l in self.layers if l.id != layer_id]
@@ -358,11 +411,22 @@ class MediaEngine:
self.rebuild() # Struktur geaendert -> Pipeline neu
return self.get_layer(layer.id)
if "name" in patch and patch["name"] != layer.name:
f = self.library.file_path(str(patch["name"]))
new_name = str(patch["name"])
if layer.kind == "generator":
if new_name not in GENERATORS:
return None
layer.name = new_name
else:
f = self.library.file_path(new_name)
if not f.exists():
return None
layer.name = str(patch["name"])
layer.kind = self.library.kind_of(layer.name) or layer.kind
layer.name = new_name
layer.kind = (self.library.kind_of(new_name)
or layer.kind)
# Quell-Wechsel braucht einen Pipeline-Rebuild
if not self.rebuild():
return None
return self.get_layer(layer.id)
# Pad-Eigenschaften live setzen (ohne Rebuild)
self._apply_pad_props(layer)
return layer
@@ -539,6 +603,14 @@ class MediaEngine:
if not isinstance(raw, dict):
continue
name = str(raw.get("name", ""))
kind_raw = str(raw.get("kind", ""))
if kind_raw == "generator":
if name not in GENERATORS:
print(f"[Projects] WARNUNG: unbekannter Generator, "
f"Layer uebersprungen: {name}")
continue
layer = Layer(self._next_id, name, "generator")
else:
f = self.library.file_path(name)
kind = self.library.kind_of(name)
if not f.exists() or kind is None:
+85 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import copy
import json
import threading
import time
@@ -9,6 +10,7 @@ import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from . import config as cfgmod
from . import distribution
from .projects import ProjectStore
from .ui import HTML_UI
@@ -102,6 +104,23 @@ class EngineServer:
self._json(artnet.monitor())
elif path == "/api/fx":
self._json(engine.fx_catalog())
elif path == "/api/generators":
self._json({"items": engine.generators()})
elif path == "/api/servers":
out = []
for s_ in settings.get("servers", []):
entry = {"name": s_.get("name"),
"url": s_.get("url"),
"online": False, "items": []}
try:
data = distribution.list_media(
str(s_.get("url", "")))
entry["online"] = True
entry["items"] = data.get("items", [])
except Exception: # noqa: BLE001
pass
out.append(entry)
self._json({"servers": out})
elif path == "/api/projects":
self._json({"items": server.projects.list(),
"autostart": settings["engine"].get(
@@ -134,6 +153,67 @@ class EngineServer:
self._send_error_json(400, engine.last_error or "?" )
else:
self._json({"layer": layer})
elif path == "/api/layers/add-generator":
body = self._read_json()
layer = engine.add_generator(
str(body.get("generator", "")))
if layer is None:
self._send_error_json(
400, engine.last_error or "unbekannter Generator")
else:
self._json({"layer": layer})
elif path == "/api/servers/add":
body = self._read_json()
name = str(body.get("name", "")).strip()
url = str(body.get("url", "")).strip()
if (not name or not url.startswith(
("http://", "https://"))):
self._send_error_json(
400, "Name und http(s)-URL erforderlich")
elif any(s_.get("name") == name
for s_ in settings.get("servers", [])):
self._send_error_json(400, "Name bereits vergeben")
elif not distribution.check(url):
self._json({"added": False,
"error": "Server nicht erreichbar "
"oder kein HMS MediaEngine"})
else:
settings.setdefault("servers", []).append(
{"name": name, "url": url})
cfgmod.save(root, settings)
self._json({"added": True, "name": name})
elif path == "/api/servers/remove":
body = self._read_json()
name = str(body.get("name", ""))
before = len(settings.get("servers", []))
settings["servers"] = [
s_ for s_ in settings.get("servers", [])
if s_.get("name") != name]
if len(settings["servers"]) == before:
self._send_error_json(404, "Server nicht gefunden")
else:
cfgmod.save(root, settings)
self._json({"removed": True})
elif path == "/api/servers/import":
body = self._read_json()
srv_name = str(body.get("server", ""))
media_name = str(body.get("media", ""))
url = None
for s_ in settings.get("servers", []):
if s_.get("name") == srv_name:
url = str(s_.get("url", ""))
break
if url is None:
self._send_error_json(404, "Server nicht gefunden")
elif not media_name:
self._send_error_json(400, "Medienname fehlt")
else:
ok, result = distribution.import_media(
url, media_name, library)
if ok:
self._json({"saved": result})
else:
self._send_error_json(400, str(result))
elif path == "/api/layers/remove":
body = self._read_json()
ok = engine.remove_layer(int(body.get("id", -1)))
@@ -226,7 +306,11 @@ class EngineServer:
def _post_settings(self):
body = self._read_json()
normalized, err = cfgmod.validate(body)
# WICHTIG: auf aktuelle Settings mergen, damit nicht
# gesendete Felder (autostart_project, servers) erhalten
# bleiben und nicht auf Defaults zurueckfallen.
merged = cfgmod.deep_merge(copy.deepcopy(settings), body)
normalized, err = cfgmod.validate(merged)
if err is not None:
self._send_error_json(400, err)
return
+44 -4
View File
@@ -105,11 +105,17 @@ footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);bo
<input type="file" id="file" multiple accept=".mp4,.m4v,.avi,.mkv,.mov,.webm,.jpg,.jpeg,.png,.bmp" style="display:none">
<div id="upbar" style="display:none;margin-bottom:10px"><div id="uptext" style="font-size:12px;color:var(--dim)"></div><progress id="uprog" style="width:100%"></progress></div>
<div class="grid" id="mgrid"></div>
<h2 style="margin-top:20px">Remote-Server</h2>
<p style="color:var(--dim);font-size:11px">Medien von anderen HMS-Servern ins lokale Archiv ziehen:</p>
<div id="rgrid"></div>
</div>
<div id="page-layers" class="page">
<div class="row"><button class="acc" onclick="location.hash='#media';showTab('media')">+ Medien hinzufügen</button>
<span style="color:var(--dim);font-size:11px;align-self:center">Aktive Layer bearbeiten:</span></div>
<div class="row">
<button class="acc" onclick="showTab('media')">+ Medien hinzufügen</button>
<select id="gensel" style="width:200px"><option value="">+ Generator…</option></select>
<button onclick="addGen()">Hinzufügen</button>
</div>
<div id="ledit"></div>
</div>
@@ -147,8 +153,13 @@ footer{background:var(--pan);padding:5px 16px;font-size:11px;color:var(--dim);bo
<div class="setgrp"><h3>Engine</h3>
<div class="frow"><label>Max. Layer</label><input type="number" id="s-engine-max"></div>
<div class="frow"><label>Loop als Standard</label><input type="checkbox" id="s-engine-loop"></div>
<div class="frow"><label>Autostart-Projekt</label><select id="s-autostart"><option value=""> keins </option></select><span class="hint">lädt beim Programmstart</span></div>
<div class="frow"><label>Fullscreen-Ausgabe</label><input type="checkbox" id="s-output-fs"><span class="hint">Windows: D3D11 · Linux: autovideosink</span></div>
</div>
<div class="setgrp"><h3>Remote-Server (Medien-Verteilung)</h3>
<div class="frow"><label>Neuer Server</label><input type="text" id="srv-name" placeholder="Name" style="width:120px"><input type="text" id="srv-url" placeholder="http://192.168.1.50:8080" style="width:240px"><button onclick="addServer()">+ Hinzufügen</button></div>
<div id="srvlist"></div>
</div>
<div class="setgrp"><h3>Medien</h3>
<div class="frow"><label>Ordnername</label><input type="text" id="s-media-dir" style="width:140px"></div>
<div class="frow"><label>Max. Upload</label><input type="number" id="s-media-max"> MB</div>
@@ -169,7 +180,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();if(p==='projects')loadProjects();}
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();loadServers()}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+'%'};
@@ -242,6 +253,8 @@ function upOne(f,done){
// Settings
async function loadSettings(){try{const s=await(await fetch('/api/settings')).json();
try{const pr=await(await fetch('/api/projects')).json();const sel=$('s-autostart');sel.innerHTML='<option value=""> keins </option>'+(pr.items||[]).map(p=>`<option value="${p.name}"${pr.autostart===p.name?' selected':''}>${p.name}</option>`).join('')}catch(e){}
loadServers();
$('s-web-port').value=s.web.port;$('s-web-bind').value=s.web.bind;
$('s-preview-width').value=s.preview.width;$('s-preview-height').value=s.preview.height;$('s-preview-fps').value=s.preview.fps;$('s-preview-q').value=s.preview.jpeg_quality;
$('s-artnet-enabled').checked=s.artnet.enabled;$('s-artnet-port').value=s.artnet.listen_port;
@@ -260,7 +273,7 @@ async function saveSettings(){const body={
master_channel:+$('s-artnet-master').value,layer_start_channel:+$('s-artnet-layer').value,
playback_channel:+$('s-artnet-playback').value,retrigger_channel:+$('s-artnet-retrigger').value,blackout_channel:+$('s-artnet-blackout').value,
signal_loss_policy:$('s-artnet-policy').value,signal_loss_timeout_s:+$('s-artnet-timeout').value},
engine:{max_layers:+$('s-engine-max').value,loop_default:$('s-engine-loop').checked},
engine:{max_layers:+$('s-engine-max').value,loop_default:$('s-engine-loop').checked,autostart_project:$('s-autostart').value||null},
output:{fullscreen:$('s-output-fs').checked},
media:{dir:$('s-media-dir').value,max_upload_mb:+$('s-media-max').value}};
const d=await api('settings',body);
@@ -277,6 +290,31 @@ async function loadProject(name){const d=await api('projects/load',{name});if(d.
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()}}
// ---------- Generatoren ----------
let GENS=[];
async function loadGens(){try{const d=await(await fetch('/api/generators')).json();GENS=d.items||[];$('gensel').innerHTML='<option value="">+ Generator…</option>'+GENS.map(g=>`<option value="${g.id}">${g.label}</option>`).join('')}catch(e){}}
async function addGen(){const g=$('gensel').value;if(!g){toast('Generator wählen',true);return}
const d=await api('layers/add-generator',{generator:g});if(d.layer){toast('Generator-Layer: '+g);$('gensel').value=''}else if(d.error)toast(d.error,true)}
// ---------- Remote-Server ----------
async function addServer(){const name=$('srv-name').value.trim(),url=$('srv-url').value.trim();
if(!name||!url){toast('Name und URL eingeben',true);return}
const d=await api('servers/add',{name,url});if(d.added){toast('Server hinzugefügt: '+name);$('srv-name').value='';$('srv-url').value='';loadServers()}
else toast(d.error||'Server nicht erreichbar oder kein HMS MediaEngine',true)}
async function delServer(name){if(!confirm(name+' entfernen?'))return;await api('servers/remove',{name});loadServers()}
async function importRemote(srv,media){toast('Importiere '+media+' von '+srv+'');
const d=await api('servers/import',{server:srv,media});if(d.saved){toast('Importiert: '+d.saved);loadMedia()}else toast(d.error||'Import fehlgeschlagen',true)}
async function loadServers(){try{const d=await(await fetch('/api/servers')).json();const servers=d.servers||[];
// Einstellungen: Server-Liste
const sl=$('srvlist');if(sl)sl.innerHTML=servers.map(S=>`<div class="frow"><label>${S.name}<br><small style="color:var(--dim)">${S.url}</small></label><span class="badge${S.online?'':' err'}">${S.online?'online':'offline'}</span><button class="danger" onclick="delServer('${S.name}')">✕</button></div>`).join('')||'<p style="color:var(--dim);font-size:11px">Noch keine Server registriert.</p>';
// Medien-Tab: Remote-Medien
const rg=$('rgrid');if(rg)rg.innerHTML=servers.map(S=>
`<div class="setgrp"><h3>${S.name} <span class="badge${S.online?'':' err'}">${S.online?'online':'offline'}</span></h3>`+
(S.online?(S.items||[]).map(m=>`<div class="frow"><label>${m.name} <small style="color:var(--dim)">${fmtSize(m.size)}</small></label><button class="acc" onclick="importRemote('${S.name}','${m.name.replace(/'/g,"\\'")}')">Ins Archiv ziehen</button></div>`).join('')||'<p style="color:var(--dim);font-size:11px">Keine Medien auf dem Server.</p>'):'')+'</div>'
).join('')||'<p style="color:var(--dim);font-size:11px">Noch keine Server. In den Einstellungen registrieren.</p>';
}catch(e){}}
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=>{
@@ -288,6 +326,8 @@ function fillFxSelects(){document.querySelectorAll('select[id^="fx1-"],select[id
const _origRender=renderLayerEdit;
renderLayerEdit=function(ls){_origRender(ls);document.querySelectorAll('select[id^="fx1-"]').forEach(s=>s.dataset.lay=s.id.slice(4));document.querySelectorAll('select[id^="fx2-"]').forEach(s=>s.dataset.lay=s.id.slice(4));fillFxSelects()};
loadFx();
loadGens();
loadServers();
poll();
</script>
</body>
+13 -5
View File
@@ -48,6 +48,9 @@ def main() -> int:
help="setup_windows.ps1 / setup_linux.sh erzeugen")
ap.add_argument("--port", type=int, default=None,
help="Web-Port ueberschreibt config.json")
ap.add_argument("--data-root", type=str, default=None,
help="Eigener Datenordner fuer mehrere Instanzen "
"(config/media/projekte dort statt neben run.py)")
args = ap.parse_args()
if args.generate_setup:
@@ -60,7 +63,12 @@ def main() -> int:
ok = setupmod.check_and_install_dependencies()
return 0 if ok else 1
settings = cfgmod.load(ROOT)
DATA_ROOT = (Path(args.data_root).expanduser().resolve()
if args.data_root else ROOT)
if DATA_ROOT != ROOT:
DATA_ROOT.mkdir(parents=True, exist_ok=True)
settings = cfgmod.load(DATA_ROOT)
if args.port is not None:
settings["web"]["port"] = args.port
@@ -69,13 +77,13 @@ def main() -> int:
cfg = setupmod.run_setup(settings)
settings["web"]["port"] = cfg["port"]
settings["output"]["fullscreen"] = cfg["fullscreen"]
cfgmod.save(ROOT, settings)
cfgmod.save(DATA_ROOT, settings)
library = MediaLibrary(settings, ROOT)
library = MediaLibrary(settings, DATA_ROOT)
library.ensure_dirs()
engine = MediaEngine(settings, library)
artnet = ArtNetInput(engine, settings)
server = EngineServer(engine, artnet, library, settings, ROOT)
server = EngineServer(engine, artnet, library, settings, DATA_ROOT)
# CLI-Dateien importieren und als Layer aktivieren
for f in args.files:
@@ -93,7 +101,7 @@ def main() -> int:
auto = settings["engine"].get("autostart_project")
if auto:
from hms_app.projects import ProjectStore
doc = ProjectStore(ROOT).load(str(auto))
doc = ProjectStore(DATA_ROOT).load(str(auto))
if doc is not None:
if engine.load_state(doc.get("state") or {}):
print(f"[Start] Autostart-Projekt geladen: {auto}")
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""E2E: Generatoren, Remote-Server-Medienverteilung, Autostart-Merge.
Startet ZWEI echte Server: A (lokal) und B (Quelle). Server A
registriert B und zieht Medien von B ins lokale Archiv.
"""
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"
DATA_B = "/tmp/hms_server_b_data"
PORT_A = 8095
PORT_B = 8096
BASE_A = "http://localhost:%d" % PORT_A
BASE_B = "http://localhost:%d" % PORT_B
procs = {}
def start_server(tag: str, base: str, port: int,
data_root: str | None = None) -> bool:
cmd = ["/usr/bin/python3", ROOT + "/run.py",
"--port", str(port)]
if data_root:
cmd += ["--data-root", data_root]
procs[tag] = subprocess.Popen(
cmd,
cwd=ROOT, stdout=open("/tmp/hms_e2e_%s.log" % tag, "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: # noqa: BLE001
time.sleep(0.3)
return False
def stop_all():
for p in procs.values():
p.terminate()
for p in procs.values():
try:
p.wait(timeout=6)
except subprocess.TimeoutExpired:
p.kill()
procs.clear()
time.sleep(0.5)
def get(base, path):
with urllib.request.urlopen(base + path, timeout=15) as r:
return json.loads(r.read().decode())
def post(base, 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=180) 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 ----------
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:
# ---------- Server B (Quelle) ----------
shutil.rmtree(DATA_B, ignore_errors=True)
check("Server B startet (eigener Datenordner)",
start_server("B", BASE_B, PORT_B, data_root=DATA_B))
# Testvideo auf B 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_dist.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_dist.mp4", "rb").read()
r = post(BASE_B, "/api/media/upload", raw=data,
headers={"X-Filename": urllib.parse.quote("clip_vom_server_B.mp4")})
check("B: Video hochgeladen", bool(r.get("saved")), r)
# ---------- Server A (Hauptserver) ----------
check("Server A startet", start_server("A", BASE_A, PORT_A))
# 1) Generatoren: Katalog + Layer + FX
gens = get(BASE_A, "/api/generators")["items"]
check("Generator-Katalog 15", len(gens) == 15, "%d" % len(gens))
gen_ids = [g["id"] for g in gens]
check("Ball + Snow vorhanden", "ball" in gen_ids and "snow" in gen_ids)
r = post(BASE_A, "/api/layers/add-generator", {"generator": "ball"})
check("Generator-Layer ball", r.get("layer", {}).get("kind")
== "generator", r)
gid = r.get("layer", {}).get("id")
r = post(BASE_A, "/api/layers/update",
{"id": gid, "fx1": "blur", "fx1_intensity": 0.6})
check("Generator + FX kombiniert", r.get("layer", {}).get("fx1")
== "blur", r)
time.sleep(2)
st = get(BASE_A, "/api/status")
check("Engine rendert Generator+FX", st["frames_rendered"] > 0
and st["error"] is None,
"frames=%d err=%s" % (st["frames_rendered"], st["error"]))
# Generator wechseln (Pattern-Tausch ohne neuen Layer)
r = post(BASE_A, "/api/layers/update", {"id": gid, "name": "snow"})
check("Generator-Wechsel ball->snow", r.get("layer", {}).get("name")
== "snow", r)
time.sleep(1.5)
st = get(BASE_A, "/api/status")
check("Engine nach Generator-Wechsel", st["running"] is True,
"err=%s" % st["error"])
# Ungueltiger Generator abgewiesen
r = post(BASE_A, "/api/layers/add-generator", {"generator": "keins"})
check("Ungueltiger Generator 400", "error" in r, r)
# 2) Remote-Server-Verteilung: A registriert B
r = post(BASE_A, "/api/servers/add",
{"name": "Node-B", "url": BASE_B})
check("Server B registriert", r.get("added") is True, r)
# Duplikat-Name abgewiesen
r = post(BASE_A, "/api/servers/add",
{"name": "Node-B", "url": BASE_B})
check("Duplikat-Name abgewiesen", "error" in r, r)
# Ungueltige URL abgewiesen
r = post(BASE_A, "/api/servers/add",
{"name": "X", "url": "ftp://kein-http"})
check("Nicht-HTTP abgewiesen", "error" in r, r)
# Server-Liste: B ist online und liefert Medien
srv = get(BASE_A, "/api/servers")["servers"]
check("Server-Liste 1 Eintrag", len(srv) == 1, srv)
check("Node-B online", srv[0]["online"] is True, srv[0]["name"])
check("B-Medien sichtbar",
any(m["name"] == "clip_vom_server_B.mp4"
for m in srv[0]["items"]),
[m["name"] for m in srv[0]["items"]])
# Medium von B nach A ziehen
r = post(BASE_A, "/api/servers/import",
{"server": "Node-B", "media": "clip_vom_server_B.mp4"})
check("Import von B nach A (Originalname, getrennte Ordner)",
r.get("saved") == "clip_vom_server_B.mp4", r)
# Datei-Gleichheit pruefen (SHA von Quelle und Kopie)
import hashlib
def sha(path):
return hashlib.sha256(open(path, "rb").read()).hexdigest()[:16]
src = os.path.join(DATA_B, "media", "clip_vom_server_B.mp4")
dst = os.path.join(ROOT, "media", "clip_vom_server_B.mp4")
check("Importierte Datei byte-identisch", sha(src) == sha(dst),
"sha=%s" % sha(dst))
lib_a = get(BASE_A, "/api/media")["items"]
check("Datei liegt in A-Archiv",
any(m["name"] == "clip_vom_server_B.mp4" for m in lib_a),
[m["name"] for m in lib_a])
# Import in A als Layer nutzbar
r = post(BASE_A, "/api/layers/add",
{"name": "clip_vom_server_B.mp4"})
check("Importiertes als Layer", "layer" in r, r)
time.sleep(1.5)
st = get(BASE_A, "/api/status")
check("Engine rendert mit Import", st["frames_rendered"] > 0
and st["error"] is None, "frames=%d" % st["frames_rendered"])
# Import von unregistriertem Namen -> 404
r = post(BASE_A, "/api/servers/import",
{"server": "gibts_nicht", "media": "x.mp4"})
check("Unbekannter Server 404", "error" in r, r)
# Offline-Server: B stoppen, Liste muss offline zeigen
procs["B"].terminate()
procs["B"].wait(timeout=6)
time.sleep(0.5)
srv = get(BASE_A, "/api/servers")["servers"]
check("B nach Stop offline", srv[0]["online"] is False)
r = post(BASE_A, "/api/servers/remove", {"name": "Node-B"})
check("Server entfernt", r.get("removed") is True, r)
cfg = json.load(open(os.path.join(ROOT, "config.json")))
check("config.json Server entfernt", cfg.get("servers") == [])
# 3) Autostart + Settings-Merge: Autostart darf beim
# Speichern der Einstellungen NICHT verloren gehen
post(BASE_A, "/api/projects/save", {"name": "Autostart Test"})
r = post(BASE_A, "/api/projects/autostart", {"name": "Autostart Test"})
check("Autostart gesetzt", r.get("autostart") == "Autostart Test", r)
# Einstellungen OHNE autostart_project speichern (UI-Merge-Fall)
r = post(BASE_A, "/api/settings", {"preview": {"fps": 20}})
check("Settings gespeichert (Merge)", bool(r.get("saved")), r)
cfg = json.load(open(os.path.join(ROOT, "config.json")))
check("Autostart UEBERLEBT Settings-Save",
cfg["engine"]["autostart_project"] == "Autostart Test",
cfg["engine"]["autostart_project"])
check("FPS uebernommen", cfg["preview"]["fps"] == 20)
st = get(BASE_A, "/api/status")
check("Server laeuft weiter", st["running"] is True)
# Autostart via Settings-Endpunkt abwaehlen (NULL akzeptiert)
r = post(BASE_A, "/api/settings",
{"engine": {"autostart_project": None}})
check("Autostart via Settings abgewaehlt",
bool(r.get("saved"))
and json.load(open(os.path.join(
ROOT, "config.json")))["engine"]["autostart_project"] is None)
# Generatoren in Projekten: speichern + laden
post(BASE_A, "/api/layers/add-generator", {"generator": "gradient"})
r = post(BASE_A, "/api/projects/save", {"name": "Mit Generator"})
check("Projekt mit Generator gespeichert", r.get("saved")
== "Mit Generator", r)
r = post(BASE_A, "/api/projects/load", {"name": "Mit Generator"})
check("Projekt mit Generator geladen", r.get("loaded") is True, r)
st = get(BASE_A, "/api/status")
check("Generator nach Projekt-Load aktiv",
any(l["kind"] == "generator" and l["name"] == "gradient"
for l in st["layers"]),
[(l["kind"], l["name"]) for l in st["layers"]])
print("")
print("ALLE VERTEILUNGS/GENERATOR-E2E-TESTS BESTANDEN")
finally:
stop_all()