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