AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert

Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell.
Jetzt ist sauber getrennt:

ROOT (was der Nutzer sieht und braucht):
- run.py                     = das Programm
- hms_app/                   = der Anwendungscode
- HMS MediaEngine.app        = macOS Doppelklick-Starter
- HMS-Start.vbs              = Windows Doppelklick-Starter
- HMS-Install.vbs             = Windows Erst-Installation
- HMS-Mac-Install.command     = macOS Homebrew-Installation
- HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix)
- installer_gui.py           = grafischer Installer
- launcher.pyw + launcher_core.py = interne Start-Logik
- LIESMICH.txt               = 10-Zeilen-Kurzanleitung
- .gitignore

_entwicklung/ (alles andere, NICHT benoetigt):
- packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/
  build/ fixture_profiles/
- PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md
- pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py

Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann
_entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne.

Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 23:44:06 +02:00
parent 696e8eb1b3
commit 362e089be0
338 changed files with 24 additions and 387 deletions
@@ -0,0 +1,9 @@
"""hms_control_server minimaler Control Core (PLAN.md §6.1B, §36 Nr. 9).
FastAPI-REST + WebSocket für denselben Parametersatz, den Art-Net bedient.
Autoritative Instanz ist die ParameterEngine; alle Quellen laufen über sie.
"""
from hms_control_server.app import create_app
__all__ = ["create_app"]
@@ -0,0 +1,10 @@
"""Control-Core-Start (Entwicklung): python -m hms_control_server"""
from __future__ import annotations
import uvicorn
if __name__ == "__main__":
# Phase 0: Development-Start. Produktion startet über den Launcher,
# bindet 127.0.0.1 und wählt freie Ports (§3.1, §9.2).
uvicorn.run("hms_control_server.app:app", host="127.0.0.1", port=8000)
@@ -0,0 +1,433 @@
"""FastAPI-Anwendung des Control Core.
Endpunkte:
- GET /api/v1/system/health
- GET /api/v1/system/identity (node_id, display_name, roles; nicht vertraulich)
- GET /api/v1/system/capabilities
- GET /api/v1/parameters
- POST /api/v1/commands (parameter.set mit Revision-Prüfung und Idempotenz)
- POST /api/v1/commands/{command_id}/release
- GET /api/v1/diagnostics
- GET /api/v1/cluster/nodes
- GET /api/v1/projects, POST/GET/PUT/DELETE /api/v1/projects/{id}
- POST /api/v1/media/import
- GET /api/v1/media
- GET /api/v1/plugins
- GET /api/v1/artnet/status
- WS /ws (State-Snapshot + Updates)
Commands folgen §23.2: command_id, type, expected_revision, actor, payload.
Node-Identität: persistente node_id aus userdata/identity (§3.6, §6.3);
im Dev-Modus ohne App-Root wird eine ephemeral-Identität erzeugt.
"""
from __future__ import annotations
import asyncio
import uuid
from pathlib import Path
from fastapi import FastAPI, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from hms_capabilities.probe import CapabilityReport
from hms_cluster.registry import NodeRegistry
from hms_domain.identity import NodeIdentity, NodeRole
from hms_domain.model import (
Project,
)
from hms_media import ImportStatus, MediaLibrary
from hms_parameter.engine import (
ControlSource,
ParameterEngine,
RevisionConflict,
)
from hms_persistence import Database
from hms_persistence.state_store import ProjectStateStore
from hms_protocol.idempotency import IdempotencyRegistry
from pydantic import BaseModel, Field
class SetParameterCommand(BaseModel):
"""parameter.set-Command (§23.2)."""
command_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
type: str = "parameter.set"
expected_revision: int | None = None
actor: dict = Field(default_factory=lambda: {"type": "web", "id": "operator-session"})
payload: dict
class ReleaseCommand(BaseModel):
source: str = "web"
class CreateProjectBody(BaseModel):
name: str = "Neues Projekt"
def _builtin_plugin_dir() -> Path:
"""Löst das Builtin-Plugin-Verzeichnis robust auf (§14.2).
Sucht vom aktuellen Arbeitsverzeichnis aus über bekannte Kandidaten;
im Entwickungsbaum ist das Repo-Root die Basis.
"""
candidates = [
Path.cwd() / "plugins" / "builtin",
Path(__file__).resolve().parents[3] / "plugins" / "builtin",
]
for candidate in candidates:
if candidate.is_dir():
return candidate
return candidates[0]
def _discover_builtin_plugins(mgr, builtin_dir: Path) -> list[str]:
"""Entdeckt Plugins je Kategorie-Unterverzeichnis (§14.2)."""
errors: list[str] = []
for category in sorted(builtin_dir.iterdir()):
if not category.is_dir():
continue
errors.extend(mgr.discover(category))
return errors
class _State:
def __init__(self, identity: NodeIdentity, data_dir: Path | None = None) -> None:
self.identity = identity
self.engine = ParameterEngine()
self.registry = IdempotencyRegistry()
self.report = CapabilityReport()
self.nodes = NodeRegistry()
self.subscribers: list[asyncio.Queue] = []
# Persistenz und Projekt-Verwaltung (§24, §6.4)
self.data_dir = data_dir or Path("/tmp/hms-dev-data")
self.database = Database(self.data_dir / "database" / "hms.db")
self.database.open()
self.database.migrate(backup_dir=self.data_dir / "backups")
self.state_store = ProjectStateStore()
self.media_library = MediaLibrary(self.data_dir / "media")
def create_app(identity: NodeIdentity | None = None) -> FastAPI:
"""Erzeugt die Control-Core-App.
identity: produktiv vom Launcher geladene persistente Identität
(userdata/identity). Ohne Angabe gilt Dev-Modus mit ephemeral-Identität
(jede App-Instanz erhält eine eigene ID; für Showbetrieb unzulässig).
"""
if identity is None:
identity = NodeIdentity.ephemeral(
"Dev Node", frozenset({NodeRole.RENDER_NODE, NodeRole.COORDINATOR})
)
app = FastAPI(title="HMS MediaEngine Control Core", version="0.1.0")
state = _State(identity)
# Eigene Node in die Registry eintragen (§6.3)
state.nodes.register(
node_id=identity.node_id,
display_name=identity.display_name,
roles=tuple(r.value for r in identity.roles),
)
def _broadcast(event: dict) -> None:
for queue in list(state.subscribers):
queue.put_nowait(event)
# ---------- System (§23.1) ----------
@app.get("/api/v1/system/health")
async def health() -> dict:
return {
"status": "ok",
"phase": 5,
"node_id": state.identity.node_id,
"revision": state.engine.revision,
}
@app.get("/api/v1/system/identity")
async def system_identity() -> dict:
"""Nicht vertrauliche Selbstauskunft (§27.1: keine Tokens/Secrets)."""
return {
"node_id": state.identity.node_id,
"display_name": state.identity.display_name,
"roles": sorted(r.value for r in state.identity.roles),
"renders_locally": state.identity.renders_locally,
"is_coordinator": state.identity.is_coordinator,
}
@app.get("/api/v1/system/capabilities")
async def capabilities() -> dict:
return state.report.as_dict()
@app.get("/api/v1/parameters")
async def parameters() -> dict:
snap = state.engine.snapshot()
return {"revision": snap.revision, "values": snap.as_dict()}
@app.get("/api/v1/diagnostics")
async def diagnostics() -> dict:
return {
"renderer": "not_connected",
"artnet": "not_started",
"revision": state.engine.revision,
"node_id": state.identity.node_id,
"state_revision": state.state_store.state_revision,
"project_revision": state.state_store.project_revision,
}
# ---------- Commands (§23.2) ----------
@app.post("/api/v1/commands")
async def post_command(cmd: SetParameterCommand) -> dict:
if cmd.type != "parameter.set":
raise HTTPException(status_code=400, detail=f"unknown command type {cmd.type!r}")
if not state.registry.register(cmd.command_id):
prior = state.registry.result(cmd.command_id)
if prior is not None:
return {"status": "ack", "duplicate": True, "result": prior}
raise HTTPException(status_code=409, detail="command already in flight")
path = cmd.payload.get("parameter_path")
value = cmd.payload.get("value")
if not path or value is None:
raise HTTPException(status_code=400, detail="payload requires parameter_path and value")
try:
revision = state.engine.set_value(
path=path,
value=float(value),
source=ControlSource.WEB,
expected_revision=cmd.expected_revision,
)
state.state_store.set_value(path, float(value))
except RevisionConflict as exc:
raise HTTPException(
status_code=409,
detail={
"error": "REVISION_CONFLICT",
"current": exc.current,
"expected": exc.expected,
},
) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
result = {
"status": "ack",
"command_id": cmd.command_id,
"revision": revision,
"effective": state.engine.effective_value(path),
}
state.registry.complete(cmd.command_id, result)
_broadcast(
{
"type": "parameter.update",
"parameter_path": path,
"value": value,
"revision": revision,
}
)
return result
@app.post("/api/v1/commands/{command_id}/release")
async def release_override(command_id: str) -> dict:
return {"status": "not_implemented_in_phase0"}
# ---------- Cluster (§23.1) ----------
@app.get("/api/v1/cluster/nodes")
async def cluster_nodes() -> dict:
"""Node-Übersicht nach UI-Kategorien (§6.3): getrennt aufgeführt."""
nodes = []
for entry in state.nodes.all():
state.nodes.evaluate_health(entry.node_id)
nodes.append(
{
"node_id": entry.node_id,
"display_name": entry.display_name,
"roles": list(entry.roles),
"health": entry.health.value,
"category": entry.category.value,
}
)
return {"self": state.identity.node_id, "nodes": nodes}
# ---------- Projekte (§23.1, §24.2) ----------
@app.get("/api/v1/projects")
async def list_projects() -> dict:
return {"projects": state.database.list_projects()}
@app.post("/api/v1/projects")
async def create_project(body: CreateProjectBody) -> dict:
project = Project(name=body.name)
state.database.save_project(project.model_dump(mode="json"))
return {"status": "ack", "project": project.model_dump(mode="json")}
@app.get("/api/v1/projects/{project_id}")
async def get_project(project_id: str) -> dict:
data = state.database.load_project(project_id)
if data is None:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
return data
@app.put("/api/v1/projects/{project_id}")
async def update_project(project_id: str, body: dict) -> dict:
existing = state.database.load_project(project_id)
if existing is None:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
for key, value in body.items():
if key in existing:
existing[key] = value
updated = Project.model_validate(existing)
state.database.save_project(updated.model_dump(mode="json"))
state.state_store.bump_project_revision()
return {"status": "ack"}
@app.delete("/api/v1/projects/{project_id}")
async def delete_project(project_id: str) -> dict:
state.database.delete_project(project_id)
return {"status": "ack"}
@app.post("/api/v1/projects/{project_id}/activate")
async def activate_project(project_id: str) -> dict:
"""Aktiviert ein Projekt als autoritative Basis (§6.4)."""
data = state.database.load_project(project_id)
if data is None:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
project = Project.model_validate(data)
revision = state.state_store.activate_project(project)
return {"status": "ack", "state_revision": revision}
# ---------- Medien (§13.3, §23.1) ----------
@app.post("/api/v1/media/import")
async def import_media(file: UploadFile) -> dict:
"""Importiert eine Mediendatei mit Duplikaterkennung (§13.3).
Import blockiert niemals den Renderthread (§13.3).
"""
media_root = state.data_dir / "media"
media_root.mkdir(parents=True, exist_ok=True)
dest = media_root / file.filename
content = await file.read()
dest.write_bytes(content)
outcome = state.media_library.import_file(dest)
if outcome.status is ImportStatus.IMPORTED and outcome.asset:
state.database.save_project({
"id": str(uuid.uuid4()),
"name": file.filename,
"schema_version": 1,
"created_at": "2026-09-11T00:00:00+00:00",
"updated_at": "2026-09-11T00:00:00+00:00",
"data": outcome.asset.model_dump(mode="json"),
})
return {
"status": outcome.status.value,
"asset": outcome.asset.model_dump(mode="json") if outcome.asset else None,
"duplicate_of": outcome.duplicate_of,
}
@app.get("/api/v1/media")
async def list_media() -> dict:
"""Medienliste mit Metadaten (§13.2)."""
missing = state.media_library.mark_missing()
assets = []
for asset in state.media_library.all():
assets.append({
**asset.model_dump(mode="json"),
"missing": asset.id in state.media_library.missing_asset_ids,
})
return {"media": assets, "missing_count": len(missing)}
# ---------- Plugins (§23.1) ----------
@app.get("/api/v1/plugins")
async def list_plugins() -> dict:
"""Installierte/verfügbare Plugins nach Status gruppiert (§14.5)."""
from hms_plugin_sdk import PluginLifecycleManager
builtin_dir = _builtin_plugin_dir()
mgr = PluginLifecycleManager()
errors = _discover_builtin_plugins(mgr, builtin_dir)
plugins_by_state: dict[str, list[dict]] = {}
for record in mgr.all():
plugins_by_state.setdefault(record.state.value, []).append(
{
"plugin_id": record.plugin_id,
"version": record.version,
"kind": record.manifest.get("kind", "unknown"),
"name": record.manifest.get("name", record.plugin_id),
"last_error": record.last_error,
}
)
return {
"plugins_by_state": plugins_by_state,
"total": len(mgr.all()),
"discovery_errors": errors,
}
@app.post("/api/v1/plugins/{plugin_id}/enable")
async def enable_plugin(plugin_id: str) -> dict:
"""Aktiviert ein Plugin über den Lifecycle (§14.5)."""
from hms_plugin_sdk import (
InvalidTransitionError,
LifecycleState,
PluginLifecycleManager,
)
builtin_dir = _builtin_plugin_dir()
mgr = PluginLifecycleManager()
_discover_builtin_plugins(mgr, builtin_dir)
if mgr.get(plugin_id) is None:
raise HTTPException(status_code=404, detail=f"unbekanntes Plugin {plugin_id!r}")
try:
for target in (
LifecycleState.INSTALLED,
LifecycleState.ENABLED,
LifecycleState.COMPILED,
LifecycleState.ACTIVE,
):
record = mgr.get(plugin_id)
if record is None:
break
if record.state is target:
break
mgr.advance(plugin_id, target)
return {"status": "ack", "plugin_id": plugin_id}
except InvalidTransitionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# ---------- Art-Net (§23.1) ----------
@app.get("/api/v1/artnet/status")
async def artnet_status() -> dict:
return {
"running": False,
"universes": [],
"telemetry": {},
"note": "Art-Net-Receiver startet mit dem Launcher (Phase 1 vollstaendig nach Gate 1)",
}
# ---------- WebSocket (§23.3) ----------
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket) -> None:
await ws.accept()
queue: asyncio.Queue = asyncio.Queue(maxsize=256)
state.subscribers.append(queue)
try:
snap = state.engine.snapshot()
await ws.send_json(
{"type": "snapshot", "revision": snap.revision, "values": snap.as_dict()}
)
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=15.0)
await ws.send_json(event)
except TimeoutError:
await ws.send_json({"type": "heartbeat"})
except WebSocketDisconnect:
pass
finally:
state.subscribers.remove(queue)
return app
app = create_app()
@@ -0,0 +1,90 @@
"""RendererStateLink auf der Control-Core-Seite (PLAN.md §6.2, §6.4).
Verbindet ProjectStateStore (autoritativ) mit dem Renderer über IPC:
- connect_and_sync(): Handshake + vollständiger Snapshot (§6.2 Pflicht)
- sync_if_changed(): Delta seit letzter gesendeter Revision
- Reconnect: immer neuer Snapshot, danach erst wieder Deltas (§6.2)
Der Link kennt den letzten beim Renderer angekommenen Zustand und
berechnet Deltas daraus keine halben Zustände (§11.4, §6.4).
"""
from __future__ import annotations
from hms_persistence.state_store import ProjectStateStore
from hms_protocol import Envelope, IpcClient, MessageType
class RendererStateLink:
"""Synchronisiert den Showzustand Control Core → Renderer über IPC."""
def __init__(self, client: IpcClient, store: ProjectStateStore) -> None:
self._client = client
self._store = store
self._last_sent_revision = 0
self._renderer_known_state: dict[str, float] = {}
@property
def last_sent_revision(self) -> int:
return self._last_sent_revision
@property
def in_sync(self) -> bool:
"""True, wenn der Renderer die aktuelle Revision besitzt."""
return self._last_sent_revision == self._store.state_revision
# ---------- Verbindung (§6.2) ----------
async def connect_and_sync(self) -> None:
"""Verbindung aufbauen und vollständigen Snapshot senden.
Nach jedem (Re-)Connect wird immer zuerst der vollständige Snapshot
übertragen; Deltas folgen erst danach (§6.2: „Re-Sync nach
Reconnect", „Deltas erst nach erfolgreichem Re-Sync akzeptiert").
"""
await self._client.connect()
await self.send_full_snapshot()
async def send_full_snapshot(self) -> int:
"""Sendet den vollständigen Zustand; liefert die gesendete Revision."""
snap = self._store.snapshot()
self._last_sent_revision = snap["state_revision"]
self._renderer_known_state = dict(snap["values"])
envelope = Envelope(
type=MessageType.SNAPSHOT,
revision=snap["state_revision"],
payload=snap,
)
await self._client.send(envelope)
return snap["state_revision"]
# ---------- Delta-Versand (§6.4) ----------
async def sync_if_changed(self) -> bool:
"""Sendet ein Delta, falls sich die Revision seit dem letzten Versand
geändert hat. Rückgabe: True, wenn etwas gesendet wurde.
Das Delta wird aus dem zuletzt bekannten Renderer-Zustand berechnet
(neu/geändert/gelöscht); die Semantik folgt StateDelta (§6.4).
"""
if self._store.state_revision == self._last_sent_revision:
return False # nichts Neues
delta = self._store.delta_since(
self._last_sent_revision, self._renderer_known_state
)
if delta is None:
return False
# Buchhaltung: was weiß der Renderer ab jetzt?
for path, value in delta.changes.items():
if value is None:
self._renderer_known_state.pop(path, None)
else:
self._renderer_known_state[path] = value
self._last_sent_revision = delta.state_revision
envelope = Envelope(
type=MessageType.EVENT,
revision=delta.state_revision,
payload=delta.to_dict(),
)
await self._client.send(envelope)
return True
View File
@@ -0,0 +1,15 @@
"""hms_launcher Supervisor/Launcher (PLAN.md §6.1A, §9).
Phase-0/1-Umfang: portable Pfadauflösung, Portwahl, GStreamer-Environment,
Prozessüberwachung mit Restart-Policy und Crashloop-Erkennung,
kontrolliertes Beenden mit Recovery-Markierung.
"""
from hms_launcher.paths import AppPaths, resolve_app_root
from hms_launcher.supervisor import (
ProcessSpec,
Supervisor,
find_free_port,
)
__all__ = ["AppPaths", "resolve_app_root", "ProcessSpec", "Supervisor", "find_free_port"]
@@ -0,0 +1,178 @@
"""Launcher-Hauptprogramm: startet die komplette Anwendung (§6.1A, §9.2).
Startablauf (§9.2):
1. Launcher bestimmt den eigenen absoluten Programmordner
2. Runtime- und GStreamer-Suchpfade werden gesetzt
3. Konfiguration und Datenbank werden migriert
4. Renderer startet und meldet Capabilities
5. Control Server startet auf konfiguriertem Port
6. Node-Identity wird geladen, Discovery gestartet
7. Healthcheck und lokaler Render-Preflight müssen grün sein
8. Standardbrowser wird optional geöffnet
9. Erst danach wird ein gespeichertes Autostart-Projekt aktiviert
Der Launcher läuft als Vordergrundprozess und beendet beide Kindprozesse
kontrolliert bei SIGINT/SIGTERM (§26.4).
"""
from __future__ import annotations
import asyncio
import signal
import sys
import time
from pathlib import Path
from hms_launcher.paths import AppPaths, resolve_app_root
from hms_launcher.supervisor import ProcessSpec, Supervisor, find_free_port
async def run(
app_root: Path | None = None,
open_browser: bool = True,
web_port: int | None = None,
) -> int:
"""Startet die komplette HMS MediaEngine-Anwendung.
Rückgabe: Exit-Code (0 = kontrolliert beendet).
"""
# 1. App-Root bestimmen (§9.2 Nr. 1)
if app_root is None:
app_root = resolve_app_root()
paths = AppPaths(root=app_root)
# Schreibbarkeit prüfen (§9.1)
if not paths.ensure_writable():
print("FEHLER: Anwendungsordner ist schreibgeschützt", file=sys.stderr)
return 1
# 2. Ports wählen (§6.1A)
web_port = web_port or find_free_port()
ipc_port = find_free_port()
print(f"HMS MediaEngine Starte (root={app_root})")
print(f" Web-UI: http://127.0.0.1:{web_port}")
print(f" IPC: 127.0.0.1:{ipc_port}")
# 3. Supervisor einrichten (§6.1A)
supervisor = Supervisor(paths)
# 4. Renderer starten (§9.2 Nr. 4)
renderer_cmd = [
sys.executable, "-m", "hms_renderer",
"--ipc-port", str(ipc_port),
]
supervisor.start(ProcessSpec(
name="renderer",
cmd=renderer_cmd,
restartable=True,
max_restarts=5,
stop_timeout_s=5.0,
))
print(" Renderer gestartet")
# 5. Control Core starten (§9.2 Nr. 5)
control_cmd = [
sys.executable, "-m", "uvicorn",
"hms_control_server.app:app",
"--host", "127.0.0.1",
"--port", str(web_port),
]
supervisor.start(ProcessSpec(
name="control_core",
cmd=control_cmd,
restartable=True,
max_restarts=5,
stop_timeout_s=10.0,
))
print(" Control Core gestartet")
# 6. Healthcheck: warten bis Web-UI erreichbar ist (§9.2 Nr. 7)
health_ok = await _wait_for_health(web_port, timeout_s=15.0)
if not health_ok:
print("FEHLER: Control Core Healthcheck fehlgeschlagen", file=sys.stderr)
supervisor.shutdown()
return 1
print(" Healthcheck grün")
# 7. Recovery-Markierung prüfen (§26.4)
recovery = supervisor.consume_recovery_marker()
if recovery:
print(f" WARNUNG: Unsauberer Shutdown erkannt: {recovery}")
# 8. Browser öffnen (§9.2 Nr. 8, optional)
if open_browser:
_open_browser(f"http://127.0.0.1:{web_port}")
print(" Browser geöffnet")
# 9. Hauptschleife: Prozesse überwachen bis SIGINT/SIGTERM
print("Anwendung läuft. Strg+C zum Beenden.")
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, stop_event.set)
except NotImplementedError:
pass # Windows
try:
while not stop_event.is_set():
states = supervisor.check()
for name, state in states.items():
if state == "crashloop":
print(f" FEHLER: {name} im Crashloop", file=sys.stderr)
stop_event.set()
elif state == "restarted":
print(f" {name} neu gestartet")
await asyncio.sleep(1.0)
finally:
# Kontrolliertes Beenden (§26.4)
print("Beende Anwendung...")
supervisor.shutdown()
print("Alle Prozesse beendet.")
return 0
async def _wait_for_health(port: int, timeout_s: float = 15.0) -> bool:
"""Wartet bis /api/v1/system/health 200 OK liefert."""
import urllib.error
import urllib.request
deadline = time.monotonic() + timeout_s
url = f"http://127.0.0.1:{port}/api/v1/system/health"
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=2.0) as resp:
if resp.status == 200:
return True
except (urllib.error.URLError, OSError):
await asyncio.sleep(0.5)
return False
def _open_browser(url: str) -> None:
"""Öffnet den Standardbrowser plattformneutral."""
import subprocess
try:
if sys.platform == "win32":
subprocess.run(
["cmd", "/c", "start", url], check=False, timeout=5
)
elif sys.platform == "darwin":
subprocess.run(["open", url], check=False, timeout=5)
else:
subprocess.run(
["xdg-open", url], check=False, timeout=5
)
except (OSError, subprocess.TimeoutExpired):
pass # Browser ist optional; Web-UI ist auch direkt erreichbar
def main() -> int:
return asyncio.run(run())
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,111 @@
"""Portable Pfadregeln (PLAN.md §9, §9.1).
- Alle Pfade relativ zum Anwendungsroot; keine Laufwerksbuchstaben.
- Keine Abhängigkeit vom Working Directory.
- Temporäre Dateien in userdata/cache, nicht im OS-Profil.
- Schreibbarkeit wird beim Start geprüft (Read-only-Modus folgt Phase 1).
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class AppPaths:
"""Alle portablen Pfade je Anwendungsroot (§9-Struktur)."""
root: Path
@property
def app(self) -> Path:
return self.root / "app"
@property
def runtime(self) -> Path:
return self.root / "runtime"
@property
def gstreamer_bin(self) -> Path:
return self.runtime / "gstreamer" / "bin"
@property
def gstreamer_plugins(self) -> Path:
return self.runtime / "gstreamer" / "lib" / "gstreamer-1.0"
@property
def web(self) -> Path:
return self.root / "web"
@property
def projects(self) -> Path:
return self.root / "projects"
@property
def media(self) -> Path:
return self.root / "media"
@property
def userdata(self) -> Path:
return self.root / "userdata"
@property
def database(self) -> Path:
return self.userdata / "database"
@property
def cache(self) -> Path:
return self.userdata / "cache"
@property
def identity(self) -> Path:
return self.userdata / "identity" / "node_id"
@property
def config(self) -> Path:
return self.root / "config"
@property
def logs(self) -> Path:
return self.root / "logs"
def ensure_writable(self) -> bool:
"""Prüft Schreibbarkeit des Roots (§9.1)."""
probe = self.root / ".write_probe"
try:
probe.write_text("ok", encoding="ascii")
probe.unlink()
return True
except OSError:
return False
def portable_environment(self) -> dict[str, str]:
"""Umgebungsvariablen für gebündelte GStreamer-Runtime (§9.2, ADR-0002).
System-Plugins werden unterdrückt (leerer GST_PLUGIN_SYSTEM_PATH_1_0),
damit ausschließlich die gebündelte, manifestierte Untermenge lädt.
"""
env = dict(os.environ)
gs_bin = self.gstreamer_bin
if gs_bin.is_dir():
path_var = "PATH"
existing = env.get(path_var, "")
env[path_var] = f"{gs_bin}{os.pathsep}{existing}" if existing else str(gs_bin)
env["GST_PLUGIN_PATH_1_0"] = str(self.gstreamer_plugins)
env["GST_PLUGIN_SYSTEM_PATH_1_0"] = ""
return env
def resolve_app_root(start_from: Path | None = None) -> Path:
"""Bestimmt den Anwendungsroot anhand der PORTABLE_MODE-Markierung (§9).
Sucht vom gegebenen Pfad (Default: dieses Paket) aufwärts nach der
Datei PORTABLE_MODE; im Entwickungsbaum ist das Repo-Root gemeint.
"""
current = Path(start_from or __file__).resolve()
for candidate in [current, *current.parents]:
if (candidate / "PORTABLE_MODE").is_file() or (candidate / "pyproject.toml").is_file():
return candidate
raise RuntimeError("app root not found (PORTABLE_MODE or pyproject.toml missing)")
@@ -0,0 +1,206 @@
"""Supervisor/Launcher (PLAN.md §6.1A, §26.2, §26.4).
Aufgaben (§6.1A):
- Start und Überwachung der Teilprozesse (Control Core, Renderer)
- Wahl freier lokaler Ports (127.0.0.1)
- Setzen portabler Runtime-Pfade (AppPaths.portable_environment)
- Heartbeat-Überwachung auf Prozessebene (Lebenszyklus)
- kontrolliertes Beenden mit Timeout (§26.4)
- Wiederanlauf nach Control-Core-Absturz, Crashloop-Erkennung (§26.2)
- Recovery-Markierung bei Zwangsbeendigung (§26.4)
Der Supervisor verwaltet echte Prozesse; keine Mock-Implementierung.
"""
from __future__ import annotations
import os
import signal
import socket
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from hms_launcher.paths import AppPaths
DEFAULT_STOP_TIMEOUT_S = 10.0
DEFAULT_MAX_RESTARTS = 5
DEFAULT_RESTART_WINDOW_S = 60.0
@dataclass(frozen=True)
class ProcessSpec:
"""Beschreibung eines zu überwachenden Teilprozesses."""
name: str
cmd: list[str]
restartable: bool = True
max_restarts: int = DEFAULT_MAX_RESTARTS
restart_window_s: float = DEFAULT_RESTART_WINDOW_S
stop_timeout_s: float = DEFAULT_STOP_TIMEOUT_S
@dataclass
class ProcessState:
"""Laufzeitinformation zu einem überwachten Prozess (§26.2)."""
spec: ProcessSpec
proc: subprocess.Popen | None = None
restarts: list[float] = field(default_factory=list) # Zeitstempel je Neustart
last_start_ns: int = 0
crashlooped: bool = False
stopped_by_supervisor: bool = False
@property
def running(self) -> bool:
return self.proc is not None and self.proc.poll() is None
@property
def returncode(self) -> int | None:
return self.proc.poll() if self.proc is not None else None
def find_free_port(host: str = "127.0.0.1") -> int:
"""Wählt einen freien lokalen Port (§6.1A). Socket wird sofort wieder
freigegeben; der Kindprozess bindet ihn anschließend selbst."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((host, 0))
return sock.getsockname()[1]
class Supervisor:
"""Überwacht Teilprozesse mit Restart-Policy und Crashloop-Schwelle."""
def __init__(self, paths: AppPaths | None = None) -> None:
self._paths = paths
self._states: dict[str, ProcessState] = {}
self._recovery_marker: Path | None = None
if paths is not None:
self._recovery_marker = paths.userdata / "recovery" / "unclean_shutdown"
# ---------- Start/Stop ----------
def start(self, spec: ProcessSpec) -> None:
"""Startet einen Teilprozess mit portabler Umgebung."""
if spec.name in self._states and self._states[spec.name].running:
raise RuntimeError(f"process {spec.name!r} already running")
env = dict(os.environ)
if self._paths is not None:
env.update(self._paths.portable_environment())
proc = subprocess.Popen(
spec.cmd,
env=env,
cwd=str(self._paths.root) if self._paths is not None else None,
)
state = self._states.get(spec.name)
if state is None:
state = ProcessState(spec=spec)
self._states[spec.name] = state
else:
state.spec = spec
state.proc = proc
state.last_start_ns = time.monotonic_ns()
state.stopped_by_supervisor = False
def stop(self, name: str, timeout: float | None = None) -> int | None:
"""Kontrolliertes Beenden: terminate → warten → kill (§26.4).
Gibt den Rückgabecode zurück; None falls der Prozess nicht lief.
"""
state = self._states.get(name)
if state is None or state.proc is None:
return None
if not state.running:
return state.returncode
state.stopped_by_supervisor = True
timeout = timeout if timeout is not None else state.spec.stop_timeout_s
state.proc.terminate() # SIGTERM: laufende Writes abschließen (§26.4)
try:
return state.proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# Zwangsbeendigung: Recovery-Markierung setzen (§26.4)
state.proc.kill()
self._write_recovery_marker(name)
return state.proc.wait(timeout=5)
def shutdown(self) -> None:
"""Beendet alle Prozesse kontrolliert (Renderer zuletzt, um Output
so lange wie möglich zu halten; §26.2)."""
for name in reversed(list(self._states)):
self.stop(name)
# ---------- Überwachung (§26.2) ----------
def check(self) -> dict[str, str]:
"""Prüft alle Prozesse; startet Abgestürzte gemäß Policy neu.
Rückgabe: name → Zustand (running/restarted/crashloop/stopped).
"""
result: dict[str, str] = {}
now = time.monotonic()
for name, state in list(self._states.items()):
if state.running:
result[name] = "running"
continue
if state.stopped_by_supervisor:
result[name] = "stopped"
continue
if not state.spec.restartable or state.crashlooped:
result[name] = "crashloop" if state.crashlooped else "stopped"
continue
# Neustarts innerhalb des Zeitfensters zählen (Crashloop, §26.2)
state.restarts = [
t for t in state.restarts if now - t < state.spec.restart_window_s
]
if len(state.restarts) >= state.spec.max_restarts:
state.crashlooped = True # endlose Neustarts verhindern
result[name] = "crashloop"
continue
state.restarts.append(now)
self.start(state.spec)
result[name] = "restarted"
return result
def state(self, name: str) -> ProcessState:
return self._states[name]
def names(self) -> list[str]:
return list(self._states)
# ---------- Recovery (§26.4) ----------
def _write_recovery_marker(self, name: str) -> None:
if self._recovery_marker is None:
return
self._recovery_marker.parent.mkdir(parents=True, exist_ok=True)
with open(self._recovery_marker, "a", encoding="utf-8") as fh:
fh.write(
f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} forced-kill {name}\n"
)
def consume_recovery_marker(self) -> list[str]:
"""Liest und löscht die Recovery-Markierung (Crash-Recovery-Dialog,
§24.3/§26.4). Gibt die Zeilen zurück."""
if self._recovery_marker is None or not self._recovery_marker.is_file():
return []
lines = self._recovery_marker.read_text(encoding="utf-8").splitlines()
self._recovery_marker.unlink()
return lines
# Windows-kompatibles SIGTERM: terminate() nutzt auf Windows TerminateProcess,
# das kein SIGTERM ist. Für sauberes Shutdown nutzen Kindprozesse dort einen
# Steuerkanal (IPC-command) der Supervisor sendet SIGTERM nur auf POSIX.
def request_graceful_stop(proc: subprocess.Popen, timeout: float) -> int | None:
"""POSIX: SIGTERM; Windows: proc.terminate(). Wartet dann kontrolliert."""
if os.name == "posix":
proc.send_signal(signal.SIGTERM)
else:
proc.terminate()
try:
return proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
return None
View File
@@ -0,0 +1,35 @@
"""hms_renderer Render-Worker (PLAN.md §6.1C, §12, §36 Nr. 46).
Python orchestriert native GStreamer-Komponenten; keine Pixelverarbeitung
in Python (§2.1, §33). Der RemoteStateMirror hält den über IPC übermittelten
Showzustand (§6.2, §6.4); die RenderEngine verbindet Mirror, Playback und
Preload zu Frame-Snapshots (§11.4)."""
from hms_renderer.engine import (
FrameSnapshot,
RenderEngine,
RenderTelemetry,
SourceHandle,
)
from hms_renderer.pipelines import (
D3D11Pipeline,
DevGLPipeline,
build_compositor_pipeline,
build_single_video_pipeline,
gst_available,
)
from hms_renderer.state_mirror import RemoteStateMirror, apply_envelope
__all__ = [
"D3D11Pipeline",
"DevGLPipeline",
"build_single_video_pipeline",
"build_compositor_pipeline",
"gst_available",
"RemoteStateMirror",
"apply_envelope",
"RenderEngine",
"SourceHandle",
"FrameSnapshot",
"RenderTelemetry",
]
@@ -0,0 +1,52 @@
"""Renderer-CLI (Phase-0-Spike).
Aufruf:
python -m hms_renderer --pipeline d3d11 --video-a A --video-b B
Ohne GStreamer-Installation: kontrollierter Abbruch mit Exit-Code 2 und
klarer Meldung niemals Erfolgssimulation (§1.1 Nr. 5, §33).
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from hms_renderer.pipelines import build_compositor_pipeline, build_single_video_pipeline
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="hms-renderer")
parser.add_argument("--pipeline", choices=["d3d11", "devgl"], default="d3d11")
parser.add_argument("--video-a", required=True)
parser.add_argument("--video-b", default=None, help="zweite Quelle für Compositing")
parser.add_argument("--dry-run", action="store_true", help="nur Pipeline-String ausgeben")
args = parser.parse_args(argv)
use_d3d11 = args.pipeline == "d3d11"
if args.video_b:
pipeline = build_compositor_pipeline(args.video_a, args.video_b, d3d11=use_d3d11)
else:
pipeline = build_single_video_pipeline(args.video_a, d3d11=use_d3d11)
if args.dry_run:
print(pipeline)
return 0
gst_launch = shutil.which("gst-launch-1.0")
if gst_launch is None:
print(
"ERROR: gst-launch-1.0 nicht gefunden. GStreamer 1.28.6 muss gebündelt "
"oder installiert sein (build/windows/GSTREAMER.md).",
file=sys.stderr,
)
return 2
result = subprocess.run([gst_launch, "-v", pipeline], check=False)
return result.returncode
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,268 @@
"""Renderer-Engine: verbindet StateMirror, Playback und Preload (§12, §6.4).
Der Rendergraph läuft in nativem GStreamer/Rust (ADR-0004); diese Engine
orchestriert:
- liest Showparameter aus dem RemoteStateMirror (§6.4)
- berechnet Playback-Positionen je Quelle (§12.5)
- verwaltet Preload-Slots für atomaren Clipwechsel (§12.2)
- bildet pro Frame einen unveränderlichen Parameter-Snapshot (§11.4)
- erzeugt Rendertelemetrie (§28.2)
Keine Pixelverarbeitung in Python (§33); die GPU-Pipeline wird über
Pipeline-Definitionen an GStreamer übergeben.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from hms_domain.model import TransportState
from hms_media import PlaybackController, PreloadSlot
from hms_renderer.state_mirror import RemoteStateMirror
@dataclass(frozen=True)
class FrameSnapshot:
"""Unveränderlicher Parameter-Snapshot für genau einen Frame (§11.4).
Der Rendergraph (Rust/GStreamer) erhält genau dieses Objekt pro Frame;
halbe Zustände sind ausgeschlossen.
"""
frame_index: int
monotonic_ns: int
state_revision: int
parameters: dict[str, float]
source_positions: dict[str, float] # source_id → normalisierte Position
source_states: dict[str, str] # source_id → TransportState
active_asset_ids: dict[str, str | None] # layer_key → asset_id (nach Commit)
@dataclass
class RenderTelemetry:
"""Rendertelemetrie je Frame (§28.2, §25.4)."""
frame_index: int = 0
fps: float = 0.0
frame_time_ms: float = 0.0
dropped_frames: int = 0
active_layers: int = 0
preload_pending: int = 0
source_events: int = 0
class SourceHandle:
"""Verwaltet eine Medienquelle im Renderer (§12.5, §12.2).
Verbindet PlaybackController mit Parameterpfaden und einem PreloadSlot:
- Transport-Befehle aus dem StateMirror steuern den Controller
- Der PreloadSlot wechselt Clips atomar bei Commit
"""
def __init__(
self,
source_id: str,
parameter_prefix: str,
fps: float = 60.0,
) -> None:
self.source_id = source_id
self.parameter_prefix = parameter_prefix
self.controller = PlaybackController(source_id=source_id)
self.preload = PreloadSlot()
self._active_asset_id: str | None = None
self._fps = fps
self._last_events: list = []
@property
def active_asset_id(self) -> str | None:
return self._active_asset_id
@property
def position(self) -> float:
return self.controller.position
@property
def state(self) -> TransportState:
return self.controller.state
def sync_from_mirror(self, mirror: RemoteStateMirror) -> list:
"""Liest Transport-Befehle und Parameter aus dem StateMirror.
Rückgabe: PlaybackEvents dieses Sync-Schritts (§12.5).
"""
events: list = []
# Transport-State aus dem Mirror lesen (§11.2)
state_value = mirror.get_value(f"{self.parameter_prefix}/source/state")
if state_value is not None:
state_map = {
0: TransportState.STOPPED,
1: TransportState.PLAYING,
2: TransportState.PAUSED,
}
target_state = state_map.get(int(state_value))
if target_state is not None:
is_playing = target_state is TransportState.PLAYING
is_paused = target_state is TransportState.PAUSED
is_stopped = target_state is TransportState.STOPPED
if is_playing and self.controller.state is not TransportState.PLAYING:
self.controller.play()
elif is_paused and self.controller.state is TransportState.PLAYING:
self.controller.pause()
elif is_stopped and self.controller.state is not TransportState.STOPPED:
self.controller.stop()
# Playback-Parameter aus dem Mirror (§10.2)
speed = mirror.get_value(f"{self.parameter_prefix}/source/speed")
if speed is not None and speed != 0:
self.controller.speed = max(-4.0, min(4.0, speed))
in_point = mirror.get_value(f"{self.parameter_prefix}/source/in_point")
if in_point is not None:
self.controller.in_point = max(0.0, min(0.99, in_point))
out_point = mirror.get_value(f"{self.parameter_prefix}/source/out_point")
if out_point is not None:
self.controller.out_point = max(self.controller.in_point + 0.01, min(1.0, out_point))
# Retrigger: Flankenwert im Mirror (1.0 = triggern, danach zurück auf 0)
retrigger = mirror.get_value(f"{self.parameter_prefix}/source/retrigger")
if retrigger is not None and retrigger > 0.5:
self.controller.retrigger()
# Clip-Auswahl über PreloadSlot (§12.2, §16.5 Load/Commit)
pending_asset = mirror.get_value(f"{self.parameter_prefix}/source/asset_id_pending")
if pending_asset is not None and pending_asset > 0:
# Asset-ID ist als Hash/Integer im Mirror; real: UUID-String aus Registry
# Hier: Asset-Wechsel nur über Commit (PreloadSlot)
self.preload.preload(str(int(pending_asset)))
commit = mirror.get_value(f"{self.parameter_prefix}/source/commit")
if commit is not None and commit > 0.5:
self.preload.mark_ready(str(int(mirror.get_value(
f"{self.parameter_prefix}/source/asset_id_pending", 0
))))
committed = self.preload.commit()
if committed:
self._active_asset_id = committed # atomar gewechselt (§12.2)
self._last_events = events
return events
def advance(self, dt_s: float, now_ns: int) -> list:
"""Advancement des PlaybackControllers; liefert Events."""
return self.controller.advance(dt_s, now_ns)
class RenderEngine:
"""Zentrale Renderer-Engine: orchestriert alle Quellen pro Frame.
Ablauf pro Frame (§12.2: feste Master-Bildrate):
1. sync_from_mirror: Transport-Parameter aus dem StateMirror lesen
2. advance: Playback-Positionen um dt weiterschieben
3. snapshot: unveränderlichen Frame-Snapshot bilden
4. telemetry: Frame-Statistiken aktualisieren
"""
def __init__(self, mirror: RemoteStateMirror, fps: float = 60.0) -> None:
self._mirror = mirror
self._fps = fps
self._frame_duration_s = 1.0 / fps
self._sources: dict[str, SourceHandle] = {}
self._frame_index = 0
self._last_frame_ns = 0
self.telemetry = RenderTelemetry()
@property
def mirror(self) -> RemoteStateMirror:
return self._mirror
@property
def fps(self) -> float:
return self._fps
@property
def frame_index(self) -> int:
return self._frame_index
def add_source(self, source_id: str, parameter_prefix: str) -> SourceHandle:
"""Registriert eine Medienquelle im Renderer."""
handle = SourceHandle(source_id, parameter_prefix, self._fps)
self._sources[source_id] = handle
return handle
def remove_source(self, source_id: str) -> None:
self._sources.pop(source_id, None)
def get_source(self, source_id: str) -> SourceHandle | None:
return self._sources.get(source_id)
def sources(self) -> list[SourceHandle]:
return list(self._sources.values())
def tick(self, now_ns: int | None = None) -> FrameSnapshot:
"""Ein Frame: Sync → Advance → Snapshot.
Wird vom Renderer-Loop mit fester Master-Bildrate aufgerufen (§12.2).
"""
now = now_ns if now_ns is not None else time.monotonic_ns()
if self._last_frame_ns == 0:
self._last_frame_ns = now
dt_s = (now - self._last_frame_ns) / 1e9
self._last_frame_ns = now
# 1. Sync: Parameter aus dem Mirror lesen
all_events: list = []
for handle in self._sources.values():
handle.sync_from_mirror(self._mirror)
# 2. Advance: Playback weiterschieben
for handle in self._sources.values():
events = handle.advance(dt_s, now)
all_events.extend(events)
# 3. Snapshot: unveränderlicher Frame-Zustand (§11.4)
self._frame_index += 1
parameters = self._mirror.all_values()
source_positions: dict[str, float] = {}
source_states: dict[str, str] = {}
active_assets: dict[str, str | None] = {}
for source_id, handle in self._sources.items():
source_positions[source_id] = handle.position
source_states[source_id] = handle.state.value
active_assets[f"{handle.parameter_prefix}/source"] = handle.active_asset_id
snapshot = FrameSnapshot(
frame_index=self._frame_index,
monotonic_ns=now,
state_revision=self._mirror.revision,
parameters=parameters,
source_positions=source_positions,
source_states=source_states,
active_asset_ids=active_assets,
)
# 4. Telemetry (§28.2)
frame_time_ms = dt_s * 1000.0
active_count = sum(
1 for h in self._sources.values()
if h.state is TransportState.PLAYING
)
preload_count = sum(
1 for h in self._sources.values()
if h.preload.pending is not None
)
self.telemetry = RenderTelemetry(
frame_index=self._frame_index,
fps=1000.0 / max(frame_time_ms, 0.01),
frame_time_ms=frame_time_ms,
dropped_frames=0, # zählt der native Rendergraph
active_layers=active_count,
preload_pending=preload_count,
source_events=len(all_events),
)
return snapshot
@@ -0,0 +1,79 @@
"""Renderer-Pipeline-Definitionen (PLAN.md §12, §13, §36 Nr. 45).
Windows-Primärpfad (§12.6): d3d11h264dec → D3D11Memory → d3d11compositor
→ d3d11videosink. Kein CPU-Rundweg (Decoder → RAM → Upload verboten).
DevGL-Pfad: Nur für Entwicklung/CI-Umgebungen ohne D3D11; ausdrücklich
gekennzeichnet, kein stiller Ersatz im Normalbetrieb (§5.2, §33).
"""
from __future__ import annotations
import shutil
from dataclasses import dataclass
@dataclass(frozen=True)
class D3D11Pipeline:
"""Primärpipeline Windows: durchgängig D3D11Memory."""
video_a: str
video_b: str
width: int = 1920
height: int = 1080
fullscreen: bool = True
def launch_string(self) -> str:
sink = "d3d11videosink fullscreen=true" if self.fullscreen else "d3d11videosink"
# Zwei Quellen → Compositor → Ausgabe (§36 Nr. 5: zwei Videos GPU-mischen)
return (
f"d3d11compositor name=mix sink_0::xpos=0 sink_0::ypos=0 "
f"sink_0::width={self.width // 2} sink_0::height={self.height} "
f"sink_1::xpos={self.width // 2} sink_1::ypos=0 "
f"sink_1::width={self.width // 2} sink_1::height={self.height} ! "
f"d3d11convert ! video/x-raw(memory:D3D11Memory),format=RGBA,"
f"width={self.width},height={self.height} ! {sink} "
f"uridecodebin uri=file:///{self.video_a} ! queue ! "
f"d3d11convert ! mix. "
f"uridecodebin uri=file:///{self.video_b} ! queue ! "
f"d3d11convert ! mix."
)
@dataclass(frozen=True)
class DevGLPipeline:
"""Entwicklungspfad ohne D3D11 (explizit gekennzeichnet, kein Normalpfad).
Nur für CI/Dev ohne Windows-GPU; die Backend-Schnittstelle bleibt
identisch (§12.6)."""
video_a: str
video_b: str
width: int = 960
height: int = 540
def launch_string(self) -> str:
return (
f"glvideomixer name=mix ! glimagesink "
f"uridecodebin uri=file:///{self.video_a} ! queue ! glupload ! mix. "
f"uridecodebin uri=file:///{self.video_b} ! queue ! glupload ! mix."
)
def build_single_video_pipeline(video: str, d3d11: bool = True) -> str:
"""Minimale Einzelquellen-Pipeline (§36 Nr. 4: Testvideo → Vollbild)."""
if d3d11:
return f"uridecodebin uri=file:///{video} ! d3d11convert ! d3d11videosink fullscreen=true"
return f"uridecodebin uri=file:///{video} ! glupload ! glimagesink"
def build_compositor_pipeline(video_a: str, video_b: str, d3d11: bool = True) -> str:
"""Zwei-Quellen-Compositing (§36 Nr. 5: GPU-Mischen ohne CPU-Readback)."""
if d3d11:
return D3D11Pipeline(video_a=video_a, video_b=video_b).launch_string()
return DevGLPipeline(video_a=video_a, video_b=video_b).launch_string()
def gst_available() -> bool:
"""True, wenn ein GStreamer-CLI auf dem System liegt (Diagnose, kein Fake)."""
return shutil.which("gst-launch-1.0") is not None
@@ -0,0 +1,412 @@
#!/usr/bin/env python3
"""HMS MediaEngine Echter GStreamer-Renderer.
Läuft mit System-Python (hat GStreamer-Bindings) und spielt VIDEOS WIRKLICH ab.
Kommuniziert über IPC mit dem Control Core.
Dieser Prozess:
1. Empfängt Befehle über IPC (MessagePack über TCP)
2. Startet/stoppt/steuert GStreamer-Pipelines
3. Mixed Videos mit videomixer/compositor
4. Sendet Frame-Snapshots zurück
5. Erzeugt einen MJPEG-Preview-Stream für den Browser
"""
import json
import os
import signal
import socket
import struct
import sys
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer
# GStreamer muss vor allem anderen importiert werden
import gi
gi.require_version("Gst", "1.0")
gi.require_version("GstVideo", "1.0")
from gi.repository import Gst, GstVideo
Gst.init(None)
class MJPEGPreview:
"""HTTP-Server, der einen MJPEG-Stream aus GStreamer empfängt und an Browser sendet."""
def __init__(self, port=8090):
self.port = port
self.running = False
self.current_jpeg = b""
self.clients = []
self.server = None
self.pipeline = None
def start_pipeline(self, width=640, height=360):
"""Erzeugt eine GStreamer-Pipeline, die Frames als JPEG encodiert."""
pipeline_str = (
f"appsrc name=preview_src is-live=true format=time "
f"caps=video/x-raw,format=RGB,width={width},height={height},framerate=15/1 ! "
f"videoconvert ! jpegenc quality=80 ! "
f"multifilesink location=/dev/null"
)
# Einfacher: appsink der JPEG-Frames liefert
self.pipeline = Gst.parse_launch(
f"videotestsrc is-live=true pattern=2 ! "
f"video/x-raw,width={width},height={height},framerate=15/1 ! "
f"videoconvert ! jpegenc ! appsink name=preview_sink emit-signals=true"
)
sink = self.pipeline.get_by_name("preview_sink")
sink.connect("new-sample", self._on_new_sample)
self.pipeline.set_state(Gst.State.PLAYING)
def _on_new_sample(self, sink):
"""Empfängt JPEG-Frames vom appsink."""
sample = sink.emit("pull-sample")
if sample:
buf = sample.get_buffer()
self.current_jpeg = buf.extract_dup(0, buf.get_size())
return Gst.FlowReturn.OK
def start_server(self):
"""Startet den HTTP-Server für den MJPEG-Stream."""
handler = self._make_handler()
self.server = HTTPServer(("0.0.0.0", self.port), handler)
self.running = True
thread = threading.Thread(target=self.server.serve_forever, daemon=True)
thread.start()
print(f"Preview-Stream: http://0.0.0.0:{self.port}/stream.mjpg")
def _make_handler(self):
preview = self
class StreamHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/stream.mjpg":
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
while preview.running:
if preview.current_jpeg:
self.wfile.write(b"--frame\r\n")
self.wfile.write(b"Content-Type: image/jpeg\r\n")
self.wfile.write(f"Content-Length: {len(preview.current_jpeg)}\r\n\r\n")
self.wfile.write(preview.current_jpeg)
self.wfile.write(b"\r\n")
time.sleep(1.0 / 15) # 15 fps
elif self.path == "/":n self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body style='margin:0;background:#000'>"
b"<img src='/stream.mjpg' style='width:100vw;height:100vh;object-fit:contain' /></body></html>"
)
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress log spam
return StreamHandler
def stop(self):
self.running = False
if self.server:
self.server.shutdown()
if self.pipeline:
self.pipeline.set_state(Gst.State.NULL)
class MediaPipeline:
"""Eine echte GStreamer-Pipeline für eine Videoquelle."""
def __init__(self, video_path: str, x=0, y=0, width=640, height=360, zorder=0):
self.video_path = video_path
self.pipeline = None
self.x = x
self.y = y
self.width = width
self.height = height
self.zorder = zorder
self.opacity = 1.0
self.playing = False
def build(self, output_port=5000):
"""Baut eine echte Pipeline: decodebin -> scale -> position -> UDP."""
uri = f"file://{os.path.abspath(self.video_path)}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! videoscale ! "
f"video/x-raw,width={self.width},height={self.height} ! "
f"videoflip method=none ! "
f"rtpjpegenc ! "
f"udpsink host=127.0.0.1 port={output_port} sync=true"
)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Pipeline-Fehler: {e}")
return False
def build_preview(self, jpeg_sink_name="preview"):
"""Baut eine Pipeline für den Browser-Preview (JPEG-Appsink)."""
uri = f"file://{os.path.abspath(self.video_path)}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! videoscale ! "
f"video/x-raw,width=320,height=180 ! "
f"jpegenc ! appsink name={jpeg_sink_name} emit-signals=true"
)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Preview-Pipeline-Fehler: {e}")
return False
def play(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.PLAYING)
self.playing = True
def pause(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.PAUSED)
self.playing = False
def stop(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.NULL)
self.playing = False
def set_opacity(self, value):
"""Setzt Opacity über volume-Element oder Alpha-Channel."""
self.opacity = max(0.0, min(1.0, value))
def get_position(self):
"""Aktuelle Position in Nanosekunden."""
if self.pipeline:
ok, pos = self.pipeline.query_position(Gst.Format.TIME)
if ok:
return pos
return 0
def get_duration(self):
"""Dauer in Nanosekunden."""
if self.pipeline:
ok, dur = self.pipeline.query_duration(Gst.Format.TIME)
if ok:
return dur
return 0
class CompositorPipeline:
"""Mischt mehrere Videoquellen zu einem Composite."""
def __init__(self, width=1280, height=720):
self.width = width
self.height = height
self.sources = {} # name -> sub-pipeline description
self.pipeline = None
def build(self, video_paths: list[str]):
"""Baut eine videomixer-Pipeline mit mehreren Quellen."""
if not video_paths:
return False
# Für eine Quelle: direkt abspielen
if len(video_paths) == 1:
uri = f"file://{os.path.abspath(video_paths[0])}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! "
f"ximagesink sync=false" # Für Container ohne Display: fakesink
)
else:
# Mehrere Quellen mit videomixer
parts = []
for i, path in enumerate(video_paths):
uri = f"file://{os.path.abspath(path)}"
x = (i % 2) * (self.width // 2)
y = (i // 2) * (self.height // 2)
parts.append(
f"uridecodebin uri={uri} ! videoconvert ! videoscale ! "
f"video/x-raw,width={self.width // 2},height={self.height // 2} ! "
f"videomixer name=mix sink_{i}::xpos={x} sink_{i}::ypos={y} "
f"sink_{i}::zorder={i}"
)
parts.append(
f"mix. ! videoconvert ! ximagesink sync=false"
)
pipeline_str = " ".join(parts)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Compositor-Fehler: {e}")
# Fallback: Einfache Test-Quelle
self.pipeline = Gst.parse_launch(
"videotestsrc is-live=true pattern=1 ! "
f"video/x-raw,width={self.width},height={self.height} ! "
"ximagesink sync=false"
)
return self.pipeline is not None
def build_with_preview(self, video_paths: list[str]):
"""Baut Pipeline mit JPEG-Appsink für Browser-Preview."""
if not video_paths:
pipeline_str = (
"videotestsrc is-live=true pattern=1 ! "
f"video/x-raw,width={self.width},height={self.height} ! "
f"videoscale ! video/x-raw,width=640,height=360 ! "
"videoconvert ! jpegenc ! appsink name=preview_sink emit-signals=true"
)
elif len(video_paths) == 1:
uri = f"file://{os.path.abspath(video_paths[0])}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! videoscale ! "
f"video/x-raw,width=640,height=360 ! "
f"jpegenc ! appsink name=preview_sink emit-signals=true"
)
else:
parts = []
for i, path in enumerate(video_paths):
uri = f"file://{os.path.abspath(path)}"
x = (i % 2) * 320
y = (i // 2) * 180
parts.append(
f"uridecodebin uri={uri} ! videoconvert ! videoscale ! "
f"video/x-raw,width=320,height=180 ! "
f"videomixer name=mix sink_{i}::xpos={x} sink_{i}::ypos={y}"
)
parts.append(
f"mix. ! videoconvert ! videoscale ! "
f"video/x-raw,width=640,height=360 ! "
f"jpegenc ! appsink name=preview_sink emit-signals=true"
)
pipeline_str = " ".join(parts)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Preview-Compositor-Fehler: {e}")
return False
def play(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.PLAYING)
def stop(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.NULL)
class Renderer:
"""Haupt-Renderer: verwaltet Compositor-Pipeline und Preview-Server."""
def __init__(self, preview_port=8090):
self.preview_port = preview_port
self.compositor = None
self.preview = MJPEGPreview(preview_port)
self.video_paths = []
self.running = False
def start(self, video_paths=None):
"""Startet den Renderer mit den gegebenen Videos."""
self.video_paths = video_paths or []
# Compositor-Pipeline mit Preview bauen
self.compositor = CompositorPipeline(width=1280, height=720)
if self.compositor.build_with_preview(self.video_paths):
# Preview-Sink verbinden
if self.compositor.pipeline:
sink = self.compositor.pipeline.get_by_name("preview_sink")
if sink:
sink.connect("new-sample", self._on_preview_frame)
# Pipeline starten
self.compositor.play()
# HTTP-Server für MJPEG starten
self.preview.start_server()
self.running = True
print(f"Renderer gestartet: {len(self.video_paths)} Video(s)")
print(f"Preview: http://0.0.0.0:{self.preview_port}/")
return True
return False
def _on_preview_frame(self, sink):
"""Empfängt JPEG-Frames für den Browser-Preview."""
sample = sink.emit("pull-sample")
if sample:
buf = sample.get_buffer()
self.preview.current_jpeg = buf.extract_dup(0, buf.get_size())
return Gst.FlowReturn.OK
def stop(self):
if self.compositor:
self.compositor.stop()
self.preview.stop()
self.running = False
def add_video(self, path):
"""Fügt ein Video hinzu (neu starten mit erweitertem Set)."""
self.video_paths.append(path)
self.stop()
self.start(self.video_paths)
def status(self):
return {
"running": self.running,
"videos": self.video_paths,
"preview_port": self.preview_port,
}
def main():
"""Haupteinstieg: Startet den Renderer mit Testvideo."""
# Testvideo erstellen wenn keins existiert
test_video = "/tmp/test_video.mp4"
if not os.path.exists(test_video):
print("Erstelle Testvideo...")
gen = Gst.parse_launch(
"videotestsrc pattern=ball num-buffers=300 ! "
"video/x-raw,width=640,height=480,framerate=30/1 ! "
"x264enc tune=zerolatency ! mp4mux ! "
f"filesink location={test_video}"
)
gen.set_state(Gst.State.PLAYING)
bus = gen.get_bus()
bus.timed_pop_filtered(Gst.CLOCK_TIME_NONE, Gst.MessageType.EOS)
gen.set_state(Gst.State.NULL)
print(f"Testvideo erstellt: {test_video}")
renderer = Renderer(preview_port=8090)
renderer.start([test_video])
# Hauptschleife
print("\nRenderer läuft.")
print(f" Preview im Browser: http://localhost:8090/")
print(f" Videos: {renderer.video_paths}")
print("\nDrücke Ctrl+C zum Beenden.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
renderer.stop()
print("Renderer beendet.")
if __name__ == "__main__":
main()
@@ -0,0 +1,96 @@
"""Remote-State-Mirror auf der Renderer-Seite (PLAN.md §6.2, §6.4).
Der Renderer hält den zuletzt übermittelten Showzustand als lokales
Spiegelbild:
- nach Verbindung: vollständiger Snapshot (§6.2 Pflicht)
- danach inkrementelle Deltas mit monotoner Revision
- Deltas werden erst nach erfolgtem Snapshot akzeptiert (§6.2:
„Deltas erst nach erfolgreichem Re-Sync")
- veraltete/doppelte Deltas sind idempotente No-Ops (§6.5)
Der Mirror ist reine Zustandslogik ohne Pixelbezug (§33); der Rendergraph
liest Werte über get_value() je Frame.
"""
from __future__ import annotations
class RemoteStateMirror:
"""Spiegel des autoritativen Showzustands im Renderer."""
def __init__(self) -> None:
self._revision = 0
self._values: dict[str, float] = {}
self._has_snapshot = False
@property
def revision(self) -> int:
return self._revision
@property
def has_snapshot(self) -> bool:
"""True, nachdem ein vollständiger Snapshot empfangen wurde."""
return self._has_snapshot
def get_value(self, path: str, default: float | None = None) -> float | None:
"""Wirksamer Wert für einen Parameterpfad (§10.2)."""
return self._values.get(path, default)
def all_values(self) -> dict[str, float]:
return dict(self._values)
# ---------- Snapshot / Delta (§6.2, §6.4) ----------
def apply_snapshot(self, payload: dict) -> None:
"""Übernimmt einen vollständigen Snapshot (nach Verbindung/Reconnect)."""
values = payload.get("values", {})
if not isinstance(values, dict):
raise ValueError("Snapshot ohne Werte-Objekt")
self._revision = int(payload["state_revision"])
self._values = {str(k): float(v) for k, v in values.items()}
self._has_snapshot = True
def apply_delta(self, payload: dict) -> bool:
"""Wendet ein Delta an.
Rückgabe:
- True: Delta angewendet ODER als veraltetes Duplikat ignoriert
- False: Re-Sync nötig (noch kein Snapshot empfangen, §6.2)
Gelöschte Pfade sind als None kodiert (StateDelta-Vertrag).
"""
if not self._has_snapshot:
return False # §6.2: Deltas erst nach Snapshot
delta_revision = int(payload["state_revision"])
if delta_revision <= self._revision:
return True # idempotent: Duplikat/veraltet, kein Handlungsbedarf
changes = payload.get("changes", {})
if not isinstance(changes, dict):
raise ValueError("Delta ohne Changes-Objekt")
for path, value in changes.items():
if value is None:
self._values.pop(str(path), None) # gelöscht
else:
self._values[str(path)] = float(value)
self._revision = delta_revision
return True
def apply_envelope(mirror: RemoteStateMirror, envelope) -> bool:
"""Verarbeitet ein IPC-Envelope in den Mirror.
- SNAPSHOT: vollständige Übernahme
- EVENT mit changes: Delta-Anwendung
- alles andere (Heartbeat, Ack, …): keine Zustandswirkung
Rückgabe: True, wenn der Zustand dadurch (re-)synchronisiert wurde;
False, wenn ein Re-Sync (neuer Snapshot) angefordert werden muss.
"""
from hms_protocol import MessageType
if envelope.type is MessageType.SNAPSHOT:
mirror.apply_snapshot(envelope.payload)
return True
if envelope.type is MessageType.EVENT and "changes" in envelope.payload:
return mirror.apply_delta(envelope.payload)
return True # keine Zustandsnachricht: nichts zu tun
View File
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>HMS MediaEngine</title>
<style>
/* Vollbild ohne Scroll auf dunklem Anthrazit (§17.2, §17.6). */
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #0d0f12;
color-scheme: dark;
}
#root {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "hms-mediaengine-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.0.1",
"typescript": "^7.0.2",
"vite": "^8.2.2",
"vitest": "^5.0.0"
}
}
File diff suppressed because it is too large Load Diff
+605
View File
@@ -0,0 +1,605 @@
/**
* HMS MediaEngine MVP-Workspace (§17.2).
*
* CSS-Grid: obere Statusleiste (32 px), Hauptbereich mit linker
* Werkzeugleiste (48 px, einklappbar), mittlerer Arbeitsbereich
* (Layer-Tabelle, §17.3), unterem Inspector (~30 %, skalier- und
* einklappbar, §17.4) und rechter, umschaltbarer Seitenleiste (§17.5)
* sowie Fußleiste (24 px) mit Setup/Live-Lock und Panel-Schaltern.
*
* Datenfluss: WebSocket liefert Snapshot + Deltas (§17.9); REST-Endpoints
* werden gepollt; Opacity-Commits laufen als parameter.set mit
* expected_revision über die Engine (§10.2, §23.2) der Server bleibt
* autoritativ, ein Browser-Neustart stoppt den Output nicht (§3.2).
* Kein GO-Button, keine Standby-Anzeige, keine Cue-Logik (§17.1).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentType, PointerEvent as ReactPointerEvent } from 'react';
import type {
AlertItem,
AlertSeverity,
ClusterNode,
DiagnosticsResponse,
FxSlot,
HealthResponse,
IdentityResponse,
InspectorTab,
Layer,
OpMode,
SidebarTab,
ToolId,
} from './types';
import { SEED_COMPOSITION_ID, SEED_LAYERS } from './seed';
import { useWebSocket } from './hooks/useWebSocket';
import {
ApiError,
getClusterNodes,
getDiagnostics,
getHealth,
getIdentity,
releaseCommand,
sendParameterSet,
} from './hooks/useApi';
import StatusBar, { type StatusToneItem } from './components/StatusBar';
import LayerTable from './components/LayerTable';
import Inspector, { type InspectorPatch } from './components/Inspector';
import RightSidebar from './components/RightSidebar';
import type { IconProps } from './icons';
import {
IconAlertTriangle,
IconChevronLeft,
IconChevronRight,
IconCrossCircle,
IconDiagnostics,
IconEffects,
IconInspectorPanel,
IconLock,
IconMedia,
IconMixer,
IconNetwork,
IconOutputs,
IconPlugins,
IconPresets,
IconSidebarPanel,
IconUnlock,
} from './icons';
import {
clamp,
cx,
engineToOpacity,
errorMessage,
formatClock,
layerOpacityPath,
opacityToEngine,
} from './utils';
const PROJECT_NAME = 'Demo-Show'; // Projekt-API folgt in späterer Phase
const INSPECTOR_MIN = 96;
const INSPECTOR_MAX = 520;
const POLL_MS = 5000;
interface ToolDef {
readonly id: ToolId;
readonly label: string;
readonly icon: ComponentType<IconProps>;
}
const TOOLS: readonly ToolDef[] = [
{ id: 'mixer', label: 'Mixer', icon: IconMixer },
{ id: 'media', label: 'Media', icon: IconMedia },
{ id: 'effects', label: 'Effekte', icon: IconEffects },
{ id: 'presets', label: 'Presets', icon: IconPresets },
{ id: 'outputs', label: 'Outputs', icon: IconOutputs },
{ id: 'network', label: 'Netzwerk', icon: IconNetwork },
{ id: 'plugins', label: 'Plugins', icon: IconPlugins },
{ id: 'diagnostics', label: 'Diagnostics', icon: IconDiagnostics },
];
export default function App() {
/* ---------- Verbindungen ---------- */
const ws = useWebSocket();
/* ---------- Workspace-Zustand ---------- */
const [layers, setLayers] = useState<readonly Layer[]>(SEED_LAYERS);
const [selectedId, setSelectedId] = useState<string | null>(
SEED_LAYERS.length > 0 ? SEED_LAYERS[0].id : null,
);
const [cursorId, setCursorId] = useState<string | null>(
SEED_LAYERS.length > 0 ? SEED_LAYERS[0].id : null,
);
const [tool, setTool] = useState<ToolId>('mixer');
const [toolbarCollapsed, setToolbarCollapsed] = useState(false);
const [inspectorCollapsed, setInspectorCollapsed] = useState(false);
const [inspectorHeight, setInspectorHeight] = useState(264);
const [inspectorResizing, setInspectorResizing] = useState(false);
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('layer');
const [sidebarVisible, setSidebarVisible] = useState(true);
const [sidebarTab, setSidebarTab] = useState<SidebarTab>('active');
const [opMode, setOpMode] = useState<OpMode>('setup');
const liveLock = opMode === 'live';
/* ---------- Meldungen ---------- */
const [alerts, setAlerts] = useState<readonly AlertItem[]>([]);
const alertSeqRef = useRef(0);
const pushAlert = useCallback((severity: AlertSeverity, message: string): void => {
alertSeqRef.current += 1;
setAlerts(prev => [...prev, { id: alertSeqRef.current, timeMs: Date.now(), severity, message }].slice(-100));
}, []);
/* ---------- REST-Daten (Control Core, app.py) ---------- */
const [identity, setIdentity] = useState<IdentityResponse | null>(null);
const [nodes, setNodes] = useState<readonly ClusterNode[]>([]);
const [selfNodeId, setSelfNodeId] = useState<string | null>(null);
const [diagnostics, setDiagnostics] = useState<DiagnosticsResponse | null>(null);
const [health, setHealth] = useState<HealthResponse | null>(null);
useEffect(() => {
let disposed = false;
const poll = async (): Promise<void> => {
const [identRes, nodesRes, diagRes, healthRes] = await Promise.allSettled([
getIdentity(),
getClusterNodes(),
getDiagnostics(),
getHealth(),
]);
if (disposed) return;
if (identRes.status === 'fulfilled') setIdentity(identRes.value);
if (nodesRes.status === 'fulfilled') {
setNodes(nodesRes.value.nodes);
setSelfNodeId(nodesRes.value.self);
}
if (diagRes.status === 'fulfilled') setDiagnostics(diagRes.value);
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
};
void poll();
const timer = window.setInterval(() => void poll(), POLL_MS);
return () => {
disposed = true;
window.clearInterval(timer);
};
}, []);
/* ---------- Uhr und UI-FPS (Renderer-Metrik folgt mit IPC) ---------- */
const [now, setNow] = useState<Date>(() => new Date());
useEffect(() => {
const t = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(t);
}, []);
const [uiFps, setUiFps] = useState<number | null>(null);
useEffect(() => {
let raf = 0;
let frames = 0;
let last = performance.now();
const tick = (ts: number): void => {
frames += 1;
if (ts - last >= 1000) {
setUiFps((frames * 1000) / (ts - last));
frames = 0;
last = ts;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
/* ---------- WS-Verbindungsübergänge als Meldung ---------- */
const prevConnectedRef = useRef<boolean | null>(null);
useEffect(() => {
const prev = prevConnectedRef.current;
prevConnectedRef.current = ws.connected;
if (prev === null) return; // erster Mount: kein Übergang
if (prev && !ws.connected) {
pushAlert('warning', 'Verbindung zum Control Core verloren Reconnect mit Backoff läuft.');
} else if (!prev && ws.connected) {
pushAlert('info', 'Control Core verbunden Zustand synchronisiert.');
}
}, [ws.connected, pushAlert]);
useEffect(() => {
pushAlert(
'info',
'MVP-Workspace: Layer-Struktur ist Demo-Platzhalter (seed.ts); Opacity läuft echt über die Parameter-Engine (§10.2, §11).',
);
}, [pushAlert]);
/* ---------- Layer-Zustand + echte Opacity aus der Engine ---------- */
const mergedLayers = useMemo<readonly Layer[]>(() => {
if (Object.keys(ws.values).length === 0) return layers;
return layers.map(l => {
const path = layerOpacityPath(SEED_COMPOSITION_ID, l.id);
const engineValue = ws.values[path];
return engineValue === undefined ? l : { ...l, opacity: engineToOpacity(engineValue) };
});
}, [layers, ws.values]);
const selectedLayer = useMemo<Layer | null>(
() => mergedLayers.find(l => l.id === selectedId) ?? null,
[mergedLayers, selectedId],
);
/* ---------- Opacity: lokales Feedback + serverbestätigter Commit ---------- */
const dragOriginRef = useRef<Map<string, number>>(new Map());
const lastOpacityCmdRef = useRef<Map<string, string>>(new Map());
const handleOpacityLocal = useCallback((id: string, value: number): void => {
setLayers(prev =>
prev.map(l => {
if (l.id !== id) return l;
if (!dragOriginRef.current.has(id)) {
dragOriginRef.current.set(id, l.opacity);
}
return { ...l, opacity: value };
}),
);
}, []);
const handleOpacityCommit = useCallback(
async (id: string, value: number): Promise<void> => {
const path = layerOpacityPath(SEED_COMPOSITION_ID, id);
try {
const ack = await sendParameterSet({
path,
value: opacityToEngine(value),
expectedRevision: ws.revision,
});
lastOpacityCmdRef.current.set(id, ack.command_id);
} catch (err) {
// Server bleibt autoritativ (§17.9): lokales Feedback zurückrollen.
const origin = dragOriginRef.current.get(id);
if (origin !== undefined) {
setLayers(prev => prev.map(l => (l.id === id ? { ...l, opacity: origin } : l)));
}
if (err instanceof ApiError && err.status === 409) {
pushAlert('warning', 'Revision-Konflikt: ein anderer Client hat inzwischen geändert.');
} else {
pushAlert('error', `Opacity nicht übernommen: ${errorMessage(err)}`);
}
} finally {
dragOriginRef.current.delete(id);
}
},
[ws.revision, pushAlert],
);
const handleReleaseOpacity = useCallback(
async (layerId: string): Promise<void> => {
const commandId = lastOpacityCmdRef.current.get(layerId);
if (commandId === undefined) {
pushAlert('info', 'Kein Web-Override für die Opacity dieses Layers vorhanden.');
return;
}
try {
const res = await releaseCommand(commandId);
pushAlert('info', `Release ausgeführt Server: ${res.status}`);
} catch (err) {
pushAlert('error', `Release fehlgeschlagen: ${errorMessage(err)}`);
}
},
[pushAlert],
);
/* ---------- Umbenennen und Inspector-Patches (lokal bis Layer-API) ---------- */
const handleRename = useCallback((id: string, name: string): void => {
setLayers(prev => prev.map(l => (l.id === id ? { ...l, name } : l)));
}, []);
const applyPatch = useCallback(
(patch: InspectorPatch): void => {
if (selectedId === null) return;
setLayers(prev =>
prev.map((l): Layer => {
if (l.id !== selectedId) return l;
let next: Layer = l;
if (patch.name !== undefined) next = { ...next, name: patch.name };
if (patch.blend !== undefined) next = { ...next, blend: patch.blend };
if (patch.loopMode !== undefined) next = { ...next, loopMode: patch.loopMode };
if (patch.timeDurationSec !== undefined) {
next = { ...next, timeDurationSec: Math.max(0, patch.timeDurationSec) };
}
if (patch.generatorPhase !== undefined) {
next = { ...next, generatorPhase: patch.generatorPhase };
}
if (patch.transformX !== undefined) {
next = { ...next, transform: { ...next.transform, x: patch.transformX } };
}
if (patch.transformY !== undefined) {
next = { ...next, transform: { ...next.transform, y: patch.transformY } };
}
if (patch.transformScale !== undefined) {
next = { ...next, transform: { ...next.transform, scale: patch.transformScale } };
}
if (patch.transformRotation !== undefined) {
next = { ...next, transform: { ...next.transform, rotation: patch.transformRotation } };
}
if (patch.colorBrightness !== undefined) {
next = { ...next, color: { ...next.color, brightness: patch.colorBrightness } };
}
if (patch.colorContrast !== undefined) {
next = { ...next, color: { ...next.color, contrast: patch.colorContrast } };
}
if (patch.colorSaturation !== undefined) {
next = { ...next, color: { ...next.color, saturation: patch.colorSaturation } };
}
if (patch.colorHue !== undefined) {
next = { ...next, color: { ...next.color, hue: patch.colorHue } };
}
if (patch.fxBypass !== undefined) {
const [idx, bypass] = patch.fxBypass;
if (idx !== null) {
const slot = next.fxSlots[idx];
if (slot !== null && slot.state !== 'error') {
const slots: [FxSlot | null, FxSlot | null] = [next.fxSlots[0], next.fxSlots[1]];
slots[idx] = { ...slot, state: bypass ? 'bypassed' : 'active' };
next = { ...next, fxSlots: slots };
}
}
}
if (patch.dmxUniverse !== undefined) {
next = { ...next, dmx: { ...next.dmx, universe: patch.dmxUniverse } };
}
if (patch.dmxAddress !== undefined) {
next = { ...next, dmx: { ...next.dmx, address: patch.dmxAddress } };
}
return next;
}),
);
},
[selectedId],
);
/* ---------- Inspector-Resize (Divider, §17.2) ---------- */
const resizeStartRef = useRef<{ readonly y: number; readonly h: number } | null>(null);
const handleInspectorResizeStart = useCallback(
(e: ReactPointerEvent<HTMLDivElement>): void => {
e.preventDefault();
resizeStartRef.current = { y: e.clientY, h: inspectorHeight };
setInspectorResizing(true);
},
[inspectorHeight],
);
useEffect(() => {
if (!inspectorResizing) return;
const onMove = (e: PointerEvent): void => {
const start = resizeStartRef.current;
if (start === null) return;
const max = Math.min(INSPECTOR_MAX, Math.max(INSPECTOR_MIN, window.innerHeight - 320));
setInspectorHeight(clamp(start.h + (start.y - e.clientY), INSPECTOR_MIN, max));
};
const onUp = (): void => {
resizeStartRef.current = null;
setInspectorResizing(false);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, [inspectorResizing]);
/* ---------- Setup/Live-Lock (§17.7) ---------- */
const toggleOpMode = (): void => {
if (opMode === 'setup') {
setOpMode('live');
return;
}
// Verlassen von Live ist strukturell und wird bestätigt (§17.7).
const leave = window.confirm('Live-Modus verlassen? Strukturänderungen werden wieder freigegeben.');
if (leave) setOpMode('setup');
};
/* ---------- Abgeleitete Statusanzeigen ---------- */
const outputStatus = useMemo<StatusToneItem>(() => {
if (diagnostics === null) return { tone: 'muted', label: 'Output —' };
if (diagnostics.renderer === 'not_connected') {
return { tone: 'warn', label: 'Renderer offline' };
}
return { tone: 'ok', label: 'Output aktiv' };
}, [diagnostics]);
const artnetStatus = useMemo<StatusToneItem>(() => {
if (diagnostics === null) return { tone: 'muted', label: 'Art-Net —' };
if (diagnostics.artnet === 'not_started') return { tone: 'muted', label: 'Art-Net aus' };
return { tone: 'ok', label: 'Art-Net Empfang' };
}, [diagnostics]);
const syncStatus = useMemo<StatusToneItem>(
() =>
ws.connected
? { tone: 'ok', label: `Sync r${ws.revision ?? '—'}` }
: { tone: 'bad', label: 'Getrennt' },
[ws.connected, ws.revision],
);
const warnTotal =
mergedLayers.filter(l => l.status === 'warning' || l.status === 'out_of_sync').length +
alerts.filter(a => a.severity === 'warning').length;
const errTotal =
mergedLayers.filter(l => l.status === 'error' || l.status === 'offline').length +
alerts.filter(a => a.severity === 'error').length;
const nodeFps = useMemo<ReadonlyMap<string, number>>(() => new Map(), []);
const artnetSenders = useMemo<ReadonlyMap<string, string>>(() => new Map(), []);
/* ---------- Render ---------- */
return (
<div className="workspace">
<StatusBar
projectName={PROJECT_NAME}
nodeName={identity?.display_name ?? '—'}
output={outputStatus}
quality="Auto"
fps={uiFps}
artnet={artnetStatus}
sync={syncStatus}
clock={formatClock(now)}
/>
<div className="main">
<nav className={cx('toolbar', toolbarCollapsed && 'collapsed')} aria-label="Werkzeugleiste">
{TOOLS.map(t => {
const Icon = t.icon;
return (
<button
key={t.id}
type="button"
className={cx('tool-btn', tool === t.id && 'active')}
title={t.label}
aria-label={t.label}
aria-pressed={tool === t.id}
onClick={() => setTool(t.id)}
>
<Icon size={18} />
</button>
);
})}
<span className="tool-spacer" />
<button
type="button"
className="collapse-btn"
title={toolbarCollapsed ? 'Werkzeugleiste ausklappen' : 'Werkzeugleiste einklappen'}
aria-label={toolbarCollapsed ? 'Werkzeugleiste ausklappen' : 'Werkzeugleiste einklappen'}
onClick={() => setToolbarCollapsed(v => !v)}
>
{toolbarCollapsed ? <IconChevronRight size={14} /> : <IconChevronLeft size={14} />}
</button>
</nav>
<div className="center">
<LayerTable
layers={mergedLayers}
selectedId={selectedId}
cursorId={cursorId}
liveLock={liveLock}
onSelect={setSelectedId}
onCursor={setCursorId}
onRename={handleRename}
onOpacityLocal={handleOpacityLocal}
onOpacityCommit={(id, value) => {
void handleOpacityCommit(id, value);
}}
onEnterPressed={() => {
if (inspectorCollapsed) setInspectorCollapsed(false);
}}
/>
<Inspector
layer={selectedLayer}
activeTab={inspectorTab}
onTabChange={setInspectorTab}
collapsed={inspectorCollapsed}
height={inspectorHeight}
liveLock={liveLock}
onToggleCollapse={() => setInspectorCollapsed(v => !v)}
onResizeStart={handleInspectorResizeStart}
resizing={inspectorResizing}
onPatch={applyPatch}
onReleaseOpacity={id => {
void handleReleaseOpacity(id);
}}
/>
</div>
{sidebarVisible && (
<RightSidebar
tab={sidebarTab}
onTabChange={setSidebarTab}
layers={mergedLayers}
nodes={nodes}
selfNodeId={selfNodeId}
alerts={alerts}
selectedLayerId={selectedId}
onSelectLayer={id => {
setSelectedId(id);
setCursorId(id);
}}
onClearAlerts={() => setAlerts([])}
nodeFps={nodeFps}
artnetSenders={artnetSenders}
/>
)}
</div>
<footer className="footer">
<button
type="button"
className="toggle-btn"
aria-pressed={liveLock}
title={
liveLock
? 'Live-Modus: Strukturänderungen gesperrt, Layerparameter nutzbar (§17.7)'
: 'Setup-Modus: Struktur, Patch, Outputs und Netzwerk änderbar (§17.7)'
}
onClick={toggleOpMode}
>
{liveLock ? <IconLock size={11} /> : <IconUnlock size={11} />}
{liveLock ? ' Live' : ' Setup'}
</button>
<span className="foot-item">
{mergedLayers.length} Layer{selectedLayer !== null ? ' · 1 ausgewählt' : ''}
</span>
{warnTotal > 0 && (
<span className="foot-item foot-warn" title="Layer und Meldungen im Warnzustand">
<IconAlertTriangle size={11} />
{warnTotal} Warnung{warnTotal !== 1 ? 'en' : ''}
</span>
)}
{errTotal > 0 && (
<span className="foot-item foot-bad" title="Layer und Meldungen im Fehlerzustand">
<IconCrossCircle size={11} />
{errTotal} Fehler
</span>
)}
<span
className={cx('foot-item', ws.connected ? 'tone-ok' : 'foot-bad')}
title={
ws.connected
? 'WebSocket zum Control Core verbunden'
: 'Keine Verbindung zum Control Core Reconnect mit Backoff läuft'
}
>
<span className={cx('dot', ws.connected ? 'tone-ok' : 'tone-bad')} />
{ws.connected ? `Sync · Revision ${ws.revision ?? '—'}` : 'Getrennt'}
</span>
{health !== null && (
<span className="foot-item" title={`Control Core: ${health.status} · Phase ${health.phase}`}>
Core {health.status}
</span>
)}
<span className="foot-spacer" />
<button
type="button"
className="toggle-btn"
aria-pressed={!inspectorCollapsed}
title="Inspector ein-/ausklappen"
onClick={() => setInspectorCollapsed(v => !v)}
>
<IconInspectorPanel size={11} /> Inspector
</button>
<button
type="button"
className="toggle-btn"
aria-pressed={sidebarVisible}
title="Seitenleiste ein-/ausblenden"
onClick={() => setSidebarVisible(v => !v)}
>
<IconSidebarPanel size={11} /> Seitenleiste
</button>
</footer>
</div>
);
}
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import LayerTable from '../components/LayerTable';
import type { Layer } from '../types';
function makeLayer(overrides: Partial<Layer> = {}): Layer {
return {
id: 'layer-1',
z: 1,
name: 'Test Layer',
type: 'media',
status: 'active',
sourceName: 'Clip A',
sourceBank: 'B1·04',
target: 'Out 1',
timePositionSec: 0,
timeDurationSec: 10,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'web' },
loopMode: 'Loop',
generatorPhase: 0,
transform: { x: 0, y: 0, scale: 100, rotation: 0 },
color: { brightness: 100, contrast: 100, saturation: 100, hue: 0 },
dmx: { universe: 0, address: 1, mode: 'rgb' },
...overrides,
};
}
const noop = () => {};
function renderTable(layers: Layer[], selectedId: string | null = null) {
return render(
<LayerTable
layers={layers}
selectedId={selectedId}
cursorId={null}
liveLock={false}
onSelect={noop}
onCursor={noop}
onRename={noop}
onOpacityLocal={noop}
onOpacityCommit={noop}
onEnterPressed={noop}
/>,
);
}
describe('LayerTable', () => {
it('renders all 10 required columns', () => {
renderTable([makeLayer()]);
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(10);
const headerTexts = headers.map((h) => h.textContent);
// Status header is empty; the remaining 9 are named.
expect(headerTexts).toEqual(['', 'Layer', 'Typ', 'Source', 'Target', 'Time', 'Opacity', 'Blend', 'FX', 'Control']);
});
it('shows layer names', () => {
renderTable([makeLayer({ id: 'a', name: 'Alpha' }), makeLayer({ id: 'b', name: 'Beta' })]);
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Beta')).toBeInTheDocument();
});
it('marks the selected row with the selected class', () => {
renderTable([makeLayer({ id: 'a', name: 'Alpha' }), makeLayer({ id: 'b', name: 'Beta' })], 'b');
const rows = screen.getAllByRole('row');
// rows[0] is the header row; rows[1] = Alpha, rows[2] = Beta
expect(rows[1]).not.toHaveClass('selected');
expect(rows[2]).toHaveClass('selected');
expect(rows[2]).toHaveAttribute('aria-selected', 'true');
});
it('distinguishes active/paused/error status visually via tone classes', () => {
renderTable([
makeLayer({ id: 'a', name: 'Active', status: 'active' }),
makeLayer({ id: 'b', name: 'Paused', status: 'paused' }),
makeLayer({ id: 'c', name: 'Error', status: 'error' }),
]);
const rows = screen.getAllByRole('row');
// Each row's status cell carries the tone class.
expect(rows[1].querySelector('.c-status')).toHaveClass('st-active');
expect(rows[2].querySelector('.c-status')).toHaveClass('st-paused');
expect(rows[3].querySelector('.c-status')).toHaveClass('st-error');
});
it('shows the status label as screen-reader text (never color alone)', () => {
renderTable([makeLayer({ id: 'a', name: 'Active', status: 'active' })]);
expect(screen.getByText('Aktiv')).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import StatusBar from '../components/StatusBar';
import type { StatusToneItem } from '../components/StatusBar';
function makeTone(overrides: Partial<StatusToneItem> = {}): StatusToneItem {
return { tone: 'ok', label: 'OK', ...overrides };
}
function renderBar(overrides: Partial<ComponentProps<typeof StatusBar>> = {}) {
return render(
<StatusBar
projectName="Demo"
nodeName="node-1"
output={makeTone({ label: 'Live' })}
quality="Auto"
fps={59.9401}
artnet={makeTone({ label: 'Art-Net OK' })}
sync={makeTone({ label: 'Sync' })}
clock="08:39:00"
{...overrides}
/>,
);
}
describe('StatusBar', () => {
it('shows FPS with two decimal places', () => {
renderBar({ fps: 59.9401 });
expect(screen.getByText('59.94')).toBeInTheDocument();
});
it('shows FPS placeholder when no measurement is available', () => {
renderBar({ fps: null });
expect(screen.getByText('—')).toBeInTheDocument();
});
it('shows Quality: Auto', () => {
renderBar({ quality: 'Auto' });
expect(screen.getByText('Quality:')).toBeInTheDocument();
expect(screen.getByText('Auto')).toBeInTheDocument();
});
it('shows the Art-Net status with label and tone class', () => {
renderBar({ artnet: makeTone({ tone: 'warn', label: 'Art-Net Warn' }) });
expect(screen.getByText('Art-Net Warn')).toBeInTheDocument();
const item = screen.getByTitle('Art-Net: Art-Net Warn');
expect(item).toHaveClass('tone-warn');
});
});
@@ -0,0 +1,484 @@
/**
* Unterer Inspector (§17.2, §17.4).
*
* Tabs: Layer, Source, Transform, Color, FX, DMX, Advanced.
* Fest positionierte gemeinsame Parameter, kontextabhängige Felder nach Typ,
* Zahlenfelder mit Tippen, Ziehen (Grip) und Feinsteuerung per Tastatur
* (Pfeiltasten, Shift = Feinschritt). Escape bricht ab, löst nichts aus.
* Vertikal skalierbar über Divider, vollständig einklappbar.
* Live-Lock (§17.7): Struktur (Name, DMX-Patch) gesperrt, Layerparameter frei.
*/
import { useRef, useState } from 'react';
import type { ComponentType, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import type { FxSlot, InspectorTab, Layer } from '../types';
import { BLEND_MODES, LOOP_MODES, STATUS_LABEL, TYPE_LABEL, cx, formatTimecode, parseNumberInput } from '../utils';
import { IconChevronDown, IconChevronUp, StatusGlyph } from '../icons';
export interface InspectorCallbacks {
readonly onPatch: (patch: InspectorPatch) => void;
/** Release des Opacity-Overrides (§11.3) über /commands/{id}/release. */
readonly onReleaseOpacity: (layerId: string) => void;
}
export interface InspectorPatch {
readonly name?: string;
readonly blend?: string;
readonly loopMode?: string;
readonly timeDurationSec?: number;
readonly generatorPhase?: number;
readonly transformX?: number;
readonly transformY?: number;
readonly transformScale?: number;
readonly transformRotation?: number;
readonly colorBrightness?: number;
readonly colorContrast?: number;
readonly colorSaturation?: number;
readonly colorHue?: number;
readonly fxBypass?: Readonly<[number | null, boolean]>;
readonly dmxUniverse?: number;
readonly dmxAddress?: number;
}
interface InspectorProps extends InspectorCallbacks {
readonly layer: Layer | null;
readonly activeTab: InspectorTab;
readonly onTabChange: (tab: InspectorTab) => void;
readonly collapsed: boolean;
readonly height: number;
readonly liveLock: boolean;
readonly onToggleCollapse: () => void;
readonly onResizeStart: (e: ReactPointerEvent<HTMLDivElement>) => void;
readonly resizing: boolean;
}
const TABS: ReadonlyArray<{ readonly id: InspectorTab; readonly label: string }> = [
{ id: 'layer', label: 'Layer' },
{ id: 'source', label: 'Source' },
{ id: 'transform', label: 'Transform' },
{ id: 'color', label: 'Color' },
{ id: 'fx', label: 'FX' },
{ id: 'dmx', label: 'DMX' },
{ id: 'advanced', label: 'Advanced' },
];
/* ----------------------------------------------------------------
* Zahlenfeld: Tippen, Ziehen am Grip, Feinsteuerung per Tastatur.
* ---------------------------------------------------------------- */
interface NumFieldProps {
readonly label: string;
readonly value: number;
readonly unit?: string;
readonly step?: number;
readonly disabled?: boolean;
readonly onCommit: (value: number) => void;
}
function NumField({ label, value, unit, step = 1, disabled = false, onCommit }: NumFieldProps) {
const [draft, setDraft] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const drag = useRef<{ readonly startValue: number; readonly startX: number } | null>(null);
const shown = draft ?? String(value);
const commitDraft = (raw: string): void => {
const parsed = parseNumberInput(raw);
setDraft(null);
if (parsed !== null) onCommit(parsed);
};
const nudge = (delta: number): void => {
const next = Math.round((value + delta) * 1000) / 1000;
onCommit(next);
};
const onGripDown = (e: ReactPointerEvent<HTMLSpanElement>): void => {
if (disabled) return;
e.preventDefault();
drag.current = { startValue: value, startX: e.clientX };
(e.target as HTMLSpanElement).setPointerCapture(e.pointerId);
};
const onGripMove = (e: ReactPointerEvent<HTMLSpanElement>): void => {
const d = drag.current;
if (d === null) return;
const raw = d.startValue + (e.clientX - d.startX) * step;
onCommit(Math.round(raw * 1000) / 1000);
};
const onGripUp = (): void => {
drag.current = null;
};
return (
<div className="field">
<span className="field-label">{label}</span>
<div className="numfield">
<input
ref={inputRef}
type="text"
className="mono"
value={shown}
disabled={disabled}
aria-label={label}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => e.target.select()}
onBlur={(e) => {
if (draft !== null) commitDraft(e.target.value);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') {
commitDraft((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
setDraft(null);
(e.target as HTMLInputElement).blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
nudge(e.shiftKey ? step / 10 : step);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
nudge(e.shiftKey ? -(step / 10) : -step);
}
}}
/>
{unit !== undefined && <span className="note">{unit}</span>}
<span
className="drag-grip"
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={`${label} ziehen`}
onPointerDown={onGripDown}
onPointerMove={onGripMove}
onPointerUp={onGripUp}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'ArrowUp') nudge(e.shiftKey ? step / 10 : step);
if (e.key === 'ArrowDown') nudge(e.shiftKey ? -(step / 10) : -step);
}}
>
</span>
</div>
</div>
);
}
/* ----------------------------------------------------------------
* Kleine Bausteine
* ---------------------------------------------------------------- */
function Field({ label, children }: { readonly label: string; readonly children: ReactNode }) {
return (
<div className="field">
<span className="field-label">{label}</span>
{children}
</div>
);
}
function FieldValue({ label, value, title }: { readonly label: string; readonly value: string; readonly title?: string }) {
return (
<Field label={label}>
<span className="field-value" title={title ?? value}>
<span className="ellip">{value}</span>
</span>
</Field>
);
}
function Group({ title, children }: { readonly title: string; readonly children: ReactNode }) {
return (
<div className="field-group">
<div className="group-title">{title}</div>
<div className="field-grid">{children}</div>
</div>
);
}
function fxStateText(state: FxSlot['state']): string {
return state === 'active' ? 'aktiv' : state === 'bypassed' ? 'bypassed' : 'Fehler';
}
/* ----------------------------------------------------------------
* Tabs
* ---------------------------------------------------------------- */
interface TabBodyProps extends InspectorCallbacks {
readonly layer: Layer;
readonly liveLock: boolean;
}
function LayerTabBody({ layer, liveLock, onPatch }: TabBodyProps) {
return (
<Group title="Layer">
<Field label="Name (Struktur Live gesperrt)">
<input
type="text"
value={layer.name}
disabled={liveLock}
aria-label="Layername"
title={liveLock ? 'Umbenennen in Live gesperrt (§17.7)' : layer.name}
onChange={(e) => onPatch({ name: e.target.value })}
/>
</Field>
<FieldValue label="Typ" value={TYPE_LABEL[layer.type]} />
<FieldValue label="Status" value={STATUS_LABEL[layer.status]} title={`Betriebszustand: ${STATUS_LABEL[layer.status]}`} />
<Field label="Blend">
<select
value={layer.blend}
aria-label="Blend-Modus"
onChange={(e) => onPatch({ blend: e.target.value })}
>
{BLEND_MODES.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Field>
<Field label="Loop">
<select
value={layer.loopMode}
aria-label="Loop-Modus"
onChange={(e) => onPatch({ loopMode: e.target.value })}
>
{LOOP_MODES.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Field>
</Group>
);
}
function SourceTabBody({ layer, onPatch }: TabBodyProps) {
return (
<>
<Group title="Quelle">
<FieldValue label="Clip / Generator" value={layer.sourceName} title={layer.sourceName} />
<FieldValue label="Bank / Index" value={layer.sourceBank} />
<FieldValue label="Ziel" value={layer.target} title={layer.target} />
</Group>
<Group title="Zeiten">
<NumField
label="Dauer"
value={layer.timeDurationSec}
unit="s · 0 = ∞"
step={0.1}
onCommit={(v) => onPatch({ timeDurationSec: Math.max(0, v) })}
/>
<FieldValue
label="Position"
value={formatTimecode(layer.timePositionSec)}
title={`aktuelle Wiedergabeposition ${formatTimecode(layer.timePositionSec)}`}
/>
{layer.type === 'generator' && (
<NumField
label="Generatorphase"
value={layer.generatorPhase}
unit="s"
step={0.01}
onCommit={(v) => onPatch({ generatorPhase: v })}
/>
)}
</Group>
</>
);
}
function TransformTabBody({ layer, onPatch }: TabBodyProps) {
return (
<Group title="Transform">
<NumField label="X" value={layer.transform.x} unit="px" step={1} onCommit={(v) => onPatch({ transformX: v })} />
<NumField label="Y" value={layer.transform.y} unit="px" step={1} onCommit={(v) => onPatch({ transformY: v })} />
<NumField label="Scale" value={layer.transform.scale} unit="%" step={0.5} onCommit={(v) => onPatch({ transformScale: v })} />
<NumField label="Rotation" value={layer.transform.rotation} unit="°" step={1} onCommit={(v) => onPatch({ transformRotation: v })} />
</Group>
);
}
function ColorTabBody({ layer, onPatch }: TabBodyProps) {
return (
<Group title="Farbkorrektur">
<NumField label="Brightness" value={layer.color.brightness} unit="%" step={1} onCommit={(v) => onPatch({ colorBrightness: v })} />
<NumField label="Contrast" value={layer.color.contrast} unit="%" step={1} onCommit={(v) => onPatch({ colorContrast: v })} />
<NumField label="Saturation" value={layer.color.saturation} unit="%" step={1} onCommit={(v) => onPatch({ colorSaturation: v })} />
<NumField label="Hue" value={layer.color.hue} unit="°" step={1} onCommit={(v) => onPatch({ colorHue: v })} />
</Group>
);
}
function FxTabBody({ layer, onPatch }: TabBodyProps) {
return (
<Group title="Effekt-Slots (2)">
{layer.fxSlots.map((slot, idx) => (
<div className="field" key={idx}>
<span className="field-label">Slot {idx + 1}</span>
{slot === null ? (
<span className="field-value">
<span className="ellip">leer</span>
</span>
) : (
<span className="field-value" title={`${slot.name} ${fxStateText(slot.state)}`}>
<span className="ellip">{slot.name}</span>
<button
type="button"
className="toggle-btn"
aria-pressed={slot.state === 'bypassed'}
title={slot.state === 'bypassed' ? 'Bypass aufheben' : 'Effekt bypassen'}
onClick={() => onPatch({ fxBypass: [idx, slot.state !== 'bypassed'] })}
>
{slot.state === 'bypassed' ? 'Bypassed' : 'Aktiv'}
</button>
{slot.state === 'error' && <span className="tone-bad">ERR</span>}
</span>
)}
</div>
))}
</Group>
);
}
function DmxTabBody({ layer, liveLock, onPatch }: TabBodyProps) {
return (
<>
<Group title="Art-Net-Patch (16-Bit, §16)">
<NumField
label="Universe (Struktur)"
value={layer.dmx.universe}
step={1}
disabled={liveLock}
onCommit={(v) => onPatch({ dmxUniverse: Math.max(0, Math.min(63, Math.round(v))) })}
/>
<NumField
label="Adresse (Struktur)"
value={layer.dmx.address}
step={1}
disabled={liveLock}
onCommit={(v) => onPatch({ dmxAddress: Math.max(1, Math.min(512, Math.round(v))) })}
/>
<FieldValue label="Modus" value={layer.dmx.mode} />
</Group>
<p className="note">
DMX-Werte sind numerisch präzise editierbar (16 Bit). Patch-Änderungen erfordern
Setup-Modus und werden serverseitig bestätigt.
</p>
</>
);
}
function AdvancedTabBody({ layer, onReleaseOpacity }: TabBodyProps) {
return (
<Group title="Automation / Ownership">
<FieldValue label="Steuerquellen" value={layer.control.sources.join(' · ')} />
<FieldValue label="Aktueller Owner" value={layer.control.owner} title={layer.control.owner} />
<FieldValue label="Opacity-Parameter" value={`composition/…/layer/${layer.id.slice(0, 8)}…/opacity`} title="Pfad folgt §10.2 (UUID-basiert)" />
<div className="field">
<span className="field-label">Override</span>
<div className="btn-row">
<button
type="button"
className="btn"
title="Web-Override des Opacity-Parameters freigeben (§11.3)"
onClick={() => onReleaseOpacity(layer.id)}
>
Release Opacity
</button>
</div>
</div>
</Group>
);
}
const TAB_BODIES: Record<InspectorTab, ComponentType<TabBodyProps>> = {
layer: LayerTabBody,
source: SourceTabBody,
transform: TransformTabBody,
color: ColorTabBody,
fx: FxTabBody,
dmx: DmxTabBody,
advanced: AdvancedTabBody,
};
/* ----------------------------------------------------------------
* Inspector
* ---------------------------------------------------------------- */
export default function Inspector(props: InspectorProps) {
const { layer, activeTab, onTabChange, collapsed, height, liveLock, onToggleCollapse, onResizeStart, resizing } = props;
const Body = TAB_BODIES[activeTab];
if (collapsed) {
return (
<div className="insp-collapsed">
<button
type="button"
className="icon-btn"
aria-label="Inspector aufklappen"
title="Inspector aufklappen"
onClick={onToggleCollapse}
>
<IconChevronUp size={13} />
</button>
<span>Inspector: {layer !== null ? layer.name : 'keine Auswahl'}</span>
</div>
);
}
return (
<section className="inspector" style={{ height }} aria-label="Inspector">
<div
className={cx('insp-divider', resizing && 'dragging')}
role="separator"
aria-orientation="horizontal"
aria-label="Inspector-Höhe ändern"
onPointerDown={onResizeStart}
/>
<div className="inspector-head">
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={activeTab === t.id}
className="insp-tab"
onClick={() => onTabChange(t.id)}
>
{t.label}
</button>
))}
<div className="insp-tools">
{layer !== null && (
<span className={cx('insp-tab')} title="Ausgewählter Layer">
<StatusGlyph status={layer.status} size={11} />
<span className="ellip" style={{ maxWidth: 120 }}>
{layer.name}
</span>
</span>
)}
<button
type="button"
className="icon-btn"
aria-label="Inspector einklappen"
title="Inspector einklappen"
onClick={onToggleCollapse}
>
<IconChevronDown size={13} />
</button>
</div>
</div>
<div className="inspector-body" role="tabpanel">
{layer === null ? (
<div className="insp-empty">Kein Layer ausgewählt Zeile in der Tabelle wählen.</div>
) : (
<Body layer={layer} liveLock={liveLock} onPatch={props.onPatch} onReleaseOpacity={props.onReleaseOpacity} />
)}
</div>
</section>
);
}
@@ -0,0 +1,289 @@
/**
* Layer-Tabelle primäre Mixeransicht (§17.3).
*
* Pflichtspalten mit fixen Breiten über --lt-cols (tokens.css):
* Status | Layer (Z + Name) | Typ | Source | Target | Time | Opacity | Blend | FX | Control.
* Zeilenhöhe 30 px (Desktop-Density), Selektion als Akzentfläche ohne Blinken,
* Pfeiltasten-Navigation, Escape bricht Editieren ab (kein Blackout, §17.7).
* Der Opacity-Fader committet real per parameter.set (§10.2, §23.2).
*/
import { useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { Layer } from '../types';
import { STATUS_LABEL, STATUS_TONE, TYPE_LABEL, cx, formatTimecode } from '../utils';
import { StatusGlyph } from '../icons';
interface LayerTableProps {
readonly layers: readonly Layer[];
readonly selectedId: string | null;
readonly cursorId: string | null;
/** Live-Modus sperrt Umbenennen (§17.7). */
readonly liveLock: boolean;
readonly onSelect: (id: string) => void;
readonly onCursor: (id: string) => void;
readonly onRename: (id: string, name: string) => void;
readonly onOpacityLocal: (id: string, value: number) => void;
readonly onOpacityCommit: (id: string, value: number) => void;
readonly onEnterPressed: () => void;
}
const HEADERS: readonly string[] = [
'',
'Layer',
'Typ',
'Source',
'Target',
'Time',
'Opacity',
'Blend',
'FX',
'Control',
];
function fxSlotTitle(layer: Layer): string {
return layer.fxSlots
.map((s, i) =>
s === null
? `Slot ${i + 1}: leer`
: `Slot ${i + 1}: ${s.name} (${s.state === 'active' ? 'aktiv' : s.state === 'bypassed' ? 'bypassed' : 'Fehler'})`,
)
.join(' · ');
}
export default function LayerTable(props: LayerTableProps) {
const { layers, selectedId, cursorId, liveLock } = props;
const [editingId, setEditingId] = useState<string | null>(null);
const [draftName, setDraftName] = useState('');
/** Lokaler Fader-Stand während des Ziehens; Commit löst parameter.set aus. */
const [dragValue, setDragValue] = useState<{ readonly id: string; readonly value: number } | null>(null);
const cursorIndex = layers.findIndex((l) => l.id === cursorId);
const handleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>): void => {
if (editingId !== null) return; // Eingabefelder fangen Tasten nie global ab (§17.7)
if (layers.length === 0) return;
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp': {
e.preventDefault();
const dir = e.key === 'ArrowDown' ? 1 : -1;
const next = Math.min(layers.length - 1, Math.max(0, cursorIndex + dir));
const id = layers[next].id;
props.onCursor(id);
props.onSelect(id);
break;
}
case 'Enter':
props.onEnterPressed();
break;
case 'Escape':
// beendet nichts Destruktives; Blackout ist separat geschützt (§17.7)
break;
default:
break;
}
};
const beginEdit = (layer: Layer): void => {
if (liveLock) return;
setEditingId(layer.id);
setDraftName(layer.name);
};
const commitEdit = (): void => {
if (editingId === null) return;
const name = draftName.trim();
if (name.length > 0) props.onRename(editingId, name);
setEditingId(null);
};
const opacityOf = (layer: Layer): number =>
dragValue !== null && dragValue.id === layer.id ? dragValue.value : layer.opacity;
const handleOpacityInput = (layer: Layer, value: number): void => {
setDragValue({ id: layer.id, value });
props.onOpacityLocal(layer.id, value);
};
const commitOpacity = (layer: Layer): void => {
if (dragValue === null || dragValue.id !== layer.id) return;
props.onOpacityCommit(layer.id, dragValue.value);
setDragValue(null);
};
return (
<div
className="layer-table"
tabIndex={0}
role="grid"
aria-label="Layer-Tabelle"
onKeyDown={handleKeyDown}
>
<div className="lt-head" role="row">
{HEADERS.map((h, i) => (
<div key={i} className="lt-cell" role="columnheader">
{h}
</div>
))}
</div>
{layers.length === 0 ? (
<div className="lt-empty">Keine Layer im Projekt.</div>
) : (
layers.map((layer) => {
const selected = layer.id === selectedId;
const isCursor = layer.id === cursorId;
const editing = layer.id === editingId;
const op = opacityOf(layer);
const dur =
layer.timeDurationSec === 0 ? '∞' : formatTimecode(layer.timeDurationSec);
return (
<div
key={layer.id}
role="row"
aria-selected={selected}
className={cx('lt-row', selected && 'selected', isCursor && 'is-cursor')}
onClick={() => {
props.onSelect(layer.id);
props.onCursor(layer.id);
}}
>
{/* Status Glyphe + Ton + Tooltip, nie Farbe allein (§17.6) */}
<div
className={cx('lt-cell', 'c-status', STATUS_TONE[layer.status])}
role="gridcell"
title={STATUS_LABEL[layer.status]}
>
<StatusGlyph status={layer.status} />
<span className="sr-only">{STATUS_LABEL[layer.status]}</span>
</div>
{/* Layer: Z-Reihenfolge + editierbarer Name */}
<div className="lt-cell" role="gridcell">
<span className="z-badge mono" title={`Z ${layer.z}`}>
{layer.z}
</span>
{editing ? (
<input
className="name-input"
value={draftName}
autoFocus
aria-label="Layer umbenennen"
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') commitEdit();
else if (e.key === 'Escape') setEditingId(null);
}}
onBlur={commitEdit}
/>
) : (
<span
className="layer-name ellip"
title={
liveLock
? `${layer.name} (Umbenennen in Live gesperrt)`
: `${layer.name} Doppelklick zum Umbenennen`
}
onDoubleClick={() => beginEdit(layer)}
>
{layer.name}
</span>
)}
</div>
{/* Typ */}
<div className="lt-cell" role="gridcell" title={TYPE_LABEL[layer.type]}>
<span className="ellip">{TYPE_LABEL[layer.type]}</span>
</div>
{/* Source: Clip-/Generatorname + Bank/Index */}
<div
className="lt-cell"
role="gridcell"
title={`${layer.sourceName} · ${layer.sourceBank}`}
>
<span className="ellip">{layer.sourceName}</span>
<span className="src-bank mono">{layer.sourceBank}</span>
</div>
{/* Target */}
<div className="lt-cell" role="gridcell" title={layer.target}>
<span className="ellip">{layer.target}</span>
</div>
{/* Time monospace/tabellarisch (§17.6) */}
<div
className="lt-cell mono"
role="gridcell"
title={`Position ${formatTimecode(layer.timePositionSec)} / Dauer ${
layer.timeDurationSec === 0 ? 'unbegrenzt' : formatTimecode(layer.timeDurationSec)
}`}
>
<span className="time-pos">{formatTimecode(layer.timePositionSec)}</span>
<span className="time-dur">{dur}</span>
</div>
{/* Opacity: Wert + schmaler Fader (64 px) */}
<div className="lt-cell" role="gridcell" title={`Opacity ${op} %`}>
<span className="opacity-val mono">{op}</span>
<input
type="range"
className="opacity-fader"
min={0}
max={100}
step={1}
value={op}
aria-label={`Opacity von ${layer.name}`}
onKeyDown={(e) => e.stopPropagation()}
onChange={(e) => handleOpacityInput(layer, Number(e.target.value))}
onPointerUp={() => commitOpacity(layer)}
onKeyUp={() => commitOpacity(layer)}
onBlur={() => commitOpacity(layer)}
/>
</div>
{/* Blend */}
<div className="lt-cell" role="gridcell" title={`Blend ${layer.blend}`}>
<span className="ellip">{layer.blend}</span>
</div>
{/* FX: zwei Slots mit Bypass-/Fehlerstatus */}
<div className="lt-cell" role="gridcell" title={fxSlotTitle(layer)}>
{layer.fxSlots.map((slot, idx) =>
slot === null ? (
<span key={idx} className="fx-slot" title="Effektslot leer">
</span>
) : (
<span
key={idx}
className={cx(
'fx-slot',
slot.state === 'bypassed' && 'bypassed',
slot.state === 'error' && 'error',
)}
title={`${slot.name} ${
slot.state === 'active'
? 'aktiv'
: slot.state === 'bypassed'
? 'bypassed'
: 'Fehler'
}`}
>
<span className="fx-dot" />
<span className="ellip">{slot.name}</span>
{slot.state === 'bypassed' && <span>BYP</span>}
{slot.state === 'error' && <span>ERR</span>}
</span>
),
)}
</div>
{/* Control: Quellen + Owner */}
<div
className="lt-cell"
role="gridcell"
title={`Steuerung: ${layer.control.sources.join(', ')} · Owner: ${layer.control.owner}`}
>
<span className="ctrl-owner ellip">{layer.control.sources.join(' · ')}</span>
<span className="ctrl-owner ellip">{layer.control.owner}</span>
</div>
</div>
);
})
)}
</div>
);
}
@@ -0,0 +1,229 @@
/**
* Rechte Seitenleiste (§17.5).
*
* Umschaltbar zwischen Active Layers (Thumbnail, Position, Node, Status,
* Pause/Stop deaktiviert bis die Transport-API existiert), Nodes
* (Anzeigename, Rolle, Health, FPS, Art-Net-Sender) und Alerts (Fehler
* mit Zeitstempel). Health nie allein über Farbe: immer Text/Glyphe.
*/
import type { AlertItem, ClusterNode, Layer, SidebarTab } from '../types';
import { STATUS_LABEL, STATUS_TONE, cx, formatTimecode, formatTimeMs } from '../utils';
import { IconAlertTriangle, IconCrossCircle, IconInfoCircle, IconPauseSm, IconPlaySm, IconStopSm, StatusGlyph } from '../icons';
interface RightSidebarProps {
readonly tab: SidebarTab;
readonly onTabChange: (tab: SidebarTab) => void;
readonly layers: readonly Layer[];
readonly nodes: readonly ClusterNode[];
readonly selfNodeId: string | null;
readonly alerts: readonly AlertItem[];
readonly selectedLayerId: string | null;
readonly onSelectLayer: (id: string) => void;
readonly onClearAlerts: () => void;
readonly nodeFps: ReadonlyMap<string, number>;
readonly artnetSenders: ReadonlyMap<string, string>;
}
const HEALTH_TONE: Record<ClusterNode['health'], string> = {
online: 'tone-ok',
degraded: 'tone-warn',
stale: 'tone-warn',
offline: 'tone-bad',
};
const HEALTH_LABEL: Record<ClusterNode['health'], string> = {
online: 'online',
degraded: 'degraded',
stale: 'veraltet',
offline: 'offline',
};
function SeverityIcon({ severity }: { readonly severity: AlertItem['severity'] }) {
if (severity === 'error') return <IconCrossCircle size={13} />;
if (severity === 'warning') return <IconAlertTriangle size={13} />;
return <IconInfoCircle size={13} />;
}
function ActiveLayersTab(props: RightSidebarProps) {
const active = props.layers.filter(
(l) => l.status === 'active' || l.status === 'preloaded' || l.status === 'out_of_sync',
);
if (active.length === 0) {
return <div className="empty-note">Keine laufenden oder vorgeladenen Layer.</div>;
}
return (
<>
{active.map((l) => (
<div
key={l.id}
className={cx('side-item', props.selectedLayerId === l.id && 'selected')}
onClick={() => props.onSelectLayer(l.id)}
>
<div className="thumb mono" title={`Thumbnail: ${l.sourceName}`}>
{l.sourceBank.slice(0, 2)}
</div>
<div className="item-main">
<div className="item-title">
<span className={cx(STATUS_TONE[l.status])}>
<StatusGlyph status={l.status} size={11} />
</span>
<span className="item-name ellip" title={l.name}>
{l.name}
</span>
</div>
<div className="item-meta">
<span className="mono">{formatTimecode(l.timePositionSec)}</span>
<span className="ellip" title={`Ziel: ${l.target}`}>
{l.target}
</span>
<span title={`Status: ${STATUS_LABEL[l.status]}`}>{STATUS_LABEL[l.status]}</span>
</div>
<div className="item-meta">
<button
type="button"
className="icon-btn"
disabled
title={`Pause ${l.name}: benötigt Transport-API (spätere Phase)`}
>
{l.status === 'paused' ? <IconPlaySm size={11} /> : <IconPauseSm size={11} />}
</button>
<button
type="button"
className="icon-btn"
disabled
title={`Stop ${l.name}: benötigt Transport-API (spätere Phase)`}
>
<IconStopSm size={11} />
</button>
</div>
</div>
</div>
))}
</>
);
}
function NodesTab(props: RightSidebarProps) {
if (props.nodes.length === 0) {
return <div className="empty-note">Keine Nodes registriert (Control Core nicht erreichbar).</div>;
}
return (
<>
{props.nodes.map((n) => {
const fps = props.nodeFps.get(n.node_id);
const sender = props.artnetSenders.get(n.node_id);
return (
<div key={n.node_id} className="side-item">
<div className="item-main">
<div className="item-title">
<span className={cx('dot', HEALTH_TONE[n.health])} title={`Health: ${HEALTH_LABEL[n.health]}`} />
<span className="item-name ellip" title={n.display_name}>
{n.display_name}
</span>
{props.selfNodeId === n.node_id && (
<span className="self-tag" title="Dieser Node">
(dieser)
</span>
)}
</div>
<div className="item-meta">
<span title={`Rollen: ${n.roles.join(', ')}`}>
{n.roles.map((r) => (
<span key={r} className="role-chip">
{r}
</span>
))}
</span>
<span className={cx(HEALTH_TONE[n.health])} title={`Health: ${HEALTH_LABEL[n.health]}`}>
{HEALTH_LABEL[n.health]}
</span>
</div>
<div className="item-meta">
<span className="mono" title="Frames pro Sekunde (Metrik folgt mit Renderer-IPC)">
FPS {fps !== undefined ? fps.toFixed(2) : '—'}
</span>
<span className="ellip" title={`Art-Net-Sender: ${sender ?? 'nicht gemeldet'}`}>
Art-Net {sender ?? '—'}
</span>
</div>
</div>
</div>
);
})}
</>
);
}
function AlertsTab(props: RightSidebarProps) {
if (props.alerts.length === 0) {
return <div className="empty-note">Keine Meldungen.</div>;
}
return (
<>
{props.alerts.map((a) => (
<div key={a.id} className="alert-item">
<span
className={cx(
a.severity === 'error' ? 'tone-bad' : a.severity === 'warning' ? 'tone-warn' : 'tone-muted',
)}
>
<SeverityIcon severity={a.severity} />
</span>
<span className="alert-time mono">{formatTimeMs(a.timeMs)}</span>
<span className="alert-msg">{a.message}</span>
</div>
))}
</>
);
}
export default function RightSidebar(props: RightSidebarProps) {
return (
<aside className="sidebar" aria-label="Seitenleiste">
<div className="side-head">
<div className="seg" role="tablist" aria-label="Seitenleiste-Ansicht">
<button
type="button"
role="tab"
aria-selected={props.tab === 'active'}
aria-pressed={props.tab === 'active'}
onClick={() => props.onTabChange('active')}
>
Active Layers
</button>
<button
type="button"
role="tab"
aria-selected={props.tab === 'nodes'}
aria-pressed={props.tab === 'nodes'}
onClick={() => props.onTabChange('nodes')}
>
Nodes
</button>
<button
type="button"
role="tab"
aria-selected={props.tab === 'alerts'}
aria-pressed={props.tab === 'alerts'}
onClick={() => props.onTabChange('alerts')}
>
Alerts
</button>
</div>
</div>
<div className="side-body" role="tabpanel">
{props.tab === 'active' && <ActiveLayersTab {...props} />}
{props.tab === 'nodes' && <NodesTab {...props} />}
{props.tab === 'alerts' && <AlertsTab {...props} />}
</div>
{props.tab === 'alerts' && (
<div className="side-foot">
<button type="button" className="clear-btn" onClick={props.onClearAlerts}>
Alle löschen
</button>
</div>
)}
</aside>
);
}
@@ -0,0 +1,92 @@
/**
* Obere Statusleiste (§17.2), 32 px hoch.
*
* Produkt-/Projektname, lokaler Node, Output-Status, „Quality: Auto“,
* FPS (monospace, 2 Dezimalstellen), Art-Net und Clock/Sync als kleine
* Zustandsanzeigen. Zustand nie allein über Farbe: immer Text/Icon daneben.
*/
import { IconClock, IconMonitor, IconSignal } from '../icons';
import { cx, formatFps } from '../utils';
export interface StatusToneItem {
readonly tone: 'ok' | 'warn' | 'bad' | 'muted';
readonly label: string;
}
interface StatusBarProps {
readonly projectName: string;
readonly nodeName: string;
readonly output: StatusToneItem;
readonly quality: string;
readonly fps: number | null;
readonly artnet: StatusToneItem;
readonly sync: StatusToneItem;
readonly clock: string;
}
const TONE_CLASS: Record<StatusToneItem['tone'], string> = {
ok: 'tone-ok',
warn: 'tone-warn',
bad: 'tone-bad',
muted: 'tone-muted',
};
export default function StatusBar(props: StatusBarProps) {
const { projectName, nodeName, output, quality, fps, artnet, sync, clock } = props;
return (
<header className="statusbar">
<span className="product">HMS MediaEngine</span>
<span className="sep" />
<span className="status-item" title={`Projekt: ${projectName}`}>
<span className="ellip">{projectName}</span>
</span>
<span className="sep" />
<span className="status-item" title={`Lokaler Node: ${nodeName}`}>
<span>Node</span>
<span className="value ellip">{nodeName}</span>
</span>
<div className="status-push">
<span
className={cx('status-item', TONE_CLASS[output.tone])}
title={`Output: ${output.label}`}
>
<IconMonitor size={13} />
<span className="value">{output.label}</span>
</span>
<span
className="status-item tone-muted"
title="Renderqualität automatische Leistungsanpassung"
>
<span>Quality:</span>
<span className="value">{quality}</span>
</span>
<span
className="status-item"
title="Frame-Rate der Bedienoberfläche (Renderer-Metrik folgt mit IPC-Handshake)"
>
<span>FPS</span>
<span className="value mono">{formatFps(fps)}</span>
</span>
<span
className={cx('status-item', TONE_CLASS[artnet.tone])}
title={`Art-Net: ${artnet.label}`}
>
<IconSignal size={13} />
<span className="value">{artnet.label}</span>
</span>
<span
className={cx('status-item', TONE_CLASS[sync.tone])}
title={`Zustandssynchronisation: ${sync.label}`}
>
<span className={cx('dot', TONE_CLASS[sync.tone])} />
<span className="value">{sync.label}</span>
</span>
<span className="status-item" title="Lokale Uhr">
<IconClock size={13} />
<span className="value mono">{clock}</span>
</span>
</div>
</header>
);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* REST-API-Anbindung des Control Core (§23.2, §17.9).
*
* Dev: '/api/v1' läuft über den Vite-Proxy auf http://localhost:8000.
* Jede Änderung wird serverseitig bestätigt (keine blinde optimistic UI,
* §17.9); Fehler landen als ApiError mit Status und Detail beim Aufrufer.
*/
import type {
ClusterNodesResponse,
CommandAck,
CommandEnvelope,
DiagnosticsResponse,
HealthResponse,
IdentityResponse,
ParametersResponse,
ReleaseResponse,
} from '../types';
const API_BASE = '/api/v1';
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
readonly detail: unknown,
) {
super(message);
this.name = 'ApiError';
}
}
function describeDetail(status: number, statusText: string, body: unknown): string {
if (typeof body === 'object' && body !== null && 'detail' in body) {
const detail = (body as { detail: unknown }).detail;
if (typeof detail === 'string') return detail;
try {
return JSON.stringify(detail);
} catch {
return `${status} ${statusText}`;
}
}
return `${status} ${statusText}`;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
} catch (err) {
throw new ApiError(0, `Netzwerkfehler: ${err instanceof Error ? err.message : 'unbekannt'}`, null);
}
const text = await response.text();
let body: unknown = null;
if (text.length > 0) {
try {
body = JSON.parse(text) as unknown;
} catch {
body = text;
}
}
if (!response.ok) {
throw new ApiError(response.status, describeDetail(response.status, response.statusText, body), body);
}
return body as T;
}
/* GET-Endpunkte (app.py) */
export const getHealth = (): Promise<HealthResponse> => request<HealthResponse>('/system/health');
export const getIdentity = (): Promise<IdentityResponse> => request<IdentityResponse>('/system/identity');
export const getClusterNodes = (): Promise<ClusterNodesResponse> =>
request<ClusterNodesResponse>('/cluster/nodes');
export const getParameters = (): Promise<ParametersResponse> => request<ParametersResponse>('/parameters');
export const getDiagnostics = (): Promise<DiagnosticsResponse> =>
request<DiagnosticsResponse>('/diagnostics');
/* Commands (§23.2) */
function newCommandId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `cmd-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export interface ParameterSetArgs {
readonly path: string;
readonly value: number;
readonly expectedRevision: number | null;
}
/** parameter.set mit Revision-Prüfung und Idempotenz (§23.2). */
export async function sendParameterSet({
path,
value,
expectedRevision,
}: ParameterSetArgs): Promise<CommandAck> {
const envelope: CommandEnvelope = {
command_id: newCommandId(),
type: 'parameter.set',
expected_revision: expectedRevision,
actor: { type: 'web', id: 'operator-session' },
payload: { parameter_path: path, value },
};
return request<CommandAck>('/commands', { method: 'POST', body: JSON.stringify(envelope) });
}
/** Override-Freigabe (§11.3); Release referenziert das ursprüngliche command_id. */
export async function releaseCommand(commandId: string): Promise<ReleaseResponse> {
return request<ReleaseResponse>(`/commands/${encodeURIComponent(commandId)}/release`, {
method: 'POST',
body: JSON.stringify({ source: 'web' }),
});
}
@@ -0,0 +1,117 @@
/**
* WebSocket zum Control Core (§17.9, §3.2).
*
* - Snapshot beim Connect, danach Deltas (parameter.update) plus Heartbeat.
* - Reconnect mit Exponential-Backoff: 1 s, 2 s, 4 s … maximum 30 s.
* - Browser-Neustart/-Reload stoppt den Output nicht (§3.2): der Client
* sendet keinerlei Commands beim Connect und synchronisiert nur Zustand.
* - URL: Same-Origin '/ws' (Dev über den Vite-Proxy → ws://localhost:8000/ws);
* alternativ direkt über VITE_WS_URL konfigurierbar.
*/
import { useEffect, useState } from 'react';
import type { WsServerEvent } from '../types';
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
function resolveWsUrl(): string {
const override: unknown = import.meta.env.VITE_WS_URL;
if (typeof override === 'string' && override.length > 0) return override;
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${protocol}://${window.location.host}/ws`;
}
function isWsServerEvent(data: unknown): data is WsServerEvent {
if (typeof data !== 'object' || data === null) return false;
const type = (data as { type?: unknown }).type;
return type === 'snapshot' || type === 'parameter.update' || type === 'heartbeat';
}
export interface WsState {
readonly connected: boolean;
/** Engine-Revision aus Snapshot/Updates; null vor der ersten Serverantwort. */
readonly revision: number | null;
readonly values: Readonly<Record<string, number>>;
readonly lastEventMs: number | null;
}
export function useWebSocket(): WsState {
const [connected, setConnected] = useState(false);
const [revision, setRevision] = useState<number | null>(null);
const [values, setValues] = useState<Record<string, number>>({});
const [lastEventMs, setLastEventMs] = useState<number | null>(null);
useEffect(() => {
let disposed = false;
let socket: WebSocket | null = null;
let timer: number | null = null;
let attempt = 0;
const clearTimer = (): void => {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
};
const scheduleReconnect = (): void => {
const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
attempt += 1;
timer = window.setTimeout(connect, delay);
};
const connect = (): void => {
if (disposed) return;
socket = new WebSocket(resolveWsUrl());
socket.onopen = () => {
if (disposed) return;
attempt = 0;
setConnected(true);
};
socket.onmessage = (ev: MessageEvent<string>) => {
if (disposed) return;
setLastEventMs(Date.now());
const parsed: unknown = (() => {
try {
return JSON.parse(ev.data) as unknown;
} catch {
return null;
}
})();
if (!isWsServerEvent(parsed)) return;
switch (parsed.type) {
case 'snapshot':
setValues(parsed.values);
setRevision(parsed.revision);
break;
case 'parameter.update':
setValues(prev => ({ ...prev, [parsed.parameter_path]: parsed.value }));
setRevision(parsed.revision);
break;
case 'heartbeat':
break;
}
};
socket.onclose = () => {
if (disposed) return;
setConnected(false);
scheduleReconnect();
};
socket.onerror = () => {
socket?.close();
};
};
connect();
return () => {
disposed = true;
clearTimer();
socket?.close(1000, 'client-unmount');
socket = null;
};
}, []);
return { connected, revision, values, lastEventMs };
}
+278
View File
@@ -0,0 +1,278 @@
/**
* Inline-SVG-Iconset (16-px-Basis, currentColor, keine externen Assets).
* Eigene schlichte Glyphen statt QLab-Übernahmen (§17.1). Jeder Layer-Zustand
* erhält eine eigene Zeichenform, damit Status nie allein über Farbe läuft (§17.6).
*/
import type { ComponentType, ReactNode, SVGProps } from 'react';
import type { LayerStatus } from './types';
export interface IconProps extends SVGProps<SVGSVGElement> {
readonly size?: number;
}
function Svg({ size = 16, children, ...rest }: IconProps & { readonly children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
{...rest}
>
{children}
</svg>
);
}
/* ---------- Werkzeugleiste (§17.2) ---------- */
export const IconMixer = (p: IconProps) => (
<Svg {...p}>
<path d="M6 4v16M12 4v16M18 4v16" />
<circle cx="6" cy="9" r="2" fill="currentColor" stroke="none" />
<circle cx="12" cy="15" r="2" fill="currentColor" stroke="none" />
<circle cx="18" cy="7" r="2" fill="currentColor" stroke="none" />
</Svg>
);
export const IconMedia = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="5.5" width="17" height="13" rx="1.5" />
<path d="M10.5 9.5v5l4.5-2.5z" fill="currentColor" stroke="none" />
</Svg>
);
export const IconEffects = (p: IconProps) => (
<Svg {...p}>
<path d="M12 4l1.8 6.2L20 12l-6.2 1.8L12 20l-1.8-6.2L4 12l6.2-1.8z" />
</Svg>
);
export const IconPresets = (p: IconProps) => (
<Svg {...p}>
<rect x="4" y="4" width="7" height="7" rx="1" />
<rect x="13" y="4" width="7" height="7" rx="1" />
<rect x="4" y="13" width="7" height="7" rx="1" />
<rect x="13" y="13" width="7" height="7" rx="1" />
</Svg>
);
export const IconOutputs = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="5" width="17" height="12" rx="1.5" />
<path d="M9 21h6M12 17v4" />
</Svg>
);
export const IconNetwork = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="5.5" r="2.2" />
<circle cx="5" cy="18.5" r="2.2" />
<circle cx="19" cy="18.5" r="2.2" />
<path d="M11 7.4L6.2 16.6M13 7.4l4.8 9.2M7.2 18.5h9.6" />
</Svg>
);
export const IconPlugins = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="3.5" width="10" height="10" rx="1.5" />
<rect x="10.5" y="10.5" width="10" height="10" rx="1.5" />
</Svg>
);
export const IconDiagnostics = (p: IconProps) => (
<Svg {...p}>
<path d="M3 12h4l2.5-6 4.5 12 2.5-6h4.5" />
</Svg>
);
/* ---------- Layer-Zustandsglyphen (§17.3, §17.10) ---------- */
export const IconStActive = (p: IconProps) => (
<Svg {...p}>
<path d="M9 6.8v10.4l8.5-5.2z" fill="currentColor" stroke="none" />
</Svg>
);
export const IconStPaused = (p: IconProps) => (
<Svg {...p}>
<path d="M8.5 6.5h2.6v11H8.5zM12.9 6.5h2.6v11h-2.6z" fill="currentColor" stroke="none" />
</Svg>
);
export const IconStPreloaded = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="7" />
<circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none" />
</Svg>
);
export const IconStWarning = (p: IconProps) => (
<Svg {...p}>
<path d="M12 4.5L20.6 19.2H3.4z" />
<path d="M12 10.2v3.6M12 16.6v.01" />
</Svg>
);
export const IconStError = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="7.2" />
<path d="M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6" />
</Svg>
);
export const IconStOffline = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="7.2" />
<path d="M6.9 6.9l10.2 10.2" />
</Svg>
);
export const IconStOutOfSync = (p: IconProps) => (
<Svg {...p}>
<path d="M3.5 12h3.2l2.2-4.5 4.2 9 2.2-4.5h5.2" />
</Svg>
);
const STATUS_ICON: Record<LayerStatus, ComponentType<IconProps>> = {
active: IconStActive,
paused: IconStPaused,
preloaded: IconStPreloaded,
warning: IconStWarning,
error: IconStError,
offline: IconStOffline,
out_of_sync: IconStOutOfSync,
};
/** Zustandssymbol: Form kodiert den Zustand, Farbe verstärkt (§17.6). */
export function StatusGlyph({ status, size = 13 }: { readonly status: LayerStatus; readonly size?: number }) {
const Icon = STATUS_ICON[status];
return <Icon size={size} />;
}
/* ---------- Bedienelemente ---------- */
export const IconChevronDown = (p: IconProps) => (
<Svg {...p}>
<path d="M6 9.5l6 6 6-6" />
</Svg>
);
export const IconChevronUp = (p: IconProps) => (
<Svg {...p}>
<path d="M6 14.5l6-6 6 6" />
</Svg>
);
export const IconChevronLeft = (p: IconProps) => (
<Svg {...p}>
<path d="M14.5 6l-6 6 6 6" />
</Svg>
);
export const IconChevronRight = (p: IconProps) => (
<Svg {...p}>
<path d="M9.5 6l6 6-6 6" />
</Svg>
);
export const IconClock = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="8" />
<path d="M12 7.5V12l3 2" />
</Svg>
);
export const IconSignal = (p: IconProps) => (
<Svg {...p}>
<path d="M4.5 13.5a10.5 10.5 0 0 1 15 0M8 16.5a5.7 5.7 0 0 1 8 0" />
<circle cx="12" cy="19.5" r="1" fill="currentColor" stroke="none" />
</Svg>
);
export const IconMonitor = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="5" width="17" height="12" rx="1.5" />
<path d="M9 20h6" />
</Svg>
);
export const IconLock = (p: IconProps) => (
<Svg {...p}>
<rect x="5.5" y="11" width="13" height="8.5" rx="1.5" />
<path d="M8.5 11V8a3.5 3.5 0 0 1 7 0v3" />
</Svg>
);
export const IconUnlock = (p: IconProps) => (
<Svg {...p}>
<rect x="5.5" y="11" width="13" height="8.5" rx="1.5" />
<path d="M8.5 11V8a3.5 3.5 0 0 1 6.8-1.2" />
</Svg>
);
export const IconAlertTriangle = (p: IconProps) => (
<Svg {...p}>
<path d="M12 4.5L20.6 19.2H3.4z" />
<path d="M12 10.2v3.6M12 16.6v.01" />
</Svg>
);
export const IconCrossCircle = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="7.2" />
<path d="M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6" />
</Svg>
);
export const IconInfoCircle = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="8" />
<path d="M12 11v5M12 7.8v.01" />
</Svg>
);
export const IconPlaySm = (p: IconProps) => (
<Svg {...p}>
<path d="M9.5 7.5v9l7-4.5z" fill="currentColor" stroke="none" />
</Svg>
);
export const IconPauseSm = (p: IconProps) => (
<Svg {...p}>
<path d="M9 7.5h2.2v9H9zM12.8 7.5H15v9h-2.2z" fill="currentColor" stroke="none" />
</Svg>
);
export const IconStopSm = (p: IconProps) => (
<Svg {...p}>
<rect x="8" y="8" width="8" height="8" rx="1" fill="currentColor" stroke="none" />
</Svg>
);
export const IconSidebarPanel = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="5" width="17" height="14" rx="1.5" />
<path d="M14.5 5v14" />
</Svg>
);
export const IconInspectorPanel = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="5" width="17" height="14" rx="1.5" />
<path d="M3.5 14.5h17" />
</Svg>
);
export const IconGrip = (p: IconProps) => (
<Svg {...p}>
<path d="M9 8.5L5.5 12 9 15.5M15 8.5L18.5 12 15 15.5" />
</Svg>
);
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles/tokens.css';
import './styles/app.css';
const container = document.getElementById('root');
if (container === null) {
throw new Error('#root nicht gefunden index.html prüfen');
}
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);
+274
View File
@@ -0,0 +1,274 @@
/**
* MVP-Datenbestand der Layer-Tabelle (PLATZHALTER).
*
* Bis die Layer-API des Control Core existiert, sind Namen, Zustände und
* Parameter Demo-Werte. Echte Opacity-Werte laufen bereits über die
* Parameter-Engine: composition/{uuid}/layer/{uuid}/opacity (§10.2, §11).
* Alles andere bleibt clientseitig und wird später serverseitig bestätigt
* (§17.9). Die Layer-IDs sind stabile UUIDs, damit Parameterpfade gültig
* bleiben (paths.validate_parameter_path).
*/
import type { Layer } from './types';
/** Demo-Composition-ID (UUID, §10.2). */
export const SEED_COMPOSITION_ID = 'd2a4c6e8-1b3f-4a5c-9d7e-6f8a0b2c4d6e';
const IDENTITY_TRANSFORM = { x: 0, y: 0, scale: 100, rotation: 0 } as const;
const IDENTITY_COLOR = { brightness: 100, contrast: 100, saturation: 100, hue: 0 } as const;
const DEFAULT_DMX = { universe: 0, address: 1, mode: '16 Bit' } as const;
export const SEED_LAYERS: readonly Layer[] = [
{
id: '9b1f4c2a-3d5e-4f6a-8b7c-1a2b3c4d5e6f',
z: 12,
name: 'Logo Bug',
type: 'media',
status: 'active',
sourceName: 'logo_main.png',
sourceBank: 'B1·04',
target: 'Local',
timePositionSec: 42.3,
timeDurationSec: 3600,
opacity: 100,
blend: 'Normal',
fxSlots: [{ name: 'Key Spill Suppress', state: 'active' }, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Loop',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '8c2e5d3b-4e6f-4a7b-9c8d-2b3c4d5e6f7a',
z: 11,
name: 'Vignette Key',
type: 'adjustment',
status: 'active',
sourceName: 'vignette_soft',
sourceBank: 'ADJ',
target: 'Local',
timePositionSec: 0,
timeDurationSec: 0,
opacity: 40,
blend: 'Multiply',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Hold',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '7d3f6e4c-5f7a-4b8c-ad9e-3c4d5e6f7a8b',
z: 10,
name: 'Strobe Accent',
type: 'generator',
status: 'active',
sourceName: 'Strobe Pulse 8 Hz',
sourceBank: 'GEN',
target: 'Local',
timePositionSec: 3.2,
timeDurationSec: 0,
opacity: 70,
blend: 'Add',
fxSlots: [{ name: 'Strobe', state: 'active' }, null],
control: { sources: ['artnet'], owner: 'Lichtpult 1' },
loopMode: 'Kontinuierlich',
generatorPhase: 0.125,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: { universe: 2, address: 49, mode: '16 Bit' },
},
{
id: '6e4a7f5d-6a8b-4c9d-beaf-4d5e6f7a8b9c',
z: 9,
name: 'Color Wash',
type: 'generator',
status: 'active',
sourceName: 'Warm Gradient',
sourceBank: 'GEN',
target: 'Gruppe Ost',
timePositionSec: 12.4,
timeDurationSec: 0,
opacity: 85,
blend: 'Screen',
fxSlots: [{ name: 'Blur', state: 'bypassed' }, { name: 'Color Trim', state: 'active' }],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Kontinuierlich',
generatorPhase: 12.4,
transform: IDENTITY_TRANSFORM,
color: { brightness: 100, contrast: 100, saturation: 120, hue: 12 },
dmx: { universe: 2, address: 57, mode: '16 Bit' },
},
{
id: '5f5b8a6e-7b9c-4dae-afb0-5e6f7a8b9cad',
z: 8,
name: 'Band Cam PIP',
type: 'media',
status: 'preloaded',
sourceName: 'cam_front_feed',
sourceBank: 'B2·01',
target: 'Local',
timePositionSec: 0,
timeDurationSec: 0,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Loop',
generatorPhase: 0,
transform: { x: 420, y: 210, scale: 32, rotation: 0 },
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '4a6c9b7f-8cad-4ebf-b0a1-6f7a8b9cadbe',
z: 7,
name: 'Titles Gruppe',
type: 'group',
status: 'active',
sourceName: 'Logo + Lower Third',
sourceBank: 'GRP',
target: 'Local',
timePositionSec: 0,
timeDurationSec: 0,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Hold',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '3b7dad8a-9dbe-4fcf-a1b2-7a8b9cadbecf',
z: 6,
name: 'Lower Third Showname',
type: 'media',
status: 'paused',
sourceName: 'lowerthird_v3.png',
sourceBank: 'B1·09',
target: 'Local',
timePositionSec: 5.2,
timeDurationSec: 30,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['timeline'], owner: 'Timeline Intro' },
loopMode: 'Once',
generatorPhase: 0,
transform: { x: 0, y: 320, scale: 100, rotation: 0 },
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '2c8ebc9b-aecf-4da0-b2c3-8b9cadbecfd0',
z: 5,
name: 'Sync Test Pattern',
type: 'generator',
status: 'out_of_sync',
sourceName: 'Grid 1080',
sourceBank: 'GEN',
target: 'Gruppe Ost',
timePositionSec: 3.1,
timeDurationSec: 60,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['automation'], owner: 'Auto-Sync' },
loopMode: 'Loop',
generatorPhase: 3.1,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '1d9fcdac-bfd0-4eb1-c3d4-9cadbecfd0e1',
z: 4,
name: 'Long Loop 4K',
type: 'media',
status: 'warning',
sourceName: 'ambient_4k_loop.mov',
sourceBank: 'B3·02',
target: 'Local',
timePositionSec: 124.8,
timeDurationSec: 600,
opacity: 85,
blend: 'Normal',
fxSlots: [{ name: 'Decode LUT', state: 'active' }, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Loop',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '0eafdecf-cfe1-4fc2-d4e5-adbecfd0e1f2',
z: 3,
name: 'Backup Node Feed',
type: 'media',
status: 'offline',
sourceName: 'backup_feed.mov',
sourceBank: 'B2·07',
target: 'Node backstage-02',
timePositionSec: 0,
timeDurationSec: 0,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Loop',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: 'af1e2d3c-4d5e-4f6a-8b9c-3c4d5e6f7a8b',
z: 2,
name: 'Sponsors Clip defekt',
type: 'media',
status: 'error',
sourceName: 'sponsors_v2.mp4',
sourceBank: 'B3·11',
target: 'Local',
timePositionSec: 0,
timeDurationSec: 8.4,
opacity: 0,
blend: 'Normal',
fxSlots: [{ name: 'Color Grade', state: 'active' }, { name: 'Strobe Mask', state: 'error' }],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Once',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: 'be2f3e4d-5e6f-4a7b-9cad-4d5e6f7a8b9c',
z: 1,
name: 'Intro Loop',
type: 'media',
status: 'preloaded',
sourceName: 'intro_opening.mp4',
sourceBank: 'B1·01',
target: 'Local',
timePositionSec: 0,
timeDurationSec: 45,
opacity: 100,
blend: 'Dissolve',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Once',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
/*
* HMS MediaEngine Design-Tokens (PLAN.md §17.6).
*
* Zentrale, später thematisierbare Ausgangswerte.
* Zustandsfarben ausschließlich für betriebliche Bedeutung;
* Status wird nie allein über Farbe vermittelt (Icon/Text ergänzen).
*/
:root {
/* Flächen (§17.6) */
--surface-app: #0d0f12; /* fast schwarzes Anthrazit (Hintergrund) */
--surface-panel: #16181d; /* Panel-Grau */
--surface-panel-raised: #1e2128; /* Panel-Grau hell */
--surface-hover: #262a33; /* Hover-Fläche */
/* Text (§17.6) */
--text-primary: #c8ccd4; /* helles Neutralgrau */
--text-secondary: #8a8f9a; /* Sekundärgrau */
--text-emphasis: #e8eaee; /* wichtige Werte, hoher Kontrast */
/* Linien */
--border: #23262d;
--border-strong: #2e323b;
/* Akzent: kühles Blau für Selektion/Fokus (§17.6) */
--accent: #4a90d9;
--accent-dim: rgba(74, 144, 217, 0.16);
--accent-line: rgba(74, 144, 217, 0.45);
/* Zustandsfarben (§17.6) keine Deko-Farben */
--state-green: #3fa34d;
--state-yellow: #d4a017;
--state-orange: #d4641e;
--state-red: #c92a2a;
--state-blue: #4a90d9;
/* Spacing: 4-px-Raster (§17.6) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
/* Radien: 24 px, keine Pillen (§17.6) */
--radius-sm: 2px;
--radius-md: 4px;
/* Typografie (§17.6) */
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'SF Mono', ui-monospace, Menlo, monospace;
--fs-xs: 11px;
--fs-sm: 12px;
--fs-md: 13px;
--fs-lg: 14px;
/* Animationen: 100180 ms, keine dekorativen Animationen (§17.6) */
--dur-fast: 100ms;
--dur-med: 180ms;
--ease: ease-out;
/* Layout-Konstanten des Workspace (§17.2) */
--bar-height: 32px; /* obere Statusleiste */
--footer-height: 24px; /* Fußleiste */
--toolbar-width: 48px; /* linke Werkzeugleiste */
--toolbar-rail: 16px; /* eingeklappte Werkzeugleiste */
--sidebar-width: 264px; /* rechte Seitenleiste */
--row-height: 30px; /* Layer-Zeilen, Desktop-Density 2832 px */
--inspector-default: 264px; /* Inspector-Grundhöhe (~30 % bei 900 px) */
/*
* Spaltenraster der Layer-Tabelle (§17.3):
* Status | Layer | Typ | Source | Target | Time | Opacity | Blend | FX | Control
*/
--lt-cols: 24px minmax(150px, 1.6fr) 84px minmax(130px, 1.4fr) minmax(108px, 1fr)
104px 132px 88px 92px 96px;
}
+8
View File
@@ -0,0 +1,8 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Ensure the DOM is cleaned up between tests (globals are disabled).
afterEach(() => {
cleanup();
});
+216
View File
@@ -0,0 +1,216 @@
/**
* Domain- und API-Typen der Weboberfläche.
*
* Spiegel der Verträge aus apps/control_server/hms_control_server/app.py
* sowie PLAN.md §10 (Domäne), §17 (Browseroberfläche) und §23 (API).
* Der Server bleibt autoritativ; der Client synchronisiert nur Zustand (§3.2, §17.9).
*/
/* ---------- Layer-Domäne (§17.3) ---------- */
/** Zustände, die §17.10 visuell eindeutig unterscheidbar verlangt. */
export type LayerStatus =
| 'active'
| 'paused'
| 'preloaded'
| 'warning'
| 'error'
| 'offline'
| 'out_of_sync';
export type LayerType = 'media' | 'generator' | 'adjustment' | 'group';
export type FxSlotState = 'active' | 'bypassed' | 'error';
export interface FxSlot {
readonly name: string;
readonly state: FxSlotState;
}
export type ControlSourceTag = 'web' | 'artnet' | 'timeline' | 'automation';
export interface ControlInfo {
readonly sources: readonly ControlSourceTag[];
readonly owner: string;
}
export interface TransformParams {
readonly x: number; // px
readonly y: number; // px
readonly scale: number; // %
readonly rotation: number; // Grad
}
export interface ColorParams {
readonly brightness: number; // %
readonly contrast: number; // %
readonly saturation: number; // %
readonly hue: number; // Grad
}
export interface DmxInfo {
readonly universe: number; // 0..63
readonly address: number; // 1..512, 16-Bit-Kanäle folgen §16
readonly mode: string;
}
export interface Layer {
/** UUID Bestandteil des Parameterpfads (§10.2), nie der sichtbare Name. */
readonly id: string;
readonly z: number;
readonly name: string;
readonly type: LayerType;
readonly status: LayerStatus;
readonly sourceName: string;
/** Bank/Index-Kurzform wie „B1·04“, „GEN“, „GRP“ oder „ADJ“. */
readonly sourceBank: string;
readonly target: string;
readonly timePositionSec: number;
readonly timeDurationSec: number; // 0 = unbegrenzt (∞)
readonly opacity: number; // 0..100 (UI); die Engine speichert 0..1
readonly blend: string;
readonly fxSlots: readonly [FxSlot | null, FxSlot | null];
readonly control: ControlInfo;
readonly loopMode: string;
readonly generatorPhase: number; // s, nur Generatoren
readonly transform: TransformParams;
readonly color: ColorParams;
readonly dmx: DmxInfo;
}
/** Feingranulare Änderungen an einem Layer (Tabelle/Inspector). */
export interface LayerPatch {
readonly name?: string;
readonly opacity?: number;
readonly blend?: string;
readonly loopMode?: string;
readonly timeDurationSec?: number;
readonly generatorPhase?: number;
readonly transform?: TransformParams;
readonly color?: ColorParams;
readonly dmx?: DmxInfo;
readonly fxSlots?: readonly [FxSlot | null, FxSlot | null];
}
/* ---------- REST-Vertrag (§23.2, app.py) ---------- */
export interface HealthResponse {
readonly status: string;
readonly phase: number;
readonly node_id: string;
readonly revision: number;
}
export interface IdentityResponse {
readonly node_id: string;
readonly display_name: string;
readonly roles: readonly string[];
readonly renders_locally: boolean;
readonly is_coordinator: boolean;
}
export type NodeHealth = 'online' | 'degraded' | 'stale' | 'offline';
export interface ClusterNode {
readonly node_id: string;
readonly display_name: string;
readonly roles: readonly string[];
readonly health: NodeHealth;
readonly category: string;
}
export interface ClusterNodesResponse {
readonly self: string;
readonly nodes: readonly ClusterNode[];
}
export interface ParametersResponse {
readonly revision: number;
readonly values: Readonly<Record<string, number>>;
}
export interface DiagnosticsResponse {
readonly renderer: string;
readonly artnet: string;
readonly revision: number;
readonly node_id: string;
}
export interface CommandActor {
readonly type: string;
readonly id: string;
}
export interface ParameterSetPayload {
readonly parameter_path: string;
readonly value: number;
}
/** Command-Envelope §23.2: command_id, type, expected_revision, actor, payload. */
export interface CommandEnvelope {
readonly command_id: string;
readonly type: string;
readonly expected_revision: number | null;
readonly actor: CommandActor;
readonly payload: ParameterSetPayload;
}
export interface CommandAck {
readonly status: string;
readonly command_id: string;
readonly revision: number;
readonly effective: number;
readonly duplicate?: boolean;
}
export interface ReleaseResponse {
readonly status: string;
}
/* ---------- WebSocket-Vertrag (§17.9, app.py /ws) ---------- */
export interface WsSnapshot {
readonly type: 'snapshot';
readonly revision: number;
readonly values: Readonly<Record<string, number>>;
}
export interface WsParameterUpdate {
readonly type: 'parameter.update';
readonly parameter_path: string;
readonly value: number;
readonly revision: number;
}
export interface WsHeartbeat {
readonly type: 'heartbeat';
}
export type WsServerEvent = WsSnapshot | WsParameterUpdate | WsHeartbeat;
/* ---------- UI-Zustände (§17.2, §17.4, §17.5) ---------- */
export type AlertSeverity = 'info' | 'warning' | 'error';
export interface AlertItem {
readonly id: number;
readonly timeMs: number;
readonly severity: AlertSeverity;
readonly message: string;
}
export type SidebarTab = 'active' | 'nodes' | 'alerts';
export type InspectorTab = 'layer' | 'source' | 'transform' | 'color' | 'fx' | 'dmx' | 'advanced';
export type ToolId =
| 'mixer'
| 'media'
| 'effects'
| 'presets'
| 'outputs'
| 'network'
| 'plugins'
| 'diagnostics';
export type OpMode = 'setup' | 'live';
+108
View File
@@ -0,0 +1,108 @@
/**
* Formatierungs- und Mapping-Helfer der Weboberfläche.
* Zeitkritische Werte werden tabellarisch monospaced dargestellt (§17.6).
*/
import type { LayerStatus, LayerType } from './types';
/** Klassennamen kompakt zusammenführen; leere/false-Teile entfallen. */
export function cx(...parts: ReadonlyArray<string | false | null | undefined>): string {
return parts.filter((p): p is string => typeof p === 'string' && p.length > 0).join(' ');
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/** Sekunden → „M:SS.t“ (tabellarisch, Zehntelsekunden). */
export function formatTimecode(totalSec: number): string {
const safe = Number.isFinite(totalSec) && totalSec >= 0 ? totalSec : 0;
const minutes = Math.floor(safe / 60);
const seconds = Math.floor(safe % 60);
const tenth = Math.floor((safe % 1) * 10);
return `${minutes}:${seconds.toString().padStart(2, '0')}.${tenth}`;
}
/** Uhrzeit HH:MM:SS (lokal). */
export function formatClock(date: Date): string {
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
const s = date.getSeconds().toString().padStart(2, '0');
return `${h}:${m}:${s}`;
}
export function formatTimeMs(ms: number): string {
return formatClock(new Date(ms));
}
/** FPS mit zwei Dezimalstellen (§17.2); null = keine Messung. */
export function formatFps(v: number | null): string {
return v === null ? '—' : v.toFixed(2);
}
/** Deutsche Zahleneingabe: „12,5“ → 12.5; null wenn nicht parsebar. */
export function parseNumberInput(raw: string): number | null {
const trimmed = raw.trim().replace(',', '.');
if (trimmed === '') return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
export function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return 'unbekannter Fehler';
}
/** Opacity: Engine speichert 0..1, die UI zeigt 0..100. */
export function engineToOpacity(v: number): number {
return clamp(Math.round(v * 100), 0, 100);
}
export function opacityToEngine(v: number): number {
return clamp(v, 0, 100) / 100;
}
/** Text-Label je Zustand Status nie allein über Farbe (§17.6). */
export const STATUS_LABEL: Record<LayerStatus, string> = {
active: 'Aktiv',
paused: 'Pausiert',
preloaded: 'Vorgeladen',
warning: 'Warnung',
error: 'Fehler',
offline: 'Offline',
out_of_sync: 'Nicht synchron',
};
/** Farbtöne je Zustand (ergänzt durch Glyphe/Text, nie allein). */
export const STATUS_TONE: Record<LayerStatus, string> = {
active: 'st-active',
paused: 'st-paused',
preloaded: 'st-preloaded',
warning: 'st-warning',
error: 'st-error',
offline: 'st-offline',
out_of_sync: 'st-out-of-sync',
};
export const TYPE_LABEL: Record<LayerType, string> = {
media: 'Media',
generator: 'Generator',
adjustment: 'Adjustment',
group: 'Group',
};
export const BLEND_MODES: readonly string[] = [
'Normal',
'Add',
'Screen',
'Multiply',
'Dissolve',
'Lighten',
];
export const LOOP_MODES: readonly string[] = ['Loop', 'Once', 'Ping-Pong', 'Hold'];
/** Parameterpfad §10.2 (packages/parameter_engine/hms_parameter/paths.py). */
export function layerOpacityPath(compositionId: string, layerId: string): string {
return `composition/${compositionId}/layer/${layerId}/opacity`;
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
/**
* Typisierte Vite-Umgebungsvariablen (apps/web).
*
* VITE_WS_URL: optionale direkte WebSocket-URL zum Control Core, z. B.
* „ws://localhost:8000/ws“, wenn die Oberfläche nicht über den
* Same-Origin-Proxy (Vite-Dev-Server/Reverse-Proxy) läuft.
*/
interface ImportMetaEnv {
readonly VITE_WS_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
File diff suppressed because one or more lines are too long
+32
View File
@@ -0,0 +1,32 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
/**
* Vite-Konfiguration der HMS-MediaEngine-Weboberfläche (§17).
*
* Dev-Server-Proxy auf den Control Core (apps/control_server, Port 8000):
* - '/api' als HTTP-Proxy
* - '/ws' als WebSocket-Proxy (Snapshot + Deltas, §17.9)
* Same-Origin im Dev-Betrieb; produktiv übernimmt der Launcher/Reverse-Proxy.
*/
const proxy = {
'/api': {
target: 'http://localhost:8000',
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
} as const;
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy,
},
preview: {
port: 4173,
proxy,
},
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: false,
setupFiles: ['./src/test/setup.ts'],
},
});