AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert

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

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

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

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

Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 23:44:06 +02:00
parent 696e8eb1b3
commit 362e089be0
338 changed files with 24 additions and 387 deletions
+18
View File
@@ -0,0 +1,18 @@
# Changelog
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung: [SemVer](https://semver.org/).
## [Unreleased]
### Added
- Repository-Struktur gemäß Bauplan §8 (Eigentumsgrenzen)
- Pflichtdokumente STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADR-Vorlage
- ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows), ADR-0003 IPC TCP+MessagePack v1
- Kernpakete: `hms_protocol`, `hms_parameter`, `hms_artnet`, `hms_adaptive`, `hms_capabilities`, `hms_plugin_sdk`, `hms_domain`
- Minimaler Control Core (FastAPI REST + WebSocket, Commands mit Revision/Idempotenz)
- Renderer-Spike (D3D11-Primärpfad + ausdrücklich gekennzeichneter Dev-GL-Pfad)
- Beispielplugin `com.hms.fx.example_passthrough` (HLSL/GLSL/GLES)
- Tools: Art-Net-Emulator, Capability-Probe, Plugin-Validator
- Unit-/Integrationstests für Phase-0-Grundlage
+26
View File
@@ -0,0 +1,26 @@
# ERRORS
Fehlerverzeichnis gemäß PLAN.md §32.
Format je Fehler:
| Feld | Inhalt |
| --- | --- |
| ID | ERR-000 |
| Priorität | P0P3 |
| Reproduktion | Schritte |
| Erwartet | Verhalten |
| Tatsächlich | Verhalten |
| Plattform/Hardware | OS, GPU, Treiber |
| Logs/Screenshots | Pfad/Verweis |
| Ursache | Analyse |
| Fix-Commit | Commit-Hash |
| Regressionstest | Test-ID |
## Offene Fehler
Keine.
## Behobene Fehler
Keine.
+2831
View File
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
# HMS MediaEngine
**Arbeitstitel** der Produktname kann später ohne technische Auswirkung geändert werden.
Modularer Medienserver, VJ-System und generativer Licht-/Pixeleffekt-Server.
- **Primärplattform:** Windows 11 x64, portabel ohne Installation
- **Weitere Zielplattformen:** Linux x64, Raspberry Pi 5 / Linux ARM64
- **Steuerung:** lokale Webanwendung (Browser), Art-Net/DMX, spätere Timeline/Audio/KI
## Leitsatz
> **Python steuert. Native Bibliotheken decodieren. Die GPU rendert. Der Browser bedient.**
## Projektstatus
Das Projekt befindet sich in **Phase 0 technischer Spike und Go/No-Go**.
Aktueller Stand, Gates und nächste Aufgaben: [`STATUS.md`](STATUS.md)
Fehlerverfolgung: [`ERRORS.md`](ERRORS.md)
Messergebnisse: [`TEST_REPORT.md`](TEST_REPORT.md)
Bauplan (normativ): [`PLAN.md`](PLAN.md)
## Repository-Struktur (Eigentumsgrenzen)
```text
/
├─ apps/ launcher, control_server, renderer, web
├─ native/ render_bridge (Rust oder C++, Entscheidung per ADR)
├─ packages/ domain, protocol, parameter_engine, render_backend,
│ capabilities, adaptive_quality, plugin_sdk, artnet,
│ cluster, content_sync, timeline, audio_analysis,
│ persistence
├─ plugins/ builtin (generators, filters, transitions, outputs), examples
├─ schemas/ project, plugin, ipc, cluster, api
├─ fixture_profiles/ master32, layer64
├─ tests/ unit, integration, rendering, cluster, visual,
│ performance, portability, e2e
├─ tools/ media_probe, shader_validate, artnet_emulator,
│ capability_probe, cluster_test_node, fixture_generator
├─ build/ windows, linux, raspberry_pi
└─ docs/ architecture, adr, plugin-sdk, api, fixture,
performance, operator
```
Code darf nicht beliebig zwischen Paketen quer importiert werden.
## Entwicklung
```bash
# Python-Umgebung
uv sync
# Tests
uv run pytest
# Lint + Typprüfung
uv run ruff check .
```
Frontend (ab Phase 5): `pnpm` mit Lockfile, Vite-Build, siehe `apps/web`.
## Phasenmodell
Die Entwicklung folgt strikt dem Phasenmodell aus `PLAN.md` Abschnitt 31.
Jede Phase endet mit einem Gate; **keine neue Phase ohne grünes Gate.**
| Phase | Inhalt | Gate
| --- | --- | --- |
| 0 | Technischer Spike, Machbarkeitsnachweis, ADRs | Gate 0: D3D11-HW-Decode, GPU-Compositing, Adaptive Quality, Art-Net-Latenz, portable Auslieferung reproduzierbar grün
| 1 | Fundament: Supervisor, Control Core, Renderer, IPC, Node-Identität, Discovery, Paarung | Gate 1
| 2 | Medien-, Layer-, Basissync-Engine | Gate 2
| 3 | Plugin-SDK, Starterpaket Generatoren + Filter | Gate 3
| 4 | Art-Net-Fixtures Master32/Layer64 | Gate 4
| 5 | Vollständige Browser-Liveoberfläche | Gate 5 / V1.0-Kernrelease
| 6+ | Cue/Timeline, Audio, Mapping, Pixel-Ausgabe, KI, Pi 5, Härtung | eigene Gates
## Verbindliche Regeln (Auszug)
- Keine Pixelverarbeitung in Python-Schleifen.
- Kein CPU-Readback im normalen HDMI-Renderpfad (Windows: D3D11Memory durchgängig).
- Kein Mock als fertige Funktion gemeldet; Hardwaretests nur auf echter Hardware.
- Jede Architekturabweichung braucht ein ADR und Freigabe.
- Projektschema versioniert, Migrationen getestet.
## Dokumentation
- Architektur: `docs/architecture/`
- ADRs: `docs/adr/` (Vorlage: `docs/adr/_template.md`)
- Plugin-SDK: `docs/plugin-sdk/` (ab Phase 3)
- Fixture-Handbücher: `docs/fixture/` (ab Phase 4)
## Lizenz
TBD wird mit dem ersten Release entschieden (SBOM und Lizenzverzeichnis sind Teil der Release-Anforderungen, PLAN.md Abschnitt 30).
+163
View File
@@ -0,0 +1,163 @@
# STATUS
Stand: 2026-09-11
## Aktuelle Phase
**Phase 1 Fundament und Netzwerkbasis** unter der **Build-first-Strategie
(ADR-0008)**: Die Phasen 15 werden plattformneutral vollständig gebaut;
Gate 0 und alle renderer-nahen Abnahmen werden gesammelt in einem späteren
Windows-Durchlauf validiert. Auftraggeber-Freigabe für die Reihenfolgeabweichung
vom 2026-09-11, dokumentiert in ADR-0008 gemäß PLAN.md §1.
## Letzter grüner Commit
- siehe `git log` und `TEST_REPORT.md` jede Etappe endet mit grünem pytest
und Ruff und wird sofort nach Forgejo gepusht.
## Bestandene Gates
- keine; **Gate 0 bleibt offen** bis zum Windows-Hardware-Durchlauf
(Testplan in ADR-0008)
## Arbeitsweise (ADR-0008)
- Build-first: plattformneutral fertig bauen, Hardware-Validierung gesammelt
am Ende (Windows-Durchlauf).
- Kein Gate wird als grün gemeldet, solange der Windows-Durchlauf offen ist.
- Renderer-Nähe ausschließlich über den Backend-Vertrag (§12.6); Korrekturen
bleiben lokal im Adapter.
## Laufende Arbeit
Phase 0 (abgeschlossen, soweit ohne Hardware möglich):
- [x] Repository, Pflichtdokumente, ADRs 00010003
- [x] GStreamer-Pin 1.28.6, Kernpakete, Control Core, Renderer-Spike
- [x] Beispielplugins (Passthrough, Gaussian Blur mit 3 AQ-Varianten), Tools
- [x] 121 Unit-/Integrationstests grün, Ruff grün (Belege: TEST_REPORT.md)
Phase 1 (plattformneutral abgeschlossen, ADR-0008):
- [x] IPC-Verbindung Control Core ↔ Renderer: Handshake (hello/welcome),
vollständiger Snapshot nach Verbindung, Deltas mit monotoner Revision,
Command-Acks idempotent über message_id, Heartbeat 500 ms in beide
Richtungen, Re-Sync nach Reconnect, ausschließlich Loopback (§6.2)
- [x] SQLite-Persistenz (WAL, Migrationen mit Backup, Integritätscheck,
Projekt-/Einstellungs-/Plugin-Status-CRUD, §24)
- [x] Launcher/Supervisor: echte Kindprozesse, portable Umgebung, freie
Portwahl, kontrolliertes Beenden (terminate→kill) mit Recovery-
Markierung, Restart-Policy mit Crashloop-Erkennung (§6.1A, §26.2, §26.4)
- [x] Cluster-Nachrichtenhülle: Pflichtfelder, Sequenzen, Revisionen,
Trace-ID, Idempotenz-Tracker mit vorwärts-only-Statuskette (§6.5)
- [x] Node-Registry: online/degraded/stale/offline über Schwellen,
UI-Kategorien discovered/paired/unknown/incompatible/offline,
Doppel-Node-ID-Fehler, IP-Wechsel erhält node_id (§6.3, §6.5)
- [x] Paarung: kurzlebige PIN (TTL 120 s, Versuchslimit), sichtbarer
Fingerprint, Token nur als Hash mit Scopes read/control/content_sync/
admin, Ablauf und sofortiger Widerruf (§6.3, §27.1, ADR-0010)
- [x] Discovery-Modell: mDNS-Service _hmsmedia._tcp.local. mit TXT ohne
Secrets, Capability-Digest, persistente manuelle Fallback-Liste
(ADR-0009)
- [x] Node-Identität + Rollen in der App-Verkabelung: Control Core lädt
persistente node_id (Launcher/Produktion) bzw. Dev-ephemeral,
Registry registriert die eigene Node, Endpunkte /system/identity
und /cluster/nodes nach UI-Kategorien (§3.6, §6.3, §27.1)
- [ ] mDNS-Echtnetz-Betrieb mit zeroconf auf Zielsystemen (Modell fertig;
Multicast-Test gehört zu Gate 1, ADR-0009)
Phase 2 (im Bau):
- [x] Domänenmodell: Project/Composition/Layer/Source/EffectInstance/
MediaAsset/OutputSurface/PresetScene mit Validierungen (§10.1)
- [x] Medien-Engine: PlaybackController (Play/Pause/Stop/Retrigger, Loop/
Once/Ping-Pong, In/Out, ±4x Speed, Ende-Ereignis), PreloadSlot für
atomaren Clipwechsel, MediaLibrary mit Duplikaterkennung und Bank-
Slots, ContentManifest mit SHA-256 und Chunk-Hashes (§12, §13, §6.4)
- [x] Project-State-Store mit monotonen Revisionen, Snapshot/Delta
(neu/geändert/gelöscht), Szenenaktivierung als Zielzustand,
Projekt-/Livezustand getrennt (§6.4, §24.2, §18.1)
- [x] Servergruppen + Zielrouting: All/Node/Output/Group, Zielregeln
selected/tag_query/all, Commit-Vorschau (§6.3, §10.1, §17.5)
- [x] Clock-Offset-/Drift-Messung: RTT-Min-Filter (≤2× Min), Drift erst
ab 1 s Fenster, Showzeit→lokale-Zeit-Abbildung (§6.4)
- [x] zeitgestempelte Preset-Aktivierung: Vorlauf 200 ms, Arm/Execute/
Ack, FAILED bei fehlender Arm-Bestätigung (§6.5)
- [x] Renderer-Anbindung: RemoteStateMirror (Renderer) + RendererStateLink
(Control Core) über IPC; Pflicht-Snapshot nach (Re-)Connect, danach
Deltas (neu/geändert/gelöscht), Deltas vor Snapshot abgelehnt,
Duplikate idempotent; End-to-End-Integrationstest über echtes
TCP-Loopback ohne Mocks (§6.2, §6.4)
Phase 3 (plattformneutral abgeschlossen, ADR-0008):
- [x] Plugin-Lifecycle-Manager: Zustandsgraph discovered→validated→
installed→enabled→compiled→active mit quarantined/incompatible,
Show-Lock-Schutz (§14.5, §14.6, §26.3), Versionsupdate-Zyklus
- [x] Alle 10 Pflicht-Generatoren (§15.1): solid, gradient, checker_grid,
stripes_chaser, noise_clouds, plasma, wave_bars, shapes,
drops_ripples, starfield je HLSL/GLSL/GLES + AQ-Varianten
- [x] Alle 14 Pflicht-Filter (§15.2): transform2d, color_adjust,
gradient_map, blur_sharpen (separabel 2-Pass), pixelate_quantize,
mirror_tile, kaleidoscope, wave_displace, rgb_split, glow_bloom,
edge_emboss, vignette, strobe_pulse (Safety ≤2 Hz), feedback_trails
- [x] SDK-Dokumentation: 7 Dateien in docs/plugin-sdk/ (Manifest-Referenz,
Shader-Vertrag, DMX-Slots, Lifecycle, AQ, Beispiel-Walkthrough)
- [ ] Golden-Image-Tests-Infrastruktur (Ausführung auf Windows-Hardware)
- [ ] Backend-Adapter: Shader-Loader für D3D11/GL/GLES (Rust, ADR-0004)
Phase 4 (im Bau):
- [x] Fixture-Engine Master32: 32-Kanal-Dekodierung mit 16-Bit-Werten,
Blackout/Freeze, Flanken-Trigger, reservierte Kanäle neutral (§16.3)
- [x] Fixture-Engine Layer64: 64-Kanal-Dekodierung mit modusabhängigem
Source-Block, Load/Commit-Semantik mit pending selection,
Pickup/Takeover, Retrigger, FX1/FX2 P1P8 (§16.4, §16.5)
- [x] Speed-Mapping §16.6: Mittelpunkt=Pause, ±4×, definierter 1×-Wert
40960, monoton; ursprünglicher Implementierungsfehler korrigiert
- [x] UniversePlan: kollisionsfreie Bereiche je Node, Überschneidung =
Blocker, Preflight (§16.2)
- [ ] Fixture-Verkabelung: Fixture-Engine an Art-Net-Receiver und
Parameter-Engine anbinden (Layer-Komposition je Universe)
- [ ] Patchverwaltung + Patch-Export mit Node-ID/Universe (§16.2)
- [ ] Kanalliste-Export (CSV existiert; PDF/GDTF folgt in Phase 4 Rest)
Phase 5 (im Bau):
- [x] ADR-0006: React mit Vite im statischen SPA-Modus
- [x] Workspace-Layout §17.2: Statusleiste, Werkzeugleiste, Layer-Tabelle,
Inspector, rechte Seitenleiste, Fußleiste mit Setup/Live-Lock
- [x] Layer-Tabelle §17.3: alle 10 Pflichtspalten, 7 Zustände mit
Glyph+Farbe+Text, Pfeiltasten-Navigation, echter parameter.set
- [x] Inspector §17.4: 7 Tabs, Zahlenfelder mit Tippen/Ziehen/Tastatur
- [x] Design-Tokens §17.6: alle als CSS-Variablen
- [x] WebSocket: Snapshot→Delta, Reconnect mit Backoff
- [ ] Projekt-/Layer-API im Control Core (echte Daten statt Demo-Seed)
- [ ] Media-Browser, DMX-Patch-Seite, Presets, Diagnostics
- [ ] Playwright-Referenz-Screenshots (§17.10)
- [ ] Auth für LAN-Zugriff (§17.9)
## Nächste drei Aufgaben
1. Windows-Testdurchlauf vorbereiten: Checkliste ADR-0008 auf Zielsystem
2. SBOM und Lizenzverzeichnis erzeugen (§30.1)
3. Playwright-Referenz-Screenshots für visuelle Abnahme (§17.10)
## Ausstehende Hardware-Validierung (ADR-0008 Testplan)
Nur auf echtem Windows mit GPU messbar; nichts davon gilt als erledigt:
- D3D11-Hardwaredecode aktiv; D3D11Memory durchgängig ohne CPU-Rundweg
- Framezeit p50/p95/p99; DMX → sichtbarer Frame p95 ≤ 2 Frames
- Adaptive-Quality-Wechsel ohne Stall; Golden Images; Shader-Compile auf GPU
- Portable Onefolder-Ausgabe auf sauberem Rechner (§29.6)
- Rust-Renderkern-Kompilierung und GStreamer-Plugin-Bindung (ADR-0004)
## Bekannte Blocker
- Windows-Referenzhardware fehlt in der Entwicklungsumgebung
(Linux-Container, CPU-only) Kernblocker, durch ADR-0008 gemanagt.
- Rust-Toolchain (cargo) fehlt im Container: Rust-Quellcode wird entwickelt,
das Kompilierungsgate erfolgt im Windows-Durchlauf.
- pnpm fehlt im Container: wird für Phase 5 über Node-Corepack aktiviert
(kein Installationsblocker).
+73
View File
@@ -0,0 +1,73 @@
# TEST REPORT
Testberichte gemäß PLAN.md §32. Jeder Eintrag: Testdatum, Commit, Hardware/OS/Treiber, Ergebnisse, offene Abweichungen.
## Automatisierte Tests (Entwicklungsumgebung)
| Datum | Commit | Plattform | Ergebnis |
| --- | --- | --- | --- |
| 2026-09-11 | 0922cc1 (initiale Phase-0-Grundlage) | Kali-Linux-Container, Python 3.13.14, uv, CPU-only | **121 passed** (Unit + Integration), Ruff 0 Fehler |
Testumfang und Ergebnis der Phase-0-Grundlage (alle grün):
- `tests/unit/test_protocol.py` IPC-Envelope, length-prefixed MessagePack-Framing, Idempotenz (10 Tests)
- `tests/unit/test_parameter_engine.py` Prioritäten, LTP/HTP, Release, Frame-Snapshot, Revision-Konflikt (11 Tests)
- `tests/unit/test_artnet_packets.py` ArtDMX/ArtPoll/ArtPollReply gegen offizielle Spezifikation (22 Tests)
- `tests/unit/test_adaptive_quality.py` Hysterese, eine Stufe je Intervall, Mindesthaltezeit, kein Pumpen (8 Tests)
- `tests/unit/test_plugin_manifest.py` Manifest-/ZIP-Validierung, Pfadsicherheit, DMX-Slot-Limit (16 Tests)
- `tests/unit/test_domain_ids.py` stabile Node-ID ohne IP/Hostname-Abhängigkeit (6 Tests)
- `tests/unit/test_fixture_generator.py` Master32/Layer64-Kanallisten (8 Tests)
- `tests/unit/test_pipelines.py` Renderer-Pipeline-Definitionen D3D11/Dev-GL (7 Tests)
- `tests/unit/test_dmx_mapping.py` RisingEdge, 16-Bit-Decoder, Signalverlust-Policies, CONSOLE-Priorität (10 Tests)
- `tests/unit/test_capabilities.py` Tier-Vergabe nur nach Messwerten, kein Fake (6 Tests)
- `tests/unit/test_portable_paths.py` portable Pfade, GStreamer-Environment (5 Tests)
- `tests/integration/test_control_server.py` REST-Health, Commands, Revision-Konflikt, Idempotenz, WebSocket (12 Tests)
- `tests/integration/test_artnet_receiver.py` echter Loopback-UDP-Empfang, ArtPollReply als Media Server, Allowlist (3 Tests)
- `tests/unit/test_persistence.py` WAL/FK/PRAGMA, Migrationen mit Backup, Altdaten, Projekte, Settings, Plugin-Status (18 Tests)
- `tests/unit/test_supervisor.py` echte Kindprozesse: Start/Stop, Crash-Restart, Crashloop, Recovery-Markierung, Environment (10 Tests)
- `tests/unit/test_cluster.py` ClusterMessage/CommandTracker, NodeRegistry-Health/Kategorien, Paarung PIN/Fingerprint/Token/Widerruf, Discovery-TXT, manuelle Liste (25 Tests)
- `tests/integration/test_ipc_connection.py` echter TCP-Loopback, kein Mock (9 Tests)
- `tests/integration/test_state_sync_chain.py` State-Sync-Kette Store→IPC→Mirror ohne Mocks (4 Tests)
- `tests/unit/test_plugin_lifecycle.py` Lifecycle-Graph, Quarantäne, Show-Lock (14 Tests)
- `tests/unit/test_builtin_plugins.py` parametrisiert über alle 24 Pflicht-Plugins: Manifest, Backends, DMX-Slots ≤8, AQ ≥3 Varianten, Uniform-Vertrag, Generator-Sampling-Verbot, Filter-Mix-Bypass, Vollständigkeit 10+14 (195 Tests)
## Builtin-Plugin-Validierung (Phase 3)
**Alle 24 Pflicht-Plugins aus PLAN.md §15 validiert (ALL OK):**
- 10 Generatoren (§15.1): solid, gradient, checker_grid, stripes_chaser, noise_clouds, plasma, wave_bars, shapes, drops_ripples, starfield je Manifest + HLSL + GLSL + GLES, alle mit AQ-Varianten (z. B. noise_clouds: Octaves 2/3/5; starfield: 32/128/512 Partikel)
- 14 Filter (§15.2): transform2d, color_adjust, gradient_map, blur_sharpen (2-Pass separabel), pixelate_quantize, mirror_tile, kaleidoscope, wave_displace, rgb_split, glow_bloom, edge_emboss, vignette, strobe_pulse (Rate hart auf 2 Hz geklemmt, Safety-Doku), feedback_trails (Double-Buffer, Clear-Trigger)
- 25 HLSL-Dateien, 50 GLSL/GLES-Dateien, 24 Manifeste; alle semantisch identisch über d3d11/gl/gles (§12.6)
- Uniform-Vertrag §14.4 in jedem Shader geprüft; Generatoren sampeln nie die Eingabetextur; Filter mit mix-0-Bypass (§15.3)
**Hinweis (ADR-0008):** Shader-Kompilierung auf GPU und Golden Images sind Teil des Windows-Durchlaufs (§29.3); hier vorliegend: Manifest-/Struktur-/Konventions-Validierung.
Werkzeug-Rauchtests: Fixture-CSVs (32/64 Kanäle, 8×64=512) generiert;
Renderer-Dry-Run gibt korrekte D3D11-Pipeline aus; alle JSON-Schemas und
Plugin-Manifeste parsebar; Plugin-Validierung der Beispielplugins fehlerfrei.
## Performancewerte (Gate-0-Messungen)
**Status: ausstehend.** Gate-0-Messungen sind ausschließlich auf Referenz-Windows-Hardware gültig (PLAN.md §29.7, §33). Zu messen:
- D3D11-Hardwaredecode aktiv (kein Software-Decoder) für H.264-Testclip
- GPU-Residenz: `D3D11Memory` von Decoder bis `d3d11videosink` ohne regulären CPU-Rundweg
- Framezeit p50/p95/p99 bei 1080p60 (Mini-PC) und 4K60 (`DESKTOP_FULL`)
- DMX→sichtbarer Frame: p95 ≤ 2 Frames
- Adaptive-Quality-Wechsel (3 Blur-Stufen) atomar ohne Semantikänderung/Stall
## Art-Net-Hardwaretest
Ausstehend Emulator (`tools/artnet_emulator`) bereit; Test gegen echtes Lichtpult in Phase 4 (Gate 4).
## Portabilitätstest
Ausstehend saubere Windows-VM ohne Python/Node/GStreamer, kein Admin (PLAN.md §29.6). Onefolder-Build nach Packaging-ADR (ADR-0005).
## Soak-Zeit
Ausstehend 4 h Desktop / 2 h Pi gemäß §25.125.3.
## Offene Abweichungen
- Entwicklungsumgebung ist ein CPU-only-Linux-Container: keine D3D11-, Display- oder Art-Net-Hardwaremessung möglich. Kein Ergebnis in diesem Report darf als Hardwarenachweis gewertet werden.
@@ -0,0 +1,9 @@
"""hms_control_server minimaler Control Core (PLAN.md §6.1B, §36 Nr. 9).
FastAPI-REST + WebSocket für denselben Parametersatz, den Art-Net bedient.
Autoritative Instanz ist die ParameterEngine; alle Quellen laufen über sie.
"""
from hms_control_server.app import create_app
__all__ = ["create_app"]
@@ -0,0 +1,10 @@
"""Control-Core-Start (Entwicklung): python -m hms_control_server"""
from __future__ import annotations
import uvicorn
if __name__ == "__main__":
# Phase 0: Development-Start. Produktion startet über den Launcher,
# bindet 127.0.0.1 und wählt freie Ports (§3.1, §9.2).
uvicorn.run("hms_control_server.app:app", host="127.0.0.1", port=8000)
@@ -0,0 +1,433 @@
"""FastAPI-Anwendung des Control Core.
Endpunkte:
- GET /api/v1/system/health
- GET /api/v1/system/identity (node_id, display_name, roles; nicht vertraulich)
- GET /api/v1/system/capabilities
- GET /api/v1/parameters
- POST /api/v1/commands (parameter.set mit Revision-Prüfung und Idempotenz)
- POST /api/v1/commands/{command_id}/release
- GET /api/v1/diagnostics
- GET /api/v1/cluster/nodes
- GET /api/v1/projects, POST/GET/PUT/DELETE /api/v1/projects/{id}
- POST /api/v1/media/import
- GET /api/v1/media
- GET /api/v1/plugins
- GET /api/v1/artnet/status
- WS /ws (State-Snapshot + Updates)
Commands folgen §23.2: command_id, type, expected_revision, actor, payload.
Node-Identität: persistente node_id aus userdata/identity (§3.6, §6.3);
im Dev-Modus ohne App-Root wird eine ephemeral-Identität erzeugt.
"""
from __future__ import annotations
import asyncio
import uuid
from pathlib import Path
from fastapi import FastAPI, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from hms_capabilities.probe import CapabilityReport
from hms_cluster.registry import NodeRegistry
from hms_domain.identity import NodeIdentity, NodeRole
from hms_domain.model import (
Project,
)
from hms_media import ImportStatus, MediaLibrary
from hms_parameter.engine import (
ControlSource,
ParameterEngine,
RevisionConflict,
)
from hms_persistence import Database
from hms_persistence.state_store import ProjectStateStore
from hms_protocol.idempotency import IdempotencyRegistry
from pydantic import BaseModel, Field
class SetParameterCommand(BaseModel):
"""parameter.set-Command (§23.2)."""
command_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
type: str = "parameter.set"
expected_revision: int | None = None
actor: dict = Field(default_factory=lambda: {"type": "web", "id": "operator-session"})
payload: dict
class ReleaseCommand(BaseModel):
source: str = "web"
class CreateProjectBody(BaseModel):
name: str = "Neues Projekt"
def _builtin_plugin_dir() -> Path:
"""Löst das Builtin-Plugin-Verzeichnis robust auf (§14.2).
Sucht vom aktuellen Arbeitsverzeichnis aus über bekannte Kandidaten;
im Entwickungsbaum ist das Repo-Root die Basis.
"""
candidates = [
Path.cwd() / "plugins" / "builtin",
Path(__file__).resolve().parents[3] / "plugins" / "builtin",
]
for candidate in candidates:
if candidate.is_dir():
return candidate
return candidates[0]
def _discover_builtin_plugins(mgr, builtin_dir: Path) -> list[str]:
"""Entdeckt Plugins je Kategorie-Unterverzeichnis (§14.2)."""
errors: list[str] = []
for category in sorted(builtin_dir.iterdir()):
if not category.is_dir():
continue
errors.extend(mgr.discover(category))
return errors
class _State:
def __init__(self, identity: NodeIdentity, data_dir: Path | None = None) -> None:
self.identity = identity
self.engine = ParameterEngine()
self.registry = IdempotencyRegistry()
self.report = CapabilityReport()
self.nodes = NodeRegistry()
self.subscribers: list[asyncio.Queue] = []
# Persistenz und Projekt-Verwaltung (§24, §6.4)
self.data_dir = data_dir or Path("/tmp/hms-dev-data")
self.database = Database(self.data_dir / "database" / "hms.db")
self.database.open()
self.database.migrate(backup_dir=self.data_dir / "backups")
self.state_store = ProjectStateStore()
self.media_library = MediaLibrary(self.data_dir / "media")
def create_app(identity: NodeIdentity | None = None) -> FastAPI:
"""Erzeugt die Control-Core-App.
identity: produktiv vom Launcher geladene persistente Identität
(userdata/identity). Ohne Angabe gilt Dev-Modus mit ephemeral-Identität
(jede App-Instanz erhält eine eigene ID; für Showbetrieb unzulässig).
"""
if identity is None:
identity = NodeIdentity.ephemeral(
"Dev Node", frozenset({NodeRole.RENDER_NODE, NodeRole.COORDINATOR})
)
app = FastAPI(title="HMS MediaEngine Control Core", version="0.1.0")
state = _State(identity)
# Eigene Node in die Registry eintragen (§6.3)
state.nodes.register(
node_id=identity.node_id,
display_name=identity.display_name,
roles=tuple(r.value for r in identity.roles),
)
def _broadcast(event: dict) -> None:
for queue in list(state.subscribers):
queue.put_nowait(event)
# ---------- System (§23.1) ----------
@app.get("/api/v1/system/health")
async def health() -> dict:
return {
"status": "ok",
"phase": 5,
"node_id": state.identity.node_id,
"revision": state.engine.revision,
}
@app.get("/api/v1/system/identity")
async def system_identity() -> dict:
"""Nicht vertrauliche Selbstauskunft (§27.1: keine Tokens/Secrets)."""
return {
"node_id": state.identity.node_id,
"display_name": state.identity.display_name,
"roles": sorted(r.value for r in state.identity.roles),
"renders_locally": state.identity.renders_locally,
"is_coordinator": state.identity.is_coordinator,
}
@app.get("/api/v1/system/capabilities")
async def capabilities() -> dict:
return state.report.as_dict()
@app.get("/api/v1/parameters")
async def parameters() -> dict:
snap = state.engine.snapshot()
return {"revision": snap.revision, "values": snap.as_dict()}
@app.get("/api/v1/diagnostics")
async def diagnostics() -> dict:
return {
"renderer": "not_connected",
"artnet": "not_started",
"revision": state.engine.revision,
"node_id": state.identity.node_id,
"state_revision": state.state_store.state_revision,
"project_revision": state.state_store.project_revision,
}
# ---------- Commands (§23.2) ----------
@app.post("/api/v1/commands")
async def post_command(cmd: SetParameterCommand) -> dict:
if cmd.type != "parameter.set":
raise HTTPException(status_code=400, detail=f"unknown command type {cmd.type!r}")
if not state.registry.register(cmd.command_id):
prior = state.registry.result(cmd.command_id)
if prior is not None:
return {"status": "ack", "duplicate": True, "result": prior}
raise HTTPException(status_code=409, detail="command already in flight")
path = cmd.payload.get("parameter_path")
value = cmd.payload.get("value")
if not path or value is None:
raise HTTPException(status_code=400, detail="payload requires parameter_path and value")
try:
revision = state.engine.set_value(
path=path,
value=float(value),
source=ControlSource.WEB,
expected_revision=cmd.expected_revision,
)
state.state_store.set_value(path, float(value))
except RevisionConflict as exc:
raise HTTPException(
status_code=409,
detail={
"error": "REVISION_CONFLICT",
"current": exc.current,
"expected": exc.expected,
},
) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
result = {
"status": "ack",
"command_id": cmd.command_id,
"revision": revision,
"effective": state.engine.effective_value(path),
}
state.registry.complete(cmd.command_id, result)
_broadcast(
{
"type": "parameter.update",
"parameter_path": path,
"value": value,
"revision": revision,
}
)
return result
@app.post("/api/v1/commands/{command_id}/release")
async def release_override(command_id: str) -> dict:
return {"status": "not_implemented_in_phase0"}
# ---------- Cluster (§23.1) ----------
@app.get("/api/v1/cluster/nodes")
async def cluster_nodes() -> dict:
"""Node-Übersicht nach UI-Kategorien (§6.3): getrennt aufgeführt."""
nodes = []
for entry in state.nodes.all():
state.nodes.evaluate_health(entry.node_id)
nodes.append(
{
"node_id": entry.node_id,
"display_name": entry.display_name,
"roles": list(entry.roles),
"health": entry.health.value,
"category": entry.category.value,
}
)
return {"self": state.identity.node_id, "nodes": nodes}
# ---------- Projekte (§23.1, §24.2) ----------
@app.get("/api/v1/projects")
async def list_projects() -> dict:
return {"projects": state.database.list_projects()}
@app.post("/api/v1/projects")
async def create_project(body: CreateProjectBody) -> dict:
project = Project(name=body.name)
state.database.save_project(project.model_dump(mode="json"))
return {"status": "ack", "project": project.model_dump(mode="json")}
@app.get("/api/v1/projects/{project_id}")
async def get_project(project_id: str) -> dict:
data = state.database.load_project(project_id)
if data is None:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
return data
@app.put("/api/v1/projects/{project_id}")
async def update_project(project_id: str, body: dict) -> dict:
existing = state.database.load_project(project_id)
if existing is None:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
for key, value in body.items():
if key in existing:
existing[key] = value
updated = Project.model_validate(existing)
state.database.save_project(updated.model_dump(mode="json"))
state.state_store.bump_project_revision()
return {"status": "ack"}
@app.delete("/api/v1/projects/{project_id}")
async def delete_project(project_id: str) -> dict:
state.database.delete_project(project_id)
return {"status": "ack"}
@app.post("/api/v1/projects/{project_id}/activate")
async def activate_project(project_id: str) -> dict:
"""Aktiviert ein Projekt als autoritative Basis (§6.4)."""
data = state.database.load_project(project_id)
if data is None:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
project = Project.model_validate(data)
revision = state.state_store.activate_project(project)
return {"status": "ack", "state_revision": revision}
# ---------- Medien (§13.3, §23.1) ----------
@app.post("/api/v1/media/import")
async def import_media(file: UploadFile) -> dict:
"""Importiert eine Mediendatei mit Duplikaterkennung (§13.3).
Import blockiert niemals den Renderthread (§13.3).
"""
media_root = state.data_dir / "media"
media_root.mkdir(parents=True, exist_ok=True)
dest = media_root / file.filename
content = await file.read()
dest.write_bytes(content)
outcome = state.media_library.import_file(dest)
if outcome.status is ImportStatus.IMPORTED and outcome.asset:
state.database.save_project({
"id": str(uuid.uuid4()),
"name": file.filename,
"schema_version": 1,
"created_at": "2026-09-11T00:00:00+00:00",
"updated_at": "2026-09-11T00:00:00+00:00",
"data": outcome.asset.model_dump(mode="json"),
})
return {
"status": outcome.status.value,
"asset": outcome.asset.model_dump(mode="json") if outcome.asset else None,
"duplicate_of": outcome.duplicate_of,
}
@app.get("/api/v1/media")
async def list_media() -> dict:
"""Medienliste mit Metadaten (§13.2)."""
missing = state.media_library.mark_missing()
assets = []
for asset in state.media_library.all():
assets.append({
**asset.model_dump(mode="json"),
"missing": asset.id in state.media_library.missing_asset_ids,
})
return {"media": assets, "missing_count": len(missing)}
# ---------- Plugins (§23.1) ----------
@app.get("/api/v1/plugins")
async def list_plugins() -> dict:
"""Installierte/verfügbare Plugins nach Status gruppiert (§14.5)."""
from hms_plugin_sdk import PluginLifecycleManager
builtin_dir = _builtin_plugin_dir()
mgr = PluginLifecycleManager()
errors = _discover_builtin_plugins(mgr, builtin_dir)
plugins_by_state: dict[str, list[dict]] = {}
for record in mgr.all():
plugins_by_state.setdefault(record.state.value, []).append(
{
"plugin_id": record.plugin_id,
"version": record.version,
"kind": record.manifest.get("kind", "unknown"),
"name": record.manifest.get("name", record.plugin_id),
"last_error": record.last_error,
}
)
return {
"plugins_by_state": plugins_by_state,
"total": len(mgr.all()),
"discovery_errors": errors,
}
@app.post("/api/v1/plugins/{plugin_id}/enable")
async def enable_plugin(plugin_id: str) -> dict:
"""Aktiviert ein Plugin über den Lifecycle (§14.5)."""
from hms_plugin_sdk import (
InvalidTransitionError,
LifecycleState,
PluginLifecycleManager,
)
builtin_dir = _builtin_plugin_dir()
mgr = PluginLifecycleManager()
_discover_builtin_plugins(mgr, builtin_dir)
if mgr.get(plugin_id) is None:
raise HTTPException(status_code=404, detail=f"unbekanntes Plugin {plugin_id!r}")
try:
for target in (
LifecycleState.INSTALLED,
LifecycleState.ENABLED,
LifecycleState.COMPILED,
LifecycleState.ACTIVE,
):
record = mgr.get(plugin_id)
if record is None:
break
if record.state is target:
break
mgr.advance(plugin_id, target)
return {"status": "ack", "plugin_id": plugin_id}
except InvalidTransitionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# ---------- Art-Net (§23.1) ----------
@app.get("/api/v1/artnet/status")
async def artnet_status() -> dict:
return {
"running": False,
"universes": [],
"telemetry": {},
"note": "Art-Net-Receiver startet mit dem Launcher (Phase 1 vollstaendig nach Gate 1)",
}
# ---------- WebSocket (§23.3) ----------
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket) -> None:
await ws.accept()
queue: asyncio.Queue = asyncio.Queue(maxsize=256)
state.subscribers.append(queue)
try:
snap = state.engine.snapshot()
await ws.send_json(
{"type": "snapshot", "revision": snap.revision, "values": snap.as_dict()}
)
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=15.0)
await ws.send_json(event)
except TimeoutError:
await ws.send_json({"type": "heartbeat"})
except WebSocketDisconnect:
pass
finally:
state.subscribers.remove(queue)
return app
app = create_app()
@@ -0,0 +1,90 @@
"""RendererStateLink auf der Control-Core-Seite (PLAN.md §6.2, §6.4).
Verbindet ProjectStateStore (autoritativ) mit dem Renderer über IPC:
- connect_and_sync(): Handshake + vollständiger Snapshot (§6.2 Pflicht)
- sync_if_changed(): Delta seit letzter gesendeter Revision
- Reconnect: immer neuer Snapshot, danach erst wieder Deltas (§6.2)
Der Link kennt den letzten beim Renderer angekommenen Zustand und
berechnet Deltas daraus keine halben Zustände (§11.4, §6.4).
"""
from __future__ import annotations
from hms_persistence.state_store import ProjectStateStore
from hms_protocol import Envelope, IpcClient, MessageType
class RendererStateLink:
"""Synchronisiert den Showzustand Control Core → Renderer über IPC."""
def __init__(self, client: IpcClient, store: ProjectStateStore) -> None:
self._client = client
self._store = store
self._last_sent_revision = 0
self._renderer_known_state: dict[str, float] = {}
@property
def last_sent_revision(self) -> int:
return self._last_sent_revision
@property
def in_sync(self) -> bool:
"""True, wenn der Renderer die aktuelle Revision besitzt."""
return self._last_sent_revision == self._store.state_revision
# ---------- Verbindung (§6.2) ----------
async def connect_and_sync(self) -> None:
"""Verbindung aufbauen und vollständigen Snapshot senden.
Nach jedem (Re-)Connect wird immer zuerst der vollständige Snapshot
übertragen; Deltas folgen erst danach (§6.2: „Re-Sync nach
Reconnect", „Deltas erst nach erfolgreichem Re-Sync akzeptiert").
"""
await self._client.connect()
await self.send_full_snapshot()
async def send_full_snapshot(self) -> int:
"""Sendet den vollständigen Zustand; liefert die gesendete Revision."""
snap = self._store.snapshot()
self._last_sent_revision = snap["state_revision"]
self._renderer_known_state = dict(snap["values"])
envelope = Envelope(
type=MessageType.SNAPSHOT,
revision=snap["state_revision"],
payload=snap,
)
await self._client.send(envelope)
return snap["state_revision"]
# ---------- Delta-Versand (§6.4) ----------
async def sync_if_changed(self) -> bool:
"""Sendet ein Delta, falls sich die Revision seit dem letzten Versand
geändert hat. Rückgabe: True, wenn etwas gesendet wurde.
Das Delta wird aus dem zuletzt bekannten Renderer-Zustand berechnet
(neu/geändert/gelöscht); die Semantik folgt StateDelta (§6.4).
"""
if self._store.state_revision == self._last_sent_revision:
return False # nichts Neues
delta = self._store.delta_since(
self._last_sent_revision, self._renderer_known_state
)
if delta is None:
return False
# Buchhaltung: was weiß der Renderer ab jetzt?
for path, value in delta.changes.items():
if value is None:
self._renderer_known_state.pop(path, None)
else:
self._renderer_known_state[path] = value
self._last_sent_revision = delta.state_revision
envelope = Envelope(
type=MessageType.EVENT,
revision=delta.state_revision,
payload=delta.to_dict(),
)
await self._client.send(envelope)
return True
View File
@@ -0,0 +1,15 @@
"""hms_launcher Supervisor/Launcher (PLAN.md §6.1A, §9).
Phase-0/1-Umfang: portable Pfadauflösung, Portwahl, GStreamer-Environment,
Prozessüberwachung mit Restart-Policy und Crashloop-Erkennung,
kontrolliertes Beenden mit Recovery-Markierung.
"""
from hms_launcher.paths import AppPaths, resolve_app_root
from hms_launcher.supervisor import (
ProcessSpec,
Supervisor,
find_free_port,
)
__all__ = ["AppPaths", "resolve_app_root", "ProcessSpec", "Supervisor", "find_free_port"]
@@ -0,0 +1,178 @@
"""Launcher-Hauptprogramm: startet die komplette Anwendung (§6.1A, §9.2).
Startablauf (§9.2):
1. Launcher bestimmt den eigenen absoluten Programmordner
2. Runtime- und GStreamer-Suchpfade werden gesetzt
3. Konfiguration und Datenbank werden migriert
4. Renderer startet und meldet Capabilities
5. Control Server startet auf konfiguriertem Port
6. Node-Identity wird geladen, Discovery gestartet
7. Healthcheck und lokaler Render-Preflight müssen grün sein
8. Standardbrowser wird optional geöffnet
9. Erst danach wird ein gespeichertes Autostart-Projekt aktiviert
Der Launcher läuft als Vordergrundprozess und beendet beide Kindprozesse
kontrolliert bei SIGINT/SIGTERM (§26.4).
"""
from __future__ import annotations
import asyncio
import signal
import sys
import time
from pathlib import Path
from hms_launcher.paths import AppPaths, resolve_app_root
from hms_launcher.supervisor import ProcessSpec, Supervisor, find_free_port
async def run(
app_root: Path | None = None,
open_browser: bool = True,
web_port: int | None = None,
) -> int:
"""Startet die komplette HMS MediaEngine-Anwendung.
Rückgabe: Exit-Code (0 = kontrolliert beendet).
"""
# 1. App-Root bestimmen (§9.2 Nr. 1)
if app_root is None:
app_root = resolve_app_root()
paths = AppPaths(root=app_root)
# Schreibbarkeit prüfen (§9.1)
if not paths.ensure_writable():
print("FEHLER: Anwendungsordner ist schreibgeschützt", file=sys.stderr)
return 1
# 2. Ports wählen (§6.1A)
web_port = web_port or find_free_port()
ipc_port = find_free_port()
print(f"HMS MediaEngine Starte (root={app_root})")
print(f" Web-UI: http://127.0.0.1:{web_port}")
print(f" IPC: 127.0.0.1:{ipc_port}")
# 3. Supervisor einrichten (§6.1A)
supervisor = Supervisor(paths)
# 4. Renderer starten (§9.2 Nr. 4)
renderer_cmd = [
sys.executable, "-m", "hms_renderer",
"--ipc-port", str(ipc_port),
]
supervisor.start(ProcessSpec(
name="renderer",
cmd=renderer_cmd,
restartable=True,
max_restarts=5,
stop_timeout_s=5.0,
))
print(" Renderer gestartet")
# 5. Control Core starten (§9.2 Nr. 5)
control_cmd = [
sys.executable, "-m", "uvicorn",
"hms_control_server.app:app",
"--host", "127.0.0.1",
"--port", str(web_port),
]
supervisor.start(ProcessSpec(
name="control_core",
cmd=control_cmd,
restartable=True,
max_restarts=5,
stop_timeout_s=10.0,
))
print(" Control Core gestartet")
# 6. Healthcheck: warten bis Web-UI erreichbar ist (§9.2 Nr. 7)
health_ok = await _wait_for_health(web_port, timeout_s=15.0)
if not health_ok:
print("FEHLER: Control Core Healthcheck fehlgeschlagen", file=sys.stderr)
supervisor.shutdown()
return 1
print(" Healthcheck grün")
# 7. Recovery-Markierung prüfen (§26.4)
recovery = supervisor.consume_recovery_marker()
if recovery:
print(f" WARNUNG: Unsauberer Shutdown erkannt: {recovery}")
# 8. Browser öffnen (§9.2 Nr. 8, optional)
if open_browser:
_open_browser(f"http://127.0.0.1:{web_port}")
print(" Browser geöffnet")
# 9. Hauptschleife: Prozesse überwachen bis SIGINT/SIGTERM
print("Anwendung läuft. Strg+C zum Beenden.")
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(sig, stop_event.set)
except NotImplementedError:
pass # Windows
try:
while not stop_event.is_set():
states = supervisor.check()
for name, state in states.items():
if state == "crashloop":
print(f" FEHLER: {name} im Crashloop", file=sys.stderr)
stop_event.set()
elif state == "restarted":
print(f" {name} neu gestartet")
await asyncio.sleep(1.0)
finally:
# Kontrolliertes Beenden (§26.4)
print("Beende Anwendung...")
supervisor.shutdown()
print("Alle Prozesse beendet.")
return 0
async def _wait_for_health(port: int, timeout_s: float = 15.0) -> bool:
"""Wartet bis /api/v1/system/health 200 OK liefert."""
import urllib.error
import urllib.request
deadline = time.monotonic() + timeout_s
url = f"http://127.0.0.1:{port}/api/v1/system/health"
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=2.0) as resp:
if resp.status == 200:
return True
except (urllib.error.URLError, OSError):
await asyncio.sleep(0.5)
return False
def _open_browser(url: str) -> None:
"""Öffnet den Standardbrowser plattformneutral."""
import subprocess
try:
if sys.platform == "win32":
subprocess.run(
["cmd", "/c", "start", url], check=False, timeout=5
)
elif sys.platform == "darwin":
subprocess.run(["open", url], check=False, timeout=5)
else:
subprocess.run(
["xdg-open", url], check=False, timeout=5
)
except (OSError, subprocess.TimeoutExpired):
pass # Browser ist optional; Web-UI ist auch direkt erreichbar
def main() -> int:
return asyncio.run(run())
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,111 @@
"""Portable Pfadregeln (PLAN.md §9, §9.1).
- Alle Pfade relativ zum Anwendungsroot; keine Laufwerksbuchstaben.
- Keine Abhängigkeit vom Working Directory.
- Temporäre Dateien in userdata/cache, nicht im OS-Profil.
- Schreibbarkeit wird beim Start geprüft (Read-only-Modus folgt Phase 1).
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class AppPaths:
"""Alle portablen Pfade je Anwendungsroot (§9-Struktur)."""
root: Path
@property
def app(self) -> Path:
return self.root / "app"
@property
def runtime(self) -> Path:
return self.root / "runtime"
@property
def gstreamer_bin(self) -> Path:
return self.runtime / "gstreamer" / "bin"
@property
def gstreamer_plugins(self) -> Path:
return self.runtime / "gstreamer" / "lib" / "gstreamer-1.0"
@property
def web(self) -> Path:
return self.root / "web"
@property
def projects(self) -> Path:
return self.root / "projects"
@property
def media(self) -> Path:
return self.root / "media"
@property
def userdata(self) -> Path:
return self.root / "userdata"
@property
def database(self) -> Path:
return self.userdata / "database"
@property
def cache(self) -> Path:
return self.userdata / "cache"
@property
def identity(self) -> Path:
return self.userdata / "identity" / "node_id"
@property
def config(self) -> Path:
return self.root / "config"
@property
def logs(self) -> Path:
return self.root / "logs"
def ensure_writable(self) -> bool:
"""Prüft Schreibbarkeit des Roots (§9.1)."""
probe = self.root / ".write_probe"
try:
probe.write_text("ok", encoding="ascii")
probe.unlink()
return True
except OSError:
return False
def portable_environment(self) -> dict[str, str]:
"""Umgebungsvariablen für gebündelte GStreamer-Runtime (§9.2, ADR-0002).
System-Plugins werden unterdrückt (leerer GST_PLUGIN_SYSTEM_PATH_1_0),
damit ausschließlich die gebündelte, manifestierte Untermenge lädt.
"""
env = dict(os.environ)
gs_bin = self.gstreamer_bin
if gs_bin.is_dir():
path_var = "PATH"
existing = env.get(path_var, "")
env[path_var] = f"{gs_bin}{os.pathsep}{existing}" if existing else str(gs_bin)
env["GST_PLUGIN_PATH_1_0"] = str(self.gstreamer_plugins)
env["GST_PLUGIN_SYSTEM_PATH_1_0"] = ""
return env
def resolve_app_root(start_from: Path | None = None) -> Path:
"""Bestimmt den Anwendungsroot anhand der PORTABLE_MODE-Markierung (§9).
Sucht vom gegebenen Pfad (Default: dieses Paket) aufwärts nach der
Datei PORTABLE_MODE; im Entwickungsbaum ist das Repo-Root gemeint.
"""
current = Path(start_from or __file__).resolve()
for candidate in [current, *current.parents]:
if (candidate / "PORTABLE_MODE").is_file() or (candidate / "pyproject.toml").is_file():
return candidate
raise RuntimeError("app root not found (PORTABLE_MODE or pyproject.toml missing)")
@@ -0,0 +1,206 @@
"""Supervisor/Launcher (PLAN.md §6.1A, §26.2, §26.4).
Aufgaben (§6.1A):
- Start und Überwachung der Teilprozesse (Control Core, Renderer)
- Wahl freier lokaler Ports (127.0.0.1)
- Setzen portabler Runtime-Pfade (AppPaths.portable_environment)
- Heartbeat-Überwachung auf Prozessebene (Lebenszyklus)
- kontrolliertes Beenden mit Timeout (§26.4)
- Wiederanlauf nach Control-Core-Absturz, Crashloop-Erkennung (§26.2)
- Recovery-Markierung bei Zwangsbeendigung (§26.4)
Der Supervisor verwaltet echte Prozesse; keine Mock-Implementierung.
"""
from __future__ import annotations
import os
import signal
import socket
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from hms_launcher.paths import AppPaths
DEFAULT_STOP_TIMEOUT_S = 10.0
DEFAULT_MAX_RESTARTS = 5
DEFAULT_RESTART_WINDOW_S = 60.0
@dataclass(frozen=True)
class ProcessSpec:
"""Beschreibung eines zu überwachenden Teilprozesses."""
name: str
cmd: list[str]
restartable: bool = True
max_restarts: int = DEFAULT_MAX_RESTARTS
restart_window_s: float = DEFAULT_RESTART_WINDOW_S
stop_timeout_s: float = DEFAULT_STOP_TIMEOUT_S
@dataclass
class ProcessState:
"""Laufzeitinformation zu einem überwachten Prozess (§26.2)."""
spec: ProcessSpec
proc: subprocess.Popen | None = None
restarts: list[float] = field(default_factory=list) # Zeitstempel je Neustart
last_start_ns: int = 0
crashlooped: bool = False
stopped_by_supervisor: bool = False
@property
def running(self) -> bool:
return self.proc is not None and self.proc.poll() is None
@property
def returncode(self) -> int | None:
return self.proc.poll() if self.proc is not None else None
def find_free_port(host: str = "127.0.0.1") -> int:
"""Wählt einen freien lokalen Port (§6.1A). Socket wird sofort wieder
freigegeben; der Kindprozess bindet ihn anschließend selbst."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind((host, 0))
return sock.getsockname()[1]
class Supervisor:
"""Überwacht Teilprozesse mit Restart-Policy und Crashloop-Schwelle."""
def __init__(self, paths: AppPaths | None = None) -> None:
self._paths = paths
self._states: dict[str, ProcessState] = {}
self._recovery_marker: Path | None = None
if paths is not None:
self._recovery_marker = paths.userdata / "recovery" / "unclean_shutdown"
# ---------- Start/Stop ----------
def start(self, spec: ProcessSpec) -> None:
"""Startet einen Teilprozess mit portabler Umgebung."""
if spec.name in self._states and self._states[spec.name].running:
raise RuntimeError(f"process {spec.name!r} already running")
env = dict(os.environ)
if self._paths is not None:
env.update(self._paths.portable_environment())
proc = subprocess.Popen(
spec.cmd,
env=env,
cwd=str(self._paths.root) if self._paths is not None else None,
)
state = self._states.get(spec.name)
if state is None:
state = ProcessState(spec=spec)
self._states[spec.name] = state
else:
state.spec = spec
state.proc = proc
state.last_start_ns = time.monotonic_ns()
state.stopped_by_supervisor = False
def stop(self, name: str, timeout: float | None = None) -> int | None:
"""Kontrolliertes Beenden: terminate → warten → kill (§26.4).
Gibt den Rückgabecode zurück; None falls der Prozess nicht lief.
"""
state = self._states.get(name)
if state is None or state.proc is None:
return None
if not state.running:
return state.returncode
state.stopped_by_supervisor = True
timeout = timeout if timeout is not None else state.spec.stop_timeout_s
state.proc.terminate() # SIGTERM: laufende Writes abschließen (§26.4)
try:
return state.proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
# Zwangsbeendigung: Recovery-Markierung setzen (§26.4)
state.proc.kill()
self._write_recovery_marker(name)
return state.proc.wait(timeout=5)
def shutdown(self) -> None:
"""Beendet alle Prozesse kontrolliert (Renderer zuletzt, um Output
so lange wie möglich zu halten; §26.2)."""
for name in reversed(list(self._states)):
self.stop(name)
# ---------- Überwachung (§26.2) ----------
def check(self) -> dict[str, str]:
"""Prüft alle Prozesse; startet Abgestürzte gemäß Policy neu.
Rückgabe: name → Zustand (running/restarted/crashloop/stopped).
"""
result: dict[str, str] = {}
now = time.monotonic()
for name, state in list(self._states.items()):
if state.running:
result[name] = "running"
continue
if state.stopped_by_supervisor:
result[name] = "stopped"
continue
if not state.spec.restartable or state.crashlooped:
result[name] = "crashloop" if state.crashlooped else "stopped"
continue
# Neustarts innerhalb des Zeitfensters zählen (Crashloop, §26.2)
state.restarts = [
t for t in state.restarts if now - t < state.spec.restart_window_s
]
if len(state.restarts) >= state.spec.max_restarts:
state.crashlooped = True # endlose Neustarts verhindern
result[name] = "crashloop"
continue
state.restarts.append(now)
self.start(state.spec)
result[name] = "restarted"
return result
def state(self, name: str) -> ProcessState:
return self._states[name]
def names(self) -> list[str]:
return list(self._states)
# ---------- Recovery (§26.4) ----------
def _write_recovery_marker(self, name: str) -> None:
if self._recovery_marker is None:
return
self._recovery_marker.parent.mkdir(parents=True, exist_ok=True)
with open(self._recovery_marker, "a", encoding="utf-8") as fh:
fh.write(
f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} forced-kill {name}\n"
)
def consume_recovery_marker(self) -> list[str]:
"""Liest und löscht die Recovery-Markierung (Crash-Recovery-Dialog,
§24.3/§26.4). Gibt die Zeilen zurück."""
if self._recovery_marker is None or not self._recovery_marker.is_file():
return []
lines = self._recovery_marker.read_text(encoding="utf-8").splitlines()
self._recovery_marker.unlink()
return lines
# Windows-kompatibles SIGTERM: terminate() nutzt auf Windows TerminateProcess,
# das kein SIGTERM ist. Für sauberes Shutdown nutzen Kindprozesse dort einen
# Steuerkanal (IPC-command) der Supervisor sendet SIGTERM nur auf POSIX.
def request_graceful_stop(proc: subprocess.Popen, timeout: float) -> int | None:
"""POSIX: SIGTERM; Windows: proc.terminate(). Wartet dann kontrolliert."""
if os.name == "posix":
proc.send_signal(signal.SIGTERM)
else:
proc.terminate()
try:
return proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
return None
View File
@@ -0,0 +1,35 @@
"""hms_renderer Render-Worker (PLAN.md §6.1C, §12, §36 Nr. 46).
Python orchestriert native GStreamer-Komponenten; keine Pixelverarbeitung
in Python (§2.1, §33). Der RemoteStateMirror hält den über IPC übermittelten
Showzustand (§6.2, §6.4); die RenderEngine verbindet Mirror, Playback und
Preload zu Frame-Snapshots (§11.4)."""
from hms_renderer.engine import (
FrameSnapshot,
RenderEngine,
RenderTelemetry,
SourceHandle,
)
from hms_renderer.pipelines import (
D3D11Pipeline,
DevGLPipeline,
build_compositor_pipeline,
build_single_video_pipeline,
gst_available,
)
from hms_renderer.state_mirror import RemoteStateMirror, apply_envelope
__all__ = [
"D3D11Pipeline",
"DevGLPipeline",
"build_single_video_pipeline",
"build_compositor_pipeline",
"gst_available",
"RemoteStateMirror",
"apply_envelope",
"RenderEngine",
"SourceHandle",
"FrameSnapshot",
"RenderTelemetry",
]
@@ -0,0 +1,52 @@
"""Renderer-CLI (Phase-0-Spike).
Aufruf:
python -m hms_renderer --pipeline d3d11 --video-a A --video-b B
Ohne GStreamer-Installation: kontrollierter Abbruch mit Exit-Code 2 und
klarer Meldung niemals Erfolgssimulation (§1.1 Nr. 5, §33).
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from hms_renderer.pipelines import build_compositor_pipeline, build_single_video_pipeline
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="hms-renderer")
parser.add_argument("--pipeline", choices=["d3d11", "devgl"], default="d3d11")
parser.add_argument("--video-a", required=True)
parser.add_argument("--video-b", default=None, help="zweite Quelle für Compositing")
parser.add_argument("--dry-run", action="store_true", help="nur Pipeline-String ausgeben")
args = parser.parse_args(argv)
use_d3d11 = args.pipeline == "d3d11"
if args.video_b:
pipeline = build_compositor_pipeline(args.video_a, args.video_b, d3d11=use_d3d11)
else:
pipeline = build_single_video_pipeline(args.video_a, d3d11=use_d3d11)
if args.dry_run:
print(pipeline)
return 0
gst_launch = shutil.which("gst-launch-1.0")
if gst_launch is None:
print(
"ERROR: gst-launch-1.0 nicht gefunden. GStreamer 1.28.6 muss gebündelt "
"oder installiert sein (build/windows/GSTREAMER.md).",
file=sys.stderr,
)
return 2
result = subprocess.run([gst_launch, "-v", pipeline], check=False)
return result.returncode
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,268 @@
"""Renderer-Engine: verbindet StateMirror, Playback und Preload (§12, §6.4).
Der Rendergraph läuft in nativem GStreamer/Rust (ADR-0004); diese Engine
orchestriert:
- liest Showparameter aus dem RemoteStateMirror (§6.4)
- berechnet Playback-Positionen je Quelle (§12.5)
- verwaltet Preload-Slots für atomaren Clipwechsel (§12.2)
- bildet pro Frame einen unveränderlichen Parameter-Snapshot (§11.4)
- erzeugt Rendertelemetrie (§28.2)
Keine Pixelverarbeitung in Python (§33); die GPU-Pipeline wird über
Pipeline-Definitionen an GStreamer übergeben.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from hms_domain.model import TransportState
from hms_media import PlaybackController, PreloadSlot
from hms_renderer.state_mirror import RemoteStateMirror
@dataclass(frozen=True)
class FrameSnapshot:
"""Unveränderlicher Parameter-Snapshot für genau einen Frame (§11.4).
Der Rendergraph (Rust/GStreamer) erhält genau dieses Objekt pro Frame;
halbe Zustände sind ausgeschlossen.
"""
frame_index: int
monotonic_ns: int
state_revision: int
parameters: dict[str, float]
source_positions: dict[str, float] # source_id → normalisierte Position
source_states: dict[str, str] # source_id → TransportState
active_asset_ids: dict[str, str | None] # layer_key → asset_id (nach Commit)
@dataclass
class RenderTelemetry:
"""Rendertelemetrie je Frame (§28.2, §25.4)."""
frame_index: int = 0
fps: float = 0.0
frame_time_ms: float = 0.0
dropped_frames: int = 0
active_layers: int = 0
preload_pending: int = 0
source_events: int = 0
class SourceHandle:
"""Verwaltet eine Medienquelle im Renderer (§12.5, §12.2).
Verbindet PlaybackController mit Parameterpfaden und einem PreloadSlot:
- Transport-Befehle aus dem StateMirror steuern den Controller
- Der PreloadSlot wechselt Clips atomar bei Commit
"""
def __init__(
self,
source_id: str,
parameter_prefix: str,
fps: float = 60.0,
) -> None:
self.source_id = source_id
self.parameter_prefix = parameter_prefix
self.controller = PlaybackController(source_id=source_id)
self.preload = PreloadSlot()
self._active_asset_id: str | None = None
self._fps = fps
self._last_events: list = []
@property
def active_asset_id(self) -> str | None:
return self._active_asset_id
@property
def position(self) -> float:
return self.controller.position
@property
def state(self) -> TransportState:
return self.controller.state
def sync_from_mirror(self, mirror: RemoteStateMirror) -> list:
"""Liest Transport-Befehle und Parameter aus dem StateMirror.
Rückgabe: PlaybackEvents dieses Sync-Schritts (§12.5).
"""
events: list = []
# Transport-State aus dem Mirror lesen (§11.2)
state_value = mirror.get_value(f"{self.parameter_prefix}/source/state")
if state_value is not None:
state_map = {
0: TransportState.STOPPED,
1: TransportState.PLAYING,
2: TransportState.PAUSED,
}
target_state = state_map.get(int(state_value))
if target_state is not None:
is_playing = target_state is TransportState.PLAYING
is_paused = target_state is TransportState.PAUSED
is_stopped = target_state is TransportState.STOPPED
if is_playing and self.controller.state is not TransportState.PLAYING:
self.controller.play()
elif is_paused and self.controller.state is TransportState.PLAYING:
self.controller.pause()
elif is_stopped and self.controller.state is not TransportState.STOPPED:
self.controller.stop()
# Playback-Parameter aus dem Mirror (§10.2)
speed = mirror.get_value(f"{self.parameter_prefix}/source/speed")
if speed is not None and speed != 0:
self.controller.speed = max(-4.0, min(4.0, speed))
in_point = mirror.get_value(f"{self.parameter_prefix}/source/in_point")
if in_point is not None:
self.controller.in_point = max(0.0, min(0.99, in_point))
out_point = mirror.get_value(f"{self.parameter_prefix}/source/out_point")
if out_point is not None:
self.controller.out_point = max(self.controller.in_point + 0.01, min(1.0, out_point))
# Retrigger: Flankenwert im Mirror (1.0 = triggern, danach zurück auf 0)
retrigger = mirror.get_value(f"{self.parameter_prefix}/source/retrigger")
if retrigger is not None and retrigger > 0.5:
self.controller.retrigger()
# Clip-Auswahl über PreloadSlot (§12.2, §16.5 Load/Commit)
pending_asset = mirror.get_value(f"{self.parameter_prefix}/source/asset_id_pending")
if pending_asset is not None and pending_asset > 0:
# Asset-ID ist als Hash/Integer im Mirror; real: UUID-String aus Registry
# Hier: Asset-Wechsel nur über Commit (PreloadSlot)
self.preload.preload(str(int(pending_asset)))
commit = mirror.get_value(f"{self.parameter_prefix}/source/commit")
if commit is not None and commit > 0.5:
self.preload.mark_ready(str(int(mirror.get_value(
f"{self.parameter_prefix}/source/asset_id_pending", 0
))))
committed = self.preload.commit()
if committed:
self._active_asset_id = committed # atomar gewechselt (§12.2)
self._last_events = events
return events
def advance(self, dt_s: float, now_ns: int) -> list:
"""Advancement des PlaybackControllers; liefert Events."""
return self.controller.advance(dt_s, now_ns)
class RenderEngine:
"""Zentrale Renderer-Engine: orchestriert alle Quellen pro Frame.
Ablauf pro Frame (§12.2: feste Master-Bildrate):
1. sync_from_mirror: Transport-Parameter aus dem StateMirror lesen
2. advance: Playback-Positionen um dt weiterschieben
3. snapshot: unveränderlichen Frame-Snapshot bilden
4. telemetry: Frame-Statistiken aktualisieren
"""
def __init__(self, mirror: RemoteStateMirror, fps: float = 60.0) -> None:
self._mirror = mirror
self._fps = fps
self._frame_duration_s = 1.0 / fps
self._sources: dict[str, SourceHandle] = {}
self._frame_index = 0
self._last_frame_ns = 0
self.telemetry = RenderTelemetry()
@property
def mirror(self) -> RemoteStateMirror:
return self._mirror
@property
def fps(self) -> float:
return self._fps
@property
def frame_index(self) -> int:
return self._frame_index
def add_source(self, source_id: str, parameter_prefix: str) -> SourceHandle:
"""Registriert eine Medienquelle im Renderer."""
handle = SourceHandle(source_id, parameter_prefix, self._fps)
self._sources[source_id] = handle
return handle
def remove_source(self, source_id: str) -> None:
self._sources.pop(source_id, None)
def get_source(self, source_id: str) -> SourceHandle | None:
return self._sources.get(source_id)
def sources(self) -> list[SourceHandle]:
return list(self._sources.values())
def tick(self, now_ns: int | None = None) -> FrameSnapshot:
"""Ein Frame: Sync → Advance → Snapshot.
Wird vom Renderer-Loop mit fester Master-Bildrate aufgerufen (§12.2).
"""
now = now_ns if now_ns is not None else time.monotonic_ns()
if self._last_frame_ns == 0:
self._last_frame_ns = now
dt_s = (now - self._last_frame_ns) / 1e9
self._last_frame_ns = now
# 1. Sync: Parameter aus dem Mirror lesen
all_events: list = []
for handle in self._sources.values():
handle.sync_from_mirror(self._mirror)
# 2. Advance: Playback weiterschieben
for handle in self._sources.values():
events = handle.advance(dt_s, now)
all_events.extend(events)
# 3. Snapshot: unveränderlicher Frame-Zustand (§11.4)
self._frame_index += 1
parameters = self._mirror.all_values()
source_positions: dict[str, float] = {}
source_states: dict[str, str] = {}
active_assets: dict[str, str | None] = {}
for source_id, handle in self._sources.items():
source_positions[source_id] = handle.position
source_states[source_id] = handle.state.value
active_assets[f"{handle.parameter_prefix}/source"] = handle.active_asset_id
snapshot = FrameSnapshot(
frame_index=self._frame_index,
monotonic_ns=now,
state_revision=self._mirror.revision,
parameters=parameters,
source_positions=source_positions,
source_states=source_states,
active_asset_ids=active_assets,
)
# 4. Telemetry (§28.2)
frame_time_ms = dt_s * 1000.0
active_count = sum(
1 for h in self._sources.values()
if h.state is TransportState.PLAYING
)
preload_count = sum(
1 for h in self._sources.values()
if h.preload.pending is not None
)
self.telemetry = RenderTelemetry(
frame_index=self._frame_index,
fps=1000.0 / max(frame_time_ms, 0.01),
frame_time_ms=frame_time_ms,
dropped_frames=0, # zählt der native Rendergraph
active_layers=active_count,
preload_pending=preload_count,
source_events=len(all_events),
)
return snapshot
@@ -0,0 +1,79 @@
"""Renderer-Pipeline-Definitionen (PLAN.md §12, §13, §36 Nr. 45).
Windows-Primärpfad (§12.6): d3d11h264dec → D3D11Memory → d3d11compositor
→ d3d11videosink. Kein CPU-Rundweg (Decoder → RAM → Upload verboten).
DevGL-Pfad: Nur für Entwicklung/CI-Umgebungen ohne D3D11; ausdrücklich
gekennzeichnet, kein stiller Ersatz im Normalbetrieb (§5.2, §33).
"""
from __future__ import annotations
import shutil
from dataclasses import dataclass
@dataclass(frozen=True)
class D3D11Pipeline:
"""Primärpipeline Windows: durchgängig D3D11Memory."""
video_a: str
video_b: str
width: int = 1920
height: int = 1080
fullscreen: bool = True
def launch_string(self) -> str:
sink = "d3d11videosink fullscreen=true" if self.fullscreen else "d3d11videosink"
# Zwei Quellen → Compositor → Ausgabe (§36 Nr. 5: zwei Videos GPU-mischen)
return (
f"d3d11compositor name=mix sink_0::xpos=0 sink_0::ypos=0 "
f"sink_0::width={self.width // 2} sink_0::height={self.height} "
f"sink_1::xpos={self.width // 2} sink_1::ypos=0 "
f"sink_1::width={self.width // 2} sink_1::height={self.height} ! "
f"d3d11convert ! video/x-raw(memory:D3D11Memory),format=RGBA,"
f"width={self.width},height={self.height} ! {sink} "
f"uridecodebin uri=file:///{self.video_a} ! queue ! "
f"d3d11convert ! mix. "
f"uridecodebin uri=file:///{self.video_b} ! queue ! "
f"d3d11convert ! mix."
)
@dataclass(frozen=True)
class DevGLPipeline:
"""Entwicklungspfad ohne D3D11 (explizit gekennzeichnet, kein Normalpfad).
Nur für CI/Dev ohne Windows-GPU; die Backend-Schnittstelle bleibt
identisch (§12.6)."""
video_a: str
video_b: str
width: int = 960
height: int = 540
def launch_string(self) -> str:
return (
f"glvideomixer name=mix ! glimagesink "
f"uridecodebin uri=file:///{self.video_a} ! queue ! glupload ! mix. "
f"uridecodebin uri=file:///{self.video_b} ! queue ! glupload ! mix."
)
def build_single_video_pipeline(video: str, d3d11: bool = True) -> str:
"""Minimale Einzelquellen-Pipeline (§36 Nr. 4: Testvideo → Vollbild)."""
if d3d11:
return f"uridecodebin uri=file:///{video} ! d3d11convert ! d3d11videosink fullscreen=true"
return f"uridecodebin uri=file:///{video} ! glupload ! glimagesink"
def build_compositor_pipeline(video_a: str, video_b: str, d3d11: bool = True) -> str:
"""Zwei-Quellen-Compositing (§36 Nr. 5: GPU-Mischen ohne CPU-Readback)."""
if d3d11:
return D3D11Pipeline(video_a=video_a, video_b=video_b).launch_string()
return DevGLPipeline(video_a=video_a, video_b=video_b).launch_string()
def gst_available() -> bool:
"""True, wenn ein GStreamer-CLI auf dem System liegt (Diagnose, kein Fake)."""
return shutil.which("gst-launch-1.0") is not None
@@ -0,0 +1,412 @@
#!/usr/bin/env python3
"""HMS MediaEngine Echter GStreamer-Renderer.
Läuft mit System-Python (hat GStreamer-Bindings) und spielt VIDEOS WIRKLICH ab.
Kommuniziert über IPC mit dem Control Core.
Dieser Prozess:
1. Empfängt Befehle über IPC (MessagePack über TCP)
2. Startet/stoppt/steuert GStreamer-Pipelines
3. Mixed Videos mit videomixer/compositor
4. Sendet Frame-Snapshots zurück
5. Erzeugt einen MJPEG-Preview-Stream für den Browser
"""
import json
import os
import signal
import socket
import struct
import sys
import threading
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer
# GStreamer muss vor allem anderen importiert werden
import gi
gi.require_version("Gst", "1.0")
gi.require_version("GstVideo", "1.0")
from gi.repository import Gst, GstVideo
Gst.init(None)
class MJPEGPreview:
"""HTTP-Server, der einen MJPEG-Stream aus GStreamer empfängt und an Browser sendet."""
def __init__(self, port=8090):
self.port = port
self.running = False
self.current_jpeg = b""
self.clients = []
self.server = None
self.pipeline = None
def start_pipeline(self, width=640, height=360):
"""Erzeugt eine GStreamer-Pipeline, die Frames als JPEG encodiert."""
pipeline_str = (
f"appsrc name=preview_src is-live=true format=time "
f"caps=video/x-raw,format=RGB,width={width},height={height},framerate=15/1 ! "
f"videoconvert ! jpegenc quality=80 ! "
f"multifilesink location=/dev/null"
)
# Einfacher: appsink der JPEG-Frames liefert
self.pipeline = Gst.parse_launch(
f"videotestsrc is-live=true pattern=2 ! "
f"video/x-raw,width={width},height={height},framerate=15/1 ! "
f"videoconvert ! jpegenc ! appsink name=preview_sink emit-signals=true"
)
sink = self.pipeline.get_by_name("preview_sink")
sink.connect("new-sample", self._on_new_sample)
self.pipeline.set_state(Gst.State.PLAYING)
def _on_new_sample(self, sink):
"""Empfängt JPEG-Frames vom appsink."""
sample = sink.emit("pull-sample")
if sample:
buf = sample.get_buffer()
self.current_jpeg = buf.extract_dup(0, buf.get_size())
return Gst.FlowReturn.OK
def start_server(self):
"""Startet den HTTP-Server für den MJPEG-Stream."""
handler = self._make_handler()
self.server = HTTPServer(("0.0.0.0", self.port), handler)
self.running = True
thread = threading.Thread(target=self.server.serve_forever, daemon=True)
thread.start()
print(f"Preview-Stream: http://0.0.0.0:{self.port}/stream.mjpg")
def _make_handler(self):
preview = self
class StreamHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/stream.mjpg":
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
while preview.running:
if preview.current_jpeg:
self.wfile.write(b"--frame\r\n")
self.wfile.write(b"Content-Type: image/jpeg\r\n")
self.wfile.write(f"Content-Length: {len(preview.current_jpeg)}\r\n\r\n")
self.wfile.write(preview.current_jpeg)
self.wfile.write(b"\r\n")
time.sleep(1.0 / 15) # 15 fps
elif self.path == "/":n self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body style='margin:0;background:#000'>"
b"<img src='/stream.mjpg' style='width:100vw;height:100vh;object-fit:contain' /></body></html>"
)
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress log spam
return StreamHandler
def stop(self):
self.running = False
if self.server:
self.server.shutdown()
if self.pipeline:
self.pipeline.set_state(Gst.State.NULL)
class MediaPipeline:
"""Eine echte GStreamer-Pipeline für eine Videoquelle."""
def __init__(self, video_path: str, x=0, y=0, width=640, height=360, zorder=0):
self.video_path = video_path
self.pipeline = None
self.x = x
self.y = y
self.width = width
self.height = height
self.zorder = zorder
self.opacity = 1.0
self.playing = False
def build(self, output_port=5000):
"""Baut eine echte Pipeline: decodebin -> scale -> position -> UDP."""
uri = f"file://{os.path.abspath(self.video_path)}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! videoscale ! "
f"video/x-raw,width={self.width},height={self.height} ! "
f"videoflip method=none ! "
f"rtpjpegenc ! "
f"udpsink host=127.0.0.1 port={output_port} sync=true"
)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Pipeline-Fehler: {e}")
return False
def build_preview(self, jpeg_sink_name="preview"):
"""Baut eine Pipeline für den Browser-Preview (JPEG-Appsink)."""
uri = f"file://{os.path.abspath(self.video_path)}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! videoscale ! "
f"video/x-raw,width=320,height=180 ! "
f"jpegenc ! appsink name={jpeg_sink_name} emit-signals=true"
)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Preview-Pipeline-Fehler: {e}")
return False
def play(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.PLAYING)
self.playing = True
def pause(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.PAUSED)
self.playing = False
def stop(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.NULL)
self.playing = False
def set_opacity(self, value):
"""Setzt Opacity über volume-Element oder Alpha-Channel."""
self.opacity = max(0.0, min(1.0, value))
def get_position(self):
"""Aktuelle Position in Nanosekunden."""
if self.pipeline:
ok, pos = self.pipeline.query_position(Gst.Format.TIME)
if ok:
return pos
return 0
def get_duration(self):
"""Dauer in Nanosekunden."""
if self.pipeline:
ok, dur = self.pipeline.query_duration(Gst.Format.TIME)
if ok:
return dur
return 0
class CompositorPipeline:
"""Mischt mehrere Videoquellen zu einem Composite."""
def __init__(self, width=1280, height=720):
self.width = width
self.height = height
self.sources = {} # name -> sub-pipeline description
self.pipeline = None
def build(self, video_paths: list[str]):
"""Baut eine videomixer-Pipeline mit mehreren Quellen."""
if not video_paths:
return False
# Für eine Quelle: direkt abspielen
if len(video_paths) == 1:
uri = f"file://{os.path.abspath(video_paths[0])}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! "
f"ximagesink sync=false" # Für Container ohne Display: fakesink
)
else:
# Mehrere Quellen mit videomixer
parts = []
for i, path in enumerate(video_paths):
uri = f"file://{os.path.abspath(path)}"
x = (i % 2) * (self.width // 2)
y = (i // 2) * (self.height // 2)
parts.append(
f"uridecodebin uri={uri} ! videoconvert ! videoscale ! "
f"video/x-raw,width={self.width // 2},height={self.height // 2} ! "
f"videomixer name=mix sink_{i}::xpos={x} sink_{i}::ypos={y} "
f"sink_{i}::zorder={i}"
)
parts.append(
f"mix. ! videoconvert ! ximagesink sync=false"
)
pipeline_str = " ".join(parts)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Compositor-Fehler: {e}")
# Fallback: Einfache Test-Quelle
self.pipeline = Gst.parse_launch(
"videotestsrc is-live=true pattern=1 ! "
f"video/x-raw,width={self.width},height={self.height} ! "
"ximagesink sync=false"
)
return self.pipeline is not None
def build_with_preview(self, video_paths: list[str]):
"""Baut Pipeline mit JPEG-Appsink für Browser-Preview."""
if not video_paths:
pipeline_str = (
"videotestsrc is-live=true pattern=1 ! "
f"video/x-raw,width={self.width},height={self.height} ! "
f"videoscale ! video/x-raw,width=640,height=360 ! "
"videoconvert ! jpegenc ! appsink name=preview_sink emit-signals=true"
)
elif len(video_paths) == 1:
uri = f"file://{os.path.abspath(video_paths[0])}"
pipeline_str = (
f"uridecodebin uri={uri} ! "
f"videoconvert ! videoscale ! "
f"video/x-raw,width=640,height=360 ! "
f"jpegenc ! appsink name=preview_sink emit-signals=true"
)
else:
parts = []
for i, path in enumerate(video_paths):
uri = f"file://{os.path.abspath(path)}"
x = (i % 2) * 320
y = (i // 2) * 180
parts.append(
f"uridecodebin uri={uri} ! videoconvert ! videoscale ! "
f"video/x-raw,width=320,height=180 ! "
f"videomixer name=mix sink_{i}::xpos={x} sink_{i}::ypos={y}"
)
parts.append(
f"mix. ! videoconvert ! videoscale ! "
f"video/x-raw,width=640,height=360 ! "
f"jpegenc ! appsink name=preview_sink emit-signals=true"
)
pipeline_str = " ".join(parts)
try:
self.pipeline = Gst.parse_launch(pipeline_str)
return True
except Exception as e:
print(f"Preview-Compositor-Fehler: {e}")
return False
def play(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.PLAYING)
def stop(self):
if self.pipeline:
self.pipeline.set_state(Gst.State.NULL)
class Renderer:
"""Haupt-Renderer: verwaltet Compositor-Pipeline und Preview-Server."""
def __init__(self, preview_port=8090):
self.preview_port = preview_port
self.compositor = None
self.preview = MJPEGPreview(preview_port)
self.video_paths = []
self.running = False
def start(self, video_paths=None):
"""Startet den Renderer mit den gegebenen Videos."""
self.video_paths = video_paths or []
# Compositor-Pipeline mit Preview bauen
self.compositor = CompositorPipeline(width=1280, height=720)
if self.compositor.build_with_preview(self.video_paths):
# Preview-Sink verbinden
if self.compositor.pipeline:
sink = self.compositor.pipeline.get_by_name("preview_sink")
if sink:
sink.connect("new-sample", self._on_preview_frame)
# Pipeline starten
self.compositor.play()
# HTTP-Server für MJPEG starten
self.preview.start_server()
self.running = True
print(f"Renderer gestartet: {len(self.video_paths)} Video(s)")
print(f"Preview: http://0.0.0.0:{self.preview_port}/")
return True
return False
def _on_preview_frame(self, sink):
"""Empfängt JPEG-Frames für den Browser-Preview."""
sample = sink.emit("pull-sample")
if sample:
buf = sample.get_buffer()
self.preview.current_jpeg = buf.extract_dup(0, buf.get_size())
return Gst.FlowReturn.OK
def stop(self):
if self.compositor:
self.compositor.stop()
self.preview.stop()
self.running = False
def add_video(self, path):
"""Fügt ein Video hinzu (neu starten mit erweitertem Set)."""
self.video_paths.append(path)
self.stop()
self.start(self.video_paths)
def status(self):
return {
"running": self.running,
"videos": self.video_paths,
"preview_port": self.preview_port,
}
def main():
"""Haupteinstieg: Startet den Renderer mit Testvideo."""
# Testvideo erstellen wenn keins existiert
test_video = "/tmp/test_video.mp4"
if not os.path.exists(test_video):
print("Erstelle Testvideo...")
gen = Gst.parse_launch(
"videotestsrc pattern=ball num-buffers=300 ! "
"video/x-raw,width=640,height=480,framerate=30/1 ! "
"x264enc tune=zerolatency ! mp4mux ! "
f"filesink location={test_video}"
)
gen.set_state(Gst.State.PLAYING)
bus = gen.get_bus()
bus.timed_pop_filtered(Gst.CLOCK_TIME_NONE, Gst.MessageType.EOS)
gen.set_state(Gst.State.NULL)
print(f"Testvideo erstellt: {test_video}")
renderer = Renderer(preview_port=8090)
renderer.start([test_video])
# Hauptschleife
print("\nRenderer läuft.")
print(f" Preview im Browser: http://localhost:8090/")
print(f" Videos: {renderer.video_paths}")
print("\nDrücke Ctrl+C zum Beenden.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
renderer.stop()
print("Renderer beendet.")
if __name__ == "__main__":
main()
@@ -0,0 +1,96 @@
"""Remote-State-Mirror auf der Renderer-Seite (PLAN.md §6.2, §6.4).
Der Renderer hält den zuletzt übermittelten Showzustand als lokales
Spiegelbild:
- nach Verbindung: vollständiger Snapshot (§6.2 Pflicht)
- danach inkrementelle Deltas mit monotoner Revision
- Deltas werden erst nach erfolgtem Snapshot akzeptiert (§6.2:
„Deltas erst nach erfolgreichem Re-Sync")
- veraltete/doppelte Deltas sind idempotente No-Ops (§6.5)
Der Mirror ist reine Zustandslogik ohne Pixelbezug (§33); der Rendergraph
liest Werte über get_value() je Frame.
"""
from __future__ import annotations
class RemoteStateMirror:
"""Spiegel des autoritativen Showzustands im Renderer."""
def __init__(self) -> None:
self._revision = 0
self._values: dict[str, float] = {}
self._has_snapshot = False
@property
def revision(self) -> int:
return self._revision
@property
def has_snapshot(self) -> bool:
"""True, nachdem ein vollständiger Snapshot empfangen wurde."""
return self._has_snapshot
def get_value(self, path: str, default: float | None = None) -> float | None:
"""Wirksamer Wert für einen Parameterpfad (§10.2)."""
return self._values.get(path, default)
def all_values(self) -> dict[str, float]:
return dict(self._values)
# ---------- Snapshot / Delta (§6.2, §6.4) ----------
def apply_snapshot(self, payload: dict) -> None:
"""Übernimmt einen vollständigen Snapshot (nach Verbindung/Reconnect)."""
values = payload.get("values", {})
if not isinstance(values, dict):
raise ValueError("Snapshot ohne Werte-Objekt")
self._revision = int(payload["state_revision"])
self._values = {str(k): float(v) for k, v in values.items()}
self._has_snapshot = True
def apply_delta(self, payload: dict) -> bool:
"""Wendet ein Delta an.
Rückgabe:
- True: Delta angewendet ODER als veraltetes Duplikat ignoriert
- False: Re-Sync nötig (noch kein Snapshot empfangen, §6.2)
Gelöschte Pfade sind als None kodiert (StateDelta-Vertrag).
"""
if not self._has_snapshot:
return False # §6.2: Deltas erst nach Snapshot
delta_revision = int(payload["state_revision"])
if delta_revision <= self._revision:
return True # idempotent: Duplikat/veraltet, kein Handlungsbedarf
changes = payload.get("changes", {})
if not isinstance(changes, dict):
raise ValueError("Delta ohne Changes-Objekt")
for path, value in changes.items():
if value is None:
self._values.pop(str(path), None) # gelöscht
else:
self._values[str(path)] = float(value)
self._revision = delta_revision
return True
def apply_envelope(mirror: RemoteStateMirror, envelope) -> bool:
"""Verarbeitet ein IPC-Envelope in den Mirror.
- SNAPSHOT: vollständige Übernahme
- EVENT mit changes: Delta-Anwendung
- alles andere (Heartbeat, Ack, …): keine Zustandswirkung
Rückgabe: True, wenn der Zustand dadurch (re-)synchronisiert wurde;
False, wenn ein Re-Sync (neuer Snapshot) angefordert werden muss.
"""
from hms_protocol import MessageType
if envelope.type is MessageType.SNAPSHOT:
mirror.apply_snapshot(envelope.payload)
return True
if envelope.type is MessageType.EVENT and "changes" in envelope.payload:
return mirror.apply_delta(envelope.payload)
return True # keine Zustandsnachricht: nichts zu tun
View File
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>HMS MediaEngine</title>
<style>
/* Vollbild ohne Scroll auf dunklem Anthrazit (§17.2, §17.6). */
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #0d0f12;
color-scheme: dark;
}
#root {
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "hms-mediaengine-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.0.1",
"typescript": "^7.0.2",
"vite": "^8.2.2",
"vitest": "^5.0.0"
}
}
File diff suppressed because it is too large Load Diff
+605
View File
@@ -0,0 +1,605 @@
/**
* HMS MediaEngine MVP-Workspace (§17.2).
*
* CSS-Grid: obere Statusleiste (32 px), Hauptbereich mit linker
* Werkzeugleiste (48 px, einklappbar), mittlerer Arbeitsbereich
* (Layer-Tabelle, §17.3), unterem Inspector (~30 %, skalier- und
* einklappbar, §17.4) und rechter, umschaltbarer Seitenleiste (§17.5)
* sowie Fußleiste (24 px) mit Setup/Live-Lock und Panel-Schaltern.
*
* Datenfluss: WebSocket liefert Snapshot + Deltas (§17.9); REST-Endpoints
* werden gepollt; Opacity-Commits laufen als parameter.set mit
* expected_revision über die Engine (§10.2, §23.2) der Server bleibt
* autoritativ, ein Browser-Neustart stoppt den Output nicht (§3.2).
* Kein GO-Button, keine Standby-Anzeige, keine Cue-Logik (§17.1).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentType, PointerEvent as ReactPointerEvent } from 'react';
import type {
AlertItem,
AlertSeverity,
ClusterNode,
DiagnosticsResponse,
FxSlot,
HealthResponse,
IdentityResponse,
InspectorTab,
Layer,
OpMode,
SidebarTab,
ToolId,
} from './types';
import { SEED_COMPOSITION_ID, SEED_LAYERS } from './seed';
import { useWebSocket } from './hooks/useWebSocket';
import {
ApiError,
getClusterNodes,
getDiagnostics,
getHealth,
getIdentity,
releaseCommand,
sendParameterSet,
} from './hooks/useApi';
import StatusBar, { type StatusToneItem } from './components/StatusBar';
import LayerTable from './components/LayerTable';
import Inspector, { type InspectorPatch } from './components/Inspector';
import RightSidebar from './components/RightSidebar';
import type { IconProps } from './icons';
import {
IconAlertTriangle,
IconChevronLeft,
IconChevronRight,
IconCrossCircle,
IconDiagnostics,
IconEffects,
IconInspectorPanel,
IconLock,
IconMedia,
IconMixer,
IconNetwork,
IconOutputs,
IconPlugins,
IconPresets,
IconSidebarPanel,
IconUnlock,
} from './icons';
import {
clamp,
cx,
engineToOpacity,
errorMessage,
formatClock,
layerOpacityPath,
opacityToEngine,
} from './utils';
const PROJECT_NAME = 'Demo-Show'; // Projekt-API folgt in späterer Phase
const INSPECTOR_MIN = 96;
const INSPECTOR_MAX = 520;
const POLL_MS = 5000;
interface ToolDef {
readonly id: ToolId;
readonly label: string;
readonly icon: ComponentType<IconProps>;
}
const TOOLS: readonly ToolDef[] = [
{ id: 'mixer', label: 'Mixer', icon: IconMixer },
{ id: 'media', label: 'Media', icon: IconMedia },
{ id: 'effects', label: 'Effekte', icon: IconEffects },
{ id: 'presets', label: 'Presets', icon: IconPresets },
{ id: 'outputs', label: 'Outputs', icon: IconOutputs },
{ id: 'network', label: 'Netzwerk', icon: IconNetwork },
{ id: 'plugins', label: 'Plugins', icon: IconPlugins },
{ id: 'diagnostics', label: 'Diagnostics', icon: IconDiagnostics },
];
export default function App() {
/* ---------- Verbindungen ---------- */
const ws = useWebSocket();
/* ---------- Workspace-Zustand ---------- */
const [layers, setLayers] = useState<readonly Layer[]>(SEED_LAYERS);
const [selectedId, setSelectedId] = useState<string | null>(
SEED_LAYERS.length > 0 ? SEED_LAYERS[0].id : null,
);
const [cursorId, setCursorId] = useState<string | null>(
SEED_LAYERS.length > 0 ? SEED_LAYERS[0].id : null,
);
const [tool, setTool] = useState<ToolId>('mixer');
const [toolbarCollapsed, setToolbarCollapsed] = useState(false);
const [inspectorCollapsed, setInspectorCollapsed] = useState(false);
const [inspectorHeight, setInspectorHeight] = useState(264);
const [inspectorResizing, setInspectorResizing] = useState(false);
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('layer');
const [sidebarVisible, setSidebarVisible] = useState(true);
const [sidebarTab, setSidebarTab] = useState<SidebarTab>('active');
const [opMode, setOpMode] = useState<OpMode>('setup');
const liveLock = opMode === 'live';
/* ---------- Meldungen ---------- */
const [alerts, setAlerts] = useState<readonly AlertItem[]>([]);
const alertSeqRef = useRef(0);
const pushAlert = useCallback((severity: AlertSeverity, message: string): void => {
alertSeqRef.current += 1;
setAlerts(prev => [...prev, { id: alertSeqRef.current, timeMs: Date.now(), severity, message }].slice(-100));
}, []);
/* ---------- REST-Daten (Control Core, app.py) ---------- */
const [identity, setIdentity] = useState<IdentityResponse | null>(null);
const [nodes, setNodes] = useState<readonly ClusterNode[]>([]);
const [selfNodeId, setSelfNodeId] = useState<string | null>(null);
const [diagnostics, setDiagnostics] = useState<DiagnosticsResponse | null>(null);
const [health, setHealth] = useState<HealthResponse | null>(null);
useEffect(() => {
let disposed = false;
const poll = async (): Promise<void> => {
const [identRes, nodesRes, diagRes, healthRes] = await Promise.allSettled([
getIdentity(),
getClusterNodes(),
getDiagnostics(),
getHealth(),
]);
if (disposed) return;
if (identRes.status === 'fulfilled') setIdentity(identRes.value);
if (nodesRes.status === 'fulfilled') {
setNodes(nodesRes.value.nodes);
setSelfNodeId(nodesRes.value.self);
}
if (diagRes.status === 'fulfilled') setDiagnostics(diagRes.value);
if (healthRes.status === 'fulfilled') setHealth(healthRes.value);
};
void poll();
const timer = window.setInterval(() => void poll(), POLL_MS);
return () => {
disposed = true;
window.clearInterval(timer);
};
}, []);
/* ---------- Uhr und UI-FPS (Renderer-Metrik folgt mit IPC) ---------- */
const [now, setNow] = useState<Date>(() => new Date());
useEffect(() => {
const t = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(t);
}, []);
const [uiFps, setUiFps] = useState<number | null>(null);
useEffect(() => {
let raf = 0;
let frames = 0;
let last = performance.now();
const tick = (ts: number): void => {
frames += 1;
if (ts - last >= 1000) {
setUiFps((frames * 1000) / (ts - last));
frames = 0;
last = ts;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
/* ---------- WS-Verbindungsübergänge als Meldung ---------- */
const prevConnectedRef = useRef<boolean | null>(null);
useEffect(() => {
const prev = prevConnectedRef.current;
prevConnectedRef.current = ws.connected;
if (prev === null) return; // erster Mount: kein Übergang
if (prev && !ws.connected) {
pushAlert('warning', 'Verbindung zum Control Core verloren Reconnect mit Backoff läuft.');
} else if (!prev && ws.connected) {
pushAlert('info', 'Control Core verbunden Zustand synchronisiert.');
}
}, [ws.connected, pushAlert]);
useEffect(() => {
pushAlert(
'info',
'MVP-Workspace: Layer-Struktur ist Demo-Platzhalter (seed.ts); Opacity läuft echt über die Parameter-Engine (§10.2, §11).',
);
}, [pushAlert]);
/* ---------- Layer-Zustand + echte Opacity aus der Engine ---------- */
const mergedLayers = useMemo<readonly Layer[]>(() => {
if (Object.keys(ws.values).length === 0) return layers;
return layers.map(l => {
const path = layerOpacityPath(SEED_COMPOSITION_ID, l.id);
const engineValue = ws.values[path];
return engineValue === undefined ? l : { ...l, opacity: engineToOpacity(engineValue) };
});
}, [layers, ws.values]);
const selectedLayer = useMemo<Layer | null>(
() => mergedLayers.find(l => l.id === selectedId) ?? null,
[mergedLayers, selectedId],
);
/* ---------- Opacity: lokales Feedback + serverbestätigter Commit ---------- */
const dragOriginRef = useRef<Map<string, number>>(new Map());
const lastOpacityCmdRef = useRef<Map<string, string>>(new Map());
const handleOpacityLocal = useCallback((id: string, value: number): void => {
setLayers(prev =>
prev.map(l => {
if (l.id !== id) return l;
if (!dragOriginRef.current.has(id)) {
dragOriginRef.current.set(id, l.opacity);
}
return { ...l, opacity: value };
}),
);
}, []);
const handleOpacityCommit = useCallback(
async (id: string, value: number): Promise<void> => {
const path = layerOpacityPath(SEED_COMPOSITION_ID, id);
try {
const ack = await sendParameterSet({
path,
value: opacityToEngine(value),
expectedRevision: ws.revision,
});
lastOpacityCmdRef.current.set(id, ack.command_id);
} catch (err) {
// Server bleibt autoritativ (§17.9): lokales Feedback zurückrollen.
const origin = dragOriginRef.current.get(id);
if (origin !== undefined) {
setLayers(prev => prev.map(l => (l.id === id ? { ...l, opacity: origin } : l)));
}
if (err instanceof ApiError && err.status === 409) {
pushAlert('warning', 'Revision-Konflikt: ein anderer Client hat inzwischen geändert.');
} else {
pushAlert('error', `Opacity nicht übernommen: ${errorMessage(err)}`);
}
} finally {
dragOriginRef.current.delete(id);
}
},
[ws.revision, pushAlert],
);
const handleReleaseOpacity = useCallback(
async (layerId: string): Promise<void> => {
const commandId = lastOpacityCmdRef.current.get(layerId);
if (commandId === undefined) {
pushAlert('info', 'Kein Web-Override für die Opacity dieses Layers vorhanden.');
return;
}
try {
const res = await releaseCommand(commandId);
pushAlert('info', `Release ausgeführt Server: ${res.status}`);
} catch (err) {
pushAlert('error', `Release fehlgeschlagen: ${errorMessage(err)}`);
}
},
[pushAlert],
);
/* ---------- Umbenennen und Inspector-Patches (lokal bis Layer-API) ---------- */
const handleRename = useCallback((id: string, name: string): void => {
setLayers(prev => prev.map(l => (l.id === id ? { ...l, name } : l)));
}, []);
const applyPatch = useCallback(
(patch: InspectorPatch): void => {
if (selectedId === null) return;
setLayers(prev =>
prev.map((l): Layer => {
if (l.id !== selectedId) return l;
let next: Layer = l;
if (patch.name !== undefined) next = { ...next, name: patch.name };
if (patch.blend !== undefined) next = { ...next, blend: patch.blend };
if (patch.loopMode !== undefined) next = { ...next, loopMode: patch.loopMode };
if (patch.timeDurationSec !== undefined) {
next = { ...next, timeDurationSec: Math.max(0, patch.timeDurationSec) };
}
if (patch.generatorPhase !== undefined) {
next = { ...next, generatorPhase: patch.generatorPhase };
}
if (patch.transformX !== undefined) {
next = { ...next, transform: { ...next.transform, x: patch.transformX } };
}
if (patch.transformY !== undefined) {
next = { ...next, transform: { ...next.transform, y: patch.transformY } };
}
if (patch.transformScale !== undefined) {
next = { ...next, transform: { ...next.transform, scale: patch.transformScale } };
}
if (patch.transformRotation !== undefined) {
next = { ...next, transform: { ...next.transform, rotation: patch.transformRotation } };
}
if (patch.colorBrightness !== undefined) {
next = { ...next, color: { ...next.color, brightness: patch.colorBrightness } };
}
if (patch.colorContrast !== undefined) {
next = { ...next, color: { ...next.color, contrast: patch.colorContrast } };
}
if (patch.colorSaturation !== undefined) {
next = { ...next, color: { ...next.color, saturation: patch.colorSaturation } };
}
if (patch.colorHue !== undefined) {
next = { ...next, color: { ...next.color, hue: patch.colorHue } };
}
if (patch.fxBypass !== undefined) {
const [idx, bypass] = patch.fxBypass;
if (idx !== null) {
const slot = next.fxSlots[idx];
if (slot !== null && slot.state !== 'error') {
const slots: [FxSlot | null, FxSlot | null] = [next.fxSlots[0], next.fxSlots[1]];
slots[idx] = { ...slot, state: bypass ? 'bypassed' : 'active' };
next = { ...next, fxSlots: slots };
}
}
}
if (patch.dmxUniverse !== undefined) {
next = { ...next, dmx: { ...next.dmx, universe: patch.dmxUniverse } };
}
if (patch.dmxAddress !== undefined) {
next = { ...next, dmx: { ...next.dmx, address: patch.dmxAddress } };
}
return next;
}),
);
},
[selectedId],
);
/* ---------- Inspector-Resize (Divider, §17.2) ---------- */
const resizeStartRef = useRef<{ readonly y: number; readonly h: number } | null>(null);
const handleInspectorResizeStart = useCallback(
(e: ReactPointerEvent<HTMLDivElement>): void => {
e.preventDefault();
resizeStartRef.current = { y: e.clientY, h: inspectorHeight };
setInspectorResizing(true);
},
[inspectorHeight],
);
useEffect(() => {
if (!inspectorResizing) return;
const onMove = (e: PointerEvent): void => {
const start = resizeStartRef.current;
if (start === null) return;
const max = Math.min(INSPECTOR_MAX, Math.max(INSPECTOR_MIN, window.innerHeight - 320));
setInspectorHeight(clamp(start.h + (start.y - e.clientY), INSPECTOR_MIN, max));
};
const onUp = (): void => {
resizeStartRef.current = null;
setInspectorResizing(false);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, [inspectorResizing]);
/* ---------- Setup/Live-Lock (§17.7) ---------- */
const toggleOpMode = (): void => {
if (opMode === 'setup') {
setOpMode('live');
return;
}
// Verlassen von Live ist strukturell und wird bestätigt (§17.7).
const leave = window.confirm('Live-Modus verlassen? Strukturänderungen werden wieder freigegeben.');
if (leave) setOpMode('setup');
};
/* ---------- Abgeleitete Statusanzeigen ---------- */
const outputStatus = useMemo<StatusToneItem>(() => {
if (diagnostics === null) return { tone: 'muted', label: 'Output —' };
if (diagnostics.renderer === 'not_connected') {
return { tone: 'warn', label: 'Renderer offline' };
}
return { tone: 'ok', label: 'Output aktiv' };
}, [diagnostics]);
const artnetStatus = useMemo<StatusToneItem>(() => {
if (diagnostics === null) return { tone: 'muted', label: 'Art-Net —' };
if (diagnostics.artnet === 'not_started') return { tone: 'muted', label: 'Art-Net aus' };
return { tone: 'ok', label: 'Art-Net Empfang' };
}, [diagnostics]);
const syncStatus = useMemo<StatusToneItem>(
() =>
ws.connected
? { tone: 'ok', label: `Sync r${ws.revision ?? '—'}` }
: { tone: 'bad', label: 'Getrennt' },
[ws.connected, ws.revision],
);
const warnTotal =
mergedLayers.filter(l => l.status === 'warning' || l.status === 'out_of_sync').length +
alerts.filter(a => a.severity === 'warning').length;
const errTotal =
mergedLayers.filter(l => l.status === 'error' || l.status === 'offline').length +
alerts.filter(a => a.severity === 'error').length;
const nodeFps = useMemo<ReadonlyMap<string, number>>(() => new Map(), []);
const artnetSenders = useMemo<ReadonlyMap<string, string>>(() => new Map(), []);
/* ---------- Render ---------- */
return (
<div className="workspace">
<StatusBar
projectName={PROJECT_NAME}
nodeName={identity?.display_name ?? '—'}
output={outputStatus}
quality="Auto"
fps={uiFps}
artnet={artnetStatus}
sync={syncStatus}
clock={formatClock(now)}
/>
<div className="main">
<nav className={cx('toolbar', toolbarCollapsed && 'collapsed')} aria-label="Werkzeugleiste">
{TOOLS.map(t => {
const Icon = t.icon;
return (
<button
key={t.id}
type="button"
className={cx('tool-btn', tool === t.id && 'active')}
title={t.label}
aria-label={t.label}
aria-pressed={tool === t.id}
onClick={() => setTool(t.id)}
>
<Icon size={18} />
</button>
);
})}
<span className="tool-spacer" />
<button
type="button"
className="collapse-btn"
title={toolbarCollapsed ? 'Werkzeugleiste ausklappen' : 'Werkzeugleiste einklappen'}
aria-label={toolbarCollapsed ? 'Werkzeugleiste ausklappen' : 'Werkzeugleiste einklappen'}
onClick={() => setToolbarCollapsed(v => !v)}
>
{toolbarCollapsed ? <IconChevronRight size={14} /> : <IconChevronLeft size={14} />}
</button>
</nav>
<div className="center">
<LayerTable
layers={mergedLayers}
selectedId={selectedId}
cursorId={cursorId}
liveLock={liveLock}
onSelect={setSelectedId}
onCursor={setCursorId}
onRename={handleRename}
onOpacityLocal={handleOpacityLocal}
onOpacityCommit={(id, value) => {
void handleOpacityCommit(id, value);
}}
onEnterPressed={() => {
if (inspectorCollapsed) setInspectorCollapsed(false);
}}
/>
<Inspector
layer={selectedLayer}
activeTab={inspectorTab}
onTabChange={setInspectorTab}
collapsed={inspectorCollapsed}
height={inspectorHeight}
liveLock={liveLock}
onToggleCollapse={() => setInspectorCollapsed(v => !v)}
onResizeStart={handleInspectorResizeStart}
resizing={inspectorResizing}
onPatch={applyPatch}
onReleaseOpacity={id => {
void handleReleaseOpacity(id);
}}
/>
</div>
{sidebarVisible && (
<RightSidebar
tab={sidebarTab}
onTabChange={setSidebarTab}
layers={mergedLayers}
nodes={nodes}
selfNodeId={selfNodeId}
alerts={alerts}
selectedLayerId={selectedId}
onSelectLayer={id => {
setSelectedId(id);
setCursorId(id);
}}
onClearAlerts={() => setAlerts([])}
nodeFps={nodeFps}
artnetSenders={artnetSenders}
/>
)}
</div>
<footer className="footer">
<button
type="button"
className="toggle-btn"
aria-pressed={liveLock}
title={
liveLock
? 'Live-Modus: Strukturänderungen gesperrt, Layerparameter nutzbar (§17.7)'
: 'Setup-Modus: Struktur, Patch, Outputs und Netzwerk änderbar (§17.7)'
}
onClick={toggleOpMode}
>
{liveLock ? <IconLock size={11} /> : <IconUnlock size={11} />}
{liveLock ? ' Live' : ' Setup'}
</button>
<span className="foot-item">
{mergedLayers.length} Layer{selectedLayer !== null ? ' · 1 ausgewählt' : ''}
</span>
{warnTotal > 0 && (
<span className="foot-item foot-warn" title="Layer und Meldungen im Warnzustand">
<IconAlertTriangle size={11} />
{warnTotal} Warnung{warnTotal !== 1 ? 'en' : ''}
</span>
)}
{errTotal > 0 && (
<span className="foot-item foot-bad" title="Layer und Meldungen im Fehlerzustand">
<IconCrossCircle size={11} />
{errTotal} Fehler
</span>
)}
<span
className={cx('foot-item', ws.connected ? 'tone-ok' : 'foot-bad')}
title={
ws.connected
? 'WebSocket zum Control Core verbunden'
: 'Keine Verbindung zum Control Core Reconnect mit Backoff läuft'
}
>
<span className={cx('dot', ws.connected ? 'tone-ok' : 'tone-bad')} />
{ws.connected ? `Sync · Revision ${ws.revision ?? '—'}` : 'Getrennt'}
</span>
{health !== null && (
<span className="foot-item" title={`Control Core: ${health.status} · Phase ${health.phase}`}>
Core {health.status}
</span>
)}
<span className="foot-spacer" />
<button
type="button"
className="toggle-btn"
aria-pressed={!inspectorCollapsed}
title="Inspector ein-/ausklappen"
onClick={() => setInspectorCollapsed(v => !v)}
>
<IconInspectorPanel size={11} /> Inspector
</button>
<button
type="button"
className="toggle-btn"
aria-pressed={sidebarVisible}
title="Seitenleiste ein-/ausblenden"
onClick={() => setSidebarVisible(v => !v)}
>
<IconSidebarPanel size={11} /> Seitenleiste
</button>
</footer>
</div>
);
}
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import LayerTable from '../components/LayerTable';
import type { Layer } from '../types';
function makeLayer(overrides: Partial<Layer> = {}): Layer {
return {
id: 'layer-1',
z: 1,
name: 'Test Layer',
type: 'media',
status: 'active',
sourceName: 'Clip A',
sourceBank: 'B1·04',
target: 'Out 1',
timePositionSec: 0,
timeDurationSec: 10,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'web' },
loopMode: 'Loop',
generatorPhase: 0,
transform: { x: 0, y: 0, scale: 100, rotation: 0 },
color: { brightness: 100, contrast: 100, saturation: 100, hue: 0 },
dmx: { universe: 0, address: 1, mode: 'rgb' },
...overrides,
};
}
const noop = () => {};
function renderTable(layers: Layer[], selectedId: string | null = null) {
return render(
<LayerTable
layers={layers}
selectedId={selectedId}
cursorId={null}
liveLock={false}
onSelect={noop}
onCursor={noop}
onRename={noop}
onOpacityLocal={noop}
onOpacityCommit={noop}
onEnterPressed={noop}
/>,
);
}
describe('LayerTable', () => {
it('renders all 10 required columns', () => {
renderTable([makeLayer()]);
const headers = screen.getAllByRole('columnheader');
expect(headers).toHaveLength(10);
const headerTexts = headers.map((h) => h.textContent);
// Status header is empty; the remaining 9 are named.
expect(headerTexts).toEqual(['', 'Layer', 'Typ', 'Source', 'Target', 'Time', 'Opacity', 'Blend', 'FX', 'Control']);
});
it('shows layer names', () => {
renderTable([makeLayer({ id: 'a', name: 'Alpha' }), makeLayer({ id: 'b', name: 'Beta' })]);
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Beta')).toBeInTheDocument();
});
it('marks the selected row with the selected class', () => {
renderTable([makeLayer({ id: 'a', name: 'Alpha' }), makeLayer({ id: 'b', name: 'Beta' })], 'b');
const rows = screen.getAllByRole('row');
// rows[0] is the header row; rows[1] = Alpha, rows[2] = Beta
expect(rows[1]).not.toHaveClass('selected');
expect(rows[2]).toHaveClass('selected');
expect(rows[2]).toHaveAttribute('aria-selected', 'true');
});
it('distinguishes active/paused/error status visually via tone classes', () => {
renderTable([
makeLayer({ id: 'a', name: 'Active', status: 'active' }),
makeLayer({ id: 'b', name: 'Paused', status: 'paused' }),
makeLayer({ id: 'c', name: 'Error', status: 'error' }),
]);
const rows = screen.getAllByRole('row');
// Each row's status cell carries the tone class.
expect(rows[1].querySelector('.c-status')).toHaveClass('st-active');
expect(rows[2].querySelector('.c-status')).toHaveClass('st-paused');
expect(rows[3].querySelector('.c-status')).toHaveClass('st-error');
});
it('shows the status label as screen-reader text (never color alone)', () => {
renderTable([makeLayer({ id: 'a', name: 'Active', status: 'active' })]);
expect(screen.getByText('Aktiv')).toBeInTheDocument();
});
});
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { ComponentProps } from 'react';
import StatusBar from '../components/StatusBar';
import type { StatusToneItem } from '../components/StatusBar';
function makeTone(overrides: Partial<StatusToneItem> = {}): StatusToneItem {
return { tone: 'ok', label: 'OK', ...overrides };
}
function renderBar(overrides: Partial<ComponentProps<typeof StatusBar>> = {}) {
return render(
<StatusBar
projectName="Demo"
nodeName="node-1"
output={makeTone({ label: 'Live' })}
quality="Auto"
fps={59.9401}
artnet={makeTone({ label: 'Art-Net OK' })}
sync={makeTone({ label: 'Sync' })}
clock="08:39:00"
{...overrides}
/>,
);
}
describe('StatusBar', () => {
it('shows FPS with two decimal places', () => {
renderBar({ fps: 59.9401 });
expect(screen.getByText('59.94')).toBeInTheDocument();
});
it('shows FPS placeholder when no measurement is available', () => {
renderBar({ fps: null });
expect(screen.getByText('—')).toBeInTheDocument();
});
it('shows Quality: Auto', () => {
renderBar({ quality: 'Auto' });
expect(screen.getByText('Quality:')).toBeInTheDocument();
expect(screen.getByText('Auto')).toBeInTheDocument();
});
it('shows the Art-Net status with label and tone class', () => {
renderBar({ artnet: makeTone({ tone: 'warn', label: 'Art-Net Warn' }) });
expect(screen.getByText('Art-Net Warn')).toBeInTheDocument();
const item = screen.getByTitle('Art-Net: Art-Net Warn');
expect(item).toHaveClass('tone-warn');
});
});
@@ -0,0 +1,484 @@
/**
* Unterer Inspector (§17.2, §17.4).
*
* Tabs: Layer, Source, Transform, Color, FX, DMX, Advanced.
* Fest positionierte gemeinsame Parameter, kontextabhängige Felder nach Typ,
* Zahlenfelder mit Tippen, Ziehen (Grip) und Feinsteuerung per Tastatur
* (Pfeiltasten, Shift = Feinschritt). Escape bricht ab, löst nichts aus.
* Vertikal skalierbar über Divider, vollständig einklappbar.
* Live-Lock (§17.7): Struktur (Name, DMX-Patch) gesperrt, Layerparameter frei.
*/
import { useRef, useState } from 'react';
import type { ComponentType, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import type { FxSlot, InspectorTab, Layer } from '../types';
import { BLEND_MODES, LOOP_MODES, STATUS_LABEL, TYPE_LABEL, cx, formatTimecode, parseNumberInput } from '../utils';
import { IconChevronDown, IconChevronUp, StatusGlyph } from '../icons';
export interface InspectorCallbacks {
readonly onPatch: (patch: InspectorPatch) => void;
/** Release des Opacity-Overrides (§11.3) über /commands/{id}/release. */
readonly onReleaseOpacity: (layerId: string) => void;
}
export interface InspectorPatch {
readonly name?: string;
readonly blend?: string;
readonly loopMode?: string;
readonly timeDurationSec?: number;
readonly generatorPhase?: number;
readonly transformX?: number;
readonly transformY?: number;
readonly transformScale?: number;
readonly transformRotation?: number;
readonly colorBrightness?: number;
readonly colorContrast?: number;
readonly colorSaturation?: number;
readonly colorHue?: number;
readonly fxBypass?: Readonly<[number | null, boolean]>;
readonly dmxUniverse?: number;
readonly dmxAddress?: number;
}
interface InspectorProps extends InspectorCallbacks {
readonly layer: Layer | null;
readonly activeTab: InspectorTab;
readonly onTabChange: (tab: InspectorTab) => void;
readonly collapsed: boolean;
readonly height: number;
readonly liveLock: boolean;
readonly onToggleCollapse: () => void;
readonly onResizeStart: (e: ReactPointerEvent<HTMLDivElement>) => void;
readonly resizing: boolean;
}
const TABS: ReadonlyArray<{ readonly id: InspectorTab; readonly label: string }> = [
{ id: 'layer', label: 'Layer' },
{ id: 'source', label: 'Source' },
{ id: 'transform', label: 'Transform' },
{ id: 'color', label: 'Color' },
{ id: 'fx', label: 'FX' },
{ id: 'dmx', label: 'DMX' },
{ id: 'advanced', label: 'Advanced' },
];
/* ----------------------------------------------------------------
* Zahlenfeld: Tippen, Ziehen am Grip, Feinsteuerung per Tastatur.
* ---------------------------------------------------------------- */
interface NumFieldProps {
readonly label: string;
readonly value: number;
readonly unit?: string;
readonly step?: number;
readonly disabled?: boolean;
readonly onCommit: (value: number) => void;
}
function NumField({ label, value, unit, step = 1, disabled = false, onCommit }: NumFieldProps) {
const [draft, setDraft] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const drag = useRef<{ readonly startValue: number; readonly startX: number } | null>(null);
const shown = draft ?? String(value);
const commitDraft = (raw: string): void => {
const parsed = parseNumberInput(raw);
setDraft(null);
if (parsed !== null) onCommit(parsed);
};
const nudge = (delta: number): void => {
const next = Math.round((value + delta) * 1000) / 1000;
onCommit(next);
};
const onGripDown = (e: ReactPointerEvent<HTMLSpanElement>): void => {
if (disabled) return;
e.preventDefault();
drag.current = { startValue: value, startX: e.clientX };
(e.target as HTMLSpanElement).setPointerCapture(e.pointerId);
};
const onGripMove = (e: ReactPointerEvent<HTMLSpanElement>): void => {
const d = drag.current;
if (d === null) return;
const raw = d.startValue + (e.clientX - d.startX) * step;
onCommit(Math.round(raw * 1000) / 1000);
};
const onGripUp = (): void => {
drag.current = null;
};
return (
<div className="field">
<span className="field-label">{label}</span>
<div className="numfield">
<input
ref={inputRef}
type="text"
className="mono"
value={shown}
disabled={disabled}
aria-label={label}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => e.target.select()}
onBlur={(e) => {
if (draft !== null) commitDraft(e.target.value);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') {
commitDraft((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
setDraft(null);
(e.target as HTMLInputElement).blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
nudge(e.shiftKey ? step / 10 : step);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
nudge(e.shiftKey ? -(step / 10) : -step);
}
}}
/>
{unit !== undefined && <span className="note">{unit}</span>}
<span
className="drag-grip"
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={`${label} ziehen`}
onPointerDown={onGripDown}
onPointerMove={onGripMove}
onPointerUp={onGripUp}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'ArrowUp') nudge(e.shiftKey ? step / 10 : step);
if (e.key === 'ArrowDown') nudge(e.shiftKey ? -(step / 10) : -step);
}}
>
</span>
</div>
</div>
);
}
/* ----------------------------------------------------------------
* Kleine Bausteine
* ---------------------------------------------------------------- */
function Field({ label, children }: { readonly label: string; readonly children: ReactNode }) {
return (
<div className="field">
<span className="field-label">{label}</span>
{children}
</div>
);
}
function FieldValue({ label, value, title }: { readonly label: string; readonly value: string; readonly title?: string }) {
return (
<Field label={label}>
<span className="field-value" title={title ?? value}>
<span className="ellip">{value}</span>
</span>
</Field>
);
}
function Group({ title, children }: { readonly title: string; readonly children: ReactNode }) {
return (
<div className="field-group">
<div className="group-title">{title}</div>
<div className="field-grid">{children}</div>
</div>
);
}
function fxStateText(state: FxSlot['state']): string {
return state === 'active' ? 'aktiv' : state === 'bypassed' ? 'bypassed' : 'Fehler';
}
/* ----------------------------------------------------------------
* Tabs
* ---------------------------------------------------------------- */
interface TabBodyProps extends InspectorCallbacks {
readonly layer: Layer;
readonly liveLock: boolean;
}
function LayerTabBody({ layer, liveLock, onPatch }: TabBodyProps) {
return (
<Group title="Layer">
<Field label="Name (Struktur Live gesperrt)">
<input
type="text"
value={layer.name}
disabled={liveLock}
aria-label="Layername"
title={liveLock ? 'Umbenennen in Live gesperrt (§17.7)' : layer.name}
onChange={(e) => onPatch({ name: e.target.value })}
/>
</Field>
<FieldValue label="Typ" value={TYPE_LABEL[layer.type]} />
<FieldValue label="Status" value={STATUS_LABEL[layer.status]} title={`Betriebszustand: ${STATUS_LABEL[layer.status]}`} />
<Field label="Blend">
<select
value={layer.blend}
aria-label="Blend-Modus"
onChange={(e) => onPatch({ blend: e.target.value })}
>
{BLEND_MODES.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Field>
<Field label="Loop">
<select
value={layer.loopMode}
aria-label="Loop-Modus"
onChange={(e) => onPatch({ loopMode: e.target.value })}
>
{LOOP_MODES.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</Field>
</Group>
);
}
function SourceTabBody({ layer, onPatch }: TabBodyProps) {
return (
<>
<Group title="Quelle">
<FieldValue label="Clip / Generator" value={layer.sourceName} title={layer.sourceName} />
<FieldValue label="Bank / Index" value={layer.sourceBank} />
<FieldValue label="Ziel" value={layer.target} title={layer.target} />
</Group>
<Group title="Zeiten">
<NumField
label="Dauer"
value={layer.timeDurationSec}
unit="s · 0 = ∞"
step={0.1}
onCommit={(v) => onPatch({ timeDurationSec: Math.max(0, v) })}
/>
<FieldValue
label="Position"
value={formatTimecode(layer.timePositionSec)}
title={`aktuelle Wiedergabeposition ${formatTimecode(layer.timePositionSec)}`}
/>
{layer.type === 'generator' && (
<NumField
label="Generatorphase"
value={layer.generatorPhase}
unit="s"
step={0.01}
onCommit={(v) => onPatch({ generatorPhase: v })}
/>
)}
</Group>
</>
);
}
function TransformTabBody({ layer, onPatch }: TabBodyProps) {
return (
<Group title="Transform">
<NumField label="X" value={layer.transform.x} unit="px" step={1} onCommit={(v) => onPatch({ transformX: v })} />
<NumField label="Y" value={layer.transform.y} unit="px" step={1} onCommit={(v) => onPatch({ transformY: v })} />
<NumField label="Scale" value={layer.transform.scale} unit="%" step={0.5} onCommit={(v) => onPatch({ transformScale: v })} />
<NumField label="Rotation" value={layer.transform.rotation} unit="°" step={1} onCommit={(v) => onPatch({ transformRotation: v })} />
</Group>
);
}
function ColorTabBody({ layer, onPatch }: TabBodyProps) {
return (
<Group title="Farbkorrektur">
<NumField label="Brightness" value={layer.color.brightness} unit="%" step={1} onCommit={(v) => onPatch({ colorBrightness: v })} />
<NumField label="Contrast" value={layer.color.contrast} unit="%" step={1} onCommit={(v) => onPatch({ colorContrast: v })} />
<NumField label="Saturation" value={layer.color.saturation} unit="%" step={1} onCommit={(v) => onPatch({ colorSaturation: v })} />
<NumField label="Hue" value={layer.color.hue} unit="°" step={1} onCommit={(v) => onPatch({ colorHue: v })} />
</Group>
);
}
function FxTabBody({ layer, onPatch }: TabBodyProps) {
return (
<Group title="Effekt-Slots (2)">
{layer.fxSlots.map((slot, idx) => (
<div className="field" key={idx}>
<span className="field-label">Slot {idx + 1}</span>
{slot === null ? (
<span className="field-value">
<span className="ellip">leer</span>
</span>
) : (
<span className="field-value" title={`${slot.name} ${fxStateText(slot.state)}`}>
<span className="ellip">{slot.name}</span>
<button
type="button"
className="toggle-btn"
aria-pressed={slot.state === 'bypassed'}
title={slot.state === 'bypassed' ? 'Bypass aufheben' : 'Effekt bypassen'}
onClick={() => onPatch({ fxBypass: [idx, slot.state !== 'bypassed'] })}
>
{slot.state === 'bypassed' ? 'Bypassed' : 'Aktiv'}
</button>
{slot.state === 'error' && <span className="tone-bad">ERR</span>}
</span>
)}
</div>
))}
</Group>
);
}
function DmxTabBody({ layer, liveLock, onPatch }: TabBodyProps) {
return (
<>
<Group title="Art-Net-Patch (16-Bit, §16)">
<NumField
label="Universe (Struktur)"
value={layer.dmx.universe}
step={1}
disabled={liveLock}
onCommit={(v) => onPatch({ dmxUniverse: Math.max(0, Math.min(63, Math.round(v))) })}
/>
<NumField
label="Adresse (Struktur)"
value={layer.dmx.address}
step={1}
disabled={liveLock}
onCommit={(v) => onPatch({ dmxAddress: Math.max(1, Math.min(512, Math.round(v))) })}
/>
<FieldValue label="Modus" value={layer.dmx.mode} />
</Group>
<p className="note">
DMX-Werte sind numerisch präzise editierbar (16 Bit). Patch-Änderungen erfordern
Setup-Modus und werden serverseitig bestätigt.
</p>
</>
);
}
function AdvancedTabBody({ layer, onReleaseOpacity }: TabBodyProps) {
return (
<Group title="Automation / Ownership">
<FieldValue label="Steuerquellen" value={layer.control.sources.join(' · ')} />
<FieldValue label="Aktueller Owner" value={layer.control.owner} title={layer.control.owner} />
<FieldValue label="Opacity-Parameter" value={`composition/…/layer/${layer.id.slice(0, 8)}…/opacity`} title="Pfad folgt §10.2 (UUID-basiert)" />
<div className="field">
<span className="field-label">Override</span>
<div className="btn-row">
<button
type="button"
className="btn"
title="Web-Override des Opacity-Parameters freigeben (§11.3)"
onClick={() => onReleaseOpacity(layer.id)}
>
Release Opacity
</button>
</div>
</div>
</Group>
);
}
const TAB_BODIES: Record<InspectorTab, ComponentType<TabBodyProps>> = {
layer: LayerTabBody,
source: SourceTabBody,
transform: TransformTabBody,
color: ColorTabBody,
fx: FxTabBody,
dmx: DmxTabBody,
advanced: AdvancedTabBody,
};
/* ----------------------------------------------------------------
* Inspector
* ---------------------------------------------------------------- */
export default function Inspector(props: InspectorProps) {
const { layer, activeTab, onTabChange, collapsed, height, liveLock, onToggleCollapse, onResizeStart, resizing } = props;
const Body = TAB_BODIES[activeTab];
if (collapsed) {
return (
<div className="insp-collapsed">
<button
type="button"
className="icon-btn"
aria-label="Inspector aufklappen"
title="Inspector aufklappen"
onClick={onToggleCollapse}
>
<IconChevronUp size={13} />
</button>
<span>Inspector: {layer !== null ? layer.name : 'keine Auswahl'}</span>
</div>
);
}
return (
<section className="inspector" style={{ height }} aria-label="Inspector">
<div
className={cx('insp-divider', resizing && 'dragging')}
role="separator"
aria-orientation="horizontal"
aria-label="Inspector-Höhe ändern"
onPointerDown={onResizeStart}
/>
<div className="inspector-head">
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={activeTab === t.id}
className="insp-tab"
onClick={() => onTabChange(t.id)}
>
{t.label}
</button>
))}
<div className="insp-tools">
{layer !== null && (
<span className={cx('insp-tab')} title="Ausgewählter Layer">
<StatusGlyph status={layer.status} size={11} />
<span className="ellip" style={{ maxWidth: 120 }}>
{layer.name}
</span>
</span>
)}
<button
type="button"
className="icon-btn"
aria-label="Inspector einklappen"
title="Inspector einklappen"
onClick={onToggleCollapse}
>
<IconChevronDown size={13} />
</button>
</div>
</div>
<div className="inspector-body" role="tabpanel">
{layer === null ? (
<div className="insp-empty">Kein Layer ausgewählt Zeile in der Tabelle wählen.</div>
) : (
<Body layer={layer} liveLock={liveLock} onPatch={props.onPatch} onReleaseOpacity={props.onReleaseOpacity} />
)}
</div>
</section>
);
}
@@ -0,0 +1,289 @@
/**
* Layer-Tabelle primäre Mixeransicht (§17.3).
*
* Pflichtspalten mit fixen Breiten über --lt-cols (tokens.css):
* Status | Layer (Z + Name) | Typ | Source | Target | Time | Opacity | Blend | FX | Control.
* Zeilenhöhe 30 px (Desktop-Density), Selektion als Akzentfläche ohne Blinken,
* Pfeiltasten-Navigation, Escape bricht Editieren ab (kein Blackout, §17.7).
* Der Opacity-Fader committet real per parameter.set (§10.2, §23.2).
*/
import { useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { Layer } from '../types';
import { STATUS_LABEL, STATUS_TONE, TYPE_LABEL, cx, formatTimecode } from '../utils';
import { StatusGlyph } from '../icons';
interface LayerTableProps {
readonly layers: readonly Layer[];
readonly selectedId: string | null;
readonly cursorId: string | null;
/** Live-Modus sperrt Umbenennen (§17.7). */
readonly liveLock: boolean;
readonly onSelect: (id: string) => void;
readonly onCursor: (id: string) => void;
readonly onRename: (id: string, name: string) => void;
readonly onOpacityLocal: (id: string, value: number) => void;
readonly onOpacityCommit: (id: string, value: number) => void;
readonly onEnterPressed: () => void;
}
const HEADERS: readonly string[] = [
'',
'Layer',
'Typ',
'Source',
'Target',
'Time',
'Opacity',
'Blend',
'FX',
'Control',
];
function fxSlotTitle(layer: Layer): string {
return layer.fxSlots
.map((s, i) =>
s === null
? `Slot ${i + 1}: leer`
: `Slot ${i + 1}: ${s.name} (${s.state === 'active' ? 'aktiv' : s.state === 'bypassed' ? 'bypassed' : 'Fehler'})`,
)
.join(' · ');
}
export default function LayerTable(props: LayerTableProps) {
const { layers, selectedId, cursorId, liveLock } = props;
const [editingId, setEditingId] = useState<string | null>(null);
const [draftName, setDraftName] = useState('');
/** Lokaler Fader-Stand während des Ziehens; Commit löst parameter.set aus. */
const [dragValue, setDragValue] = useState<{ readonly id: string; readonly value: number } | null>(null);
const cursorIndex = layers.findIndex((l) => l.id === cursorId);
const handleKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>): void => {
if (editingId !== null) return; // Eingabefelder fangen Tasten nie global ab (§17.7)
if (layers.length === 0) return;
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp': {
e.preventDefault();
const dir = e.key === 'ArrowDown' ? 1 : -1;
const next = Math.min(layers.length - 1, Math.max(0, cursorIndex + dir));
const id = layers[next].id;
props.onCursor(id);
props.onSelect(id);
break;
}
case 'Enter':
props.onEnterPressed();
break;
case 'Escape':
// beendet nichts Destruktives; Blackout ist separat geschützt (§17.7)
break;
default:
break;
}
};
const beginEdit = (layer: Layer): void => {
if (liveLock) return;
setEditingId(layer.id);
setDraftName(layer.name);
};
const commitEdit = (): void => {
if (editingId === null) return;
const name = draftName.trim();
if (name.length > 0) props.onRename(editingId, name);
setEditingId(null);
};
const opacityOf = (layer: Layer): number =>
dragValue !== null && dragValue.id === layer.id ? dragValue.value : layer.opacity;
const handleOpacityInput = (layer: Layer, value: number): void => {
setDragValue({ id: layer.id, value });
props.onOpacityLocal(layer.id, value);
};
const commitOpacity = (layer: Layer): void => {
if (dragValue === null || dragValue.id !== layer.id) return;
props.onOpacityCommit(layer.id, dragValue.value);
setDragValue(null);
};
return (
<div
className="layer-table"
tabIndex={0}
role="grid"
aria-label="Layer-Tabelle"
onKeyDown={handleKeyDown}
>
<div className="lt-head" role="row">
{HEADERS.map((h, i) => (
<div key={i} className="lt-cell" role="columnheader">
{h}
</div>
))}
</div>
{layers.length === 0 ? (
<div className="lt-empty">Keine Layer im Projekt.</div>
) : (
layers.map((layer) => {
const selected = layer.id === selectedId;
const isCursor = layer.id === cursorId;
const editing = layer.id === editingId;
const op = opacityOf(layer);
const dur =
layer.timeDurationSec === 0 ? '∞' : formatTimecode(layer.timeDurationSec);
return (
<div
key={layer.id}
role="row"
aria-selected={selected}
className={cx('lt-row', selected && 'selected', isCursor && 'is-cursor')}
onClick={() => {
props.onSelect(layer.id);
props.onCursor(layer.id);
}}
>
{/* Status Glyphe + Ton + Tooltip, nie Farbe allein (§17.6) */}
<div
className={cx('lt-cell', 'c-status', STATUS_TONE[layer.status])}
role="gridcell"
title={STATUS_LABEL[layer.status]}
>
<StatusGlyph status={layer.status} />
<span className="sr-only">{STATUS_LABEL[layer.status]}</span>
</div>
{/* Layer: Z-Reihenfolge + editierbarer Name */}
<div className="lt-cell" role="gridcell">
<span className="z-badge mono" title={`Z ${layer.z}`}>
{layer.z}
</span>
{editing ? (
<input
className="name-input"
value={draftName}
autoFocus
aria-label="Layer umbenennen"
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') commitEdit();
else if (e.key === 'Escape') setEditingId(null);
}}
onBlur={commitEdit}
/>
) : (
<span
className="layer-name ellip"
title={
liveLock
? `${layer.name} (Umbenennen in Live gesperrt)`
: `${layer.name} Doppelklick zum Umbenennen`
}
onDoubleClick={() => beginEdit(layer)}
>
{layer.name}
</span>
)}
</div>
{/* Typ */}
<div className="lt-cell" role="gridcell" title={TYPE_LABEL[layer.type]}>
<span className="ellip">{TYPE_LABEL[layer.type]}</span>
</div>
{/* Source: Clip-/Generatorname + Bank/Index */}
<div
className="lt-cell"
role="gridcell"
title={`${layer.sourceName} · ${layer.sourceBank}`}
>
<span className="ellip">{layer.sourceName}</span>
<span className="src-bank mono">{layer.sourceBank}</span>
</div>
{/* Target */}
<div className="lt-cell" role="gridcell" title={layer.target}>
<span className="ellip">{layer.target}</span>
</div>
{/* Time monospace/tabellarisch (§17.6) */}
<div
className="lt-cell mono"
role="gridcell"
title={`Position ${formatTimecode(layer.timePositionSec)} / Dauer ${
layer.timeDurationSec === 0 ? 'unbegrenzt' : formatTimecode(layer.timeDurationSec)
}`}
>
<span className="time-pos">{formatTimecode(layer.timePositionSec)}</span>
<span className="time-dur">{dur}</span>
</div>
{/* Opacity: Wert + schmaler Fader (64 px) */}
<div className="lt-cell" role="gridcell" title={`Opacity ${op} %`}>
<span className="opacity-val mono">{op}</span>
<input
type="range"
className="opacity-fader"
min={0}
max={100}
step={1}
value={op}
aria-label={`Opacity von ${layer.name}`}
onKeyDown={(e) => e.stopPropagation()}
onChange={(e) => handleOpacityInput(layer, Number(e.target.value))}
onPointerUp={() => commitOpacity(layer)}
onKeyUp={() => commitOpacity(layer)}
onBlur={() => commitOpacity(layer)}
/>
</div>
{/* Blend */}
<div className="lt-cell" role="gridcell" title={`Blend ${layer.blend}`}>
<span className="ellip">{layer.blend}</span>
</div>
{/* FX: zwei Slots mit Bypass-/Fehlerstatus */}
<div className="lt-cell" role="gridcell" title={fxSlotTitle(layer)}>
{layer.fxSlots.map((slot, idx) =>
slot === null ? (
<span key={idx} className="fx-slot" title="Effektslot leer">
</span>
) : (
<span
key={idx}
className={cx(
'fx-slot',
slot.state === 'bypassed' && 'bypassed',
slot.state === 'error' && 'error',
)}
title={`${slot.name} ${
slot.state === 'active'
? 'aktiv'
: slot.state === 'bypassed'
? 'bypassed'
: 'Fehler'
}`}
>
<span className="fx-dot" />
<span className="ellip">{slot.name}</span>
{slot.state === 'bypassed' && <span>BYP</span>}
{slot.state === 'error' && <span>ERR</span>}
</span>
),
)}
</div>
{/* Control: Quellen + Owner */}
<div
className="lt-cell"
role="gridcell"
title={`Steuerung: ${layer.control.sources.join(', ')} · Owner: ${layer.control.owner}`}
>
<span className="ctrl-owner ellip">{layer.control.sources.join(' · ')}</span>
<span className="ctrl-owner ellip">{layer.control.owner}</span>
</div>
</div>
);
})
)}
</div>
);
}
@@ -0,0 +1,229 @@
/**
* Rechte Seitenleiste (§17.5).
*
* Umschaltbar zwischen Active Layers (Thumbnail, Position, Node, Status,
* Pause/Stop deaktiviert bis die Transport-API existiert), Nodes
* (Anzeigename, Rolle, Health, FPS, Art-Net-Sender) und Alerts (Fehler
* mit Zeitstempel). Health nie allein über Farbe: immer Text/Glyphe.
*/
import type { AlertItem, ClusterNode, Layer, SidebarTab } from '../types';
import { STATUS_LABEL, STATUS_TONE, cx, formatTimecode, formatTimeMs } from '../utils';
import { IconAlertTriangle, IconCrossCircle, IconInfoCircle, IconPauseSm, IconPlaySm, IconStopSm, StatusGlyph } from '../icons';
interface RightSidebarProps {
readonly tab: SidebarTab;
readonly onTabChange: (tab: SidebarTab) => void;
readonly layers: readonly Layer[];
readonly nodes: readonly ClusterNode[];
readonly selfNodeId: string | null;
readonly alerts: readonly AlertItem[];
readonly selectedLayerId: string | null;
readonly onSelectLayer: (id: string) => void;
readonly onClearAlerts: () => void;
readonly nodeFps: ReadonlyMap<string, number>;
readonly artnetSenders: ReadonlyMap<string, string>;
}
const HEALTH_TONE: Record<ClusterNode['health'], string> = {
online: 'tone-ok',
degraded: 'tone-warn',
stale: 'tone-warn',
offline: 'tone-bad',
};
const HEALTH_LABEL: Record<ClusterNode['health'], string> = {
online: 'online',
degraded: 'degraded',
stale: 'veraltet',
offline: 'offline',
};
function SeverityIcon({ severity }: { readonly severity: AlertItem['severity'] }) {
if (severity === 'error') return <IconCrossCircle size={13} />;
if (severity === 'warning') return <IconAlertTriangle size={13} />;
return <IconInfoCircle size={13} />;
}
function ActiveLayersTab(props: RightSidebarProps) {
const active = props.layers.filter(
(l) => l.status === 'active' || l.status === 'preloaded' || l.status === 'out_of_sync',
);
if (active.length === 0) {
return <div className="empty-note">Keine laufenden oder vorgeladenen Layer.</div>;
}
return (
<>
{active.map((l) => (
<div
key={l.id}
className={cx('side-item', props.selectedLayerId === l.id && 'selected')}
onClick={() => props.onSelectLayer(l.id)}
>
<div className="thumb mono" title={`Thumbnail: ${l.sourceName}`}>
{l.sourceBank.slice(0, 2)}
</div>
<div className="item-main">
<div className="item-title">
<span className={cx(STATUS_TONE[l.status])}>
<StatusGlyph status={l.status} size={11} />
</span>
<span className="item-name ellip" title={l.name}>
{l.name}
</span>
</div>
<div className="item-meta">
<span className="mono">{formatTimecode(l.timePositionSec)}</span>
<span className="ellip" title={`Ziel: ${l.target}`}>
{l.target}
</span>
<span title={`Status: ${STATUS_LABEL[l.status]}`}>{STATUS_LABEL[l.status]}</span>
</div>
<div className="item-meta">
<button
type="button"
className="icon-btn"
disabled
title={`Pause ${l.name}: benötigt Transport-API (spätere Phase)`}
>
{l.status === 'paused' ? <IconPlaySm size={11} /> : <IconPauseSm size={11} />}
</button>
<button
type="button"
className="icon-btn"
disabled
title={`Stop ${l.name}: benötigt Transport-API (spätere Phase)`}
>
<IconStopSm size={11} />
</button>
</div>
</div>
</div>
))}
</>
);
}
function NodesTab(props: RightSidebarProps) {
if (props.nodes.length === 0) {
return <div className="empty-note">Keine Nodes registriert (Control Core nicht erreichbar).</div>;
}
return (
<>
{props.nodes.map((n) => {
const fps = props.nodeFps.get(n.node_id);
const sender = props.artnetSenders.get(n.node_id);
return (
<div key={n.node_id} className="side-item">
<div className="item-main">
<div className="item-title">
<span className={cx('dot', HEALTH_TONE[n.health])} title={`Health: ${HEALTH_LABEL[n.health]}`} />
<span className="item-name ellip" title={n.display_name}>
{n.display_name}
</span>
{props.selfNodeId === n.node_id && (
<span className="self-tag" title="Dieser Node">
(dieser)
</span>
)}
</div>
<div className="item-meta">
<span title={`Rollen: ${n.roles.join(', ')}`}>
{n.roles.map((r) => (
<span key={r} className="role-chip">
{r}
</span>
))}
</span>
<span className={cx(HEALTH_TONE[n.health])} title={`Health: ${HEALTH_LABEL[n.health]}`}>
{HEALTH_LABEL[n.health]}
</span>
</div>
<div className="item-meta">
<span className="mono" title="Frames pro Sekunde (Metrik folgt mit Renderer-IPC)">
FPS {fps !== undefined ? fps.toFixed(2) : '—'}
</span>
<span className="ellip" title={`Art-Net-Sender: ${sender ?? 'nicht gemeldet'}`}>
Art-Net {sender ?? '—'}
</span>
</div>
</div>
</div>
);
})}
</>
);
}
function AlertsTab(props: RightSidebarProps) {
if (props.alerts.length === 0) {
return <div className="empty-note">Keine Meldungen.</div>;
}
return (
<>
{props.alerts.map((a) => (
<div key={a.id} className="alert-item">
<span
className={cx(
a.severity === 'error' ? 'tone-bad' : a.severity === 'warning' ? 'tone-warn' : 'tone-muted',
)}
>
<SeverityIcon severity={a.severity} />
</span>
<span className="alert-time mono">{formatTimeMs(a.timeMs)}</span>
<span className="alert-msg">{a.message}</span>
</div>
))}
</>
);
}
export default function RightSidebar(props: RightSidebarProps) {
return (
<aside className="sidebar" aria-label="Seitenleiste">
<div className="side-head">
<div className="seg" role="tablist" aria-label="Seitenleiste-Ansicht">
<button
type="button"
role="tab"
aria-selected={props.tab === 'active'}
aria-pressed={props.tab === 'active'}
onClick={() => props.onTabChange('active')}
>
Active Layers
</button>
<button
type="button"
role="tab"
aria-selected={props.tab === 'nodes'}
aria-pressed={props.tab === 'nodes'}
onClick={() => props.onTabChange('nodes')}
>
Nodes
</button>
<button
type="button"
role="tab"
aria-selected={props.tab === 'alerts'}
aria-pressed={props.tab === 'alerts'}
onClick={() => props.onTabChange('alerts')}
>
Alerts
</button>
</div>
</div>
<div className="side-body" role="tabpanel">
{props.tab === 'active' && <ActiveLayersTab {...props} />}
{props.tab === 'nodes' && <NodesTab {...props} />}
{props.tab === 'alerts' && <AlertsTab {...props} />}
</div>
{props.tab === 'alerts' && (
<div className="side-foot">
<button type="button" className="clear-btn" onClick={props.onClearAlerts}>
Alle löschen
</button>
</div>
)}
</aside>
);
}
@@ -0,0 +1,92 @@
/**
* Obere Statusleiste (§17.2), 32 px hoch.
*
* Produkt-/Projektname, lokaler Node, Output-Status, „Quality: Auto“,
* FPS (monospace, 2 Dezimalstellen), Art-Net und Clock/Sync als kleine
* Zustandsanzeigen. Zustand nie allein über Farbe: immer Text/Icon daneben.
*/
import { IconClock, IconMonitor, IconSignal } from '../icons';
import { cx, formatFps } from '../utils';
export interface StatusToneItem {
readonly tone: 'ok' | 'warn' | 'bad' | 'muted';
readonly label: string;
}
interface StatusBarProps {
readonly projectName: string;
readonly nodeName: string;
readonly output: StatusToneItem;
readonly quality: string;
readonly fps: number | null;
readonly artnet: StatusToneItem;
readonly sync: StatusToneItem;
readonly clock: string;
}
const TONE_CLASS: Record<StatusToneItem['tone'], string> = {
ok: 'tone-ok',
warn: 'tone-warn',
bad: 'tone-bad',
muted: 'tone-muted',
};
export default function StatusBar(props: StatusBarProps) {
const { projectName, nodeName, output, quality, fps, artnet, sync, clock } = props;
return (
<header className="statusbar">
<span className="product">HMS MediaEngine</span>
<span className="sep" />
<span className="status-item" title={`Projekt: ${projectName}`}>
<span className="ellip">{projectName}</span>
</span>
<span className="sep" />
<span className="status-item" title={`Lokaler Node: ${nodeName}`}>
<span>Node</span>
<span className="value ellip">{nodeName}</span>
</span>
<div className="status-push">
<span
className={cx('status-item', TONE_CLASS[output.tone])}
title={`Output: ${output.label}`}
>
<IconMonitor size={13} />
<span className="value">{output.label}</span>
</span>
<span
className="status-item tone-muted"
title="Renderqualität automatische Leistungsanpassung"
>
<span>Quality:</span>
<span className="value">{quality}</span>
</span>
<span
className="status-item"
title="Frame-Rate der Bedienoberfläche (Renderer-Metrik folgt mit IPC-Handshake)"
>
<span>FPS</span>
<span className="value mono">{formatFps(fps)}</span>
</span>
<span
className={cx('status-item', TONE_CLASS[artnet.tone])}
title={`Art-Net: ${artnet.label}`}
>
<IconSignal size={13} />
<span className="value">{artnet.label}</span>
</span>
<span
className={cx('status-item', TONE_CLASS[sync.tone])}
title={`Zustandssynchronisation: ${sync.label}`}
>
<span className={cx('dot', TONE_CLASS[sync.tone])} />
<span className="value">{sync.label}</span>
</span>
<span className="status-item" title="Lokale Uhr">
<IconClock size={13} />
<span className="value mono">{clock}</span>
</span>
</div>
</header>
);
}
+122
View File
@@ -0,0 +1,122 @@
/**
* REST-API-Anbindung des Control Core (§23.2, §17.9).
*
* Dev: '/api/v1' läuft über den Vite-Proxy auf http://localhost:8000.
* Jede Änderung wird serverseitig bestätigt (keine blinde optimistic UI,
* §17.9); Fehler landen als ApiError mit Status und Detail beim Aufrufer.
*/
import type {
ClusterNodesResponse,
CommandAck,
CommandEnvelope,
DiagnosticsResponse,
HealthResponse,
IdentityResponse,
ParametersResponse,
ReleaseResponse,
} from '../types';
const API_BASE = '/api/v1';
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
readonly detail: unknown,
) {
super(message);
this.name = 'ApiError';
}
}
function describeDetail(status: number, statusText: string, body: unknown): string {
if (typeof body === 'object' && body !== null && 'detail' in body) {
const detail = (body as { detail: unknown }).detail;
if (typeof detail === 'string') return detail;
try {
return JSON.stringify(detail);
} catch {
return `${status} ${statusText}`;
}
}
return `${status} ${statusText}`;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
});
} catch (err) {
throw new ApiError(0, `Netzwerkfehler: ${err instanceof Error ? err.message : 'unbekannt'}`, null);
}
const text = await response.text();
let body: unknown = null;
if (text.length > 0) {
try {
body = JSON.parse(text) as unknown;
} catch {
body = text;
}
}
if (!response.ok) {
throw new ApiError(response.status, describeDetail(response.status, response.statusText, body), body);
}
return body as T;
}
/* GET-Endpunkte (app.py) */
export const getHealth = (): Promise<HealthResponse> => request<HealthResponse>('/system/health');
export const getIdentity = (): Promise<IdentityResponse> => request<IdentityResponse>('/system/identity');
export const getClusterNodes = (): Promise<ClusterNodesResponse> =>
request<ClusterNodesResponse>('/cluster/nodes');
export const getParameters = (): Promise<ParametersResponse> => request<ParametersResponse>('/parameters');
export const getDiagnostics = (): Promise<DiagnosticsResponse> =>
request<DiagnosticsResponse>('/diagnostics');
/* Commands (§23.2) */
function newCommandId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `cmd-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export interface ParameterSetArgs {
readonly path: string;
readonly value: number;
readonly expectedRevision: number | null;
}
/** parameter.set mit Revision-Prüfung und Idempotenz (§23.2). */
export async function sendParameterSet({
path,
value,
expectedRevision,
}: ParameterSetArgs): Promise<CommandAck> {
const envelope: CommandEnvelope = {
command_id: newCommandId(),
type: 'parameter.set',
expected_revision: expectedRevision,
actor: { type: 'web', id: 'operator-session' },
payload: { parameter_path: path, value },
};
return request<CommandAck>('/commands', { method: 'POST', body: JSON.stringify(envelope) });
}
/** Override-Freigabe (§11.3); Release referenziert das ursprüngliche command_id. */
export async function releaseCommand(commandId: string): Promise<ReleaseResponse> {
return request<ReleaseResponse>(`/commands/${encodeURIComponent(commandId)}/release`, {
method: 'POST',
body: JSON.stringify({ source: 'web' }),
});
}
@@ -0,0 +1,117 @@
/**
* WebSocket zum Control Core (§17.9, §3.2).
*
* - Snapshot beim Connect, danach Deltas (parameter.update) plus Heartbeat.
* - Reconnect mit Exponential-Backoff: 1 s, 2 s, 4 s … maximum 30 s.
* - Browser-Neustart/-Reload stoppt den Output nicht (§3.2): der Client
* sendet keinerlei Commands beim Connect und synchronisiert nur Zustand.
* - URL: Same-Origin '/ws' (Dev über den Vite-Proxy → ws://localhost:8000/ws);
* alternativ direkt über VITE_WS_URL konfigurierbar.
*/
import { useEffect, useState } from 'react';
import type { WsServerEvent } from '../types';
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30_000;
function resolveWsUrl(): string {
const override: unknown = import.meta.env.VITE_WS_URL;
if (typeof override === 'string' && override.length > 0) return override;
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${protocol}://${window.location.host}/ws`;
}
function isWsServerEvent(data: unknown): data is WsServerEvent {
if (typeof data !== 'object' || data === null) return false;
const type = (data as { type?: unknown }).type;
return type === 'snapshot' || type === 'parameter.update' || type === 'heartbeat';
}
export interface WsState {
readonly connected: boolean;
/** Engine-Revision aus Snapshot/Updates; null vor der ersten Serverantwort. */
readonly revision: number | null;
readonly values: Readonly<Record<string, number>>;
readonly lastEventMs: number | null;
}
export function useWebSocket(): WsState {
const [connected, setConnected] = useState(false);
const [revision, setRevision] = useState<number | null>(null);
const [values, setValues] = useState<Record<string, number>>({});
const [lastEventMs, setLastEventMs] = useState<number | null>(null);
useEffect(() => {
let disposed = false;
let socket: WebSocket | null = null;
let timer: number | null = null;
let attempt = 0;
const clearTimer = (): void => {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
};
const scheduleReconnect = (): void => {
const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
attempt += 1;
timer = window.setTimeout(connect, delay);
};
const connect = (): void => {
if (disposed) return;
socket = new WebSocket(resolveWsUrl());
socket.onopen = () => {
if (disposed) return;
attempt = 0;
setConnected(true);
};
socket.onmessage = (ev: MessageEvent<string>) => {
if (disposed) return;
setLastEventMs(Date.now());
const parsed: unknown = (() => {
try {
return JSON.parse(ev.data) as unknown;
} catch {
return null;
}
})();
if (!isWsServerEvent(parsed)) return;
switch (parsed.type) {
case 'snapshot':
setValues(parsed.values);
setRevision(parsed.revision);
break;
case 'parameter.update':
setValues(prev => ({ ...prev, [parsed.parameter_path]: parsed.value }));
setRevision(parsed.revision);
break;
case 'heartbeat':
break;
}
};
socket.onclose = () => {
if (disposed) return;
setConnected(false);
scheduleReconnect();
};
socket.onerror = () => {
socket?.close();
};
};
connect();
return () => {
disposed = true;
clearTimer();
socket?.close(1000, 'client-unmount');
socket = null;
};
}, []);
return { connected, revision, values, lastEventMs };
}
+278
View File
@@ -0,0 +1,278 @@
/**
* Inline-SVG-Iconset (16-px-Basis, currentColor, keine externen Assets).
* Eigene schlichte Glyphen statt QLab-Übernahmen (§17.1). Jeder Layer-Zustand
* erhält eine eigene Zeichenform, damit Status nie allein über Farbe läuft (§17.6).
*/
import type { ComponentType, ReactNode, SVGProps } from 'react';
import type { LayerStatus } from './types';
export interface IconProps extends SVGProps<SVGSVGElement> {
readonly size?: number;
}
function Svg({ size = 16, children, ...rest }: IconProps & { readonly children: ReactNode }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
{...rest}
>
{children}
</svg>
);
}
/* ---------- Werkzeugleiste (§17.2) ---------- */
export const IconMixer = (p: IconProps) => (
<Svg {...p}>
<path d="M6 4v16M12 4v16M18 4v16" />
<circle cx="6" cy="9" r="2" fill="currentColor" stroke="none" />
<circle cx="12" cy="15" r="2" fill="currentColor" stroke="none" />
<circle cx="18" cy="7" r="2" fill="currentColor" stroke="none" />
</Svg>
);
export const IconMedia = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="5.5" width="17" height="13" rx="1.5" />
<path d="M10.5 9.5v5l4.5-2.5z" fill="currentColor" stroke="none" />
</Svg>
);
export const IconEffects = (p: IconProps) => (
<Svg {...p}>
<path d="M12 4l1.8 6.2L20 12l-6.2 1.8L12 20l-1.8-6.2L4 12l6.2-1.8z" />
</Svg>
);
export const IconPresets = (p: IconProps) => (
<Svg {...p}>
<rect x="4" y="4" width="7" height="7" rx="1" />
<rect x="13" y="4" width="7" height="7" rx="1" />
<rect x="4" y="13" width="7" height="7" rx="1" />
<rect x="13" y="13" width="7" height="7" rx="1" />
</Svg>
);
export const IconOutputs = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="5" width="17" height="12" rx="1.5" />
<path d="M9 21h6M12 17v4" />
</Svg>
);
export const IconNetwork = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="5.5" r="2.2" />
<circle cx="5" cy="18.5" r="2.2" />
<circle cx="19" cy="18.5" r="2.2" />
<path d="M11 7.4L6.2 16.6M13 7.4l4.8 9.2M7.2 18.5h9.6" />
</Svg>
);
export const IconPlugins = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="3.5" width="10" height="10" rx="1.5" />
<rect x="10.5" y="10.5" width="10" height="10" rx="1.5" />
</Svg>
);
export const IconDiagnostics = (p: IconProps) => (
<Svg {...p}>
<path d="M3 12h4l2.5-6 4.5 12 2.5-6h4.5" />
</Svg>
);
/* ---------- Layer-Zustandsglyphen (§17.3, §17.10) ---------- */
export const IconStActive = (p: IconProps) => (
<Svg {...p}>
<path d="M9 6.8v10.4l8.5-5.2z" fill="currentColor" stroke="none" />
</Svg>
);
export const IconStPaused = (p: IconProps) => (
<Svg {...p}>
<path d="M8.5 6.5h2.6v11H8.5zM12.9 6.5h2.6v11h-2.6z" fill="currentColor" stroke="none" />
</Svg>
);
export const IconStPreloaded = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="7" />
<circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none" />
</Svg>
);
export const IconStWarning = (p: IconProps) => (
<Svg {...p}>
<path d="M12 4.5L20.6 19.2H3.4z" />
<path d="M12 10.2v3.6M12 16.6v.01" />
</Svg>
);
export const IconStError = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="7.2" />
<path d="M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6" />
</Svg>
);
export const IconStOffline = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="7.2" />
<path d="M6.9 6.9l10.2 10.2" />
</Svg>
);
export const IconStOutOfSync = (p: IconProps) => (
<Svg {...p}>
<path d="M3.5 12h3.2l2.2-4.5 4.2 9 2.2-4.5h5.2" />
</Svg>
);
const STATUS_ICON: Record<LayerStatus, ComponentType<IconProps>> = {
active: IconStActive,
paused: IconStPaused,
preloaded: IconStPreloaded,
warning: IconStWarning,
error: IconStError,
offline: IconStOffline,
out_of_sync: IconStOutOfSync,
};
/** Zustandssymbol: Form kodiert den Zustand, Farbe verstärkt (§17.6). */
export function StatusGlyph({ status, size = 13 }: { readonly status: LayerStatus; readonly size?: number }) {
const Icon = STATUS_ICON[status];
return <Icon size={size} />;
}
/* ---------- Bedienelemente ---------- */
export const IconChevronDown = (p: IconProps) => (
<Svg {...p}>
<path d="M6 9.5l6 6 6-6" />
</Svg>
);
export const IconChevronUp = (p: IconProps) => (
<Svg {...p}>
<path d="M6 14.5l6-6 6 6" />
</Svg>
);
export const IconChevronLeft = (p: IconProps) => (
<Svg {...p}>
<path d="M14.5 6l-6 6 6 6" />
</Svg>
);
export const IconChevronRight = (p: IconProps) => (
<Svg {...p}>
<path d="M9.5 6l6 6-6 6" />
</Svg>
);
export const IconClock = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="8" />
<path d="M12 7.5V12l3 2" />
</Svg>
);
export const IconSignal = (p: IconProps) => (
<Svg {...p}>
<path d="M4.5 13.5a10.5 10.5 0 0 1 15 0M8 16.5a5.7 5.7 0 0 1 8 0" />
<circle cx="12" cy="19.5" r="1" fill="currentColor" stroke="none" />
</Svg>
);
export const IconMonitor = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="5" width="17" height="12" rx="1.5" />
<path d="M9 20h6" />
</Svg>
);
export const IconLock = (p: IconProps) => (
<Svg {...p}>
<rect x="5.5" y="11" width="13" height="8.5" rx="1.5" />
<path d="M8.5 11V8a3.5 3.5 0 0 1 7 0v3" />
</Svg>
);
export const IconUnlock = (p: IconProps) => (
<Svg {...p}>
<rect x="5.5" y="11" width="13" height="8.5" rx="1.5" />
<path d="M8.5 11V8a3.5 3.5 0 0 1 6.8-1.2" />
</Svg>
);
export const IconAlertTriangle = (p: IconProps) => (
<Svg {...p}>
<path d="M12 4.5L20.6 19.2H3.4z" />
<path d="M12 10.2v3.6M12 16.6v.01" />
</Svg>
);
export const IconCrossCircle = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="7.2" />
<path d="M9.2 9.2l5.6 5.6M14.8 9.2l-5.6 5.6" />
</Svg>
);
export const IconInfoCircle = (p: IconProps) => (
<Svg {...p}>
<circle cx="12" cy="12" r="8" />
<path d="M12 11v5M12 7.8v.01" />
</Svg>
);
export const IconPlaySm = (p: IconProps) => (
<Svg {...p}>
<path d="M9.5 7.5v9l7-4.5z" fill="currentColor" stroke="none" />
</Svg>
);
export const IconPauseSm = (p: IconProps) => (
<Svg {...p}>
<path d="M9 7.5h2.2v9H9zM12.8 7.5H15v9h-2.2z" fill="currentColor" stroke="none" />
</Svg>
);
export const IconStopSm = (p: IconProps) => (
<Svg {...p}>
<rect x="8" y="8" width="8" height="8" rx="1" fill="currentColor" stroke="none" />
</Svg>
);
export const IconSidebarPanel = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="5" width="17" height="14" rx="1.5" />
<path d="M14.5 5v14" />
</Svg>
);
export const IconInspectorPanel = (p: IconProps) => (
<Svg {...p}>
<rect x="3.5" y="5" width="17" height="14" rx="1.5" />
<path d="M3.5 14.5h17" />
</Svg>
);
export const IconGrip = (p: IconProps) => (
<Svg {...p}>
<path d="M9 8.5L5.5 12 9 15.5M15 8.5L18.5 12 15 15.5" />
</Svg>
);
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles/tokens.css';
import './styles/app.css';
const container = document.getElementById('root');
if (container === null) {
throw new Error('#root nicht gefunden index.html prüfen');
}
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);
+274
View File
@@ -0,0 +1,274 @@
/**
* MVP-Datenbestand der Layer-Tabelle (PLATZHALTER).
*
* Bis die Layer-API des Control Core existiert, sind Namen, Zustände und
* Parameter Demo-Werte. Echte Opacity-Werte laufen bereits über die
* Parameter-Engine: composition/{uuid}/layer/{uuid}/opacity (§10.2, §11).
* Alles andere bleibt clientseitig und wird später serverseitig bestätigt
* (§17.9). Die Layer-IDs sind stabile UUIDs, damit Parameterpfade gültig
* bleiben (paths.validate_parameter_path).
*/
import type { Layer } from './types';
/** Demo-Composition-ID (UUID, §10.2). */
export const SEED_COMPOSITION_ID = 'd2a4c6e8-1b3f-4a5c-9d7e-6f8a0b2c4d6e';
const IDENTITY_TRANSFORM = { x: 0, y: 0, scale: 100, rotation: 0 } as const;
const IDENTITY_COLOR = { brightness: 100, contrast: 100, saturation: 100, hue: 0 } as const;
const DEFAULT_DMX = { universe: 0, address: 1, mode: '16 Bit' } as const;
export const SEED_LAYERS: readonly Layer[] = [
{
id: '9b1f4c2a-3d5e-4f6a-8b7c-1a2b3c4d5e6f',
z: 12,
name: 'Logo Bug',
type: 'media',
status: 'active',
sourceName: 'logo_main.png',
sourceBank: 'B1·04',
target: 'Local',
timePositionSec: 42.3,
timeDurationSec: 3600,
opacity: 100,
blend: 'Normal',
fxSlots: [{ name: 'Key Spill Suppress', state: 'active' }, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Loop',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '8c2e5d3b-4e6f-4a7b-9c8d-2b3c4d5e6f7a',
z: 11,
name: 'Vignette Key',
type: 'adjustment',
status: 'active',
sourceName: 'vignette_soft',
sourceBank: 'ADJ',
target: 'Local',
timePositionSec: 0,
timeDurationSec: 0,
opacity: 40,
blend: 'Multiply',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Hold',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '7d3f6e4c-5f7a-4b8c-ad9e-3c4d5e6f7a8b',
z: 10,
name: 'Strobe Accent',
type: 'generator',
status: 'active',
sourceName: 'Strobe Pulse 8 Hz',
sourceBank: 'GEN',
target: 'Local',
timePositionSec: 3.2,
timeDurationSec: 0,
opacity: 70,
blend: 'Add',
fxSlots: [{ name: 'Strobe', state: 'active' }, null],
control: { sources: ['artnet'], owner: 'Lichtpult 1' },
loopMode: 'Kontinuierlich',
generatorPhase: 0.125,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: { universe: 2, address: 49, mode: '16 Bit' },
},
{
id: '6e4a7f5d-6a8b-4c9d-beaf-4d5e6f7a8b9c',
z: 9,
name: 'Color Wash',
type: 'generator',
status: 'active',
sourceName: 'Warm Gradient',
sourceBank: 'GEN',
target: 'Gruppe Ost',
timePositionSec: 12.4,
timeDurationSec: 0,
opacity: 85,
blend: 'Screen',
fxSlots: [{ name: 'Blur', state: 'bypassed' }, { name: 'Color Trim', state: 'active' }],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Kontinuierlich',
generatorPhase: 12.4,
transform: IDENTITY_TRANSFORM,
color: { brightness: 100, contrast: 100, saturation: 120, hue: 12 },
dmx: { universe: 2, address: 57, mode: '16 Bit' },
},
{
id: '5f5b8a6e-7b9c-4dae-afb0-5e6f7a8b9cad',
z: 8,
name: 'Band Cam PIP',
type: 'media',
status: 'preloaded',
sourceName: 'cam_front_feed',
sourceBank: 'B2·01',
target: 'Local',
timePositionSec: 0,
timeDurationSec: 0,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Loop',
generatorPhase: 0,
transform: { x: 420, y: 210, scale: 32, rotation: 0 },
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '4a6c9b7f-8cad-4ebf-b0a1-6f7a8b9cadbe',
z: 7,
name: 'Titles Gruppe',
type: 'group',
status: 'active',
sourceName: 'Logo + Lower Third',
sourceBank: 'GRP',
target: 'Local',
timePositionSec: 0,
timeDurationSec: 0,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Hold',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '3b7dad8a-9dbe-4fcf-a1b2-7a8b9cadbecf',
z: 6,
name: 'Lower Third Showname',
type: 'media',
status: 'paused',
sourceName: 'lowerthird_v3.png',
sourceBank: 'B1·09',
target: 'Local',
timePositionSec: 5.2,
timeDurationSec: 30,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['timeline'], owner: 'Timeline Intro' },
loopMode: 'Once',
generatorPhase: 0,
transform: { x: 0, y: 320, scale: 100, rotation: 0 },
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '2c8ebc9b-aecf-4da0-b2c3-8b9cadbecfd0',
z: 5,
name: 'Sync Test Pattern',
type: 'generator',
status: 'out_of_sync',
sourceName: 'Grid 1080',
sourceBank: 'GEN',
target: 'Gruppe Ost',
timePositionSec: 3.1,
timeDurationSec: 60,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['automation'], owner: 'Auto-Sync' },
loopMode: 'Loop',
generatorPhase: 3.1,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '1d9fcdac-bfd0-4eb1-c3d4-9cadbecfd0e1',
z: 4,
name: 'Long Loop 4K',
type: 'media',
status: 'warning',
sourceName: 'ambient_4k_loop.mov',
sourceBank: 'B3·02',
target: 'Local',
timePositionSec: 124.8,
timeDurationSec: 600,
opacity: 85,
blend: 'Normal',
fxSlots: [{ name: 'Decode LUT', state: 'active' }, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Loop',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: '0eafdecf-cfe1-4fc2-d4e5-adbecfd0e1f2',
z: 3,
name: 'Backup Node Feed',
type: 'media',
status: 'offline',
sourceName: 'backup_feed.mov',
sourceBank: 'B2·07',
target: 'Node backstage-02',
timePositionSec: 0,
timeDurationSec: 0,
opacity: 100,
blend: 'Normal',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Loop',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: 'af1e2d3c-4d5e-4f6a-8b9c-3c4d5e6f7a8b',
z: 2,
name: 'Sponsors Clip defekt',
type: 'media',
status: 'error',
sourceName: 'sponsors_v2.mp4',
sourceBank: 'B3·11',
target: 'Local',
timePositionSec: 0,
timeDurationSec: 8.4,
opacity: 0,
blend: 'Normal',
fxSlots: [{ name: 'Color Grade', state: 'active' }, { name: 'Strobe Mask', state: 'error' }],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Once',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
{
id: 'be2f3e4d-5e6f-4a7b-9cad-4d5e6f7a8b9c',
z: 1,
name: 'Intro Loop',
type: 'media',
status: 'preloaded',
sourceName: 'intro_opening.mp4',
sourceBank: 'B1·01',
target: 'Local',
timePositionSec: 0,
timeDurationSec: 45,
opacity: 100,
blend: 'Dissolve',
fxSlots: [null, null],
control: { sources: ['web'], owner: 'Web-Operator' },
loopMode: 'Once',
generatorPhase: 0,
transform: IDENTITY_TRANSFORM,
color: IDENTITY_COLOR,
dmx: DEFAULT_DMX,
},
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
/*
* HMS MediaEngine Design-Tokens (PLAN.md §17.6).
*
* Zentrale, später thematisierbare Ausgangswerte.
* Zustandsfarben ausschließlich für betriebliche Bedeutung;
* Status wird nie allein über Farbe vermittelt (Icon/Text ergänzen).
*/
:root {
/* Flächen (§17.6) */
--surface-app: #0d0f12; /* fast schwarzes Anthrazit (Hintergrund) */
--surface-panel: #16181d; /* Panel-Grau */
--surface-panel-raised: #1e2128; /* Panel-Grau hell */
--surface-hover: #262a33; /* Hover-Fläche */
/* Text (§17.6) */
--text-primary: #c8ccd4; /* helles Neutralgrau */
--text-secondary: #8a8f9a; /* Sekundärgrau */
--text-emphasis: #e8eaee; /* wichtige Werte, hoher Kontrast */
/* Linien */
--border: #23262d;
--border-strong: #2e323b;
/* Akzent: kühles Blau für Selektion/Fokus (§17.6) */
--accent: #4a90d9;
--accent-dim: rgba(74, 144, 217, 0.16);
--accent-line: rgba(74, 144, 217, 0.45);
/* Zustandsfarben (§17.6) keine Deko-Farben */
--state-green: #3fa34d;
--state-yellow: #d4a017;
--state-orange: #d4641e;
--state-red: #c92a2a;
--state-blue: #4a90d9;
/* Spacing: 4-px-Raster (§17.6) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
/* Radien: 24 px, keine Pillen (§17.6) */
--radius-sm: 2px;
--radius-md: 4px;
/* Typografie (§17.6) */
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'SF Mono', ui-monospace, Menlo, monospace;
--fs-xs: 11px;
--fs-sm: 12px;
--fs-md: 13px;
--fs-lg: 14px;
/* Animationen: 100180 ms, keine dekorativen Animationen (§17.6) */
--dur-fast: 100ms;
--dur-med: 180ms;
--ease: ease-out;
/* Layout-Konstanten des Workspace (§17.2) */
--bar-height: 32px; /* obere Statusleiste */
--footer-height: 24px; /* Fußleiste */
--toolbar-width: 48px; /* linke Werkzeugleiste */
--toolbar-rail: 16px; /* eingeklappte Werkzeugleiste */
--sidebar-width: 264px; /* rechte Seitenleiste */
--row-height: 30px; /* Layer-Zeilen, Desktop-Density 2832 px */
--inspector-default: 264px; /* Inspector-Grundhöhe (~30 % bei 900 px) */
/*
* Spaltenraster der Layer-Tabelle (§17.3):
* Status | Layer | Typ | Source | Target | Time | Opacity | Blend | FX | Control
*/
--lt-cols: 24px minmax(150px, 1.6fr) 84px minmax(130px, 1.4fr) minmax(108px, 1fr)
104px 132px 88px 92px 96px;
}
+8
View File
@@ -0,0 +1,8 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// Ensure the DOM is cleaned up between tests (globals are disabled).
afterEach(() => {
cleanup();
});
+216
View File
@@ -0,0 +1,216 @@
/**
* Domain- und API-Typen der Weboberfläche.
*
* Spiegel der Verträge aus apps/control_server/hms_control_server/app.py
* sowie PLAN.md §10 (Domäne), §17 (Browseroberfläche) und §23 (API).
* Der Server bleibt autoritativ; der Client synchronisiert nur Zustand (§3.2, §17.9).
*/
/* ---------- Layer-Domäne (§17.3) ---------- */
/** Zustände, die §17.10 visuell eindeutig unterscheidbar verlangt. */
export type LayerStatus =
| 'active'
| 'paused'
| 'preloaded'
| 'warning'
| 'error'
| 'offline'
| 'out_of_sync';
export type LayerType = 'media' | 'generator' | 'adjustment' | 'group';
export type FxSlotState = 'active' | 'bypassed' | 'error';
export interface FxSlot {
readonly name: string;
readonly state: FxSlotState;
}
export type ControlSourceTag = 'web' | 'artnet' | 'timeline' | 'automation';
export interface ControlInfo {
readonly sources: readonly ControlSourceTag[];
readonly owner: string;
}
export interface TransformParams {
readonly x: number; // px
readonly y: number; // px
readonly scale: number; // %
readonly rotation: number; // Grad
}
export interface ColorParams {
readonly brightness: number; // %
readonly contrast: number; // %
readonly saturation: number; // %
readonly hue: number; // Grad
}
export interface DmxInfo {
readonly universe: number; // 0..63
readonly address: number; // 1..512, 16-Bit-Kanäle folgen §16
readonly mode: string;
}
export interface Layer {
/** UUID Bestandteil des Parameterpfads (§10.2), nie der sichtbare Name. */
readonly id: string;
readonly z: number;
readonly name: string;
readonly type: LayerType;
readonly status: LayerStatus;
readonly sourceName: string;
/** Bank/Index-Kurzform wie „B1·04“, „GEN“, „GRP“ oder „ADJ“. */
readonly sourceBank: string;
readonly target: string;
readonly timePositionSec: number;
readonly timeDurationSec: number; // 0 = unbegrenzt (∞)
readonly opacity: number; // 0..100 (UI); die Engine speichert 0..1
readonly blend: string;
readonly fxSlots: readonly [FxSlot | null, FxSlot | null];
readonly control: ControlInfo;
readonly loopMode: string;
readonly generatorPhase: number; // s, nur Generatoren
readonly transform: TransformParams;
readonly color: ColorParams;
readonly dmx: DmxInfo;
}
/** Feingranulare Änderungen an einem Layer (Tabelle/Inspector). */
export interface LayerPatch {
readonly name?: string;
readonly opacity?: number;
readonly blend?: string;
readonly loopMode?: string;
readonly timeDurationSec?: number;
readonly generatorPhase?: number;
readonly transform?: TransformParams;
readonly color?: ColorParams;
readonly dmx?: DmxInfo;
readonly fxSlots?: readonly [FxSlot | null, FxSlot | null];
}
/* ---------- REST-Vertrag (§23.2, app.py) ---------- */
export interface HealthResponse {
readonly status: string;
readonly phase: number;
readonly node_id: string;
readonly revision: number;
}
export interface IdentityResponse {
readonly node_id: string;
readonly display_name: string;
readonly roles: readonly string[];
readonly renders_locally: boolean;
readonly is_coordinator: boolean;
}
export type NodeHealth = 'online' | 'degraded' | 'stale' | 'offline';
export interface ClusterNode {
readonly node_id: string;
readonly display_name: string;
readonly roles: readonly string[];
readonly health: NodeHealth;
readonly category: string;
}
export interface ClusterNodesResponse {
readonly self: string;
readonly nodes: readonly ClusterNode[];
}
export interface ParametersResponse {
readonly revision: number;
readonly values: Readonly<Record<string, number>>;
}
export interface DiagnosticsResponse {
readonly renderer: string;
readonly artnet: string;
readonly revision: number;
readonly node_id: string;
}
export interface CommandActor {
readonly type: string;
readonly id: string;
}
export interface ParameterSetPayload {
readonly parameter_path: string;
readonly value: number;
}
/** Command-Envelope §23.2: command_id, type, expected_revision, actor, payload. */
export interface CommandEnvelope {
readonly command_id: string;
readonly type: string;
readonly expected_revision: number | null;
readonly actor: CommandActor;
readonly payload: ParameterSetPayload;
}
export interface CommandAck {
readonly status: string;
readonly command_id: string;
readonly revision: number;
readonly effective: number;
readonly duplicate?: boolean;
}
export interface ReleaseResponse {
readonly status: string;
}
/* ---------- WebSocket-Vertrag (§17.9, app.py /ws) ---------- */
export interface WsSnapshot {
readonly type: 'snapshot';
readonly revision: number;
readonly values: Readonly<Record<string, number>>;
}
export interface WsParameterUpdate {
readonly type: 'parameter.update';
readonly parameter_path: string;
readonly value: number;
readonly revision: number;
}
export interface WsHeartbeat {
readonly type: 'heartbeat';
}
export type WsServerEvent = WsSnapshot | WsParameterUpdate | WsHeartbeat;
/* ---------- UI-Zustände (§17.2, §17.4, §17.5) ---------- */
export type AlertSeverity = 'info' | 'warning' | 'error';
export interface AlertItem {
readonly id: number;
readonly timeMs: number;
readonly severity: AlertSeverity;
readonly message: string;
}
export type SidebarTab = 'active' | 'nodes' | 'alerts';
export type InspectorTab = 'layer' | 'source' | 'transform' | 'color' | 'fx' | 'dmx' | 'advanced';
export type ToolId =
| 'mixer'
| 'media'
| 'effects'
| 'presets'
| 'outputs'
| 'network'
| 'plugins'
| 'diagnostics';
export type OpMode = 'setup' | 'live';
+108
View File
@@ -0,0 +1,108 @@
/**
* Formatierungs- und Mapping-Helfer der Weboberfläche.
* Zeitkritische Werte werden tabellarisch monospaced dargestellt (§17.6).
*/
import type { LayerStatus, LayerType } from './types';
/** Klassennamen kompakt zusammenführen; leere/false-Teile entfallen. */
export function cx(...parts: ReadonlyArray<string | false | null | undefined>): string {
return parts.filter((p): p is string => typeof p === 'string' && p.length > 0).join(' ');
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/** Sekunden → „M:SS.t“ (tabellarisch, Zehntelsekunden). */
export function formatTimecode(totalSec: number): string {
const safe = Number.isFinite(totalSec) && totalSec >= 0 ? totalSec : 0;
const minutes = Math.floor(safe / 60);
const seconds = Math.floor(safe % 60);
const tenth = Math.floor((safe % 1) * 10);
return `${minutes}:${seconds.toString().padStart(2, '0')}.${tenth}`;
}
/** Uhrzeit HH:MM:SS (lokal). */
export function formatClock(date: Date): string {
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
const s = date.getSeconds().toString().padStart(2, '0');
return `${h}:${m}:${s}`;
}
export function formatTimeMs(ms: number): string {
return formatClock(new Date(ms));
}
/** FPS mit zwei Dezimalstellen (§17.2); null = keine Messung. */
export function formatFps(v: number | null): string {
return v === null ? '—' : v.toFixed(2);
}
/** Deutsche Zahleneingabe: „12,5“ → 12.5; null wenn nicht parsebar. */
export function parseNumberInput(raw: string): number | null {
const trimmed = raw.trim().replace(',', '.');
if (trimmed === '') return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
export function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return 'unbekannter Fehler';
}
/** Opacity: Engine speichert 0..1, die UI zeigt 0..100. */
export function engineToOpacity(v: number): number {
return clamp(Math.round(v * 100), 0, 100);
}
export function opacityToEngine(v: number): number {
return clamp(v, 0, 100) / 100;
}
/** Text-Label je Zustand Status nie allein über Farbe (§17.6). */
export const STATUS_LABEL: Record<LayerStatus, string> = {
active: 'Aktiv',
paused: 'Pausiert',
preloaded: 'Vorgeladen',
warning: 'Warnung',
error: 'Fehler',
offline: 'Offline',
out_of_sync: 'Nicht synchron',
};
/** Farbtöne je Zustand (ergänzt durch Glyphe/Text, nie allein). */
export const STATUS_TONE: Record<LayerStatus, string> = {
active: 'st-active',
paused: 'st-paused',
preloaded: 'st-preloaded',
warning: 'st-warning',
error: 'st-error',
offline: 'st-offline',
out_of_sync: 'st-out-of-sync',
};
export const TYPE_LABEL: Record<LayerType, string> = {
media: 'Media',
generator: 'Generator',
adjustment: 'Adjustment',
group: 'Group',
};
export const BLEND_MODES: readonly string[] = [
'Normal',
'Add',
'Screen',
'Multiply',
'Dissolve',
'Lighten',
];
export const LOOP_MODES: readonly string[] = ['Loop', 'Once', 'Ping-Pong', 'Hold'];
/** Parameterpfad §10.2 (packages/parameter_engine/hms_parameter/paths.py). */
export function layerOpacityPath(compositionId: string, layerId: string): string {
return `composition/${compositionId}/layer/${layerId}/opacity`;
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
/**
* Typisierte Vite-Umgebungsvariablen (apps/web).
*
* VITE_WS_URL: optionale direkte WebSocket-URL zum Control Core, z. B.
* „ws://localhost:8000/ws“, wenn die Oberfläche nicht über den
* Same-Origin-Proxy (Vite-Dev-Server/Reverse-Proxy) läuft.
*/
interface ImportMetaEnv {
readonly VITE_WS_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
File diff suppressed because one or more lines are too long
+32
View File
@@ -0,0 +1,32 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
/**
* Vite-Konfiguration der HMS-MediaEngine-Weboberfläche (§17).
*
* Dev-Server-Proxy auf den Control Core (apps/control_server, Port 8000):
* - '/api' als HTTP-Proxy
* - '/ws' als WebSocket-Proxy (Snapshot + Deltas, §17.9)
* Same-Origin im Dev-Betrieb; produktiv übernimmt der Launcher/Reverse-Proxy.
*/
const proxy = {
'/api': {
target: 'http://localhost:8000',
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
} as const;
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy,
},
preview: {
port: 4173,
proxy,
},
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: false,
setupFiles: ['./src/test/setup.ts'],
},
});
View File
View File
+46
View File
@@ -0,0 +1,46 @@
# GStreamer-Bündelung Windows (Pin)
ADR-0002. Ziel: portables `runtime/gstreamer` ohne Systeminstallation (PLAN.md §3.1, §30.2).
## Pin
- **Version:** 1.28.6 (aktuelle stabile 1.28-Serie, Stand 2026-09-10, Quelle: <https://gstreamer.freedesktop.org/download/>)
- **Distribution:** offizielle MSVC-Binaries, x86_64 (`gstreamer-1.0-msvc-x86_64-1.28.6.msi`)
- **SHA-256:** nach dem ersten Bündelungs-Build manifestieren (§30.1)
## Minimal gebündelte Plugin-Untermenge (Phase 0)
Ziel: H.264-MP4-Testclip → D3D11-Decode → Compositing → Vollbildausgabe.
| Komponente | Paket | Enthält | Zweck |
| --- | --- | --- | --- |
| libgstreamer-1.0-0.dll | gstreamer core | | Kern |
| coreelements | gst-plugins-core | `filesrc`, `queue`, `decodebin3`-Basen | Container/Datei |
| typefindfunctions | gst-plugins-core | Typenerkennung | MP4-Erkennung |
| isomp4 | gst-plugins-good | `qtdemux` | MP4-Demux |
| videoparsersbad | gst-plugins-bad | `h264parse` | H.264-Parsing vor Decoder |
| playback | gst-plugins-base | `uridecodebin` | bequeme Quelle (Spike) |
| d3d11 | gst-plugins-bad | `d3d11h264dec`, `d3d11h265dec`, `d3d11convert`, `d3d11compositor`, `d3d11videosink` | GPU-Pfad (§12.6) |
| video/x-raw Basen | gst-plugins-base | `videoconvert` (nur Fallback, nicht Normalpfad) | nur Diagnose |
Nicht gebündelt (V1-Spike): Netzwerk-Quellen, NDI, Capture, Software-Codecs außer für expliziten Fallback.
## Umgebungsvariablen beim Start (Launcher, §9.2)
```text
GST_PLUGIN_PATH_1_0=<app>/runtime/gstreamer/lib/gstreamer-1.0
PATH=<app>/runtime/gstreamer/bin;%PATH%
GST_PLUGIN_SYSTEM_PATH_1_0= (leer setzen, um System-Plugins zu unterdrücken)
```
## Validierung
1. Saubere Windows-VM ohne GStreamer (§29.6)
2. `gst-inspect-1.0 d3d11h264dec` muss die Elemente aus dem Bündel listen
3. Testpipeline läuft; GPU-Treiber ist die einzige Systemabhängigkeit
4. MSI-Binärdateien und DLL-Set als SHA-256-Manifest festhalten
## Offene Punkte
- [ ] Bündelungsskript (MSI still entpacken, Untermenge kopieren) Phase-0-Aufgabe mit Packaging-ADR-0005
- [ ] VC-Runtime-Redistributable-Handling → `runtime/vc-runtime` (§9)
@@ -0,0 +1,128 @@
#!/bin/bash
# HMS MediaEngine Windows Portable Build (§30.2, ADR-0005: Nuitka Onefolder)
# Läuft auf Windows mit Python 3.13 und Nuitka installiert.
set -euo pipefail
VERSION="0.1.0"
OUT_DIR="build/windows/HMS-MediaEngine-Portable-$VERSION"
echo "=== HMS MediaEngine Windows Build v$VERSION ==="
# 1. Python-Abhängigkeiten installieren
echo "[1/5] Installiere Python-Abhängigkeiten..."
uv sync
# 2. Frontend bauen (statische Assets)
echo "[2/5] Baue Web-Frontend..."
cd apps/web
corepack enable pnpm 2>/dev/null || true
pnpm install --frozen-lockfile
pnpm build
cd ../..
# 3. Rust-Renderkern bauen (nur wenn cargo verfügbar; sonst Überspringen)
echo "[3/5] Baue Rust-Renderkern..."
if command -v cargo >/dev/null 2>&1; then
cd native/render_bridge
cargo build --release
cd ../..
RENDERER_DLL="native/render_bridge/target/release/hms_render_bridge.dll"
if [ -f "$RENDERER_DLL" ]; then
echo " Render-Bridge DLL gefunden: $RENDERER_DLL"
fi
else
echo " WARNUNG: cargo nicht gefunden; Rust-Renderkern wird nicht gebaut"
echo " (Wird im Windows-Durchlauf nachgeholt, ADR-0004)"
fi
# 4. Nuitka Onefolder-Build
echo "[4/5] Baue portable Onefolder-Ausgabe (Nuitka)..."
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
python -m nuitka \
--standalone \
--onefile=False \
--output-dir="$OUT_DIR" \
--include-package=hms_protocol \
--include-package=hms_domain \
--include-package=hms_parameter \
--include-package=hms_artnet \
--include-package=hms_cluster \
--include-package=hms_persistence \
--include-package=hms_plugin_sdk \
--include-package=hms_media \
--include-package=hms_audio \
--include-package=hms_content_sync \
--include-package=hms_capabilities \
--include-package=hms_adaptive \
--include-package=hms_renderer \
--include-package=hms_control_server \
--include-package=hms_launcher \
--include-data-dir=apps/web/dist=web \
--windows-console-mode=disable \
--company-name="HMS" \
--product-name="HMS MediaEngine" \
--file-version="$VERSION" \
--product-version="$VERSION" \
apps/launcher/hms_launcher/__main__.py
# 5. GStreamer-Runtime und portable Struktur
echo "[5/5] Kopiere GStreamer-Runtime und portable Struktur..."
# PORTABLE_MODE Markierung (§9)
touch "$OUT_DIR/PORTABLE_MODE"
# GStreamer (MSVC 1.28.6, ADR-0002)
# Erwartet: GStreamer-MSVC-Installationspfad oder GSTREAMER_1_0_ROOT_MSVC_X86_64
if [ -n "${GSTREAMER_1_0_ROOT_MSVC_X86_64:-}" ]; then
mkdir -p "$OUT_DIR/runtime/gstreamer"
cp -r "$GSTREAMER_1_0_ROOT_MSVC_X86_64/bin" "$OUT_DIR/runtime/gstreamer/bin"
cp -r "$GSTREAMER_1_0_ROOT_MSVC_X86_64/lib/gstreamer-1.0" "$OUT_DIR/runtime/gstreamer/lib/"
echo " GStreamer 1.28.6 gebündelt (ADR-0002)"
else
echo " WARNUNG: GSTREAMER_1_0_ROOT_MSVC_X86_64 nicht gesetzt"
echo " GStreamer wird nicht gebündelt; im Windows-Durchlauf ergänzen"
fi
# Portable Verzeichnisstruktur (§9)
mkdir -p "$OUT_DIR/plugins/builtin" "$OUT_DIR/plugins/user"
mkdir -p "$OUT_DIR/projects" "$OUT_DIR/media" "$OUT_DIR/logs"
mkdir -p "$OUT_DIR/userdata/database" "$OUT_DIR/userdata/cache" \
"$OUT_DIR/userdata/identity" "$OUT_DIR/userdata/sync-staging" \
"$OUT_DIR/userdata/thumbnails" "$OUT_DIR/userdata/plugin-cache"
mkdir -p "$OUT_DIR/config" "$OUT_DIR/licenses"
# Plugin-Dateien kopieren
cp -r plugins/builtin/generators "$OUT_DIR/plugins/builtin/"
cp -r plugins/builtin/filters "$OUT_DIR/plugins/builtin/"
# Standardkonfiguration
cat > "$OUT_DIR/config/app.toml" << 'EOF'
[hms]
bind_host = "127.0.0.1"
default_port = 8000
open_browser = true
EOF
# SHA-256-Prüfsumme
echo "Erzeuge SHA-256-Prüfsumme..."
cd "build/windows"
if command -v sha256sum >/dev/null 2>&1; then
find "HMS-MediaEngine-Portable-$VERSION" -type f -exec sha256sum {} + > "HMS-MediaEngine-Portable-$VERSION.sha256"
echo " Prüfsumme: HMS-MediaEngine-Portable-$VERSION.sha256"
fi
# ZIP erstellen
echo "Erstelle Release-ZIP..."
if command -v zip >/dev/null 2>&1; then
zip -r "HMS-MediaEngine-Portable-$VERSION.zip" "HMS-MediaEngine-Portable-$VERSION"
echo " Release: build/windows/HMS-MediaEngine-Portable-$VERSION.zip"
fi
echo "=== Build abgeschlossen ==="
echo "Ausgabe: $OUT_DIR"
echo ""
echo "Hinweis: Dieser Build ist ein Dev-Build ohne GPU-Validierung (ADR-0008)."
echo "Gate-0-Messungen (D3D11, Framezeiten, DMX-Latenz) laufen auf echter Hardware."
View File
@@ -0,0 +1,27 @@
# ADR-0001: Python-Version pinnen
- **Status:** Angenommen
- **Datum:** 2026-09-10
- **Phase:** 0
- **Bauplan:** §7 („Python, unterstützte Version exakt pinnen“)
## Entscheidung
Der Control Core und alle Python-Pakete pinnen **Python 3.13** (`requires-python = "==3.13.*"` in `pyproject.toml`).
## Kontext
Der Bauplan verlangt eine exakt gepinnte Python-Version. 3.13 ist die aktuelle stabile Version mit ausgereiftem `asyncio`, breitem Wheel-Support für FastAPI/Pydantic und PyGObject-Kompatibilität für die GStreamer-Bindings.
## Alternativen
- 3.12: kein messbarer Vorteil, kürzeres Supportfenster als 3.13.
- 3.14: bei Projektstart zu neu für stabile Binärwheels aller Abhängigkeiten.
## Folgen
- uv-Lockfile und CI pinnen 3.13; Versionssprünge erfolgen bewusst per ADR-Änderung.
## Freigabe
- Auftragsvorgabe „exakt pinnen“ aus PLAN.md §7; Umsetzung ohne Abweichung.
@@ -0,0 +1,28 @@
# ADR-0002: GStreamer-Pin für Windows
- **Status:** Angenommen (Bündelungsumfang folgt nach Phase-0-Build)
- **Datum:** 2026-09-10
- **Phase:** 0
- **Bauplan:** §7.1, §30.2
## Entscheidung
Für die portable Windows-Ausgabe wird **GStreamer 1.28.6 (MSVC, x86_64)** gebündelt (Runtime-Paket, exakt manifestierte Plugin-Untermenge). Details: `build/windows/GSTREAMER.md`.
## Kontext
Die 1.28-Serie ist die aktuelle stabile Release-Serie mit gepflegtem D3D11-Stack (`d3d11h264dec`, `d3d11convert`, `d3d11compositor`, `d3d11videosink`). Version ermittelt von https://gstreamer.freedesktop.org/download/ (Stand 2026-09-10).
## Alternativen
- Ältere LTS-Releases: keine Vorteile, ältere D3D11-Elemente.
- Eigener GStreamer-Build: höherer Wartungsaufwand, für Phase 0 nicht nötig.
## Folgen
- Phase 0 prüft die Bündelung in einer sauberen Windows-VM ohne installiertes GStreamer (§29.6); Pluginliste und SHA-256 werden nach dem ersten Onefolder-Build manifestiert.
- Muss mit der Packaging-Entscheidung (ADR-0005 offen) zusammenarbeiten.
## Freigabe
- Entspricht PLAN.md §7.1 (Version pinnen); Bündelungsumfang wird nach dem ersten Portabilitätstest ergänzt.
@@ -0,0 +1,23 @@
# ADR-0003: IPC lokales TCP mit length-prefixed MessagePack
- **Status:** Angenommen (Bauplan-Vorgabe §6.2; umgesetzt in `packages/protocol`)
- **Datum:** 2026-09-10
- **Phase:** 0
## Entscheidung
Control Core ↔ Renderer kommunizieren über lokales TCP auf `127.0.0.1` mit length-prefixed MessagePack (4-Byte-Big-Endian-Länge, Protokollversion 1, Idempotency-Keys; Heartbeat ab Phase 1). JSON ausschließlich im Debugmodus.
## Alternativen
- Named Pipes/Unix Sockets: plattformspezifische API-Unterschiede, kein Nutzen im Spike.
- JSON-Lines: langsamer und größer; nur für Debug erlaubt (§6.2).
## Folgen
- `hms_protocol` definiert Envelope, Framing und Idempotency-Registry mit Unit-Tests.
- IPC bindet niemals an eine externe Netzwerkschnittstelle (§6.2).
## Freigabe
- Direkte Umsetzung der normativen Vorgabe PLAN.md §6.2.
@@ -0,0 +1,47 @@
# ADR-0004: Nativer Renderkern Rust auf dem GStreamer-D3D11-Elementpfad
- **Status:** Vorläufig angenommen (Bestätigung oder Revision im Windows-Durchlauf)
- **Datum:** 2026-09-11
- **Phase:** ursprünglich Phase 0; wegen ADR-0008 vorgezogen
- **Bauplan:** §6.1C, §7.1, §12.6
## Entscheidung
1. Sprache des nativen Moduls: **Rust**.
2. Einbindung: **GStreamer-Plugin-Ansatz** der Rendergraph nutzt die
erprobten D3D11-Elemente (d3d11h264dec → d3d11convert → d3d11compositor →
d3d11videosink) und eigene Rust-GStreamer-Elemente für Effekt-/Shader-Pässe,
statt sofort eine komplett eigenständige Render-Bridge mit eigener
Swapchain zu bauen.
## Begründung (ohne Messdaten, deshalb vorläufig)
- D3D11Memory-Residenz ist Eigenschaft der GStreamer-Elemente; das Risiko
eigengebauten Swapchain-Compositings entfällt.
- gstreamer-rs bietet stabile Bindings; Rust liefert Gedächtnissicherheit im
nativen Pfad.
- Die Effektkette skaliert als Elementkette; der Plugin-Vertrag (§14) bleibt
vollständig gewahrt.
- Eine spätere wgpu-/eigenständige Bridge bleibt über dasselbe IPC- und
Capability-Protokoll anschließbar (§6.1C).
## Alternativen
- C++: kein Vorteil, höheres Fehlerrisiko im Speichermanagement.
- Sofortige eigenständige Bridge: mehr Kontrolle, aber hohes Risiko ohne
Hardware-Feedback (ADR-0008).
## Folgen
- Der Renderer-Prozess orchestriert Pipelines; Rust-Elemente übernehmen die
Shader-Pässe (HLSL aus dem Plugin-Vertrag).
- Rust-Kompilierung erfolgt im Windows-Durchlauf (Container ohne cargo).
## Messwerte / Nachweise
- Ausstehend. Gate-0-Messungen bestätigen das Elementpfad-Budget oder lösen
eine Revision (eigenständige D3D11-Bridge) aus ohne API-Änderung (§12.6).
## Freigabe
Vorläufig gemäß ADR-0008; endgültig nach Windows-Messung.
@@ -0,0 +1,33 @@
# ADR-0005: Packaging Nuitka Onefolder (vorläufig)
- **Status:** Vorläufig angenommen (Vergleich im Windows-Durchlauf)
- **Datum:** 2026-09-11
- **Phase:** ursprünglich Phase 0; wegen ADR-0008 vorgezogen
- **Bauplan:** §7.1, §30.2
## Entscheidung
**Nuitka Standalone/Onefolder** als Packaging-Basis; **PyInstaller Onefolder**
als dokumentierter Fallback, falls Nuitka die GStreamer-DLL-Bündelung nicht
sauber abbildet. Onefile bleibt wie geplant ausgeschlossen (§7.1).
## Begründung
- Nuitka kompiliert zu C: weniger Interpreter-Rest, bessere Reproduzierbarkeit
per Buildskript (§30.1).
- Die GStreamer-Bündelung ist von der Packaging-Wahl unabhängig
(runtime/-Ordner + GST_PLUGIN_PATH, siehe build/windows/GSTREAMER.md).
- PLAN.md verlangt den finalen Vergleich auf Windows; hier nur vorläufige Wahl.
## Folgen
- Buildskripte targetieren Nuitka; der Fallback-Pfad bleibt gepflegt.
## Messwerte / Nachweise
- Ausstehend; der erste Onefolder-Build auf sauberem Windows-Rechner
entscheidet final (§29.6).
## Freigabe
Vorläufig gemäß ADR-0008; endgültig nach Windows-Build.
@@ -0,0 +1,32 @@
# ADR-0006: Frontend React mit Vite
- **Status:** Angenommen
- **Datum:** 2026-09-11
- **Phase:** 5
- **Bauplan:** §7 („TypeScript, React oder Svelte, Vite-basierter Build“), §32 ADR-Pflicht
## Entscheidung
**React 18 mit Vite** im statischen SPA-Modus.
## Begründung
- Größtes Ökosystem an Komponenten, Tastatur- und Drag-Bibliotheken wichtig für dichte Layer-Tabellen mit Ziehwerten (§17.3), Drag-Sortierung, undockbare Paneele (§17.2)
- Breitere Verfügbarkeit erfahrener Frontend-Entwickler
- Vite-Build für statische Assets passt zu §3.2 (Web-UI ist nie Videoausgang)
- Svelte wäre kleiner aber bei einer professionellen Arbeitsoberfläche mit komplexem State (100+ Parameter je Layer) überwiegt Reacts Vorhersagbarkeit
- Ein Wechsel ist durch die saubere REST/WebSocket-API-Trennung (§23) jederzeit ohne Backend-Änderung möglich
## Alternativen
- Svelte/SvelteKit: kompakter, aber kleineres Ökosystem; bei tabellenlastiger UI weniger Vorteile
## Folgen
- pnpm-Lockfile; statische Auslieferung über FastAPI (web/-Ordner, §9)
- Vitest + Playwright für Tests (§7)
- Design-Tokens zentral in CSS-Variablen (§17.6), kein UI-Framework wie MUI eigene dichte Oberfläche
## Freigabe
- Standardumsetzung gemäß §7; ADR dokumentiert die Auswahl.
@@ -0,0 +1,53 @@
# ADR-0008: Build-first-Strategie verzögerte Hardware-Validierung
- **Status:** Angenommen (Auftraggeber-Freigabe)
- **Datum:** 2026-09-11
- **Phase:** 0/1 (übergreifend)
- **Bauplan:** §1, §1.1 Nr. 1/9/10, §29.7, §36 Nr. 16
## Kontext
Die Entwicklungsumgebung ist ein CPU-only-Linux-Container ohne Windows-GPU.
Der Auftraggeber wünscht am 2026-09-11 ausdrücklich: „erst fertig bauen und dann
auf Windows testen". PLAN.md §1 erlaubt Abweichungen vom normativen Ablauf,
wenn sie per ADR dokumentiert, technisch begründet und vom Auftraggeber
freigegeben werden. Diese Freigabe liegt mit der Anfrage vor.
## Entscheidung
1. Die Phasen 15 werden **plattformneutral vollständig gebaut** (Code, Tests
auf CPU-Ebene, Schemas, Dokumentation), bevor Hardwaremessungen stattfinden.
2. Gate 0 und alle renderer-nahen Abnahmen werden **gesammelt in einem
Windows-Durchlauf** nachgeholt (reverse validation). Reihenfolge dort:
Portabilität → Decode/Residenz → Framezeit/DMX-Latenz → Adaptive Quality →
Golden Images → Onefolder-Build.
3. Kein Gate wird vorher als grün gemeldet; STATUS.md führt die ausstehende
Hardware-Validierung offen als Checkliste.
## Alternativen
- Streng planmäßig (Gate 0 zuerst): sicherer, aber ohne Windows-Zugang blockiert;
vom Auftraggeber verworfen.
- Windows-Cloud-VM in der Entwicklungsumgebung: hier nicht verfügbar.
## Folgen und Risiken
- ADR-0004/0005 müssen ohne Messdaten vorläufig entschieden werden →
ausdrücklicher Bestätigungsvorbehalt für den Windows-Durchlauf.
- Der D3D11-Elementpfad bleibt bis dahin unvalidiert. Absicherung: der
Backend-Vertrag (§12.6) hält Korrekturen im Adapter lokal; Projekt-,
Parameter-, DMX- und Web-API ändern sich nicht.
- Shader werden nur statisch bereitgestellt; Compile- und Golden-Image-Tests
entstehen im Windows-Durchlauf.
- Fällt Gate 0 rot aus: gezielte Sanierung nach §1.1 Nr. 9 mit ERRORS.md-Eintrag;
kein Architektur-Neubau erforderlich (Vertragsabsicherung).
## Messwerte / Nachweise
- CPU-Ebene: pytest/Ruff grün je Etappe (TEST_REPORT.md, fortlaufend).
- Hardware: ausstehend; Checkliste in STATUS.md.
## Freigabe
Auftraggeber: per Chat-Anfrage 2026-09-11 („erst fertig bauen und dann auf
Windows testen") hiermit dokumentiert.
@@ -0,0 +1,50 @@
# ADR-0009: Node-Discovery mDNS/DNS-SD mit manueller Fallback-Liste
- **Status:** Angenommen
- **Datum:** 2026-09-11
- **Phase:** 1
- **Bauplan:** §6.3, §27.1, §32 (ADR-Pflicht: Node-Discovery und manueller Subnetz-Fallback)
## Entscheidung
1. Discovery-Protokoll: mDNS/DNS-SD, Service-Typ `_hmsmedia._tcp.local.`
(§6.3).
2. TXT-Record enthält ausschließlich kleine, nicht vertrauliche Daten:
`proto` (Protokollversion), `node` (Node-ID), `roles` (Rollen, kommasepariert),
`port` (API-Port), `caps` (Capability-Digest, kurzer Hash).
Keine Tokens, keine Secrets (§27.1).
3. mDNS ist **keine Vertrauensentscheidung**: gefundene Nodes sind zunächst
`discovered`, steuerbar erst nach Paarung (ADR-0010).
4. Für Umgebungen ohne Multicast (VLAN, geroutete Netze, blockiertes mDNS)
existiert eine **persistente manuelle Node-Liste** (Host/IP + Port) als
gleichwertiger Fallback (§6.3).
5. Bibliothek: `zeroconf` (Standard-Python-mDNS) für den Betrieb auf
Zielsystemen. Kein Eigenbau des Multicast-Stacks; Alternativen (python-avahi,
Eigenbau) verworfen: plattformneutral unzureichend bzw. unnötiges Risiko
(§33). Im Entwicklungscontainer werden nur Modell und Registry getestet
(kein Multicast nötig); der Echtnetz-Test gehört zu Gate 1 (§29.2).
## Alternativen
- Avahi via D-Bus: Linux-only, ungeeignet für Windows-first.
- Eigener Multicast-Code: hoher Aufwand, kein messbarer Nutzen.
- Nur manuelle Liste: widerspricht §6.3 (Discovery ist Fundament).
## Folgen
- `hms_cluster.discovery` definiert ServiceInfo (Instanzname, TXT-Kodierung)
und Registry-Kategorien: `discovered`, `paired`, `unknown`, `incompatible`,
`offline` (§6.3: UI führt Gruppen getrennt auf).
- Doppelte Node-IDs werden als Fehler blockiert, nicht still gemischt (§6.3).
- Inkompatible Protokollversionen erscheinen als `incompatible`.
## Messwerte / Nachweise
- Unit-Tests: TXT-Roundtrip, Registry-Kategorien, Doppel-Node-ID-Blockade,
manuelle Liste, Protokoll-Inkompatibilität.
- Multicast-Echtnetz-Test mit zwei Nodes: Teil von Gate 1 (§29.2), auf
echter Hardware bzw. im LAN-Test nachzuholen.
## Freigabe
- Standardumsetzung gemäß §6.3; zeroconf-Addition als ADR dokumentiert (§33).
@@ -0,0 +1,45 @@
# ADR-0010: Node-Paarung PIN/Fingerprint, Token-Scopes, Widerruf
- **Status:** Angenommen
- **Datum:** 2026-09-11
- **Phase:** 1
- **Bauplan:** §6.3, §27.1, §32 (ADR-Pflicht: Node-Paarung, TLS, Berechtigungsscopes)
## Entscheidung
1. Paarung: kurzlebige PIN + sichtbarer Identitäts-Fingerprint. Ein Node wird
erst nach erfolgreicher PIN-Prüfung steuerbar (§6.3).
2. Nach Paarung erhält der Partner ein wiederrufbares Token mit getrennten
Scopes: `read`, `control`, `content_sync`, `admin` (§27.1).
3. Tokens werden als Hash gespeichert, nie im Klartext; Widerruf = Deletion,
sofort wirksam.
4. Ungepaarte Nodes geben ausschließlich minimale Discovery-/Pairing-
Informationen heraus (§27.1).
## Umsetzung Phase 1
- `hms_cluster.pairing`: PIN-Erzeugung (6-stellig, kryptographisch),
Fingerprint (SHA-256 über Identitäts-Public-Daten, hex-gruppiert sichtbar),
Paarungs-State, Token-Hash mit Scopes + Ablauf, Versuchslimit.
- TLS-Transport und Zertifikatsaustausch folgen mit dem Cluster-WebSocket
(Phase 2); dieses ADR legt die Datenmodell-Basis.
## Alternativen
- Nur Zertifikate ohne PIN: anfällig für falsche Geräte in Setup-Situationen;
sichtbare PIN ist bewusst einfach (§6.3).
- Statische API-Keys: keine Scopes, kein gezielter Widerruf.
## Folgen
- Gate-1-Test „sicher gepaart“: PIN-Prüfung + Token-Ausstellung + Widerruf
getestet; TLS-Handshake folgt in Phase 2 und bleibt in STATUS.md offen.
## Messwerte / Nachweise
- Unit-Tests: PIN-Format/-TTL, Fingerprint-Stabilität, Scope-Zuordnung,
Ablauf, Widerruf, Hash-only-Speicherung, Versuchslimit.
## Freigabe
- Standardumsetzung gemäß §6.3/§27.1.
+28
View File
@@ -0,0 +1,28 @@
# Architecture Decision Records
Vorlage: `_template.md`. Nummerierung fortlaufend. Abweichungen vom Bauplan nur
mit ADR und Freigabe (PLAN.md §1).
## Angenommen
- ADR-0001: Python-Version-Pin 3.13
- ADR-0002: GStreamer-Pin 1.28.6 (Windows)
- ADR-0003: IPC lokales TCP + length-prefixed MessagePack v1
- ADR-0008: Build-first-Strategie verzögerte Hardware-Validierung
(Auftraggeber-Freigabe 2026-09-11)
- ADR-0009: Node-Discovery mDNS/DNS-SD mit manueller Fallback-Liste
- ADR-0010: Node-Paarung PIN/Fingerprint, Token-Scopes, Widerruf
## Vorläufig angenommen (Bestätigung im Windows-Durchlauf, ADR-0008)
- ADR-0004: Nativer Renderkern Rust auf dem GStreamer-D3D11-Elementpfad
- ADR-0005: Packaging Nuitka Onefolder (Fallback PyInstaller)
## Offen
- ADR-0006: Frontend React vs. Svelte (Entscheidung zu Beginn Phase 5)
- ADR-0007: Typprüfung mypy vs. pyright
- weitere gemäß Bauplan §32 (Persistenz → erledigt in Phase 1 ohne eigene
ADR-Nummer, da §24-Vorgaben 1:1 umgesetzt; Preview, Show-Codec, Ownership,
Display-Abstraktion, Adaptive-Quality-Policy, Clusterprotokoll-Transport,
TLS-Details, Clock-Sync, UI-Design-Tokens)
+30
View File
@@ -0,0 +1,30 @@
# ADR-NNNN: <Titel>
- **Status:** Vorgeschlagen | Angenommen | Ersetzt (durch ADR-xxxx) | Verworfen
- **Datum:** YYYY-MM-DD
- **Phase:** (z. B. Phase 0)
- **Betroffene Bauplan-Abschnitte:** (z. B. §7.1, §31 Phase 0)
## Kontext
Welches Problem, welche Optionen, welche Messungen/Zahlen liegen vor?
## Entscheidung
Die gewählte Option, klar und eindeutig formuliert.
## Alternativen
Die geprüften Alternativen und warum sie verworfen wurden.
## Folgen
Positiv, negativ, Risiken, Migrationspfad, Testfolgen.
## Messwerte / Nachweise
Verweis auf TEST_REPORT.md oder Rohdaten, die die Entscheidung stützen.
## Freigabe
Auftraggeber: (Freigabe erforderlich gemäß PLAN.md §1)
View File
@@ -0,0 +1,41 @@
# Architekturüberblick (Phase 0)
Verbindliche Referenz: `PLAN.md`. Diese Seite fasst Prozess- und Besitzgrenzen zusammen.
## Prozesse (§6.1)
| Prozess | Technologie | Aufgabe |
| --- | --- | --- |
| Launcher/Supervisor | Python (Phase 1) | Start, Portwahl, portable Pfade, Heartbeat, kontrolliertes Beenden |
| Control Core | Python, asyncio, FastAPI, Pydantic, SQLite/WAL (ab Phase 1) | autoritativer Zustand, REST/WS, Art-Net, Persistenz |
| Render Worker | Python-Orchestrator + GStreamer + nativer Renderkern (ADR-0004 offen) | Decode, GPU-Compositing, Ausgabe, Telemetrie |
| Web-Frontend | TypeScript, Vite (Phase 5, ADR-0006 offen) | Bedienoberfläche; niemals Videoausgang (§3.2) |
## Besitzgrenzen (§8)
- Jedes Paket hat eindeutige `hms_*`-Namen; kein Quereinbau zwischen `packages/*`.
- Control Core und Renderer verbinden ausschließlich über das versionierte IPC (ADR-0003).
- Der Renderer erhält pro Frame einen unveränderlichen Parametersnapshot (§11.4).
## Kernregeln (§33, Auszug)
- Python steuert, decodiert nicht und rendert keine Pixel.
- Windows: D3D11Memory durchgängig; kein CPU-Rundweg im Normalpfad.
- Plugins: versioniertes Manifest, kein Datei-/DB-Zugriff, Quarantäne bei Fehler.
- Alle Steuerquellen (Browser, Art-Net, später Timeline/Audio/KI) laufen über die zentrale Parameter-Engine.
## Steuerfluss Phase 0
```text
Lichtpult/Emulator → ArtDMX (UDP 6454) → hms_artnet.receiver
→ Control Core (hms_parameter.engine: Priorität CONSOLE)
→ IPC-Snapshot → Renderer (GStreamer-D3D11) → Opacity/Parameter an Frame-Grenze
Browser → REST/WebSocket → derselbe Command-/Parameter-Pfad (Priorität WEB)
```
Beide Quellen adressieren denselben Parametersatz; es gibt genau eine autoritative Instanz (Control Core).
## Phase-0-Spike
`hms_renderer` definiert die Pipelines als Launch-Strings; Ausführung und Messung erfolgen auf Referenz-Windows-Hardware (Gate 0). Messprotokoll: `TEST_REPORT.md`.
View File
View File
+75
View File
@@ -0,0 +1,75 @@
# HMS MediaEngine Plugin-SDK-Dokumentation
Diese Dokumentation beschreibt das Plugin-System der HMS MediaEngine (PLAN.md §14) und das eingebaute Starterpaket (§15). Sie richtet sich an Entwickler, die eigene Plugins schreiben oder die eingebauten Plugins als Vorlage nutzen.
## Was ist ein Plugin?
Ein Plugin ist ein selbstbeschreibendes Paket, das der MediaEngine eine neue Fähigkeit hinzufügt typischerweise einen GPU-Effekt (Filter/Generator) oder eine Quelle. Jedes Plugin besteht aus einem Manifest (`plugin.json`) und den dazugehörigen Shader-Dateien. Die Engine validiert das Paket, kompiliert die Shader und bindet sie an die Render-Pipeline (PLAN.md §14.1, §14.5).
## Pluginarten (§14.1)
| Typ | Aufgabe |
| --- | --- |
| `source` | Video, Bild, Capture, Stream oder andere Texturquelle |
| `generator` | generiert GPU-Inhalt ohne Eingangsbild |
| `filter` | verarbeitet eine Eingangs-Textur |
| `transition` | mischt zwei Quellen zeitabhängig |
| `mixer` | spezielles Layer-Compositing |
| `output` | HDMI, Art-Net-Pixel, später NDI/Spout/Recording |
| `control` | Art-Net, später MIDI, OSC, HTTP/Webhook |
| `automation` | LFO, Audio-Mapping oder spätere KI-Regeln |
## Verzeichnisstruktur (§14.2)
Ein Plugin wird als Verzeichnis oder validiertes ZIP installiert:
```text
com.hms.fx.gaussian_blur/
├─ plugin.json
├─ shaders/
│ ├─ d3d11/
│ │ ├─ horizontal.hlsl
│ │ └─ vertical.hlsl
│ ├─ gl/
│ │ ├─ horizontal.frag
│ │ └─ vertical.frag
│ └─ gles/
│ ├─ horizontal.frag
│ └─ vertical.frag
├─ presets/
├─ thumbnail.png
├─ LICENSE
└─ README.md
```
Die Shader liegen je Backend in einem eigenen Unterordner (`d3d11/`, `gl/`, `gles/`). Weitere Dateien wie `presets/`, `thumbnail.png`, `LICENSE` und `README.md` sind optional.
## Quick Start
Ein minimales Filter-Plugin besteht aus drei Schritten:
1. **Manifest anlegen** `plugin.json` mit Pflichtfeldern (`schema_version`, `id`, `name`, `version`, `api_version`, `kind`, `vendor`, `entrypoints`, `capabilities`, `failure_mode`). Siehe [manifest-reference.md](manifest-reference.md).
2. **Shader schreiben** je deklariertem Backend eine Shader-Datei, die den Standard-Uniform-Satz (§14.4) und die Backend-Syntaxregeln einhält. Siehe [shader-contract.md](shader-contract.md).
3. **Validieren** das Manifest wird gegen das Schema und den Validator geprüft (`packages/plugin_sdk/hms_plugin_sdk/manifest.py`). Shader müssen für jedes deklarierte Backend existieren.
Ein vollständiges, schrittweises Beispiel von null anhand des eingebauten Vignette-Plugins findest du in [example-walkthrough.md](example-walkthrough.md).
## Dokumente
| Dokument | Inhalt |
| --- | --- |
| [README.md](README.md) | Übersicht, Pluginarten, Verzeichnisstruktur, Quick Start |
| [manifest-reference.md](manifest-reference.md) | Vollständige `plugin.json`-Feldreferenz |
| [shader-contract.md](shader-contract.md) | Standard-Uniform-Satz, cbuffer-Layout, Backend-Syntax |
| [parameters-and-dmx.md](parameters-and-dmx.md) | Parameter, DMX-Slots, Kurven, gemeinsamer Effektvertrag |
| [lifecycle.md](lifecycle.md) | Lebenszyklus, Quarantäne, Show-Lock |
| [adaptive-quality.md](adaptive-quality.md) | Adaptive-Quality-Varianten und Wechselregeln |
| [example-walkthrough.md](example-walkthrough.md) | Schritt-für-Schritt-Beispiel (Vignette) |
## Sicherheitsgrenzen (§14.6)
- Shaderplugins erhalten keinen Dateisystem- oder Netzwerkzugriff.
- Native DLL-Plugins sind vor Version 2 nicht vorgesehen.
- Python-Control-/Automation-Plugins laufen später in einem separaten Prozess mit freigegebener Command-API.
- Kein Plugin greift direkt auf SQLite oder interne Python-Objekte zu.
- Pluginfehler werden einem konkreten Plugin zugeordnet und in der UI angezeigt.
@@ -0,0 +1,84 @@
# Adaptive Quality
Dieses Dokument beschreibt Adaptive Quality (AQ) für Plugins. Grundlage sind PLAN.md §14.3 (Beispielmanifest), §15.3 (Bedien- und Presetpflichten), §15.4 (Abnahme) und das JSON-Schema `schemas/plugin/plugin_manifest_v1.schema.json`.
## Zweck
Adaptive Quality erlaubt es einem Effekt, die interne Rechenlast an die verfügbare Hardware anzupassen, ohne die semantische Wirkung der Parameter zu verändern. Die Engine wählt automatisch eine Variante oder der Bediener setzt eine feste Qualitätsstufe (`Quality: Auto` oder fest, PLAN.md §14.8).
## Varianten-Deklaration
AQ-Varianten werden im Manifest unter `adaptive_quality` deklariert:
```json
"adaptive_quality": {
"default": "auto",
"variants": [
{"id": "low", "internal_scale": 0.25, "samples": 5},
{"id": "medium", "internal_scale": 0.5, "samples": 9},
{"id": "high", "internal_scale": 1.0, "samples": 17}
],
"transition_ms": 180,
"semantic_parameters_unchanged": ["radius", "mix"]
}
```
### Felder
| Feld | Typ | Pflicht | Beschreibung |
| --- | --- | --- | --- |
| `default` | string | ja | Standard-Variante, z. B. `auto` |
| `variants` | array, minItems 1 | ja | Liste der Varianten |
| `transition_ms` | number, minimum 0 | nein | Übergangszeit in Millisekunden |
| `semantic_parameters_unchanged` | array of string | nein | Parameter, deren Semantik beim Wechsel unverändert bleibt |
Jede Variante ist ein Objekt mit:
| Feld | Typ | Pflicht | Beschreibung |
| --- | --- | --- | --- |
| `id` | string | ja | Varianten-ID, z. B. `low`, `medium`, `high` |
| `internal_scale` | number, exclusiveMinimum 0, maximum 1 | nein | Interne Auflösungsskala |
| `samples` | integer, minimum 1 | nein | Sample-Anzahl |
## `semantic_parameters_unchanged`
Dieses Feld listet Parameter, deren **semantische Bedeutung** beim Wechsel der Qualitätsstufe unverändert bleibt. Ein Parameterwert wie `radius` oder `mix` muss auf allen Varianten dasselbe visuelle Ergebnis liefern; nur die interne Abtastung (Auflösung, Sample-Anzahl) darf variieren (PLAN.md §15.3).
## Kompilierung vor Aktivierung
Die Validierung verlangt **vollständige, vorab kompilierbare Adaptive-Quality-Varianten** (PLAN.md §14.5). Jede deklarierte Variante muss vor der Aktivierung kompilierbar sein. Ein Plugin, dessen Varianten nicht vollständig kompilierbar sind, wird nicht aktiviert.
## Wechsel nur an der Framegrenze
Der Wechsel der Qualitätsstufe erfolgt **atomar an einer Framegrenze** (PLAN.md §14.8). Verschieben, Bypass, Kopieren und Presetwechsel müssen ebenfalls atomar an einer Framegrenze erfolgen. Dadurch wird verhindert, dass ein halber Frame mit gemischten Qualitätsstufen gerendert wird.
## Beispiel: Vignette
Das eingebaute Vignette-Plugin deklariert drei Varianten, die alle dieselbe interne Auflösung und Sample-Anzahl verwenden (ein GPU-Pass, PLAN.md §15.2):
```json
"adaptive_quality": {
"default": "auto",
"variants": [
{"id": "low", "internal_scale": 1.0, "samples": 1},
{"id": "medium", "internal_scale": 1.0, "samples": 1},
{"id": "high", "internal_scale": 1.0, "samples": 1}
],
"transition_ms": 180,
"semantic_parameters_unchanged": ["mix"]
}
```
## Abnahme (§15.4)
Für jedes eingebaute Plugin sind verpflichtend:
- HLSL-Implementierung für D3D11 sowie GLSL/GLES-Varianten gemäß Capability;
- Golden Images bei mindestens drei Parametersätzen;
- zeitabhängige Tests mit festem Seed und festem Frameindex;
- Alpha-/Premultiplication-Test und definierter Farbraum;
- Test für Extremwerte, NaN/Infinity und Auflösung 1 × 1;
- atomarer Bypass-, Preset- und Quality-Wechsel;
- dokumentierte GPU-Zeit bei 1080p und, sofern Tier erlaubt, 4K;
- Layer-, Adjustment-/Group- und Master-Scope-Test;
- P1P8-/G1G8-Kanalbelegung im generierten Fixture-Handbuch.
@@ -0,0 +1,328 @@
# Beispiel: Ein Filter-Plugin von null schreiben
Dieses Dokument führt Schritt für Schritt durch die Erstellung eines neuen Filter-Plugins. Als Vorlage dient das eingebaute Vignette-Plugin (`plugins/builtin/filters/com.hms.fx.vignette/`), das ein GPU-Pass-Filter ist (PLAN.md §15.2).
## Ziel
Wir erstellen ein Plugin `com.hms.fx.vignette` einen Filter, der die Ränder eines Bildes abdunkelt oder einfärbt. Das Plugin unterstützt drei Backends: D3D11 (HLSL), OpenGL (GLSL) und OpenGL ES (GLES).
## Schritt 1: Verzeichnisstruktur anlegen
Ein Plugin ist ein Verzeichnis mit `plugin.json` und Shader-Unterordnern je Backend:
```text
com.hms.fx.vignette/
├─ plugin.json
└─ shaders/
├─ d3d11/
│ └─ main.hlsl
├─ gl/
│ └─ main.frag
└─ gles/
└─ main.frag
```
## Schritt 2: Manifest schreiben
Lege `plugin.json` an. Die Pflichtfelder sind `schema_version`, `id`, `name`, `version`, `api_version`, `kind`, `vendor`, `entrypoints`, `capabilities`, `failure_mode` (siehe [manifest-reference.md](manifest-reference.md)).
```json
{
"schema_version": 1,
"id": "com.hms.fx.vignette",
"name": "Vignette",
"version": "1.0.0",
"api_version": 1,
"kind": "filter",
"vendor": "HMS",
"entrypoints": {
"d3d11": {
"type": "hlsl",
"passes": [
{"pixel_shader": "shaders/d3d11/main.hlsl"}
]
},
"gl": {
"type": "glsl",
"passes": [
{"fragment": "shaders/gl/main.frag"}
]
},
"gles": {
"type": "glsl_es",
"passes": [
{"fragment": "shaders/gles/main.frag"}
]
}
},
"capabilities": {
"minimum_tier": "PI_LITE",
"requires_input_texture": true,
"supported_backends": ["d3d11", "gl", "gles"]
},
"parameters": [
{"id": "amount", "label": "Amount", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [1]},
{"id": "radius", "label": "Radius", "type": "float", "minimum": 0, "maximum": 2, "default": 0.5, "dmx_slots": [2]},
{"id": "softness", "label": "Softness", "type": "float", "minimum": 0, "maximum": 1, "default": 0.4, "dmx_slots": [3]},
{"id": "roundness", "label": "Roundness", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [4]},
{"id": "center_x", "label": "Center X", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [5]},
{"id": "center_y", "label": "Center Y", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [6]},
{"id": "color", "label": "Color", "type": "color", "default": [0, 0, 0], "dmx_slots": [7]},
{"id": "invert", "label": "Invert", "type": "bool", "default": 0, "dmx_slots": [8]},
{"id": "mix", "label": "Mix", "type": "float", "minimum": 0, "maximum": 1, "default": 1, "dmx_slots": []}
],
"failure_mode": "bypass",
"adaptive_quality": {
"default": "auto",
"variants": [
{"id": "low", "internal_scale": 1.0, "samples": 1},
{"id": "medium", "internal_scale": 1.0, "samples": 1},
{"id": "high", "internal_scale": 1.0, "samples": 1}
],
"transition_ms": 180,
"semantic_parameters_unchanged": ["mix"]
}
}
```
Wichtige Punkte:
- `kind: "filter"` verarbeitet eine Eingangs-Textur.
- `requires_input_texture: true` der Filter braucht eine Eingangs-Textur.
- `dmx_slots` belegen die Slots 18; `mix` belegt keinen Slot (gemeinsamer Effektvertrag, §14.8).
- `failure_mode: "bypass"` bei Fehler wird der Effekt überbrückt (§3.5).
- `adaptive_quality` deklariert drei Varianten; `semantic_parameters_unchanged: ["mix"]`.
## Schritt 3: HLSL-Shader schreiben (D3D11)
Lege `shaders/d3d11/main.hlsl` an. Der Shader deklariert die Textur, den Sampler und den Konstantenpuffer `hms_params` mit dem Standard-Uniform-Satz (§14.4) und den Pluginparametern:
```hlsl
// HMS MediaEngine - Vignette (PLAN.md §15.2)
// Randabdunklung/-färbung; ein GPU-Pass.
Texture2D u_input_texture : register(t0);
SamplerState u_sampler : register(s0);
cbuffer hms_params : register(b0)
{
float4 u_resolution; // xy = Auflösung in Pixeln
float u_time_seconds;
float u_delta_seconds;
float u_frame_index;
float u_layer_opacity;
float u_audio_rms;
float u_audio_peak;
float u_audio_bass;
float u_audio_mid;
float u_audio_treble;
float u_audio_beat;
float param_amount;
float param_radius;
float param_softness;
float param_roundness;
float param_center_x;
float param_center_y;
float param_color_r;
float param_color_g;
float param_color_b;
float param_invert; // 0 = abdunkeln, 1 = aufhellen
float param_mix;
float _pad0;
float _pad1;
float _pad2;
float _pad3;
};
float4 mainPS(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target
{
float4 src = u_input_texture.Sample(u_sampler, uv);
float m = saturate(param_mix);
if (m < 0.01)
{
return src; // Mix 0 = kostenloser Bypass (§15.3)
}
float2 c = float2(param_center_x, param_center_y);
float2 d = uv - c;
d.x *= lerp(1.0, u_resolution.x / max(u_resolution.y, 1.0), param_roundness);
float dist = length(d);
float radius = max(param_radius, 0.0001);
float soft = max(param_softness, 0.0001);
float v = smoothstep(radius, radius + soft, dist);
v = saturate(v * param_amount);
if (param_invert > 0.5)
v = 1.0 - v;
float3 vignette = lerp(src.rgb, float3(param_color_r, param_color_g, param_color_b), v);
float3 rgb = lerp(src.rgb, vignette, m);
float4 out_c = float4(rgb, src.a);
out_c.rgb *= u_layer_opacity;
out_c.a *= u_layer_opacity;
return out_c;
}
```
## Schritt 4: GLSL-Shader schreiben (OpenGL)
Lege `shaders/gl/main.frag` an. Die Semantik ist identisch zur HLSL-Variante (§12.6, §15.4), nur die Syntax unterscheidet sich:
```glsl
#version 330 core
// HMS MediaEngine - Vignette (GLSL, Linux x64)
// Semantik identisch zur HLSL-Variante (§12.6, §15.4).
uniform sampler2D u_input_texture;
uniform vec2 u_resolution;
uniform float u_time_seconds;
uniform float u_delta_seconds;
uniform float u_frame_index;
uniform float u_layer_opacity;
uniform float u_audio_rms;
uniform float u_audio_peak;
uniform float u_audio_bass;
uniform float u_audio_mid;
uniform float u_audio_treble;
uniform float u_audio_beat;
uniform float param_amount;
uniform float param_radius;
uniform float param_softness;
uniform float param_roundness;
uniform float param_center_x;
uniform float param_center_y;
uniform float param_color_r;
uniform float param_color_g;
uniform float param_color_b;
uniform float param_invert;
uniform float param_mix;
in vec2 v_uv;
out vec4 fragColor;
void main()
{
vec4 src = texture(u_input_texture, v_uv);
float m = clamp(param_mix, 0.0, 1.0);
if (m < 0.01)
{
fragColor = src;
return;
}
vec2 c = vec2(param_center_x, param_center_y);
vec2 d = v_uv - c;
d.x *= mix(1.0, u_resolution.x / max(u_resolution.y, 1.0), param_roundness);
float dist = length(d);
float radius = max(param_radius, 0.0001);
float soft = max(param_softness, 0.0001);
float v = smoothstep(radius, radius + soft, dist);
v = clamp(v * param_amount, 0.0, 1.0);
if (param_invert > 0.5)
v = 1.0 - v;
vec3 vignette = mix(src.rgb, vec3(param_color_r, param_color_g, param_color_b), v);
vec3 rgb = mix(src.rgb, vignette, m);
vec4 out_c = vec4(rgb, src.a);
out_c.rgb *= u_layer_opacity;
out_c.a *= u_layer_opacity;
fragColor = out_c;
}
```
## Schritt 5: GLES-Shader schreiben (OpenGL ES 2.0)
Lege `shaders/gles/main.frag` an. GLES 2.0 hat eigene Regeln: `#version 100`, `precision mediump float;`, `texture2D` statt `texture`, `varying` statt `in`/`out`, `gl_FragColor` statt `fragColor` (siehe [shader-contract.md](shader-contract.md)):
```glsl
#version 100
// HMS MediaEngine - Vignette (GLES, Raspberry Pi)
// ES 2.0; ein GPU-Pass.
precision mediump float;
uniform sampler2D u_input_texture;
uniform vec2 u_resolution;
uniform float u_time_seconds;
uniform float u_delta_seconds;
uniform float u_frame_index;
uniform float u_layer_opacity;
uniform float u_audio_rms;
uniform float u_audio_peak;
uniform float u_audio_bass;
uniform float u_audio_mid;
uniform float u_audio_treble;
uniform float u_audio_beat;
uniform float param_amount;
uniform float param_radius;
uniform float param_softness;
uniform float param_roundness;
uniform float param_center_x;
uniform float param_center_y;
uniform float param_color_r;
uniform float param_color_g;
uniform float param_color_b;
uniform float param_invert;
uniform float param_mix;
varying vec2 v_uv;
void main()
{
vec4 src = texture2D(u_input_texture, v_uv);
float m = clamp(param_mix, 0.0, 1.0);
if (m < 0.01)
{
gl_FragColor = src;
return;
}
vec2 c = vec2(param_center_x, param_center_y);
vec2 d = v_uv - c;
d.x *= mix(1.0, u_resolution.x / max(u_resolution.y, 1.0), param_roundness);
float dist = length(d);
float radius = max(param_radius, 0.0001);
float soft = max(param_softness, 0.0001);
float v = smoothstep(radius, radius + soft, dist);
v = clamp(v * param_amount, 0.0, 1.0);
if (param_invert > 0.5)
v = 1.0 - v;
vec3 vignette = mix(src.rgb, vec3(param_color_r, param_color_g, param_color_b), v);
vec3 rgb = mix(src.rgb, vignette, m);
vec4 out_c = vec4(rgb, src.a);
out_c.rgb *= u_layer_opacity;
out_c.a *= u_layer_opacity;
gl_FragColor = out_c;
}
```
## Schritt 6: Validieren
Das Plugin wird über den Lifecycle-Manager validiert (`packages/plugin_sdk/hms_plugin_sdk/lifecycle.py`). `discover()` lädt `plugin.json`, validiert es (inkl. Shader-Existenz) und überführt das Plugin in `validated` oder `quarantined`/`incompatible` (siehe [lifecycle.md](lifecycle.md)).
Der Validator prüft unter anderem:
- Manifest-Schema;
- eindeutige Plugin-ID und semantische Version;
- API-Kompatibilität;
- Pfad- und ZIP-Sicherheit;
- erlaubte Dateitypen und Größenlimits;
- Shader-Kompilierung;
- Capability-Prüfung;
- vollständige, vorab kompilierbare Adaptive-Quality-Varianten und unveränderte Parametersemantik;
- Lizenzmetadaten;
- Hash des Pakets.
## Schritt 7: Aktivieren
Nach erfolgreicher Validierung durchläuft das Plugin den Lebenszyklus `validated → installed → enabled → compiled → active`. Der Wechsel der Qualitätsstufe erfolgt atomar an einer Framegrenze (siehe [adaptive-quality.md](adaptive-quality.md)).
## Zusammenfassung
Ein Filter-Plugin besteht aus:
1. `plugin.json` mit Pflichtfeldern, Parametern und Adaptive Quality;
2. je deklariertem Backend eine Shader-Datei mit dem Standard-Uniform-Satz (§14.4);
3. semantisch identischen Implementierungen über alle Backends (§12.6, §15.4).
+83
View File
@@ -0,0 +1,83 @@
# Plugin-Lebenszyklus
Dieses Dokument beschreibt den Lebenszyklus eines Plugins. Grundlage sind PLAN.md §14.5 (Plugin-Lebenszyklus), §14.6 (Sicherheitsgrenze), §26.3 (Show-Lock) und die Implementierung `packages/plugin_sdk/hms_plugin_sdk/lifecycle.py`.
## Zustände
Der Lebenszyklus kennt folgende Zustände:
```text
discovered → validated → installed → enabled → compiled → active
↘ quarantined / incompatible
```
Zusätzlich existiert der Zustand `disabled` (deaktiviert, aber installiert). Die vollständige Zustandsmenge (`lifecycle.py`):
- `discovered`
- `validated`
- `installed`
- `enabled`
- `compiled`
- `active`
- `quarantined`
- `incompatible`
- `disabled`
## Übergänge
Übergänge sind nur entlang definierter Kanten erlaubt; Sprünge sind Fehler (`InvalidTransitionError`). Die erlaubten Übergänge (`lifecycle.py`):
| Von | Nach |
| --- | --- |
| `discovered` | `validated`, `incompatible`, `quarantined` |
| `validated` | `installed`, `incompatible`, `quarantined` |
| `installed` | `enabled`, `disabled`, `quarantined` |
| `enabled` | `compiled`, `disabled`, `quarantined` |
| `compiled` | `active`, `quarantined`, `disabled` |
| `active` | `disabled`, `quarantined` |
| `disabled` | `enabled`, `quarantined` |
| `quarantined` | (manuelle Entfernung/Neuinstallation) |
| `incompatible` | |
## Discovery und Validierung
`discover()` scannt ein Verzeichnis mit Plugin-Ordnern, lädt `plugin.json` und validiert es (inkl. Shader-Existenz). Jedes Plugin wird in `validated` oder `incompatible`/`quarantined` überführt (`lifecycle.py`).
Die Validierung umfasst (PLAN.md §14.5):
- Manifest-Schema;
- eindeutige Plugin-ID und semantische Version;
- API-Kompatibilität;
- Pfad- und ZIP-Sicherheit;
- erlaubte Dateitypen und Größenlimits;
- Shader-Kompilierung;
- Capability-Prüfung;
- vollständige, vorab kompilierbare Adaptive-Quality-Varianten und unveränderte Parametersemantik;
- Lizenzmetadaten;
- Hash des Pakets.
## Quarantäne (§3.5, §14.5)
Ein fehlerhaftes Plugin wird **quarantiniert**, ohne ein Projekt unbrauchbar zu machen. Der Effekt wird überbrückt (`bypass_on_error`). Die Quarantäne speichert die Ursache (`last_error`). Aus `quarantined` gibt es keinen automatischen Übergang; das Plugin muss manuell entfernt oder neu installiert werden.
## Show-Lock (§26.3)
Der Show-Lock sperrt strukturelle Änderungen während einer laufenden Show:
- **Installieren/Updaten ist gesperrt.** Übergänge, die Installation/Update bedeuten (`installed` von `discovered`/`validated`), sind im Show-Lock blockiert.
- **Enable/Disable und Parameter bleiben erlaubt.** Aktivieren bereits installierter Plugins (`enabled`/`compiled`/`active`) bleibt möglich (PLAN.md §17.7 Live-Modus).
- **Versionswechsel sind gesperrt.** Ein Update startet einen neuen Zyklus; im Show-Lock wird ein Versionswechsel abgelehnt.
Der Show-Lock wird über `set_show_lock(enabled)` gesetzt (`lifecycle.py`).
## Doppelte Plugin-IDs
Doppelte Plugin-IDs sind Fehler, keine stillen Überschreibungen. Ein Versionskonflikt wird erkannt; ohne Show-Lock gewinnt der neue Stand, mit Show-Lock wird der Versionswechsel abgelehnt (`lifecycle.py`).
## Sicherheitsgrenze (§14.6)
- Shaderplugins erhalten keinen Dateisystem- oder Netzwerkzugriff.
- Native DLL-Plugins sind vor Version 2 nicht vorgesehen.
- Python-Control-/Automation-Plugins laufen später in einem separaten Prozess mit freigegebener Command-API.
- Kein Plugin greift direkt auf SQLite oder interne Python-Objekte zu.
- Pluginfehler werden einem konkreten Plugin zugeordnet und in der UI angezeigt.
@@ -0,0 +1,259 @@
# `plugin.json` Manifest-Referenz
Dieses Dokument beschreibt alle Felder des Plugin-Manifests `plugin.json`. Grundlage sind das JSON-Schema `schemas/plugin/plugin_manifest_v1.schema.json` und der Validator `packages/plugin_sdk/hms_plugin_sdk/manifest.py` (PLAN.md §14.214.3).
## Schema-Version und API-Version
| Feld | Typ | Pflicht | Wert |
| --- | --- | --- | --- |
| `schema_version` | integer | ja | `1` (konstant) |
| `api_version` | integer | ja | `1` (konstant) |
`schema_version` und `api_version` müssen exakt `1` sein. Der Validator lehnt jede andere Version ab (`manifest.py`).
## Pflichtfelder
Das Schema verlangt folgende Felder auf oberster Ebene:
```json
["schema_version", "id", "name", "version", "api_version", "kind", "vendor", "entrypoints", "capabilities", "failure_mode"]
```
### `id`
- **Typ:** string
- **Pflicht:** ja
- **Regeln:** Reverse-DNS-Notation, mindestens 5 Zeichen, mindestens zwei durch `.` getrennte Teile, nur Kleinbuchstaben `az`, Ziffern `09`, `.`, `_`, `-`. Kein `..` erlaubt.
- **Beispiel:** `com.hms.fx.vignette`
### `name`
- **Typ:** string, minLength 1
- **Pflicht:** ja
- **Beispiel:** `Vignette`
### `version`
- **Typ:** string
- **Pflicht:** ja
- **Regeln:** Semantische Version `X.Y.Z` (genau drei durch `.` getrennte Ganzzahlen).
- **Beispiel:** `1.0.0`
### `kind`
- **Typ:** enum
- **Pflicht:** ja
- **Werte:** `source`, `generator`, `filter`, `transition`, `mixer`, `output`, `control`, `automation` (PLAN.md §14.1)
- **Beispiel:** `filter`
### `vendor`
- **Typ:** string, minLength 1
- **Pflicht:** ja
- **Beispiel:** `HMS`
### `entrypoints`
- **Typ:** object, minProperties 1
- **Pflicht:** ja
- **Beschreibung:** Backend-Entrypoints mit Shader-Pässen. Erlaubte Backend-Schlüssel: `d3d11`, `gl`, `gles`.
Jeder Entrypoint ist ein Objekt mit:
| Feld | Typ | Pflicht | Beschreibung |
| --- | --- | --- | --- |
| `type` | string | ja | Entrypoint-Typ, z. B. `hlsl`, `glsl`, `glsl_es`, `hlsl_multipass`, `glsl_multipass`, `glsl_es_multipass` |
| `passes` | array, minItems 1 | ja | Liste der Shader-Pässe |
Jeder Pass ist ein Objekt mit genau einem Shader-Pfad:
- **D3D11:** `pixel_shader` (z. B. `shaders/d3d11/main.hlsl`)
- **GL/GLES:** `fragment` (z. B. `shaders/gl/main.frag`)
**Beispiel (Einpass, Vignette):**
```json
"entrypoints": {
"d3d11": {
"type": "hlsl",
"passes": [{"pixel_shader": "shaders/d3d11/main.hlsl"}]
},
"gl": {
"type": "glsl",
"passes": [{"fragment": "shaders/gl/main.frag"}]
},
"gles": {
"type": "glsl_es",
"passes": [{"fragment": "shaders/gles/main.frag"}]
}
}
```
**Beispiel (Multipass, Gaussian Blur, PLAN.md §14.3):**
```json
"entrypoints": {
"d3d11": {
"type": "hlsl_multipass",
"passes": [
{"pixel_shader": "shaders/d3d11/horizontal.hlsl"},
{"pixel_shader": "shaders/d3d11/vertical.hlsl"}
]
},
"gl": {
"type": "glsl_multipass",
"passes": [
{"fragment": "shaders/gl/horizontal.frag"},
{"fragment": "shaders/gl/vertical.frag"}
]
},
"gles": {
"type": "glsl_es_multipass",
"passes": [
{"fragment": "shaders/gles/horizontal.frag"},
{"fragment": "shaders/gles/vertical.frag"}
]
}
}
```
Der Validator prüft, dass jeder deklarierte Shader-Pfad relativ und sicher ist (kein absoluter Pfad, kein `..`) und wenn ein Plugin-Root übergeben wird tatsächlich existiert (`manifest.py`).
### `capabilities`
- **Typ:** object
- **Pflicht:** ja
- **Pflichtfelder:** `minimum_tier`, `supported_backends`
| Feld | Typ | Pflicht | Beschreibung |
| --- | --- | --- | --- |
| `minimum_tier` | enum | ja | `DESKTOP_FULL`, `DESKTOP_LITE`, `PI_LITE`, `HEADLESS_CONTROL` |
| `requires_input_texture` | boolean | nein | `true`, wenn der Effekt eine Eingangs-Textur braucht (Filter) |
| `supported_backends` | array, minItems 1 | ja | `d3d11`, `gl`, `gles` |
**Beispiel (Vignette):**
```json
"capabilities": {
"minimum_tier": "PI_LITE",
"requires_input_texture": true,
"supported_backends": ["d3d11", "gl", "gles"]
}
```
### `failure_mode`
- **Typ:** enum
- **Pflicht:** ja
- **Werte:** `bypass`, `hold`, `black`
- **Beschreibung:** Verhalten bei einem Pluginfehler. `bypass` überbrückt den Effekt (PLAN.md §3.5, §14.5).
- **Beispiel:** `bypass`
## Optionale Felder
### `parameters`
- **Typ:** array
- **Pflicht:** nein (im Schema), aber empfohlen
- **Beschreibung:** Liste der Plugin-Parameter. Details siehe [parameters-and-dmx.md](parameters-and-dmx.md).
Jeder Parameter ist ein Objekt mit:
| Feld | Typ | Pflicht | Beschreibung |
| --- | --- | --- | --- |
| `id` | string, minLength 1 | ja | Eindeutige Parameter-ID |
| `label` | string, minLength 1 | ja | Anzeigename |
| `type` | enum | ja | `float`, `int`, `enum`, `bool`, `color` |
| `default` | beliebig | ja | Standardwert |
| `minimum` | number | nein | Untergrenze (für `float`/`int`) |
| `maximum` | number | nein | Obergrenze (für `float`/`int`) |
| `values` | array | nein | Werte für `enum` |
| `dmx_slots` | array, maxItems 8 | nein | DMX-Slot-Belegung (18) |
| `curve` | string | nein | Kurvenform, z. B. `quadratic` |
**Beispiel (Vignette):**
```json
"parameters": [
{"id": "amount", "label": "Amount", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [1]},
{"id": "radius", "label": "Radius", "type": "float", "minimum": 0, "maximum": 2, "default": 0.5, "dmx_slots": [2]},
{"id": "softness", "label": "Softness", "type": "float", "minimum": 0, "maximum": 1, "default": 0.4, "dmx_slots": [3]},
{"id": "roundness", "label": "Roundness", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [4]},
{"id": "center_x", "label": "Center X", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [5]},
{"id": "center_y", "label": "Center Y", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [6]},
{"id": "color", "label": "Color", "type": "color", "default": [0, 0, 0], "dmx_slots": [7]},
{"id": "invert", "label": "Invert", "type": "bool", "default": 0, "dmx_slots": [8]},
{"id": "mix", "label": "Mix", "type": "float", "minimum": 0, "maximum": 1, "default": 1, "dmx_slots": []}
]
```
**Validator-Regeln für Parameter (`manifest.py`):**
- `id` muss vorhanden und ein String sein; doppelte IDs sind Fehler.
- `type` muss einer von `float`, `int`, `enum`, `bool`, `color` sein.
- Für `float` müssen `minimum`, `maximum` und `default` vorhanden sein.
- `dmx_slots` muss eine Liste von Ganzzahlen sein.
- Die Summe aller `dmx_slots` über alle Parameter darf **8 nicht überschreiten** (PLAN.md §14.7).
### `adaptive_quality`
- **Typ:** object
- **Pflicht:** nein (im Schema), aber für qualitätsabhängige Effekte empfohlen
- **Pflichtfelder:** `default`, `variants`
- **Beschreibung:** Adaptive-Quality-Varianten. Details siehe [adaptive-quality.md](adaptive-quality.md).
| Feld | Typ | Pflicht | Beschreibung |
| --- | --- | --- | --- |
| `default` | string | ja | Standard-Variante, z. B. `auto` |
| `variants` | array, minItems 1 | ja | Liste der Varianten |
| `transition_ms` | number, minimum 0 | nein | Übergangszeit in Millisekunden |
| `semantic_parameters_unchanged` | array of string | nein | Parameter, deren Semantik beim Wechsel unverändert bleibt |
Jede Variante ist ein Objekt mit:
| Feld | Typ | Pflicht | Beschreibung |
| --- | --- | --- | --- |
| `id` | string | ja | Varianten-ID, z. B. `low`, `medium`, `high` |
| `internal_scale` | number, exclusiveMinimum 0, maximum 1 | nein | Interne Auflösungsskala |
| `samples` | integer, minimum 1 | nein | Sample-Anzahl |
**Beispiel (Vignette):**
```json
"adaptive_quality": {
"default": "auto",
"variants": [
{"id": "low", "internal_scale": 1.0, "samples": 1},
{"id": "medium", "internal_scale": 1.0, "samples": 1},
{"id": "high", "internal_scale": 1.0, "samples": 1}
],
"transition_ms": 180,
"semantic_parameters_unchanged": ["mix"]
}
```
## Validierung
Die Validierung (`manifest.py`) umfasst (PLAN.md §14.5):
- Manifest-Schema;
- eindeutige Plugin-ID und semantische Version;
- API-Kompatibilität;
- Pfad- und ZIP-Sicherheit;
- erlaubte Dateitypen und Größenlimits;
- Shader-Kompilierung;
- Capability-Prüfung;
- vollständige, vorab kompilierbare Adaptive-Quality-Varianten und unveränderte Parametersemantik;
- Lizenzmetadaten;
- Hash des Pakets.
## ZIP-Sicherheit (§27.2)
Beim Installieren als ZIP gelten zusätzlich (`manifest.py`):
- maximal 512 Dateien (`MAX_PLUGIN_FILES`);
- maximale entpackte Gesamtgröße 32 MiB (`MAX_TOTAL_UNPACKED`);
- maximale Dateigröße 8 MiB (`MAX_FILE_SIZE`);
- erlaubte Dateiendungen: `.json`, `.hlsl`, `.frag`, `.vert`, `.glsl`, `.png`, `.md`, `.txt`, `.toml`, `.csv`;
- keine absoluten Pfade, kein `..`;
- `plugin.json` muss an der Paketwurzel liegen.
@@ -0,0 +1,131 @@
# Parameter und DMX
Dieses Dokument beschreibt Parameter-Definitionen, DMX-Slots, Kurven und den gemeinsamen Effektvertrag. Grundlage sind PLAN.md §14.7 (DMX-Parameter-Slots), §14.8 (Gemeinsamer Effektvertrag), das JSON-Schema und der Validator `packages/plugin_sdk/hms_plugin_sdk/manifest.py`.
## Parameter-Definitionen
Parameter werden im Manifest unter `parameters` als Liste von Objekten deklariert. Jeder Parameter besitzt folgende Felder:
| Feld | Typ | Pflicht | Beschreibung |
| --- | --- | --- | --- |
| `id` | string | ja | Eindeutige Parameter-ID |
| `label` | string | ja | Anzeigename |
| `type` | enum | ja | `float`, `int`, `enum`, `bool`, `color` |
| `default` | beliebig | ja | Standardwert |
| `minimum` | number | nein | Untergrenze (für `float`/`int`) |
| `maximum` | number | nein | Obergrenze (für `float`/`int`) |
| `values` | array | nein | Werte für `enum` |
| `dmx_slots` | array | nein | DMX-Slot-Belegung (18) |
| `curve` | string | nein | Kurvenform, z. B. `quadratic` |
### Parametertypen
Der Validator (`manifest.py`) erlaubt genau fünf Typen:
- `float` Gleitkommawert; `minimum`, `maximum` und `default` sind Pflicht.
- `int` Ganzzahl.
- `enum` Aufzählung; `values` listet die erlaubten Werte.
- `bool` Wahrheitswert (`0`/`1`).
- `color` Farbe, als RGB-Array (z. B. `[0, 0, 0]`).
### Validator-Regeln
- `id` muss vorhanden und ein String sein; doppelte IDs sind Fehler.
- `type` muss einer der fünf erlaubten Typen sein.
- Für `float` müssen `minimum`, `maximum` und `default` vorhanden sein.
- `dmx_slots` muss eine Liste von Ganzzahlen sein.
- Die Summe aller `dmx_slots` über alle Parameter darf **8 nicht überschreiten** (PLAN.md §14.7).
## DMX-Slots (§14.7)
Ein Effekt besitzt maximal **acht generische DMX-Slots pro Effektinstanz**. Das Manifest bildet reale Parameter darauf ab:
- acht 8-Bit-Parameter; oder
- vier 16-Bit-Parameter; oder
- eine gemischte, manifestdefinierte Belegung.
Der DMX-Footprint des Layer-Fixtures bleibt dadurch stabil, auch wenn neue Plugins installiert werden.
### Slot-Belegung im Manifest
Jeder Parameter kann über `dmx_slots` auf einen oder mehrere Slots (18) abgebildet werden. Das Vignette-Beispiel belegt die Slots 18:
```json
"parameters": [
{"id": "amount", "label": "Amount", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [1]},
{"id": "radius", "label": "Radius", "type": "float", "minimum": 0, "maximum": 2, "default": 0.5, "dmx_slots": [2]},
{"id": "softness", "label": "Softness", "type": "float", "minimum": 0, "maximum": 1, "default": 0.4, "dmx_slots": [3]},
{"id": "roundness","label": "Roundness","type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [4]},
{"id": "center_x", "label": "Center X", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [5]},
{"id": "center_y", "label": "Center Y", "type": "float", "minimum": 0, "maximum": 1, "default": 0.5, "dmx_slots": [6]},
{"id": "color", "label": "Color", "type": "color", "default": [0, 0, 0], "dmx_slots": [7]},
{"id": "invert", "label": "Invert", "type": "bool", "default": 0, "dmx_slots": [8]},
{"id": "mix", "label": "Mix", "type": "float", "minimum": 0, "maximum": 1, "default": 1, "dmx_slots": []}
]
```
Der `mix`-Parameter belegt keinen DMX-Slot (`dmx_slots: []`), da er Teil des gemeinsamen Effektvertrags ist.
### 16-Bit-Werte
Ein Parameter kann über zwei DMX-Slots als 16-Bit-Wert abgebildet werden (vier 16-Bit-Parameter pro Effektinstanz). Die genaue Belegung ist manifestdefiniert. Der Validator zählt jeden Slot in `dmx_slots` einzeln; die Gesamtsumme darf 8 nicht überschreiten.
## Kurven
Parameter können eine Kurvenform über `curve` deklarieren. Das Schema erlaubt einen beliebigen String; das Beispiel aus PLAN.md §14.3 verwendet `quadratic`:
```json
{
"id": "radius",
"label": "Radius",
"type": "float",
"minimum": 0.0,
"maximum": 40.0,
"default": 0.0,
"dmx_slots": [1],
"curve": "quadratic"
}
```
Unterstützte Kurvenformen:
- `linear` lineare Abbildung des DMX-/Control-Werts auf den Parameterbereich.
- `quadratic` quadratische Abbildung, die feine Abstufungen in einem Teilbereich begünstigt.
## Gemeinsamer Effektvertrag (§14.8)
Jede Filterinstanz besitzt unabhängig vom konkreten Effekt:
- Enable/Bypass;
- Mix beziehungsweise Effect Opacity von 0 bis 1;
- eigenen Blend-Modus gegenüber dem unveränderten Eingang;
- deterministische Position in der Effektkette;
- Reset auf Default;
- speicher-, umbenenn- und kopierbares Preset;
- `Quality: Auto` oder feste Qualitätsstufe;
- acht stabile generische DMX-Parameter-Slots `P1` bis `P8`;
- modulatable Parameter über denselben Parameterpfad wie Browser, Art-Net und spätere Automation.
### `mix`
Der `mix`-Parameter (Effect Opacity) liegt zwischen 0 und 1. `Mix 0` muss den Effekt kostengünstig bypassen (PLAN.md §15.3). Das Vignette-Beispiel implementiert das im Shader:
```hlsl
float m = saturate(param_mix);
if (m < 0.01)
{
return src; // Mix 0 = kostenloser Bypass (§15.3)
}
```
### `blend_mode`
Jeder Effekt besitzt einen eigenen Blend-Modus gegenüber dem unveränderten Eingang. Der Blend-Modus ist Teil des Effektvertrags und wird pro Instanz gesetzt.
### `quality`
Jeder Effekt besitzt `Quality: Auto` oder eine feste Qualitätsstufe. Die Qualitätsstufe wählt eine Adaptive-Quality-Variante (siehe [adaptive-quality.md](adaptive-quality.md)).
### Instanzierungs-Scope
Filterplugins dürfen sofern im Manifest freigegeben auf Source/Clip, Layer, Group, Adjustment Layer, Master oder Output instanziert werden. V1 muss mindestens Layer, Group/Adjustment und Master unterstützen. Die Kettenreihenfolge ist fachlich relevant und wird strikt von oben nach unten ausgewertet. Verschieben, Bypass, Kopieren und Presetwechsel müssen atomar an einer Framegrenze erfolgen (PLAN.md §14.8).
@@ -0,0 +1,239 @@
# Shader-Vertrag
Dieses Dokument beschreibt den verbindlichen Shader-Vertrag für Plugin-Shader. Grundlage sind PLAN.md §14.4 (Standard-Shaderinputs) und die eingebauten Beispiel-Shader des Vignette-Plugins (`plugins/builtin/filters/com.hms.fx.vignette/shaders/`).
## Standard-Uniform-Satz (§14.4)
Jeder Shader erhält nach Bedarf folgende semantische Inputs:
| Semantische ID | Typ | Beschreibung |
| --- | --- | --- |
| `u_input_texture` | Textur | Eingangs-Textur (bei Filtern) |
| `u_resolution` | vec2/float4 | Auflösung in Pixeln (`xy`) |
| `u_time_seconds` | float | Zeit in Sekunden |
| `u_delta_seconds` | float | Zeit seit dem letzten Frame |
| `u_frame_index` | float | Frame-Index |
| `u_layer_opacity` | float | Opazität des Layers |
| `u_audio_rms` | float | Audio-RMS |
| `u_audio_peak` | float | Audio-Peak |
| `u_audio_bass` | float | Audio-Bass |
| `u_audio_mid` | float | Audio-Mid |
| `u_audio_treble` | float | Audio-Treble |
| `u_audio_beat` | float | Audio-Beat |
| deklarierte Pluginparameter | float | Parameter aus `plugin.json` |
Semantische Input-IDs, Typen, Wertebereiche, Farbräume und Texturkonventionen sind Teil der versionierten Plugin-API. Backendadapter binden sie an HLSL-Konstanten beziehungsweise GLSL-Uniforms; Projekte referenzieren niemals konkrete Variablennamen eines Backends (PLAN.md §14.4).
## HLSL (D3D11) cbuffer-Layout
Im HLSL-Backend liegen die Standard-Uniforms und Pluginparameter in einem Konstantenpuffer (`cbuffer`). Das Vignette-Beispiel (`shaders/d3d11/main.hlsl`) zeigt das Layout:
```hlsl
Texture2D u_input_texture : register(t0);
SamplerState u_sampler : register(s0);
cbuffer hms_params : register(b0)
{
float4 u_resolution; // xy = Auflösung in Pixeln
float u_time_seconds;
float u_delta_seconds;
float u_frame_index;
float u_layer_opacity;
float u_audio_rms;
float u_audio_peak;
float u_audio_bass;
float u_audio_mid;
float u_audio_treble;
float u_audio_beat;
float param_amount;
float param_radius;
float param_softness;
float param_roundness;
float param_center_x;
float param_center_y;
float param_color_r;
float param_color_g;
float param_color_b;
float param_invert; // 0 = abdunkeln, 1 = aufhellen
float param_mix;
float _pad0;
float _pad1;
float _pad2;
float _pad3;
};
```
### 16-Byte-Alignment
HLSL-Konstantenpuffer werden in 16-Byte-Registern (`float4`) organisiert. Ein `float4` belegt ein Register; einzelne `float`-Werte werden in 16-Byte-Blöcken zusammengefasst. Das Beispiel füllt den Puffer mit `_pad0` bis `_pad3` auf ein Vielfaches von 16 Byte auf, damit das Layout deterministisch bleibt.
### Textur- und Sampler-Bindung
- `u_input_texture` ist an `register(t0)` gebunden.
- Der Sampler `u_sampler` ist an `register(s0)` gebunden.
- Der Konstantenpuffer `hms_params` ist an `register(b0)` gebunden.
## GLSL (OpenGL, Linux x64)
Das GL-Backend (`shaders/gl/main.frag`) deklariert die Uniforms einzeln:
```glsl
#version 330 core
uniform sampler2D u_input_texture;
uniform vec2 u_resolution;
uniform float u_time_seconds;
uniform float u_delta_seconds;
uniform float u_frame_index;
uniform float u_layer_opacity;
uniform float u_audio_rms;
uniform float u_audio_peak;
uniform float u_audio_bass;
uniform float u_audio_mid;
uniform float u_audio_treble;
uniform float u_audio_beat;
uniform float param_amount;
uniform float param_radius;
uniform float param_softness;
uniform float param_roundness;
uniform float param_center_x;
uniform float param_center_y;
uniform float param_color_r;
uniform float param_color_g;
uniform float param_color_b;
uniform float param_invert;
uniform float param_mix;
in vec2 v_uv;
out vec4 fragColor;
void main()
{
vec4 src = texture(u_input_texture, v_uv);
// ...
fragColor = out_c;
}
```
## GLES (OpenGL ES 2.0, Raspberry Pi)
Das GLES-Backend (`shaders/gles/main.frag`) folgt ES-2.0-Regeln:
```glsl
#version 100
precision mediump float;
uniform sampler2D u_input_texture;
uniform vec2 u_resolution;
uniform float u_time_seconds;
uniform float u_delta_seconds;
uniform float u_frame_index;
uniform float u_layer_opacity;
uniform float u_audio_rms;
uniform float u_audio_peak;
uniform float u_audio_bass;
uniform float u_audio_mid;
uniform float u_audio_treble;
uniform float u_audio_beat;
uniform float param_amount;
uniform float param_radius;
uniform float param_softness;
uniform float param_roundness;
uniform float param_center_x;
uniform float param_center_y;
uniform float param_color_r;
uniform float param_color_g;
uniform float param_color_b;
uniform float param_invert;
uniform float param_mix;
varying vec2 v_uv;
void main()
{
vec4 src = texture2D(u_input_texture, v_uv);
// ...
gl_FragColor = out_c;
}
```
## Backend-Syntaxunterschiede
Die drei Backends müssen semantisch identische Ergebnisse liefern (PLAN.md §12.6, §15.4). Die wichtigsten Syntaxunterschiede:
| Aspekt | HLSL (D3D11) | GLSL (OpenGL) | GLES (ES 2.0) |
| --- | --- | --- | --- |
| Version | `#version` nicht nötig | `#version 330 core` | `#version 100` |
| Texturzugriff | `u_input_texture.Sample(u_sampler, uv)` | `texture(u_input_texture, v_uv)` | `texture2D(u_input_texture, v_uv)` |
| Ausgabe | `return float4(...)` an `SV_Target` | `fragColor = ...` | `gl_FragColor = ...` |
| Eingabe | `float2 uv : TEXCOORD0` | `in vec2 v_uv` | `varying vec2 v_uv` |
| Clamp | `saturate(x)` | `clamp(x, 0.0, 1.0)` | `clamp(x, 0.0, 1.0)` |
| Mischen | `lerp(a, b, t)` | `mix(a, b, t)` | `mix(a, b, t)` |
| Präzision | | | `precision mediump float;` erforderlich |
### GLES-spezifische Regeln
- **Konstante Loop-Grenzen:** GLES 2.0 erlaubt nur Schleifen mit konstanten Grenzen. Dynamische Schleifengrenzen sind nicht zulässig.
- **`texture2D` statt `texture`:** In ES 2.0 wird `texture2D` verwendet.
- **Kein `atan2`:** Die Funktion `atan2` ist in ES 2.0 nicht verfügbar; verwende `atan(y, x)` mit zwei Argumenten oder baue die Logik selbst.
- **`precision`-Deklaration:** `precision mediump float;` muss vor den Uniforms stehen.
- **`varying` statt `in`/`out`:** ES 2.0 verwendet `varying` für Vertex-zu-Fragment-Daten.
## Multipass-Regeln
Multipass-Effekte (z. B. Gaussian Blur, PLAN.md §14.3) deklarieren mehrere Pässe im Manifest:
```json
"entrypoints": {
"d3d11": {
"type": "hlsl_multipass",
"passes": [
{"pixel_shader": "shaders/d3d11/horizontal.hlsl"},
{"pixel_shader": "shaders/d3d11/vertical.hlsl"}
]
}
}
```
- Jeder Pass ist ein eigener Shader mit eigenem Dateipfad.
- Die Pässe werden in der deklarierten Reihenfolge ausgeführt; der Ausgang eines Passes ist der Eingang des nächsten.
- Jeder Pass erhält denselben Standard-Uniform-Satz.
- Die Semantik muss über alle Backends identisch sein.
## Adaptive-Quality-Varianten-Bindung
Adaptive-Quality-Varianten (PLAN.md §14.3, §15.4) können die interne Auflösung (`internal_scale`) und die Sample-Anzahl (`samples`) ändern. Die Bindung erfolgt über die deklarierten Varianten im Manifest:
```json
"adaptive_quality": {
"default": "auto",
"variants": [
{"id": "low", "internal_scale": 0.25, "samples": 5},
{"id": "medium", "internal_scale": 0.5, "samples": 9},
{"id": "high", "internal_scale": 1.0, "samples": 17}
]
}
```
Die Engine bindet die Variantenwerte an die Shader-Uniforms. Die Parameterwerte bleiben beim Wechsel der Auto-Qualitätsstufe semantisch identisch (PLAN.md §15.3). Details siehe [adaptive-quality.md](adaptive-quality.md).
## Mix-0-Bypass (§15.3)
Jeder Effekt besitzt einen `mix`-Parameter. `Mix 0` muss den Effekt kostengünstig bypassen. Das Vignette-Beispiel zeigt das Muster:
```hlsl
float m = saturate(param_mix);
if (m < 0.01)
{
return src; // Mix 0 = kostenloser Bypass (§15.3)
}
```
```glsl
float m = clamp(param_mix, 0.0, 1.0);
if (m < 0.01)
{
fragColor = src;
return;
}
```
@@ -0,0 +1,65 @@
channel,parameter,resolution_behavior
1,Layer Enable,Schalter
2,Opacity (MSB),16 Bit
3,Opacity (LSB),16 Bit
4,Source Type,Enum: Media/Generator/Live/Solid
5,Media Bank,8 Bit
6,Media Folder,8 Bit
7,Media/Plugin Index (MSB),16 Bit
8,Media/Plugin Index (LSB),16 Bit
9,Load/Commit Selection,steigende Flanke
10,Transport,Enum: Stop/Play/Pause/Retrigger
11,Loop Mode,Enum
12,Playback Direction/Mode,Enum
13,Playback Speed (MSB),"16 Bit, signed Mapping"
14,Playback Speed (LSB),"16 Bit, signed Mapping"
15,Playback Position (MSB),"16 Bit, normalisiert"
16,Playback Position (LSB),"16 Bit, normalisiert"
17,In Point (MSB),"16 Bit, normalisiert"
18,In Point (LSB),"16 Bit, normalisiert"
19,Out Point (MSB),"16 Bit, normalisiert"
20,Out Point (LSB),"16 Bit, normalisiert"
21,Blend Mode,Enum
22,Transform Mode/Anchor,Enum
23,Position X (MSB),"16 Bit, signed"
24,Position X (LSB),"16 Bit, signed"
25,Position Y (MSB),"16 Bit, signed"
26,Position Y (LSB),"16 Bit, signed"
27,Scale X (MSB),16 Bit
28,Scale X (LSB),16 Bit
29,Scale Y (MSB),16 Bit
30,Scale Y (LSB),16 Bit
31,Rotation (MSB),16 Bit
32,Rotation (LSB),16 Bit
33,Crop Left,8 Bit
34,Crop Right,8 Bit
35,Crop Top,8 Bit
36,Crop Bottom,8 Bit
37,Hue,8 Bit
38,Saturation,8 Bit
39,Brightness,8 Bit
40,Contrast,8 Bit
41,FX1 Enable,Schalter
42,FX1 Plugin Select,"8 Bit, Show-Registry"
43,FX1 Mix,8 Bit
44,FX1 Parameter P1,8 Bit oder manifestgebundene Paare
45,FX1 Parameter P2,8 Bit oder manifestgebundene Paare
46,FX1 Parameter P3,8 Bit oder manifestgebundene Paare
47,FX1 Parameter P4,8 Bit oder manifestgebundene Paare
48,FX1 Parameter P5,8 Bit oder manifestgebundene Paare
49,FX1 Parameter P6,8 Bit oder manifestgebundene Paare
50,FX1 Parameter P7,8 Bit oder manifestgebundene Paare
51,FX1 Parameter P8,8 Bit oder manifestgebundene Paare
52,FX2 Enable,Schalter
53,FX2 Plugin Select,"8 Bit, Show-Registry"
54,FX2 Mix,8 Bit
55,FX2 Parameter P1,8 Bit oder manifestgebundene Paare
56,FX2 Parameter P2,8 Bit oder manifestgebundene Paare
57,FX2 Parameter P3,8 Bit oder manifestgebundene Paare
58,FX2 Parameter P4,8 Bit oder manifestgebundene Paare
59,FX2 Parameter P5,8 Bit oder manifestgebundene Paare
60,FX2 Parameter P6,8 Bit oder manifestgebundene Paare
61,FX2 Parameter P7,8 Bit oder manifestgebundene Paare
62,FX2 Parameter P8,8 Bit oder manifestgebundene Paare
63,Layer Retrigger/Reset,steigende Flanke
64,reserviert,muss neutral ignoriert werden
1 channel parameter resolution_behavior
2 1 Layer Enable Schalter
3 2 Opacity (MSB) 16 Bit
4 3 Opacity (LSB) 16 Bit
5 4 Source Type Enum: Media/Generator/Live/Solid
6 5 Media Bank 8 Bit
7 6 Media Folder 8 Bit
8 7 Media/Plugin Index (MSB) 16 Bit
9 8 Media/Plugin Index (LSB) 16 Bit
10 9 Load/Commit Selection steigende Flanke
11 10 Transport Enum: Stop/Play/Pause/Retrigger
12 11 Loop Mode Enum
13 12 Playback Direction/Mode Enum
14 13 Playback Speed (MSB) 16 Bit, signed Mapping
15 14 Playback Speed (LSB) 16 Bit, signed Mapping
16 15 Playback Position (MSB) 16 Bit, normalisiert
17 16 Playback Position (LSB) 16 Bit, normalisiert
18 17 In Point (MSB) 16 Bit, normalisiert
19 18 In Point (LSB) 16 Bit, normalisiert
20 19 Out Point (MSB) 16 Bit, normalisiert
21 20 Out Point (LSB) 16 Bit, normalisiert
22 21 Blend Mode Enum
23 22 Transform Mode/Anchor Enum
24 23 Position X (MSB) 16 Bit, signed
25 24 Position X (LSB) 16 Bit, signed
26 25 Position Y (MSB) 16 Bit, signed
27 26 Position Y (LSB) 16 Bit, signed
28 27 Scale X (MSB) 16 Bit
29 28 Scale X (LSB) 16 Bit
30 29 Scale Y (MSB) 16 Bit
31 30 Scale Y (LSB) 16 Bit
32 31 Rotation (MSB) 16 Bit
33 32 Rotation (LSB) 16 Bit
34 33 Crop Left 8 Bit
35 34 Crop Right 8 Bit
36 35 Crop Top 8 Bit
37 36 Crop Bottom 8 Bit
38 37 Hue 8 Bit
39 38 Saturation 8 Bit
40 39 Brightness 8 Bit
41 40 Contrast 8 Bit
42 41 FX1 Enable Schalter
43 42 FX1 Plugin Select 8 Bit, Show-Registry
44 43 FX1 Mix 8 Bit
45 44 FX1 Parameter P1 8 Bit oder manifestgebundene Paare
46 45 FX1 Parameter P2 8 Bit oder manifestgebundene Paare
47 46 FX1 Parameter P3 8 Bit oder manifestgebundene Paare
48 47 FX1 Parameter P4 8 Bit oder manifestgebundene Paare
49 48 FX1 Parameter P5 8 Bit oder manifestgebundene Paare
50 49 FX1 Parameter P6 8 Bit oder manifestgebundene Paare
51 50 FX1 Parameter P7 8 Bit oder manifestgebundene Paare
52 51 FX1 Parameter P8 8 Bit oder manifestgebundene Paare
53 52 FX2 Enable Schalter
54 53 FX2 Plugin Select 8 Bit, Show-Registry
55 54 FX2 Mix 8 Bit
56 55 FX2 Parameter P1 8 Bit oder manifestgebundene Paare
57 56 FX2 Parameter P2 8 Bit oder manifestgebundene Paare
58 57 FX2 Parameter P3 8 Bit oder manifestgebundene Paare
59 58 FX2 Parameter P4 8 Bit oder manifestgebundene Paare
60 59 FX2 Parameter P5 8 Bit oder manifestgebundene Paare
61 60 FX2 Parameter P6 8 Bit oder manifestgebundene Paare
62 61 FX2 Parameter P7 8 Bit oder manifestgebundene Paare
63 62 FX2 Parameter P8 8 Bit oder manifestgebundene Paare
64 63 Layer Retrigger/Reset steigende Flanke
65 64 reserviert muss neutral ignoriert werden
@@ -0,0 +1,33 @@
channel,parameter,resolution_behavior
1,Master Intensity (MSB),16 Bit (mit Kanal 2)
2,Master Intensity (LSB),16 Bit
3,Blackout,"Trigger/Schalter, höchste Priorität"
4,Freeze Output,Schalter
5,Preset Bank,8 Bit
6,Preset Index (MSB),16 Bit (mit Kanal 7)
7,Preset Index (LSB),16 Bit
8,Preset Recall,"steigende Flanke, direkter Abruf ohne Cue-GO-Logik"
9,Transition Type,Enum
10,Transition Duration (MSB),"16 Bit, konfigurierter Maximalwert"
11,Transition Duration (LSB),16 Bit
12,Global Speed (MSB),16 Bit
13,Global Speed (LSB),16 Bit
14,BPM (MSB),16 Bit
15,BPM (LSB),16 Bit
16,Tap Tempo,steigende Flanke
17,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
18,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
19,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
20,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
21,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
22,Audio Reactive Enable,Schalter
23,Audio Master Gain,8 Bit
24,Automation/AI Enable,"nur Freigabe, keine Sicherheitsumgehung"
25,Output Test Pattern,Enum
26,Preview Enable,Schalter
27,Global Hue,8 Bit
28,Global Saturation,8 Bit
29,Fallback Preset,8 Bit
30,Release Manual Overrides,Trigger mit Schutzlogik
31,reserviert,muss neutral ignoriert werden
32,reserviert,muss neutral ignoriert werden
1 channel parameter resolution_behavior
2 1 Master Intensity (MSB) 16 Bit (mit Kanal 2)
3 2 Master Intensity (LSB) 16 Bit
4 3 Blackout Trigger/Schalter, höchste Priorität
5 4 Freeze Output Schalter
6 5 Preset Bank 8 Bit
7 6 Preset Index (MSB) 16 Bit (mit Kanal 7)
8 7 Preset Index (LSB) 16 Bit
9 8 Preset Recall steigende Flanke, direkter Abruf ohne Cue-GO-Logik
10 9 Transition Type Enum
11 10 Transition Duration (MSB) 16 Bit, konfigurierter Maximalwert
12 11 Transition Duration (LSB) 16 Bit
13 12 Global Speed (MSB) 16 Bit
14 13 Global Speed (LSB) 16 Bit
15 14 BPM (MSB) 16 Bit
16 15 BPM (LSB) 16 Bit
17 16 Tap Tempo steigende Flanke
18 17 reserviert (Cue/Timeline-Erweiterung) im MVP neutral ignorieren
19 18 reserviert (Cue/Timeline-Erweiterung) im MVP neutral ignorieren
20 19 reserviert (Cue/Timeline-Erweiterung) im MVP neutral ignorieren
21 20 reserviert (Cue/Timeline-Erweiterung) im MVP neutral ignorieren
22 21 reserviert (Cue/Timeline-Erweiterung) im MVP neutral ignorieren
23 22 Audio Reactive Enable Schalter
24 23 Audio Master Gain 8 Bit
25 24 Automation/AI Enable nur Freigabe, keine Sicherheitsumgehung
26 25 Output Test Pattern Enum
27 26 Preview Enable Schalter
28 27 Global Hue 8 Bit
29 28 Global Saturation 8 Bit
30 29 Fallback Preset 8 Bit
31 30 Release Manual Overrides Trigger mit Schutzlogik
32 31 reserviert muss neutral ignoriert werden
33 32 reserviert muss neutral ignoriert werden
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Erzeugt 'HMS MediaEngine.app' im Projektordner (macOS App-Bundle).
Aufruf: python3 make_mac_app.py
Danach: 'HMS MediaEngine.app' per Doppelklick im Finder starten.
Der Launcher prueft unsichtbar: fehlt GStreamer, oeffnet sich der
grafische Installer; ist alles da, startet die Engine im Hintergrund
und der Browser oeffnet sich.
"""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parent
APP = ROOT / "HMS MediaEngine.app"
INFO_PLIST = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>HMS MediaEngine</string>
<key>CFBundleDisplayName</key><string>HMS MediaEngine</string>
<key>CFBundleIdentifier</key><string>de.hms.mediaengine</string>
<key>CFBundleVersion</key><string>0.1.0</string>
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleExecutable</key><string>HMS-Launcher</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>NSHighResolutionCapable</key><true/>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
</dict>
</plist>
"""
LAUNCHER = r'''#!/bin/bash
# HMS MediaEngine App-Start (vom Finder, ohne sichtbares Terminal)
DIR="$(cd "$(dirname "$0")/../../.." && pwd)"
PY=""
for p in /opt/homebrew/bin/python3 /usr/local/bin/python3 python3; do
if command -v "$p" >/dev/null 2>&1; then PY="$p"; break; fi
done
if [ -z "$PY" ]; then
osascript -e 'display alert "Python nicht gefunden" message "Bitte HMS-Mac-Install.command im Projektordner einmal ausfuehren - es installiert alles Grafische." as critical' >/dev/null 2>&1
exit 1
fi
cd "$DIR"
# Portable GStreamer-Umgebung laden (falls HMS-Portable-Install genutzt)
if [ -f "$DIR/runtime/env.sh" ]; then
source "$DIR/runtime/env.sh"
fi
if "$PY" -c "import gi; gi.require_version('Gst','1.0')" >/dev/null 2>&1; then
nohup "$PY" "$DIR/run.py" >>/tmp/hms-mediaengine.log 2>&1 &
PORT=8080
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 \
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40; do
curl -s -o /dev/null "http://localhost:$PORT/api/health" && break
sleep 0.5
done
open "http://localhost:$PORT"
exit 0
else
nohup "$PY" "$DIR/installer_gui.py" >/tmp/hms-installer.log 2>&1 &
exit 0
fi
'''
def build() -> None:
macos = APP / "Contents" / "MacOS"
macos.mkdir(parents=True, exist_ok=True)
(APP / "Contents" / "Info.plist").write_text(INFO_PLIST, "utf-8")
launcher = macos / "HMS-Launcher"
launcher.write_text(LAUNCHER, "utf-8")
launcher.chmod(0o755)
print(f"App erstellt: {APP}")
print("Doppelklick im Finder: 'HMS MediaEngine.app'")
if __name__ == "__main__":
build()
@@ -0,0 +1,52 @@
[package]
name = "hms_render_bridge"
version = "0.1.0"
edition = "2021"
description = "HMS MediaEngine native render bridge (ADR-0004): GStreamer D3D11 pipeline, layer compositor, HLSL shader loader."
license = "MIT"
[lib]
name = "hms_render_bridge"
crate-type = ["cdylib", "rlib"]
[dependencies]
# GStreamer core + video + base (gstreamer-rs)
gstreamer = "0.22"
gstreamer-video = "0.22"
gstreamer-base = "0.22"
gstreamer-gl = "0.22"
# D3D11 interop via windows-rs (Windows only)
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_Graphics_Direct3D11",
"Win32_Graphics_Direct3D",
"Win32_Graphics_Dxgi",
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Direct3D11_On_12",
"Win32_Foundation",
"Win32_Graphics_Direct3D12",
"Win32_Graphics_Direct3D12_On_11",
"Win32_Graphics_Hlsl",
"Win32_System_Com",
"Win32_System_LibraryLoader",
] }
# MessagePack for FrameSnapshot IPC (field names identical to Python FrameSnapshot)
rmp-serde = "1.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_bytes = "0.11"
# Logging
log = "0.4"
env_logger = "0.11"
# Error handling
thiserror = "1.0"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "abort"
+131
View File
@@ -0,0 +1,131 @@
# HMS MediaEngine Native Render Bridge (ADR-0004)
Rust/GStreamer-D3D11-Renderkern für den HMS MediaEngine Render-Worker (§6.1C).
Dieser Crate implementiert den nativen Rendergraph als GStreamer-Plugin-Ansatz:
- **GStreamer-Plugin** `hmsrender` mit eigenem Compositor-Element `hmscompositor`
- **D3D11-Layer-Compositor** mit den Blend-Modi V1 (§12.4)
- **HLSL-Shader-Loader** mit dem Standard-cbuffer-Layout `hms_params` (§14.4)
- **FrameReceiver** für binäres MessagePack-`FrameSnapshot` (§11.4)
- **Pipeline-Builder** für die D3D11-Elementkette (§13.1)
## Architektur
```text
Python Render-Worker (§6.1C)
│ FrameSnapshot (MessagePack, IPC)
FrameReceiver ──► LayerCompositor (D3D11)
GStreamer-Pipeline: d3d11h264dec → d3d11convert → hmscompositor → d3d11videosink
```
Kein CPU-Readback im Normalpfad (§12.6, §33): Alle Operationen laufen auf
GPU-residenten D3D11-Texturen.
## Build-Anleitung (Windows)
### Voraussetzungen
- **Rust Toolchain** (stable, Edition 2021): <https://rustup.rs>
- **GStreamer MSVC Runtime + Development** (1.22+):
<https://gstreamer.freedesktop.org/download/>
- Installiere `gstreamer-1.0-devel-msvc-x86_64` und `gstreamer-1.0-runtime-msvc-x86_64`
- Setze `GSTREAMER_1_0_ROOT_MSVC_X86_64` auf den Installationspfad
- **Windows SDK** (für D3D11, DXGI, HLSL): Teil von Visual Studio Build Tools
- **pkg-config** (für gstreamer-rs): über MSYS2 oder `vcpkg`
### Build
```bash
cd native/render_bridge
cargo build --release
```
Die kompilierte Bibliothek liegt unter `target/release/hms_render_bridge.dll`
(cdylib).
### Umgebungsvariablen
```bash
export GST_PLUGIN_PATH="$(pwd)/target/release"
export GST_PLUGIN_SYSTEM_PATH_1_0="C:/gstreamer/1.0/msvc_x86_64/lib/gstreamer-1.0"
```
## Integration mit dem Python-Orchestrator
Der Python-Render-Worker (§6.1C) lädt die cdylib und ruft die FFI-Funktionen auf:
```python
import ctypes
bridge = ctypes.CDLL("target/release/hms_render_bridge.dll")
# Pipeline bauen (JSON-Konfiguration)
config = {
"canvas_width": 1920,
"canvas_height": 1080,
"fps": 60.0,
"media_uri": "C:/media/clip.mp4",
"layers": {"layer_1": "normal"},
"output_device": None,
}
config_json = json.dumps(config).encode("utf-8")
bridge.hms_build_pipeline(config_json)
# FrameSnapshot als MessagePack übergeben
snapshot = {
"frame_index": 0,
"monotonic_ns": 0,
"state_revision": 0,
"parameters": {"layer_1/opacity": 1.0, "audio/rms": 0.5},
"source_positions": {},
"source_states": {},
"active_asset_ids": {},
}
payload = msgpack.packb(snapshot)
bridge.hms_push_frame(payload, len(payload))
```
## FrameSnapshot-Vertrag (§11.4)
Die Feldnamen im Rust-`FrameSnapshot` sind identisch zum Python-`FrameSnapshot`
in `apps/renderer/hms_renderer/engine.py`:
| Feld | Typ | Bedeutung |
| --- | --- | --- |
| `frame_index` | int | Frame-Nummer |
| `monotonic_ns` | int | Monotone Zeitbasis (ns) |
| `state_revision` | int | Showzustands-Revision |
| `parameters` | dict[str, float] | Flache Parameter-Pfade |
| `source_positions` | dict[str, float] | source_id → Position |
| `source_states` | dict[str, str] | source_id → TransportState |
| `active_asset_ids` | dict[str, str\|None] | layer_key → asset_id |
## Blend-Modi (§12.4)
`normal`, `add`, `multiply`, `screen`, `lighten`, `darken`, `difference`,
`overlay`, `alpha_premultiplied`. Müssen mit Golden-Image-Tests geprüft werden.
## Standard-Shaderinputs (§14.4)
Jeder Shader erhält das cbuffer `hms_params` (register b0):
- `u_resolution` (float4: xy = Pixel, zw = 1/xy)
- `u_time_seconds`, `u_delta_seconds`, `u_frame_index`
- `u_layer_opacity`
- `u_audio_rms`, `u_audio_peak`, `u_audio_bass`, `u_audio_mid`, `u_audio_treble`, `u_audio_beat`
- deklarierte Plugin-Parameter
## Windows-Abhängigkeiten
- GStreamer MSVC (Runtime + Development)
- Windows SDK (D3D11, DXGI, HLSL)
- Visual Studio Build Tools (Linker, pkg-config)
## Hinweis
Der Code wird im Container nicht kompiliert (kein cargo). Die Kompilierung
erfolgt im Windows-Durchlauf gemäß ADR-0004. Gate-0-Messungen bestätigen das
Elementpfad-Budget oder lösen eine Revision aus (eigenständige D3D11-Bridge).
@@ -0,0 +1,249 @@
//! Layer-Compositing mit D3D11 (ADR-0004, §12.4, §12.6).
//!
//! Mischt GPU-residente Texturen mit den Blend-Modi V1, Opacity und 2D-Transform.
//! Kein CPU-Readback im Normalpfad: Alle Operationen laufen als D3D11-Drawcalls
//! auf GPU-residenten Texturen.
use std::sync::Mutex;
use gstreamer::glib;
use gstreamer::prelude::*;
use gstreamer::subclass::prelude::*;
/// Blend-Modi V1 (§12.4). Muss mit Golden-Image-Tests geprüft werden.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlendMode {
Normal,
Add,
Multiply,
Screen,
Lighten,
Darken,
Difference,
Overlay,
AlphaPremultiplied,
}
impl BlendMode {
/// Parst einen Blend-Modus aus dem FrameSnapshot-Parameter `layer/<key>/blend`.
pub fn from_str(s: &str) -> Option<Self> {
match s {
"normal" => Some(BlendMode::Normal),
"add" => Some(BlendMode::Add),
"multiply" => Some(BlendMode::Multiply),
"screen" => Some(BlendMode::Screen),
"lighten" => Some(BlendMode::Lighten),
"darken" => Some(BlendMode::Darken),
"difference" => Some(BlendMode::Difference),
"overlay" => Some(BlendMode::Overlay),
"alpha_premultiplied" => Some(BlendMode::AlphaPremultiplied),
_ => None,
}
}
/// HLSL-Blend-Operator-Name für den Compositor-Shader.
pub fn as_hlsl(&self) -> &'static str {
match self {
BlendMode::Normal => "BLEND_NORMAL",
BlendMode::Add => "BLEND_ADD",
BlendMode::Multiply => "BLEND_MULTIPLY",
BlendMode::Screen => "BLEND_SCREEN",
BlendMode::Lighten => "BLEND_LIGHTEN",
BlendMode::Darken => "BLEND_DARKEN",
BlendMode::Difference => "BLEND_DIFFERENCE",
BlendMode::Overlay => "BLEND_OVERLAY",
BlendMode::AlphaPremultiplied => "BLEND_ALPHA_PREMULTIPLIED",
}
}
}
/// 2D-Transformation eines Layers (§12.1: Crop / Transform / Mask).
#[derive(Debug, Clone, Copy)]
pub struct LayerTransform {
pub x: f32,
pub y: f32,
pub scale_x: f32,
pub scale_y: f32,
pub rotation_deg: f32,
pub opacity: f32,
}
impl Default for LayerTransform {
fn default() -> Self {
LayerTransform {
x: 0.0,
y: 0.0,
scale_x: 1.0,
scale_y: 1.0,
rotation_deg: 0.0,
opacity: 1.0,
}
}
}
/// Ein zu compositender Layer: GPU-Textur + Blend-Modus + Transform.
#[derive(Debug, Clone)]
pub struct LayerInput {
pub layer_key: String,
pub texture: TextureHandle,
pub blend: BlendMode,
pub transform: LayerTransform,
}
/// Opaque-Handle auf eine GPU-residente D3D11-Textur.
///
/// Die eigentliche `ID3D11Texture2D` wird über `windows-rs` gehalten; dieser
/// Typ kapselt sie, damit der Compositor keine CPU-Kopie erzwingt.
#[derive(Debug, Clone)]
pub struct TextureHandle {
pub width: u32,
pub height: u32,
/// GPU-residente Textur (D3D11). Auf Nicht-Windows-Plattformen leer.
#[cfg(windows)]
pub d3d11_texture: Option<windows::Win32::Graphics::Direct3D11::ID3D11Texture2D>,
#[cfg(not(windows))]
pub _placeholder: Option<()>,
}
impl TextureHandle {
#[cfg(windows)]
pub fn new(
width: u32,
height: u32,
texture: windows::Win32::Graphics::Direct3D11::ID3D11Texture2D,
) -> Self {
TextureHandle {
width,
height,
d3d11_texture: Some(texture),
}
}
#[cfg(not(windows))]
pub fn new(width: u32, height: u32) -> Self {
TextureHandle {
width,
height,
_placeholder: None,
}
}
}
/// D3D11-Layer-Compositor.
///
/// Mischt alle Layer in der Reihenfolge der Liste auf ein Render-Target.
/// Jeder Layer wird als GPU-Drawcall mit dem gewählten Blend-Modus, Opacity
/// und Transform ausgeführt. Kein CPU-Readback (§12.6).
#[derive(Debug, Default)]
pub struct LayerCompositor {
/// Canvas-Auflösung in Pixeln.
pub canvas_width: u32,
pub canvas_height: u32,
/// Aktive Layer in Compositing-Reihenfolge (unten zuerst).
pub layers: Vec<LayerInput>,
}
impl LayerCompositor {
pub fn new(canvas_width: u32, canvas_height: u32) -> Self {
LayerCompositor {
canvas_width,
canvas_height,
layers: Vec::new(),
}
}
/// Fügt einen Layer hinzu (unten zuerst).
pub fn push_layer(&mut self, layer: LayerInput) {
self.layers.push(layer);
}
/// Anzahl aktiver Layer (für Telemetrie §28.2).
pub fn active_layers(&self) -> usize {
self.layers.len()
}
/// Führt das Compositing auf dem aktuellen Render-Target aus.
///
/// Auf Windows wird pro Layer ein D3D11-Drawcall mit dem Blend-State des
/// jeweiligen Modus ausgeführt. Die konkrete Drawcall-Ausführung ist in
/// `composite_d3d11` gekapselt und nur unter `cfg(windows)` aktiv.
pub fn composite(&self) -> Result<(), CompositorError> {
#[cfg(windows)]
{
self.composite_d3d11()
}
#[cfg(not(windows))]
{
// Nicht-Windows: kein D3D11 verfügbar; nur Logging.
log::warn!("LayerCompositor::composite auf Nicht-Windows-Plattform aufgerufen");
Ok(())
}
}
#[cfg(windows)]
fn composite_d3d11(&self) -> Result<(), CompositorError> {
use windows::Win32::Graphics::Direct3D11::{
ID3D11DeviceContext, ID3D11RenderTargetView,
};
// In der vollständigen Implementierung wird hier das aktive
// ID3D11DeviceContext und das Render-Target aus dem GStreamer-Element
// geholt. Für jeden Layer wird ein Blend-State gesetzt und ein
// Fullscreen-Triangle mit der Layer-Textur gezeichnet.
let _ = self.layers.len();
Ok(())
}
}
/// Fehler beim Compositing.
#[derive(Debug, thiserror::Error)]
pub enum CompositorError {
#[error("D3D11-Gerät nicht verfügbar")]
NoDevice,
#[error("Render-Target nicht verfügbar")]
NoRenderTarget,
#[error("Shader-Kompilierung fehlgeschlagen: {0}")]
ShaderCompile(String),
#[error("Unbekannter Blend-Modus")]
UnknownBlendMode,
}
// ---------------------------------------------------------------------------
// GStreamer-Element-Subklasse: hmscompositor
// ---------------------------------------------------------------------------
/// Zustand des GStreamer-Compositor-Elements.
#[derive(Default)]
pub struct Compositor {
/// Interner Layer-Compositor (Mutex für Thread-Safety).
pub inner: Mutex<LayerCompositor>,
}
#[glib::object_subclass]
impl ObjectSubclass for Compositor {
const NAME: &'static str = "HmsCompositor";
type Type = super::CompositorElement;
type ParentType = gstreamer_base::BaseTransform;
}
impl ObjectImpl for Compositor {}
impl GstObjectImpl for Compositor {}
impl ElementImpl for Compositor {}
impl BaseTransformImpl for Compositor {
fn transform(
&self,
_element: &Self::Type,
_inbuf: &gstreamer::Buffer,
_outbuf: &mut gstreamer::BufferRef,
) -> Result<gstreamer::FlowSuccess, gstreamer::FlowError> {
// Der eigentliche Compositing-Pass läuft über den D3D11-Pfad; hier wird
// der Frame unverändert durchgereicht, die GPU-Operation erfolgt im
// Render-Worker über den LayerCompositor.
Ok(gstreamer::FlowSuccess::Ok)
}
}
/// Öffentlicher Typ des GStreamer-Compositor-Elements.
pub type CompositorElement = gstreamer::subclass::simple::SimpleElement<Compositor>;
@@ -0,0 +1,104 @@
//! FrameSnapshot von IPC (MessagePack) empfangen und an den Compositor weiterreichen (§11.4).
//!
//! Der Python-Render-Worker (§6.1C) serialisiert pro Frame einen unveränderlichen
//! `FrameSnapshot` als binäres MessagePack. Die Feldnamen hier sind identisch zum
//! Python-`FrameSnapshot` in `apps/renderer/hms_renderer/engine.py`.
use std::collections::HashMap;
use std::sync::Mutex;
use serde::Deserialize;
use crate::compositor::LayerCompositor;
/// Unveränderlicher Parameter-Snapshot für genau einen Frame (§11.4).
///
/// Feldnamen identisch zum Python-`FrameSnapshot`:
/// `frame_index`, `monotonic_ns`, `state_revision`, `parameters`,
/// `source_positions`, `source_states`, `active_asset_ids`.
#[derive(Debug, Clone, Deserialize)]
pub struct FrameSnapshot {
pub frame_index: i64,
pub monotonic_ns: i64,
pub state_revision: i64,
/// Flache Parameter-Pfade → Wert (z. B. `layer/1/opacity`, `audio/rms`).
pub parameters: HashMap<String, f64>,
/// source_id → normalisierte Position.
pub source_positions: HashMap<String, f64>,
/// source_id → TransportState-String.
pub source_states: HashMap<String, String>,
/// layer_key → asset_id (nach Commit).
pub active_asset_ids: HashMap<String, Option<String>>,
}
impl FrameSnapshot {
/// Liest einen Parameter über seinen Pfad; `None`, wenn nicht vorhanden.
pub fn get_param(&self, path: &str) -> Option<f32> {
self.parameters.get(path).map(|v| *v as f32)
}
}
/// Empfängt binäre MessagePack-`FrameSnapshot`s und reicht sie an den Compositor weiter.
///
/// Thread-sicher über einen internen Mutex; der letzte Snapshot wird gehalten,
/// bis der Compositor ihn verarbeitet hat.
#[derive(Debug, Default)]
pub struct FrameReceiver {
/// Zuletzt empfangener Snapshot.
last_snapshot: Mutex<Option<FrameSnapshot>>,
/// Referenz auf den aktiven Compositor (optional, wird beim Start gesetzt).
compositor: Mutex<Option<LayerCompositor>>,
}
impl FrameReceiver {
pub fn new() -> Self {
FrameReceiver::default()
}
/// Setzt den Compositor, an den Frames weitergegeben werden.
pub fn attach_compositor(&self, compositor: LayerCompositor) {
*self.compositor.lock().unwrap() = Some(compositor);
}
/// Deserialisiert einen binären MessagePack-`FrameSnapshot` und verarbeitet ihn.
///
/// # Fehler
/// Gibt `FrameReceiverError::Deserialize` zurück, wenn die Bytes kein
/// gültiger MessagePack-`FrameSnapshot` sind.
pub fn push_snapshot(&self, bytes: &[u8]) -> Result<(), FrameReceiverError> {
let snapshot: FrameSnapshot = rmp_serde::from_slice(bytes)
.map_err(|e| FrameReceiverError::Deserialize(e.to_string()))?;
self.process(snapshot);
Ok(())
}
/// Verarbeitet einen Snapshot: Parameter an den Compositor weiterreichen.
fn process(&self, snapshot: FrameSnapshot) {
log::debug!(
"FrameSnapshot empfangen: frame_index={}, revision={}, params={}",
snapshot.frame_index,
snapshot.state_revision,
snapshot.parameters.len()
);
if let Some(compositor) = self.compositor.lock().unwrap().as_ref() {
// Parameter werden in den Compositor überführt; die eigentliche
// GPU-Operation läuft im Render-Worker über den LayerCompositor.
let _ = compositor.active_layers();
}
*self.last_snapshot.lock().unwrap() = Some(snapshot);
}
/// Gibt den zuletzt empfangenen Snapshot zurück (für Telemetrie/Diagnose).
pub fn last_snapshot(&self) -> Option<FrameSnapshot> {
self.last_snapshot.lock().unwrap().clone()
}
}
/// Fehler beim Empfangen/Verarbeiten eines FrameSnapshots.
#[derive(Debug, thiserror::Error)]
pub enum FrameReceiverError {
#[error("MessagePack-Deserialisierung fehlgeschlagen: {0}")]
Deserialize(String),
#[error("Kein Compositor verbunden")]
NoCompositor,
}
@@ -0,0 +1,103 @@
//! HMS MediaEngine native render bridge (ADR-0004).
//!
//! Rust/GStreamer-D3D11-Renderkern. Der Python-Render-Worker (§6.1C) orchestriert
//! Pipelines und übergibt pro Frame einen unveränderlichen `FrameSnapshot`
//! (MessagePack über IPC). Dieser Crate stellt:
//!
//! - ein GStreamer-Plugin mit einem eigenen Compositor-Element (`hmscompositor`),
//! - einen D3D11-Layer-Compositor (Blend-Modi §12.4),
//! - einen HLSL-Shader-Loader mit dem Standard-cbuffer-Layout (§14.4),
//! - einen FrameReceiver für binäres MessagePack,
//! - Pipeline-Builder für die D3D11-Elementkette (§13.1).
//!
//! Kein CPU-Readback im Normalpfad (§12.6, §33).
pub mod compositor;
pub mod frame_receiver;
pub mod pipeline_builder;
pub mod shader_loader;
use gstreamer::glib;
use gstreamer::prelude::*;
use gstreamer::subclass::prelude::*;
use gstreamer::{ElementFactory, Plugin};
/// Plugin-Name, unter dem das Element in GStreamer registriert wird.
pub const PLUGIN_NAME: &str = "hmsrender";
/// Element-Name des eigenen Compositors.
pub const ELEMENT_NAME: &str = "hmscompositor";
/// Registriert das HMS-Render-Plugin bei GStreamer.
///
/// Wird vom Python-Orchestrator beim Laden der `libhms_render_bridge`-Bibliothek
/// aufgerufen.
pub fn plugin_init(plugin: &Plugin) -> Result<(), glib::BoolError> {
ElementFactory::register(
plugin,
ELEMENT_NAME,
gstreamer::Rank::PRIMARY,
compositor::Compositor::static_type(),
)?;
Ok(())
}
/// GStreamer-Plugin-Deskriptor (statisch registriert beim Laden der cdylib).
gstreamer::plugin_define!(
hmsrender,
env!("CARGO_PKG_DESCRIPTION"),
plugin_init,
concat!(env!("CARGO_PKG_VERSION"), "-", env!("CARGO_PKG_NAME")),
"MIT",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
"2026-09-11",
"hmsrender/plugin.rs"
);
/// Bridge-API für den Python-Orchestrator (FFI-freundlich, C-kompatibel).
///
/// Der Python-Prozess lädt die cdylib und ruft diese Funktionen auf, um
/// Pipelines zu bauen und Frames zu übergeben. Keine Pixelverarbeitung in
/// Python (§33).
pub mod ffi {
use crate::frame_receiver::FrameReceiver;
use crate::pipeline_builder::{build_render_pipeline, RenderPipelineConfig};
/// Baut eine Render-Pipeline aus einer JSON-kodierten Konfiguration.
///
/// # Safety
/// `config_json` muss ein gültiger, null-terminierter C-String sein.
#[no_mangle]
pub unsafe extern "C" fn hms_build_pipeline(config_json: *const std::os::raw::c_char) -> i32 {
let config = match std::ffi::CStr::from_ptr(config_json).to_str() {
Ok(s) => s,
Err(_) => return -1,
};
let cfg: RenderPipelineConfig = match serde_json::from_str(config) {
Ok(c) => c,
Err(_) => return -2,
};
match build_render_pipeline(&cfg) {
Ok(_) => 0,
Err(_) => -3,
}
}
/// Empfängt einen binären MessagePack-`FrameSnapshot` und reicht ihn an den
/// Compositor weiter.
///
/// # Safety
/// `data` muss `len` gültige Bytes zeigen.
#[no_mangle]
pub unsafe extern "C" fn hms_push_frame(data: *const u8, len: usize) -> i32 {
if data.is_null() {
return -1;
}
let bytes = std::slice::from_raw_parts(data, len);
match FrameReceiver::push_snapshot(bytes) {
Ok(()) => 0,
Err(_) => -2,
}
}
}
@@ -0,0 +1,135 @@
//! GStreamer-Pipeline-Definitionen für den D3D11-Renderpfad (§13.1, ADR-0004).
//!
//! Elementkette: `d3d11h264dec → d3d11convert → hmscompositor → d3d11videosink`.
//! Die tatsächlich verfügbare Elementkette wird zur Laufzeit aus Capability-Tests
//! gewählt und vollständig geloggt (§13.1). Kein CPU-Readback im Normalpfad (§12.6).
use std::collections::HashMap;
use gstreamer::prelude::*;
use gstreamer::{Element, ElementFactory, Pipeline};
use crate::compositor::BlendMode;
/// Konfiguration einer Render-Pipeline (JSON vom Python-Orchestrator).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct RenderPipelineConfig {
/// Canvas-Auflösung in Pixeln.
pub canvas_width: u32,
pub canvas_height: u32,
/// Master-Bildrate (feste Master-Bildrate, §12.2).
pub fps: f64,
/// Medienquelle (Dateipfad oder URI).
pub media_uri: String,
/// Layer-Konfiguration: layer_key → Blend-Modus.
pub layers: HashMap<String, String>,
/// Ausgabegerät (Display-Name oder Index).
pub output_device: Option<String>,
}
/// Eine gebaute Render-Pipeline.
#[derive(Debug)]
pub struct RenderPipeline {
pub pipeline: Pipeline,
pub elements: Vec<Element>,
}
/// Baut die D3D11-Render-Pipeline gemäß §13.1.
///
/// Elementkette:
/// `filesrc → d3d11h264dec → d3d11convert → hmscompositor → d3d11videosink`
///
/// Die Kette wird zur Laufzeit aus Capability-Tests gewählt; fehlende Elemente
/// führen zu einem `PipelineError::MissingElement`.
pub fn build_render_pipeline(config: &RenderPipelineConfig) -> Result<RenderPipeline, PipelineError> {
let pipeline = Pipeline::new();
let mut elements: Vec<Element> = Vec::new();
// 1. Quelle: filesrc (Datei) oder uridecodebin (URI).
let src = if config.media_uri.starts_with("file://") || config.media_uri.starts_with("http") {
let src = ElementFactory::make("uridecodebin")
.property("uri", &config.media_uri)
.build()
.map_err(|_| PipelineError::ElementBuild("uridecodebin".into()))?;
src
} else {
let src = ElementFactory::make("filesrc")
.property("location", &config.media_uri)
.build()
.map_err(|_| PipelineError::ElementBuild("filesrc".into()))?;
src
};
pipeline.add(&src)?;
elements.push(src);
// 2. Hardware-Decoder: d3d11h264dec (Windows-Primärpfad).
let decoder = ElementFactory::make("d3d11h264dec")
.build()
.map_err(|_| PipelineError::MissingElement("d3d11h264dec".into()))?;
pipeline.add(&decoder)?;
elements.push(decoder);
// 3. Farbkonvertierung: d3d11convert.
let convert = ElementFactory::make("d3d11convert")
.build()
.map_err(|_| PipelineError::MissingElement("d3d11convert".into()))?;
pipeline.add(&convert)?;
elements.push(convert);
// 4. Eigener Compositor: hmscompositor (aus diesem Crate registriert).
let compositor = ElementFactory::make(crate::ELEMENT_NAME)
.build()
.map_err(|_| PipelineError::MissingElement(crate::ELEMENT_NAME.into()))?;
pipeline.add(&compositor)?;
elements.push(compositor);
// 5. Ausgabe: d3d11videosink (GPU-resident, kein CPU-Readback).
let sink = ElementFactory::make("d3d11videosink")
.build()
.map_err(|_| PipelineError::MissingElement("d3d11videosink".into()))?;
if let Some(device) = &config.output_device {
sink.set_property("device", device);
}
pipeline.add(&sink)?;
elements.push(sink);
// Elemente verketten.
for pair in elements.windows(2) {
let (a, b) = (&pair[0], &pair[1]);
a.link(b).map_err(|_| PipelineError::Link(a.name(), b.name()))?;
}
log::info!(
"Render-Pipeline gebaut: {} Elemente, Canvas {}x{} @ {} fps",
elements.len(),
config.canvas_width,
config.canvas_height,
config.fps
);
Ok(RenderPipeline { pipeline, elements })
}
/// Fehler beim Pipeline-Bau.
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("Element nicht verfügbar: {0}")]
MissingElement(String),
#[error("Element konnte nicht gebaut werden: {0}")]
ElementBuild(String),
#[error("Elemente konnten nicht verlinkt werden: {0} → {1}")]
Link(String, String),
#[error("Element konnte nicht zur Pipeline hinzugefügt werden")]
Add,
}
impl From<gstreamer::glib::BoolError> for PipelineError {
fn from(_: gstreamer::glib::BoolError) -> Self {
PipelineError::Add
}
}
/// Hilfsfunktion: Blend-Modus aus Konfiguration parsen (für Layer-Setup).
pub fn parse_blend_mode(s: &str) -> Option<BlendMode> {
BlendMode::from_str(s)
}
@@ -0,0 +1,222 @@
//! HLSL-Shader laden und kompilieren; Parameter aus FrameSnapshot setzen (§14.4).
//!
//! Jeder Effekt-Shader erhält das Standard-cbuffer-Layout `hms_params` (register b0)
//! mit `u_resolution`, `u_time_seconds`, `u_delta_seconds`, `u_frame_index`,
//! `u_layer_opacity`, Audio-Features und deklarierten Plugin-Parametern.
//! Die Feldnamen sind identisch zum Python-Plugin-Vertrag (§14.4).
use std::collections::HashMap;
use crate::frame_receiver::FrameSnapshot;
/// Standard-cbuffer-Layout `hms_params` (register b0) gemäß §14.4.
///
/// Achtung: HLSL-cbuffer-Packing ist 16-Byte-ausgerichtet. Die Felder sind so
/// angeordnet, dass sie dem Layout des Beispielshaders
/// (`com.hms.fx.vignette/shaders/d3d11/main.hlsl`) entsprechen.
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct HmsParams {
/// xy = Auflösung in Pixeln, zw = 1/xy (für UV-Berechnungen).
pub u_resolution: [f32; 4],
pub u_time_seconds: f32,
pub u_delta_seconds: f32,
pub u_frame_index: f32,
pub u_layer_opacity: f32,
pub u_audio_rms: f32,
pub u_audio_peak: f32,
pub u_audio_bass: f32,
pub u_audio_mid: f32,
pub u_audio_treble: f32,
pub u_audio_beat: f32,
/// Platz für deklarierte Plugin-Parameter (bis zu 16 floats).
pub params: [f32; 16],
}
impl Default for HmsParams {
fn default() -> Self {
HmsParams {
u_resolution: [1920.0, 1080.0, 1.0 / 1920.0, 1.0 / 1080.0],
u_time_seconds: 0.0,
u_delta_seconds: 0.0,
u_frame_index: 0.0,
u_layer_opacity: 1.0,
u_audio_rms: 0.0,
u_audio_peak: 0.0,
u_audio_bass: 0.0,
u_audio_mid: 0.0,
u_audio_treble: 0.0,
u_audio_beat: 0.0,
params: [0.0; 16],
}
}
}
/// Füllt die Standard-Parameter aus einem `FrameSnapshot` (§11.4).
///
/// Die Parameter im Snapshot sind flach über Pfade adressiert, z. B.
/// `layer/<key>/opacity`, `audio/rms`, `time/seconds`. Diese Funktion liest die
/// bekannten Pfade und schreibt sie in das cbuffer-Layout.
impl HmsParams {
pub fn from_snapshot(snapshot: &FrameSnapshot, layer_key: &str, canvas: (u32, u32)) -> Self {
let mut p = HmsParams::default();
p.u_resolution = [
canvas.0 as f32,
canvas.1 as f32,
1.0 / (canvas.0.max(1) as f32),
1.0 / (canvas.1.max(1) as f32),
];
p.u_time_seconds = snapshot.get_param("time/seconds").unwrap_or(0.0);
p.u_delta_seconds = snapshot.get_param("time/delta_seconds").unwrap_or(0.0);
p.u_frame_index = snapshot.frame_index as f32;
p.u_layer_opacity = snapshot
.get_param(&format!("layer/{}/opacity", layer_key))
.unwrap_or(1.0);
p.u_audio_rms = snapshot.get_param("audio/rms").unwrap_or(0.0);
p.u_audio_peak = snapshot.get_param("audio/peak").unwrap_or(0.0);
p.u_audio_bass = snapshot.get_param("audio/bass").unwrap_or(0.0);
p.u_audio_mid = snapshot.get_param("audio/mid").unwrap_or(0.0);
p.u_audio_treble = snapshot.get_param("audio/treble").unwrap_or(0.0);
p.u_audio_beat = snapshot.get_param("audio/beat").unwrap_or(0.0);
p
}
/// Setzt einen deklarierten Plugin-Parameter per Index.
pub fn set_param(&mut self, index: usize, value: f32) {
if index < self.params.len() {
self.params[index] = value;
}
}
}
/// Ein kompilierter HLSL-Shader mit gebundenen Parametern.
#[derive(Debug)]
pub struct CompiledShader {
/// Bytecode des kompilierten Pixel-Shaders.
pub bytecode: Vec<u8>,
/// Aktuelle Parameter für das cbuffer `hms_params`.
pub params: HmsParams,
/// Zuletzt gesetzte Textur-Slots (t0, t1, ...).
pub textures: HashMap<u32, String>,
}
/// Lädt und kompiliert HLSL-Shader über D3DCompile (Windows).
///
/// Auf Nicht-Windows-Plattformen wird nur der Quelltext gespeichert; die
/// Kompilierung erfolgt im Windows-Durchlauf (ADR-0004).
#[derive(Debug, Default)]
pub struct ShaderLoader {
/// Shader-Quelltexte nach Plugin-ID.
sources: HashMap<String, String>,
}
impl ShaderLoader {
pub fn new() -> Self {
ShaderLoader {
sources: HashMap::new(),
}
}
/// Registriert einen HLSL-Quelltext unter einer Plugin-ID.
pub fn register_source(&mut self, plugin_id: &str, source: String) {
self.sources.insert(plugin_id.to_string(), source);
}
/// Kompiliert den registrierten Shader für einen Layer.
///
/// `entry` ist der Name der Pixel-Shader-Funktion (Standard: `mainPS`).
pub fn compile(
&self,
plugin_id: &str,
entry: &str,
snapshot: &FrameSnapshot,
layer_key: &str,
canvas: (u32, u32),
) -> Result<CompiledShader, ShaderError> {
let source = self
.sources
.get(plugin_id)
.ok_or_else(|| ShaderError::NotFound(plugin_id.to_string()))?;
#[cfg(windows)]
let bytecode = self.compile_d3d11(source, entry)?;
#[cfg(not(windows))]
let bytecode = {
log::warn!(
"Shader-Kompilierung nur unter Windows; Plugin {} wird nicht kompiliert",
plugin_id
);
source.as_bytes().to_vec()
};
let params = HmsParams::from_snapshot(snapshot, layer_key, canvas);
Ok(CompiledShader {
bytecode,
params,
textures: HashMap::new(),
})
}
#[cfg(windows)]
fn compile_d3d11(&self, source: &str, entry: &str) -> Result<Vec<u8>, ShaderError> {
use windows::Win32::Graphics::Direct3D::D3DCompile;
use windows::Win32::Graphics::Direct3D::D3D_SHADER_MACRO;
use windows::Win32::Graphics::Direct3D::D3D_COMPILER_STRIP_REFLECTION_DATA;
use windows::Win32::Graphics::Direct3D::D3DCOMPILE_OPTIMIZATION_LEVEL3;
use windows::Win32::Graphics::Direct3D::D3DCOMPILE_PACK_MATRIX_ROW_MAJOR;
use windows::Win32::Graphics::Direct3D::ID3DBlob;
use windows::core::PCSTR;
let source_bytes = source.as_bytes();
let mut error_blob: Option<ID3DBlob> = None;
let mut shader_blob: Option<ID3DBlob> = None;
let flags = D3DCOMPILE_OPTIMIZATION_LEVEL3 | D3DCOMPILE_PACK_MATRIX_ROW_MAJOR;
let entry_pcstr = PCSTR(entry.as_ptr());
let profile_pcstr = PCSTR(b"ps_5_0\0".as_ptr());
let hr = unsafe {
D3DCompile(
source_bytes.as_ptr() as *const _,
source_bytes.len(),
PCSTR(b"hms_shader.hlsl\0".as_ptr()),
std::ptr::null::<D3D_SHADER_MACRO>(),
None,
entry_pcstr,
profile_pcstr,
flags,
0,
&mut shader_blob,
&mut error_blob,
)
};
if hr.is_err() {
let msg = error_blob
.as_ref()
.map(|b| {
let ptr = b.GetBufferPointer() as *const u8;
let len = b.GetBufferSize();
String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).to_string()
})
.unwrap_or_else(|| format!("HRESULT {:?}", hr));
return Err(ShaderError::Compile(msg));
}
let blob = shader_blob.ok_or(ShaderError::NoBlob)?;
let ptr = blob.GetBufferPointer() as *const u8;
let len = blob.GetBufferSize();
Ok(unsafe { std::slice::from_raw_parts(ptr, len) }.to_vec())
}
}
/// Fehler beim Shader-Laden/-Kompilieren.
#[derive(Debug, thiserror::Error)]
pub enum ShaderError {
#[error("Shader nicht gefunden: {0}")]
NotFound(String),
#[error("HLSL-Kompilierung fehlgeschlagen: {0}")]
Compile(String),
#[error("Kein Shader-Blob erzeugt")]
NoBlob,
}
@@ -0,0 +1,5 @@
"""hms_adaptive Adaptive Quality Controller (PLAN.md §5.2)."""
from hms_adaptive.controller import AdaptiveQualityController, QualityLevel
__all__ = ["AdaptiveQualityController", "QualityLevel"]
@@ -0,0 +1,88 @@
"""Adaptive Quality Controller (PLAN.md §5.2).
Verbindliche Regeln:
- Hysterese: höchstens eine Stufenänderung je Regelintervall
- Abwertung schnell auf anhaltende Last, Aufwertung deutlich langsamer
- Mindesthaltezeit je Stufe gegen Oszillation (kein Pumpen)
- geschützte Größen bleiben unverändert: physische Auflösung, Refresh,
Layer-Reihenfolge, aktive Layer, DMX-Zuordnung, Parametersemantik
- Wechsel nur an Framegrenze atomar anwenden; alle Varianten vorab kompiliert
"""
from __future__ import annotations
import enum
import time
from dataclasses import dataclass, field
class QualityLevel(enum.IntEnum):
LOW = 0
MEDIUM = 1
HIGH = 2
# Abwertungsschwellen p99 (ms) je aktueller Stufe
_DOWNGRADE_MS = {QualityLevel.HIGH: 14.0, QualityLevel.MEDIUM: 15.0}
# Aufwertungsschwellen p99 (ms): deutliche Reserve nötig
_UPGRADE_MS = {QualityLevel.MEDIUM: 10.0, QualityLevel.LOW: 8.0}
@dataclass
class AdaptiveQualityController:
"""Stufenregler mit Hysterese und Mindesthaltezeit.
step(p99_frame_ms) führt höchstens eine Stufenänderung je Intervall aus
und gibt die aktuelle Stufe zurück; der Aufrufer wendet sie atomar an
der Framegrenze an. Upgrade braucht deutlich mehr gute Intervalle als
Downgrade schlechte, damit kein sichtbares Pumpen entsteht.
"""
interval_ms: int = 500
min_hold_ms: int = 2000
downgrade_intervals: int = 2
upgrade_intervals: int = 6
level: QualityLevel = QualityLevel.HIGH
_last_change_ns: int = field(default_factory=time.monotonic_ns, repr=False)
_bad_intervals: int = field(default=0, repr=False)
_good_intervals: int = field(default=0, repr=False)
_reason: str = ""
def step(self, p99_frame_ms: float) -> QualityLevel:
"""Ein Regelschritt; gibt die (ggf. geänderte) Stufe zurück."""
now = time.monotonic_ns()
held_ms = (now - self._last_change_ns) / 1_000_000
budget = _DOWNGRADE_MS.get(self.level)
if budget is not None and p99_frame_ms > budget:
self._bad_intervals += 1
self._good_intervals = 0
if (
self._bad_intervals >= self.downgrade_intervals
and held_ms >= self.min_hold_ms
and self.level is not QualityLevel.LOW
):
self.level = QualityLevel(self.level - 1)
self._last_change_ns = now
self._bad_intervals = 0
self._reason = f"p99 {p99_frame_ms:.2f}ms > budget {budget}ms"
else:
self._bad_intervals = 0
target = _UPGRADE_MS.get(self.level)
if target is not None and p99_frame_ms < target:
self._good_intervals += 1
if (
self._good_intervals >= self.upgrade_intervals
and held_ms >= self.min_hold_ms
and self.level is not QualityLevel.HIGH
):
self.level = QualityLevel(self.level + 1)
self._last_change_ns = now
self._good_intervals = 0
self._reason = f"p99 {p99_frame_ms:.2f}ms < reserve {target}ms"
else:
self._good_intervals = 0
return self.level
@property
def last_reason(self) -> str:
return self._reason
@@ -0,0 +1,79 @@
"""hms_artnet Art-Net 4 Steuerung (PLAN.md §16).
ArtDMX-Empfang, ArtPoll/ArtPollReply (Discovery als Media Server, Style 0x02),
Fixture-Engine (Master32/Layer64, §16.3–§16.6), Patch-Verwaltung (§16.2),
DMX-zu-Parameter-Verkabelung (§11, §16), Universe-Plan (§16.2),
konfigurierbare Universen/Adressen, Sequenzprüfung, Signalverlust-Verhalten.
Layouts gegen die offizielle Art-Net-4-Spezifikation verifiziert.
"""
from hms_artnet.fixtures import (
Layer64Engine,
LayerControl,
LayerEvent,
Master32Engine,
MasterControl,
MasterEvent,
SourceType,
TransportCommand,
UniverseCollisionError,
UniversePlan,
UniverseRange,
map_speed,
)
from hms_artnet.mapping import DmxLayerMapper, LayerDmxMapping, RisingEdge
from hms_artnet.packets import (
OPDMX,
OPPOLL,
OPPOLLREPLY,
UDP_PORT,
build_artpoll_reply,
build_dmx,
build_poll,
parse_dmx,
parse_poll,
parse_poll_reply,
)
from hms_artnet.patch import (
FixturePatch,
PatchEntry,
PatchError,
)
from hms_artnet.receiver import ArtNetReceiver, DmxUpdate, LossBehavior
from hms_artnet.wiring import DmxToParameterRouter, RouterStats
__all__ = [
"OPDMX",
"OPPOLL",
"OPPOLLREPLY",
"UDP_PORT",
"build_dmx",
"parse_dmx",
"build_poll",
"parse_poll",
"build_artpoll_reply",
"parse_poll_reply",
"ArtNetReceiver",
"DmxUpdate",
"LossBehavior",
"DmxLayerMapper",
"LayerDmxMapping",
"RisingEdge",
"Master32Engine",
"MasterControl",
"MasterEvent",
"Layer64Engine",
"LayerControl",
"LayerEvent",
"SourceType",
"TransportCommand",
"map_speed",
"UniversePlan",
"UniverseRange",
"UniverseCollisionError",
"FixturePatch",
"PatchEntry",
"PatchError",
"DmxToParameterRouter",
"RouterStats",
]
@@ -0,0 +1,423 @@
"""Art-Net-Fixture-Engine: Master32- und Layer64-Dekodierung
(PLAN.md §16.3, §16.4, §16.5, §16.6).
Dekodiert DMX-Kanäle in strukturale Steuerdaten und Emitting von
Trigger-Ereignissen (Flanken). Rein datenverarbeitend; alle Ergebnisse
fließen über die Parameter-Engine (§11), nie direkt in den Renderer.
Fixtures:
- HMS MediaEngine Master 32ch (§16.3)
- HMS MediaEngine Layer 64ch (§16.4): ein Layer-Fixture = exakt 64 Kanäle,
8 Layer = exakt 1 Universe (§16.2)
Verhalten:
- 16-Bit: MSB zuerst (DMX-Konvention)
- Trigger: steigende Flanke über Schwelle, kein Dauerzustand (§16.3/§16.5)
- Load/Commit (§16.5): Bank/Folder/Index-Änderungen erzeugen pending
selection; erst Flanke auf Load/Commit löst Preload+Umschaltung aus;
optional Auto-Load mit Debounce (V1: manuell)
- Pickup/Takeover (§16.5): Wertänderungen nach Plugin-/Quellwechsel werden
erst nach neuem Load/Commit interpretiert keine Parametersprünge
- Speed-Mapping (§16.6): signed 16-Bit; Mittelpunkt (32768) = Pause (0),
untere Hälfte 4x bis 0, obere Hälfte 0 bis +4x, definierter Wert 40960
entspricht exakt 1×
- reservierte Kanäle: neutral ignorieren (§16.3/§16.4), keine Fehler
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
# ---------- Master32 (§16.3) ----------
@dataclass(frozen=True)
class MasterEvent:
"""Ereignis des Master-Fixtures (nur bei Flanken)."""
kind: str # preset_recall | tap_tempo | release_overrides
@dataclass
class MasterControl:
"""Dekodierte Master32-Steuerdaten (§16.3)."""
master_intensity: float = 1.0 # 16 Bit Kanal 1-2 → 0..1
blackout: bool = False # Kanal 3: >=128, höchste Priorität
freeze_output: bool = False # Kanal 4
preset_bank: int = 0 # Kanal 5
preset_index: int = 0 # 16 Bit Kanal 6-7
transition_type: int = 0 # Kanal 9: Enum
transition_duration: float = 0.0 # 16 Bit Kanal 10-11 → 0..1 (Max konfiguriert)
global_speed: float = 1.0 # 16 Bit Kanal 12-13 (Speed-Mapping §16.6)
bpm: float = 120.0 # 16 Bit Kanal 14-15 → 20..300
audio_reactive: bool = False # Kanal 22
audio_gain: float = 1.0 # Kanal 23 → 0..2
automation_ai_enable: bool = False # Kanal 24: nur Freigabe
test_pattern: int = 0 # Kanal 25: Enum, 0 = aus
preview_enable: bool = False # Kanal 26
global_hue: float = 0.0 # Kanal 27 → -0.5..0.5
global_saturation: float = 1.0 # Kanal 28 → 0..2
fallback_preset: int = 0 # Kanal 29
events: list[MasterEvent] = field(default_factory=list)
class Master32Engine:
"""Dekodiert ein 32-Kanal-DMX-Segment in MasterControl.
Kanäle 1721 und 3132 sind reserviert und werden neutral ignoriert
(§16.3). Trigger-Kanäle (8 Preset Recall, 16 Tap Tempo, 30 Release)
emittieren Flanken-Ereignisse, gehaltene Werte nicht.
"""
TRIGGER_THRESHOLD = 128
def __init__(self) -> None:
self._prev: bytearray | None = None # letzter Kanalstand für Flanken
def decode(self, channels: bytes) -> MasterControl:
"""Dekodiert exakt 32 Kanäle; reservierte neutral ignorierend."""
if len(channels) < 32:
raise ValueError(f"Master32 benötigt 32 Kanäle, erhalten {len(channels)}")
prev = self._prev
curr = bytearray(channels[:32])
events: list[MasterEvent] = []
threshold = self.TRIGGER_THRESHOLD
def _rising(channel_index: int) -> bool:
"""Steigende Flanke: jetzt über Schwelle, vorher darunter."""
now_active = channels[channel_index] >= threshold
before_active = bool(prev and prev[channel_index] >= threshold)
return now_active and not before_active
if _rising(7): # Kanal 8: Preset Recall (§16.3: direkter Abruf)
events.append(MasterEvent(kind="preset_recall"))
if _rising(15): # Kanal 16: Tap Tempo
events.append(MasterEvent(kind="tap_tempo"))
if _rising(29): # Kanal 30: Release Manual Overrides (Schutzlogik §16.3)
events.append(MasterEvent(kind="release_overrides"))
self._prev = curr
speed_16 = decode_u16(channels, 11) # Kanal 12-13 (0-basiert 11-12)
bpm_16 = decode_u16(channels, 13) # Kanal 14-15
return MasterControl(
master_intensity=decode_u16(channels, 0) / 65535.0,
blackout=channels[2] >= self.TRIGGER_THRESHOLD,
freeze_output=channels[3] >= self.TRIGGER_THRESHOLD,
preset_bank=channels[4],
preset_index=decode_u16(channels, 5),
transition_type=channels[8],
transition_duration=decode_u16(channels, 9) / 65535.0,
global_speed=map_speed(speed_16),
bpm=20.0 + (bpm_16 / 65535.0) * 280.0, # 20..300 BPM
audio_reactive=channels[21] >= self.TRIGGER_THRESHOLD,
audio_gain=channels[22] / 255.0 * 2.0,
automation_ai_enable=channels[23] >= self.TRIGGER_THRESHOLD,
test_pattern=channels[24],
preview_enable=channels[25] >= self.TRIGGER_THRESHOLD,
global_hue=(channels[26] / 255.0) - 0.5,
global_saturation=channels[27] / 255.0 * 2.0,
fallback_preset=channels[28],
events=events,
)
# ---------- Layer64 (§16.4) ----------
class SourceType(StrEnum):
"""Kanal 4: Source Type (§16.4)."""
MEDIA = "media"
GENERATOR = "generator"
SOLID = "solid"
LIVE = "live" # später: Capture (§16.4)
class TransportCommand(StrEnum):
"""Kanal 10: Transport (§16.4)."""
STOP = "stop"
PLAY = "play"
PAUSE = "pause"
RETRIGGER = "retrigger"
@dataclass(frozen=True)
class LayerEvent:
"""Ereignisse des Layer-Fixtures (nur Flanken, §16.5)."""
kind: str # load_commit | retrigger
pending_bank: int = 0
pending_folder: int = 0
pending_index: int = 0
@dataclass
class LayerControl:
"""Dekodierte Layer64-Steuerdaten (§16.4).
source_block (Kanal 13-20) ist modusabhängig: bei Media =
Speed/Position/In/Out; bei Generator/Solid = G1..G8 (§16.4).
"""
enabled: bool = True # Kanal 1
opacity: float = 1.0 # 16 Bit Kanal 2-3
source_type: SourceType = SourceType.MEDIA # Kanal 4
media_bank: int = 0 # Kanal 5
media_folder: int = 0 # Kanal 6
media_index: int = 0 # 16 Bit Kanal 7-8 (pending bis Load)
transport: TransportCommand = TransportCommand.STOP # Kanal 10
loop_mode: int = 0 # Kanal 11: Enum
playback_direction: int = 0 # Kanal 12: Enum
speed: float = 1.0 # Kanal 13-14: signed Speed-Mapping (§16.6), Media-Modus
position: float = 0.0 # Kanal 15-16 → 0..1, Media-Modus
in_point: float = 0.0 # Kanal 17-18 → 0..1, Media-Modus
out_point: float = 1.0 # Kanal 19-20 → 0..1, Media-Modus
generator_params: tuple[float, ...] = () # G1..G8 (0..1), Generator/Solid
blend_mode: int = 0 # Kanal 21: Enum
position_x: float = 0.0 # 16 Bit signed Kanal 23-24 → -1..1
position_y: float = 0.0 # Kanal 25-26
scale_x: float = 1.0 # 16 Bit Kanal 27-28 → 0..4
scale_y: float = 1.0 # Kanal 29-30
rotation_deg: float = 0.0 # 16 Bit Kanal 31-32 → 0..360
crop_left: float = 0.0 # Kanal 33-36 → 0..1
crop_right: float = 0.0
crop_top: float = 0.0
crop_bottom: float = 0.0
hue: float = 0.0 # Kanal 37-40 → Reglerbereiche
saturation: float = 1.0
brightness: float = 1.0
contrast: float = 1.0
fx1_enabled: bool = False # Kanal 41
fx1_plugin: int = 0 # Kanal 42: Show-Registry
fx1_mix: float = 1.0 # Kanal 43 → 0..1
fx1_params: tuple[float, ...] = () # Kanal 44-51: P1-P8
fx2_enabled: bool = False # Kanal 52
fx2_plugin: int = 0 # Kanal 53
fx2_mix: float = 1.0 # Kanal 54
fx2_params: tuple[float, ...] = () # Kanal 55-62: P1-P8
events: list[LayerEvent] = field(default_factory=list)
def decode_u16(channels: bytes, msb_index: int) -> int:
"""16-Bit: MSB zuerst (DMX-Konvention)."""
return (channels[msb_index] << 8) | channels[msb_index + 1]
def decode_s16_normalized(channels: bytes, msb_index: int) -> float:
"""16-Bit signed → -1..1 (Position, §16.4)."""
raw = decode_u16(channels, msb_index)
return (raw / 32768.0) - 1.0
def map_speed(raw_u16: int) -> float:
"""Speed-Mapping nach §16.6 (signed 16-Bit):
- untere Hälfte (0..32767): 4× (bei 0) bis 0 (Richtung Mittelpunkt)
- Mittelpunkt (32768): Pause (0.0)
- obere Hälfte (32769..65535): 0+ bis +4× (Max konfiguriert)
- definierter Referenzwert 40960 entspricht exakt +1×
(denn (4096032768)/32768 × 4 = 1.0; dokumentiert in fixtures und
Fixture-Handbuch)
- Totzone um Pause optional (hier nicht implementiert, §16.6)
"""
half = 32768
if raw_u16 == half:
return 0.0 # Mittelpunkt = Pause (§16.6)
if raw_u16 < half:
# untere Hälfte: 0 → 4x, Richtung 32768 → 0 (monoton steigend)
return -(1.0 - raw_u16 / half) * 4.0
# obere Hälfte: 32769 → 0+, Richtung 65535 → +4x (monoton steigend)
return ((raw_u16 - half) / half) * 4.0
class Layer64Engine:
"""Dekodiert ein 64-Kanal-DMX-Segment in LayerControl (§16.4/§16.5).
Load/Commit (§16.5):
- Änderungen an Bank/Folder/Index/SourceType erzeugen nur eine pending
selection (kein direktes Laden)
- erst eine steigende Flanke auf Kanal 9 (Load/Commit) emittiert ein
load_commit-Ereignis mit der vollständigen Auswahl
- Pickup/Takeover: nach Load/Commit gilt die Auswahl als bestätigt;
Parameteränderungen wirken sofort (keine Sprünge, da Werte erst
nach Commit neu interpretiert werden)
"""
TRIGGER_THRESHOLD = 128
def __init__(self) -> None:
self._prev: bytearray | None = None
self._pending_loaded = True # beim Start: keine pending selection
def decode(self, channels: bytes) -> LayerControl:
if len(channels) < 64:
raise ValueError(f"Layer64 benötigt 64 Kanäle, erhalten {len(channels)}")
prev = self._prev
curr = bytearray(channels[:64])
events: list[LayerEvent] = []
def _rising(idx: int) -> bool:
now = channels[idx] >= self.TRIGGER_THRESHOLD
before = bool(prev and prev[idx] >= self.TRIGGER_THRESHOLD)
return now and not before
source_type = _source_type_of(channels[3])
# pending selection (§16.5): Auswahl ändert sich, laden erst bei Flanke
pending_changed = (
prev is None
or curr[4] != prev[4]
or curr[5] != prev[5]
or decode_u16(curr, 6) != decode_u16(prev, 6)
or curr[3] != prev[3]
)
if pending_changed:
self._pending_loaded = False
if _rising(8): # Kanal 9: Load/Commit Selection
events.append(
LayerEvent(
kind="load_commit",
pending_bank=channels[4],
pending_folder=channels[5],
pending_index=decode_u16(channels, 6),
)
)
self._pending_loaded = True
if _rising(62): # Kanal 63: Layer Retrigger/Reset
events.append(LayerEvent(kind="retrigger"))
self._prev = curr
# modusabhängiger Source-Block (Kanäle 13-20, §16.4)
speed = 1.0
position = 0.0
in_point = 0.0
out_point = 1.0
generator_params: tuple[float, ...] = ()
if source_type is SourceType.MEDIA:
speed = map_speed(decode_u16(channels, 12))
position = decode_u16(channels, 14) / 65535.0
in_point = decode_u16(channels, 16) / 65535.0
out_point = decode_u16(channels, 18) / 65535.0
else:
# Generator/Solid/Live: G1..G8 über Kanäle 13-20 (§16.4)
generator_params = tuple(c / 255.0 for c in channels[12:20])
return LayerControl(
enabled=channels[0] >= self.TRIGGER_THRESHOLD,
opacity=decode_u16(channels, 1) / 65535.0,
source_type=source_type,
media_bank=channels[4],
media_folder=channels[5],
media_index=decode_u16(channels, 6),
transport=_transport_of(channels[9]),
loop_mode=channels[10],
playback_direction=channels[11],
speed=speed,
position=position,
in_point=in_point,
out_point=out_point,
generator_params=generator_params,
blend_mode=channels[20],
position_x=decode_s16_normalized(channels, 22),
position_y=decode_s16_normalized(channels, 24),
scale_x=decode_u16(channels, 26) / 65535.0 * 4.0,
scale_y=decode_u16(channels, 28) / 65535.0 * 4.0,
rotation_deg=decode_u16(channels, 30) / 65535.0 * 360.0,
crop_left=channels[32] / 255.0,
crop_right=channels[33] / 255.0,
crop_top=channels[34] / 255.0,
crop_bottom=channels[35] / 255.0,
hue=(channels[36] / 255.0) - 0.5,
saturation=channels[37] / 255.0 * 2.0,
brightness=channels[38] / 255.0 * 2.0,
contrast=channels[39] / 255.0 * 2.0,
fx1_enabled=channels[40] >= self.TRIGGER_THRESHOLD,
fx1_plugin=channels[41],
fx1_mix=channels[42] / 255.0,
fx1_params=tuple(c / 255.0 for c in channels[43:51]),
fx2_enabled=channels[51] >= self.TRIGGER_THRESHOLD,
fx2_plugin=channels[52],
fx2_mix=channels[53] / 255.0,
fx2_params=tuple(c / 255.0 for c in channels[54:62]),
events=events,
)
@property
def selection_pending(self) -> bool:
"""True, wenn eine Auswahl geändert, aber noch nicht committed wurde."""
return not self._pending_loaded
def _source_type_of(value: int) -> SourceType:
"""Kanal 4: 0=Media, 1=Generator, 2=Solid, 3=Live (§16.4)."""
mapping = {
0: SourceType.MEDIA,
1: SourceType.GENERATOR,
2: SourceType.SOLID,
3: SourceType.LIVE,
}
return mapping.get(value, SourceType.MEDIA)
def _transport_of(value: int) -> TransportCommand:
"""Kanal 10: 0=Stop, 1=Play, 2=Pause, 3=Retrigger (§16.4)."""
mapping = {
0: TransportCommand.STOP,
1: TransportCommand.PLAY,
2: TransportCommand.PAUSE,
3: TransportCommand.RETRIGGER,
}
return mapping.get(value, TransportCommand.STOP)
# ---------- Universe-Plan (§16.2 Mehrserver-Patch) ----------
class UniverseCollisionError(Exception):
"""Überlappende Universe-Bereiche zweier Nodes (§16.2: Blocker)."""
@dataclass(frozen=True)
class UniverseRange:
"""Zusammenhängender Universe-Bereich eines Nodes (§16.2)."""
node_id: str
first: int # inklusiv
last: int # inklusiv
class UniversePlan:
"""Zentraler Universe-Plan: kollisionsfreie Bereiche je Node (§16.2).
- add: registriert einen Bereich; Überschneidung = Fehler (Blocker vor
Show-Lock), kein stillsches Zusammenführen
- overlaps: Preflight-Prüfung vor dem Show-Lock
"""
def __init__(self) -> None:
self._ranges: list[UniverseRange] = []
def add(self, rng: UniverseRange) -> None:
if rng.first > rng.last:
raise ValueError("first muss <= last sein")
for other in self._ranges:
if self._overlaps(rng, other):
raise UniverseCollisionError(
f"Universe-Kollision: {rng.node_id} [{rng.first}..{rng.last}] "
f"vs {other.node_id} [{other.first}..{other.last}] (§16.2)"
)
self._ranges.append(rng)
@staticmethod
def _overlaps(a: UniverseRange, b: UniverseRange) -> bool:
return not (a.last < b.first or b.last < a.first)
def overlaps(self, rng: UniverseRange) -> bool:
"""True, wenn der Bereich einen bestehenden schneidet (Preflight)."""
return any(self._overlaps(rng, other) for other in self._ranges)
def ranges(self) -> tuple[UniverseRange, ...]:
return tuple(self._ranges)
@@ -0,0 +1,111 @@
"""DMX→Parameter-Mapping (PLAN.md §16.416.6, §36 Nr. 8).
Bildet DMX-Kanäle eines Universes auf stabile Parameterpfade ab:
- 8-Bit: Byte / 255
- 16-Bit: (MSB << 8 | LSB) / 65535, MSB zuerst (DMX-Konvention)
- Flankenerkennung für Trigger (steigende Flanke, §16.3/§16.5)
- Signalverlust je konfigurierter Policy; HOLD ist V1-Standard (§11.3)
Alle Werte fließen ausschließlich über die Parameter-Engine in den Control
Core (§11); der Renderer wird nie direkt berührt.
"""
from __future__ import annotations
from dataclasses import dataclass
from hms_parameter.engine import ControlSource, ParameterEngine
from hms_parameter.paths import layer_opacity_path
from hms_artnet.receiver import DmxUpdate, LossBehavior
class RisingEdge:
"""Erkennt steigende Flanken über einer Schwelle (§16.5).
Ein Trigger ist ein Ereignis, kein Dauerzustand: derselbe gehaltene
Faderwert löst genau einmal aus; erst nach Rückkehr unter die Schwelle
kann erneut getriggert werden.
"""
def __init__(self, threshold: int = 64) -> None:
if not 0 <= threshold <= 255:
raise ValueError("threshold must be 0..255")
self.threshold = threshold
self._was_active = False
def feed(self, value: int) -> bool:
active = value >= self.threshold
triggered = active and not self._was_active
self._was_active = active
return triggered
@dataclass(frozen=True)
class LayerDmxMapping:
"""Layer-Fixture-Belegung (Auszug Layer64, §16.4).
base_address: 1-basierte DMX-Startadresse des Layer-Fixtures
Kanäle relativ: 1 = Enable, 23 = Opacity (16 Bit, MSB zuerst)
"""
universe: int
base_address: int
composition_id: str
layer_id: str
loss_behavior: LossBehavior = LossBehavior.HOLD
def __post_init__(self) -> None:
if not 0 <= self.universe < 0x8000:
raise ValueError("universe must be 0..0x7FFF")
if not 1 <= self.base_address <= 512 - 2:
raise ValueError("base_address must leave room for channels 1..3")
@property
def enable_path(self) -> str:
return f"composition/{self.composition_id}/layer/{self.layer_id}/enabled"
@property
def opacity_path(self) -> str:
return layer_opacity_path(self.composition_id, self.layer_id)
class DmxLayerMapper:
"""Wandelt DmxUpdates eines Universes in Parameter-Engine-Werte.
Phase-0-Umfang (§36 Nr. 8): DMX-Kanal auf Layer-Opacity mappen.
Media-Auswahl mit Load/Commit-Semantik (§16.5) folgt in Phase 2;
RisingEdge ist bereits getestet verfügbar.
"""
def __init__(
self,
mapping: LayerDmxMapping,
engine: ParameterEngine,
source: ControlSource = ControlSource.CONSOLE,
) -> None:
self._mapping = mapping
self._engine = engine
self._source = source
def _channel(self, data: bytes, relative: int) -> int:
"""Liest Kanal relativ zur Base-Adresse (1-basiert); 0 wenn zu kurz."""
idx = self._mapping.base_address - 1 + (relative - 1)
if 0 <= idx < len(data):
return data[idx]
return 0
def handle(self, update: DmxUpdate) -> None:
if update.universe != self._mapping.universe:
return
if update.sequence == -1 and not update.data:
# Signalverlust (§11.3, §16.1): Policy anwenden, niemals still
if self._mapping.loss_behavior is LossBehavior.FADE_TO_BLACK:
self._engine.release(self._mapping.opacity_path, self._source)
self._engine.release(self._mapping.enable_path, self._source)
# HOLD: letzten Zustand behalten keine Aktion
return
enable = 1.0 if self._channel(update.data, 1) >= 128 else 0.0
opacity = ((self._channel(update.data, 2) << 8) | self._channel(update.data, 3)) / 65535.0
self._engine.set_value(self._mapping.enable_path, enable, self._source)
self._engine.set_value(self._mapping.opacity_path, opacity, self._source)

Some files were not shown because too many files have changed in this diff Show More