Phase 0: Repository-Initialisierung nach Bauplan v1.2
- 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).
This commit is contained in:
+31
@@ -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/
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
|
||||||
|
Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung: [SemVer](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Repository-Struktur gemäß Bauplan §8 (Eigentumsgrenzen)
|
||||||
|
- Pflichtdokumente STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADR-Vorlage
|
||||||
|
- ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows), ADR-0003 IPC TCP+MessagePack v1
|
||||||
|
- Kernpakete: `hms_protocol`, `hms_parameter`, `hms_artnet`, `hms_adaptive`, `hms_capabilities`, `hms_plugin_sdk`, `hms_domain`
|
||||||
|
- Minimaler Control Core (FastAPI REST + WebSocket, Commands mit Revision/Idempotenz)
|
||||||
|
- Renderer-Spike (D3D11-Primärpfad + ausdrücklich gekennzeichneter Dev-GL-Pfad)
|
||||||
|
- Beispielplugin `com.hms.fx.example_passthrough` (HLSL/GLSL/GLES)
|
||||||
|
- Tools: Art-Net-Emulator, Capability-Probe, Plugin-Validator
|
||||||
|
- Unit-/Integrationstests für Phase-0-Grundlage
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ERRORS
|
||||||
|
|
||||||
|
Fehlerverzeichnis gemäß PLAN.md §32.
|
||||||
|
|
||||||
|
Format je Fehler:
|
||||||
|
|
||||||
|
| Feld | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| ID | ERR-000 |
|
||||||
|
| Priorität | P0–P3 |
|
||||||
|
| Reproduktion | Schritte |
|
||||||
|
| Erwartet | Verhalten |
|
||||||
|
| Tatsächlich | Verhalten |
|
||||||
|
| Plattform/Hardware | OS, GPU, Treiber |
|
||||||
|
| Logs/Screenshots | Pfad/Verweis |
|
||||||
|
| Ursache | Analyse |
|
||||||
|
| Fix-Commit | Commit-Hash |
|
||||||
|
| Regressionstest | Test-ID |
|
||||||
|
|
||||||
|
## Offene Fehler
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Behobene Fehler
|
||||||
|
|
||||||
|
Keine.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# HMS MediaEngine
|
||||||
|
|
||||||
|
**Arbeitstitel** – der Produktname kann später ohne technische Auswirkung geändert werden.
|
||||||
|
|
||||||
|
Modularer Medienserver, VJ-System und generativer Licht-/Pixeleffekt-Server.
|
||||||
|
|
||||||
|
- **Primärplattform:** Windows 11 x64, portabel ohne Installation
|
||||||
|
- **Weitere Zielplattformen:** Linux x64, Raspberry Pi 5 / Linux ARM64
|
||||||
|
- **Steuerung:** lokale Webanwendung (Browser), Art-Net/DMX, spätere Timeline/Audio/KI
|
||||||
|
|
||||||
|
## Leitsatz
|
||||||
|
|
||||||
|
> **Python steuert. Native Bibliotheken decodieren. Die GPU rendert. Der Browser bedient.**
|
||||||
|
|
||||||
|
## Projektstatus
|
||||||
|
|
||||||
|
Das Projekt befindet sich in **Phase 0 – technischer Spike und Go/No-Go**.
|
||||||
|
|
||||||
|
Aktueller Stand, Gates und nächste Aufgaben: [`STATUS.md`](STATUS.md)
|
||||||
|
Fehlerverfolgung: [`ERRORS.md`](ERRORS.md)
|
||||||
|
Messergebnisse: [`TEST_REPORT.md`](TEST_REPORT.md)
|
||||||
|
Bauplan (normativ): [`PLAN.md`](PLAN.md)
|
||||||
|
|
||||||
|
## Repository-Struktur (Eigentumsgrenzen)
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├─ apps/ launcher, control_server, renderer, web
|
||||||
|
├─ native/ render_bridge (Rust oder C++, Entscheidung per ADR)
|
||||||
|
├─ packages/ domain, protocol, parameter_engine, render_backend,
|
||||||
|
│ capabilities, adaptive_quality, plugin_sdk, artnet,
|
||||||
|
│ cluster, content_sync, timeline, audio_analysis,
|
||||||
|
│ persistence
|
||||||
|
├─ plugins/ builtin (generators, filters, transitions, outputs), examples
|
||||||
|
├─ schemas/ project, plugin, ipc, cluster, api
|
||||||
|
├─ fixture_profiles/ master32, layer64
|
||||||
|
├─ tests/ unit, integration, rendering, cluster, visual,
|
||||||
|
│ performance, portability, e2e
|
||||||
|
├─ tools/ media_probe, shader_validate, artnet_emulator,
|
||||||
|
│ capability_probe, cluster_test_node, fixture_generator
|
||||||
|
├─ build/ windows, linux, raspberry_pi
|
||||||
|
└─ docs/ architecture, adr, plugin-sdk, api, fixture,
|
||||||
|
performance, operator
|
||||||
|
```
|
||||||
|
|
||||||
|
Code darf nicht beliebig zwischen Paketen quer importiert werden.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Python-Umgebung
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# Lint + Typprüfung
|
||||||
|
uv run ruff check .
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend (ab Phase 5): `pnpm` mit Lockfile, Vite-Build, siehe `apps/web`.
|
||||||
|
|
||||||
|
## Phasenmodell
|
||||||
|
|
||||||
|
Die Entwicklung folgt strikt dem Phasenmodell aus `PLAN.md` Abschnitt 31.
|
||||||
|
Jede Phase endet mit einem Gate; **keine neue Phase ohne grünes Gate.**
|
||||||
|
|
||||||
|
| Phase | Inhalt | Gate
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | Technischer Spike, Machbarkeitsnachweis, ADRs | Gate 0: D3D11-HW-Decode, GPU-Compositing, Adaptive Quality, Art-Net-Latenz, portable Auslieferung reproduzierbar grün
|
||||||
|
| 1 | Fundament: Supervisor, Control Core, Renderer, IPC, Node-Identität, Discovery, Paarung | Gate 1
|
||||||
|
| 2 | Medien-, Layer-, Basissync-Engine | Gate 2
|
||||||
|
| 3 | Plugin-SDK, Starterpaket Generatoren + Filter | Gate 3
|
||||||
|
| 4 | Art-Net-Fixtures Master32/Layer64 | Gate 4
|
||||||
|
| 5 | Vollständige Browser-Liveoberfläche | Gate 5 / V1.0-Kernrelease
|
||||||
|
| 6+ | Cue/Timeline, Audio, Mapping, Pixel-Ausgabe, KI, Pi 5, Härtung | eigene Gates
|
||||||
|
|
||||||
|
## Verbindliche Regeln (Auszug)
|
||||||
|
|
||||||
|
- Keine Pixelverarbeitung in Python-Schleifen.
|
||||||
|
- Kein CPU-Readback im normalen HDMI-Renderpfad (Windows: D3D11Memory durchgängig).
|
||||||
|
- Kein Mock als fertige Funktion gemeldet; Hardwaretests nur auf echter Hardware.
|
||||||
|
- Jede Architekturabweichung braucht ein ADR und Freigabe.
|
||||||
|
- Projektschema versioniert, Migrationen getestet.
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
- Architektur: `docs/architecture/`
|
||||||
|
- ADRs: `docs/adr/` (Vorlage: `docs/adr/_template.md`)
|
||||||
|
- Plugin-SDK: `docs/plugin-sdk/` (ab Phase 3)
|
||||||
|
- Fixture-Handbücher: `docs/fixture/` (ab Phase 4)
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
TBD – wird mit dem ersten Release entschieden (SBOM und Lizenzverzeichnis sind Teil der Release-Anforderungen, PLAN.md Abschnitt 30).
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""hms_control_server – minimaler Control Core (PLAN.md §6.1B, §36 Nr. 9).
|
||||||
|
|
||||||
|
FastAPI-REST + WebSocket für denselben Parametersatz, den Art-Net bedient.
|
||||||
|
Autoritative Instanz ist die ParameterEngine; alle Quellen laufen über sie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from hms_control_server.app import create_app
|
||||||
|
|
||||||
|
__all__ = ["create_app"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Control-Core-Start (Entwicklung): python -m hms_control_server"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Phase 0: Development-Start. Produktion startet über den Launcher,
|
||||||
|
# bindet 127.0.0.1 und wählt freie Ports (§3.1, §9.2).
|
||||||
|
uvicorn.run("hms_control_server.app:app", host="127.0.0.1", port=8000)
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Portable Pfadregeln (PLAN.md §9, §9.1).
|
||||||
|
|
||||||
|
- Alle Pfade relativ zum Anwendungsroot; keine Laufwerksbuchstaben.
|
||||||
|
- Keine Abhängigkeit vom Working Directory.
|
||||||
|
- Temporäre Dateien in userdata/cache, nicht im OS-Profil.
|
||||||
|
- Schreibbarkeit wird beim Start geprüft (Read-only-Modus folgt Phase 1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AppPaths:
|
||||||
|
"""Alle portablen Pfade je Anwendungsroot (§9-Struktur)."""
|
||||||
|
|
||||||
|
root: Path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def app(self) -> Path:
|
||||||
|
return self.root / "app"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def runtime(self) -> Path:
|
||||||
|
return self.root / "runtime"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gstreamer_bin(self) -> Path:
|
||||||
|
return self.runtime / "gstreamer" / "bin"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gstreamer_plugins(self) -> Path:
|
||||||
|
return self.runtime / "gstreamer" / "lib" / "gstreamer-1.0"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def web(self) -> Path:
|
||||||
|
return self.root / "web"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def projects(self) -> Path:
|
||||||
|
return self.root / "projects"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media(self) -> Path:
|
||||||
|
return self.root / "media"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def userdata(self) -> Path:
|
||||||
|
return self.root / "userdata"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database(self) -> Path:
|
||||||
|
return self.userdata / "database"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache(self) -> Path:
|
||||||
|
return self.userdata / "cache"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity(self) -> Path:
|
||||||
|
return self.userdata / "identity" / "node_id"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> Path:
|
||||||
|
return self.root / "config"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logs(self) -> Path:
|
||||||
|
return self.root / "logs"
|
||||||
|
|
||||||
|
def ensure_writable(self) -> bool:
|
||||||
|
"""Prüft Schreibbarkeit des Roots (§9.1)."""
|
||||||
|
probe = self.root / ".write_probe"
|
||||||
|
try:
|
||||||
|
probe.write_text("ok", encoding="ascii")
|
||||||
|
probe.unlink()
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def portable_environment(self) -> dict[str, str]:
|
||||||
|
"""Umgebungsvariablen für gebündelte GStreamer-Runtime (§9.2, ADR-0002).
|
||||||
|
|
||||||
|
System-Plugins werden unterdrückt (leerer GST_PLUGIN_SYSTEM_PATH_1_0),
|
||||||
|
damit ausschließlich die gebündelte, manifestierte Untermenge lädt.
|
||||||
|
"""
|
||||||
|
env = dict(os.environ)
|
||||||
|
gs_bin = self.gstreamer_bin
|
||||||
|
if gs_bin.is_dir():
|
||||||
|
path_var = "PATH"
|
||||||
|
existing = env.get(path_var, "")
|
||||||
|
env[path_var] = f"{gs_bin}{os.pathsep}{existing}" if existing else str(gs_bin)
|
||||||
|
env["GST_PLUGIN_PATH_1_0"] = str(self.gstreamer_plugins)
|
||||||
|
env["GST_PLUGIN_SYSTEM_PATH_1_0"] = ""
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_app_root(start_from: Path | None = None) -> Path:
|
||||||
|
"""Bestimmt den Anwendungsroot anhand der PORTABLE_MODE-Markierung (§9).
|
||||||
|
|
||||||
|
Sucht vom gegebenen Pfad (Default: dieses Paket) aufwärts nach der
|
||||||
|
Datei PORTABLE_MODE; im Entwickungsbaum ist das Repo-Root gemeint.
|
||||||
|
"""
|
||||||
|
current = Path(start_from or __file__).resolve()
|
||||||
|
for candidate in [current, *current.parents]:
|
||||||
|
if (candidate / "PORTABLE_MODE").is_file() or (candidate / "pyproject.toml").is_file():
|
||||||
|
return candidate
|
||||||
|
raise RuntimeError("app root not found (PORTABLE_MODE or pyproject.toml missing)")
|
||||||
@@ -0,0 +1,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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Renderer-CLI (Phase-0-Spike).
|
||||||
|
|
||||||
|
Aufruf:
|
||||||
|
python -m hms_renderer --pipeline d3d11 --video-a A --video-b B
|
||||||
|
|
||||||
|
Ohne GStreamer-Installation: kontrollierter Abbruch mit Exit-Code 2 und
|
||||||
|
klarer Meldung – niemals Erfolgssimulation (§1.1 Nr. 5, §33).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from hms_renderer.pipelines import build_compositor_pipeline, build_single_video_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="hms-renderer")
|
||||||
|
parser.add_argument("--pipeline", choices=["d3d11", "devgl"], default="d3d11")
|
||||||
|
parser.add_argument("--video-a", required=True)
|
||||||
|
parser.add_argument("--video-b", default=None, help="zweite Quelle für Compositing")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="nur Pipeline-String ausgeben")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
use_d3d11 = args.pipeline == "d3d11"
|
||||||
|
if args.video_b:
|
||||||
|
pipeline = build_compositor_pipeline(args.video_a, args.video_b, d3d11=use_d3d11)
|
||||||
|
else:
|
||||||
|
pipeline = build_single_video_pipeline(args.video_a, d3d11=use_d3d11)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(pipeline)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
gst_launch = shutil.which("gst-launch-1.0")
|
||||||
|
if gst_launch is None:
|
||||||
|
print(
|
||||||
|
"ERROR: gst-launch-1.0 nicht gefunden. GStreamer 1.28.6 muss gebündelt "
|
||||||
|
"oder installiert sein (build/windows/GSTREAMER.md).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
result = subprocess.run([gst_launch, "-v", pipeline], check=False)
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Renderer-Pipeline-Definitionen (PLAN.md §12, §13, §36 Nr. 4–5).
|
||||||
|
|
||||||
|
Windows-Primärpfad (§12.6): d3d11h264dec → D3D11Memory → d3d11compositor
|
||||||
|
→ d3d11videosink. Kein CPU-Rundweg (Decoder → RAM → Upload verboten).
|
||||||
|
|
||||||
|
DevGL-Pfad: Nur für Entwicklung/CI-Umgebungen ohne D3D11; ausdrücklich
|
||||||
|
gekennzeichnet, kein stiller Ersatz im Normalbetrieb (§5.2, §33).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class D3D11Pipeline:
|
||||||
|
"""Primärpipeline Windows: durchgängig D3D11Memory."""
|
||||||
|
|
||||||
|
video_a: str
|
||||||
|
video_b: str
|
||||||
|
width: int = 1920
|
||||||
|
height: int = 1080
|
||||||
|
fullscreen: bool = True
|
||||||
|
|
||||||
|
def launch_string(self) -> str:
|
||||||
|
sink = "d3d11videosink fullscreen=true" if self.fullscreen else "d3d11videosink"
|
||||||
|
# Zwei Quellen → Compositor → Ausgabe (§36 Nr. 5: zwei Videos GPU-mischen)
|
||||||
|
return (
|
||||||
|
f"d3d11compositor name=mix sink_0::xpos=0 sink_0::ypos=0 "
|
||||||
|
f"sink_0::width={self.width // 2} sink_0::height={self.height} "
|
||||||
|
f"sink_1::xpos={self.width // 2} sink_1::ypos=0 "
|
||||||
|
f"sink_1::width={self.width // 2} sink_1::height={self.height} ! "
|
||||||
|
f"d3d11convert ! video/x-raw(memory:D3D11Memory),format=RGBA,"
|
||||||
|
f"width={self.width},height={self.height} ! {sink} "
|
||||||
|
f"uridecodebin uri=file:///{self.video_a} ! queue ! "
|
||||||
|
f"d3d11convert ! mix. "
|
||||||
|
f"uridecodebin uri=file:///{self.video_b} ! queue ! "
|
||||||
|
f"d3d11convert ! mix."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DevGLPipeline:
|
||||||
|
"""Entwicklungspfad ohne D3D11 (explizit gekennzeichnet, kein Normalpfad).
|
||||||
|
|
||||||
|
Nur für CI/Dev ohne Windows-GPU; die Backend-Schnittstelle bleibt
|
||||||
|
identisch (§12.6)."""
|
||||||
|
|
||||||
|
video_a: str
|
||||||
|
video_b: str
|
||||||
|
width: int = 960
|
||||||
|
height: int = 540
|
||||||
|
|
||||||
|
def launch_string(self) -> str:
|
||||||
|
return (
|
||||||
|
f"glvideomixer name=mix ! glimagesink "
|
||||||
|
f"uridecodebin uri=file:///{self.video_a} ! queue ! glupload ! mix. "
|
||||||
|
f"uridecodebin uri=file:///{self.video_b} ! queue ! glupload ! mix."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_single_video_pipeline(video: str, d3d11: bool = True) -> str:
|
||||||
|
"""Minimale Einzelquellen-Pipeline (§36 Nr. 4: Testvideo → Vollbild)."""
|
||||||
|
if d3d11:
|
||||||
|
return f"uridecodebin uri=file:///{video} ! d3d11convert ! d3d11videosink fullscreen=true"
|
||||||
|
return f"uridecodebin uri=file:///{video} ! glupload ! glimagesink"
|
||||||
|
|
||||||
|
|
||||||
|
def build_compositor_pipeline(video_a: str, video_b: str, d3d11: bool = True) -> str:
|
||||||
|
"""Zwei-Quellen-Compositing (§36 Nr. 5: GPU-Mischen ohne CPU-Readback)."""
|
||||||
|
if d3d11:
|
||||||
|
return D3D11Pipeline(video_a=video_a, video_b=video_b).launch_string()
|
||||||
|
return DevGLPipeline(video_a=video_a, video_b=video_b).launch_string()
|
||||||
|
|
||||||
|
|
||||||
|
def gst_available() -> bool:
|
||||||
|
"""True, wenn ein GStreamer-CLI auf dem System liegt (Diagnose, kein Fake)."""
|
||||||
|
return shutil.which("gst-launch-1.0") is not None
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# GStreamer-Bündelung Windows (Pin)
|
||||||
|
|
||||||
|
ADR-0002. Ziel: portables `runtime/gstreamer` ohne Systeminstallation (PLAN.md §3.1, §30.2).
|
||||||
|
|
||||||
|
## Pin
|
||||||
|
|
||||||
|
- **Version:** 1.28.6 (aktuelle stabile 1.28-Serie, Stand 2026-09-10, Quelle: <https://gstreamer.freedesktop.org/download/>)
|
||||||
|
- **Distribution:** offizielle MSVC-Binaries, x86_64 (`gstreamer-1.0-msvc-x86_64-1.28.6.msi`)
|
||||||
|
- **SHA-256:** nach dem ersten Bündelungs-Build manifestieren (§30.1)
|
||||||
|
|
||||||
|
## Minimal gebündelte Plugin-Untermenge (Phase 0)
|
||||||
|
|
||||||
|
Ziel: H.264-MP4-Testclip → D3D11-Decode → Compositing → Vollbildausgabe.
|
||||||
|
|
||||||
|
| Komponente | Paket | Enthält | Zweck |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| libgstreamer-1.0-0.dll | gstreamer core | – | Kern |
|
||||||
|
| coreelements | gst-plugins-core | `filesrc`, `queue`, `decodebin3`-Basen | Container/Datei |
|
||||||
|
| typefindfunctions | gst-plugins-core | Typenerkennung | MP4-Erkennung |
|
||||||
|
| isomp4 | gst-plugins-good | `qtdemux` | MP4-Demux |
|
||||||
|
| videoparsersbad | gst-plugins-bad | `h264parse` | H.264-Parsing vor Decoder |
|
||||||
|
| playback | gst-plugins-base | `uridecodebin` | bequeme Quelle (Spike) |
|
||||||
|
| d3d11 | gst-plugins-bad | `d3d11h264dec`, `d3d11h265dec`, `d3d11convert`, `d3d11compositor`, `d3d11videosink` | GPU-Pfad (§12.6) |
|
||||||
|
| video/x-raw Basen | gst-plugins-base | `videoconvert` (nur Fallback, nicht Normalpfad) | nur Diagnose |
|
||||||
|
|
||||||
|
Nicht gebündelt (V1-Spike): Netzwerk-Quellen, NDI, Capture, Software-Codecs außer für expliziten Fallback.
|
||||||
|
|
||||||
|
## Umgebungsvariablen beim Start (Launcher, §9.2)
|
||||||
|
|
||||||
|
```text
|
||||||
|
GST_PLUGIN_PATH_1_0=<app>/runtime/gstreamer/lib/gstreamer-1.0
|
||||||
|
PATH=<app>/runtime/gstreamer/bin;%PATH%
|
||||||
|
GST_PLUGIN_SYSTEM_PATH_1_0= (leer setzen, um System-Plugins zu unterdrücken)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validierung
|
||||||
|
|
||||||
|
1. Saubere Windows-VM ohne GStreamer (§29.6)
|
||||||
|
2. `gst-inspect-1.0 d3d11h264dec` muss die Elemente aus dem Bündel listen
|
||||||
|
3. Testpipeline läuft; GPU-Treiber ist die einzige Systemabhängigkeit
|
||||||
|
4. MSI-Binärdateien und DLL-Set als SHA-256-Manifest festhalten
|
||||||
|
|
||||||
|
## Offene Punkte
|
||||||
|
|
||||||
|
- [ ] Bündelungsskript (MSI still entpacken, Untermenge kopieren) – Phase-0-Aufgabe mit Packaging-ADR-0005
|
||||||
|
- [ ] VC-Runtime-Redistributable-Handling → `runtime/vc-runtime` (§9)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# ADR-0001: Python-Version pinnen
|
||||||
|
|
||||||
|
- **Status:** Angenommen
|
||||||
|
- **Datum:** 2026-09-10
|
||||||
|
- **Phase:** 0
|
||||||
|
- **Bauplan:** §7 („Python, unterstützte Version exakt pinnen“)
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Der Control Core und alle Python-Pakete pinnen **Python 3.13** (`requires-python = "==3.13.*"` in `pyproject.toml`).
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Der Bauplan verlangt eine exakt gepinnte Python-Version. 3.13 ist die aktuelle stabile Version mit ausgereiftem `asyncio`, breitem Wheel-Support für FastAPI/Pydantic und PyGObject-Kompatibilität für die GStreamer-Bindings.
|
||||||
|
|
||||||
|
## Alternativen
|
||||||
|
|
||||||
|
- 3.12: kein messbarer Vorteil, kürzeres Supportfenster als 3.13.
|
||||||
|
- 3.14: bei Projektstart zu neu für stabile Binärwheels aller Abhängigkeiten.
|
||||||
|
|
||||||
|
## Folgen
|
||||||
|
|
||||||
|
- uv-Lockfile und CI pinnen 3.13; Versionssprünge erfolgen bewusst per ADR-Änderung.
|
||||||
|
|
||||||
|
## Freigabe
|
||||||
|
|
||||||
|
- Auftragsvorgabe „exakt pinnen“ aus PLAN.md §7; Umsetzung ohne Abweichung.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# ADR-0002: GStreamer-Pin für Windows
|
||||||
|
|
||||||
|
- **Status:** Angenommen (Bündelungsumfang folgt nach Phase-0-Build)
|
||||||
|
- **Datum:** 2026-09-10
|
||||||
|
- **Phase:** 0
|
||||||
|
- **Bauplan:** §7.1, §30.2
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Für die portable Windows-Ausgabe wird **GStreamer 1.28.6 (MSVC, x86_64)** gebündelt (Runtime-Paket, exakt manifestierte Plugin-Untermenge). Details: `build/windows/GSTREAMER.md`.
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Die 1.28-Serie ist die aktuelle stabile Release-Serie mit gepflegtem D3D11-Stack (`d3d11h264dec`, `d3d11convert`, `d3d11compositor`, `d3d11videosink`). Version ermittelt von https://gstreamer.freedesktop.org/download/ (Stand 2026-09-10).
|
||||||
|
|
||||||
|
## Alternativen
|
||||||
|
|
||||||
|
- Ältere LTS-Releases: keine Vorteile, ältere D3D11-Elemente.
|
||||||
|
- Eigener GStreamer-Build: höherer Wartungsaufwand, für Phase 0 nicht nötig.
|
||||||
|
|
||||||
|
## Folgen
|
||||||
|
|
||||||
|
- Phase 0 prüft die Bündelung in einer sauberen Windows-VM ohne installiertes GStreamer (§29.6); Pluginliste und SHA-256 werden nach dem ersten Onefolder-Build manifestiert.
|
||||||
|
- Muss mit der Packaging-Entscheidung (ADR-0005 offen) zusammenarbeiten.
|
||||||
|
|
||||||
|
## Freigabe
|
||||||
|
|
||||||
|
- Entspricht PLAN.md §7.1 (Version pinnen); Bündelungsumfang wird nach dem ersten Portabilitätstest ergänzt.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ADR-0003: IPC – lokales TCP mit length-prefixed MessagePack
|
||||||
|
|
||||||
|
- **Status:** Angenommen (Bauplan-Vorgabe §6.2; umgesetzt in `packages/protocol`)
|
||||||
|
- **Datum:** 2026-09-10
|
||||||
|
- **Phase:** 0
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Control Core ↔ Renderer kommunizieren über lokales TCP auf `127.0.0.1` mit length-prefixed MessagePack (4-Byte-Big-Endian-Länge, Protokollversion 1, Idempotency-Keys; Heartbeat ab Phase 1). JSON ausschließlich im Debugmodus.
|
||||||
|
|
||||||
|
## Alternativen
|
||||||
|
|
||||||
|
- Named Pipes/Unix Sockets: plattformspezifische API-Unterschiede, kein Nutzen im Spike.
|
||||||
|
- JSON-Lines: langsamer und größer; nur für Debug erlaubt (§6.2).
|
||||||
|
|
||||||
|
## Folgen
|
||||||
|
|
||||||
|
- `hms_protocol` definiert Envelope, Framing und Idempotency-Registry mit Unit-Tests.
|
||||||
|
- IPC bindet niemals an eine externe Netzwerkschnittstelle (§6.2).
|
||||||
|
|
||||||
|
## Freigabe
|
||||||
|
|
||||||
|
- Direkte Umsetzung der normativen Vorgabe PLAN.md §6.2.
|
||||||
@@ -0,0 +1,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)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# ADR-NNNN: <Titel>
|
||||||
|
|
||||||
|
- **Status:** Vorgeschlagen | Angenommen | Ersetzt (durch ADR-xxxx) | Verworfen
|
||||||
|
- **Datum:** YYYY-MM-DD
|
||||||
|
- **Phase:** (z. B. Phase 0)
|
||||||
|
- **Betroffene Bauplan-Abschnitte:** (z. B. §7.1, §31 Phase 0)
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
Welches Problem, welche Optionen, welche Messungen/Zahlen liegen vor?
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
|
||||||
|
Die gewählte Option, klar und eindeutig formuliert.
|
||||||
|
|
||||||
|
## Alternativen
|
||||||
|
|
||||||
|
Die geprüften Alternativen und warum sie verworfen wurden.
|
||||||
|
|
||||||
|
## Folgen
|
||||||
|
|
||||||
|
Positiv, negativ, Risiken, Migrationspfad, Testfolgen.
|
||||||
|
|
||||||
|
## Messwerte / Nachweise
|
||||||
|
|
||||||
|
Verweis auf TEST_REPORT.md oder Rohdaten, die die Entscheidung stützen.
|
||||||
|
|
||||||
|
## Freigabe
|
||||||
|
|
||||||
|
Auftraggeber: (Freigabe erforderlich gemäß PLAN.md §1)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Architekturüberblick (Phase 0)
|
||||||
|
|
||||||
|
Verbindliche Referenz: `PLAN.md`. Diese Seite fasst Prozess- und Besitzgrenzen zusammen.
|
||||||
|
|
||||||
|
## Prozesse (§6.1)
|
||||||
|
|
||||||
|
| Prozess | Technologie | Aufgabe |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Launcher/Supervisor | Python (Phase 1) | Start, Portwahl, portable Pfade, Heartbeat, kontrolliertes Beenden |
|
||||||
|
| Control Core | Python, asyncio, FastAPI, Pydantic, SQLite/WAL (ab Phase 1) | autoritativer Zustand, REST/WS, Art-Net, Persistenz |
|
||||||
|
| Render Worker | Python-Orchestrator + GStreamer + nativer Renderkern (ADR-0004 offen) | Decode, GPU-Compositing, Ausgabe, Telemetrie |
|
||||||
|
| Web-Frontend | TypeScript, Vite (Phase 5, ADR-0006 offen) | Bedienoberfläche; niemals Videoausgang (§3.2) |
|
||||||
|
|
||||||
|
## Besitzgrenzen (§8)
|
||||||
|
|
||||||
|
- Jedes Paket hat eindeutige `hms_*`-Namen; kein Quereinbau zwischen `packages/*`.
|
||||||
|
- Control Core und Renderer verbinden ausschließlich über das versionierte IPC (ADR-0003).
|
||||||
|
- Der Renderer erhält pro Frame einen unveränderlichen Parametersnapshot (§11.4).
|
||||||
|
|
||||||
|
## Kernregeln (§33, Auszug)
|
||||||
|
|
||||||
|
- Python steuert, decodiert nicht und rendert keine Pixel.
|
||||||
|
- Windows: D3D11Memory durchgängig; kein CPU-Rundweg im Normalpfad.
|
||||||
|
- Plugins: versioniertes Manifest, kein Datei-/DB-Zugriff, Quarantäne bei Fehler.
|
||||||
|
- Alle Steuerquellen (Browser, Art-Net, später Timeline/Audio/KI) laufen über die zentrale Parameter-Engine.
|
||||||
|
|
||||||
|
## Steuerfluss Phase 0
|
||||||
|
|
||||||
|
```text
|
||||||
|
Lichtpult/Emulator → ArtDMX (UDP 6454) → hms_artnet.receiver
|
||||||
|
→ Control Core (hms_parameter.engine: Priorität CONSOLE)
|
||||||
|
→ IPC-Snapshot → Renderer (GStreamer-D3D11) → Opacity/Parameter an Frame-Grenze
|
||||||
|
|
||||||
|
Browser → REST/WebSocket → derselbe Command-/Parameter-Pfad (Priorität WEB)
|
||||||
|
```
|
||||||
|
|
||||||
|
Beide Quellen adressieren denselben Parametersatz; es gibt genau eine autoritative Instanz (Control Core).
|
||||||
|
|
||||||
|
## Phase-0-Spike
|
||||||
|
|
||||||
|
`hms_renderer` definiert die Pipelines als Launch-Strings; Ausführung und Messung erfolgen auf Referenz-Windows-Hardware (Gate 0). Messprotokoll: `TEST_REPORT.md`.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
channel,parameter,resolution_behavior
|
||||||
|
1,Layer Enable,Schalter
|
||||||
|
2,Opacity (MSB),16 Bit
|
||||||
|
3,Opacity (LSB),16 Bit
|
||||||
|
4,Source Type,Enum: Media/Generator/Live/Solid
|
||||||
|
5,Media Bank,8 Bit
|
||||||
|
6,Media Folder,8 Bit
|
||||||
|
7,Media/Plugin Index (MSB),16 Bit
|
||||||
|
8,Media/Plugin Index (LSB),16 Bit
|
||||||
|
9,Load/Commit Selection,steigende Flanke
|
||||||
|
10,Transport,Enum: Stop/Play/Pause/Retrigger
|
||||||
|
11,Loop Mode,Enum
|
||||||
|
12,Playback Direction/Mode,Enum
|
||||||
|
13,Playback Speed (MSB),"16 Bit, signed Mapping"
|
||||||
|
14,Playback Speed (LSB),"16 Bit, signed Mapping"
|
||||||
|
15,Playback Position (MSB),"16 Bit, normalisiert"
|
||||||
|
16,Playback Position (LSB),"16 Bit, normalisiert"
|
||||||
|
17,In Point (MSB),"16 Bit, normalisiert"
|
||||||
|
18,In Point (LSB),"16 Bit, normalisiert"
|
||||||
|
19,Out Point (MSB),"16 Bit, normalisiert"
|
||||||
|
20,Out Point (LSB),"16 Bit, normalisiert"
|
||||||
|
21,Blend Mode,Enum
|
||||||
|
22,Transform Mode/Anchor,Enum
|
||||||
|
23,Position X (MSB),"16 Bit, signed"
|
||||||
|
24,Position X (LSB),"16 Bit, signed"
|
||||||
|
25,Position Y (MSB),"16 Bit, signed"
|
||||||
|
26,Position Y (LSB),"16 Bit, signed"
|
||||||
|
27,Scale X (MSB),16 Bit
|
||||||
|
28,Scale X (LSB),16 Bit
|
||||||
|
29,Scale Y (MSB),16 Bit
|
||||||
|
30,Scale Y (LSB),16 Bit
|
||||||
|
31,Rotation (MSB),16 Bit
|
||||||
|
32,Rotation (LSB),16 Bit
|
||||||
|
33,Crop Left,8 Bit
|
||||||
|
34,Crop Right,8 Bit
|
||||||
|
35,Crop Top,8 Bit
|
||||||
|
36,Crop Bottom,8 Bit
|
||||||
|
37,Hue,8 Bit
|
||||||
|
38,Saturation,8 Bit
|
||||||
|
39,Brightness,8 Bit
|
||||||
|
40,Contrast,8 Bit
|
||||||
|
41,FX1 Enable,Schalter
|
||||||
|
42,FX1 Plugin Select,"8 Bit, Show-Registry"
|
||||||
|
43,FX1 Mix,8 Bit
|
||||||
|
44,FX1 Parameter P1,8 Bit oder manifestgebundene Paare
|
||||||
|
45,FX1 Parameter P2,8 Bit oder manifestgebundene Paare
|
||||||
|
46,FX1 Parameter P3,8 Bit oder manifestgebundene Paare
|
||||||
|
47,FX1 Parameter P4,8 Bit oder manifestgebundene Paare
|
||||||
|
48,FX1 Parameter P5,8 Bit oder manifestgebundene Paare
|
||||||
|
49,FX1 Parameter P6,8 Bit oder manifestgebundene Paare
|
||||||
|
50,FX1 Parameter P7,8 Bit oder manifestgebundene Paare
|
||||||
|
51,FX1 Parameter P8,8 Bit oder manifestgebundene Paare
|
||||||
|
52,FX2 Enable,Schalter
|
||||||
|
53,FX2 Plugin Select,"8 Bit, Show-Registry"
|
||||||
|
54,FX2 Mix,8 Bit
|
||||||
|
55,FX2 Parameter P1,8 Bit oder manifestgebundene Paare
|
||||||
|
56,FX2 Parameter P2,8 Bit oder manifestgebundene Paare
|
||||||
|
57,FX2 Parameter P3,8 Bit oder manifestgebundene Paare
|
||||||
|
58,FX2 Parameter P4,8 Bit oder manifestgebundene Paare
|
||||||
|
59,FX2 Parameter P5,8 Bit oder manifestgebundene Paare
|
||||||
|
60,FX2 Parameter P6,8 Bit oder manifestgebundene Paare
|
||||||
|
61,FX2 Parameter P7,8 Bit oder manifestgebundene Paare
|
||||||
|
62,FX2 Parameter P8,8 Bit oder manifestgebundene Paare
|
||||||
|
63,Layer Retrigger/Reset,steigende Flanke
|
||||||
|
64,reserviert,muss neutral ignoriert werden
|
||||||
|
@@ -0,0 +1,33 @@
|
|||||||
|
channel,parameter,resolution_behavior
|
||||||
|
1,Master Intensity (MSB),16 Bit (mit Kanal 2)
|
||||||
|
2,Master Intensity (LSB),16 Bit
|
||||||
|
3,Blackout,"Trigger/Schalter, höchste Priorität"
|
||||||
|
4,Freeze Output,Schalter
|
||||||
|
5,Preset Bank,8 Bit
|
||||||
|
6,Preset Index (MSB),16 Bit (mit Kanal 7)
|
||||||
|
7,Preset Index (LSB),16 Bit
|
||||||
|
8,Preset Recall,"steigende Flanke, direkter Abruf ohne Cue-GO-Logik"
|
||||||
|
9,Transition Type,Enum
|
||||||
|
10,Transition Duration (MSB),"16 Bit, konfigurierter Maximalwert"
|
||||||
|
11,Transition Duration (LSB),16 Bit
|
||||||
|
12,Global Speed (MSB),16 Bit
|
||||||
|
13,Global Speed (LSB),16 Bit
|
||||||
|
14,BPM (MSB),16 Bit
|
||||||
|
15,BPM (LSB),16 Bit
|
||||||
|
16,Tap Tempo,steigende Flanke
|
||||||
|
17,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
|
||||||
|
18,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
|
||||||
|
19,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
|
||||||
|
20,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
|
||||||
|
21,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
|
||||||
|
22,Audio Reactive Enable,Schalter
|
||||||
|
23,Audio Master Gain,8 Bit
|
||||||
|
24,Automation/AI Enable,"nur Freigabe, keine Sicherheitsumgehung"
|
||||||
|
25,Output Test Pattern,Enum
|
||||||
|
26,Preview Enable,Schalter
|
||||||
|
27,Global Hue,8 Bit
|
||||||
|
28,Global Saturation,8 Bit
|
||||||
|
29,Fallback Preset,8 Bit
|
||||||
|
30,Release Manual Overrides,Trigger mit Schutzlogik
|
||||||
|
31,reserviert,muss neutral ignoriert werden
|
||||||
|
32,reserviert,muss neutral ignoriert werden
|
||||||
|
@@ -0,0 +1,5 @@
|
|||||||
|
"""hms_adaptive – Adaptive Quality Controller (PLAN.md §5.2)."""
|
||||||
|
|
||||||
|
from hms_adaptive.controller import AdaptiveQualityController, QualityLevel
|
||||||
|
|
||||||
|
__all__ = ["AdaptiveQualityController", "QualityLevel"]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Adaptive Quality Controller (PLAN.md §5.2).
|
||||||
|
|
||||||
|
Verbindliche Regeln:
|
||||||
|
- Hysterese: höchstens eine Stufenänderung je Regelintervall
|
||||||
|
- Abwertung schnell auf anhaltende Last, Aufwertung deutlich langsamer
|
||||||
|
- Mindesthaltezeit je Stufe gegen Oszillation (kein Pumpen)
|
||||||
|
- geschützte Größen bleiben unverändert: physische Auflösung, Refresh,
|
||||||
|
Layer-Reihenfolge, aktive Layer, DMX-Zuordnung, Parametersemantik
|
||||||
|
- Wechsel nur an Framegrenze atomar anwenden; alle Varianten vorab kompiliert
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
class QualityLevel(enum.IntEnum):
|
||||||
|
LOW = 0
|
||||||
|
MEDIUM = 1
|
||||||
|
HIGH = 2
|
||||||
|
|
||||||
|
|
||||||
|
# Abwertungsschwellen p99 (ms) je aktueller Stufe
|
||||||
|
_DOWNGRADE_MS = {QualityLevel.HIGH: 14.0, QualityLevel.MEDIUM: 15.0}
|
||||||
|
# Aufwertungsschwellen p99 (ms): deutliche Reserve nötig
|
||||||
|
_UPGRADE_MS = {QualityLevel.MEDIUM: 10.0, QualityLevel.LOW: 8.0}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdaptiveQualityController:
|
||||||
|
"""Stufenregler mit Hysterese und Mindesthaltezeit.
|
||||||
|
|
||||||
|
step(p99_frame_ms) führt höchstens eine Stufenänderung je Intervall aus
|
||||||
|
und gibt die aktuelle Stufe zurück; der Aufrufer wendet sie atomar an
|
||||||
|
der Framegrenze an. Upgrade braucht deutlich mehr gute Intervalle als
|
||||||
|
Downgrade schlechte, damit kein sichtbares Pumpen entsteht.
|
||||||
|
"""
|
||||||
|
|
||||||
|
interval_ms: int = 500
|
||||||
|
min_hold_ms: int = 2000
|
||||||
|
downgrade_intervals: int = 2
|
||||||
|
upgrade_intervals: int = 6
|
||||||
|
level: QualityLevel = QualityLevel.HIGH
|
||||||
|
_last_change_ns: int = field(default_factory=time.monotonic_ns, repr=False)
|
||||||
|
_bad_intervals: int = field(default=0, repr=False)
|
||||||
|
_good_intervals: int = field(default=0, repr=False)
|
||||||
|
_reason: str = ""
|
||||||
|
|
||||||
|
def step(self, p99_frame_ms: float) -> QualityLevel:
|
||||||
|
"""Ein Regelschritt; gibt die (ggf. geänderte) Stufe zurück."""
|
||||||
|
now = time.monotonic_ns()
|
||||||
|
held_ms = (now - self._last_change_ns) / 1_000_000
|
||||||
|
budget = _DOWNGRADE_MS.get(self.level)
|
||||||
|
if budget is not None and p99_frame_ms > budget:
|
||||||
|
self._bad_intervals += 1
|
||||||
|
self._good_intervals = 0
|
||||||
|
if (
|
||||||
|
self._bad_intervals >= self.downgrade_intervals
|
||||||
|
and held_ms >= self.min_hold_ms
|
||||||
|
and self.level is not QualityLevel.LOW
|
||||||
|
):
|
||||||
|
self.level = QualityLevel(self.level - 1)
|
||||||
|
self._last_change_ns = now
|
||||||
|
self._bad_intervals = 0
|
||||||
|
self._reason = f"p99 {p99_frame_ms:.2f}ms > budget {budget}ms"
|
||||||
|
else:
|
||||||
|
self._bad_intervals = 0
|
||||||
|
target = _UPGRADE_MS.get(self.level)
|
||||||
|
if target is not None and p99_frame_ms < target:
|
||||||
|
self._good_intervals += 1
|
||||||
|
if (
|
||||||
|
self._good_intervals >= self.upgrade_intervals
|
||||||
|
and held_ms >= self.min_hold_ms
|
||||||
|
and self.level is not QualityLevel.HIGH
|
||||||
|
):
|
||||||
|
self.level = QualityLevel(self.level + 1)
|
||||||
|
self._last_change_ns = now
|
||||||
|
self._good_intervals = 0
|
||||||
|
self._reason = f"p99 {p99_frame_ms:.2f}ms < reserve {target}ms"
|
||||||
|
else:
|
||||||
|
self._good_intervals = 0
|
||||||
|
return self.level
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_reason(self) -> str:
|
||||||
|
return self._reason
|
||||||
@@ -0,0 +1,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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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", opcode) + 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, 8)
|
||||||
|
if opcode != OPDMX:
|
||||||
|
return None
|
||||||
|
(protver,) = 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, 8)
|
||||||
|
if opcode != OPPOLL:
|
||||||
|
return None
|
||||||
|
(protver,) = 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", OPPOLLREPLY)
|
||||||
|
pkt += ip
|
||||||
|
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("<H", packet, 8)
|
||||||
|
if opcode != OPPOLLREPLY:
|
||||||
|
return None
|
||||||
|
ip = ".".join(str(b) for b in packet[10:14])
|
||||||
|
net_switch = packet[20] & 0x7F
|
||||||
|
sub_switch = packet[21] & 0x0F
|
||||||
|
short_name = packet[28:46].split(b"\x00")[0].decode("ascii", errors="replace")
|
||||||
|
long_name = packet[46:110].split(b"\x00")[0].decode("ascii", errors="replace")
|
||||||
|
node_report = packet[110:174].split(b"\x00")[0].decode("ascii", errors="replace")
|
||||||
|
num_ports = packet[175]
|
||||||
|
port_types = bytes(packet[176:180])
|
||||||
|
sw_in = bytes(packet[188:192])
|
||||||
|
sw_out = bytes(packet[192:196])
|
||||||
|
style = packet[202]
|
||||||
|
mac = bytes(packet[203:209])
|
||||||
|
bind_index = packet[209]
|
||||||
|
return PollReplyInfo(
|
||||||
|
ip=ip,
|
||||||
|
short_name=short_name,
|
||||||
|
long_name=long_name,
|
||||||
|
node_report=node_report,
|
||||||
|
style=style,
|
||||||
|
bind_index=bind_index,
|
||||||
|
net_switch=net_switch,
|
||||||
|
sub_switch=sub_switch,
|
||||||
|
num_ports=num_ports,
|
||||||
|
port_types=port_types,
|
||||||
|
sw_in=sw_in,
|
||||||
|
sw_out=sw_out,
|
||||||
|
mac=mac,
|
||||||
|
)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Art-Net-Empfänger (PLAN.md §16.1).
|
||||||
|
|
||||||
|
- UDP 6454, wählbare Schnittstelle, optional Sender-Allowlist
|
||||||
|
- ArtPoll → ArtPollReply als Media Server (Style 0x02)
|
||||||
|
- ArtDMX-Sequenznummern auswerten, soweit vorhanden
|
||||||
|
- mehrere Sender werden nicht still zusammengeführt: je Universe wird der
|
||||||
|
aktive Sender vermerkt; ein Senderwechsel wird protokolliert (§16.1, §16.2)
|
||||||
|
- Signalverlust je Universe konfigurierbar (hold/fade_to_scene/fade_to_black)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from hms_artnet.packets import build_artpoll_reply, parse_dmx, parse_poll
|
||||||
|
|
||||||
|
|
||||||
|
class LossBehavior(Enum):
|
||||||
|
"""Verhalten bei DMX-Signalverlust (§11.3)."""
|
||||||
|
|
||||||
|
HOLD = "hold"
|
||||||
|
FADE_TO_BLACK = "fade_to_black"
|
||||||
|
FADE_TO_SCENE = "fade_to_scene"
|
||||||
|
DISABLE_SOURCE = "disable_source"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DmxUpdate:
|
||||||
|
"""Ein DMX-Update je Universe; sequence=-1 und data=b'' = Signalverlust."""
|
||||||
|
|
||||||
|
universe: int
|
||||||
|
data: bytes
|
||||||
|
sender_ip: str
|
||||||
|
received_ns: int
|
||||||
|
sequence: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UniverseTelemetry:
|
||||||
|
universe: int
|
||||||
|
packets: int = 0
|
||||||
|
last_received_ns: int = 0
|
||||||
|
last_sender_ip: str = ""
|
||||||
|
sender_changed: int = 0
|
||||||
|
sequence_gaps: int = 0
|
||||||
|
loss_reported: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArtNetReceiver:
|
||||||
|
"""Asynchroner Art-Net-Empfänger (asyncio-Datagramm, ein Socket)."""
|
||||||
|
|
||||||
|
universes: set[int] = field(default_factory=set)
|
||||||
|
bind_host: str = "0.0.0.0"
|
||||||
|
port: int = 6454
|
||||||
|
short_name: str = "HMS MediaEngine"
|
||||||
|
long_name: str = "HMS MediaEngine Render Node"
|
||||||
|
node_ip: bytes = b"\x7f\x00\x00\x01"
|
||||||
|
mac: bytes = b"\x00\x00\x00\x00\x00\x00"
|
||||||
|
sender_allowlist: set[str] = field(default_factory=set)
|
||||||
|
timeout_ms: int = 2500
|
||||||
|
loss_behavior: LossBehavior = LossBehavior.HOLD
|
||||||
|
_transport: object | None = field(default=None, repr=False)
|
||||||
|
_telemetry: dict[int, UniverseTelemetry] = field(default_factory=dict, repr=False)
|
||||||
|
_last_sequence: dict[int, int] = field(default_factory=dict, repr=False)
|
||||||
|
_handlers: list[Callable[[DmxUpdate], None]] = field(default_factory=list, repr=False)
|
||||||
|
_watchdog_task: object | None = field(default=None, repr=False)
|
||||||
|
|
||||||
|
def on_dmx(self, handler: Callable[[DmxUpdate], None]) -> 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)
|
||||||
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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"}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user