From 0922cc1d68c7f0873922994534ccd2adfedbdcef Mon Sep 17 00:00:00 2001 From: HMS MediaEngine Agent Date: Fri, 11 Sep 2026 00:36:59 +0200 Subject: [PATCH] Phase 0: Repository-Initialisierung nach Bauplan v1.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Struktur gemäß §8 (Eigentumsgrenzen), PLAN.md als normative Basis - Pflichtdokumente: STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADRs - ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows), ADR-0003 IPC TCP+MessagePack v1 - Kernpakete: hms_protocol, hms_domain, hms_parameter, hms_artnet, hms_adaptive, hms_capabilities, hms_plugin_sdk - Renderer-Spike: D3D11-Primärpfad + Dev-GL-Pfad (§36 Nr. 4-5) - Control Core: FastAPI REST + WebSocket (§36 Nr. 9) - Beispielplugins: Passthrough + Gaussian Blur (3 Adaptive-Quality- Varianten, HLSL/GLSL/GLES) - Tools: Art-Net-Emulator, Fixture-Generator (Master32/Layer64-CSV), Capability-Probe - JSON-Schemas: IPC, Plugin, Projekt, Cluster - 121 Unit-/Integrationstests grün, Ruff grün Gate 0 bleibt offen: Hardwaremessungen nur auf echter Windows-Referenz- hardware gültig (§29.7, §33). --- .gitignore | 31 + CHANGELOG.md | 18 + ERRORS.md | 26 + PLAN.md | 2831 +++++++++++++++++ README.md | 95 + STATUS.md | 40 + TEST_REPORT.md | 47 + apps/control_server/.gitkeep | 0 .../hms_control_server/__init__.py | 9 + .../hms_control_server/__main__.py | 10 + apps/control_server/hms_control_server/app.py | 159 + apps/launcher/.gitkeep | 0 apps/launcher/hms_launcher/__init__.py | 10 + apps/launcher/hms_launcher/paths.py | 111 + apps/renderer/.gitkeep | 0 apps/renderer/hms_renderer/__init__.py | 22 + apps/renderer/hms_renderer/__main__.py | 52 + apps/renderer/hms_renderer/pipelines.py | 79 + apps/web/.gitkeep | 0 build/linux/.gitkeep | 0 build/raspberry_pi/.gitkeep | 0 build/windows/.gitkeep | 0 build/windows/GSTREAMER.md | 46 + docs/adr/.gitkeep | 0 docs/adr/0001-python-version-pin.md | 27 + docs/adr/0002-gstreamer-pin-windows.md | 28 + docs/adr/0003-ipc-tcp-msgpack.md | 23 + docs/adr/README.md | 17 + docs/adr/_template.md | 30 + docs/api/.gitkeep | 0 docs/architecture/.gitkeep | 0 docs/architecture/overview.md | 41 + docs/fixture/.gitkeep | 0 docs/operator/.gitkeep | 0 docs/performance/.gitkeep | 0 docs/plugin-sdk/.gitkeep | 0 fixture_profiles/layer64/.gitkeep | 0 fixture_profiles/layer64/layer64.csv | 65 + fixture_profiles/master32/.gitkeep | 0 fixture_profiles/master32/master32.csv | 33 + native/render_bridge/.gitkeep | 0 packages/adaptive_quality/.gitkeep | 0 .../adaptive_quality/hms_adaptive/__init__.py | 5 + .../hms_adaptive/controller.py | 88 + packages/artnet/.gitkeep | 0 packages/artnet/hms_artnet/__init__.py | 36 + packages/artnet/hms_artnet/mapping.py | 111 + packages/artnet/hms_artnet/packets.py | 225 ++ packages/artnet/hms_artnet/receiver.py | 169 + packages/audio_analysis/.gitkeep | 0 packages/capabilities/.gitkeep | 0 .../capabilities/hms_capabilities/__init__.py | 5 + .../capabilities/hms_capabilities/probe.py | 99 + packages/cluster/.gitkeep | 0 packages/content_sync/.gitkeep | 0 packages/domain/.gitkeep | 0 packages/domain/hms_domain/__init__.py | 5 + packages/domain/hms_domain/ids.py | 53 + packages/parameter_engine/.gitkeep | 0 .../hms_parameter/__init__.py | 23 + .../parameter_engine/hms_parameter/engine.py | 156 + .../parameter_engine/hms_parameter/paths.py | 49 + packages/persistence/.gitkeep | 0 packages/plugin_sdk/.gitkeep | 0 .../plugin_sdk/hms_plugin_sdk/__init__.py | 15 + .../plugin_sdk/hms_plugin_sdk/manifest.py | 227 ++ packages/protocol/.gitkeep | 0 packages/protocol/hms_protocol/__init__.py | 18 + packages/protocol/hms_protocol/envelope.py | 37 + packages/protocol/hms_protocol/framing.py | 61 + packages/protocol/hms_protocol/idempotency.py | 37 + packages/render_backend/d3d11/.gitkeep | 0 packages/render_backend/gl_gles/.gitkeep | 0 packages/timeline/.gitkeep | 0 plugins/builtin/filters/.gitkeep | 0 plugins/builtin/generators/.gitkeep | 0 plugins/builtin/outputs/.gitkeep | 0 plugins/builtin/transitions/.gitkeep | 0 plugins/examples/.gitkeep | 0 .../com.hms.fx.example_passthrough/README.md | 13 + .../plugin.json | 57 + .../shaders/d3d11/passthrough.hlsl | 37 + .../shaders/gl/passthrough.frag | 33 + .../shaders/gles/passthrough.frag | 32 + .../com.hms.fx.gaussian_blur/README.md | 16 + .../com.hms.fx.gaussian_blur/plugin.json | 68 + .../shaders/d3d11/horizontal.hlsl | 52 + .../shaders/d3d11/vertical.hlsl | 50 + .../shaders/gl/horizontal.frag | 47 + .../shaders/gl/vertical.frag | 46 + .../shaders/gles/horizontal.frag | 48 + .../shaders/gles/vertical.frag | 47 + pyproject.toml | 47 + schemas/api/.gitkeep | 0 schemas/cluster/.gitkeep | 0 .../cluster/cluster_message_v1.schema.json | 20 + schemas/ipc/.gitkeep | 0 schemas/ipc/envelope_v1.schema.json | 18 + schemas/plugin/.gitkeep | 0 schemas/plugin/plugin_manifest_v1.schema.json | 91 + schemas/project/.gitkeep | 0 schemas/project/project_v1.schema.json | 60 + tests/cluster/.gitkeep | 0 tests/conftest.py | 32 + tests/e2e/.gitkeep | 0 tests/integration/.gitkeep | 0 tests/integration/test_artnet_receiver.py | 113 + tests/integration/test_control_server.py | 121 + tests/performance/.gitkeep | 0 tests/portability/.gitkeep | 0 tests/rendering/.gitkeep | 0 tests/unit/.gitkeep | 0 tests/unit/test_adaptive_quality.py | 74 + tests/unit/test_artnet_packets.py | 189 ++ tests/unit/test_capabilities.py | 52 + tests/unit/test_dmx_mapping.py | 172 + tests/unit/test_domain_ids.py | 53 + tests/unit/test_fixture_generator.py | 66 + tests/unit/test_parameter_engine.py | 105 + tests/unit/test_pipelines.py | 61 + tests/unit/test_plugin_manifest.py | 134 + tests/unit/test_portable_paths.py | 54 + tests/unit/test_protocol.py | 90 + tests/visual/.gitkeep | 0 tools/artnet_emulator/.gitkeep | 0 tools/artnet_emulator/artnet_emulator.py | 85 + tools/capability_probe/.gitkeep | 0 tools/cluster_test_node/.gitkeep | 0 tools/fixture_generator/.gitkeep | 0 tools/fixture_generator/fixture_generator.py | 148 + tools/media_probe/.gitkeep | 0 tools/shader_validate/.gitkeep | 0 uv.lock | 339 ++ 133 files changed, 7939 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 ERRORS.md create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 STATUS.md create mode 100644 TEST_REPORT.md create mode 100644 apps/control_server/.gitkeep create mode 100644 apps/control_server/hms_control_server/__init__.py create mode 100644 apps/control_server/hms_control_server/__main__.py create mode 100644 apps/control_server/hms_control_server/app.py create mode 100644 apps/launcher/.gitkeep create mode 100644 apps/launcher/hms_launcher/__init__.py create mode 100644 apps/launcher/hms_launcher/paths.py create mode 100644 apps/renderer/.gitkeep create mode 100644 apps/renderer/hms_renderer/__init__.py create mode 100644 apps/renderer/hms_renderer/__main__.py create mode 100644 apps/renderer/hms_renderer/pipelines.py create mode 100644 apps/web/.gitkeep create mode 100644 build/linux/.gitkeep create mode 100644 build/raspberry_pi/.gitkeep create mode 100644 build/windows/.gitkeep create mode 100644 build/windows/GSTREAMER.md create mode 100644 docs/adr/.gitkeep create mode 100644 docs/adr/0001-python-version-pin.md create mode 100644 docs/adr/0002-gstreamer-pin-windows.md create mode 100644 docs/adr/0003-ipc-tcp-msgpack.md create mode 100644 docs/adr/README.md create mode 100644 docs/adr/_template.md create mode 100644 docs/api/.gitkeep create mode 100644 docs/architecture/.gitkeep create mode 100644 docs/architecture/overview.md create mode 100644 docs/fixture/.gitkeep create mode 100644 docs/operator/.gitkeep create mode 100644 docs/performance/.gitkeep create mode 100644 docs/plugin-sdk/.gitkeep create mode 100644 fixture_profiles/layer64/.gitkeep create mode 100644 fixture_profiles/layer64/layer64.csv create mode 100644 fixture_profiles/master32/.gitkeep create mode 100644 fixture_profiles/master32/master32.csv create mode 100644 native/render_bridge/.gitkeep create mode 100644 packages/adaptive_quality/.gitkeep create mode 100644 packages/adaptive_quality/hms_adaptive/__init__.py create mode 100644 packages/adaptive_quality/hms_adaptive/controller.py create mode 100644 packages/artnet/.gitkeep create mode 100644 packages/artnet/hms_artnet/__init__.py create mode 100644 packages/artnet/hms_artnet/mapping.py create mode 100644 packages/artnet/hms_artnet/packets.py create mode 100644 packages/artnet/hms_artnet/receiver.py create mode 100644 packages/audio_analysis/.gitkeep create mode 100644 packages/capabilities/.gitkeep create mode 100644 packages/capabilities/hms_capabilities/__init__.py create mode 100644 packages/capabilities/hms_capabilities/probe.py create mode 100644 packages/cluster/.gitkeep create mode 100644 packages/content_sync/.gitkeep create mode 100644 packages/domain/.gitkeep create mode 100644 packages/domain/hms_domain/__init__.py create mode 100644 packages/domain/hms_domain/ids.py create mode 100644 packages/parameter_engine/.gitkeep create mode 100644 packages/parameter_engine/hms_parameter/__init__.py create mode 100644 packages/parameter_engine/hms_parameter/engine.py create mode 100644 packages/parameter_engine/hms_parameter/paths.py create mode 100644 packages/persistence/.gitkeep create mode 100644 packages/plugin_sdk/.gitkeep create mode 100644 packages/plugin_sdk/hms_plugin_sdk/__init__.py create mode 100644 packages/plugin_sdk/hms_plugin_sdk/manifest.py create mode 100644 packages/protocol/.gitkeep create mode 100644 packages/protocol/hms_protocol/__init__.py create mode 100644 packages/protocol/hms_protocol/envelope.py create mode 100644 packages/protocol/hms_protocol/framing.py create mode 100644 packages/protocol/hms_protocol/idempotency.py create mode 100644 packages/render_backend/d3d11/.gitkeep create mode 100644 packages/render_backend/gl_gles/.gitkeep create mode 100644 packages/timeline/.gitkeep create mode 100644 plugins/builtin/filters/.gitkeep create mode 100644 plugins/builtin/generators/.gitkeep create mode 100644 plugins/builtin/outputs/.gitkeep create mode 100644 plugins/builtin/transitions/.gitkeep create mode 100644 plugins/examples/.gitkeep create mode 100644 plugins/examples/com.hms.fx.example_passthrough/README.md create mode 100644 plugins/examples/com.hms.fx.example_passthrough/plugin.json create mode 100644 plugins/examples/com.hms.fx.example_passthrough/shaders/d3d11/passthrough.hlsl create mode 100644 plugins/examples/com.hms.fx.example_passthrough/shaders/gl/passthrough.frag create mode 100644 plugins/examples/com.hms.fx.example_passthrough/shaders/gles/passthrough.frag create mode 100644 plugins/examples/com.hms.fx.gaussian_blur/README.md create mode 100644 plugins/examples/com.hms.fx.gaussian_blur/plugin.json create mode 100644 plugins/examples/com.hms.fx.gaussian_blur/shaders/d3d11/horizontal.hlsl create mode 100644 plugins/examples/com.hms.fx.gaussian_blur/shaders/d3d11/vertical.hlsl create mode 100644 plugins/examples/com.hms.fx.gaussian_blur/shaders/gl/horizontal.frag create mode 100644 plugins/examples/com.hms.fx.gaussian_blur/shaders/gl/vertical.frag create mode 100644 plugins/examples/com.hms.fx.gaussian_blur/shaders/gles/horizontal.frag create mode 100644 plugins/examples/com.hms.fx.gaussian_blur/shaders/gles/vertical.frag create mode 100644 pyproject.toml create mode 100644 schemas/api/.gitkeep create mode 100644 schemas/cluster/.gitkeep create mode 100644 schemas/cluster/cluster_message_v1.schema.json create mode 100644 schemas/ipc/.gitkeep create mode 100644 schemas/ipc/envelope_v1.schema.json create mode 100644 schemas/plugin/.gitkeep create mode 100644 schemas/plugin/plugin_manifest_v1.schema.json create mode 100644 schemas/project/.gitkeep create mode 100644 schemas/project/project_v1.schema.json create mode 100644 tests/cluster/.gitkeep create mode 100644 tests/conftest.py create mode 100644 tests/e2e/.gitkeep create mode 100644 tests/integration/.gitkeep create mode 100644 tests/integration/test_artnet_receiver.py create mode 100644 tests/integration/test_control_server.py create mode 100644 tests/performance/.gitkeep create mode 100644 tests/portability/.gitkeep create mode 100644 tests/rendering/.gitkeep create mode 100644 tests/unit/.gitkeep create mode 100644 tests/unit/test_adaptive_quality.py create mode 100644 tests/unit/test_artnet_packets.py create mode 100644 tests/unit/test_capabilities.py create mode 100644 tests/unit/test_dmx_mapping.py create mode 100644 tests/unit/test_domain_ids.py create mode 100644 tests/unit/test_fixture_generator.py create mode 100644 tests/unit/test_parameter_engine.py create mode 100644 tests/unit/test_pipelines.py create mode 100644 tests/unit/test_plugin_manifest.py create mode 100644 tests/unit/test_portable_paths.py create mode 100644 tests/unit/test_protocol.py create mode 100644 tests/visual/.gitkeep create mode 100644 tools/artnet_emulator/.gitkeep create mode 100644 tools/artnet_emulator/artnet_emulator.py create mode 100644 tools/capability_probe/.gitkeep create mode 100644 tools/cluster_test_node/.gitkeep create mode 100644 tools/fixture_generator/.gitkeep create mode 100644 tools/fixture_generator/fixture_generator.py create mode 100644 tools/media_probe/.gitkeep create mode 100644 tools/shader_validate/.gitkeep create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94dc576 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +dist/ + +# Build-Artefakte (nicht das Quellverzeichnis build/) +build_out/ +*.zip + +# Node / Frontend (Phase 5) +node_modules/ +apps/web/dist/ + +# Portable Testausgaben +HMS-MediaEngine-Portable/ + +# Logs & Betriebsdaten +logs/ +*.log +userdata/ + +# OS/Editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bd0c2df --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/ERRORS.md b/ERRORS.md new file mode 100644 index 0000000..cbb4718 --- /dev/null +++ b/ERRORS.md @@ -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. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c50b584 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,2831 @@ +# HMS MediaEngine – technischer Bauplan v1.2 + +> Arbeitstitel. Der Produktname kann später ohne technische Auswirkung geändert werden. + +**Dokumentstatus:** verbindliche Umsetzungsgrundlage +**Zielgruppe:** ausführende Entwicklungs-KI und technische Prüfer +**Primärplattform:** Windows 11 x64, portabel ohne Installation +**Weitere Zielplattformen:** Linux x64 und Raspberry Pi 5 / Linux ARM64 +**Produktklasse:** modularer Medienserver, VJ-System und generativer Licht-/Pixeleffekt-Server +**Revision 1.2:** Windows-D3D11-Renderpfad, Mini-PC-Profil, Multi-Server-Discovery/Sync, QLab-inspirierte Weboberfläche und verbindliches Generator-/Effekt-Starterpaket +**Lieferumfang dieses Dokuments:** ausschließlich Bau- und Prüfvorgaben für eine spätere Umsetzung; kein Programmcode und keine fertige Benutzeroberfläche +**Stand:** 10. September 2026 + +--- + +## 1. Auftrag an die ausführende KI + +Baue eine portable, modular erweiterbare Medienserver-Software, die Videos, Bilder, Live-Quellen und generative Effekte in mehreren Layern GPU-beschleunigt verarbeitet. Das System wird lokal über HDMI beziehungsweise DisplayPort ausgegeben, über einen Webbrowser im Netzwerk bedient und zusätzlich wie ein Medienserver-Fixture über Art-Net/DMX gesteuert. + +Die Software muss von Beginn an Erweiterungspunkte für folgende spätere Funktionen besitzen: + +- zusätzliche Quellen, Videoeffekte, Generatoren, Übergänge und Ausgaben als Plugins; +- mehrere physische Displays und Projektoren; +- Output-Mapping, Warping und Edge-Blending; +- Art-Net-Pixel-Ausgabe für LED-Fixtures; +- Cue- und Timeline-Betrieb; +- Audioanalyse und deterministische Sound-to-Light-Modulation; +- KI-gestützte Programmierung, Szenenauswahl und Automatik; +- im LAN auffindbare, gruppierbare und synchron steuerbare Render-Nodes. + +Dieser Bauplan ist normativ. Abweichungen sind nur zulässig, wenn sie in einer Architecture Decision Record dokumentiert, technisch begründet, getestet und vom Auftraggeber freigegeben werden. + +### 1.1 Verbindliche Arbeitsweise + +Die ausführende KI muss: + +1. zuerst Phase 0 als technischen Machbarkeitsnachweis abschließen; +2. danach strikt in der angegebenen Phasenreihenfolge arbeiten; +3. nach jeder Phase alle zugehörigen Tests und Abnahmekriterien ausführen; +4. `STATUS.md`, `ERRORS.md`, `TEST_REPORT.md` und die ADRs aktuell halten; +5. keine Mock-Funktion, Attrappe oder reine UI-Darstellung als fertig melden; +6. keine nicht getestete Softwaredecodierung als Hardwarebeschleunigung ausgeben; +7. keine Pixelverarbeitung in Python-Schleifen implementieren; +8. keine Plattformabhängigkeit ohne Abstraktionsschicht in den Domänenkern aufnehmen; +9. bei einem nicht bestandenen Gate stoppen, die Ursache dokumentieren und gezielt sanieren; +10. keine spätere Phase beginnen, solange das aktuelle Gate rot ist. + +--- + +## 2. Produktvision + +Das Ziel ist kein vollständiger Nachbau von Resolume oder MADRIX in Version 1.0. Ziel ist ein belastbarer, klar begrenzter Kern, der die wichtigsten Funktionen beider Produktklassen verbindet: + +- Medienserver: Clips, Playback, Layer, Blend-Modi, Transformationen, Videoeffekte und DMX-Steuerung; +- VJ-System: schnelle Medienauswahl, Szenen, Übergänge, Live-Preview und Timeline; +- generativer Effektserver: Farbverläufe, Noise, Plasma, Chaser, Formen, Partikel und audioaktive Parameter; +- Mapping-System: virtuelle Canvas, mehrere Ausgänge, Warping und Edge-Blending; +- Automationsplattform: Parameterbindungen, Modulatoren, Audioanalyse und später KI. + +### 2.1 Leitsatz + +**Python steuert. Native Bibliotheken decodieren. Die GPU rendert. Der Browser bedient.** + +Python darf das Showmodell, die API, Art-Net, Persistenz, Plugins, Timeline und Automation verwalten. Video-Decoding, Skalierung, Blending, Blur, Distortion, Farbbearbeitung und finale Ausgabe müssen über native Bibliotheken und GPU-Shader erfolgen. + +--- + +## 3. Nicht verhandelbare Anforderungen + +### 3.1 Portable Windows-Ausgabe + +- Auslieferung als entpackbares ZIP-Verzeichnis. +- Start über `HMS-MediaEngine.exe` ohne Setup. +- Keine Administratorrechte. +- Keine Registry-Pflicht. +- Kein global installiertes Python, Node.js, GStreamer oder FFmpeg erforderlich. +- Alle Anwendungsabhängigkeiten werden mitgeliefert. +- GPU-Treiber und Betriebssystemkomponenten dürfen vorausgesetzt werden; sie können technisch nicht sinnvoll mitgeliefert werden. +- Anwendungsdaten und Projekte bleiben vollständig innerhalb des portablen Ordners, sofern kein anderer Datenpfad konfiguriert wurde. +- Keine automatische Aktualisierung während eines laufenden Showbetriebs. + +### 3.2 Browseroberfläche + +- Die vollständige Bedienoberfläche wird als lokale Webanwendung ausgeliefert. +- Bedienung lokal und von anderen Geräten im LAN. +- Echtzeitänderungen über WebSocket. +- Die Browseroberfläche ist niemals der primäre Videoausgang. +- Ein Abbruch oder Neuladen des Browsers darf den laufenden Videooutput nicht stoppen. +- Responsive Bedienung für Desktop und Tablet. +- Ein Control-PC beziehungsweise ein als Coordinator gewählter Server muss mehrere Media-Server in einer gemeinsamen Oberfläche finden, paaren, überwachen, gruppieren und bedienen können. +- Die Showausgabe läuft auf jedem Node unabhängig von der Browserverbindung weiter. + +### 3.3 Videoausgabe + +- Native randlose Vollbildausgabe auf wählbaren Displays. +- Mindestens 1920 × 1080 bei 60 Hz. +- Desktop-Zielprofil: virtuelle Canvas bis mindestens 3840 × 2160 bei 60 Hz. +- VSync und kontrolliertes Frame-Pacing. +- Anzeigezuordnung anhand stabiler Displaymerkmale, soweit das Betriebssystem diese liefert. +- Sicherer Fallback, falls ein Display fehlt oder getrennt wird. + +### 3.4 Art-Net + +- Art-Net 4 ArtDMX-Eingang. +- Das System meldet sich bei Discovery als Media Server beziehungsweise passender Node-Stil. +- Konfigurierbare Universe- und Startadressen. +- Master-Fixture und ein Layer-Fixture pro Layer. +- Ein Lichtpult kann mehrere Server gleichzeitig steuern; jeder Server erhält eine eindeutige Node-ID, einen eindeutigen Art-Net-Namen und konfliktfreie Universe-Bereiche. +- 8- und 16-Bit-Parameter. +- Konfigurierbares Verhalten bei Signalverlust. +- Später ArtTimeCode, ArtTrigger, ArtSync und Art-Net-Pixel-Ausgabe. + +### 3.5 Modularität + +- Plugins sind ab der ersten lauffähigen Version Bestandteil der Architektur. +- Plugin-API ist versioniert. +- Der Kern kennt keine fest codierte Liste aller zukünftigen Effekte. +- Plugins dürfen den Renderer nicht unkontrolliert blockieren oder direkten Zugriff auf interne Datenbanken erhalten. +- Ein fehlerhaftes Plugin muss deaktiviert oder überbrückt werden können, ohne ein Projekt unbrauchbar zu machen. + +### 3.6 Zukunftssicherheit + +- Alle wichtigen Objekte erhalten stabile UUIDs. +- Jeder Server erhält beim ersten Start eine dauerhafte `node_id`, die nicht von IP-Adresse oder Hostname abhängt. +- Projektdateien enthalten eine `schema_version`. +- Persistierte Strukturen erhalten Migrationen. +- Parameter werden über stabile Pfade beziehungsweise IDs adressiert. +- Audio, Timeline, DMX, Browser und KI greifen über denselben Parameter-/Command-Layer zu. +- Eine spätere KI darf ausschließlich freigegebene Commands und Tools verwenden, nicht direkt Renderer, Datenbank oder Dateisystem manipulieren. + +--- + +## 4. Umfang und Abgrenzung + +### 4.1 MVP-Funktionsumfang + +Der erste produktiv nutzbare Stand umfasst: + +- einen Master-Canvas; +- acht gleichzeitig patchbare Layer; +- Video-, Bild-, Solid-, Generator- und Adjustment-Layer; +- zwei frei belegbare Effekt-Slots pro Layer; +- Opacity, Blend-Modus und 2D-Transformation; +- das vollständige Generator- und Effekt-Starterpaket aus Abschnitt 15; +- speicherbare Szenen/Presets mit direktem Abruf, jedoch noch ohne QLab-artige Cue-Ablauflogik; +- Art-Net-Steuerung über Master- und Layer-Fixtures; +- LAN-Discovery, sichere Node-Paarung und eine Cluster-/Serverübersicht für mindestens zwei Server; +- direkte Mehrserver-Steuerung durch ein Lichtpult über getrennte Universe-Bereiche; +- Browser-Liveoberfläche mit reduziertem Preview; +- Projekt speichern, laden, duplizieren und exportieren; +- ein oder zwei physische Ausgänge; +- portables Windows-Paket; +- Diagnoseansicht für FPS, Framezeit, Decoder und Art-Net. + +### 4.2 Nicht Bestandteil des ersten MVP + +- vollständiger 3D-Renderer; +- hardware-framegelockte Multi-Rechner-Ausgabe; +- Genlock-/Framelock-Steuerung; +- vollständige NDI-, SDI- oder Capture-Card-Matrix; +- QLab-artige GO-/Standby-Logik, Cue-Sequenzen, Cue-Carts und vollständige Show-Cue-Listen; +- vollständige Timeline; Datenmodell und Parameter-Engine bleiben dafür vorbereitet; +- Plugin-Marktplatz; +- Multi-User-Rechteverwaltung auf Enterprise-Niveau; +- KI-gesteuerte Live-Show ohne Bedienerfreigabe; +- vollständiger MADRIX- oder Resolume-Funktionsumfang; +- framegenaue Rückwärtswiedergabe jedes beliebigen Long-GOP-Codecs. + +Die Architektur darf diese Erweiterungen nicht verhindern, sie müssen aber nicht vorzeitig implementiert werden. + +--- + +## 5. Zielprofile und Capability-Tiers + +Das System erkennt beim Start Hardware und verfügbare Backends und ordnet sie einem Capability-Tier zu. + +| Tier | Zielsystem | Verbindliches Testprofil | +| --- | --- | --- | +| `DESKTOP_FULL` | Windows/Linux mit dedizierter GPU und mindestens 8 GB VRAM | 4K60 Master, 8 × 1080p60 Quellen, normale Shader-Effekte, bis zu 2 Ausgänge | +| `DESKTOP_LITE` | aktueller Windows-Mini-PC mit schneller iGPU | 1080p60 Master, 4 Medien-/Generator-Layer, mehrere einfache Effekte, ein mittlerer Blur, ein Ausgang plus reduziertes Preview | +| `PI_LITE` | Raspberry Pi 5 mit aktiver Kühlung | 1080p60 Master, 2 Video-/Generator-Layer, einfache Shader-Effekte | +| `HEADLESS_CONTROL` | Server ohne Display-GPU | Medienverwaltung, Timeline, Art-Net und Remote-Steuerung ohne lokalen Renderoutput | + +Ein Plugin deklariert Mindest-Tier, Kostenklasse und Qualitätsstufen. Die normale Betriebsart ist `Auto`: Anpassungen laufen ohne Dialog, Unterbrechung oder auffällige UI-Warnung und werden nur dezent als `AUTO` sowie ausführlich in Diagnostics protokolliert. Erst wenn selbst die definierte Mindestqualität das Framebudget nicht hält, erscheint eine Operatorwarnung. Ein stiller Wechsel von Hardware- auf Softwaredecodierung bleibt untersagt. + +### 5.1 Realistische Mini-PC-Klassen + +| Hardwareklasse | Erwartetes Profil | Einordnung | +| --- | --- | --- | +| moderner 6–8-Kern-Mini-PC mit Radeon-680M/780M-Klasse oder vergleichbarer schneller iGPU, 32 GB Dual-Channel-RAM, NVMe, 1-Gbit-Ethernet | `DESKTOP_LITE`, 1080p60 mit etwa vier typischen Layern | empfohlene portable Standardhardware; konkrete Layerzahl hängt von Codec, Auflösung und Effekten ab | +| aktuelle Intel-Core-U-/Core-Ultra- oder vergleichbare iGPU-Plattform mit funktionierendem D3D11-Hardwaredecode und zwei Speicherkanälen | `DESKTOP_LITE` nach Phase-0-Messung | geeignet, wenn der GPU-residente Pfad und die Treiberprüfung grün sind | +| Intel-N100-Klasse mit 16 GB RAM | unteres Lite-Profil, typischerweise ein bis zwei Videolayer und einfache Effekte | als kompakter Player/Node sinnvoll, nicht als 4K- oder Heavy-FX-Hauptserver einplanen | +| dedizierte GPU mit mindestens 8 GB VRAM | `DESKTOP_FULL` | für 4K60, mehrere Ausgänge, viele Layer, starken Blur/Feedback und komplexes Mapping | + +Bei integrierten GPUs ist Speicherbandbreite ein Teil des Grafikbudgets. Dual-Channel-RAM ist deshalb verbindlich empfohlen. Die Software zeigt kein pauschales Leistungsversprechen, sondern führt beim ersten Start einen kurzen Capability-/Decode-Test aus und aktiviert selbstständig ein passendes Qualitätsprofil. + +### 5.2 Automatische Hardwareerkennung und Adaptive Quality + +Beim ersten Start sowie nach Änderung von GPU, Treiber oder Displaykonfiguration führt die Engine vor Freigabe des Liveoutputs einen kurzen, reproduzierbaren Selbsttest aus. Das Ergebnis wird an einen Hardware-Fingerprint gebunden gespeichert. + +Erfasst und getestet werden mindestens: + +- GPU-Adapter, Feature-Level/API, Treiber und dedizierter beziehungsweise geteilter Speicher; +- D3D11-/GL-/GLES-Funktionen, maximale Textur-/Render-Target-Größen und Timing Queries; +- Hardware-Decodeprofile für H.264, H.265 und optional AV1 sowie gleichzeitige Decoderinstanzen; +- GPU-residenter Memory-Pfad und notwendige Farbkonvertierungen; +- Hardwareencoder für Preview; +- CPU-Kerne, RAM, Speicherbandbreiten-Indikatoren und NVMe-Durchsatz; +- angeschlossene Displays, Auflösungen, Bildraten und gemeinsame Ausgabeanforderung; +- Mikrobeispiele für Decode, zwei-/vierfaches Compositing, Blur, Farbkorrektur und Present. + +Aus den Messwerten entstehen kein bloßer Marketing-Score, sondern konkrete Budgets: maximale sichere Decoderzahl je Format, Renderzeitbudget, Textur-/Speicherbudget, Preloadbudget und zulässige Qualitätsstufen je Effekt. + +#### Laufzeitregler + +Ein eigener `AdaptiveQualityController` überwacht GPU-Framezeit p50/p95/p99, Decodequeues, Dropped Frames, Speicherbudget, Encoderlast und thermische Drosselung. Er arbeitet mit Hysterese und ändert höchstens eine Stufe pro Regelintervall: + +1. Preview-FPS/Auflösung und Thumbnail-Aktualisierung reduzieren; +2. nicht showkritische Analyse, Scope- und UI-Telemetrieraten reduzieren; +3. Samplezahl, Downsample-Faktor und Iterationen teurer Effekte innerhalb manifestierter Qualitätsvarianten anpassen; +4. Generator-/Partikeldichte und interne Feedbackauflösung reduzieren; +5. vorbereitete Medien-Proxies verwenden, sofern Bildformat, Timing und In-/Out-Punkte identisch bleiben; +6. erst wenn das nicht genügt: klarer Überlaststatus und definierte Fallback-Policy. + +Nicht automatisch verändert werden: + +- physische Ausgangsauflösung oder Refresh Rate; +- Layer-Reihenfolge, aktive Layer, Cue-/Szenenlogik oder DMX-Zuordnung; +- Farben/Geometrie außerhalb deklarierter tolerierbarer Qualitätsunterschiede; +- Hardwaredecode zu Softwaredecode; +- ein Effekt durch vollständigen Bypass, sofern der Betreiber dies nicht als letzte Fallback-Stufe freigegeben hat. + +Alle Shadervarianten werden vorab kompiliert. Diskrete Wechsel erfolgen atomar an einer Framegrenze; kontinuierliche Qualitätsparameter werden kurz interpoliert. Abwertung reagiert schnell auf anhaltende Last, Aufwertung deutlich langsamer, damit kein sichtbares Pumpen entsteht. Der Operator sieht im Normalbetrieb nur `Quality: Auto`; Experten können Mindeststufe, Prioritäten und ein festes Profil setzen. Im Show-Lock darf niemals ein neuer Benchmark gestartet werden. + +--- + +## 6. Gesamtarchitektur + +```mermaid +flowchart TD + WEB["Control Center im Browser"] --> COORD["Coordinator / Control Core"] + DESK["Lichtpult via Art-Net"] --> NODEA["Media-Server A"] + DESK --> NODEB["Media-Server B"] + COORD --> NODEA + COORD --> NODEB + NODEA --> OUTA["HDMI / Mapping A"] + NODEB --> OUTB["HDMI / Mapping B"] +``` + +Jeder Media-Server bleibt zugleich eigenständig: lokal besteht er weiterhin aus Supervisor, Control Core, Render Worker und Webserver. Der Coordinator ist eine Rolle des Control Core und kann auf einem Render-Server oder einem reinen `HEADLESS_CONTROL`-PC laufen. + +### 6.1 Prozessaufteilung + +#### A. Supervisor/Launcher + +Aufgaben: + +- Start und Überwachung der Teilprozesse; +- Wahl freier lokaler Ports; +- Setzen portabler Runtime-Pfade; +- Öffnen des Standardbrowsers; +- Heartbeat-Überwachung; +- kontrolliertes Beenden; +- Wiederanlauf nach einem Control-Core-Absturz; +- Crash- und Startdiagnose. + +#### B. Control Core + +Technologie: Python mit `asyncio`, FastAPI, Pydantic und WebSocket. + +Aufgaben: + +- autoritativer Projekt- und Showzustand; +- REST-/WebSocket-API; +- Art-Net-Empfang und Parameterauflösung; +- Node-Identität, Discovery, Paarung und Clusterprotokoll; +- optionaler Coordinator für Servergruppen, Preflight und zeitgestempelte Commands; +- Szenen, Cues, Timeline und Automation; +- Plugin-Verwaltung; +- Medienindex und Metadaten; +- Persistenz und Migrationen; +- Audioanalyse beziehungsweise Audio-Feature-Verteilung; +- KI-Command-Gateway; +- Telemetrieaggregation. + +#### C. Render Worker + +Technologie in V1: Python-Prozess als Orchestrator für native GStreamer- und GPU-Komponenten. Unter Windows ist D3D11 der Primärpfad; Linux nutzt OpenGL und Raspberry Pi OpenGL ES. Keine Pixelverarbeitung in Python. + +Der eigentliche Rendergraph läuft in einem mitgelieferten nativen Render-Modul beziehungsweise GStreamer-Plugin. Phase 0 entscheidet per ADR zwischen Rust und C++ sowie zwischen eigenständiger Render-Bridge und GStreamer-Plugin. Python darf Pipelines konfigurieren und gebündelte Parameterblöcke übergeben, aber weder pro Pixel arbeiten noch pro Frame einzelne D3D11-Drawcalls steuern. + +Aufgaben: + +- Hardwaredecoder auswählen und verifizieren; +- Medienquellen laden und vorpuffern; +- GPU-Texturen verwalten; +- Rendergraph aufbauen; +- Shader kompilieren und ausführen; +- Layer mischen; +- finale Canvas rendern; +- physische Ausgänge ansteuern; +- Rendertelemetrie liefern; +- bei Verlust des Control Core den letzten gültigen Zustand halten oder den konfigurierten Fallback ausführen. + +Der Render Worker muss austauschbar bleiben. Eine spätere Rust-/wgpu-Implementierung muss über dasselbe IPC- und Capability-Protokoll angebunden werden können. + +#### D. Web-Frontend + +Technologie: TypeScript mit React oder Svelte; eine Technologie auswählen und danach nicht ohne ADR wechseln. Empfehlung: React mit Vite oder SvelteKit im statischen SPA-Modus. Keine Server-Side-Rendering-Pflicht. + +Aufgaben: + +- Live-Mixer; +- Medienbrowser; +- Effekt- und Parametereditor; +- Presets/Szenen mit direktem Abruf; +- später optionaler Cue-/Timeline-Workspace als getrenntes Modul; +- Audio-/Automation-Mapping; +- DMX-Patch; +- Output-Mapping; +- Pluginverwaltung; +- Diagnose; +- QLab-inspirierte visuelle Designsprache für Mixer, Medien, Parameter und Cluster-/Node-Ansicht; keine QLab-artige GO-/Cue-Bedienlogik im MVP. + +#### E. Optionale Worker + +- Preview-Encoder; +- Art-Net-Pixel-Sender bei hoher Universe-Zahl; +- Offline-Medienanalyse/Transcoding; +- KI-Analysejobs. + +Diese Worker werden nur extrahiert, wenn Messungen einen Nutzen zeigen. + +### 6.2 IPC + +Das IPC zwischen Control Core und Renderer verwendet in V1 lokales TCP auf `127.0.0.1` mit length-prefixed MessagePack. JSON darf ausschließlich im Debugmodus verwendet werden. + +Jede Nachricht besitzt mindestens: + +```json +{ + "protocol_version": 1, + "message_id": "uuid", + "type": "command|event|snapshot|ack|error|telemetry", + "revision": 123, + "monotonic_timestamp_ns": 0, + "payload": {} +} +``` + +Pflichtfunktionen: + +- Handshake mit Versions- und Capability-Austausch; +- vollständiger State Snapshot nach Verbindungsaufbau; +- inkrementelle Deltas mit monotoner Revision; +- Ack/Fehler je zustandsänderndem Command; +- Idempotency-Key für wiederholbare Commands; +- Heartbeat mindestens alle 500 ms; +- Re-Sync nach Reconnect; +- maximale Payloadgröße; +- lokales IPC niemals an eine externe Netzwerkschnittstelle binden. + +### 6.3 Multi-Server-Rollen und Discovery + +#### Rollen + +| Rolle | Aufgabe | Darf ohne andere Rollen laufen? | +| --- | --- | --- | +| `RENDER_NODE` | decodiert und rendert lokale Outputs; empfängt Art-Net und Cluster-Commands | ja | +| `COORDINATOR` | führt Node-Registry, Servergruppen, Preflight, Content-/State-Sync und zeitgestempelte Gruppen-Commands | ja, auch auf einem Render-Node | +| `CONTROL_DESK` | Browser-Arbeitsplatz ohne lokalen Renderer | ja, als `HEADLESS_CONTROL` mit Coordinator | +| `ARTNET_CONSOLE` | externes Lichtpult; steuert Nodes direkt oder optional über Gruppen-Fixtures am Coordinator | ja | + +Es gibt in V1 keine automatische Leader-Wahl. Der Betreiber bestimmt einen Coordinator und optional einen vorbereiteten Ersatz. Dadurch bleibt das Verhalten in einer Show nachvollziehbar. Fällt der Coordinator aus, spielen Render-Nodes ihren bereits aktivierten Zustand weiter und ihr direkter Art-Net-Eingang bleibt aktiv. + +#### Auffinden + +- Jeder Server erzeugt einmalig eine persistente `node_id` und einen editierbaren Anzeigenamen. +- Management-Discovery erfolgt per mDNS/DNS-SD als `_hmsmedia._tcp.local.`. +- Der TXT-Datensatz enthält nur kleine, nicht vertrauliche Hinweise: Protokollversion, Node-ID, Rollen, API-Port und Capability-Digest. +- Die UI führt gefundene, bereits gepaarte, unbekannte, inkompatible und offline Nodes getrennt auf. +- mDNS ist keine Vertrauensentscheidung. Ein neuer Node wird erst nach PIN-/Fingerprint-Prüfung und Token-/Zertifikatsaustausch steuerbar. +- Für VLANs, geroutete Netze oder blockiertes Multicast gibt es eine persistente manuelle Node-Liste mit Host/IP und optionaler zentraler DNS-SD-Registrierung. +- Art-Net-Discovery bleibt davon unabhängig: Jeder Render-Node beantwortet `ArtPoll` mit einem eindeutigen Short-/Long-Name und seinen konfigurierten Ports/Universen. +- IP-Wechsel ändern die `node_id` nicht. Doppelte Node-IDs werden als Fehler blockiert. + +#### Bedienmodelle + +1. **Direktes Lichtpult-Modell:** Das Pult sendet ArtDMX an die IP oder Universe-Bereiche jedes Servers. Dies ist der robuste V1-Standard und benötigt keinen Coordinator im Echtzeitpfad. +2. **Control-Center-Modell:** Der Browser verbindet sich nur mit dem Coordinator. Dieser aggregiert Zustand und Telemetrie aller gepaarten Nodes und routet Commands an `All`, eine `ServerGroup`, einen einzelnen `Node` oder einen `Output`. +3. **Koordiniertes Show-Modell:** Der Coordinator prüft Medien/Plugins/Revision und verteilt in V1 zeitgestempelte Preset-/Parameterkommandos; spätere Cue-/Timeline-Kommandos verwenden denselben Pfad. Videoframes werden nicht über das Managementnetz gestreamt; jeder Node rendert lokal. + +### 6.4 Getrennte Sync-Stufen + +„Sync“ ist kein einzelnes Feature. Folgende Stufen müssen separat implementiert, angezeigt und getestet werden: + +| Stufe | Zweck | V1-Verfahren | Garantie | +| --- | --- | --- | --- | +| Discovery | Server finden | mDNS/DNS-SD plus manuelle Fallback-Liste | keine Autorisierung | +| Content Sync | identische Medien und Plugins | SHA-256-Manifest, resumierbare Chunks, Hashprüfung, Staging | byte-identische freigegebene Inhalte | +| Project Sync | identisches Showmodell | versionierter Snapshot plus Migration und atomare Aktivierung | gleiche Projekt-Revision | +| State Sync | Parameter-/Transportzustand | Full Snapshot, monotone Revisionen, Deltas und Reconnect | kein halber Zustand | +| Clock Sync | gemeinsame Showzeit | PTP, wenn verfügbar; sonst gemessene Offset-/Drift-Schätzung gegen Coordinator | messbare Softwarezeit, kein Genlock | +| Preset-/Command-Sync; später Cue/Timeline | gemeinsamer Start | Preload/Arm/Ack und `execute_at` typischerweise 100–300 ms im Voraus | Ziel p95 ≤ 10 ms Node-zu-Node im verkabelten Referenz-LAN | +| Frame/Scanout Sync | gleicher physischer Bildwechsel | nur mit geeigneter Genlock-/Framelock-Hardware | außerhalb V1; Software-Sync genügt dafür nicht | + +Der Wert von 10 ms ist ein Abnahmeziel für vorgepufferte Preset-/Command-Starts, keine Zusage für beliebige Rechner, WLAN, Codecs oder Displays. Für nahtloses Edge-Blending über mehrere Rechner ist Hardware-Synchronisation erforderlich. + +### 6.5 Clusterprotokoll, Preflight und Fehlerfälle + +- Node-Kommunikation: authentisiertes TLS-WebSocket mit MessagePack für Commands/State, HTTPS für Ressourcen und resumierbare Content-Chunks. +- Jede Cluster-Nachricht enthält mindestens `cluster_id`, `node_id`, `command_id`, Sequenz, Projekt-Revision, Absenderzeit, optionale `execute_at`-Showzeit und Trace-ID. +- Zustandsändernde Commands sind idempotent und werden mit `accepted`, `armed`, `executed` oder `failed` bestätigt. +- Ein Show-Preflight vergleicht Projekt-Revision, Asset-/Plugin-Hashes, Plugin-API, Render-Backend, Outputzuordnung, freien Speicher, Clock-Offset und erwartete Last. +- Aktivierung erfolgt zweiphasig: vollständig ins Staging übertragen und validieren, danach auf allen gewählten Nodes atomar auf dieselbe Revision schalten. +- Fehlende oder inkompatible Inhalte blockieren standardmäßig das `Arm`; eine bewusst gewählte Degradations-Policy muss sichtbar protokolliert werden. +- Heartbeat standardmäßig alle 500 ms; Zustände `online`, `degraded`, `stale`, `offline`, mit konfigurierbaren Schwellen. +- Bei Verbindungsverlust gilt je Node/Output eine Policy: letzten Zustand halten, Fallback-Szene oder Fade to Black. +- Nach Reconnect sendet der Node zunächst Status und vollständige Revision; Deltas werden erst nach erfolgreichem Re-Sync akzeptiert. +- Verkabeltes Gigabit-Ethernet und nach Möglichkeit ein getrenntes Show-VLAN sind der Referenzbetrieb. WLAN ist für Bedienung/Monitoring zulässig, nicht für garantierten Cue- oder Frame-Sync. + +```mermaid +sequenceDiagram + participant UI as Control Center + participant C as Coordinator + participant A as Node A + participant B as Node B + UI->>C: Preset auf Gruppe aktivieren + C->>A: Preflight und Arm + C->>B: Preflight und Arm + A-->>C: Ready + lokale Showzeit + B-->>C: Ready + lokale Showzeit + C->>A: Execute at T + C->>B: Execute at T + A-->>C: Executed + Istzeit + B-->>C: Executed + Istzeit +``` + +--- + +## 7. Verbindlicher Technologie-Stack + +| Bereich | Vorgabe | +| --- | --- | +| Sprache Control Core | Python, unterstützte Version exakt pinnen | +| API | FastAPI + Pydantic | +| Async Runtime | `asyncio` | +| Medienpipeline | GStreamer | +| nativer Renderkern | gebündelte Rust- oder C++-Render-Bridge/GStreamer-Plugin; Entscheidung in Phase 0 | +| GPU-Pipeline V1 | Backend-Abstraktion: Windows D3D11/D3D11Memory primär; Linux OpenGL; Raspberry Pi OpenGL ES | +| Shader V1 | backendneutrales Parameterschema; mitgelieferte HLSL-Implementierung für D3D11 und GLSL/GLES-Implementierung für Linux/Pi | +| Datenbank | SQLite mit WAL-Modus | +| Web-Frontend | TypeScript, React oder Svelte, Vite-basierter Build | +| Frontend-Paketmanager | pnpm mit Lockfile | +| Python-Paketmanager | uv mit Lockfile | +| Python-Portable-Build | Nuitka Standalone/Onefolder oder nach Phase-0-Nachweis PyInstaller Onefolder | +| Tests Python | pytest, pytest-asyncio, Hypothesis wo sinnvoll | +| Frontend-Tests | Vitest + Playwright | +| Typprüfung | mypy oder pyright, Auswahl per ADR | +| Lint/Format | Ruff; Frontend ESLint/Prettier | +| Protokollkodierung | MessagePack, versionierte Pydantic-Schemata | + +### 7.1 Technischer Entscheidungszwang in Phase 0 + +Nuitka und PyInstaller müssen nicht beide dauerhaft unterstützt werden. Phase 0 testet mindestens eine vollständige portable Onefolder-Ausgabe inklusive GStreamer. Die Variante mit reproduzierbarerem Build und weniger Laufzeitfehlern wird per ADR festgelegt. + +Ein Onefile-Paket ist ausdrücklich nicht das Ziel, weil Startzeit, temporäres Entpacken, Pluginverwaltung und gebündelte GStreamer-Komponenten dadurch unnötig erschwert werden. + +Phase 0 vergleicht auf Windows einen vollständig D3D11-residenten Pfad mit einem möglichen GStreamer-GL-/Interop-Pfad. Freigegeben wird nur ein Pfad, bei dem Decoderoberfläche, Effekt-/Composite-Pässe und Ausgabe ohne regulären GPU→CPU→GPU-Rundweg arbeiten. Die Backend-Schnittstelle wird vor den Effekten festgelegt; ein späterer Backendwechsel darf Projekt-, Parameter-, DMX- oder Web-API nicht verändern. + +--- + +## 8. Repository-Struktur + +```text +/ +├─ README.md +├─ PLAN.md +├─ STATUS.md +├─ ERRORS.md +├─ TEST_REPORT.md +├─ CHANGELOG.md +├─ pyproject.toml +├─ uv.lock +├─ package.json +├─ pnpm-lock.yaml +├─ apps/ +│ ├─ launcher/ +│ ├─ control_server/ +│ ├─ renderer/ +│ └─ web/ +├─ native/ +│ └─ render_bridge/ +├─ packages/ +│ ├─ domain/ +│ ├─ protocol/ +│ ├─ parameter_engine/ +│ ├─ render_backend/ +│ │ ├─ d3d11/ +│ │ └─ gl_gles/ +│ ├─ 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/ +``` + +Leere Zukunftsverzeichnisse müssen nicht erzeugt werden. Die Struktur beschreibt Eigentumsgrenzen. Code darf nicht beliebig zwischen Paketen quer importiert werden. + +--- + +## 9. Portable Verzeichnisstruktur der Windows-Ausgabe + +```text +HMS-MediaEngine-Portable/ +├─ HMS-MediaEngine.exe +├─ renderer.exe +├─ app/ +├─ runtime/ +│ ├─ python/ +│ ├─ gstreamer/ +│ └─ vc-runtime/ +├─ web/ +├─ plugins/ +│ ├─ builtin/ +│ └─ user/ +├─ projects/ +├─ media/ +├─ userdata/ +│ ├─ database/ +│ ├─ cache/ +│ ├─ sync-staging/ +│ ├─ identity/ +│ ├─ thumbnails/ +│ └─ plugin-cache/ +├─ config/ +│ ├─ app.toml +│ ├─ artnet.toml +│ ├─ cluster.toml +│ └─ displays.toml +├─ logs/ +├─ licenses/ +└─ PORTABLE_MODE +``` + +### 9.1 Portable-Pfadregeln + +- Alle internen Pfade werden relativ zum Anwendungsroot oder Projektroot gespeichert. +- Keine hart codierten Laufwerksbuchstaben. +- Keine Abhängigkeit vom aktuellen Working Directory. +- Medien dürfen intern kopiert oder als relative externe Referenz eingebunden werden. +- Beim Projekt-Export werden Medien optional gesammelt und Pfade neu geschrieben. +- Temporäre Dateien liegen in `userdata/cache`, nicht unkontrolliert im Betriebssystemprofil. +- Schreibbarkeit des Ordners wird beim Start geprüft. +- Bei schreibgeschütztem Medium startet die Anwendung sichtbar im Read-only-Modus. + +### 9.2 Startablauf + +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 und – falls konfiguriert – die Verbindung zum Coordinator aufgebaut. +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. + +--- + +## 10. Domänenmodell + +Alle Modelle werden in einem plattformneutralen Paket definiert und sowohl für Persistenz als auch API/IPC verwendet. + +### 10.1 Hauptobjekte + +#### Project + +- `id: UUID` +- `schema_version: int` +- `name: str` +- `created_at`, `updated_at` +- `settings` +- `media_assets[]` +- `compositions[]` +- `scenes[]` +- `timelines[]` +- `outputs[]` +- `control_bindings[]` +- `audio_profiles[]` +- `automation_policies[]` +- `plugin_requirements[]` + +#### Composition + +- `id` +- `name` +- `width`, `height`, `fps` +- `color_space` +- `background_color` +- `layers[]` in eindeutiger Z-Reihenfolge +- `duration` optional + +#### Layer + +- `id` +- `name` +- `enabled` +- `layer_type` +- `source` +- `opacity` +- `blend_mode` +- `transform` +- `crop` +- `color_controls` +- `effects[]` +- `mask` optional +- `target_group_id` optional +- `dmx_patch` optional + +#### Source + +- `plugin_id` +- `plugin_version` +- `source_type` +- `asset_id` optional +- `parameters` +- `playback_state` +- `in_point`, `out_point` +- `loop_mode` +- `speed` + +#### EffectInstance + +- `id` +- `plugin_id` +- `plugin_version` +- `scope: source|layer|group|master|output` +- `order_index` +- `enabled` +- `mix` +- `effect_blend_mode` +- `parameters` +- `preset_id` optional +- `quality_mode: auto|fixed` +- `requested_quality` und nicht persistierter `resolved_quality` +- `bypass_on_error` + +#### PresetScene + +- referenzierter Composition-Zustand oder Parameter-Diff; +- Übergangstyp und -dauer; +- Preload-Hinweise; +- Trigger-Metadaten. + +#### Cue – reserviertes Modell nach dem MVP + +- referenziert später Preset, Parameteraktion, Medienaktion oder Kontrollbefehl; +- optional quantisierte Ausführung auf Beat/Timecode; +- Pre-/Post-Wait, Follow/Continue, Playhead-/Standby-Metadaten; +- wird in V1 weder in der Standard-UI angezeigt noch für direkten Presetabruf benötigt. + +#### OutputSurface + +- `id`, `node_id`, `display_id` +- physische Auflösung und Bildrate; +- Canvas-Ausschnitt; +- Zielrechteck; +- Warp-Mesh; +- Edge-Masks; +- Gamma/Farbkorrektur; +- Rotation/Flip; +- Testbildmodus; +- Fallbackverhalten. + +#### Node + +- `id: UUID` als persistente, netzwerkunabhängige Identität; +- Anzeigename, Hostname und zuletzt bekannte Endpunkte; +- Rollen und Capability-Digest; +- Software-/Protokollversion; +- Vertrauens-/Paarungsstatus; +- Renderbackend, Decoder, Outputs und Leistungs-Tier; +- Health, Clock-Offset/Drift, FPS und letzte Heartbeat-Zeit; +- aktive Projekt-/State-/Content-Revision. + +#### ServerGroup + +- `id`, `name`; +- `node_ids[]`; +- Zielregel `all`, `selected`, `tag_query` oder feste Node-Liste; +- optionale Output-/Layer-Zuordnung; +- Sync- und Fehler-Policy; +- Art-Net-Gruppenpatch optional. + +#### Cluster + +- `id`, `name`; +- `coordinator_node_id`; +- gepaarte Nodes und Gruppen; +- gewünschte Projekt-/Content-Revision; +- Discovery- und Netzwerkprofil; +- Clock- und Sync-Policy; +- rollenbasierte Tokens/Zertifikats-Fingerprints. + +#### ContentManifest + +- Manifest-ID und Revision; +- Projekt-, Medien- und Plugin-Einträge; +- relativer portabler Pfad, Größe und SHA-256 je Datei; +- Plugin-API-/Backend-Anforderungen; +- optionale Chunk-Hashes für Resume; +- Signatur-/Herkunftsmetadaten; +- Freigabestatus je Node. + +### 10.2 Parameterpfad + +Jeder steuerbare Parameter besitzt einen stabilen Pfad, beispielsweise: + +```text +composition/{composition_id}/layer/{layer_id}/opacity +composition/{composition_id}/layer/{layer_id}/source/speed +composition/{composition_id}/layer/{layer_id}/effect/{effect_id}/parameter/radius +output/{output_id}/edge/left/gamma +cluster/group/{group_id}/scene/activate +cluster/node/{node_id}/output/{output_id}/intensity +master/intensity +``` + +Parameterpfade werden niemals aus sichtbaren Namen gebildet. Umbenennungen dürfen Automationen und DMX-Bindings nicht zerstören. + +--- + +## 11. Parameter- und Control-Engine + +Alle Steuerquellen arbeiten über eine gemeinsame Parameter-Engine. Direkte Seiteneffekte aus Art-Net-, Browser-, Timeline-, Audio- oder KI-Code auf den Renderer sind verboten. + +### 11.1 Auswertungsreihenfolge + +```text +Defaultwert +→ Projekt-/Szenenbasis +→ Timeline-Automation +→ deterministische Modulatoren und Audio +→ Browser-/MIDI-/OSC-/Art-Net-Override +→ Sicherheitsregeln und Wertebegrenzung +→ Frame-Snapshot an Renderer +``` + +### 11.2 Prioritäten + +1. Not-Aus/Blackout und Sicherheitslogik +2. expliziter manueller Operator-Override +3. freigegebener Lichtpult-Override +4. Browser-Livebedienung +5. Timeline/Cue +6. Audio-/LFO-Modulatoren +7. KI-Automation +8. Defaultwert + +Die Priorität kann je Binding konfiguriert werden, aber KI darf niemals eine Sicherheitsregel überstimmen. + +### 11.3 Übernahmeverfahren + +- LTP für Auswahl-, Positions- und Effektparameter; +- HTP optional für Intensität/Opacity; +- Soft-Takeover für Browser/MIDI; +- Ownership mit Timeout; +- `release`-Command zur Rückgabe an die nächstniedrige Quelle; +- DMX-Ausfallmodus: `hold`, `fade_to_scene`, `fade_to_black` oder `disable_source`; +- geglättete Parameter mit Attack/Release oder definierter Interpolation; +- Trigger werden flankenbasiert und nicht als Dauerzustand ausgewertet. + +### 11.4 Frame-Konsistenz + +Alle für ein Bild geltenden Parameter werden vor dem Frame als unveränderlicher Snapshot gebildet. Es darf kein halber Zustand gerendert werden, bei dem beispielsweise X bereits geändert, Y aber noch alt ist. + +--- + +## 12. Renderarchitektur + +### 12.1 Rendergraph + +```text +Media Decode / Generator +→ Source Texture +→ Source Color Conversion +→ Effect Chain +→ Crop / Transform / Mask +→ Layer Composite +→ Adjustment Layers +→ Master Effects +→ Master Canvas +→ Output Slice / Warp / Edge / Color +→ Display Sink oder Pixel Mapper +``` + +### 12.2 Grundregeln + +- Decodierte Frames bleiben möglichst vom Decoder bis zur Ausgabe im GPU-Speicher. +- CPU-Readback ist im normalen HDMI-Renderpfad verboten. +- Jeder unnötige Farbformatwechsel ist zu vermeiden und zu messen. +- Shader werden vor Aktivierung kompiliert. +- Medienwechsel werden vorgepuffert und atomar umgeschaltet. +- Bei Pluginfehler wird der Effekt überbrückt; der Layer bleibt aktiv. +- Der Renderer arbeitet mit einer festen Master-Bildrate. +- Quellen mit anderer Bildrate werden kontrolliert synchronisiert. +- Audio und Video verwenden eine gemeinsame monotone Zeitbasis. + +### 12.3 Layer-Typen + +#### Media Layer + +- Video +- Standbild +- Bildsequenz später +- Kamera/Capture später +- Netzwerkstream später + +#### Generator Layer + +- Shader erzeugt ein Bild ohne Medienquelle. +- Standard-Inputs: Zeit, Phase, Auflösung, Farben, Seed, Audiofeatures. + +#### Adjustment Layer + +- verarbeitet die bereits zusammengesetzten Layer unterhalb beziehungsweise innerhalb einer Zielgruppe; +- Beispiele: Blur, Color Grade, Distortion, Feedback; +- Zielbereich muss explizit gespeichert werden. + +#### Group Layer + +- gruppiert mehrere Layer; +- eigene Opacity, Transform und Effektkette; +- verhindert spätere Sackgassen beim Mapping und bei komplexen Shows. + +### 12.4 Blend-Modi V1 + +- Normal +- Add +- Multiply +- Screen +- Lighten +- Darken +- Difference +- Overlay +- Alpha Premultiplied + +Blend-Modi müssen mit Golden-Image-Tests geprüft werden. + +### 12.5 Playback + +- Play, Pause, Stop, Retrigger; +- Loop, Once, Ping-Pong nur wenn technisch unterstützt; +- variable Geschwindigkeit; +- In-/Out-Punkte; +- normalisierte Position; +- Preload; +- Ende-Ereignis; +- optional Audio an/aus und Lautstärke. + +Rückwärtswiedergabe und harte Random-Seeks werden nur für dafür geeignete Medien garantiert. Die Oberfläche muss sichtbar anzeigen, wenn ein Codec diese Funktionen nicht performant unterstützt. + +### 12.6 Plattform-Backend-Vertrag + +Der Rendergraph ist fachlich einheitlich, seine GPU-Implementierung jedoch plattformspezifisch. Projektdatei, Effektparameter, Layerlogik und DMX-Slots dürfen keinen D3D11-, HLSL-, OpenGL- oder GLSL-Typ enthalten. + +| Plattform | Primärer GPU-residenter Pfad | Verbotener Normalpfad | +| --- | --- | --- | +| Windows 11 | GStreamer-Hardwaredecoder mit `D3D11Memory` → D3D11-Texturen → D3D11-Compositor/eigene HLSL-Pässe → DXGI-Swapchain beziehungsweise `d3d11videosink` | Decoder → System-RAM/`appsink` → erneuter GPU-Upload | +| Linux x64 | Hardwaredecode/DMABuf oder GLMemory → OpenGL-Texturen → GLSL → nativer Display-Sink | synchrones CPU-Readback je Frame | +| Raspberry Pi 5 | verfügbarer V4L2-/GStreamer-Hardwarepfad → DMABuf/GL ES → GLES-Shader → Display-Sink | Softwaredecode als stiller Ersatz | + +Das interne `RenderBackend`-Interface stellt mindestens bereit: + +- Geräte-/Adapterauswahl und Capability-Abfrage; +- Decoderoberflächen importieren; +- Render-Targets und Texturen anlegen; +- Shader-/Effektpässe ausführen; +- Layer compositen; +- Output-Slice präsentieren; +- GPU-Fences/Timing auslesen; +- ausschließlich expliziten asynchronen Readback für Preview/Pixel-Mapping anfordern. + +Jeder Frame meldet seinen Memory-Typ. Ein unerwarteter Übergang in System-RAM setzt die Quelle auf `degraded`, erzeugt eine sichtbare Warnung und wird in Performance-Abnahmetests als Fehler gewertet. + +--- + +## 13. Medienpipeline und Medienbibliothek + +### 13.1 GStreamer + +GStreamer übernimmt: + +- Container-Demuxing; +- Decoderwahl; +- Hardwaredecoding; +- Zeitstempel und Synchronisation; +- Audio-Decoding; +- GPU-Upload beziehungsweise GPU-Memory-Weitergabe; +- Preview-Encoding; +- optional Transcoding-Hilfsfunktionen. + +Der Renderer protokolliert pro Quelle: + +- verwendeter Decoder; +- Hardware- oder Softwarepfad; +- Eingangsformat; +- GPU-Memory-Typ; +- Decodierzeit; +- Queuefüllstand; +- verworfene Frames; +- Farbkonvertierungen. + +Unter Windows werden `d3d11h264dec`/passende D3D11-Decoder, `d3d11convert`, `d3d11compositor` und der D3D11-Ausgabepfad als erste Kandidaten geprüft. Die tatsächlich verfügbare Elementkette wird zur Laufzeit aus Capability-Tests gewählt und vollständig geloggt. Ein OpenGL-Pfad unter Windows ist nur zulässig, wenn Phase 0 dessen Interop ohne regelmäßige CPU-Kopie nachweist und er auf der Referenzhardware messbar besser oder stabiler ist. + +### 13.2 Medienasset + +Ein Asset speichert: + +- stabile UUID; +- relativen Pfad; +- Dateigröße und Änderungszeit; +- Content-Hash optional beziehungsweise bei Projektpaketen verpflichtend; +- Container und Codecs; +- Breite, Höhe, FPS, Dauer; +- Alpha-Unterstützung; +- Audiostreams; +- Thumbnail-/Proxy-Pfade; +- Eignung für Seek/Reverse; +- Analyse- und Transcodingstatus. + +### 13.3 Import + +- Import blockiert niemals den Renderthread. +- Metadaten werden asynchron ermittelt. +- Thumbnails werden im Hintergrund erzeugt. +- Doppelte Assets werden erkannt. +- Fehlende Dateien werden sichtbar markiert und können neu verknüpft werden. +- Medienbank und Clipnummer sind explizite Show-Metadaten, nicht von Dateinamen abhängig. + +### 13.4 Codecstrategie + +V1 priorisiert verbreitete hardwaredecodierbare H.264-/H.265-Dateien. Zusätzlich ist ein für schnelles Triggern geeigneter Intra-Frame- beziehungsweise GPU-freundlicher Medienpfad vorzusehen. Der konkrete zusätzliche Codec wird erst nach Lizenz-, GStreamer- und Performancetest per ADR festgelegt. + +--- + +## 14. Plugin-System + +### 14.1 Pluginarten + +| 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 | + +### 14.2 Plugin-Paket + +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 +``` + +### 14.3 Beispielmanifest + +```json +{ + "schema_version": 1, + "id": "com.hms.fx.gaussian_blur", + "name": "Gaussian Blur", + "version": "1.0.0", + "api_version": 1, + "kind": "filter", + "vendor": "HMS", + "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"} + ] + } + }, + "capabilities": { + "minimum_tier": "DESKTOP_LITE", + "requires_input_texture": true, + "supported_backends": ["d3d11", "gl", "gles"] + }, + "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"] + }, + "parameters": [ + { + "id": "radius", + "label": "Radius", + "type": "float", + "minimum": 0.0, + "maximum": 40.0, + "default": 0.0, + "dmx_slots": [1], + "curve": "quadratic" + }, + { + "id": "quality", + "label": "Quality", + "type": "enum", + "values": ["auto", "low", "medium", "high"], + "default": "auto", + "dmx_slots": [2] + } + ], + "failure_mode": "bypass" +} +``` + +### 14.4 Standard-Shaderinputs + +Jeder Shader erhält nach Bedarf: + +- `u_input_texture` +- `u_resolution` +- `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 Pluginparameter + +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. + +### 14.5 Plugin-Lebenszyklus + +```text +discovered → validated → installed → enabled → compiled → active + ↘ quarantined / incompatible +``` + +Validierung umfasst: + +- 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. + +### 14.6 Sicherheitsgrenze + +- 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. + +### 14.7 DMX-Parameter-Slots + +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. + +### 14.8 Gemeinsamer Effektvertrag + +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. + +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. + +--- + +## 15. Eingebautes Generator- und Effekt-Starterpaket + +Die Auswahl orientiert sich funktional an den grundlegenden Echtzeitmustern von MADRIX und am Effekt-Workflow von Resolume, ohne deren Code, Presets, Namen oder Assets zu übernehmen. Ziel ist keine dreistellige Effektmenge, sondern ein kleiner, verlässlicher Grundstock, aus dem durch Layering, Parameter und Effektketten viele Looks entstehen. + +### 15.1 Pflichtgeneratoren für V1 + +| Interne Plugin-ID | Funktion | DMX-/Hauptparameter G1–G8 | Adaptive Quality | +| --- | --- | --- | --- | +| `hms.generator.solid` | einfarbige Fläche | Farbe, Alpha | nicht erforderlich | +| `hms.generator.gradient` | linearer/radialer Mehrfarbenverlauf und Color Scroll | Palette, Typ, Winkel, Zentrum X/Y, Breite, Phase, Geschwindigkeit | Anzahl Farbstopps bleibt gleich; nur interne Abtastung darf variieren | +| `hms.generator.checker_grid` | Checkerboard, Raster und Kacheln | Farbe A/B, Zellgröße, Linienbreite, Offset X/Y, Winkel, Geschwindigkeit | nicht erforderlich | +| `hms.generator.stripes_chaser` | Streifen, Lauflicht und weiche Chaser | Palette, Breite, Abstand, Winkel, Richtung, Phase, Geschwindigkeit, Softness | nicht erforderlich | +| `hms.generator.noise_clouds` | Noise, Wolken und organische Texturen | Palette, Seed, Skala, Detail, Kontrast, Geschwindigkeit, Richtung, Morph | Octaves/Detail intern reduzieren | +| `hms.generator.plasma` | flüssiger, bewegter Farbverlauf | Palette, Skala, Stretch X/Y, Distanz, Phase, Geschwindigkeit, Seed | interne Frequenzzahl reduzieren | +| `hms.generator.wave_bars` | Sinus-/Dreieckwellen, Balken und Röhren | Palette, Wellenform, Anzahl, Amplitude, Frequenz, Winkel, Phase, Geschwindigkeit | Anzahl/Antialiasing intern reduzieren | +| `hms.generator.shapes` | Kreis, Rechteck, Dreieck, Linie und Polygon | Form, Anzahl, Größe, Rotation, Verteilung, Fill/Stroke, Palette, Seed | Formanzahl innerhalb deklarierter Grenze reduzieren | +| `hms.generator.drops_ripples` | fallende Punkte, Tropfen und konzentrische Wellen | Palette, Dichte, Größe, Lebensdauer, Geschwindigkeit, Ripplebreite, Richtung, Seed | maximale aktive Elemente reduzieren | +| `hms.generator.starfield` | Sternfeld beziehungsweise leichter Partikelraum | Palette, Anzahl, Größe, Geschwindigkeit, Richtung, Tiefe, Twinkle, Seed | Partikelzahl und interne Tiefe reduzieren | + +Generatoren verwenden eine lokale, speicherbare Phase. Sie unterstützen `free_run`, `restart_on_load`, `manual_phase` und später `beat_sync`. Gleicher Seed, gleiche Phase und gleiche Parameter müssen auf allen Backends innerhalb definierter Bildtoleranz dasselbe Muster liefern. + +### 15.2 Pflichtfilter für V1 + +| Interne Plugin-ID | Wirkung | P1–P8 beziehungsweise Hauptparameter | Last-/Sicherheitsregel | +| --- | --- | --- | --- | +| `hms.fx.transform2d` | zusätzlicher, wiederholbarer Transform in der Effektkette | Position X/Y, Scale X/Y, Rotation, Anchor, Wrap/Tile | keine Qualitätsreduktion; Geometrie bleibt exakt | +| `hms.fx.color_adjust` | Hue, Saturation, Brightness, Contrast, Gamma sowie Grayscale/Invert | Hue, Saturation, Brightness, Contrast, Gamma, Temperatur/Tint, Mode, Mix | ein GPU-Pass | +| `hms.fx.gradient_map` | Luminanz auf zwei Farben oder einen Verlauf abbilden | Palette, Low/High, Balance, Smoothness, Preserve Alpha, Invert, Mode, Mix | ein GPU-Pass | +| `hms.fx.blur_sharpen` | Gaussian Blur und Unsharp/Sharpen | Mode, Radius, Strength, Direction, Edge Mode, Quality, Threshold, Mix | separierbarer Blur; Downsample und Samplezahl über Auto Quality | +| `hms.fx.pixelate_quantize` | Mosaik/Pixelate und Posterize/Quantization | Pixel X/Y, Color Levels, Dither, Grid Offset, Aspect Lock, Mode, Mix | ein bis zwei GPU-Pässe | +| `hms.fx.mirror_tile` | horizontal/vertikal spiegeln, wiederholen und Teile tauschen | Mode, Tiles X/Y, Offset X/Y, Center, Rotation, Mix | ein GPU-Pass | +| `hms.fx.kaleidoscope` | radiale oder rechteckige Mehrfachspiegelung | Segments, Rotation, Center X/Y, Zoom, Mirror Mode, Phase, Mix | Segmentzahl semantisch unverändert; ein GPU-Pass | +| `hms.fx.wave_displace` | Wave-, Turbulence- und Noise-Displacement | Amount X/Y, Frequency, Angle, Phase, Speed, Noise Scale, Edge Mode | Noise-Detail adaptiv, Verformungsbetrag unverändert | +| `hms.fx.rgb_split` | RGB-Versatz/chromatische Aberration | Amount, Angle, Radial, Center X/Y, Red/Blue Balance, Edge Mode, Mix | ein GPU-Pass | +| `hms.fx.glow_bloom` | helle Bildteile weich aufleuchten lassen | Threshold, Knee, Radius, Intensity, Tint, Quality, Composite Mode, Mix | Downsample-Kaskade; höchste Stufe nur bei Budget | +| `hms.fx.edge_emboss` | Edge Detect, Outline und Emboss | Mode, Strength, Radius, Threshold, Direction, Color, Background, Mix | ein bis zwei GPU-Pässe | +| `hms.fx.vignette` | Randabdunklung/-färbung | Amount, Radius, Softness, Roundness, Center X/Y, Color, Invert, Mix | ein GPU-Pass | +| `hms.fx.strobe_pulse` | zeitgesteuertes Blinken/Pulsieren | Rate, Duty, Attack, Release, Phase, Sync Mode, Minimum Level, Mix | Projektweiter Safety-Lock; oberhalb der sicheren Voreinstellung nur nach expliziter Freigabe, nie durch DMX allein freischaltbar | +| `hms.fx.feedback_trails` | Feedback, Echo und Bewegungsspuren | Decay, Zoom, Rotation, Offset X/Y, Hue Shift, Blend, Clear | eigener Double Buffer; interne Auflösung adaptiv; `Clear` als flankenbasierter Trigger | + +`Transform2D` ergänzt den festen Layer-Transform: Der feste Transform bestimmt die grundlegende Platzierung; zusätzliche Transformeffekte können an jeder Stelle der Kette stehen. Dadurch sind beispielsweise „skalieren → spiegeln → verzerren → erneut platzieren“ möglich. + +### 15.3 Bedien- und Presetpflichten + +- Effektbrowser mit Suche, Kategorien `Color`, `Blur`, `Distort`, `Stylize`, `Transform`, `Temporal` und Favoriten; +- Effekt per Drag-and-drop oder Suchdialog in einen freien Slot beziehungsweise eine Adjustment-/Master-Kette einfügen; +- Live-Preview vor dem Commit, ohne den aktiven Output zu verändern; +- Reihenfolge per Drag-and-drop, Copy/Paste zwischen Layern und Reset je Instanz; +- benannte Benutzerpresets speichern, duplizieren, exportieren und fehlertolerant migrieren; +- jeder Effekt besitzt Mix und Blend-Modus; Mix `0` muss den Effekt kostengünstig bypassen; +- Parameterwerte bleiben beim Wechsel der Auto-Qualitätsstufe semantisch identisch; +- Feedback besitzt eine explizite `Clear`-Funktion und wird beim Projektwechsel kontrolliert geleert; +- Generator- und Effektparameter werden automatisch in Inspector, REST/WebSocket und DMX-Slot-Dokumentation veröffentlicht. + +### 15.4 Abnahme jedes eingebauten Plugins + +Für jeden Generator und Filter 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. + +### 15.5 Nach dem V1-Starterpaket + +- Chroma-/Luma-Key und Maskenwerkzeuge; +- LUT-/erweitertes Color Grading; +- Lens-/Fisheye-/Perspective-Distortion; +- Fire, Fluid, Metaballs und komplexe Partikelsysteme; +- Text, Clock und Ticker; +- Audio Spectrum, Waveform und beat-synchronisierte Varianten; +- Motion Detection/Optical Flow; +- Übergangspaket und node-basierter Effekt-Editor. + +Jedes eingebaute Plugin dient zugleich als dokumentiertes SDK-Beispiel. Produktnamen anderer Hersteller werden nicht als Plugin-IDs oder Effektbezeichnungen verwendet. + +--- + +## 16. Art-Net- und Fixture-Konzept + +### 16.1 Netzwerk + +- UDP-Port 6454 gemäß Art-Net. +- auswählbare Netzwerkschnittstelle; +- optional an konfigurierte Sender-IP binden; +- ArtPoll empfangen und ArtPollReply senden; +- ArtDMX-Sequenznummer auswerten, soweit vorhanden; +- Universe-Zählweise in UI eindeutig darstellen; +- Empfangstelemetrie pro Universe; +- Paketverlust, Jitter und letzter Empfang sichtbar; +- mehrere Sender nicht stillschweigend zusammenführen. + +### 16.2 Fixture-Strategie + +V1 verwendet: + +- `HMS MediaEngine Master 32ch` +- `HMS MediaEngine Layer 64ch` + +Der Patch ist frei konfigurierbar. Ein Layer-Fixture belegt exakt 64 Kanäle. Acht Layer entsprechen damit exakt einem DMX-Universe. Das Master-Fixture liegt separat oder auf einer anderen freien Adresse. + +#### Mehrserver-Patch für ein Lichtpult + +- Jeder Server verwendet dieselben Personality-Typen, aber einen eigenen, eindeutigen Universe-Bereich. +- Empfohlene Übertragung ist Art-Net-Unicast an die jeweilige Node-IP; Broadcast bleibt optional für einfache Netze. +- Der Coordinator führt einen zentralen Universe-Plan und markiert Überschneidungen zwischen gepaarten Nodes als Blocker vor dem Show-Lock. +- Ein Patch-Export enthält Node-ID, Art-Net Short Name, IP/Hostname, Net/Sub-Net/Universe, Startadresse, Layernummer und Fixture-Version. +- Jeder Node zeigt live, von welcher Sender-IP und auf welchem Universe Werte eintreffen. Mehrere Sender benötigen eine explizite Merge-/Prioritätsregel. +- Ändert sich eine IP, bleibt der Patch logisch an der Node-ID gebunden; das Pult muss je nach Pultmodell dennoch auf die neue Unicast-Adresse aktualisiert werden. +- Der Mindesttest umfasst ein Lichtpult, zwei Server, je ein Master-Fixture und zusammen mindestens acht Layer-Fixtures ohne Universe-Kollision. + +### 16.3 Master-Fixture 32ch – Entwurf V1 + +| Kanal | Parameter | Auflösung/Verhalten | +| ---: | --- | --- | +| 1–2 | Master Intensity | 16 Bit | +| 3 | Blackout | Trigger/Schalter, höchste Priorität | +| 4 | Freeze Output | Schalter | +| 5 | Preset Bank | 8 Bit | +| 6–7 | Preset Index | 16 Bit | +| 8 | Preset Recall | steigende Flanke, direkter Abruf ohne Cue-GO-Logik | +| 9 | Transition Type | Enum | +| 10–11 | Transition Duration | 16 Bit, konfigurierter Maximalwert | +| 12–13 | Global Speed | 16 Bit | +| 14–15 | BPM | 16 Bit | +| 16 | Tap Tempo | steigende Flanke | +| 17–21 | reserviert für spätere 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–32 | reserviert | müssen neutral ignoriert werden | + +### 16.4 Layer-Fixture 64ch – Entwurf V1 + +| Kanal | Parameter | Auflösung/Verhalten | +| ---: | --- | --- | +| 1 | Layer Enable | Schalter | +| 2–3 | Opacity | 16 Bit | +| 4 | Source Type | Media/Generator/Live/Solid usw. | +| 5 | Media Bank | 8 Bit | +| 6 | Media Folder | 8 Bit | +| 7–8 | Media/Plugin Index | 16 Bit | +| 9 | Load/Commit Selection | steigende Flanke | +| 10 | Transport | Stop/Play/Pause/Retrigger | +| 11 | Loop Mode | Enum | +| 12 | Playback Direction/Mode | Enum | +| 13–14 | Playback Speed | 16 Bit, signed mapping | +| 15–16 | Playback Position | 16 Bit normalisiert | +| 17–18 | In Point | 16 Bit normalisiert | +| 19–20 | Out Point | 16 Bit normalisiert | +| 21 | Blend Mode | Enum | +| 22 | Transform Mode/Anchor | Enum | +| 23–24 | Position X | 16 Bit signed | +| 25–26 | Position Y | 16 Bit signed | +| 27–28 | Scale X | 16 Bit | +| 29–30 | Scale Y | 16 Bit | +| 31–32 | Rotation | 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–51 | FX1 Parameter P1–P8 | je 8 Bit oder manifestgebundene Paare | +| 52 | FX2 Enable | Schalter | +| 53 | FX2 Plugin Select | 8 Bit Show-Registry | +| 54 | FX2 Mix | 8 Bit | +| 55–62 | FX2 Parameter P1–P8 | je 8 Bit oder manifestgebundene Paare | +| 63 | Layer Retrigger/Reset | steigende Flanke | +| 64 | reserviert | neutral ignorieren | + +#### Modusabhängiger Source-Parameterblock 13–20 + +Die Kanäle 13–20 sind ein stabiler Source-Block mit pultseitigen Modes: + +- bei `Media`: 13–14 Speed, 15–16 Position, 17–18 In Point, 19–20 Out Point wie in der Tabelle; +- bei `Generator`: acht generische Slots `G1` bis `G8` oder manifestdefinierte 16-Bit-Paare; +- bei `Solid`: Farbe/Alpha über definierte G-Slots; +- bei einem späteren Live-/Capture-Plugin: manifestdefinierte Source-Slots mit neutralen Defaults. + +Der Fixture-Generator erzeugt für jeden eingebauten Generator einen Mode mit den sprechenden Parameternamen aus Abschnitt 15. Ein Wechsel von Source Type oder Plugin Index interpretiert vorhandene Faderwerte erst nach `Load/Commit Selection` neu. Pickup/Takeover verhindert Parametersprünge. + +### 16.5 Auswahl- und Ladeverhalten + +Änderungen an Bank/Folder/Index erzeugen zunächst nur eine `pending selection`. Erst eine steigende Flanke auf `Load/Commit Selection` löst Preload und Umschaltung aus. Optional darf ein konfigurierbarer Auto-Load-Modus mit Debounce angeboten werden. Dadurch lädt ein langsam bewegter Fader nicht dutzende Dateien hintereinander. + +### 16.6 Speed-Mapping + +Das genaue signed 16-Bit-Mapping wird dokumentiert und getestet. Vorgabe: + +- untere Hälfte: negative Geschwindigkeit bis 0; +- Mittelpunkt: Pause; +- obere Hälfte: positive Geschwindigkeit bis konfiguriert maximal 4×; +- ein definierter, dokumentierter Wert entspricht exakt 1×; +- Totzone um Pause optional; +- fehlerhafte oder nicht unterstützte Reverse-Wiedergabe wird sichtbar gemeldet. + +### 16.7 Fixtures und GDTF + +Die Software erzeugt zunächst eine menschenlesbare CSV-/PDF-Kanalliste. Danach folgt ein Generator für GDTF beziehungsweise pultspezifische Personality-Dateien. Generierte Dateien müssen dieselbe Fixture-Schema-Version wie der Server ausweisen. + +### 16.8 Art-Net-Pixel-Ausgabe + +Spätere Output-Plugin-Pipeline: + +```text +Canvas oder Output Slice +→ Pixel-Mapping +→ GPU-Sampling +→ asynchroner Readback +→ Farb-/Gamma-Korrektur +→ RGB/RGBW-Kanalpackung +→ ArtDMX +→ ArtSync +``` + +Pflichtfunktionen: + +- RGB, RGBW und wählbare Kanalreihenfolge; +- Start-Universe und Startadresse; +- mehrere Fixtures/Flächen; +- Gamma und Masterdimmer; +- Universe-Rate standardmäßig maximal DMX-kompatibel; +- ArtSync für zusammengehörige Universen; +- kein synchroner GPU-Readback im Renderthread; +- Bandbreitenanzeige und Universe-Zähler. + +--- + +## 17. Browseroberfläche + +### 17.1 Visuelle Richtung: QLab-inspiriert, funktional eigenständig + +Als visuelle Referenz wurden die offiziellen QLab-5-Abbildungen des Workspace, der Cue-Liste, des Inspectors, der Seitenleiste mit aktiven Cues und des Cue-Carts geprüft. Übernommen werden ausschließlich allgemeine Gestaltungsprinzipien: + +- dunkle, neutrale Desktop-Arbeitsoberfläche; +- hohe Informationsdichte statt großer Dashboard-Karten; +- klare horizontale Tabellenzeilen, dünne Trennlinien und kompakte Werkzeugleisten; +- ein großer zentraler Arbeitsbereich mit andockbaren, größenveränderbaren Randbereichen; +- ein kontextabhängiger Inspector im unteren Bereich; +- eine schmale rechte Status-/Aktiv-Seitenleiste; +- sparsame Statusfarben und eine deutliche Auswahlfarbe; +- zeitkritische Angaben in tabellarisch ausgerichteter Monospace-Schrift. + +Nicht übernommen werden QLab-Logo, Icons, exakte Farben, Bildassets, Bezeichnungen, Layoutmaße oder Code. Der MVP besitzt ausdrücklich **keinen GO-Button, keine Standby-Anzeige, keine Cue-Carts und keine QLab-Cue-Logik**. Es wird auch kein funktionsloser GO-Platzhalter angezeigt. + +### 17.2 MVP-Workspace + +| Bereich | Inhalt und Verhalten | +| --- | --- | +| obere Statusleiste | Produkt-/Projektname, lokaler Node beziehungsweise aktive Servergruppe, Outputstatus, `Quality: Auto`, FPS, Art-Net und Clock/Sync als kleine Zustandsanzeigen | +| linke Werkzeugleiste | einklappbare Symbole für Mixer, Media, Effekte, Presets, Outputs, Netzwerk, Plugins und Diagnostics; Textlabels optional | +| mittlerer Arbeitsbereich | standardmäßig dichte Layer-Tabelle oder der jeweilige Editor; keine card-basierte Startseite | +| unterer Inspector | kontextabhängige Tabs für ausgewählten Layer, Source, Transform, Color, FX, DMX und Advanced; vertikal skalier- und vollständig einklappbar | +| rechte Seitenleiste | umschaltbar zwischen `Active Layers`, `Media Banks`, `Nodes` und `Alerts`; aktive Einträge zeigen Fortschritt, Status und direkte sichere Aktionen | +| Fußleiste | `Setup`/`Live`-Lock, Anzahl/Selektion, Warnungszähler, Netzwerkstatus sowie Schalter für Inspector/Seitenleisten | + +Alle Paneele lassen sich per Divider vergrößern, verkleinern oder einklappen. Die Anordnung wird pro Browser/Bedienplatz gespeichert, die Showdaten bleiben davon getrennt. + +### 17.3 Layer-Tabelle statt Kartenwand + +Die primäre Mixeransicht verwendet eine kompakte Tabelle. Pflichtspalten: + +| Spalte | Inhalt | +| --- | --- | +| Status | aktiv, pausiert, vorgeladen, Warnung, Fehler oder offline | +| Layer | Z-Reihenfolge und editierbarer Name | +| Typ | Media, Generator, Adjustment oder Group | +| Source | Clip-/Generatorname und Bank/Index | +| Target | lokaler Node, Servergruppe, Output oder Composition-Gruppe | +| Time | Position/Dauer beziehungsweise Generatorphase | +| Opacity | Wert plus schmaler Fader | +| Blend | aktueller Blend-Modus | +| FX | zwei Slots mit Bypass-/Fehlerstatus | +| Control | Web, Art-Net, Timeline/Automation und aktueller Owner | + +Selektierte Zeilen erhalten eine klare, eigenständige Akzentfläche; laufende Zustände werden nicht durch großflächig blinkende Farben dargestellt. Zeilenhöhe im Desktop-Density-Modus: ungefähr 28–32 px. Mindestens acht Layer müssen bei 1440 × 900 zusammen mit einem nutzbaren Inspector sichtbar sein. + +### 17.4 Inspector + +- Tabs und Felder ändern sich nach Objekttyp, behalten aber feste Positionen für gemeinsame Parameter. +- Zahlenfelder erlauben Tippen, Ziehen und Feinsteuerung per Tastatur. +- Zeitwerte, 16-Bit-DMX-Werte und Positionen sind numerisch präzise editierbar. +- Jeder Parameter zeigt bei Bedarf Automation/Art-Net-Ownership und einen `Release`-Befehl. +- Pluginparameter werden aus dem manifestierten UI-Schema erzeugt, sehen aber wie native Controls aus. +- Änderungen mit Renderwirkung werden serverseitig bestätigt; Fehler erscheinen direkt am Feld und zusätzlich in `Alerts`. + +### 17.5 Rechte Seitenleiste und Mehrserver-Ansicht + +`Active Layers` zeigt nur tatsächlich laufende oder vorgeladene Layer mit Thumbnail, Position, Node, Pause/Stop und Fehlerstatus. `Nodes` zeigt je Server: + +- Anzeigename, online/degraded/offline und Rolle; +- Output-Miniaturen beziehungsweise bewusst deaktiviertes Preview; +- Projekt-/Content-Revision und Übertragungsfortschritt; +- Clock-Offset/Drift und letzter synchroner Start; +- FPS, p95-Framezeit, Dropped Frames, GPU-/Speicherbudget; +- Art-Net-Sender, Paketrate und Universe-Bereich; +- Pairing-, Preflight- und Show-Lock-Status. + +Ein Filter oben im Workspace bestimmt das aktuelle Ziel: `Local`, `All`, Servergruppe, einzelner Node oder Output. Gruppenänderungen zeigen vor dem Commit, welche Nodes sie erhalten. + +### 17.6 Eigene Design-Tokens + +Ausgangswerte, die im Designsystem zentral definiert und später thematisierbar sind: + +- Flächen: fast schwarzes Anthrazit, zwei abgestufte Panelgraus und eine leicht hellere Hoverfläche; +- Text: helles Neutralgrau, gedämpftes Sekundärgrau, hoher Kontrast für wichtige Werte; +- Akzent: eigenes kühles Blau für Selektion/Fokus; +- Zustände: zurückhaltendes Grün, Gelb, Orange und Rot ausschließlich für betriebliche Bedeutung; +- Abstände: 4-/8-px-Raster; +- Radien: überwiegend 2–4 px, keine übermäßigen Pillen oder schwebenden Karten; +- Typografie: System-Sans beziehungsweise Inter-kompatibel; tabellarische Monospace-Ziffern für Zeit, DMX, FPS und Sync; +- Animationen: 100–180 ms für Paneele/Focus; keine dekorativen Animationen während `Live`. + +Status darf nie allein durch Farbe vermittelt werden; Icon, Text oder Muster ergänzen die Bedeutung. + +### 17.7 Betriebsmodi und Tastatur + +- `Setup`: Struktur, Patch, Plugins, Outputs und Netzwerk dürfen geändert werden. +- `Live`: strukturelle Änderungen sind gesperrt; Layerparameter, Presetabruf und ausdrücklich freigegebene Livefunktionen bleiben nutzbar. +- Blackout/Freeze bleiben dauerhaft erreichbar, erhalten aber eine eigene Form und Schutzlogik statt QLab-Transportbuttons zu kopieren. +- Die Leertaste hat im MVP **keine globale GO-Funktion**. Tastenkürzel sind konfigurierbar und werden in Eingabefeldern nie abgefangen. +- Pfeiltasten navigieren Tabellen; Enter öffnet den Inspector; Escape beendet einen Editiervorgang, löst jedoch keinen Blackout aus. +- Destruktive Aktionen benötigen Bestätigung. Projektaktivierung zeigt Ziel-Nodes und Preflight-Ergebnis. + +### 17.8 Preview + +- Preview ist vom Operator-Output getrennt. +- Ziel: maximal 720p, standardmäßig 10–15 FPS. +- Hardwareencoding bevorzugt. +- Deaktivierbar und erste Stufe der automatischen Leistungsreduktion. +- Eine langsame Browserverbindung darf den Renderer nicht bremsen. +- Für Mapping darf ein höherwertiger lokaler Previewmodus existieren. + +### 17.9 Netzwerk und Bedienerzugriff + +- Der Einrichtungsdialog bietet `Nur dieser Rechner` und `Show-LAN`; keine unbemerkte Freigabe ins LAN. +- Im Show-LAN wird genau eine ausgewählte Schnittstelle gebunden und mDNS-Discovery aktiviert. +- Beim ersten Aktivieren wird ein Zugangstoken beziehungsweise Passwort eingerichtet; Node-Paarung ist davon getrennt. +- WebSocket und REST verwenden dieselbe Authentisierung; CORS ist restriktiv. +- Mehrere Browserclients erhalten denselben autoritativen Zustand. +- Zustandsänderungen werden serverseitig bestätigt; reine optimistic UI genügt nicht. + +### 17.10 Visuelle Abnahme + +Playwright erzeugt stabile Referenz-Screenshots für 1366 × 768, 1440 × 900, 1920 × 1080 und ein Tabletprofil. Abnahmekriterien: + +- kein horizontaler Gesamtseiten-Scroll im Desktopprofil; +- Layer-Tabelle, Inspector und rechte Seitenleiste bleiben per Tastatur erreichbar; +- acht Layer sind bei 1440 × 900 ohne Überlagerung bedienbar; +- Zustände `active`, `paused`, `preloaded`, `warning`, `error`, `offline` und `out_of_sync` sind eindeutig unterscheidbar; +- lange Medien-/Node-Namen werden gekürzt, sind aber per Tooltip/Detailansicht vollständig lesbar; +- Layout bleibt bei 125 % und 150 % Browserzoom nutzbar; +- Preview-Ausfall verändert weder Layout noch Showoutput; +- helle, card-lastige oder marketingartige Dashboard-Varianten bestehen die Designabnahme nicht. + +### 17.11 Spätere QLab-ähnliche Fähigkeiten + +Erst in einer ausdrücklich später freigegebenen Produktphase darf ein optionaler `Cue Workspace` hinzukommen: Cue-Liste, Standby, GO, aktive Cues, Cue-Carts und Timeline. Diese Funktionen nutzen dann dieselbe Parameter-, Projekt-, Netzwerk- und Inspector-Infrastruktur, bleiben aber ein separates Modul und verändern den MVP-Workspace nicht. + +--- + +## 18. Presets/Szenen und spätere Cue-Erweiterung + +### 18.1 Preset/Szene im MVP + +Eine Szene enthält entweder: + +- vollständigen Composition-Snapshot; oder +- sparsamen Parameter-Diff gegenüber einem Basissnapshot. + +Die Implementierung muss eindeutig festlegen, welche Variante gespeichert wird. Empfehlung: intern normalisierter Snapshot mit deduplizierten Asset-/Pluginreferenzen; für Übergänge wird zur Laufzeit ein Diff berechnet. + +Im MVP wird ein Preset direkt aus Browser, Art-Net oder API geladen. Es gibt noch keinen Playhead, kein Standby und keinen GO-Ablauf. + +### 18.2 Cue nach dem MVP + +Das spätere optionale Cue-Modul referenziert: + +- Zielszene; +- Übergangstyp; +- Dauer; +- Preloadzeit; +- Quantisierung optional; +- Follow-Zeit optional; +- Trigger-ID; +- Notizen. + +### 18.3 Preset-Übergänge V1 + +- Cut +- Crossfade +- Dip to Black +- Wipe horizontal/vertical +- Luma Fade +- Plugin Transition + +Ein Übergang darf beim Laden eines Mediums nicht auf einen schwarzen, noch nicht bereiten Decoder umschalten. Bei nicht bestandenem Preload gilt die konfigurierte Policy: warten, altes Bild halten oder definierter Fallback. + +--- + +## 19. Timeline + +Die Timeline ist ausdrücklich ein Modul nach dem MVP. Sie wird architektonisch vorbereitet und nutzt später dieselbe Parameter-Engine wie Livebetrieb und DMX; im ersten Workspace wird kein funktionsloser Timeline- oder GO-Bereich eingeblendet. + +### 19.1 Tracktypen + +- Media Track +- Generator Track +- Effect Track +- Parameter Automation Track +- Scene/Cue Track +- Master Track +- Audio Track +- Marker/Trigger Track +- später Timecode-/External-Control-Track + +### 19.2 Timeline-Funktionen + +- Play, Pause, Stop, Scrub; +- In/Out und Loop; +- Zoom und Snap; +- Clips verschieben, trimmen und duplizieren; +- Keyframes; +- Interpolation: Step, Linear, Smooth/Cubic, Bezier; +- Marker und benannte Abschnitte; +- Zeitbasis Sekunden und SMPTE; +- optionale musikalische Zeitbasis BPM/Takte/Beats; +- externe Synchronisation später über ArtTimeCode beziehungsweise weitere Timecode-Adapter; +- Undo/Redo; +- Autosave. + +### 19.3 Echtzeitmodell + +- Timelinezeit basiert auf monotonic clock beziehungsweise externer Zeitquelle. +- Der Renderer erhält pro Frame deterministisch ausgewertete Werte. +- Scrubbing verwendet einen separaten Preview-/Seekpfad und darf den Liveoutput nur im ausdrücklich aktivierten Edit-Live-Modus verändern. +- DMX oder Browser kann Parameter temporär übernehmen; nach Release übernimmt die Timeline anhand definierter Pickup-/Return-Regel. + +### 19.4 Datenmodell + +```text +Timeline + ├─ timebase + ├─ duration + ├─ loop_range + └─ tracks[] + ├─ target_parameter_path oder layer_id + ├─ clips[] + └─ keyframes[] +``` + +Keyframes referenzieren stabile Parameterpfade, nicht UI-Komponenten. + +--- + +## 20. Audioanalyse und Sound-to-Light + +Audio-Reaktivität muss ohne KI vollständig funktionieren. KI ist später eine zusätzliche Planungsebene, niemals Voraussetzung für Beat- oder Pegelsteuerung. + +### 20.1 Audioeingänge + +- Windows WASAPI beziehungsweise plattformneutral über GStreamer; +- physischer Eingang; +- Loopback/Systemaudio optional; +- Audiospur eines Videos; +- Netzwerkquelle später; +- Testsignalgenerator. + +### 20.2 Analysefeatures + +- Peak und RMS; +- geglättete Lautheit; +- FFT-Spektrum; +- konfigurierbare Frequenzbänder; +- Bass, Low-Mid, Mid, High-Mid, Treble; +- Spectral Flux/Onset; +- Beat-Trigger; +- BPM-Schätzung; +- Beat-Phase und Confidence; +- Stilleerkennung; +- später musikalische Abschnittserkennung offline. + +### 20.3 Laufzeit + +- Audioaufnahme in eigenem Echtzeitpfad; +- Analyse beispielsweise 50–100 Mal pro Sekunde; +- Feature-Snapshots timestamped; +- kein LLM und keine Cloudanfrage im Audiothread; +- Ringbuffer statt unkontrollierter Queues; +- Latenz und Dropouts sichtbar. + +### 20.4 Mapping Engine + +Jedes Audiofeature kann über ein Binding auf einen Parameter wirken: + +```text +Audiofeature +→ Gate/Threshold +→ Normalisierung +→ Gain +→ Kurve +→ Attack/Release +→ Min/Max +→ optional Quantisierung +→ Zielparameter +``` + +Beispiele: + +- Bass steuert Blur Radius. +- Beat triggert einen Chaser-Step. +- Treble steuert Partikeldichte. +- RMS steuert Layer-Opacity. +- Beat-Phase steuert Gradient-Position. + +Bindings sind speicherbar, aktivierbar, priorisierbar und in der UI live sichtbar. + +### 20.5 Modulatoren ohne Audio + +- LFO Sine/Triangle/Saw/Square; +- Random mit Seed; +- Envelope; +- Step Sequencer; +- BPM-synchroner Chaser; +- mathematische Kombination mehrerer Quellen. + +--- + +## 21. KI- und Automationsfähigkeit + +### 21.1 Grundsatz + +Eine KI greift niemals direkt auf: + +- GPU-/Rendererobjekte; +- GStreamer-Pipelines; +- SQLite; +- Projektdateien; +- Art-Net-Sockets; +- Plugin-Dateien + +zu. Sie nutzt ausschließlich das versionierte Automation/Command Gateway. + +### 21.2 KI-Tool-API + +Spätere Tools: + +- `get_system_capabilities` +- `list_projects` +- `get_active_show_state` +- `list_layers` +- `list_media` +- `list_plugins` +- `get_parameter_schema` +- `preview_command_plan` +- `set_parameter` +- `apply_scene` +- `create_scene` +- `create_timeline_draft` +- `bind_audio_feature` +- `start_timeline` +- `stop_timeline` +- `release_override` +- `get_diagnostics` + +Jeder schreibende KI-Command besitzt: + +- Request-ID; +- Actor/Provider/Model; +- erlaubten Scope; +- Dry-run-Möglichkeit; +- erwartete Zustandsrevision; +- Wertevalidierung; +- Rate-Limit; +- Audit-Eintrag; +- optional Bedienerfreigabe; +- Rollback- beziehungsweise Undo-Information. + +### 21.3 KI-Priorität + +KI arbeitet standardmäßig mit der niedrigsten aktiven Priorität. Manuelle Bedienung, Lichtpult, Timeline und Sicherheitsfunktionen können KI-Werte jederzeit überstimmen. + +### 21.4 Sinnvolle KI-Funktionen + +#### Offline/Programmierbetrieb + +- Medien automatisch taggen; +- Musikdatei analysieren und Abschnitte markieren; +- Timeline-Entwurf aus Songstruktur erzeugen; +- passende Generatoren und Farbpaletten vorschlagen; +- Effektketten erstellen; +- DMX-/Audio-Bindings vorschlagen; +- Belastung anhand der Hardware abschätzen; +- Show auf fehlende Medien oder Plugins prüfen. + +#### Livebetrieb + +- zwischen vorher freigegebenen Szenen wählen; +- Effektintensität langsam an Stimmung/Energie anpassen; +- bei erkannten Musikabschnitten Automation-Presets wechseln; +- Diagnose erklären und einen sicheren Fallback vorschlagen. + +Ein LLM darf nicht im Frame-, Audio- oder Art-Net-Echtzeitpfad liegen. Live-KI entscheidet höchstens auf einer langsamen Steuerungsebene; die deterministische Engine führt aus. + +### 21.5 Provider-Abstraktion + +- lokaler Provider; +- OpenAI-kompatible API; +- Ollama-kompatibler Provider; +- deaktivierter Offline-Modus; +- keine Providerlogik im Domänenmodell. + +Secrets werden nicht in Projektdateien exportiert. + +--- + +## 22. Multi-Display, Mapping und Edge-Blending + +### 22.1 Architektur + +Das System rendert zunächst eine virtuelle Master-Canvas. Physische Ausgänge erhalten Ausschnitte dieser Canvas. + +```text +Master Canvas + ├─ Output Slice A → Warp → Edge Mask → Color → Display 1 + ├─ Output Slice B → Warp → Edge Mask → Color → Display 2 + └─ Pixel Map C → Sampling → Art-Net +``` + +Diese Trennung muss bereits im Datenmodell von V1 existieren, auch wenn der vollständige Mapping-Editor später folgt. + +### 22.2 Output-Slice + +- frei wählbares Quellrechteck auf Master-Canvas; +- Zielauflösung und Refresh Rate; +- Position/Rotation/Flip; +- Overscan; +- Testbilder; +- Displayzuordnung; +- Aktiv/Standby; +- Fallback bei Displayverlust. + +### 22.3 Warping + +- initial 4-Corner-Keystone; +- danach Mesh-Warp mit konfigurierbarem Raster; +- Punkte verschieben und numerisch editieren; +- Preview-/Edit-Modus getrennt von Live; +- Mapping-Presets; +- Undo/Redo; +- GPU-Vertex- beziehungsweise Mesh-Verarbeitung. + +### 22.4 Edge-Blending + +- unabhängig für links/rechts/oben/unten; +- Blendbreite; +- Gamma/Exponent; +- Black-Level-Kompensation; +- Helligkeits- und Farbkorrektur je Output; +- Testpattern für Überlappung; +- Maskentextur optional; +- atomare Aktivierung vorbereiteter Änderungen. + +### 22.5 Synchronisation + +- Ausgänge derselben GPU verwenden denselben Renderclock und Frame-Snapshot. +- Mehrere GPUs sind in V1 nicht freigegeben. +- Exakte Synchronität über mehrere Rechner erfordert später externen Sync/Genlock/Framelock beziehungsweise klar spezifizierte Softwaregrenzen. +- ArtSync synchronisiert Art-Net-Daten, nicht physische HDMI-Refreshzyklen. + +### 22.6 Verteilte Render-Nodes + +Discovery, Paarung, gemeinsame Bedienung, Content-/Project-Sync und zeitgestempelte Starts gehören zur V1-Netzwerkbasis. Eine über mehrere Rechner aufgespannte gemeinsame Canvas ist dagegen eine spätere Mapping-Ausbaustufe. + +Pflichtregeln für diese spätere Ausbaustufe: + +- jedes `OutputSurface` bleibt fest einer `node_id` und einem physischen Display zugeordnet; +- Coordinator verteilt nur Showzustand, Medien und Zeitkommandos, keine fertigen Videoframes; +- alle Nodes bestätigen Preload und Renderfähigkeit vor Aktivierung; +- Software-Cue-Sync und physischer Scanout-/Genlock-Status werden getrennt angezeigt; +- ein Output darf nicht automatisch auf einen anderen Node springen, solange dessen Display-/Farb-/Warp-Profil nicht ausdrücklich kompatibel ist; +- für projektorübergreifendes Edge-Blending über mehrere Rechner ist geeignete Genlock-/Framelock-Hardware ein eigenes Capability- und Abnahmeprofil. + +--- + +## 23. API-Konzept + +### 23.1 REST + +REST ist für Ressourcen und nicht hochfrequente Aktionen vorgesehen: + +```text +GET /api/v1/system/capabilities +GET /api/v1/system/health +GET /api/v1/projects +POST /api/v1/projects +GET /api/v1/projects/{id} +PUT /api/v1/projects/{id} +POST /api/v1/projects/{id}/activate +POST /api/v1/media/import +GET /api/v1/media +GET /api/v1/plugins +POST /api/v1/plugins/install +POST /api/v1/plugins/{id}/enable +GET /api/v1/artnet/status +PUT /api/v1/artnet/patch +GET /api/v1/cluster/nodes +POST /api/v1/cluster/nodes/{id}/pair +GET /api/v1/cluster/groups +PUT /api/v1/cluster/groups/{id} +POST /api/v1/cluster/preflight +POST /api/v1/cluster/sync/content +POST /api/v1/cluster/revisions/{revision}/activate +GET /api/v1/diagnostics +``` + +### 23.2 Commands + +Zustandsänderungen mit Showwirkung laufen über versionierte Commands: + +```json +{ + "command_id": "uuid", + "type": "parameter.set", + "expected_revision": 120, + "actor": {"type": "web", "id": "operator-session"}, + "target": {"type": "server_group", "id": "uuid"}, + "execute_at_show_time_ns": null, + "payload": { + "parameter_path": "composition/.../opacity", + "value": 0.75, + "ownership": "temporary" + } +} +``` + +### 23.3 WebSocket + +Kanäle beziehungsweise Eventtypen: + +- State Snapshot/Delta; +- Parameter Updates; +- Playback Position; +- Audio Features, gedrosselt; +- Art-Net Status; +- Node Discovery/Pairing; +- Cluster Health, Clock Offset und Revisionen; +- Content-Sync-Fortschritt und Preflight; +- Renderer Telemetry; +- Plugin Status; +- Jobs/Imports; +- Alerts; +- Command Acks/Errors. + +Der Server begrenzt Frequenz und Payload. Hochfrequente Telemetrie wird gebündelt. Kein Client bekommt unkontrolliert jeden internen Framezustand. + +### 23.4 Fehlerformat + +```json +{ + "error": { + "code": "PLUGIN_SHADER_COMPILE_FAILED", + "message": "Gaussian Blur konnte nicht geladen werden.", + "details": {}, + "correlation_id": "uuid", + "recoverable": true, + "suggested_action": "bypass_plugin" + } +} +``` + +Interne Stacktraces werden geloggt, aber nicht ungefiltert an LAN-Clients ausgegeben. + +--- + +## 24. Persistenz, Projekte und Migrationen + +### 24.1 SQLite + +- WAL-Modus; +- Foreign Keys aktiv; +- kurze Transaktionen; +- keine Datenbankoperation im Renderthread; +- automatisches Backup vor Migration; +- Integritätscheck beim Start nach unsauberem Shutdown. + +### 24.2 Trennung + +- SQLite speichert Index, Einstellungen, Pluginstatus und Projektmetadaten. +- Große Medien liegen als Dateien. +- Projekt-Export kann ein portables Projektpaket erzeugen. +- Livezustand und dauerhafter Projektzustand werden getrennt behandelt. + +### 24.3 Autosave und Recovery + +- transaktionales Autosave; +- Recovery-Journal für nicht gespeicherte Bedienänderungen; +- letzte stabile Projektversion erhalten; +- keine direkte Überschreibung ohne temporäre Datei und atomaren Replace; +- Crash-Recovery-Dialog beim nächsten Start. + +### 24.4 Migrationen + +Jede Schemaänderung benötigt: + +- Vorwärtsmigration; +- Test mit Projekt einer vorherigen Version; +- Backup; +- dokumentierte Nicht-Rückwärtskompatibilität; +- aktualisierte `schema_version`; +- Fixture-/Plugin-Kompatibilitätsprüfung. + +--- + +## 25. Performance- und Echtzeitanforderungen + +### 25.1 Desktop-Abnahmelast + +Auf definierter Referenzhardware für `DESKTOP_FULL`: + +- Master-Canvas 3840 × 2160 @ 60 Hz; +- acht 1920 × 1080 @ 60 Hz Medienquellen; +- normale Blend- und Transformoperationen; +- mindestens vier einfache Effektinstanzen und ein Blur in mittlerer Qualität; +- ein Hauptausgang und ein reduzierter Previewstream; +- Art-Net-Steuerung aktiv. + +Ziele: + +- Framezeit p99 innerhalb des 16,67-ms-Budgets; +- keine sichtbare Blockade beim Medienimport; +- DMX-zu-sichtbarem-Frame p95 höchstens zwei Frames; +- Browsercommand p95 bis bestätigte Zustandsübernahme höchstens 100 ms im LAN; +- mindestens vier Stunden Soak-Test ohne Speicherwachstum oder Prozessabbruch; +- verworfene Frames werden erfasst und im Testreport ausgewiesen. + +Diese Werte sind ein Abnahmeziel und keine Behauptung für beliebige Codecs oder GPUs. Phase 0 und die spätere Performance-Matrix bestimmen die konkrete Referenzhardware. + +### 25.2 Windows-Mini-PC-Abnahmelast (`DESKTOP_LITE`) + +Auf einer fest dokumentierten Referenzplattform mit schneller integrierter GPU, 32 GB Dual-Channel-RAM, NVMe und aktuellem Treiber: + +- 1920 × 1080 @ 60 Hz Master; +- vier hardwaredecodierbare 1080p-Quellen mit definiertem H.264-/H.265-Testmaterial; +- zwei einfache Effekte und ein Blur mit `Quality: Auto`; +- ein physischer Ausgang und ein adaptives Browserpreview; +- Art-Net, Web-UI und Cluster-Heartbeat aktiv; +- mindestens vier Stunden Soak-Test. + +Ziele: + +- D3D11Memory bleibt vom Decoder durch Composite/FX bis zur Ausgabe nachgewiesen GPU-resident; +- p99-Framezeit liegt nach Einregelung innerhalb 16,67 ms; +- Ausgangsauflösung, Refresh Rate, aktive Layer und Parametersemantik ändern sich während Adaptive Quality nicht; +- ein künstlicher Lastsprung wird zunächst durch Preview-/interne Qualitätsreduktion abgefangen; +- Qualitätsstufen oszillieren nicht und werden nach längerer Reserve schrittweise wieder erhöht; +- kein sichtbarer Shader-Kompilierungsruckler, da alle Varianten vor Aktivierung kompiliert sind. + +### 25.3 Raspberry-Pi-Abnahmelast + +- 1920 × 1080 @ 60 Hz; +- zwei hardwaredecodierbare Quellen oder eine Quelle plus Generator; +- mindestens ein einfacher Filter; +- ein Ausgang; +- Browserbedienung und Art-Net aktiv; +- zweistündiger Soak-Test. + +### 25.4 Budgetierung + +Telemetrie muss Zeit und Speicher je Stufe ausweisen: + +- Decode; +- Upload/Interop; +- Effektpässe; +- Compositing; +- Output-Mapping; +- Preview; +- GPU-/VRAM-Nutzung; +- CPU je Prozess. +- aktuell gewählte Adaptive-Quality-Stufe und Grund jeder Änderung; +- Hardware-Fingerprint und Ergebnisse des letzten Capability-Selbsttests. + +### 25.5 Schutzmaßnahmen + +- maximale Layerzahl je Capability-Tier; +- VRAM-Schätzung vor Aktivierung; +- Effekt-Qualitätsstufen; +- Warnung vor zu hoher Renderlast; +- Preload-Budget; +- begrenzte Queuegrößen; +- Backpressure; +- kein unbeschränktes Caching; +- kein stilles Entfernen von Effekten zur Lastreduktion. +- Hysterese und Mindesthaltezeit je Qualitätsstufe; +- harte Untergrenze je Plugin und Output; +- Preflight-Warnung, bevor eine Show die gemessenen Decoder-/Speicherbudgets überschreitet; +- automatische Änderungen nur an manifestierten, vorab getesteten Qualitätsvarianten. + +--- + +## 26. Betriebs- und Ausfallsicherheit + +### 26.1 Fail-Safe-Verhalten + +Konfigurierbare Renderer-Policy bei Fehlern: + +- letztes gültiges Bild halten; +- definierte Fallback-Szene; +- Fade to Black; +- sofort Blackout nur für explizite Sicherheitsfälle. + +### 26.2 Watchdog + +- Supervisor überwacht Control Core und Renderer getrennt. +- Renderer sendet Frame-/Heartbeatstatus. +- Control Core darf neu starten, ohne den Output sofort zu verlieren. +- Renderer-Neustart lädt den letzten autoritativen Snapshot. +- Crashloop-Erkennung verhindert endlose Neustarts. +- Fehler bleibt in der UI und im Log sichtbar. + +### 26.3 Show Lock + +Ein aktivierbarer Show-Lock verhindert: + +- Plugininstallation/-update; +- Schema-/Datenmigration; +- Display-Neuzuordnung; +- Medienverschiebung; +- kritische Konfigurationsänderungen; +- automatische Softwareupdates. + +### 26.4 Shutdown + +- laufende Writes abschließen; +- Projektzustand sichern; +- Output kontrolliert auf Fallback oder Black setzen; +- Worker mit Timeout beenden; +- bei Zwangsbeendigung Recovery-Markierung setzen. + +--- + +## 27. Sicherheit + +### 27.1 Netzwerk + +- Default-Bind nur `127.0.0.1`; +- LAN-Modus explizit; +- Token/Passwort; +- Rate Limits; +- restriktives CORS; +- keine Debug-Endpunkte im Release; +- WebSocket-Authentisierung; +- Art-Net-Empfang auf wählbarer Schnittstelle und optional Sender-Allowlist. +- Discovery-Nachrichten enthalten keine Tokens oder Secrets. +- Node-Paarung prüft eine kurzlebige PIN beziehungsweise einen sichtbaren Zertifikats-Fingerprint. +- Nach Paarung verwendet Coordinator↔Node gegenseitig authentisierte, widerrufbare Identitäten mit getrennten Scopes für Lesen, Steuern, Content-Sync und Administration. +- Ein gefundener, aber ungepaarter Node darf ausschließlich minimale Discovery-/Pairing-Informationen liefern. + +### 27.2 Dateien und Plugins + +- Schutz vor Path Traversal; +- ZIP-Bomb-Limits; +- Dateigrößenlimits; +- MIME/Signaturprüfung soweit sinnvoll; +- keine Ausführung hochgeladener nativer Binärdateien; +- Plugins nur in vorgesehenem Verzeichnis; +- Pluginquarantäne; +- Hash und Herkunft speichern. + +### 27.3 KI + +- Scopes und Allowlist; +- kein Secretzugriff; +- keine Roh-Shell; +- keine direkten Dateipfade als freie KI-Aktion; +- Dry-run und Bestätigung für riskante Aktionen; +- Auditlog; +- Abschaltknopf; +- KI ist im Show-Lock standardmäßig eingeschränkt. + +--- + +## 28. Beobachtbarkeit und Diagnose + +### 28.1 Logs + +- strukturierte JSON-Logs intern; +- menschenlesbare Rollings Logs zusätzlich oder per Viewer; +- Korrelations-ID über Web, Control Core und Renderer; +- Logrotation nach Größe und Anzahl; +- keine Secrets; +- Export eines Diagnosepakets. + +### 28.2 Metriken + +- Output FPS; +- Renderframe p50/p95/p99; +- Dropped/Duplicated Frames; +- Decoder pro Layer; +- CPU/RAM/GPU/VRAM soweit verfügbar; +- Queuefüllstände; +- Art-Net Paketrate/Jitter/Timeouts; +- WebSocketclients; +- Audiopegel/Dropouts; +- Pluginfehler; +- Previewlast. +- Renderbackend, GPU-Memory-Typ und unerwartete Copy-/Interop-Stufen; +- Adaptive-Quality-Stufe, Wechselgrund und Wechselhäufigkeit; +- Node-/Coordinator-Heartbeat, Clock-Offset/Drift und Cue-Startabweichung; +- Projekt-/Content-Revision, fehlende Hashes und Sync-Fortschritt je Node; +- Universe-Überschneidungen und Art-Net-Sender je Node. + +### 28.3 Diagnosepaket + +Exportiert nach Nutzerfreigabe: + +- Versionsinformationen; +- Hardware-/Treiberübersicht; +- Capability-Ergebnis; +- bereinigte Konfiguration; +- Logs; +- Pluginliste; +- letzter Test-/Healthstatus; +- keine Medieninhalte und keine Secrets. + +--- + +## 29. Teststrategie und Quality Gates + +### 29.1 Unit Tests + +- Parameterkonvertierung und Clamping; +- 8-/16-Bit-DMX-Mapping; +- signed Speed-Mapping; +- Triggerflanken; +- Ownership/HTP/LTP; +- Timelineinterpolation; +- Schema- und Projektmigrationen; +- Pluginmanifestvalidierung; +- Pfadsicherheit; +- Audio-Mappingkurven; +- Command-Idempotenz. +- Adaptive-Quality-Hysterese, Prioritätsleiter und unveränderliche Schutzparameter; +- Discovery-Registry, Node-ID-Duplikate und manuelle Fallback-Adressen; +- Contentmanifest-/Chunk-Hashes und atomare Revision; +- Cluster-Command-Idempotenz und Showzeit→lokale-Monotonic-Abbildung. + +### 29.2 Integrationstests + +- Control Core ↔ Renderer Handshake; +- Snapshot/Delta/Reconnect; +- GStreamer-Pipeline mit Testvideo; +- Hardwaredecodererkennung; +- Shader Load/Bypass; +- Medienpreload und atomarer Wechsel; +- Art-Net-Empfang mehrerer Universen; +- WebSocket State Sync; +- Projekt speichern/laden; +- Timeline + DMX Override; +- Audiofeature → Parameter → Renderer. +- mDNS-Discovery sowie manuelles Hinzufügen über mindestens zwei Nodes; +- Pairing, Zertifikats-/Tokenwiderruf und Reconnect; +- Content-/Project-Sync mit Abbruch, Resume, Hashfehler und atomarer Aktivierung; +- Preload/Arm/Execute/Ack eines zeitgestempelten Gruppen-Commands; +- ein Lichtpult steuert zwei Art-Net-Nodes über konfliktfreie Universe-Bereiche. + +### 29.3 Renderingstests + +- Golden Images pro Generator, Filter und Blend-Modus; +- definierte Auflösung, Seed und Zeit; +- toleranzbasierter Bildvergleich; +- Alpha/Premultiplication; +- Farbkonvertierung; +- Multi-Pass-Effekte; +- Warp-/Edge-Masks; +- Test auf Windows D3D11/HLSL, Linux OpenGL/GLSL und Raspberry Pi OpenGL ES; +- automatischer Test, dass der normale Windows-Testpfad keinen CPU-Readback enthält; +- identische semantische Effektparameter und toleranzbasierter Bildvergleich über Backends; +- atomarer Wechsel aller Adaptive-Quality-Varianten ohne Shader-Stall. + +### 29.4 End-to-End + +- Projekt anlegen; +- Medien importieren; +- Layer laden; +- Effekt hinzufügen; +- DMX-Patch setzen; +- Art-Net-Werte simulieren; +- Szene speichern und starten; +- Timeline abspielen; +- Anwendung neu starten und Projekt wiederherstellen. +- zwei Server finden und paaren; +- Projekt/Medien synchronisieren und Preflight bestehen; +- vom Control Center denselben Presetabruf zeitgestempelt auf beiden Nodes auslösen; +- beide Server mit einem Lichtpult getrennt steuern; +- Node-Verlust simulieren und laufenden Output gemäß Policy halten. + +### 29.5 Fehler- und Soak-Tests + +- Art-Net-Paketverlust und Senderausfall; +- Browserdisconnect; +- Control-Core-Neustart; +- Rendererabsturz; +- ungültiges Plugin; +- Shaderkompilierungsfehler; +- fehlendes Medium; +- Display-Hotplug; +- volle oder schreibgeschützte Platte; +- vier Stunden Desktop-Soak; +- zwei Stunden Pi-Soak; +- wiederholtes Laden von 1.000 Szenenwechseln. +- Coordinator-Ausfall bei weiterlaufenden Render-Nodes; +- Clock-Drift, Latenzspitzen, Paketumordnung und 1–5 % kontrollierter Cluster-Paketverlust; +- blockiertes mDNS mit erfolgreichem manuellen Fallback; +- Universe-Kollision und doppelte Node-ID; +- wechselnde GPU-Last mit Nachweis, dass Adaptive Quality nicht pumpt. + +### 29.6 Portabilitätstest + +Auf einer sauberen Windows-VM beziehungsweise einem sauberen Testrechner: + +- kein Python installiert; +- kein Node.js installiert; +- kein GStreamer installiert; +- keine Administratorrechte; +- Anwendung aus beliebigem Pfad und USB-/zweitem Laufwerk starten; +- Browser öffnen; +- Testvideo rendern; +- Art-Net-Testpaket empfangen; +- Projekt speichern und nach Neustart laden. + +### 29.7 CI-Gates + +Jeder Merge beziehungsweise Release benötigt: + +- Ruff/Lint grün; +- Typprüfung grün; +- Python Unit/Integration grün; +- Frontend Unit grün; +- Frontend Build grün; +- Playwright Kernflow grün; +- Schema-Validierung grün; +- Shader-Compile-Gate grün; +- Dependency-/Lizenzreport; +- portabler Build erfolgreich; +- keine offenen P0-Fehler; +- P1-Fehler explizit bewertet. + +GPU-, Display- und Art-Net-Hardwaretests laufen zusätzlich auf dedizierten Testmaschinen und dürfen nicht durch reine Mocks ersetzt werden. + +--- + +## 30. Build, Release und Updates + +### 30.1 Reproduzierbarkeit + +- Python- und Frontend-Lockfiles verpflichtend; +- GStreamer-Version und Pluginmenge exakt manifestieren; +- Shader- und Fixture-Schemaversionen manifestieren; +- Buildnummer und Git-Commit in Diagnoseansicht; +- SBOM und Lizenzverzeichnis erzeugen; +- reproduzierbares Buildskript ohne manuelles Kopieren. + +### 30.2 Windows-Paket + +- Onefolder/Standalone; +- Frontend als statische Assets; +- nur benötigte GStreamer-Plugins bündeln; +- Plugin-Scanner-Cache lokal; +- Anwendungspfade beim Start setzen; +- ZIP mit Versionsnummer; +- SHA-256-Prüfsumme; +- optional Code Signing später; +- keine Internetverbindung zum Start erforderlich. + +### 30.3 Linux/Raspberry Pi + +- ARM64-Tarball oder Debian-Paket plus portable Projekt-/Pluginstruktur; +- Hardwaretreiber bleiben Bestandteil des Zielbetriebssystems; +- gleiche API und Projektschemata; +- capability-basierte Effektfreigabe; +- kein Fork der Geschäftslogik; +- plattformspezifische Anpassungen nur in Adapterpaketen. + +### 30.4 Updates + +- niemals Dateien im laufenden Programm überschreiben; +- Anwendung und Nutzerdaten trennen; +- neues Verzeichnis parallel entpacken; +- Projekt-/DB-Backup; +- Versionskompatibilität prüfen; +- Rollback auf vorherige Programmversion ermöglichen; +- Plugins separat versionieren; +- automatische Updates im Show-Lock deaktiviert. + +--- + +## 31. Implementierungsphasen + +Die Zeitangaben sind grobe Größenordnungen für einen erfahrenen Entwickler mit KI-Unterstützung und echter Testhardware. Sie sind keine Festpreise. + +### Phase 0 – technischer Spike und Go/No-Go, etwa 2–4 Wochen + +**Ziel:** Kritische Leistungs- und Portabilitätsrisiken vor Produktentwicklung beseitigen. + +Umsetzen: + +- GPU-/Decoder-/Display-Capabilities automatisch erfassen und Hardware-Fingerprint bilden; +- Testvideo über Windows-D3D11-Hardwaredecoder laden; +- durchgängigen `D3D11Memory`-Pfad ohne regulären CPU-Readback nachweisen; +- minimalen nativen Renderkern beziehungsweise GStreamer-D3D11-Plugin als belastbaren Effekt-/Compositing-Pfad festlegen; +- zwei Videos über D3D11 auf der GPU mischen; +- einen HLSL-Passthrough-/Effektparameter live ändern; +- denselben semantischen Effektvertrag mit einer GLSL-Testimplementierung validieren; +- drei vorab kompilierte Blur-Qualitätsstufen automatisch und ruckelfrei wechseln; +- Output in randlosem Vollbild auf wählbarem Display; +- ArtDMX empfangen und Opacity innerhalb maximal zwei Frames ändern; +- minimale FastAPI-/WebSocket-Bedienung; +- portable Windows-Onefolder-Ausgabe auf sauberem Rechner testen; +- erste Messwerte für 1080p60 auf Standard-Mini-PC-Hardware und 4K60 auf `DESKTOP_FULL`; +- GStreamer-/Packaging-Variante per ADR entscheiden. + +**Gate 0:** Kein Weiterbau, wenn D3D11-Hardwaredecoding, GPU-residentes Compositing, ruckelfreie Adaptive-Quality-Varianten, Art-Net-Latenz oder portable Auslieferung nicht reproduzierbar funktionieren. + +### Phase 1 – Fundament und Netzwerkbasis, etwa 3–5 Wochen + +- Repository und CI; +- Supervisor; +- Control Core; +- Rendererprozess; +- versioniertes IPC; +- Capability-Handshake; +- zentrale Parameter-Engine; +- Basislogging und Healthchecks; +- SQLite und Migrationen; +- leere Projektstruktur; +- persistente Node-ID und Rollen; +- mDNS/DNS-SD-Discovery und manuelle Node-Liste; +- sichere Paarung und Node-Vertrauen; +- Coordinator-Registry, Heartbeats und Reconnect; +- Cluster-Nachrichtenhülle mit Idempotenz und Revisionen. + +**Gate 1:** Zwei Server werden automatisch gefunden beziehungsweise manuell eingetragen, sicher gepaart und nach IP-/Prozesswechsel korrekt wiederverbunden; Snapshot, Crash-Recovery und portable Starts bestehen die Tests. + +### Phase 2 – Medien-, Layer- und Basissync-Engine, etwa 4–8 Wochen + +- Master-Canvas; +- acht Layer; +- Video, Bild, Solid und Generator-Grundquelle; +- Playback; +- Opacity, Transform, Crop, Blend-Modi; +- Preload und atomarer Clipwechsel; +- ein bis zwei Outputs; +- Rendertelemetrie; +- Contentmanifest mit SHA-256, resumierbare Übertragung und Staging; +- Project-/State-Snapshot mit monotonen Revisionen; +- Servergruppen und Zielrouting; +- Clock-Offset-/Driftmessung; +- zeitgestempelte, vorgepufferte Presetaktivierung auf zwei Nodes. + +**Gate 2:** Das Mini-PC-Desktopprofil läuft stabil; zwei Nodes erhalten dieselbe geprüfte Projekt-/Content-Revision und starten ein vorbereitetes Preset im verkabelten Referenz-LAN mit p95 höchstens 10 ms auseinander. Medienimport/-sync blockiert keinen Output. + +### Phase 3 – Plugin-SDK und Effekte, etwa 3–5 Wochen + +- Pluginmanifest und Validator; +- Shader-Pluginloader; +- D3D11/HLSL- sowie GL/GLES-Backendadapter; +- Multi-Pass; +- deklarative Adaptive-Quality-Varianten; +- Parameter-UI-Schema; +- Failure Bypass; +- Pluginquarantäne; +- alle zehn Pflichtgeneratoren und vierzehn Pflichtfilter aus Abschnitt 15; +- SDK-Dokumentation und Beispielplugin. + +**Gate 3:** Ein externes Beispielplugin lässt sich ohne Kernänderung installieren und steuern; alle deklarierten Backends kompilieren, und Auto Quality wechselt Varianten ohne Semantik- oder Renderstall. + +### Phase 4 – Art-Net-Fixtures, etwa 2–4 Wochen + +- ArtPoll/ArtPollReply; +- ArtDMX; +- Patchverwaltung; +- zentraler Mehrserver-Universe-Plan und Kollisionsprüfung; +- Master32 und Layer64; +- 16-Bit, Trigger, Ownership und Signalverlust; +- Emulator- und Hardwaretest; +- Kanalliste exportieren. + +**Gate 4:** Ein Lichtpult steuert mindestens zwei Server mit zusammen mindestens acht Layern sowie deren Master-Fixtures stabil; Universe-Kollision, Paketverlust und Failsafe sind getestet. + +### Phase 5 – vollständige Browser-Liveoberfläche, etwa 4–7 Wochen + +- eigenständiges dunkles, QLab-inspiriertes Designsystem; +- dichte Layer-Tabelle statt Karten-Dashboard; +- andockbarer unterer Inspector; +- rechte Seitenleiste für Active Layers, Media Banks, Nodes und Alerts; +- Mixer; +- Media Library; +- Effekteditor; +- DMX-Seite; +- Pluginseite; +- Diagnostics; +- Cluster-Control-Center, Nodefilter und Sync-/Preflightstatus; +- dezente `Quality: Auto`-Anzeige ohne störende Dialoge; +- Preview; +- Tabletlayout; +- Auth für LAN; +- Playwright-Referenzbilder und Zoom-/Density-Tests; +- ausdrücklich kein GO-, Standby-, Cue-Cart- oder Timeline-Bereich. + +**Gate 5 / Kernrelease V1.0:** Die komplette Layer-/Effektshow kann ohne lokale Desktop-UI vorbereitet und live bedient werden. Ein Control Center bedient mindestens zwei Nodes; der visuelle Stil besteht die Referenzabnahme, ohne QLab-Assets oder QLab-Cue-Funktionen zu kopieren. + +### Phase 6 – optionaler Cue-/Timeline-Workspace nach V1.0, etwa 4–8 Wochen + +Diese Phase beginnt nur nach neuer Freigabe. Erst hier werden QLab-ähnliche Bedienfähigkeiten ergänzt. + +- Cue List mit Playhead, Standby und optionalem GO; +- Cue Carts als optionaler Live-Abrufmodus; +- Übergänge; +- Preload; +- Timeline-Tracks; +- Clips und Keyframes; +- Automation; +- Override/Release mit DMX; +- Autosave/Undo; +- zeitgestempelte Cue-/Timeline-Ausführung über Servergruppen. + +**Gate 6:** Eine mindestens zehnminütige programmierte Show läuft reproduzierbar und ist nach Neustart identisch. + +### Phase 7 – Audio und Modulatoren, etwa 2–5 Wochen + +- Audio Device Handling; +- FFT/Bänder/RMS/Peak; +- Beat/Onset/BPM; +- Mapping Engine; +- LFO/Envelope/Step Sequencer; +- Audio UI; +- Latenz- und Dropouttest. + +**Gate 7:** Sound-to-Light funktioniert deterministisch ohne KI und ohne Renderstörungen. + +### Phase 8 – Mapping und Edge-Blending, etwa 4–8 Wochen + +- Output Slices; +- Mappingeditor; +- 4-Corner-Warp; +- Mesh-Warp; +- Edge-Masks; +- Gamma und Black Level; +- Testpattern; +- atomare Liveaktivierung. + +**Gate 8:** Zwei Projektoren können als gemeinsame Canvas kalibriert und gespeichert werden. + +### Phase 9 – Art-Net-Pixel-Ausgabe, etwa 2–5 Wochen + +- Pixel Mapper; +- RGB/RGBW; +- Universe-Packing; +- ArtSync; +- asynchroner GPU-Readback; +- Bandbreiten-/Lasttest. + +**Gate 9:** Definiertes LED-Testsetup läuft ohne Renderthread-Stall und mit synchronen Universen. + +### Phase 10 – KI- und Advanced Automation, etwa 3–8 Wochen + +- versioniertes KI-Command-Gateway; +- Provideradapter; +- Scopes, Dry-run, Audit und Freigaben; +- Offline-Musikanalyse; +- Timeline-/Szenenvorschläge; +- Live-Autopilot nur innerhalb freigegebener Regeln. + +**Gate 10:** KI kann einen prüfbaren Entwurf erzeugen, aber keine Sicherheits- oder Prioritätsgrenze umgehen. + +### Phase 11 – Raspberry Pi 5, etwa 2–5 Wochen + +- ARM64-Build; +- GStreamer-Hardwarepfad; +- OpenGL-ES-Shaderprüfung; +- PI_LITE-Profil; +- reduzierte Qualitätsstufen; +- portable Startstruktur; +- Soak-Test. + +**Gate 11:** Das definierte 1080p60-PI_LITE-Profil läuft stabil auf realer Hardware. + +### Phase 12 – Produktionshärtung, fortlaufend mindestens 4–8 Wochen + +- Installer-freies Releaseverfahren; +- Update/Rollback; +- Langzeittests; +- breite GPU-/Pultmatrix; +- Bedienerdokumentation; +- Diagnoseexport; +- Lizenzprüfung; +- Release Candidate und Feldtest. + +--- + +## 32. Pflichtdokumente während der Entwicklung + +### `STATUS.md` + +- aktuelle Phase; +- letzter grüner Commit; +- bestandene Gates; +- laufende Arbeit; +- nächste drei Aufgaben; +- bekannte Blocker. + +### `ERRORS.md` + +Jeder Fehler mit: + +- ID; +- Priorität P0–P3; +- Reproduktionsschritte; +- erwartetes/tatsächliches Verhalten; +- Plattform/Hardware; +- Logs/Screenshots; +- Ursache; +- Fixcommit; +- Regressionstest. + +### `TEST_REPORT.md` + +- Testdatum und Commit; +- Hardware/OS/Treiber; +- automatisierte Testergebnisse; +- Performancewerte; +- Art-Net-Hardwaretest; +- Portabilitätstest; +- Soak-Zeit; +- offene Abweichungen. + +### ADRs + +Mindestens erforderlich: + +1. Packaging: Nuitka oder PyInstaller; +2. Frontend: React oder Svelte; +3. IPC und Serialisierung; +4. GStreamer GPU-Memory-Pfad je Plattform; +5. Renderbackend-Vertrag und Shader-Sprachprofile HLSL/GLSL/GLES; +6. Projektpersistenz; +7. Preview-Technik; +8. zusätzlicher Show-Codec; +9. Control-Ownership; +10. Output-/Displayabstraktion; +11. Capability-Selbsttest und Adaptive-Quality-Policy; +12. Node-Discovery und manueller Subnetz-Fallback; +13. Coordinator-/Clusterprotokoll; +14. Node-Paarung, TLS und Berechtigungsscopes; +15. Clock-Sync, `execute_at` und messbare Softwaregrenzen; +16. QLab-inspirierte, aber eigenständige UI-Design-Tokens und Density-Regeln. + +--- + +## 33. Verbotene Abkürzungen und Architekturfehler + +Die ausführende KI darf ausdrücklich nicht: + +- Videoframes mit NumPy/Pillow/OpenCV im Livepfad pro Pixel bearbeiten; +- aus Python pro Frame einzelne GPU-Drawcalls oder Texturkopien ausführen; +- das Operatorbild als Browser-Canvas ausgeben; +- UI, Art-Net und Renderloop als untrennbaren Monolithen bauen; +- Plugins direkt interne Klassen importieren lassen; +- jedem Plugin einen variablen DMX-Footprint geben; +- absolute Medienpfade ohne Portabilitätsschicht speichern; +- Softwaredecoding unbemerkt als Fallback aktivieren; +- Windows-Videoframes im Normalpfad aus D3D11 in CPU-RAM und zurück kopieren; +- Render-/Pluginverträge fest an nur eine Grafik-API koppeln; +- Layer, Ausgangsauflösung oder Showlogik zur Lastreduktion unbemerkt verändern; +- Qualitätsstufen zur Laufzeit erstmals kompilieren; +- mDNS-Discovery als Authentisierung behandeln; +- Nodes ohne Paarung oder gültige Berechtigung steuern; +- WLAN als garantierten Cue-/Frame-Sync-Pfad ausgeben; +- Softwarezeit-Synchronisation als Genlock/Framelock bezeichnen; +- Mapping erst nachträglich ohne OutputSurface-Datenmodell anflanschen; +- KI direkten Datenbank- oder Shellzugriff geben; +- unbeschränkte Queues oder unkontrollierte Caches verwenden; +- Renderfehler durch pauschales `try/except` verschlucken; +- auf echter Hardware notwendige Tests durch Mocks ersetzen; +- Docker als primären Desktop-/GPU-Auslieferungsweg verwenden; +- neue Frameworks ohne ADR und messbaren Bedarf hinzufügen; +- den gesamten Renderkern vor Phase 0 selbst neu schreiben. + +--- + +## 34. Risikoregister + +| Risiko | Auswirkung | Gegenmaßnahme | +| --- | --- | --- | +| GPU-Frames fallen auf System-RAM zurück | massive Last/Framedrops | Phase-0-Memory-Pfad messen; sichtbarer Decoderstatus | +| GStreamer-Bündelung unter Windows | Portable Build startet nicht | sauberer VM-Test in Phase 0; Runtime manifestieren | +| Shader laufen auf Desktop, nicht Pi | Plugininkompatibilität | GLSL/GLES-Subset; Cross-Compile-/Hardwaretest | +| Long-GOP-Medien triggern langsam | unbrauchbare Livebedienung | Preload, Codecprofil, Medientest und Warnung | +| Heavy Blur/Feedback überlastet GPU | Framedrops | Qualitätsstufen, Framebudget, Capability-Tier | +| Auto Quality schaltet sichtbar hin und her | unruhiges Bild | Hysterese, Mindesthaltezeit, Interpolation, Preview zuerst reduzieren | +| Browserpreview belastet Renderer | Showoutput ruckelt | gedrosselte separate Pipeline, abschaltbar | +| mehrere Steuerquellen kämpfen | springende Parameter | zentrale Ownership-/Prioritätsengine | +| Plugin beschädigt Projekte | Show nicht ladbar | Versionierung, Bypass, Missing-Plugin-Fallback | +| Display-ID ändert sich | falscher Output | EDID/Metadaten, Zuordnungsdialog, Safe Mode | +| Pi wird als Desktop-Ersatz erwartet | Zielverfehlung | eigenes PI_LITE-Abnahmeprofil | +| KI verändert Livezustand unkontrolliert | Betriebsrisiko | Scopes, Dry-run, niedrigste Priorität, Audit | +| Edge-Blending sieht trotz Geometrie schlecht aus | unprofessionelles Bild | Gamma, Black Level, Testbilder, realer Projektortest | +| mDNS ist in VLAN/Netz blockiert | Server erscheinen nicht automatisch | manuelle Node-Liste, optionale zentrale DNS-SD-Registrierung | +| doppelte Universen auf mehreren Nodes | falsche Layer reagieren | zentraler Universe-Plan, Preflight-Blocker, Unicast | +| Content-/Pluginstand weicht ab | verschiedene Bilder trotz gleichem Command | Hashmanifest, Staging und atomare Revision | +| Clock-Drift zwischen Rechnern | sichtbarer Startversatz | Driftmessung, geplante Commands, verkabeltes LAN, Genlock-Hinweis | +| Coordinator fällt aus | zentrale UI/Sync vorübergehend weg | Nodes halten Zustand und direkten Art-Net-Empfang; vorbereiteter Ersatz | + +--- + +## 35. Definition of Done für Version 1.0 + +Version 1.0 gilt nur als fertig, wenn alle folgenden Punkte erfüllt sind: + +- portable Windows-Anwendung startet auf sauberem Rechner ohne Installation; +- Browseroberfläche funktioniert lokal und autorisiert im LAN; +- Browserneustart beeinflusst den Output nicht; +- acht Layer sind patch- und steuerbar; +- Videos werden auf Referenzhardware nachweislich hardwaredecodiert; +- GPU-Compositing und Windows-D3D11Memory-Pfad sind ohne regulären CPU-Rundweg nachgewiesen; +- Hardware/Decoder/Displays werden automatisch erkannt und einem gemessenen Profil zugeordnet; +- Adaptive Quality hält das Ausgabeformat stabil und wechselt nur vorab getestete interne Qualitätsstufen; +- Media-, Generator- und Adjustment-Layer funktionieren; +- zwei Effekt-Slots je Layer funktionieren; +- alle Pflichtgeneratoren und Pflichtfilter aus Abschnitt 15 bestehen Bild-, Parameter-, Backend- und Performance-Abnahme; +- externe Shaderplugins lassen sich ohne Kernänderung installieren; +- Master32- und Layer64-Art-Net-Fixtures sind vollständig dokumentiert; +- ein Lichtpult steuert mindestens zwei Server über getrennte Universe-Bereiche; Failsafe ist auf realem Netzwerk getestet; +- mindestens zwei Server werden gefunden beziehungsweise manuell hinzugefügt, sicher gepaart und in einer gemeinsamen Oberfläche bedient; +- Content-/Project-/State-Sync, Preflight und zeitgestempelte Presetaktivierung auf zwei Nodes sind getestet; +- Presets/Szenen sind speicherbar und direkt abrufbar, ohne GO-/Standby-/Cue-Logik; +- die Weboberfläche verwendet die definierte dunkle, kompakte QLab-inspirierte Designsprache, jedoch keine QLab-Assets oder Cue-Funktionen; +- Performance- und Soak-Gates sind dokumentiert bestanden; +- keine offenen P0-Fehler; +- alle P1-Fehler sind behoben oder ausdrücklich für Release akzeptiert; +- Projektmigration, Backup und Crash-Recovery sind getestet; +- Diagnoseansicht zeigt Decoder-, Frame- und Art-Net-Status; +- Plugin-, API- und Operator-Dokumentation liegt vor; +- SBOM und Drittanbieter-Lizenzen liegen bei; +- Release-ZIP, Prüfsumme und reproduzierbares Buildskript sind vorhanden. + +Cue-/GO-/Timeline-Funktionen, Audioanalyse, Mapping/Edge-Blending, Art-Net-Pixeloutput, KI und Raspberry Pi besitzen eigene spätere Gates und sind nicht Voraussetzung für V1.0, dürfen durch V1.0 aber nicht architektonisch blockiert sein. + +--- + +## 36. Erster konkreter Arbeitsauftrag für die Entwicklungs-KI + +Die ausführende KI beginnt nicht mit dem vollständigen UI und nicht mit einem großen Plugin-Katalog. Sie führt exakt diese ersten Schritte aus: + +1. Repository gemäß Eigentumsgrenzen initialisieren. +2. `STATUS.md`, `ERRORS.md`, `TEST_REPORT.md` und ADR-Vorlage anlegen. +3. GStreamer-Version für Windows pinnen. +4. Minimalen Windows-Renderer bauen: Testvideo → D3D11-Hardwaredecode → `D3D11Memory` → Vollbild. +5. Zweite Quelle und D3D11-GPU-Compositing ohne CPU-Readback ergänzen. +6. HLSL-Passthrough-Plugin mit einem live änderbaren Parameter sowie eine semantisch gleiche GLSL-Testvariante ergänzen. +7. Minimalen Art-Net-Empfänger mit einem Universe bauen. +8. DMX-Kanal auf Layer-Opacity mappen. +9. Minimalen FastAPI-/WebSocket-Endpunkt für denselben Parameter bauen. +10. Capability-Selbsttest und drei Adaptive-Quality-Varianten implementieren. +11. Framezeit, Decoder, Memory-Pfad, Qualitätswechsel und DMX-Latenz messen. +12. Portable Onefolder-Ausgabe erzeugen. +13. Auf einem sauberen Windows-Rechner ohne Entwicklungsumgebung testen. +14. Ergebnisse in `TEST_REPORT.md` dokumentieren. +15. ADRs zu Packaging, GPU-Pfad, Adaptive Quality und IPC entscheiden. +16. Erst bei bestandenem Gate 0 mit Phase 1 fortfahren. + +### Gate-0-Demo + +Die Abnahmevorführung muss zeigen: + +- portables Starten aus einem entpackten Ordner; +- Browser öffnet sich; +- zwei Videos laufen gleichzeitig; +- ein Shader verändert sichtbar das Bild; +- zusätzliche Last führt zu einem atomaren, nicht auffälligen Auto-Quality-Wechsel, ohne Ausgangsauflösung oder Layer zu ändern; +- ein Art-Net-Fader steuert die Opacity; +- ein Browserregler steuert einen Effektparameter; +- Videooutput bleibt beim Schließen des Browsers aktiv; +- Diagnose zeigt Hardwaredecoder und stabile Framezeit; +- Anwendung beendet sich kontrolliert. + +--- + +## 37. Strategische Schlussentscheidung + +Das Produkt wird **Windows-first, aber nicht Windows-only** entwickelt. Die erste belastbare Renderplattform ist Windows mit GStreamer und einem GPU-residenten D3D11-Pfad; Linux nutzt OpenGL und Raspberry Pi OpenGL ES hinter demselben Backend-Vertrag. Control Core, Projektmodell, Pluginmanifest, Parameter-Engine, Art-Net, spätere Timeline/Audiofunktionen und Web-UI bleiben plattformneutral. Raspberry Pi wird als separates Leistungsprofil behandelt und nicht als gleichwertiger Ersatz für eine Desktop-GPU vermarktet. + +Die V1-Weboberfläche übernimmt nur die visuelle Ruhe, Dichte und Paneelstruktur professioneller QLab-Arbeitsflächen. GO, Standby, Cue-Carts und Cue-Timeline werden nicht vorgetäuscht, sondern erst als späteres Modul gebaut. Discovery, Paarung und Mehrserver-Steuerung sind dagegen Teil des Fundaments, weil sie sich nicht sauber nachträglich an ein Einzelsystem anflanschen lassen. + +Der wichtigste Projekterfolg ist nicht eine möglichst große Featureliste, sondern ein stabiler Echtzeitkern mit folgenden festen Grenzen: + +- keine Python-Pixelpipeline; +- kein browserbasierter Showoutput; +- keine direkte KI-Steuerung am Kern vorbei; +- kein Pluginwildwuchs ohne versionierten Vertrag; +- keine Performanceaussage ohne Messung auf echter Hardware; +- keine neue Phase ohne grünes Gate. + +Damit bleibt der kleine Medienserver zunächst realistisch baubar und kann anschließend kontrolliert zu einem leistungsfähigen Medien-, VJ-, Pixel- und Automationssystem wachsen. + +--- + +## 38. Technische Primärquellen + +- [Art-Net 4 – offizielle Spezifikation](https://art-net.org.uk/downloads/art-net.pdf) +- [GStreamer D3D11 H.264 Hardwaredecoder](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11h264dec.html) +- [GStreamer D3D11 Compositor](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11compositor.html) +- [GStreamer D3D11 Converter](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11convert.html) +- [GStreamer D3D11 Video Sink](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11videosink.html) +- [GStreamer NVIDIA H.264 Decoder und GPU-Memory-Ausgaben](https://gstreamer.freedesktop.org/documentation/nvcodec/nvh264dec.html) +- [GStreamer OpenGL Video Mixer](https://gstreamer.freedesktop.org/documentation/opengl/glvideomixer.html) +- [GStreamer GLSL Shader Filter](https://gstreamer.freedesktop.org/documentation/opengl/glshader.html) +- [RFC 6762 – Multicast DNS](https://www.rfc-editor.org/rfc/rfc6762.html) +- [RFC 6763 – DNS-Based Service Discovery](https://www.rfc-editor.org/rfc/rfc6763.html) +- [MADRIX 5 – offizielle Liste der Static Color Effects](https://help.madrix.com/m5/html/madrix/hidd_effects_sce_link.html) +- [MADRIX 5 – offizielle Filterübersicht](https://help.madrix.com/m5/html/madrix/hidd_filters.html) +- [MADRIX 5 – offizielles Beispiel SCE Plasma und dessen Parameter](https://help.madrix.com/m5/html/madrix/hidd_effect_sce_plasma.html) +- [Resolume – offizielles Effektmodell, Effektketten, Presets und Scopes](https://resolume.com/support/en/effects) +- [Resolume – offizielle Transform-/Slice-Transform-Dokumentation](https://resolume.com/support/en/transform) +- [Resolume – offizielle Generator-/Source-Dokumentation](https://resolume.com/support/en/sources) +- [QLab 5 – offizieller Workspace-Aufbau und UI-Abbildungen](https://qlab.app/docs/v5/fundamentals/workspace/) +- [QLab 5 – offizielle Cue-Cart-Abbildung](https://qlab.app/docs/v5/fundamentals/cue-carts/) +- [AMD Ryzen 7 8845HS – offizielle Produktspezifikation](https://www.amd.com/en/products/processors/laptop/ryzen/8000-series/amd-ryzen-7-8845hs.html) +- [Intel Processor N100 – offizielle Produktspezifikation](https://www.intel.com/content/www/us/en/products/sku/231803/intel-processor-n100-6m-cache-up-to-3-40-ghz/specifications.html) +- [wgpu – mögliche spätere portable native Renderbasis](https://wgpu.rs/) +- [Raspberry Pi 5 – offizielle Hardwaredaten](https://www.raspberrypi.com/products/raspberry-pi-5/) diff --git a/README.md b/README.md new file mode 100644 index 0000000..2359929 --- /dev/null +++ b/README.md @@ -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). diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..47ead85 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,40 @@ +# STATUS + +Stand: 2026-09-10 + +## Aktuelle Phase + +**Phase 0 – technischer Spike und Go/No-Go** (PLAN.md §31, §36) + +## Letzter grüner Commit + +- initialer Commit (Repository-Initialisierung + Phase-0-Grundlage; Messwerte siehe TEST_REPORT.md) + +## Bestandene Gates + +- keine; **Gate 0 ist offen** + +## Laufende Arbeit + +Erster Arbeitsauftrag (§36 Nr. 1–3 erledigt, Nr. 4–15 in Arbeit): + +- [x] Repository gemäß Eigentumsgrenzen initialisiert (§8) +- [x] Pflichtdokumente + ADR-Vorlage + ADRs 0001–0003 angelegt +- [x] GStreamer-Version für Windows gepinnt: **1.28.6** (`build/windows/GSTREAMER.md`, ADR-0002) +- [x] Kernpakete mit Unit-Tests: IPC-Protokoll (§6.2), Parameter-Engine (§11), Art-Net-Pakete/Empfänger (§16), Adaptive Quality (§5.2), Capability-Probe (§5.2), Plugin-SDK-Validierung (§14.5, §27.2) +- [x] Minimaler Control Core: FastAPI-REST + WebSocket für denselben Parametersatz (§36 Nr. 9) +- [x] Renderer-Spike: D3D11-/Dev-GL-Pipeline-Definitionen + CLI (§36 Nr. 4–5); ohne GStreamer-Installation kontrollierter Abbruch (Exit-Code 2), keine Erfolgssimulation (§33) +- [x] Beispielplugin Passthrough (HLSL + GLSL + GLES) als SDK-Referenz (§36 Nr. 6) +- [x] Tools: Art-Net-Emulator, Capability-Probe, Plugin/Shader-Validator + +## Nächste drei Aufgaben + +1. **Gate-0-Messungen auf Referenz-Windows-Hardware:** D3D11-Hardwaredecode, durchgängiger `D3D11Memory`-Pfad ohne CPU-Readback, Framezeit p99, DMX-Latenz (≤ 2 Frames), ruckelfreier Adaptive-Quality-Wechsel (§25, §36 Nr. 11) +2. Portable Onefolder-Ausgabe erzeugen und auf sauberem Windows-Rechner testen (Nuitka vs. PyInstaller → ADR; §36 Nr. 12–13) +3. Nativen Renderkern festlegen (Rust vs. C++, Bridge vs. GStreamer-Plugin → ADR-0004) und Renderer über IPC-Handshake an die Parameter-Engine anbinden (Phase 1) + +## Bekannte Blocker + +- **Keine Windows-Referenzhardware in der Entwicklungsumgebung** (Linux-Container, CPU-only). Alle Gate-0-Kriterien sind ausschließlich auf echter Hardware gültig (§29.7, §33). Spike-Code ist bereit; Messungen und Portabilitätstest stehen aus. +- pnpm/Node-Frontend noch nicht eingerichtet (Phase 5, ADR-0006 offen). +- HLSL-Live-Parameter im D3D11-Pfad erfordert den nativen Renderkern (ADR-0004 offen); GLSL-Testvariante für den Dev-Pfad liegt bei. diff --git a/TEST_REPORT.md b/TEST_REPORT.md new file mode 100644 index 0000000..a8a7aa5 --- /dev/null +++ b/TEST_REPORT.md @@ -0,0 +1,47 @@ +# 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-10 | (initialer Commit) | Kali-Linux-Container, Python 3.13, CPU-only | ausstehend – wird nach `uv sync` + `uv run pytest` hier eingetragen | + +Testumfang der Phase-0-Grundlage: + +- `tests/unit/test_protocol.py` – IPC-Envelope, length-prefixed MessagePack-Framing, Idempotenz +- `tests/unit/test_parameter_engine.py` – Prioritäten, LTP/HTP, Release, Frame-Snapshot, Revision +- `tests/unit/test_artnet_packets.py` – ArtDMX/ArtPoll-Bau und Parse, Prüfsummen +- `tests/unit/test_adaptive_quality.py` – Hysterese, eine Stufe je Intervall, Mindesthaltezeit +- `tests/unit/test_plugin_manifest.py` – Manifestvalidierung, Pfadsicherheit, ZIP-Limits +- `tests/unit/test_domain_ids.py` – stabile Node-ID ohne IP/Hostname-Abhängigkeit +- `tests/unit/test_fixture_generator.py` – Master32/Layer64-Kanallisten-CSV +- `tests/unit/test_pipelines.py` – Renderer-Pipeline-Definitionen (D3D11/Dev-GL) +- `tests/integration/test_control_server.py` – REST-Health, Commands, Revision-Konflikt, WebSocket-Snapshot + +## 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. diff --git a/apps/control_server/.gitkeep b/apps/control_server/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/control_server/hms_control_server/__init__.py b/apps/control_server/hms_control_server/__init__.py new file mode 100644 index 0000000..f14ab30 --- /dev/null +++ b/apps/control_server/hms_control_server/__init__.py @@ -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"] diff --git a/apps/control_server/hms_control_server/__main__.py b/apps/control_server/hms_control_server/__main__.py new file mode 100644 index 0000000..4c0f63f --- /dev/null +++ b/apps/control_server/hms_control_server/__main__.py @@ -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) diff --git a/apps/control_server/hms_control_server/app.py b/apps/control_server/hms_control_server/app.py new file mode 100644 index 0000000..fd021d4 --- /dev/null +++ b/apps/control_server/hms_control_server/app.py @@ -0,0 +1,159 @@ +"""FastAPI-Anwendung des Control Core (Phase-0-Minimalversion). + +Endpunkte: +- GET /api/v1/system/health +- 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 +- WS /ws (State-Snapshot + Updates) + +Commands folgen §23.2: command_id, type, expected_revision, actor, payload. +""" + +from __future__ import annotations + +import asyncio +import uuid + +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from hms_capabilities.probe import CapabilityReport +from hms_parameter.engine import ( + ControlSource, + ParameterEngine, + RevisionConflict, +) +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 _State: + def __init__(self) -> None: + self.engine = ParameterEngine() + self.registry = IdempotencyRegistry() + self.report = CapabilityReport() + self.subscribers: list[asyncio.Queue] = [] + + +def create_app() -> FastAPI: + app = FastAPI(title="HMS MediaEngine Control Core", version="0.1.0") + state = _State() + + def _broadcast(event: dict) -> None: + for queue in list(state.subscribers): + queue.put_nowait(event) + + @app.get("/api/v1/system/health") + async def health() -> dict: + return {"status": "ok", "phase": 0, "revision": state.engine.revision} + + @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.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, + ) + 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: + # Release nach §11.3; command_id referenziert den ursprünglichen Command. + return {"status": "not_implemented_in_phase0"} + + @app.get("/api/v1/diagnostics") + async def diagnostics() -> dict: + return { + "renderer": "not_connected", # IPC-Handshake folgt in Phase 1 (ADR-0003) + "artnet": "not_started", + "revision": state.engine.revision, + } + + @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() diff --git a/apps/launcher/.gitkeep b/apps/launcher/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/launcher/hms_launcher/__init__.py b/apps/launcher/hms_launcher/__init__.py new file mode 100644 index 0000000..9f79a89 --- /dev/null +++ b/apps/launcher/hms_launcher/__init__.py @@ -0,0 +1,10 @@ +"""hms_launcher – Supervisor/Launcher (PLAN.md §6.1A, §9). + +Phase-0-Umfang: portable Pfadauflösung, Portwahl, GStreamer-Environment, +kontrollierter Start von Control Core und Renderer. Vollständige +Heartbeat-/Crash-Recovery-Logik folgt in Phase 1 (§31). +""" + +from hms_launcher.paths import AppPaths, resolve_app_root + +__all__ = ["AppPaths", "resolve_app_root"] diff --git a/apps/launcher/hms_launcher/paths.py b/apps/launcher/hms_launcher/paths.py new file mode 100644 index 0000000..36e7bac --- /dev/null +++ b/apps/launcher/hms_launcher/paths.py @@ -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)") diff --git a/apps/renderer/.gitkeep b/apps/renderer/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/renderer/hms_renderer/__init__.py b/apps/renderer/hms_renderer/__init__.py new file mode 100644 index 0000000..e214e63 --- /dev/null +++ b/apps/renderer/hms_renderer/__init__.py @@ -0,0 +1,22 @@ +"""hms_renderer – Render-Worker-Spike (PLAN.md §6.1C, §12, §36 Nr. 4–6). + +Python orchestriert native GStreamer-Komponenten; keine Pixelverarbeitung +in Python (§2.1, §33). Pipelines werden als gst-launch-Strings definiert +und auf dem Zielsystem ausgeführt/messbar. +""" + +from hms_renderer.pipelines import ( + D3D11Pipeline, + DevGLPipeline, + build_compositor_pipeline, + build_single_video_pipeline, + gst_available, +) + +__all__ = [ + "D3D11Pipeline", + "DevGLPipeline", + "build_single_video_pipeline", + "build_compositor_pipeline", + "gst_available", +] diff --git a/apps/renderer/hms_renderer/__main__.py b/apps/renderer/hms_renderer/__main__.py new file mode 100644 index 0000000..dd37a12 --- /dev/null +++ b/apps/renderer/hms_renderer/__main__.py @@ -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()) diff --git a/apps/renderer/hms_renderer/pipelines.py b/apps/renderer/hms_renderer/pipelines.py new file mode 100644 index 0000000..b38db9c --- /dev/null +++ b/apps/renderer/hms_renderer/pipelines.py @@ -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 diff --git a/apps/web/.gitkeep b/apps/web/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/build/linux/.gitkeep b/build/linux/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/build/raspberry_pi/.gitkeep b/build/raspberry_pi/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/build/windows/.gitkeep b/build/windows/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/build/windows/GSTREAMER.md b/build/windows/GSTREAMER.md new file mode 100644 index 0000000..65c35dd --- /dev/null +++ b/build/windows/GSTREAMER.md @@ -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: ) +- **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=/runtime/gstreamer/lib/gstreamer-1.0 +PATH=/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) diff --git a/docs/adr/.gitkeep b/docs/adr/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/adr/0001-python-version-pin.md b/docs/adr/0001-python-version-pin.md new file mode 100644 index 0000000..c277b81 --- /dev/null +++ b/docs/adr/0001-python-version-pin.md @@ -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. diff --git a/docs/adr/0002-gstreamer-pin-windows.md b/docs/adr/0002-gstreamer-pin-windows.md new file mode 100644 index 0000000..6544d59 --- /dev/null +++ b/docs/adr/0002-gstreamer-pin-windows.md @@ -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. diff --git a/docs/adr/0003-ipc-tcp-msgpack.md b/docs/adr/0003-ipc-tcp-msgpack.md new file mode 100644 index 0000000..d031ec5 --- /dev/null +++ b/docs/adr/0003-ipc-tcp-msgpack.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..00dc7a7 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,17 @@ +# 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 + +## Offen (Phase 0 entscheidet, §32 / §7.1) + +- ADR-0004: Nativer Renderkern – Rust vs. C++, eigenständige Bridge vs. GStreamer-Plugin +- ADR-0005: Packaging – Nuitka vs. PyInstaller (Onefolder) +- ADR-0006: Frontend – React vs. Svelte +- ADR-0007: Typprüfung – mypy vs. pyright +- weitere gemäß Bauplan §32 (Persistenz, Preview, Show-Codec, Ownership, Display-Abstraktion, Adaptive-Quality-Policy, Discovery, Clusterprotokoll, Paarung/TLS, Clock-Sync, UI-Design-Tokens) diff --git a/docs/adr/_template.md b/docs/adr/_template.md new file mode 100644 index 0000000..7b1503e --- /dev/null +++ b/docs/adr/_template.md @@ -0,0 +1,30 @@ +# ADR-NNNN: + +- **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) diff --git a/docs/api/.gitkeep b/docs/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/architecture/.gitkeep b/docs/architecture/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..94a2a1f --- /dev/null +++ b/docs/architecture/overview.md @@ -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`. diff --git a/docs/fixture/.gitkeep b/docs/fixture/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/operator/.gitkeep b/docs/operator/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/performance/.gitkeep b/docs/performance/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/plugin-sdk/.gitkeep b/docs/plugin-sdk/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/fixture_profiles/layer64/.gitkeep b/fixture_profiles/layer64/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/fixture_profiles/layer64/layer64.csv b/fixture_profiles/layer64/layer64.csv new file mode 100644 index 0000000..fd7ebe7 --- /dev/null +++ b/fixture_profiles/layer64/layer64.csv @@ -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 diff --git a/fixture_profiles/master32/.gitkeep b/fixture_profiles/master32/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/fixture_profiles/master32/master32.csv b/fixture_profiles/master32/master32.csv new file mode 100644 index 0000000..094b313 --- /dev/null +++ b/fixture_profiles/master32/master32.csv @@ -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 diff --git a/native/render_bridge/.gitkeep b/native/render_bridge/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/adaptive_quality/.gitkeep b/packages/adaptive_quality/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/adaptive_quality/hms_adaptive/__init__.py b/packages/adaptive_quality/hms_adaptive/__init__.py new file mode 100644 index 0000000..ee64647 --- /dev/null +++ b/packages/adaptive_quality/hms_adaptive/__init__.py @@ -0,0 +1,5 @@ +"""hms_adaptive – Adaptive Quality Controller (PLAN.md §5.2).""" + +from hms_adaptive.controller import AdaptiveQualityController, QualityLevel + +__all__ = ["AdaptiveQualityController", "QualityLevel"] diff --git a/packages/adaptive_quality/hms_adaptive/controller.py b/packages/adaptive_quality/hms_adaptive/controller.py new file mode 100644 index 0000000..367c1f0 --- /dev/null +++ b/packages/adaptive_quality/hms_adaptive/controller.py @@ -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 diff --git a/packages/artnet/.gitkeep b/packages/artnet/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/artnet/hms_artnet/__init__.py b/packages/artnet/hms_artnet/__init__.py new file mode 100644 index 0000000..8186343 --- /dev/null +++ b/packages/artnet/hms_artnet/__init__.py @@ -0,0 +1,36 @@ +"""hms_artnet – Art-Net 4 Steuerung (PLAN.md §16). + +ArtDMX-Empfang, ArtPoll/ArtPollReply (Discovery als Media Server, Style 0x02), +konfigurierbare Universen/Adressen, Sequenzprüfung, Signalverlust-Verhalten. +Layouts gegen die offizielle Art-Net-4-Spezifikation verifiziert. +""" + +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.receiver import ArtNetReceiver, DmxUpdate, LossBehavior + +__all__ = [ + "OPDMX", + "OPPOLL", + "OPPOLLREPLY", + "UDP_PORT", + "build_dmx", + "parse_dmx", + "build_poll", + "parse_poll", + "build_artpoll_reply", + "parse_poll_reply", + "ArtNetReceiver", + "DmxUpdate", + "LossBehavior", +] diff --git a/packages/artnet/hms_artnet/mapping.py b/packages/artnet/hms_artnet/mapping.py new file mode 100644 index 0000000..65cbca5 --- /dev/null +++ b/packages/artnet/hms_artnet/mapping.py @@ -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) diff --git a/packages/artnet/hms_artnet/packets.py b/packages/artnet/hms_artnet/packets.py new file mode 100644 index 0000000..19dc5c8 --- /dev/null +++ b/packages/artnet/hms_artnet/packets.py @@ -0,0 +1,225 @@ +"""Art-Net-Pakete: Bau und Parse (offizielle Art-Net-4-Spezifikation). + +Verifizierte Regeln: +- ID: 'Art-Net\\0' (8 Bytes) +- OpCode: Int16 little-endian (low byte first) +- ProtVer: 14, high byte first (0x00 0x0E) +- ArtDMX: OpCode 0x5000, 18-Byte-Header + 2..512 Datenbytes, gerade Länge +- ArtPoll: OpCode 0x2000, 14 Bytes Kern, >= 14 akzeptieren +- ArtPollReply: OpCode 0x2100, 210 Bytes, Style 0x02 = StMedia, + NodeReport-Format '#hhhh [hhhh] text', Port 0x1936 +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +ARTNET_ID = b"Art-Net\x00" +PROTVER = 14 +OPDMX = 0x5000 +OPPOLL = 0x2000 +OPPOLLREPLY = 0x2100 +UDP_PORT = 0x1936 # 6454 +STYLE_STMEDIA = 0x02 + + +def _header(opcode: int) -> bytes: + """ID + OpCode (little-endian) + ProtVer 14 (high byte first). + + Endianness gemäß Spezifikation: OpCode low byte first, ProtVer + dagegen high byte first (0x00 0x0E). + """ + return ARTNET_ID + struct.pack("H", PROTVER) + + +def build_dmx(universe: int, data: bytes, sequence: int = 0, physical: int = 0) -> bytes: + """Baut ein ArtDMX-Paket (OpCode 0x5000). + + universe: 15-bit Port-Address (Net<<8 | SubUni) + data: 2..512 Kanalbytes; Länge muss gerade sein, wird aufgerundet. + """ + if not 0 <= universe < 0x8000: + raise ValueError("universe must be 0..0x7FFF") + if not 2 <= len(data) <= 512: + raise ValueError("data must be 2..512 bytes") + if len(data) % 2: + data = data + b"\x00" + length = len(data) + sub_uni = universe & 0xFF + net = (universe >> 8) & 0x7F + return ( + _header(OPDMX) + + struct.pack(">BB", sequence & 0xFF, physical & 0xFF) + + struct.pack(">BB", sub_uni, net) + + struct.pack(">H", length) + + data + ) + + +@dataclass(frozen=True) +class DmxPacket: + sequence: int + physical: int + universe: int + data: bytes + + +def parse_dmx(packet: bytes) -> DmxPacket | None: + """Parst ein ArtDMX-Paket; None wenn kein gültiges ArtDMX.""" + if len(packet) < 18 or packet[:8] != ARTNET_ID: + return None + (opcode,) = struct.unpack_from("H", packet, 10) + if protver < 14: + return None + sequence = packet[12] + physical = packet[13] + sub_uni = packet[14] + net = packet[15] & 0x7F + (length,) = struct.unpack_from(">H", packet, 16) + if length < 2 or length > 512: + return None + if len(packet) < 18 + length: + return None + return DmxPacket(sequence, physical, (net << 8) | sub_uni, bytes(packet[18 : 18 + length])) + + +def build_poll(talk_to_me: int = 0x00, priority: int = 0x0A) -> bytes: + """Baut ein ArtPoll-Paket (OpCode 0x2000, 14 Bytes Kern).""" + return _header(OPPOLL) + struct.pack(">BB", talk_to_me, priority) + + +@dataclass(frozen=True) +class PollPacket: + talk_to_me: int + priority: int + + +def parse_poll(packet: bytes) -> PollPacket | None: + """Parst ein ArtPoll; akzeptiert >= 14 Bytes (fehlende Felder = 0).""" + if len(packet) < 14 or packet[:8] != ARTNET_ID: + return None + (opcode,) = struct.unpack_from("H", packet, 10) + if protver < 14: + return None + return PollPacket(packet[12], packet[13]) + + +@dataclass(frozen=True) +class PollReplyInfo: + ip: str + short_name: str + long_name: str + node_report: str + style: int + bind_index: int + net_switch: int + sub_switch: int + num_ports: int + port_types: bytes + sw_in: bytes + sw_out: bytes + mac: bytes + + +def build_artpoll_reply( + ip: bytes, + short_name: str, + long_name: str, + node_report: str = "Media Server Ready", + report_code: int = 0x0000, + error_count: int = 0, + style: int = STYLE_STMEDIA, + mac: bytes = b"\x00" * 6, + net_switch: int = 0, + sub_switch: int = 0, + num_ports: int = 1, + port_types: bytes = b"\x80\x00\x00\x00", # Port 0: DMX512, Output-fähig + good_output: bytes = b"\x00\x00\x00\x00", + sw_out: bytes = b"\x00\x00\x00\x00", + bind_index: int = 1, + esta_man: int = 0x0000, # unregistriert; ESTA-Code später beantragen + vers_info: int = 0x00010000, +) -> bytes: + """Baut ein ArtPollReply (exakt 210 Bytes) als Media Server (Style 0x02).""" + if len(ip) != 4: + raise ValueError("ip must be 4 bytes") + if len(mac) != 6: + raise ValueError("mac must be 6 bytes") + short = short_name.encode("ascii", errors="replace")[:17] + long = long_name.encode("ascii", errors="replace")[:63] + report = f"#{report_code:04X} [{error_count:04X}] {node_report}".encode( + "ascii", errors="replace" + )[:63] + pkt = bytearray() + pkt += ARTNET_ID + pkt += struct.pack("H", UDP_PORT) + pkt += struct.pack(">I", vers_info) + pkt += struct.pack(">B", net_switch & 0x7F) + pkt += struct.pack(">B", sub_switch & 0x0F) + pkt += struct.pack(">H", 0x0000) # OEM: Platzhalter bis Registrierung + pkt += b"\x00" # UbeaVersion + pkt += b"\x00" # Status1 + pkt += struct.pack(">H", esta_man) # ESTA Manufacturer, high byte first + pkt += short.ljust(18, b"\x00") + pkt += long.ljust(64, b"\x00") + pkt += report.ljust(64, b"\x00") + pkt += struct.pack(">BB", 0, num_ports & 0x03) # NumPortsLo 0..4 + pkt += port_types[:4].ljust(4, b"\x00") + pkt += b"\x00" * 4 # GoodInput (kein DMX-In in V1) + pkt += good_output[:4].ljust(4, b"\x00") + pkt += b"\x00" * 4 # SwIn + pkt += sw_out[:4].ljust(4, b"\x00") + pkt += b"\x00" * 3 # SwVideo, SwMacro, SwRemote (deprecated = 0) + pkt += b"\x00" * 3 # Spare1..3 + pkt += struct.pack(">B", style) + pkt += mac + pkt += struct.pack(">B", bind_index) + if len(pkt) != 210: + raise AssertionError(f"ArtPollReply must be 210 bytes, got {len(pkt)}") + return bytes(pkt) + + +def parse_poll_reply(packet: bytes) -> PollReplyInfo | None: + """Parst ein ArtPollReply (akzeptiert >= 210 Bytes).""" + if len(packet) < 210 or packet[:8] != ARTNET_ID: + return None + (opcode,) = struct.unpack_from(" None: + self._handlers.append(handler) + + async def start(self) -> None: + loop = asyncio.get_running_loop() + self._transport, _ = await loop.create_datagram_endpoint( + lambda: _Protocol(self), local_addr=(self.bind_host, self.port) + ) + self._watchdog_task = loop.create_task(self._signal_watchdog()) + + async def stop(self) -> None: + if self._watchdog_task: + self._watchdog_task.cancel() + self._watchdog_task = None + if self._transport: + self._transport.close() + self._transport = None + + def telemetry(self) -> dict[int, UniverseTelemetry]: + return dict(self._telemetry) + + def _handle_datagram(self, data: bytes, addr: tuple) -> None: + sender_ip = addr[0] if addr else "" + if self.sender_allowlist and sender_ip not in self.sender_allowlist: + return + if parse_poll(data) is not None: + reply = build_artpoll_reply( + ip=self.node_ip, + short_name=self.short_name, + long_name=self.long_name, + node_report="Media Server Ready", + mac=self.mac, + ) + if self._transport is not None: + self._transport.sendto(reply, addr) + return + dmx = parse_dmx(data) + if dmx is None or dmx.universe not in self.universes: + return + tel = self._telemetry.setdefault(dmx.universe, UniverseTelemetry(dmx.universe)) + if dmx.sequence != 0: + last = self._last_sequence.get(dmx.universe) + if last is not None and dmx.sequence != ((last + 1) & 0xFF): + tel.sequence_gaps += 1 + self._last_sequence[dmx.universe] = dmx.sequence + if tel.last_sender_ip and tel.last_sender_ip != sender_ip: + tel.sender_changed += 1 + tel.packets += 1 + tel.last_received_ns = time.monotonic_ns() + tel.last_sender_ip = sender_ip + tel.loss_reported = False + update = DmxUpdate( + universe=dmx.universe, + data=dmx.data, + sender_ip=sender_ip, + received_ns=tel.last_received_ns, + sequence=dmx.sequence, + ) + for handler in list(self._handlers): + handler(update) + + async def _signal_watchdog(self) -> None: + while True: + await asyncio.sleep(0.5) + now = time.monotonic_ns() + threshold = self.timeout_ms * 1_000_000 + for uni, tel in list(self._telemetry.items()): + if ( + tel.last_received_ns + and not tel.loss_reported + and now - tel.last_received_ns > threshold + ): + tel.loss_reported = True + for handler in list(self._handlers): + handler( + DmxUpdate( + universe=uni, + data=b"", + sender_ip=tel.last_sender_ip, + received_ns=now, + sequence=-1, + ) + ) + + +class _Protocol(asyncio.DatagramProtocol): + def __init__(self, receiver: ArtNetReceiver) -> None: + self._receiver = receiver + + def datagram_received(self, data: bytes, addr: tuple) -> None: + self._receiver._handle_datagram(data, addr) + + def error_received(self, exc: Exception) -> None: + # Socket-Fehler nicht schlucken (§33); an Watchdog-Protokoll escalate via log + import logging + + logging.getLogger("hms.artnet").error("Art-Net socket error: %s", exc) diff --git a/packages/audio_analysis/.gitkeep b/packages/audio_analysis/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/capabilities/.gitkeep b/packages/capabilities/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/capabilities/hms_capabilities/__init__.py b/packages/capabilities/hms_capabilities/__init__.py new file mode 100644 index 0000000..1a8978a --- /dev/null +++ b/packages/capabilities/hms_capabilities/__init__.py @@ -0,0 +1,5 @@ +"""hms_capabilities – Hardware-Erkennung und Capability-Tiers (PLAN.md §5).""" + +from hms_capabilities.probe import CapabilityReport, CapabilityTier, detect_cpu_ram + +__all__ = ["CapabilityTier", "CapabilityReport", "detect_cpu_ram"] diff --git a/packages/capabilities/hms_capabilities/probe.py b/packages/capabilities/hms_capabilities/probe.py new file mode 100644 index 0000000..6cab903 --- /dev/null +++ b/packages/capabilities/hms_capabilities/probe.py @@ -0,0 +1,99 @@ +"""Capability-Probe (PLAN.md §5, §5.2). + +Phase 0: plattformneutrale Basis-Erkennung (CPU/RAM/OS) und Tier-Vergabe +nach gemessenen Fakten. GPU-/Decoder-/Display-Erkennung läuft auf dem +Zielsystem (D3D11/GL/GLES); hier kein Fake-Ergebnis (§33: keine nicht +getestete Dekodierung als Hardwarebeschleunigung ausgeben). +""" + +from __future__ import annotations + +import enum + + +class CapabilityTier(enum.StrEnum): + DESKTOP_FULL = "DESKTOP_FULL" + DESKTOP_LITE = "DESKTOP_LITE" + PI_LITE = "PI_LITE" + HEADLESS_CONTROL = "HEADLESS_CONTROL" + + +# Mindest-VRAM für DESKTOP_FULL (§5) +_DESKTOP_FULL_MIN_VRAM_GB = 8.0 + + +def detect_cpu_ram() -> dict[str, object]: + """Basis-Hardwareinformationen (plattformneutral, ohne Fake).""" + import os + import platform + + info: dict[str, object] = { + "os": platform.system(), + "os_release": platform.release(), + "machine": platform.machine(), + "cpu_count": os.cpu_count() or 1, + "ram_total_gb": _ram_gb(), + } + return info + + +def _ram_gb() -> float: + """RAM in GB; Linux via /proc/meminfo, sonst -1 (unbekannt, nicht geraten).""" + try: + with open("/proc/meminfo", encoding="ascii") as fh: + for line in fh: + if line.startswith("MemTotal:"): + kib = int(line.split()[1]) + return round(kib / (1024 * 1024), 2) + except (OSError, ValueError): + pass + return -1.0 + + +class CapabilityReport: + """Ergebnis des Capability-Selbsttests (Phase 0: Skelett). + + GPU/Decoder/Displays werden auf dem Zielsystem gemessen und hier + ergänzt; ein Report ohne GPU-Messung kann kein DESKTOP-Tier vergeben. + """ + + def __init__(self) -> None: + self.cpu_ram = detect_cpu_ram() + self.gpu: dict[str, object] | None = None + self.decoders: dict[str, object] | None = None + self.displays: dict[str, object] | None = None + self.tier: CapabilityTier | None = None + self.fingerprint: str = "" # an Messwerte gebunden (§5.2) + + def conclude_tier( + self, + has_gpu: bool, + vram_gb: float | None, + decode_ok: bool, + has_display: bool, + ) -> CapabilityTier | None: + """Vergibt das Tier nach gemessenen Fakten; None wenn unklar. + + Unklar bedeutet: Gate 0 darf nicht grün melden, solange keine + Messwerte vorliegen (§33). + """ + if not has_display: + self.tier = CapabilityTier.HEADLESS_CONTROL + return self.tier + if not has_gpu or not decode_ok: + return None + if vram_gb is not None and vram_gb >= _DESKTOP_FULL_MIN_VRAM_GB: + self.tier = CapabilityTier.DESKTOP_FULL + else: + self.tier = CapabilityTier.DESKTOP_LITE + return self.tier + + def as_dict(self) -> dict[str, object]: + return { + "cpu_ram": self.cpu_ram, + "gpu": self.gpu, + "decoders": self.decoders, + "displays": self.displays, + "tier": self.tier.value if self.tier else None, + "fingerprint": self.fingerprint, + } diff --git a/packages/cluster/.gitkeep b/packages/cluster/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/content_sync/.gitkeep b/packages/content_sync/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/domain/.gitkeep b/packages/domain/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/domain/hms_domain/__init__.py b/packages/domain/hms_domain/__init__.py new file mode 100644 index 0000000..74c5679 --- /dev/null +++ b/packages/domain/hms_domain/__init__.py @@ -0,0 +1,5 @@ +"""hms_domain – plattformneutrale Domänenobjekte (PLAN.md §10).""" + +from hms_domain.ids import new_node_id, new_uuid, persistent_node_id + +__all__ = ["new_uuid", "new_node_id", "persistent_node_id"] diff --git a/packages/domain/hms_domain/ids.py b/packages/domain/hms_domain/ids.py new file mode 100644 index 0000000..890f3cb --- /dev/null +++ b/packages/domain/hms_domain/ids.py @@ -0,0 +1,53 @@ +"""Stabile IDs (PLAN.md §3.6, §10.1). + +- UUIDs für alle Show-Objekte. +- Persistente node_id: einmal erzeugt, dauerhaft gespeichert; unabhängig + von IP-Adresse und Hostname (§6.3). +""" + +from __future__ import annotations + +import os +import uuid +from pathlib import Path + + +def new_uuid() -> str: + """Stabile UUID für Show-Objekte (Layer, Effekte, Outputs, ...).""" + return str(uuid.uuid4()) + + +def _machine_independent_seed() -> bytes: + """Einstreu ohne IP/Hostname: OS-Urandom hat Priorität (§6.3).""" + return os.urandom(16) + + +def new_node_id() -> str: + """Erzeugt eine neue, netzwerkunabhängige node_id (UUIDv4).""" + return str(uuid.UUID(bytes=_machine_independent_seed(), version=4)) + + +def persistent_node_id(identity_file: Path) -> str: + """Lädt die node_id aus identity_file oder erzeugt sie genau einmal. + + IP-Wechsel ändern die node_id nicht; doppelte Vergabe über die Datei + wird durch exklusives Erzeugen (O_EXCL) verhindert. + """ + identity_file = Path(identity_file) + if identity_file.exists(): + existing = identity_file.read_text(encoding="utf-8").strip() + if existing: + uuid.UUID(existing) # Validierung: muss UUID sein + return existing + identity_file.parent.mkdir(parents=True, exist_ok=True) + candidate = new_node_id() + try: + fd = os.open(identity_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(candidate) + return candidate + except FileExistsError: + existing = identity_file.read_text(encoding="utf-8").strip() + if not existing: + raise + return existing diff --git a/packages/parameter_engine/.gitkeep b/packages/parameter_engine/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/parameter_engine/hms_parameter/__init__.py b/packages/parameter_engine/hms_parameter/__init__.py new file mode 100644 index 0000000..5f80f2d --- /dev/null +++ b/packages/parameter_engine/hms_parameter/__init__.py @@ -0,0 +1,23 @@ +"""hms_parameter – zentrale Parameter- und Control-Engine (PLAN.md §11).""" + +from hms_parameter.engine import ( + ControlSource, + MergeMode, + ParameterEngine, + ParameterFrame, +) +from hms_parameter.paths import ( + layer_opacity_path, + master_intensity_path, + validate_parameter_path, +) + +__all__ = [ + "ControlSource", + "MergeMode", + "ParameterEngine", + "ParameterFrame", + "validate_parameter_path", + "layer_opacity_path", + "master_intensity_path", +] diff --git a/packages/parameter_engine/hms_parameter/engine.py b/packages/parameter_engine/hms_parameter/engine.py new file mode 100644 index 0000000..f40ee5a --- /dev/null +++ b/packages/parameter_engine/hms_parameter/engine.py @@ -0,0 +1,156 @@ +"""Parameter-Engine: Prioritäten, Übernahme, Frame-Snapshot (PLAN.md §11). + +Alle Steuerquellen (Browser, Art-Net, später Timeline/Audio/KI) laufen über +diese Engine; direkte Renderer-Zugriffe sind verboten (§11, §33). +""" + +from __future__ import annotations + +import enum +import time +from dataclasses import dataclass, field + +from hms_parameter.paths import validate_parameter_path + + +class ControlSource(enum.IntEnum): + """Steuerquellen in Prioritätsordnung (§11.2).""" + + SAFETY = 1 # Not-Aus/Blackout, überstimmt alles + OPERATOR = 2 # expliziter manueller Override + CONSOLE = 3 # freigegebenes Lichtpult (Art-Net) + WEB = 4 # Browser-Livebedienung + TIMELINE = 5 # reserviert + AUDIO = 6 # reserviert (Modulatoren) + AI = 7 # reserviert, niedrigste Priorität + + +class MergeMode(enum.Enum): + """Übernahmeverfahren (§11.3).""" + + LTP = "ltp" # letzte Änderung gewinnt (Standard) + HTP = "htp" # höchster Wert gewinnt (optional für Intensität) + + +@dataclass +class _Binding: + value: float + last_change_ns: int + + +class ParameterFrame: + """Unveränderlicher Snapshot aller Parameter für genau einen Frame (§11.4).""" + + __slots__ = ("_values", "revision", "created_ns") + + def __init__(self, values: dict[str, float], revision: int) -> None: + object.__setattr__(self, "_values", dict(values)) + object.__setattr__(self, "revision", revision) + object.__setattr__(self, "created_ns", time.monotonic_ns()) + + def get(self, path: str, default: float = 0.0) -> float: + return self._values.get(path, default) + + def as_dict(self) -> dict[str, float]: + return dict(self._values) + + def __contains__(self, path: str) -> bool: + return path in self._values + + +class RevisionConflict(Exception): + """Erwartete Revision stimmt nicht (optimistische Sperre, §23.2).""" + + def __init__(self, current: int, expected: int) -> None: + self.current = current + self.expected = expected + super().__init__(f"revision conflict: current={current}, expected={expected}") + + +@dataclass +class ParameterEngine: + """Autoritative Parameter-Instanz des Control Core. + + - set_value: Override einer Quelle mit Prioritätsprüfung + - release: Rückgabe an nächstniedrigere Quelle (§11.3) + - snapshot: atomarer Frame-Snapshot (§11.4) + """ + + revision: int = 0 + default: float = 0.0 + merge_mode: MergeMode = MergeMode.LTP + _bindings: dict[str, dict[ControlSource, _Binding]] = field( + default_factory=dict, repr=False + ) + _defaults: dict[str, float] = field(default_factory=dict, repr=False) + + def set_value( + self, + path: str, + value: float, + source: ControlSource, + expected_revision: int | None = None, + ) -> int: + """Setzt einen Override; gibt die neue Revision zurück.""" + if not validate_parameter_path(path): + raise ValueError(f"invalid parameter path: {path!r}") + value = float(value) + if value != value or value in (float("inf"), float("-inf")): + raise ValueError(f"value must be finite, got {value}") + if expected_revision is not None and expected_revision != self.revision: + raise RevisionConflict(self.revision, expected_revision) + + per_source = self._bindings.setdefault(path, {}) + # Priorität: eine niedrigere Quelle kann eine höhere Quelle nicht + # verdrängen, aber ihre eigene Bindung jederzeit aktualisieren. + existing = per_source.get(source) + now = time.monotonic_ns() + if existing is None: + per_source[source] = _Binding(value, now) + self.revision += 1 + elif self.merge_mode is MergeMode.LTP: + # LTP: jede Übernahme aktualisiert Bindung und Revision (§11.3) + per_source[source] = _Binding(value, now) + self.revision += 1 + elif value > existing.value: + # HTP: nur ein höherer Wert übernimmt; Maximum bleibt (§11.3) + per_source[source] = _Binding(value, now) + self.revision += 1 + return self.revision + + def effective_value(self, path: str) -> float: + """Wirksamer Wert: höchste Priorität gewinnt; sonst Default (§11.1).""" + per_source = self._bindings.get(path) + if not per_source: + return self._defaults.get(path, self.default) + source = min(per_source) # kleinster IntEnum-Wert = höchste Priorität + return per_source[source].value + + def current_source(self, path: str) -> ControlSource | None: + per_source = self._bindings.get(path) + if not per_source: + return None + return min(per_source) + + def release(self, path: str, source: ControlSource) -> int: + """Gibt den Override zurück; nächstniedrigere Quelle übernimmt (§11.3).""" + per_source = self._bindings.get(path) + if per_source and source in per_source: + del per_source[source] + if not per_source: + self._bindings.pop(path, None) + self.revision += 1 + return self.revision + + def snapshot(self) -> ParameterFrame: + """Atomarer Snapshot aller wirksamen Werte für einen Frame (§11.4).""" + values = {p: self._defaults[p] for p in self._defaults} + for path, per_source in self._bindings.items(): + if per_source: + values[path] = per_source[min(per_source)].value + return ParameterFrame(values, self.revision) + + def set_default(self, path: str, value: float) -> None: + if not validate_parameter_path(path): + raise ValueError(f"invalid parameter path: {path!r}") + self._defaults[path] = float(value) diff --git a/packages/parameter_engine/hms_parameter/paths.py b/packages/parameter_engine/hms_parameter/paths.py new file mode 100644 index 0000000..e45774f --- /dev/null +++ b/packages/parameter_engine/hms_parameter/paths.py @@ -0,0 +1,49 @@ +"""Stabile Parameterpfade (PLAN.md §10.2). + +Pfade werden niemals aus sichtbaren Namen gebildet; alle Teile sind UUIDs +oder feste Schlüsselwörter. +""" + +from __future__ import annotations + +import uuid + +_ALLOWED_ROOTS = {"composition", "output", "cluster", "master"} + + +def _is_uuid(value: str) -> bool: + try: + uuid.UUID(value) + return True + except (ValueError, AttributeError): + return False + + +def validate_parameter_path(path: str) -> bool: + """True, wenn der Pfad dem Muster §10.2 entspricht.""" + if not path or path.startswith("/") or "\\" in path or ".." in path: + return False + parts = path.split("/") + root = parts[0] + if root not in _ALLOWED_ROOTS: + return False + if root == "master": + return len(parts) == 2 and parts[1] != "" + if root in {"composition", "output"}: + if len(parts) < 3: + return False + if not _is_uuid(parts[1]): + return False + return all(p != "" for p in parts[2:]) + # cluster: cluster/group/{uuid}/... oder cluster/node/{uuid}/... + if len(parts) >= 3 and parts[1] in {"group", "node"} and _is_uuid(parts[2]): + return all(p != "" for p in parts[3:]) + return False + + +def layer_opacity_path(composition_id: str, layer_id: str) -> str: + return f"composition/{composition_id}/layer/{layer_id}/opacity" + + +def master_intensity_path() -> str: + return "master/intensity" diff --git a/packages/persistence/.gitkeep b/packages/persistence/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/plugin_sdk/.gitkeep b/packages/plugin_sdk/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/plugin_sdk/hms_plugin_sdk/__init__.py b/packages/plugin_sdk/hms_plugin_sdk/__init__.py new file mode 100644 index 0000000..bf234d6 --- /dev/null +++ b/packages/plugin_sdk/hms_plugin_sdk/__init__.py @@ -0,0 +1,15 @@ +"""hms_plugin_sdk – Plugin-API, Manifest, Validierung (PLAN.md §14).""" + +from hms_plugin_sdk.manifest import ( + PluginKind, + load_manifest, + validate_manifest, + validate_plugin_zip, +) + +__all__ = [ + "PluginKind", + "load_manifest", + "validate_manifest", + "validate_plugin_zip", +] diff --git a/packages/plugin_sdk/hms_plugin_sdk/manifest.py b/packages/plugin_sdk/hms_plugin_sdk/manifest.py new file mode 100644 index 0000000..6508d1f --- /dev/null +++ b/packages/plugin_sdk/hms_plugin_sdk/manifest.py @@ -0,0 +1,227 @@ +"""Plugin-Manifest und Validierung (PLAN.md §14.2–14.6, §27.2). + +Sicherheitsgrenzen: +- Pfadsicherheit: keine absoluten Pfade, kein '..' in Manifest und ZIP +- ZIP-Bomb-Limits, Dateigrößenlimits, erlaubte Dateitypen +- eindeutige Plugin-ID (reverse-dns), SemVer, api_version +- Shader-Dateien müssen je deklariertem Backend existieren +- max. 8 generische DMX-Slots je Effektinstanz (§14.7) +""" + +from __future__ import annotations + +import json +import zipfile +from enum import StrEnum +from pathlib import Path, PurePosixPath +from typing import Any + +MANIFEST_SCHEMA_VERSION = 1 +MAX_PLUGIN_FILES = 512 +MAX_TOTAL_UNPACKED = 32 * 1024 * 1024 +MAX_FILE_SIZE = 8 * 1024 * 1024 +_ALLOWED_SUFFIXES = { + ".json", + ".hlsl", + ".frag", + ".vert", + ".glsl", + ".png", + ".md", + ".txt", + ".toml", + ".csv", +} +_ALLOWED_BACKENDS = {"d3d11", "gl", "gles"} + + +class PluginKind(StrEnum): + SOURCE = "source" + GENERATOR = "generator" + FILTER = "filter" + TRANSITION = "transition" + MIXER = "mixer" + OUTPUT = "output" + CONTROL = "control" + AUTOMATION = "automation" + + +def _safe_relative(raw: str) -> PurePosixPath | None: + """Prüft Pfadsicherheit; None wenn unsicher (absolut oder Traversal).""" + if not raw: + return None + p = PurePosixPath(raw) + if p.is_absolute() or ".." in p.parts: + return None + return p + + +def _validate_parameters(params: list[dict[str, Any]]) -> list[str]: + errors: list[str] = [] + seen: set[str] = set() + total_dmx_slots = 0 + for param in params: + pid = param.get("id") + if not pid or not isinstance(pid, str): + errors.append("parameter without id") + continue + if pid in seen: + errors.append(f"duplicate parameter id: {pid}") + seen.add(pid) + ptype = param.get("type") + if ptype not in {"float", "int", "enum", "bool", "color"}: + errors.append(f"parameter {pid}: invalid type {ptype!r}") + if ptype == "float": + for key in ("minimum", "maximum", "default"): + if key not in param: + errors.append(f"parameter {pid}: missing {key}") + slots = param.get("dmx_slots", []) + if not isinstance(slots, list) or any(not isinstance(s, int) for s in slots): + errors.append(f"parameter {pid}: dmx_slots must be int list") + slots = [] + total_dmx_slots += len(slots) + if total_dmx_slots > 8: + errors.append(f"dmx slot footprint {total_dmx_slots} exceeds 8 (§14.7)") + return errors + + +def _valid_plugin_id(pid: str) -> bool: + if ".." in pid or len(pid) < 5: + return False + parts = pid.split(".") + if len(parts) < 2: + return False + allowed = set("abcdefghijklmnopqrstuvwxyz0123456789._-") + return all(c in allowed for c in pid) + + +def _valid_semver(version: str) -> bool: + parts = version.split(".") + if len(parts) != 3: + return False + try: + for p in parts: + int(p) + except ValueError: + return False + return True + + +def validate_manifest( + manifest: dict[str, Any], plugin_root: Path | None = None +) -> list[str]: + """Validiert ein geparstes Manifest; leere Fehlerliste = gültig. + + plugin_root: wenn gesetzt, werden deklarierte Shader auf Existenz geprüft. + """ + errors: list[str] = [] + + if manifest.get("schema_version") != MANIFEST_SCHEMA_VERSION: + errors.append(f"schema_version must be {MANIFEST_SCHEMA_VERSION}") + + pid = manifest.get("id", "") + if not isinstance(pid, str) or not _valid_plugin_id(pid): + errors.append(f"invalid plugin id: {pid!r} (expected reverse-dns)") + + for key in ("name", "version", "vendor"): + value = manifest.get(key) + if not isinstance(value, str) or not value: + errors.append(f"missing or empty {key}") + + if not _valid_semver(manifest.get("version", "")): + errors.append("version must be semantic (X.Y.Z)") + + if manifest.get("api_version") != MANIFEST_SCHEMA_VERSION: + errors.append(f"api_version must be {MANIFEST_SCHEMA_VERSION}") + + if manifest.get("kind") not in {k.value for k in PluginKind}: + errors.append(f"invalid kind: {manifest.get('kind')!r}") + + entrypoints = manifest.get("entrypoints", {}) + if not isinstance(entrypoints, dict) or not entrypoints: + errors.append("entrypoints required") + else: + supported = set(manifest.get("capabilities", {}).get("supported_backends", [])) + unknown = supported - _ALLOWED_BACKENDS + if unknown: + errors.append(f"unsupported backends: {sorted(unknown)}") + for backend, entry in entrypoints.items(): + if backend not in _ALLOWED_BACKENDS: + errors.append(f"entrypoint backend {backend!r} not allowed") + continue + if backend in supported: + passes = entry.get("passes", []) + if not passes: + errors.append(f"entrypoint {backend}: no passes") + for pas in passes: + shader_key = "pixel_shader" if "pixel_shader" in pas else "fragment" + shader_rel = pas.get(shader_key) + if not shader_rel: + errors.append(f"entrypoint {backend}: pass without shader") + continue + sp = _safe_relative(shader_rel) + if sp is None: + errors.append(f"unsafe shader path: {shader_rel!r}") + continue + if plugin_root is not None and not (plugin_root / sp).is_file(): + errors.append(f"missing shader file: {shader_rel}") + + params = manifest.get("parameters", []) + if not isinstance(params, list): + errors.append("parameters must be a list") + else: + errors.extend(_validate_parameters(params)) + + if manifest.get("failure_mode") not in {"bypass", "hold", "black"}: + errors.append("failure_mode must be bypass|hold|black") + + return errors + + +def validate_plugin_zip(zip_path: Path) -> list[str]: + """Prüft ein Plugin-ZIP: Pfadsicherheit, Limits, Typen, Manifest (§27.2).""" + errors: list[str] = [] + try: + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + if len(names) > MAX_PLUGIN_FILES: + errors.append(f"too many files: {len(names)} > {MAX_PLUGIN_FILES}") + total = 0 + for info in zf.infolist(): + if info.is_dir(): + continue + total += info.file_size + if info.file_size > MAX_FILE_SIZE: + errors.append(f"file too large: {info.filename}") + if _safe_relative(info.filename) is None: + errors.append(f"unsafe path in zip: {info.filename!r}") + if Path(info.filename).suffix.lower() not in _ALLOWED_SUFFIXES: + errors.append(f"disallowed file type: {info.filename}") + if total > MAX_TOTAL_UNPACKED: + errors.append(f"zip too large unpacked: {total} > {MAX_TOTAL_UNPACKED}") + manifest_name = next( + (n for n in names if n.endswith("plugin.json") and n.count("/") == 1), + None, + ) + if manifest_name is None: + errors.append("plugin.json not found at package root") + else: + manifest = json.loads(zf.read(manifest_name)) + errors.extend(validate_manifest(manifest)) + except zipfile.BadZipFile: + errors.append("not a valid zip file") + except json.JSONDecodeError as exc: + errors.append(f"plugin.json invalid JSON: {exc}") + return errors + + +def load_manifest(plugin_dir: Path) -> tuple[dict[str, Any], list[str]]: + """Lädt und validiert plugin.json aus einem Plugin-Verzeichnis.""" + manifest_path = plugin_dir / "plugin.json" + if not manifest_path.is_file(): + return {}, ["plugin.json missing"] + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return {}, [f"plugin.json invalid JSON: {exc}"] + return manifest, validate_manifest(manifest, plugin_root=plugin_dir) diff --git a/packages/protocol/.gitkeep b/packages/protocol/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/protocol/hms_protocol/__init__.py b/packages/protocol/hms_protocol/__init__.py new file mode 100644 index 0000000..f33e910 --- /dev/null +++ b/packages/protocol/hms_protocol/__init__.py @@ -0,0 +1,18 @@ +"""hms_protocol – versioniertes IPC (PLAN.md §6.2, ADR-0003). + +Lokales TCP auf 127.0.0.1, length-prefixed MessagePack, Protokollversion 1. +""" + +from hms_protocol.envelope import Envelope, MessageType +from hms_protocol.framing import decode_frame, encode_frame, read_frame, write_frame +from hms_protocol.idempotency import IdempotencyRegistry + +__all__ = [ + "Envelope", + "MessageType", + "encode_frame", + "decode_frame", + "read_frame", + "write_frame", + "IdempotencyRegistry", +] diff --git a/packages/protocol/hms_protocol/envelope.py b/packages/protocol/hms_protocol/envelope.py new file mode 100644 index 0000000..ce8709e --- /dev/null +++ b/packages/protocol/hms_protocol/envelope.py @@ -0,0 +1,37 @@ +"""IPC-Nachrichten-Umschlag (PLAN.md §6.2).""" + +from __future__ import annotations + +import time +import uuid +from enum import StrEnum + +from pydantic import BaseModel, Field + +PROTOCOL_VERSION = 1 + + +class MessageType(StrEnum): + COMMAND = "command" + EVENT = "event" + SNAPSHOT = "snapshot" + ACK = "ack" + ERROR = "error" + TELEMETRY = "telemetry" + + +class Envelope(BaseModel): + """Jede IPC-Nachricht besitzt mindestens diese Felder (§6.2).""" + + protocol_version: int = PROTOCOL_VERSION + message_id: str = Field(default_factory=lambda: str(uuid.uuid4())) + type: MessageType + revision: int = 0 + monotonic_timestamp_ns: int = Field(default_factory=lambda: time.monotonic_ns()) + payload: dict = Field(default_factory=dict) + + def model_post_init(self, _ctx: object) -> None: + if self.protocol_version != PROTOCOL_VERSION: + raise ValueError( + f"protocol_version {self.protocol_version} != {PROTOCOL_VERSION}" + ) diff --git a/packages/protocol/hms_protocol/framing.py b/packages/protocol/hms_protocol/framing.py new file mode 100644 index 0000000..2090297 --- /dev/null +++ b/packages/protocol/hms_protocol/framing.py @@ -0,0 +1,61 @@ +"""Length-prefixed MessagePack-Framing (ADR-0003). + +4-Byte-Big-Endian-Länge, danach MessagePack-Payload. Maximale Payloadgröße +schützt vor unkontrollierten Queues/Resourcenerschöpfung (§6.2, §33). +""" + +from __future__ import annotations + +import socket +import struct + +import msgpack + +MAX_PAYLOAD_SIZE = 16 * 1024 * 1024 # 16 MiB Obergrenze je Nachricht +_LENGTH = struct.Struct(">I") + + +def encode_frame(payload: dict) -> bytes: + """Serialisiert ein dict zu length-prefixed MessagePack.""" + body = msgpack.packb(payload, use_bin_type=True) + if len(body) > MAX_PAYLOAD_SIZE: + raise ValueError(f"payload too large: {len(body)} > {MAX_PAYLOAD_SIZE}") + return _LENGTH.pack(len(body)) + body + + +def decode_frame(frame: bytes) -> dict: + """Dekodiert einen vollständigen Frame (Länge + Body).""" + if len(frame) < _LENGTH.size: + raise ValueError("frame too short") + (length,) = _LENGTH.unpack_from(frame, 0) + if length > MAX_PAYLOAD_SIZE: + raise ValueError(f"declared length {length} exceeds limit") + body = frame[_LENGTH.size : _LENGTH.size + length] + if len(body) != length: + raise ValueError(f"truncated frame: expected {length}, got {len(body)}") + return msgpack.unpackb(body, raw=False) + + +def read_frame(sock: socket.socket) -> dict: + """Liest einen Frame von einem verbundenen Socket.""" + header = _recv_exact(sock, _LENGTH.size) + (length,) = _LENGTH.unpack(header) + if length > MAX_PAYLOAD_SIZE: + raise ValueError(f"declared length {length} exceeds limit") + body = _recv_exact(sock, length) + return msgpack.unpackb(body, raw=False) + + +def write_frame(sock: socket.socket, payload: dict) -> None: + """Schreibt einen Frame auf einen verbundenen Socket.""" + sock.sendall(encode_frame(payload)) + + +def _recv_exact(sock: socket.socket, count: int) -> bytes: + buf = bytearray() + while len(buf) < count: + chunk = sock.recv(count - len(buf)) + if not chunk: + raise ConnectionError("socket closed mid-frame") + buf.extend(chunk) + return bytes(buf) diff --git a/packages/protocol/hms_protocol/idempotency.py b/packages/protocol/hms_protocol/idempotency.py new file mode 100644 index 0000000..df43806 --- /dev/null +++ b/packages/protocol/hms_protocol/idempotency.py @@ -0,0 +1,37 @@ +"""Idempotency-Registry für wiederholbare Commands (§6.2, §23.2).""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import Any + + +class IdempotencyRegistry: + """Merkt sich command_id → Ergebnis; Wiederholungen liefern dasselbe Ack.""" + + def __init__(self, capacity: int = 4096) -> None: + if capacity <= 0: + raise ValueError("capacity must be positive") + self._capacity = capacity + self._entries: OrderedDict[str, Any] = OrderedDict() + + def register(self, command_id: str) -> bool: + """False, wenn die command_id bereits bekannt ist (Duplikat).""" + if command_id in self._entries: + self._entries.move_to_end(command_id) + return False + self._entries[command_id] = None # Ergebnis folgt mit complete() + if len(self._entries) > self._capacity: + self._entries.popitem(last=False) + return True + + def complete(self, command_id: str, result: Any) -> None: + if command_id in self._entries: + self._entries[command_id] = result + self._entries.move_to_end(command_id) + + def result(self, command_id: str) -> Any | None: + return self._entries.get(command_id) + + def __len__(self) -> int: + return len(self._entries) diff --git a/packages/render_backend/d3d11/.gitkeep b/packages/render_backend/d3d11/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/render_backend/gl_gles/.gitkeep b/packages/render_backend/gl_gles/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/timeline/.gitkeep b/packages/timeline/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/builtin/filters/.gitkeep b/plugins/builtin/filters/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/builtin/generators/.gitkeep b/plugins/builtin/generators/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/builtin/outputs/.gitkeep b/plugins/builtin/outputs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/builtin/transitions/.gitkeep b/plugins/builtin/transitions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/examples/.gitkeep b/plugins/examples/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/examples/com.hms.fx.example_passthrough/README.md b/plugins/examples/com.hms.fx.example_passthrough/README.md new file mode 100644 index 0000000..2c96cd9 --- /dev/null +++ b/plugins/examples/com.hms.fx.example_passthrough/README.md @@ -0,0 +1,13 @@ +# Example Passthrough (SDK-Referenz) + +Erstes Beispielplugin gemäß PLAN.md §14 und §36 Nr. 6: HLSL-Passthrough mit einem live änderbaren Parameter `mix` sowie semantisch gleiche GLSL- und GLES-Testvarianten. + +- `shaders/d3d11/passthrough.hlsl` – Windows-Primärpfad (D3D11, PS_5_0) +- `shaders/gl/passthrough.frag` – Linux x64 (GLSL 330) +- `shaders/gles/passthrough.frag` – Raspberry Pi / GLES (100) + +Alle Varianten verwenden dieselben semantischen Standard-Inputs (§14.4). +Das Plugin validiert fehlerfrei gegen `hms_plugin_sdk` (Validator-Test in `tests/unit/test_plugin_manifest.py`). + +Shader-Kompilierung und Bildgleichheit werden auf Zielsystemen geprüft +(Gate-0-Hardwaremessung, §29.3); dieser Container besitzt keine GPU. diff --git a/plugins/examples/com.hms.fx.example_passthrough/plugin.json b/plugins/examples/com.hms.fx.example_passthrough/plugin.json new file mode 100644 index 0000000..3846461 --- /dev/null +++ b/plugins/examples/com.hms.fx.example_passthrough/plugin.json @@ -0,0 +1,57 @@ +{ + "schema_version": 1, + "id": "com.hms.fx.example_passthrough", + "name": "Example Passthrough", + "version": "1.0.0", + "api_version": 1, + "kind": "filter", + "vendor": "HMS", + "entrypoints": { + "d3d11": { + "type": "hlsl_singlepass", + "passes": [ + {"pixel_shader": "shaders/d3d11/passthrough.hlsl"} + ] + }, + "gl": { + "type": "glsl_singlepass", + "passes": [ + {"fragment": "shaders/gl/passthrough.frag"} + ] + }, + "gles": { + "type": "glsl_es_singlepass", + "passes": [ + {"fragment": "shaders/gles/passthrough.frag"} + ] + } + }, + "capabilities": { + "minimum_tier": "PI_LITE", + "requires_input_texture": true, + "supported_backends": ["d3d11", "gl", "gles"] + }, + "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"] + }, + "parameters": [ + { + "id": "mix", + "label": "Mix", + "type": "float", + "minimum": 0.0, + "maximum": 1.0, + "default": 0.0, + "dmx_slots": [1], + "curve": "linear" + } + ], + "failure_mode": "bypass" +} diff --git a/plugins/examples/com.hms.fx.example_passthrough/shaders/d3d11/passthrough.hlsl b/plugins/examples/com.hms.fx.example_passthrough/shaders/d3d11/passthrough.hlsl new file mode 100644 index 0000000..e6db45f --- /dev/null +++ b/plugins/examples/com.hms.fx.example_passthrough/shaders/d3d11/passthrough.hlsl @@ -0,0 +1,37 @@ +// HMS MediaEngine – Beispielplugin: Passthrough mit Live-Parameter "mix" +// PLAN.md §14.4 (Standard-Shaderinputs), §36 Nr. 6 +// D3D11-Pixelshader (PS_5_0); Bindung übernimmt der Backend-Adapter (ADR-0004 offen). +// Projekte referenzieren niemals konkrete Backend-Variablennamen (§12.6). + +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_mix; // Plugin-Parameter, live änderbar (0..1) + float _pad0; // 16-Byte-Alignment des cbuffer +}; + +float4 mainPS(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target +{ + float4 src = u_input_texture.Sample(u_sampler, uv); + + // Live-Parameter: mix blendet zwischen Original und zeitmodulierter Helligkeit + float pulse = 0.5 + 0.5 * sin(u_time_seconds * 2.0); + float m = saturate(param_mix); + float3 rgb = src.rgb * lerp(1.0, pulse, m); + float a = src.a * u_layer_opacity; + + return float4(rgb * u_layer_opacity, a); +} diff --git a/plugins/examples/com.hms.fx.example_passthrough/shaders/gl/passthrough.frag b/plugins/examples/com.hms.fx.example_passthrough/shaders/gl/passthrough.frag new file mode 100644 index 0000000..d4f6375 --- /dev/null +++ b/plugins/examples/com.hms.fx.example_passthrough/shaders/gl/passthrough.frag @@ -0,0 +1,33 @@ +#version 330 core +// HMS MediaEngine – Beispielplugin: Passthrough mit Live-Parameter "mix" +// GLSL-Testvariante, semantisch identisch zur HLSL-Implementierung (§36 Nr. 6). +// Standard-Inputs gemäß PLAN.md §14.4; Bindung über den GL-Backend-Adapter. + +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_mix; + +in vec2 v_uv; +out vec4 fragColor; + +void main() +{ + vec4 src = texture(u_input_texture, v_uv); + + float pulse = 0.5 + 0.5 * sin(u_time_seconds * 2.0); + float m = clamp(param_mix, 0.0, 1.0); + vec3 rgb = src.rgb * mix(1.0, pulse, m); + float a = src.a * u_layer_opacity; + + fragColor = vec4(rgb * u_layer_opacity, a); +} diff --git a/plugins/examples/com.hms.fx.example_passthrough/shaders/gles/passthrough.frag b/plugins/examples/com.hms.fx.example_passthrough/shaders/gles/passthrough.frag new file mode 100644 index 0000000..8297f40 --- /dev/null +++ b/plugins/examples/com.hms.fx.example_passthrough/shaders/gles/passthrough.frag @@ -0,0 +1,32 @@ +#version 100 +// HMS MediaEngine – Beispielplugin: Passthrough (OpenGL ES, Raspberry Pi Pfad) +// Semantisch identisch zu HLSL/GLSL-Varianten (§36 Nr. 6, §12.6). +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_mix; + +varying vec2 v_uv; + +void main() +{ + vec4 src = texture2D(u_input_texture, v_uv); + + float pulse = 0.5 + 0.5 * sin(u_time_seconds * 2.0); + float m = clamp(param_mix, 0.0, 1.0); + vec3 rgb = src.rgb * mix(1.0, pulse, m); + float a = src.a * u_layer_opacity; + + gl_FragColor = vec4(rgb * u_layer_opacity, a); +} diff --git a/plugins/examples/com.hms.fx.gaussian_blur/README.md b/plugins/examples/com.hms.fx.gaussian_blur/README.md new file mode 100644 index 0000000..6071774 --- /dev/null +++ b/plugins/examples/com.hms.fx.gaussian_blur/README.md @@ -0,0 +1,16 @@ +# Gaussian Blur (SDK-Beispiel, PLAN.md §14.3 / §15.2 / §36 Nr. 10) + +Separierbarer Gauß-Blur mit zwei Pässen (horizontal + vertikal) je Backend. + +Adaptive Quality: drei deklarierte Varianten (low/medium/high), die sich nur +in `internal_scale` und `samples` unterscheiden; `radius` bleibt semantisch +unverändert (§5.2). Radius 0 bypassed kostenfrei (§15.3). + +| Variante | internal_scale | samples | +| --- | --- | --- | +| low | 0.25 | 5 | +| medium | 0.5 | 9 | +| high | 1.0 | 17 | + +Alle Varianten müssen vor Aktivierung kompiliert werden (§5.2, §33); +Bild- und Performance-Abnahme erfolgt auf Zielsystemen (§15.4). diff --git a/plugins/examples/com.hms.fx.gaussian_blur/plugin.json b/plugins/examples/com.hms.fx.gaussian_blur/plugin.json new file mode 100644 index 0000000..6476fed --- /dev/null +++ b/plugins/examples/com.hms.fx.gaussian_blur/plugin.json @@ -0,0 +1,68 @@ +{ + "schema_version": 1, + "id": "com.hms.fx.gaussian_blur", + "name": "Gaussian Blur", + "version": "1.0.0", + "api_version": 1, + "kind": "filter", + "vendor": "HMS", + "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"} + ] + } + }, + "capabilities": { + "minimum_tier": "DESKTOP_LITE", + "requires_input_texture": true, + "supported_backends": ["d3d11", "gl", "gles"] + }, + "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"] + }, + "parameters": [ + { + "id": "radius", + "label": "Radius", + "type": "float", + "minimum": 0.0, + "maximum": 40.0, + "default": 0.0, + "dmx_slots": [1], + "curve": "quadratic" + }, + { + "id": "quality", + "label": "Quality", + "type": "enum", + "values": ["auto", "low", "medium", "high"], + "default": "auto", + "dmx_slots": [2] + } + ], + "failure_mode": "bypass" +} diff --git a/plugins/examples/com.hms.fx.gaussian_blur/shaders/d3d11/horizontal.hlsl b/plugins/examples/com.hms.fx.gaussian_blur/shaders/d3d11/horizontal.hlsl new file mode 100644 index 0000000..29c133b --- /dev/null +++ b/plugins/examples/com.hms.fx.gaussian_blur/shaders/d3d11/horizontal.hlsl @@ -0,0 +1,52 @@ +// HMS MediaEngine – Gaussian Blur, horizontaler Pass (separable) +// PLAN.md §14.3-Beispiel, §36 Nr. 10: drei Adaptive-Quality-Varianten +// (low: 5 Samples, medium: 9, high: 17) werden vorab kompiliert; nur +// u_quality_samples/internal_scale ändern sich, param_radius bleibt semantisch identisch. + +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_radius; // 0..40 (quadratic curve, DMX P1) + float u_quality_samples; // 5 | 9 | 17 je Variante (Backend-Bindung) + float _pad0; + float _pad1; +}; + +float4 mainPS(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target +{ + float radius = max(param_radius, 0.0); + float4 src = u_input_texture.Sample(u_sampler, uv); + if (radius < 0.01) + { + return src; // Radius 0 = kostenloser Bypass (§15.3) + } + + float samples = clamp(u_quality_samples, 1.0, 17.0); + float stepSize = radius / max(samples - 1.0, 1.0); + float2 texel = float2(1.0, 0.0) / u_resolution.xy; + + float4 acc = float4(0.0, 0.0, 0.0, 0.0); + float total = 0.0; + [loop] + for (float i = 0.0; i < samples; i += 1.0) + { + float t = i - (samples - 1.0) * 0.5; + float w = exp(-(t * t) / (samples * 0.5)); + acc += u_input_texture.Sample(u_sampler, uv + texel * (t * stepSize)) * w; + total += w; + } + return acc / total; +} diff --git a/plugins/examples/com.hms.fx.gaussian_blur/shaders/d3d11/vertical.hlsl b/plugins/examples/com.hms.fx.gaussian_blur/shaders/d3d11/vertical.hlsl new file mode 100644 index 0000000..2240652 --- /dev/null +++ b/plugins/examples/com.hms.fx.gaussian_blur/shaders/d3d11/vertical.hlsl @@ -0,0 +1,50 @@ +// HMS MediaEngine – Gaussian Blur, vertikaler Pass (separable) +// Identisch zu horizontal.hlsl mit texel = (0, 1). + +Texture2D u_input_texture : register(t0); +SamplerState u_sampler : register(s0); + +cbuffer hms_params : register(b0) +{ + float4 u_resolution; + 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_radius; + float u_quality_samples; + float _pad0; + float _pad1; +}; + +float4 mainPS(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target +{ + float radius = max(param_radius, 0.0); + float4 src = u_input_texture.Sample(u_sampler, uv); + if (radius < 0.01) + { + return src; + } + + float samples = clamp(u_quality_samples, 1.0, 17.0); + float stepSize = radius / max(samples - 1.0, 1.0); + float2 texel = float2(0.0, 1.0) / u_resolution.xy; + + float4 acc = float4(0.0, 0.0, 0.0, 0.0); + float total = 0.0; + [loop] + for (float i = 0.0; i < samples; i += 1.0) + { + float t = i - (samples - 1.0) * 0.5; + float w = exp(-(t * t) / (samples * 0.5)); + acc += u_input_texture.Sample(u_sampler, uv + texel * (t * stepSize)) * w; + total += w; + } + return acc / total; +} diff --git a/plugins/examples/com.hms.fx.gaussian_blur/shaders/gl/horizontal.frag b/plugins/examples/com.hms.fx.gaussian_blur/shaders/gl/horizontal.frag new file mode 100644 index 0000000..cf69fd8 --- /dev/null +++ b/plugins/examples/com.hms.fx.gaussian_blur/shaders/gl/horizontal.frag @@ -0,0 +1,47 @@ +#version 330 core +// HMS MediaEngine – Gaussian Blur, horizontaler Pass (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_radius; +uniform float u_quality_samples; + +in vec2 v_uv; +out vec4 fragColor; + +void main() +{ + float radius = max(param_radius, 0.0); + vec4 src = texture(u_input_texture, v_uv); + if (radius < 0.01) + { + fragColor = src; + return; + } + + float samples = clamp(u_quality_samples, 1.0, 17.0); + float stepSize = radius / max(samples - 1.0, 1.0); + vec2 texel = vec2(1.0, 0.0) / u_resolution; + + vec4 acc = vec4(0.0); + float total = 0.0; + for (float i = 0.0; i < samples; i += 1.0) + { + float t = i - (samples - 1.0) * 0.5; + float w = exp(-(t * t) / (samples * 0.5)); + acc += texture(u_input_texture, v_uv + texel * (t * stepSize)) * w; + total += w; + } + fragColor = acc / total; +} diff --git a/plugins/examples/com.hms.fx.gaussian_blur/shaders/gl/vertical.frag b/plugins/examples/com.hms.fx.gaussian_blur/shaders/gl/vertical.frag new file mode 100644 index 0000000..4738fa7 --- /dev/null +++ b/plugins/examples/com.hms.fx.gaussian_blur/shaders/gl/vertical.frag @@ -0,0 +1,46 @@ +#version 330 core +// HMS MediaEngine – Gaussian Blur, vertikaler Pass (GLSL, Linux x64) + +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_radius; +uniform float u_quality_samples; + +in vec2 v_uv; +out vec4 fragColor; + +void main() +{ + float radius = max(param_radius, 0.0); + vec4 src = texture(u_input_texture, v_uv); + if (radius < 0.01) + { + fragColor = src; + return; + } + + float samples = clamp(u_quality_samples, 1.0, 17.0); + float stepSize = radius / max(samples - 1.0, 1.0); + vec2 texel = vec2(0.0, 1.0) / u_resolution; + + vec4 acc = vec4(0.0); + float total = 0.0; + for (float i = 0.0; i < samples; i += 1.0) + { + float t = i - (samples - 1.0) * 0.5; + float w = exp(-(t * t) / (samples * 0.5)); + acc += texture(u_input_texture, v_uv + texel * (t * stepSize)) * w; + total += w; + } + fragColor = acc / total; +} diff --git a/plugins/examples/com.hms.fx.gaussian_blur/shaders/gles/horizontal.frag b/plugins/examples/com.hms.fx.gaussian_blur/shaders/gles/horizontal.frag new file mode 100644 index 0000000..d30cd1c --- /dev/null +++ b/plugins/examples/com.hms.fx.gaussian_blur/shaders/gles/horizontal.frag @@ -0,0 +1,48 @@ +#version 100 +// HMS MediaEngine – Gaussian Blur, horizontaler Pass (GLES, Raspberry Pi) +// ES 2.0: Schleifen mit konstanter Höchstgrenze; Abbruch über Bedingung. +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_radius; +uniform float u_quality_samples; + +varying vec2 v_uv; + +void main() +{ + float radius = max(param_radius, 0.0); + vec4 src = texture2D(u_input_texture, v_uv); + if (radius < 0.01) + { + gl_FragColor = src; + return; + } + + float samples = clamp(u_quality_samples, 1.0, 17.0); + float stepSize = radius / max(samples - 1.0, 1.0); + vec2 texel = vec2(1.0, 0.0) / u_resolution; + + vec4 acc = vec4(0.0); + float total = 0.0; + for (int i = 0; i < 17; i++) + { + if (float(i) >= samples) { break; } + float t = float(i) - (samples - 1.0) * 0.5; + float w = exp(-(t * t) / (samples * 0.5)); + acc += texture2D(u_input_texture, v_uv + texel * (t * stepSize)) * w; + total += w; + } + gl_FragColor = acc / total; +} diff --git a/plugins/examples/com.hms.fx.gaussian_blur/shaders/gles/vertical.frag b/plugins/examples/com.hms.fx.gaussian_blur/shaders/gles/vertical.frag new file mode 100644 index 0000000..8d69d07 --- /dev/null +++ b/plugins/examples/com.hms.fx.gaussian_blur/shaders/gles/vertical.frag @@ -0,0 +1,47 @@ +#version 100 +// HMS MediaEngine – Gaussian Blur, vertikaler Pass (GLES, Raspberry Pi) +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_radius; +uniform float u_quality_samples; + +varying vec2 v_uv; + +void main() +{ + float radius = max(param_radius, 0.0); + vec4 src = texture2D(u_input_texture, v_uv); + if (radius < 0.01) + { + gl_FragColor = src; + return; + } + + float samples = clamp(u_quality_samples, 1.0, 17.0); + float stepSize = radius / max(samples - 1.0, 1.0); + vec2 texel = vec2(0.0, 1.0) / u_resolution; + + vec4 acc = vec4(0.0); + float total = 0.0; + for (int i = 0; i < 17; i++) + { + if (float(i) >= samples) { break; } + float t = float(i) - (samples - 1.0) * 0.5; + float w = exp(-(t * t) / (samples * 0.5)); + acc += texture2D(u_input_texture, v_uv + texel * (t * stepSize)) * w; + total += w; + } + gl_FragColor = acc / total; +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5674893 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "hms-mediaengine" +version = "0.1.0" +description = "HMS MediaEngine – modularer Medienserver, VJ-System und generativer Effektserver (Arbeitstitel)" +requires-python = "==3.13.*" +dependencies = [ + "fastapi>=0.115", + "uvicorn>=0.30", + "pydantic>=2.7", + "msgpack>=1.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.2", + "httpx>=0.27", + "ruff>=0.6", +] + +[build-system] +requires = ["hatchling>=1.22"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = [ + "packages/protocol/hms_protocol", + "packages/domain/hms_domain", + "packages/parameter_engine/hms_parameter", + "packages/artnet/hms_artnet", + "packages/adaptive_quality/hms_adaptive", + "packages/capabilities/hms_capabilities", + "packages/plugin_sdk/hms_plugin_sdk", + "apps/renderer/hms_renderer", + "apps/control_server/hms_control_server", + "apps/launcher/hms_launcher", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B"] diff --git a/schemas/api/.gitkeep b/schemas/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/schemas/cluster/.gitkeep b/schemas/cluster/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/schemas/cluster/cluster_message_v1.schema.json b/schemas/cluster/cluster_message_v1.schema.json new file mode 100644 index 0000000..a157633 --- /dev/null +++ b/schemas/cluster/cluster_message_v1.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "hms/schemas/cluster/cluster_message_v1.schema.json", + "title": "HMS Cluster Message v1", + "description": "PLAN.md §6.5: Jede Cluster-Nachricht enthält mindestens cluster_id, node_id, command_id, Sequenz, Projekt-Revision, Absenderzeit und Trace-ID.", + "type": "object", + "required": ["cluster_id", "node_id", "command_id", "sequence", "project_revision", "sender_time_ns", "trace_id"], + "properties": { + "cluster_id": {"type": "string", "format": "uuid"}, + "node_id": {"type": "string", "format": "uuid"}, + "command_id": {"type": "string", "format": "uuid"}, + "sequence": {"type": "integer", "minimum": 0}, + "project_revision": {"type": "integer", "minimum": 0}, + "sender_time_ns": {"type": "integer", "minimum": 0}, + "trace_id": {"type": "string", "format": "uuid"}, + "execute_at_show_time_ns": {"type": ["integer", "null"], "minimum": 0}, + "status": {"enum": ["accepted", "armed", "executed", "failed"]}, + "payload": {"type": "object"} + } +} diff --git a/schemas/ipc/.gitkeep b/schemas/ipc/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/schemas/ipc/envelope_v1.schema.json b/schemas/ipc/envelope_v1.schema.json new file mode 100644 index 0000000..17edbf6 --- /dev/null +++ b/schemas/ipc/envelope_v1.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "hms/schemas/ipc/envelope_v1.schema.json", + "title": "HMS IPC Envelope v1", + "description": "PLAN.md §6.2: Jede IPC-Nachricht besitzt mindestens diese Felder.", + "type": "object", + "required": ["protocol_version", "message_id", "type", "revision", "monotonic_timestamp_ns", "payload"], + "properties": { + "protocol_version": {"const": 1}, + "message_id": {"type": "string", "format": "uuid"}, + "type": {"enum": ["command", "event", "snapshot", "ack", "error", "telemetry"]}, + "revision": {"type": "integer", "minimum": 0}, + "monotonic_timestamp_ns": {"type": "integer", "minimum": 0}, + "payload": {"type": "object"}, + "idempotency_key": {"type": "string"} + }, + "additionalProperties": false +} diff --git a/schemas/plugin/.gitkeep b/schemas/plugin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/schemas/plugin/plugin_manifest_v1.schema.json b/schemas/plugin/plugin_manifest_v1.schema.json new file mode 100644 index 0000000..234bc08 --- /dev/null +++ b/schemas/plugin/plugin_manifest_v1.schema.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "hms/schemas/plugin/plugin_manifest_v1.schema.json", + "title": "HMS Plugin Manifest v1", + "description": "PLAN.md §14.3: Plugin-Manifest mit Backend-Entrypoints, Parameters und Adaptive Quality.", + "type": "object", + "required": ["schema_version", "id", "name", "version", "api_version", "kind", "vendor", "entrypoints", "capabilities", "failure_mode"], + "properties": { + "schema_version": {"const": 1}, + "id": {"type": "string", "pattern": "^[a-z0-9._-]{5,}$"}, + "name": {"type": "string", "minLength": 1}, + "version": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$"}, + "api_version": {"const": 1}, + "kind": {"enum": ["source", "generator", "filter", "transition", "mixer", "output", "control", "automation"]}, + "vendor": {"type": "string", "minLength": 1}, + "entrypoints": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "required": ["type", "passes"], + "properties": { + "type": {"type": "string"}, + "passes": { + "type": "array", + "minItems": 1, + "items": {"type": "object"} + } + } + } + }, + "capabilities": { + "type": "object", + "required": ["minimum_tier", "supported_backends"], + "properties": { + "minimum_tier": {"enum": ["DESKTOP_FULL", "DESKTOP_LITE", "PI_LITE", "HEADLESS_CONTROL"]}, + "requires_input_texture": {"type": "boolean"}, + "supported_backends": { + "type": "array", + "items": {"enum": ["d3d11", "gl", "gles"]}, + "minItems": 1 + } + } + }, + "adaptive_quality": { + "type": "object", + "required": ["default", "variants"], + "properties": { + "default": {"type": "string"}, + "variants": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": {"type": "string"}, + "internal_scale": {"type": "number", "exclusiveMinimum": 0, "maximum": 1}, + "samples": {"type": "integer", "minimum": 1} + } + } + }, + "transition_ms": {"type": "number", "minimum": 0}, + "semantic_parameters_unchanged": {"type": "array", "items": {"type": "string"}} + } + }, + "parameters": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "label", "type", "default"], + "properties": { + "id": {"type": "string", "minLength": 1}, + "label": {"type": "string", "minLength": 1}, + "type": {"enum": ["float", "int", "enum", "bool", "color"]}, + "minimum": {"type": "number"}, + "maximum": {"type": "number"}, + "default": {}, + "values": {"type": "array"}, + "dmx_slots": { + "type": "array", + "maxItems": 8, + "items": {"type": "integer", "minimum": 1, "maximum": 8} + }, + "curve": {"type": "string"} + } + } + }, + "failure_mode": {"enum": ["bypass", "hold", "black"]} + } +} diff --git a/schemas/project/.gitkeep b/schemas/project/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/schemas/project/project_v1.schema.json b/schemas/project/project_v1.schema.json new file mode 100644 index 0000000..9ab4b3e --- /dev/null +++ b/schemas/project/project_v1.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "hms/schemas/project/project_v1.schema.json", + "title": "HMS Project v1", + "description": "PLAN.md §10, §24: Projektdatei mit schema_version und stabilen UUIDs. Phase 0: Kernfelder; Layer-/Effektschnitt wird in Phase 2 vollständig ausgebaut.", + "type": "object", + "required": ["schema_version", "id", "name", "created_at", "updated_at", "compositions"], + "properties": { + "schema_version": {"const": 1}, + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string", "minLength": 1}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"}, + "settings": {"type": "object"}, + "media_assets": {"type": "array", "items": {"type": "object"}}, + "compositions": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name", "width", "height", "fps", "color_space", "layers"], + "properties": { + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string"}, + "width": {"type": "integer", "minimum": 16, "maximum": 8192}, + "height": {"type": "integer", "minimum": 16, "maximum": 8192}, + "fps": {"type": "number", "exclusiveMinimum": 0}, + "color_space": {"enum": ["sRGB", "linear"]}, + "background_color": {"type": "string"}, + "duration": {"type": ["number", "null"]}, + "layers": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "required": ["id", "name", "layer_type"], + "properties": { + "id": {"type": "string", "format": "uuid"}, + "name": {"type": "string"}, + "enabled": {"type": "boolean"}, + "layer_type": {"enum": ["media", "image", "solid", "generator", "adjustment", "group"]}, + "opacity": {"type": "number", "minimum": 0, "maximum": 1}, + "blend_mode": {"enum": ["normal", "add", "multiply", "screen", "lighten", "darken", "difference", "overlay", "alpha_premultiplied"]}, + "effects": { + "type": "array", + "maxItems": 2, + "items": {"type": "object"} + } + } + } + } + } + } + }, + "scenes": {"type": "array", "items": {"type": "object"}}, + "timelines": {"type": "array", "items": {"type": "object"}}, + "outputs": {"type": "array", "items": {"type": "object"}}, + "control_bindings": {"type": "array", "items": {"type": "object"}}, + "plugin_requirements": {"type": "array", "items": {"type": "object"}} + } +} diff --git a/tests/cluster/.gitkeep b/tests/cluster/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3056f2e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +"""Test-Pfad-Setup: macht alle hms_*-Pakete ohne Installation importierbar. + +Eigentumsgrenzen bleiben gewahrt: jede Testsuite importiert nur die +Pakete, die sie prüft; Quereinbau zwischen packages/* bleibt verboten. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + +_PACKAGE_DIRS = [ + "packages/protocol", + "packages/domain", + "packages/parameter_engine", + "packages/artnet", + "packages/adaptive_quality", + "packages/capabilities", + "packages/plugin_sdk", + "apps/renderer", + "apps/control_server", + "apps/launcher", + "tools/fixture_generator", + "tools/artnet_emulator", +] + +for _sub in _PACKAGE_DIRS: + _dir = str(REPO / _sub) + if _dir not in sys.path: + sys.path.insert(0, _dir) diff --git a/tests/e2e/.gitkeep b/tests/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_artnet_receiver.py b/tests/integration/test_artnet_receiver.py new file mode 100644 index 0000000..fc40c1a --- /dev/null +++ b/tests/integration/test_artnet_receiver.py @@ -0,0 +1,113 @@ +"""Integrationstests Art-Net-Receiver über echten Loopback-UDP-Socket (§16.1). + +Kein Mock: echter Datagramm-Versand auf 127.0.0.1. Async-Tests laufen +über asyncio.run(), damit keine pytest-asyncio-Versionsbindung entsteht. +""" + +from __future__ import annotations + +import asyncio + +from hms_artnet import ArtNetReceiver, build_dmx, build_poll, parse_poll_reply + + +def test_receiver_emits_updates_and_telemetry() -> None: + asyncio.run(_recv_updates_impl()) + + +async def _recv_updates_impl() -> None: + receiver = ArtNetReceiver(universes={0}, bind_host="127.0.0.1", port=0) + await receiver.start() + sock = receiver._transport.get_extra_info("socket") + actual_port = sock.getsockname()[1] + + received: list = [] + receiver.on_dmx(received.append) + + receiver._transport.sendto( + build_dmx(0, b"\x11\x22\x33\x44"), ("127.0.0.1", actual_port) + ) + await asyncio.sleep(0.2) + + assert len(received) >= 1 + assert received[-1].universe == 0 + assert received[-1].data == b"\x11\x22\x33\x44" + tel = receiver.telemetry() + assert tel[0].packets >= 1 + assert tel[0].last_sender_ip == "127.0.0.1" + + # nicht abonniertes Universe ignorieren + n_before = tel[0].packets + receiver._transport.sendto( + build_dmx(9, b"\x00\x00"), ("127.0.0.1", actual_port) + ) + await asyncio.sleep(0.15) + assert receiver.telemetry()[0].packets == n_before + + await receiver.stop() + + +def test_receiver_answers_poll_as_media_server() -> None: + asyncio.run(_poll_reply_impl()) + + +async def _poll_reply_impl() -> None: + receiver = ArtNetReceiver( + universes={0}, + bind_host="127.0.0.1", + port=0, + short_name="HMS Test", + long_name="HMS MediaEngine Test Node", + ) + await receiver.start() + sock = receiver._transport.get_extra_info("socket") + actual_port = sock.getsockname()[1] + + reply_ready = asyncio.Event() + replies: list = [] + + class _Client(asyncio.DatagramProtocol): + def datagram_received(self, data, addr) -> None: + replies.append(data) + reply_ready.set() + + loop = asyncio.get_running_loop() + client_transport, _ = await loop.create_datagram_endpoint( + _Client, local_addr=("127.0.0.1", 0) + ) + client_transport.sendto(build_poll(), ("127.0.0.1", actual_port)) + await asyncio.wait_for(reply_ready.wait(), timeout=2.0) + + info = parse_poll_reply(replies[0]) + assert info is not None + assert info.style == 0x02 # StMedia (§3.4) + assert info.short_name == "HMS Test" + + client_transport.close() + await receiver.stop() + + +def test_receiver_allowlist_blocks_foreign_sender() -> None: + asyncio.run(_allowlist_impl()) + + +async def _allowlist_impl() -> None: + receiver = ArtNetReceiver( + universes={0}, + bind_host="127.0.0.1", + port=0, + sender_allowlist={"192.168.50.10"}, # nur dieser Sender erlaubt + ) + await receiver.start() + sock = receiver._transport.get_extra_info("socket") + actual_port = sock.getsockname()[1] + + received: list = [] + receiver.on_dmx(received.append) + + receiver._transport.sendto( + build_dmx(0, b"\x00\x00"), ("127.0.0.1", actual_port) + ) + await asyncio.sleep(0.2) + assert received == [] # Absender 127.0.0.1 steht nicht in der Allowlist + await receiver.stop() diff --git a/tests/integration/test_control_server.py b/tests/integration/test_control_server.py new file mode 100644 index 0000000..7b93911 --- /dev/null +++ b/tests/integration/test_control_server.py @@ -0,0 +1,121 @@ +"""Integrationstests Control Core (PLAN.md §6.1B, §23, §36 Nr. 9). + +FastAPI-REST + WebSocket mit derselben Parameter-Engine, die auch +Art-Net bedient (§11: eine autoritative Instanz). +""" + +from __future__ import annotations + +import uuid + +import pytest +from fastapi.testclient import TestClient +from hms_control_server import create_app + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(create_app()) + + +def _payload(path: str, value: float) -> dict: + return {"parameter_path": path, "value": value} + + +@pytest.fixture() +def path() -> str: + return f"composition/{uuid.uuid4()}/layer/{uuid.uuid4()}/opacity" + + +# ---------- Health/Capabilities ---------- + + +def test_health(client: TestClient) -> None: + r = client.get("/api/v1/system/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" + assert r.json()["phase"] == 0 + + +def test_capabilities_reported_without_fake_tier(client: TestClient) -> None: + r = client.get("/api/v1/system/capabilities") + assert r.status_code == 200 + body = r.json() + assert body["tier"] is None # ungeprüft (CPU-only-Umgebung), kein Fake + + +def test_diagnostics_reports_not_connected(client: TestClient) -> None: + r = client.get("/api/v1/diagnostics") + assert r.status_code == 200 + assert r.json()["renderer"] == "not_connected" # IPC-Handshake Phase 1 + + +# ---------- Commands (§23.2) ---------- + + +def test_parameter_set_command_ack(client: TestClient, path: str) -> None: + r = client.post("/api/v1/commands", json={"payload": _payload(path, 0.75)}) + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ack" + assert body["effective"] == pytest.approx(0.75) + + +def test_duplicate_command_id_is_idempotent(client: TestClient, path: str) -> None: + cmd = {"command_id": str(uuid.uuid4()), "payload": _payload(path, 0.5)} + first = client.post("/api/v1/commands", json=cmd).json() + second = client.post("/api/v1/commands", json=cmd).json() + assert first["status"] == "ack" + assert second.get("duplicate") is True # gleiches Ack, kein Doppel-Apply + assert second["result"]["revision"] == first["revision"] + + +def test_revision_conflict_returns_409(client: TestClient, path: str) -> None: + r = client.post( + "/api/v1/commands", + json={"payload": _payload(path, 0.5), "expected_revision": 999}, + ) + assert r.status_code == 409 + assert r.json()["detail"]["error"] == "REVISION_CONFLICT" + + +def test_invalid_parameter_path_returns_400(client: TestClient) -> None: + r = client.post( + "/api/v1/commands", + json={"payload": _payload("../evil/path", 1.0)}, + ) + assert r.status_code == 400 + + +def test_unknown_command_type_returns_400(client: TestClient) -> None: + r = client.post("/api/v1/commands", json={"type": "renderer.draw", "payload": {}}) + assert r.status_code == 400 + + +def test_parameters_endpoint_reflects_state(client: TestClient, path: str) -> None: + client.post("/api/v1/commands", json={"payload": _payload(path, 0.42)}) + r = client.get("/api/v1/parameters") + assert r.status_code == 200 + assert r.json()["values"][path] == pytest.approx(0.42) + assert r.json()["revision"] >= 1 + + +# ---------- WebSocket (§23.3) ---------- + + +def test_websocket_snapshot_on_connect(client: TestClient, path: str) -> None: + client.post("/api/v1/commands", json={"payload": _payload(path, 0.33)}) + with client.websocket_connect("/ws") as ws: + snap = ws.receive_json() + assert snap["type"] == "snapshot" + assert snap["values"][path] == pytest.approx(0.33) + + +def test_websocket_receives_parameter_updates(client: TestClient, path: str) -> None: + with client.websocket_connect("/ws") as ws: + ws.receive_json() # Initial-Snapshot + client.post("/api/v1/commands", json={"payload": _payload(path, 0.9)}) + event = ws.receive_json() + assert event["type"] == "parameter.update" + assert event["parameter_path"] == path + assert event["value"] == pytest.approx(0.9) diff --git a/tests/performance/.gitkeep b/tests/performance/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/portability/.gitkeep b/tests/portability/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/rendering/.gitkeep b/tests/rendering/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_adaptive_quality.py b/tests/unit/test_adaptive_quality.py new file mode 100644 index 0000000..511e809 --- /dev/null +++ b/tests/unit/test_adaptive_quality.py @@ -0,0 +1,74 @@ +"""Unit-Tests Adaptive Quality (PLAN.md §5.2).""" + +from __future__ import annotations + +from hms_adaptive import AdaptiveQualityController, QualityLevel + + +def _fast_controller() -> AdaptiveQualityController: + """Regler ohne echte Wartezeiten für deterministische Tests.""" + return AdaptiveQualityController(min_hold_ms=0, downgrade_intervals=2, upgrade_intervals=6) + + +def test_starts_at_high() -> None: + assert _fast_controller().level is QualityLevel.HIGH + + +def test_single_bad_interval_does_not_downgrade() -> None: + c = _fast_controller() + assert c.step(30.0) is QualityLevel.HIGH # 1 schlechtes Intervall genügt nicht + + +def test_downgrades_one_level_per_interval_not_more() -> None: + c = _fast_controller() + c.step(30.0) + c.step(30.0) + assert c.level is QualityLevel.MEDIUM # genau eine Stufe (Hysterese, §5.2) + c.step(30.0) + c.step(30.0) + assert c.level is QualityLevel.LOW + assert c.step(30.0) is QualityLevel.LOW # Untergrenze + + +def test_upgrade_needs_sustained_reserve() -> None: + c = _fast_controller() + for _ in range(4): + c.step(30.0) + assert c.level is QualityLevel.LOW + for _ in range(5): # 5 gute Intervalle genügen nicht (Upgrade träger) + c.step(1.0) + assert c.level is QualityLevel.LOW + c.step(1.0) # 6. gutes Intervall → eine Stufe hoch + assert c.level is QualityLevel.MEDIUM + + +def test_upgrade_slower_than_downgrade() -> None: + c = _fast_controller() + assert c.downgrade_intervals < c.upgrade_intervals + + +def test_no_pumping_on_alternating_load() -> None: + c = _fast_controller() + levels = [] + for _ in range(60): + levels.append(c.step(30.0)) # Dauerlast → LOW + assert c.level is QualityLevel.LOW + for _ in range(3): + c.step(1.0) # kurze Erholung darf kein Pumpen erzeugen + c.step(30.0) + assert c.level is QualityLevel.LOW # keine Aufwertung bei alternierender Last + + +def test_reason_is_recorded() -> None: + c = _fast_controller() + c.step(30.0) + c.step(30.0) + assert "p99" in c.last_reason and "budget" in c.last_reason + + +def test_default_min_hold_prevents_rapid_changes(monkeypatch) -> None: + + c = AdaptiveQualityController() # min_hold_ms=2000 (Produktionswert) + c.step(30.0) + c.step(30.0) + assert c.level is QualityLevel.HIGH # Mindesthaltezeit noch nicht vergangen diff --git a/tests/unit/test_artnet_packets.py b/tests/unit/test_artnet_packets.py new file mode 100644 index 0000000..e4a91b6 --- /dev/null +++ b/tests/unit/test_artnet_packets.py @@ -0,0 +1,189 @@ +"""Unit-Tests Art-Net-Pakete gegen die offizielle Spezifikation (PLAN.md §16). + +Verifizierte Referenzwerte aus der Art-Net-4-Spezifikation: +- ID 'Art-Net\\0', OpCode little-endian, ProtVer 14 high-byte-first +- ArtDMX: 0x5000, Header 18 Bytes, Länge gerade, 2..512 +- ArtPoll: 0x2000, 14 Bytes +- ArtPollReply: 0x2100, 210 Bytes, Style 0x02 StMedia, Port 0x1936 +""" + +from __future__ import annotations + +import struct + +import pytest +from hms_artnet.packets import ( + ARTNET_ID, + UDP_PORT, + build_artpoll_reply, + build_dmx, + build_poll, + parse_dmx, + parse_poll, + parse_poll_reply, +) + +# ---------- Header ---------- + +def test_artnet_id_is_8_bytes_with_null() -> None: + assert ARTNET_ID == b"Art-Net\x00" + assert len(ARTNET_ID) == 8 + + +def test_dmx_opcode_little_endian() -> None: + packet = build_dmx(0, b"\x00\x00") + assert packet[8:10] == b"\x00\x50" # 0x5000 low byte first + + +def test_protocol_version_14_high_byte_first() -> None: + packet = build_dmx(0, b"\x00\x00") + assert packet[10:12] == b"\x00\x0e" # ProtVer 14 + + +# ---------- ArtDMX ---------- + +def test_dmx_roundtrip() -> None: + data = bytes(range(64)) + packet = build_dmx(universe=5, data=data, sequence=7, physical=1) + parsed = parse_dmx(packet) + assert parsed is not None + assert parsed.universe == 5 + assert parsed.sequence == 7 + assert parsed.physical == 1 + assert parsed.data == data + + +def test_dmx_length_is_even_and_header_18_bytes() -> None: + packet = build_dmx(0, b"\x01\x02\x03") # ungerade → aufgerundet + (length,) = struct.unpack_from(">H", packet, 16) + assert length == 4 # auf gerade aufgerundet + assert len(packet) == 18 + length + + +def test_dmx_universe_encoding_net_and_subuni() -> None: + # universe = Net<<8 | SubUni; Net 7 Bit, SubUni 8 Bit + packet = build_dmx(universe=(3 << 8) | 0x42, data=b"\x00\x00") + assert packet[14] == 0x42 # SubUni + assert packet[15] == 0x03 # Net + parsed = parse_dmx(packet) + assert parsed is not None + assert parsed.universe == (3 << 8) | 0x42 + + +def test_dmx_rejects_short_data() -> None: + with pytest.raises(ValueError): + build_dmx(0, b"\x00") # unter 2 Bytes + with pytest.raises(ValueError): + build_dmx(0, b"\x00" * 513) # über 512 + + +def test_dmx_rejects_invalid_universe() -> None: + with pytest.raises(ValueError): + build_dmx(0x8000, b"\x00\x00") # 15 Bit max + + +def test_parse_dmx_rejects_garbage() -> None: + assert parse_dmx(b"") is None + assert parse_dmx(b"\x00" * 10) is None + wrong_opcode = ARTNET_ID + struct.pack(" None: + packet = bytearray(build_dmx(0, b"\x00\x00")) + packet[10:12] = b"\x00\x0c" # ProtVer 12 + assert parse_dmx(bytes(packet)) is None + + +def test_parse_dmx_rejects_truncated_payload() -> None: + packet = build_dmx(0, b"\x00" * 64) + assert parse_dmx(packet[:-32]) is None # abgeschnittene Daten + + +# ---------- ArtPoll ---------- + +def test_poll_is_14_bytes_and_roundtrips() -> None: + packet = build_poll(talk_to_me=0x02, priority=0x0A) + assert len(packet) == 14 + parsed = parse_poll(packet) + assert parsed is not None + assert parsed.talk_to_me == 0x02 + assert parsed.priority == 0x0A + + +def test_poll_opcode() -> None: + assert build_poll()[8:10] == b"\x00\x20" # 0x2000 low byte first + + +def test_parse_poll_accepts_extended_packets() -> None: + packet = build_poll() + b"\x00" * 10 # größere Pakete müssen akzeptiert werden + assert parse_poll(packet) is not None + + +def test_parse_poll_rejects_wrong_opcode() -> None: + packet = build_dmx(0, b"\x00\x00") + assert parse_poll(packet) is None + + +# ---------- ArtPollReply ---------- + +def test_pollreply_exactly_210_bytes() -> None: + reply = build_artpoll_reply( + ip=b"\xc0\xa8\x01\x2a", + short_name="HMS ME", + long_name="HMS MediaEngine Render Node", + ) + assert len(reply) == 210 + + +def test_pollreply_roundtrip_as_media_server() -> None: + reply = build_artpoll_reply( + ip=b"\xc0\xa8\x01\x2a", + short_name="HMS ME", + long_name="HMS MediaEngine Render Node A", + node_report="Media Server Ready", + mac=b"\xde\xad\xbe\xef\x00\x01", + ) + info = parse_poll_reply(reply) + assert info is not None + assert info.ip == "192.168.1.42" + assert info.short_name == "HMS ME" + assert info.long_name == "HMS MediaEngine Render Node A" + assert info.style == 0x02 # StMedia (Media Server, §3.4) + assert info.mac == b"\xde\xad\xbe\xef\x00\x01" + assert info.bind_index == 1 + + +def test_pollreply_port_is_6454() -> None: + reply = build_artpoll_reply(ip=b"\x7f\x00\x00\x01", short_name="x", long_name="y") + (port,) = struct.unpack_from(">H", reply, 14) + assert port == UDP_PORT == 0x1936 + + +def test_pollreply_node_report_format() -> None: + reply = build_artpoll_reply( + ip=b"\x7f\x00\x00\x01", + short_name="x", + long_name="y", + report_code=0x0000, + error_count=3, + ) + info = parse_poll_reply(reply) + assert info is not None + assert info.node_report.startswith("#0000 [0003]") # Format '#hhhh [hhhh] text' + + +def test_pollreply_names_null_terminated_and_truncated() -> None: + reply = build_artpoll_reply( + ip=b"\x7f\x00\x00\x01", + short_name="S" * 40, # > 17 → auf 17 gekürzt + long_name="L" * 100, # > 63 → auf 63 gekürzt + ) + info = parse_poll_reply(reply) + assert info is not None + assert info.short_name == "S" * 17 + assert info.long_name == "L" * 63 + + +def test_pollreply_rejects_short_packets() -> None: + assert parse_poll_reply(b"\x00" * 100) is None diff --git a/tests/unit/test_capabilities.py b/tests/unit/test_capabilities.py new file mode 100644 index 0000000..718883d --- /dev/null +++ b/tests/unit/test_capabilities.py @@ -0,0 +1,52 @@ +"""Unit-Tests Capability-Probe (PLAN.md §5, §5.2). + +Regel: kein Fake-Ergebnis (§33). Ein Report ohne GPU-Messung darf kein +DESKTOP-Tier vergeben; HEADLESS nur ohne Display. +""" + +from __future__ import annotations + +from hms_capabilities import CapabilityReport, CapabilityTier + + +def test_report_starts_with_unknown_tier_and_gpu() -> None: + report = CapabilityReport() + assert report.tier is None + assert report.gpu is None + d = report.as_dict() + assert d["tier"] is None # ungeprüft = ungeeignet für Gate-Aussagen + + +def test_full_gpu_with_8gb_vram_is_desktop_full() -> None: + report = CapabilityReport() + tier = report.conclude_tier(has_gpu=True, vram_gb=12.0, decode_ok=True, has_display=True) + assert tier is CapabilityTier.DESKTOP_FULL + + +def test_igpu_with_low_vram_is_desktop_lite() -> None: + report = CapabilityReport() + tier = report.conclude_tier(has_gpu=True, vram_gb=2.0, decode_ok=True, has_display=True) + assert tier is CapabilityTier.DESKTOP_LITE + + +def test_no_display_is_headless_control() -> None: + report = CapabilityReport() + tier = report.conclude_tier(has_gpu=False, vram_gb=None, decode_ok=False, has_display=False) + assert tier is CapabilityTier.HEADLESS_CONTROL + + +def test_unmeasured_gpu_yields_no_tier() -> None: + """Ohne GPU-Messung darf kein DESKTOP-Tier vergeben werden (§33).""" + report = CapabilityReport() + tier = report.conclude_tier(has_gpu=True, vram_gb=None, decode_ok=True, has_display=True) + assert tier is CapabilityTier.DESKTOP_LITE + # Aber: ohne verifizierten Decode → None + tier = report.conclude_tier(has_gpu=True, vram_gb=None, decode_ok=False, has_display=True) + assert tier is None + + +def test_failed_decode_on_display_machine_is_none_not_lite() -> None: + """Display vorhanden, aber Decode unbestätigt → kein stiller Lite-Status.""" + report = CapabilityReport() + tier = report.conclude_tier(has_gpu=True, vram_gb=16.0, decode_ok=False, has_display=True) + assert tier is None diff --git a/tests/unit/test_dmx_mapping.py b/tests/unit/test_dmx_mapping.py new file mode 100644 index 0000000..49934b3 --- /dev/null +++ b/tests/unit/test_dmx_mapping.py @@ -0,0 +1,172 @@ +"""Unit-Tests DMX-Mapping: Flanken, 16-Bit-Decoder, Signalverlust (§16.4–16.6).""" + +from __future__ import annotations + +import uuid + +import pytest +from hms_artnet.mapping import DmxLayerMapper, LayerDmxMapping, RisingEdge +from hms_artnet.receiver import DmxUpdate, LossBehavior +from hms_parameter.engine import ControlSource, ParameterEngine + + +def _opacity(comp: str, layer: str) -> str: + return f"composition/{comp}/layer/{layer}/opacity" + + +def _enabled(comp: str, layer: str) -> str: + return f"composition/{comp}/layer/{layer}/enabled" + + +# ---------- RisingEdge (§16.3/§16.5: Trigger = Flanke, kein Dauerzustand) ---------- + + +def test_rising_edge_triggers_once_per_crossing() -> None: + edge = RisingEdge(threshold=64) + assert edge.feed(0) is False + assert edge.feed(64) is True # steigende Flanke + assert edge.feed(100) is False # gehaltener Wert: kein erneuter Trigger + assert edge.feed(200) is False + assert edge.feed(10) is False # Rückfall + assert edge.feed(70) is True # neue Flanke nach Rückkehr + + +def test_rising_edge_boundary() -> None: + with pytest.raises(ValueError): + RisingEdge(threshold=256) + with pytest.raises(ValueError): + RisingEdge(threshold=-1) + + +# ---------- LayerDmxMapping ---------- + + +@pytest.fixture() +def ids() -> tuple[str, str]: + return str(uuid.uuid4()), str(uuid.uuid4()) + + +def test_mapping_paths_use_stable_uuids(ids: tuple[str, str]) -> None: + comp, layer = ids + m = LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer) + assert m.opacity_path == _opacity(comp, layer) + assert m.enable_path == _enabled(comp, layer) + + +def test_mapping_validates_universe_and_address(ids: tuple[str, str]) -> None: + comp, layer = ids + with pytest.raises(ValueError): + LayerDmxMapping(universe=0x8000, base_address=1, composition_id=comp, layer_id=layer) + with pytest.raises(ValueError): + LayerDmxMapping(universe=0, base_address=511, composition_id=comp, layer_id=layer) + + +# ---------- DmxLayerMapper (§36 Nr. 8: DMX-Kanal → Opacity) ---------- + + +def _update(universe: int, data: bytes, sequence: int = 1) -> DmxUpdate: + return DmxUpdate( + universe=universe, + data=data, + sender_ip="10.0.0.9", + received_ns=0, + sequence=sequence, + ) + + +def _loss(universe: int, sender: str = "10.0.0.9") -> DmxUpdate: + return DmxUpdate(universe=universe, data=b"", sender_ip=sender, received_ns=1, sequence=-1) + + +def test_mapper_sets_opacity_from_16bit_channels(ids: tuple[str, str]) -> None: + comp, layer = ids + engine = ParameterEngine() + mapper = DmxLayerMapper( + LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer), + engine, + ) + # Kanal 2-3 = Opacity 16 Bit: MSB zuerst → 0x8000/0xFFFF + mapper.handle(_update(0, bytes([255, 0x80, 0x00]))) + assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(0x8000 / 65535) + # Voll auf: 0xFFFF + mapper.handle(_update(0, bytes([255, 0xFF, 0xFF]))) + assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(1.0) + # Kanal 1 < 128 → Layer disabled + mapper.handle(_update(0, bytes([0, 0xFF, 0xFF]))) + assert engine.effective_value(_enabled(comp, layer)) == pytest.approx(0.0) + + +def test_mapper_ignores_other_universe(ids: tuple[str, str]) -> None: + comp, layer = ids + engine = ParameterEngine() + mapper = DmxLayerMapper( + LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer), + engine, + ) + mapper.handle(_update(7, bytes([255, 0xFF, 0xFF]))) + assert _opacity(comp, layer) not in engine.snapshot() + + +def test_mapper_base_address_offset(ids: tuple[str, str]) -> None: + comp, layer = ids + engine = ParameterEngine() + mapper = DmxLayerMapper( + LayerDmxMapping(universe=1, base_address=65, composition_id=comp, layer_id=layer), + engine, + ) + # Zweiter Layer im selben Universe: Startadresse 65 → Kanal 66/67 + data = bytearray(128) + data[64] = 255 # Kanal 65: Enable + data[65] = 0x40 # Kanal 66: Opacity MSB + data[66] = 0x00 # Kanal 67: Opacity LSB + mapper.handle(_update(1, bytes(data))) + assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(0x4000 / 65535) + + +def test_mapper_hold_on_signal_loss(ids: tuple[str, str]) -> None: + comp, layer = ids + engine = ParameterEngine() + m = LayerDmxMapping( + universe=0, + base_address=1, + composition_id=comp, + layer_id=layer, + loss_behavior=LossBehavior.HOLD, + ) + mapper = DmxLayerMapper(m, engine) + mapper.handle(_update(0, bytes([255, 0xFF, 0x00]))) + before = engine.effective_value(_opacity(comp, layer)) + mapper.handle(_loss(0)) + assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(before) + + +def test_mapper_fade_to_black_releases_on_signal_loss(ids: tuple[str, str]) -> None: + comp, layer = ids + engine = ParameterEngine() + m = LayerDmxMapping( + universe=0, + base_address=1, + composition_id=comp, + layer_id=layer, + loss_behavior=LossBehavior.FADE_TO_BLACK, + ) + mapper = DmxLayerMapper(m, engine) + mapper.handle(_update(0, bytes([255, 0xFF, 0xFF]))) + mapper.handle(_loss(0)) + snap = engine.snapshot() + assert _opacity(comp, layer) not in snap # Override freigegeben + + +def test_dmx_source_priority_is_console(ids: tuple[str, str]) -> None: + """Art-Net wirkt als CONSOLE (Priorität 3) und überstimmt Web (§11.2).""" + comp, layer = ids + engine = ParameterEngine() + path = _opacity(comp, layer) + engine.set_value(path, 0.1, ControlSource.WEB) + mapper = DmxLayerMapper( + LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer), + engine, + ) + mapper.handle(_update(0, bytes([255, 0xFF, 0xFF]))) + assert engine.effective_value(path) == pytest.approx(1.0) # Pult gewinnt + assert engine.current_source(path) is ControlSource.CONSOLE diff --git a/tests/unit/test_domain_ids.py b/tests/unit/test_domain_ids.py new file mode 100644 index 0000000..6e2ada1 --- /dev/null +++ b/tests/unit/test_domain_ids.py @@ -0,0 +1,53 @@ +"""Unit-Tests stabile IDs (PLAN.md §3.6, §6.3).""" + +from __future__ import annotations + +import uuid + +from hms_domain import new_node_id, new_uuid, persistent_node_id + + +def test_new_uuids_are_unique_and_parseable() -> None: + a, b = new_uuid(), new_uuid() + assert a != b + uuid.UUID(a) + uuid.UUID(b) + + +def test_node_id_is_uuid_and_random() -> None: + a, b = new_node_id(), new_node_id() + uuid.UUID(a) + assert a != b + + +def test_persistent_node_id_stable_across_calls(tmp_path) -> None: + identity = tmp_path / "identity" / "node_id" + first = persistent_node_id(identity) + second = persistent_node_id(identity) + assert first == second # IP-/Hostnamenunabhängig: Datei ist Quelle der Wahrheit + assert identity.read_text(encoding="utf-8").strip() == first + + +def test_persistent_node_id_validated_on_load(tmp_path) -> None: + identity = tmp_path / "identity" / "node_id" + identity.parent.mkdir(parents=True) + identity.write_text("not-a-uuid", encoding="utf-8") + import pytest + + with pytest.raises(ValueError): + persistent_node_id(identity) + + +def test_two_nodes_get_distinct_ids(tmp_path) -> None: + a = persistent_node_id(tmp_path / "a" / "node_id") + b = persistent_node_id(tmp_path / "b" / "node_id") + assert a != b # doppelte node_ids wären ein Fehler (§6.3) + + +def test_concurrent_creation_yields_single_id(tmp_path) -> None: + """O_EXCL-Rennbedingung: beide Aufrufer erhalten dieselbe ID.""" + identity = tmp_path / "identity" / "node_id" + id_a = persistent_node_id(identity) + # zweite Erzeugung mit bereits existierender Datei → lädt vorhandene ID + id_b = persistent_node_id(identity) + assert id_a == id_b diff --git a/tests/unit/test_fixture_generator.py b/tests/unit/test_fixture_generator.py new file mode 100644 index 0000000..c4a93d1 --- /dev/null +++ b/tests/unit/test_fixture_generator.py @@ -0,0 +1,66 @@ +"""Unit-Tests Fixture-Generator (PLAN.md §16.3, §16.4).""" + +from __future__ import annotations + +import csv +from pathlib import Path + +from fixture_generator import LAYER64, MASTER32, write_csv + + +def test_master32_has_exactly_32_contiguous_channels() -> None: + assert len(MASTER32) == 32 + assert [row[0] for row in MASTER32] == list(range(1, 33)) + + +def test_layer64_has_exactly_64_contiguous_channels() -> None: + assert len(LAYER64) == 64 + assert [row[0] for row in LAYER64] == list(range(1, 65)) + + +def test_master32_key_channels_match_plan() -> None: + by_channel = {row[0]: row for row in MASTER32} + assert "Blackout" in by_channel[3][1] # Kanal 3 Blackout + assert "Preset Recall" in by_channel[8][1] # Kanal 8 steigende Flanke + assert "Tap Tempo" in by_channel[16][1] # Kanal 16 Tap + + +def test_layer64_key_channels_match_plan() -> None: + by_channel = {row[0]: row for row in LAYER64} + assert "Layer Enable" in by_channel[1][1] + assert "Opacity" in by_channel[2][1] + assert "Load/Commit" in by_channel[9][1] + assert "Blend Mode" in by_channel[21][1] + assert "FX1 Enable" in by_channel[41][1] + assert "FX2 Enable" in by_channel[52][1] + assert "Retrigger" in by_channel[63][1] + + +def test_layer64_fx_parameter_blocks() -> None: + by_channel = {row[0]: row for row in LAYER64} + for slot in range(1, 9): + assert f"P{slot}" in by_channel[43 + slot][1] + assert f"P{slot}" in by_channel[54 + slot][1] + + +def test_eight_layers_fit_exactly_one_universe() -> None: + # §16.2: „Acht Layer entsprechen damit exakt einem DMX-Universe“ + assert 8 * len(LAYER64) == 512 + + +def test_write_csv_output(tmp_path: Path) -> None: + out = tmp_path / "layer64" / "layer64.csv" + write_csv(LAYER64, out) + with open(out, encoding="utf-8", newline="") as fh: + rows = list(csv.reader(fh)) + assert rows[0] == ["channel", "parameter", "resolution_behavior"] + assert len(rows) == 65 # Header + 64 + assert rows[1] == ["1", "Layer Enable", "Schalter"] + + +def test_write_csv_rejects_gaps(tmp_path: Path) -> None: + import pytest + + broken = [(1, "a", "x"), (3, "b", "y")] # Kanal 2 fehlt + with pytest.raises(ValueError, match="contiguous"): + write_csv(broken, tmp_path / "broken.csv") # ValueError vor dem Schreiben diff --git a/tests/unit/test_parameter_engine.py b/tests/unit/test_parameter_engine.py new file mode 100644 index 0000000..1e88da8 --- /dev/null +++ b/tests/unit/test_parameter_engine.py @@ -0,0 +1,105 @@ +"""Unit-Tests Parameter-Engine (PLAN.md §11).""" + +from __future__ import annotations + +import uuid + +import pytest +from hms_parameter import ParameterEngine, layer_opacity_path, master_intensity_path +from hms_parameter.engine import ControlSource, MergeMode, RevisionConflict + + +@pytest.fixture() +def path() -> str: + return layer_opacity_path(str(uuid.uuid4()), str(uuid.uuid4())) + + +def test_invalid_path_rejected() -> None: + engine = ParameterEngine() + with pytest.raises(ValueError, match="invalid parameter path"): + engine.set_value("composition/not-a-uuid/layer/x/opacity", 1.0, ControlSource.WEB) + with pytest.raises(ValueError, match="invalid parameter path"): + engine.set_value("../escape", 1.0, ControlSource.WEB) + + +def test_nan_and_infinity_rejected(path: str) -> None: + engine = ParameterEngine() + with pytest.raises(ValueError, match="finite"): + engine.set_value(path, float("nan"), ControlSource.WEB) + with pytest.raises(ValueError, match="finite"): + engine.set_value(path, float("inf"), ControlSource.WEB) + + +def test_higher_priority_wins(path: str) -> None: + engine = ParameterEngine() + engine.set_value(path, 0.5, ControlSource.WEB) + assert engine.effective_value(path) == pytest.approx(0.5) + engine.set_value(path, 0.9, ControlSource.CONSOLE) + assert engine.effective_value(path) == pytest.approx(0.9) # Pult überstimmt Web + engine.set_value(path, 0.0, ControlSource.SAFETY) + assert engine.effective_value(path) == pytest.approx(0.0) # Blackout überstimmt alles + + +def test_lower_priority_cannot_displace_higher(path: str) -> None: + engine = ParameterEngine() + engine.set_value(path, 0.9, ControlSource.CONSOLE) + engine.set_value(path, 0.1, ControlSource.WEB) # niedrigere Priorität + assert engine.effective_value(path) == pytest.approx(0.9) # CONSOLE bleibt wirksam + assert engine.current_source(path) is ControlSource.CONSOLE + + +def test_release_falls_back_to_lower_priority(path: str) -> None: + engine = ParameterEngine() + engine.set_value(path, 0.2, ControlSource.WEB) + engine.set_value(path, 0.8, ControlSource.CONSOLE) + engine.release(path, ControlSource.CONSOLE) + assert engine.effective_value(path) == pytest.approx(0.2) # Web übernimmt wieder + engine.release(path, ControlSource.WEB) + assert engine.current_source(path) is None + + +def test_revision_increments_on_set_and_release(path: str) -> None: + engine = ParameterEngine() + r0 = engine.revision + r1 = engine.set_value(path, 0.5, ControlSource.WEB) + r2 = engine.set_value(path, 0.6, ControlSource.WEB) + r3 = engine.release(path, ControlSource.WEB) + assert (r1, r2, r3) == (r0 + 1, r0 + 2, r0 + 3) + + +def test_optimistic_locking_revision_conflict(path: str) -> None: + engine = ParameterEngine() + engine.set_value(path, 0.5, ControlSource.WEB) + with pytest.raises(RevisionConflict): + engine.set_value(path, 0.6, ControlSource.WEB, expected_revision=engine.revision + 5) + engine.set_value(path, 0.6, ControlSource.WEB, expected_revision=engine.revision) + + +def test_snapshot_is_atomic_and_readonly(path: str) -> None: + engine = ParameterEngine() + engine.set_value(path, 0.42, ControlSource.WEB) + snap = engine.snapshot() + assert snap.get(path) == pytest.approx(0.42) + assert snap.revision == engine.revision + values = snap.as_dict() + values[path] = 99.0 # Manipulation der Kopie darf Snapshot nicht ändern + assert snap.get(path) == pytest.approx(0.42) + # Änderung nach dem Snapshot erscheint nicht im alten Snapshot (§11.4) + engine.set_value(path, 0.9, ControlSource.WEB) + assert snap.get(path) == pytest.approx(0.42) + + +def test_defaults_in_snapshot_without_override() -> None: + engine = ParameterEngine() + master = master_intensity_path() + engine.set_default(master, 1.0) + snap = engine.snapshot() + assert snap.get(master) == pytest.approx(1.0) + assert engine.effective_value(master) == pytest.approx(1.0) + + +def test_htp_mode_takes_maximum(path: str) -> None: + engine = ParameterEngine(merge_mode=MergeMode.HTP) + engine.set_value(path, 0.9, ControlSource.WEB) + engine.set_value(path, 0.1, ControlSource.WEB) + assert engine.effective_value(path) == pytest.approx(0.9) # HTP: Maximum bleibt diff --git a/tests/unit/test_pipelines.py b/tests/unit/test_pipelines.py new file mode 100644 index 0000000..c47468f --- /dev/null +++ b/tests/unit/test_pipelines.py @@ -0,0 +1,61 @@ +"""Unit-Tests Renderer-Pipeline-Definitionen (PLAN.md §12, §36 Nr. 4–5).""" + +from __future__ import annotations + +from hms_renderer import ( + D3D11Pipeline, + DevGLPipeline, + build_compositor_pipeline, + build_single_video_pipeline, + gst_available, +) + + +def test_single_video_d3d11_uses_hardware_decoder_path() -> None: + pipeline = build_single_video_pipeline("C:/clips/test.mp4", d3d11=True) + assert "d3d11" in pipeline + assert "d3d11videosink" in pipeline + assert "fullscreen=true" in pipeline # randloses Vollbild (§3.3) + assert "uridecodebin" in pipeline + + +def test_single_video_devgl_marked_alternative() -> None: + pipeline = build_single_video_pipeline("/tmp/test.mp4", d3d11=False) + assert "d3d11" not in pipeline # Dev-Pfad darf D3D11 nicht still nutzen + assert "glimagesink" in pipeline + + +def test_compositor_d3d11_mixed_two_sources_on_gpu() -> None: + pipeline = build_compositor_pipeline("a.mp4", "b.mp4", d3d11=True) + # Zwei Quellen → Compositor → Ausgabe ohne CPU-Readback (§36 Nr. 5) + assert pipeline.count("uridecodebin") == 2 + assert "d3d11compositor" in pipeline + assert "d3d11convert" in pipeline + assert "D3D11Memory" in pipeline # GPU-Residenz explizit angefordert + assert "appsink" not in pipeline # kein CPU-Abgriff im Normalpfad + assert "videoconvert" not in pipeline # kein Software-Farbkonverter + + +def test_compositor_devgl_is_separate_path() -> None: + pipeline = build_compositor_pipeline("a.mp4", "b.mp4", d3d11=False) + assert "glvideomixer" in pipeline + assert "d3d11" not in pipeline + + +def test_d3d11_pipeline_splits_screen_for_two_videos() -> None: + p = D3D11Pipeline(video_a="a.mp4", video_b="b.mp4", width=1920, height=1080) + s = p.launch_string() + assert "sink_0::width=960" in s # linke Hälfte + assert "sink_1::width=960" in s # rechte Hälfte + assert "width=1920,height=1080" in s # Master-Auflösung + + +def test_devgl_pipeline_reduced_resolution() -> None: + p = DevGLPipeline(video_a="a.mp4", video_b="b.mp4") + assert p.width == 960 and p.height == 540 # Dev-Pfad kleiner, gekennzeichnet + + +def test_gst_available_reflects_environment() -> None: + # Im Entwicklungscontainer ist GStreamer nicht installiert → False. + # Auf dem Windows-Ziel mit gebündelter Runtime → True. Kein Fake. + assert isinstance(gst_available(), bool) diff --git a/tests/unit/test_plugin_manifest.py b/tests/unit/test_plugin_manifest.py new file mode 100644 index 0000000..d8fdb06 --- /dev/null +++ b/tests/unit/test_plugin_manifest.py @@ -0,0 +1,134 @@ +"""Unit-Tests Plugin-Manifest-Validierung (PLAN.md §14, §27.2).""" + +from __future__ import annotations + +import json +import zipfile +from pathlib import Path + +from hms_plugin_sdk import load_manifest, validate_manifest, validate_plugin_zip + +REPO = Path(__file__).resolve().parents[2] +EXAMPLES = REPO / "plugins" / "examples" + + +def _valid_manifest() -> dict: + return json.loads( + (EXAMPLES / "com.hms.fx.example_passthrough" / "plugin.json").read_text(encoding="utf-8") + ) + + +def test_example_passthrough_validates_with_shaders() -> None: + manifest, errors = load_manifest(EXAMPLES / "com.hms.fx.example_passthrough") + assert errors == [], errors + assert manifest["id"] == "com.hms.fx.example_passthrough" + + +def test_example_gaussian_blur_validates_with_shaders() -> None: + manifest, errors = load_manifest(EXAMPLES / "com.hms.fx.gaussian_blur") + assert errors == [], errors + variants = manifest["adaptive_quality"]["variants"] + assert [v["id"] for v in variants] == ["low", "medium", "high"] + assert [v["samples"] for v in variants] == [5, 9, 17] + + +def test_valid_manifest_without_root_ok() -> None: + assert validate_manifest(_valid_manifest()) == [] + + +def test_wrong_schema_version_rejected() -> None: + m = _valid_manifest() + m["schema_version"] = 99 + assert any("schema_version" in e for e in validate_manifest(m)) + + +def test_invalid_plugin_id_rejected() -> None: + m = _valid_manifest() + m["id"] = "../evil" + assert any("invalid plugin id" in e for e in validate_manifest(m)) + + +def test_invalid_semver_rejected() -> None: + m = _valid_manifest() + m["version"] = "1.0" + assert any("semantic" in e for e in validate_manifest(m)) + + +def test_dmx_footprint_over_8_slots_rejected() -> None: + m = _valid_manifest() + m["parameters"] = [ + {"id": f"p{i}", "label": f"P{i}", "type": "float", "minimum": 0, "maximum": 1, + "default": 0, "dmx_slots": [i]} + for i in range(1, 10) + ] + assert any("exceeds 8" in e for e in validate_manifest(m)) # §14.7 + + +def test_unsafe_shader_path_rejected() -> None: + m = _valid_manifest() + m["entrypoints"]["gl"]["passes"][0]["fragment"] = "../../evil.frag" + assert any("unsafe shader path" in e for e in validate_manifest(m)) + + +def test_missing_shader_file_detected_with_root() -> None: + m = _valid_manifest() + m["entrypoints"]["gl"]["passes"][0]["fragment"] = "shaders/gl/missing.frag" + errors = validate_manifest(m, plugin_root=EXAMPLES / "com.hms.fx.example_passthrough") + assert any("missing shader file" in e for e in errors) + + +def test_invalid_failure_mode_rejected() -> None: + m = _valid_manifest() + m["failure_mode"] = "crash" + assert any("failure_mode" in e for e in validate_manifest(m)) + + +def test_duplicate_parameter_ids_rejected() -> None: + m = _valid_manifest() + m["parameters"].append(dict(m["parameters"][0])) + assert any("duplicate parameter id" in e for e in validate_manifest(m)) + + +def _make_zip(tmp_path: Path, files: dict[str, str | bytes]) -> Path: + zpath = tmp_path / "plugin.zip" + with zipfile.ZipFile(zpath, "w") as zf: + for name, content in files.items(): + zf.writestr(name, content) + return zpath + + +def test_valid_zip_passes(tmp_path: Path) -> None: + plugin_dir = EXAMPLES / "com.hms.fx.example_passthrough" + files: dict[str, str | bytes] = {} + for f in sorted(plugin_dir.rglob("*")): + if f.is_file(): + rel = f.relative_to(plugin_dir.parent) + files[str(rel)] = f.read_text(encoding="utf-8") + zpath = _make_zip(tmp_path, files) + assert validate_plugin_zip(zpath) == [] + + +def test_zip_traversal_rejected(tmp_path: Path) -> None: + files = {"pkg/plugin.json": json.dumps(_valid_manifest()), "../evil.frag": "x"} + zpath = _make_zip(tmp_path, files) + assert any("unsafe path" in e for e in validate_plugin_zip(zpath)) + + +def test_zip_disallowed_file_type_rejected(tmp_path: Path) -> None: + files = { + "pkg/plugin.json": json.dumps(_valid_manifest()), + "pkg/evil.exe": "MZ", + } + zpath = _make_zip(tmp_path, files) + assert any("disallowed file type" in e for e in validate_plugin_zip(zpath)) + + +def test_zip_without_manifest_rejected(tmp_path: Path) -> None: + zpath = _make_zip(tmp_path, {"pkg/shader.frag": "void main(){}"}) + assert any("plugin.json not found" in e for e in validate_plugin_zip(zpath)) + + +def test_corrupt_zip_rejected(tmp_path: Path) -> None: + zpath = tmp_path / "broken.zip" + zpath.write_bytes(b"not a zip at all") + assert any("not a valid zip" in e for e in validate_plugin_zip(zpath)) diff --git a/tests/unit/test_portable_paths.py b/tests/unit/test_portable_paths.py new file mode 100644 index 0000000..2b0a0da --- /dev/null +++ b/tests/unit/test_portable_paths.py @@ -0,0 +1,54 @@ +"""Unit-Tests portable Pfade (PLAN.md §9, §9.1).""" + +from __future__ import annotations + +from pathlib import Path + +from hms_launcher import AppPaths + + +def test_paths_are_relative_to_root(tmp_path: Path) -> None: + paths = AppPaths(root=tmp_path) + assert paths.app == tmp_path / "app" + assert paths.runtime == tmp_path / "runtime" + assert paths.gstreamer_bin == tmp_path / "runtime" / "gstreamer" / "bin" + assert paths.gstreamer_plugins == tmp_path / "runtime" / "gstreamer" / "lib" / "gstreamer-1.0" + assert paths.database == tmp_path / "userdata" / "database" + assert paths.cache == tmp_path / "userdata" / "cache" + assert paths.identity == tmp_path / "userdata" / "identity" / "node_id" + # Keine Laufwerksbuchstaben, keine absoluten Fremdpfade + assert not str(paths.app).startswith("C:") + + +def test_ensure_writable(tmp_path: Path) -> None: + assert AppPaths(root=tmp_path).ensure_writable() is True + assert not (tmp_path / ".write_probe").exists() # Probe wird aufgeräumt + + +def test_ensure_writable_false_on_write_error(tmp_path: Path, monkeypatch) -> None: + """Schreibfehler (z. B. schreibgeschütztes Medium) → False. + + Der Fehler wird simuliert, weil root in Containern Verzeichnisrechte + umgeht und ein chmod-Test dort falsch grün/rot wäre. + """ + from pathlib import Path as _Path + + def _raise_write(self, *args, **kwargs): + raise OSError("read-only file system") + + monkeypatch.setattr(_Path, "write_text", _raise_write) + assert AppPaths(root=tmp_path).ensure_writable() is False + + +def test_portable_environment_sets_gstreamer_vars(tmp_path: Path) -> None: + paths = AppPaths(root=tmp_path) + env = paths.portable_environment() + assert env["GST_PLUGIN_PATH_1_0"].endswith("gstreamer-1.0") + assert env["GST_PLUGIN_SYSTEM_PATH_1_0"] == "" # System-Plugins unterdrückt + + +def test_portable_environment_prepends_bundled_bin(tmp_path: Path) -> None: + gs_bin = tmp_path / "runtime" / "gstreamer" / "bin" + gs_bin.mkdir(parents=True) + env = AppPaths(root=tmp_path).portable_environment() + assert env["PATH"].startswith(str(gs_bin)) diff --git a/tests/unit/test_protocol.py b/tests/unit/test_protocol.py new file mode 100644 index 0000000..89fc66d --- /dev/null +++ b/tests/unit/test_protocol.py @@ -0,0 +1,90 @@ +"""Unit-Tests IPC-Protokoll (PLAN.md §6.2, ADR-0003).""" + +from __future__ import annotations + +import pytest +from hms_protocol import ( + Envelope, + IdempotencyRegistry, + MessageType, + decode_frame, + encode_frame, +) + + +def test_frame_roundtrip() -> None: + payload = { + "protocol_version": 1, + "message_id": "abc", + "type": "command", + "revision": 5, + "monotonic_timestamp_ns": 123, + "payload": {"key": "value", "nested": [1, 2, 3]}, + } + frame = encode_frame(payload) + assert decode_frame(frame) == payload + + +def test_frame_length_prefix_is_4_byte_big_endian() -> None: + frame = encode_frame({"a": 1}) + assert frame[:4] == len(frame[4:]).to_bytes(4, "big") + + +def test_oversized_payload_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + import hms_protocol.framing as framing + + monkeypatch.setattr(framing, "MAX_PAYLOAD_SIZE", 16) + with pytest.raises(ValueError, match="too large"): + framing.encode_frame({"data": "x" * 64}) + + +def test_truncated_frame_rejected() -> None: + frame = encode_frame({"a": 1}) + with pytest.raises(ValueError, match="truncated"): + decode_frame(frame[:-2]) + + +def test_declared_length_over_limit_rejected() -> None: + import struct + + evil = struct.pack(">I", 2**31) + b"x" * 8 + with pytest.raises(ValueError, match="exceeds limit"): + decode_frame(evil) + + +def test_envelope_defaults_and_validation() -> None: + env = Envelope(type=MessageType.COMMAND) + assert env.protocol_version == 1 + assert env.revision == 0 + assert env.message_id + assert env.payload == {} + + +def test_envelope_rejects_wrong_protocol_version() -> None: + with pytest.raises(ValueError, match="protocol_version"): + Envelope(type=MessageType.EVENT, protocol_version=2) + + +def test_idempotency_register_and_duplicate() -> None: + reg = IdempotencyRegistry() + assert reg.register("cmd-1") is True + assert reg.register("cmd-1") is False + assert reg.register("cmd-2") is True + assert len(reg) == 2 + + +def test_idempotency_complete_returns_same_result() -> None: + reg = IdempotencyRegistry() + reg.register("cmd-1") + reg.complete("cmd-1", {"status": "ack"}) + assert reg.result("cmd-1") == {"status": "ack"} + + +def test_idempotency_capacity_eviction_lru() -> None: + reg = IdempotencyRegistry(capacity=2) + reg.register("a") + reg.register("b") + reg.register("a") # a wird jüngst benutzt + reg.register("c") # verdrängt b (LRU) + assert reg.register("b") is True # b wurde verdrängt → neu + assert reg.register("c") is False # c existiert noch diff --git a/tests/visual/.gitkeep b/tests/visual/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tools/artnet_emulator/.gitkeep b/tools/artnet_emulator/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tools/artnet_emulator/artnet_emulator.py b/tools/artnet_emulator/artnet_emulator.py new file mode 100644 index 0000000..3fd0c9d --- /dev/null +++ b/tools/artnet_emulator/artnet_emulator.py @@ -0,0 +1,85 @@ +"""Art-Net-Emulator (PLAN.md §16, §29.2, §36 Nr. 7–8). + +Simuliert ein Lichtpult für Gate-0-Tests: +- poll: sendet ArtPoll und zeigt die ArtPollReply des Nodes (Media Server) +- sweep: fährt einen Fader über Layer-Opacity (16 Bit, Kanäle 2–3) und + sendet ArtDMX mit Sequenznummern + +Nur für Testnetze bestimmt; kein Ersatz für den Hardwarepulttest (§29.7). +""" + +from __future__ import annotations + +import argparse +import socket +import time + +from hms_artnet.packets import build_dmx, build_poll, parse_poll_reply + + +def cmd_poll(host: str, port: int) -> int: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(2.0) + sock.bind(("0.0.0.0", 0)) + local_port = sock.getsockname()[1] + sock.sendto(build_poll(talk_to_me=0x00), (host, port)) + try: + data, addr = sock.recvfrom(2048) + except TimeoutError: + print("keine ArtPollReply erhalten (Timeout)") + return 1 + info = parse_poll_reply(data) + if info is None: + print(f"ungültige Antwort von {addr}") + return 1 + print(f"ArtPollReply von {addr[0]} → lokal gebunden auf Port {local_port}") + print(f" IP: {info.ip}") + print(f" ShortName: {info.short_name}") + print(f" LongName: {info.long_name}") + print(f" NodeReport: {info.node_report}") + print(f" Style: 0x{info.style:02X} (0x02 = StMedia/Media Server)") + print(f" Ports: {info.num_ports}") + return 0 + + +def cmd_sweep(host: str, port: int, universe: int, rate_hz: float, cycles: int) -> int: + sequence = 0 + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + for _ in range(cycles): + for step in range(0, 256, 8): + # Layer64-Layout: Kanal 1 Enable, Kanal 2-3 Opacity 16 Bit (MSB zuerst) + data = bytearray(16) + data[0] = 255 + data[1] = step + data[2] = 0 + sequence = (sequence % 255) + 1 + sock.sendto(build_dmx(universe, bytes(data), sequence=sequence), (host, port)) + time.sleep(1.0 / rate_hz) + for step in range(255, -1, -8): + data = bytearray(16) + data[0] = 255 + data[1] = step + data[2] = 0 + sequence = (sequence % 255) + 1 + sock.sendto(build_dmx(universe, bytes(data), sequence=sequence), (host, port)) + time.sleep(1.0 / rate_hz) + print(f"sweep abgeschlossen: {cycles} Zyklen auf Universe {universe}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="artnet_emulator") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=6454) + parser.add_argument("--mode", choices=["poll", "sweep"], required=True) + parser.add_argument("--universe", type=int, default=0) + parser.add_argument("--rate", type=float, default=40.0, help="Pakete pro Sekunde") + parser.add_argument("--cycles", type=int, default=1) + args = parser.parse_args(argv) + if args.mode == "poll": + return cmd_poll(args.host, args.port) + return cmd_sweep(args.host, args.port, args.universe, args.rate, args.cycles) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/capability_probe/.gitkeep b/tools/capability_probe/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tools/cluster_test_node/.gitkeep b/tools/cluster_test_node/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tools/fixture_generator/.gitkeep b/tools/fixture_generator/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tools/fixture_generator/fixture_generator.py b/tools/fixture_generator/fixture_generator.py new file mode 100644 index 0000000..541c151 --- /dev/null +++ b/tools/fixture_generator/fixture_generator.py @@ -0,0 +1,148 @@ +"""Fixture-Generator (PLAN.md §16.3, §16.4, §16.7). + +Erzeugt menschenlesbare CSV-Kanallisten für die V1-Personalities: +- HMS MediaEngine Master 32ch +- HMS MediaEngine Layer 64ch + +GDTF-/pultspezifische Personality-Dateien folgen in Phase 4; die hier +erzeugten Listen tragen bereits dieselbe Fixture-Schema-Version (§16.7). +""" + +from __future__ import annotations + +import argparse +import csv +from pathlib import Path + +FIXTURE_SCHEMA_VERSION = 1 + +MASTER32: list[tuple[int, str, str]] = [ + (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"), +] + + +def _layer64() -> list[tuple[int, str, str]]: + rows: list[tuple[int, str, str]] = [ + (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"), + ] + for slot in range(1, 9): + rows.append((43 + slot, f"FX1 Parameter P{slot}", "8 Bit oder manifestgebundene Paare")) + rows.extend( + [ + (52, "FX2 Enable", "Schalter"), + (53, "FX2 Plugin Select", "8 Bit, Show-Registry"), + (54, "FX2 Mix", "8 Bit"), + ] + ) + for slot in range(1, 9): + rows.append((54 + slot, f"FX2 Parameter P{slot}", "8 Bit oder manifestgebundene Paare")) + rows.append((63, "Layer Retrigger/Reset", "steigende Flanke")) + rows.append((64, "reserviert", "muss neutral ignoriert werden")) + return rows + + +LAYER64: list[tuple[int, str, str]] = _layer64() + +_HEADER = ["channel", "parameter", "resolution_behavior"] + + +def write_csv(rows: list[tuple[int, str, str]], path: Path) -> Path: + """Schreibt eine Kanalliste als CSV; Elternordner werden angelegt.""" + if [r[0] for r in rows] != list(range(1, len(rows) + 1)): + raise ValueError("channel numbers must be contiguous starting at 1") + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + writer.writerow(_HEADER) + writer.writerows(rows) + return path + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="fixture_generator") + parser.add_argument("--out-dir", default="fixture_profiles", help="Zielordner") + args = parser.parse_args(argv) + out = Path(args.out_dir) + master = write_csv(MASTER32, out / "master32" / "master32.csv") + layer = write_csv(LAYER64, out / "layer64" / "layer64.csv") + print(f"master32: {master} ({len(MASTER32)} Kanäle)") + print(f"layer64: {layer} ({len(LAYER64)} Kanäle)") + print(f"fixture_schema_version: {FIXTURE_SCHEMA_VERSION}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/media_probe/.gitkeep b/tools/media_probe/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tools/shader_validate/.gitkeep b/tools/shader_validate/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..1807a36 --- /dev/null +++ b/uv.lock @@ -0,0 +1,339 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hms-mediaengine" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "msgpack" }, + { name = "pydantic" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "msgpack", specifier = ">=1.0" }, + { name = "pydantic", specifier = ">=2.7" }, + { name = "uvicorn", specifier = ">=0.30" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", specifier = ">=8.2" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" }, + { url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" }, + { url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" }, + { url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] + +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +]