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:
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
"""hms_renderer – Render-Worker (PLAN.md §6.1C, §12, §36 Nr. 4–6).
|
||||
|
||||
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. 4–5).
|
||||
|
||||
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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1464
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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: 2–4 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: 100–180 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 28–32 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user