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:
HMS MediaEngine Agent
2026-09-11 00:36:59 +02:00
commit 0922cc1d68
133 changed files with 7939 additions and 0 deletions
+31
View File
@@ -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/
+18
View File
@@ -0,0 +1,18 @@
# Changelog
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung: [SemVer](https://semver.org/).
## [Unreleased]
### Added
- Repository-Struktur gemäß Bauplan §8 (Eigentumsgrenzen)
- Pflichtdokumente STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADR-Vorlage
- ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows), ADR-0003 IPC TCP+MessagePack v1
- Kernpakete: `hms_protocol`, `hms_parameter`, `hms_artnet`, `hms_adaptive`, `hms_capabilities`, `hms_plugin_sdk`, `hms_domain`
- Minimaler Control Core (FastAPI REST + WebSocket, Commands mit Revision/Idempotenz)
- Renderer-Spike (D3D11-Primärpfad + ausdrücklich gekennzeichneter Dev-GL-Pfad)
- Beispielplugin `com.hms.fx.example_passthrough` (HLSL/GLSL/GLES)
- Tools: Art-Net-Emulator, Capability-Probe, Plugin-Validator
- Unit-/Integrationstests für Phase-0-Grundlage
+26
View File
@@ -0,0 +1,26 @@
# ERRORS
Fehlerverzeichnis gemäß PLAN.md §32.
Format je Fehler:
| Feld | Inhalt |
| --- | --- |
| ID | ERR-000 |
| Priorität | P0P3 |
| Reproduktion | Schritte |
| Erwartet | Verhalten |
| Tatsächlich | Verhalten |
| Plattform/Hardware | OS, GPU, Treiber |
| Logs/Screenshots | Pfad/Verweis |
| Ursache | Analyse |
| Fix-Commit | Commit-Hash |
| Regressionstest | Test-ID |
## Offene Fehler
Keine.
## Behobene Fehler
Keine.
+2831
View File
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
# HMS MediaEngine
**Arbeitstitel** der Produktname kann später ohne technische Auswirkung geändert werden.
Modularer Medienserver, VJ-System und generativer Licht-/Pixeleffekt-Server.
- **Primärplattform:** Windows 11 x64, portabel ohne Installation
- **Weitere Zielplattformen:** Linux x64, Raspberry Pi 5 / Linux ARM64
- **Steuerung:** lokale Webanwendung (Browser), Art-Net/DMX, spätere Timeline/Audio/KI
## Leitsatz
> **Python steuert. Native Bibliotheken decodieren. Die GPU rendert. Der Browser bedient.**
## Projektstatus
Das Projekt befindet sich in **Phase 0 technischer Spike und Go/No-Go**.
Aktueller Stand, Gates und nächste Aufgaben: [`STATUS.md`](STATUS.md)
Fehlerverfolgung: [`ERRORS.md`](ERRORS.md)
Messergebnisse: [`TEST_REPORT.md`](TEST_REPORT.md)
Bauplan (normativ): [`PLAN.md`](PLAN.md)
## Repository-Struktur (Eigentumsgrenzen)
```text
/
├─ apps/ launcher, control_server, renderer, web
├─ native/ render_bridge (Rust oder C++, Entscheidung per ADR)
├─ packages/ domain, protocol, parameter_engine, render_backend,
│ capabilities, adaptive_quality, plugin_sdk, artnet,
│ cluster, content_sync, timeline, audio_analysis,
│ persistence
├─ plugins/ builtin (generators, filters, transitions, outputs), examples
├─ schemas/ project, plugin, ipc, cluster, api
├─ fixture_profiles/ master32, layer64
├─ tests/ unit, integration, rendering, cluster, visual,
│ performance, portability, e2e
├─ tools/ media_probe, shader_validate, artnet_emulator,
│ capability_probe, cluster_test_node, fixture_generator
├─ build/ windows, linux, raspberry_pi
└─ docs/ architecture, adr, plugin-sdk, api, fixture,
performance, operator
```
Code darf nicht beliebig zwischen Paketen quer importiert werden.
## Entwicklung
```bash
# Python-Umgebung
uv sync
# Tests
uv run pytest
# Lint + Typprüfung
uv run ruff check .
```
Frontend (ab Phase 5): `pnpm` mit Lockfile, Vite-Build, siehe `apps/web`.
## Phasenmodell
Die Entwicklung folgt strikt dem Phasenmodell aus `PLAN.md` Abschnitt 31.
Jede Phase endet mit einem Gate; **keine neue Phase ohne grünes Gate.**
| Phase | Inhalt | Gate
| --- | --- | --- |
| 0 | Technischer Spike, Machbarkeitsnachweis, ADRs | Gate 0: D3D11-HW-Decode, GPU-Compositing, Adaptive Quality, Art-Net-Latenz, portable Auslieferung reproduzierbar grün
| 1 | Fundament: Supervisor, Control Core, Renderer, IPC, Node-Identität, Discovery, Paarung | Gate 1
| 2 | Medien-, Layer-, Basissync-Engine | Gate 2
| 3 | Plugin-SDK, Starterpaket Generatoren + Filter | Gate 3
| 4 | Art-Net-Fixtures Master32/Layer64 | Gate 4
| 5 | Vollständige Browser-Liveoberfläche | Gate 5 / V1.0-Kernrelease
| 6+ | Cue/Timeline, Audio, Mapping, Pixel-Ausgabe, KI, Pi 5, Härtung | eigene Gates
## Verbindliche Regeln (Auszug)
- Keine Pixelverarbeitung in Python-Schleifen.
- Kein CPU-Readback im normalen HDMI-Renderpfad (Windows: D3D11Memory durchgängig).
- Kein Mock als fertige Funktion gemeldet; Hardwaretests nur auf echter Hardware.
- Jede Architekturabweichung braucht ein ADR und Freigabe.
- Projektschema versioniert, Migrationen getestet.
## Dokumentation
- Architektur: `docs/architecture/`
- ADRs: `docs/adr/` (Vorlage: `docs/adr/_template.md`)
- Plugin-SDK: `docs/plugin-sdk/` (ab Phase 3)
- Fixture-Handbücher: `docs/fixture/` (ab Phase 4)
## Lizenz
TBD wird mit dem ersten Release entschieden (SBOM und Lizenzverzeichnis sind Teil der Release-Anforderungen, PLAN.md Abschnitt 30).
+40
View File
@@ -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. 13 erledigt, Nr. 415 in Arbeit):
- [x] Repository gemäß Eigentumsgrenzen initialisiert (§8)
- [x] Pflichtdokumente + ADR-Vorlage + ADRs 00010003 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. 45); 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. 1213)
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.
+47
View File
@@ -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.125.3.
## Offene Abweichungen
- Entwicklungsumgebung ist ein CPU-only-Linux-Container: keine D3D11-, Display- oder Art-Net-Hardwaremessung möglich. Kein Ergebnis in diesem Report darf als Hardwarenachweis gewertet werden.
View File
@@ -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()
View File
+10
View File
@@ -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"]
+111
View File
@@ -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)")
View File
+22
View File
@@ -0,0 +1,22 @@
"""hms_renderer Render-Worker-Spike (PLAN.md §6.1C, §12, §36 Nr. 46).
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",
]
+52
View File
@@ -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())
+79
View File
@@ -0,0 +1,79 @@
"""Renderer-Pipeline-Definitionen (PLAN.md §12, §13, §36 Nr. 45).
Windows-Primärpfad (§12.6): d3d11h264dec D3D11Memory d3d11compositor
d3d11videosink. Kein CPU-Rundweg (Decoder RAM Upload verboten).
DevGL-Pfad: Nur für Entwicklung/CI-Umgebungen ohne D3D11; ausdrücklich
gekennzeichnet, kein stiller Ersatz im Normalbetrieb (§5.2, §33).
"""
from __future__ import annotations
import shutil
from dataclasses import dataclass
@dataclass(frozen=True)
class D3D11Pipeline:
"""Primärpipeline Windows: durchgängig D3D11Memory."""
video_a: str
video_b: str
width: int = 1920
height: int = 1080
fullscreen: bool = True
def launch_string(self) -> str:
sink = "d3d11videosink fullscreen=true" if self.fullscreen else "d3d11videosink"
# Zwei Quellen → Compositor → Ausgabe (§36 Nr. 5: zwei Videos GPU-mischen)
return (
f"d3d11compositor name=mix sink_0::xpos=0 sink_0::ypos=0 "
f"sink_0::width={self.width // 2} sink_0::height={self.height} "
f"sink_1::xpos={self.width // 2} sink_1::ypos=0 "
f"sink_1::width={self.width // 2} sink_1::height={self.height} ! "
f"d3d11convert ! video/x-raw(memory:D3D11Memory),format=RGBA,"
f"width={self.width},height={self.height} ! {sink} "
f"uridecodebin uri=file:///{self.video_a} ! queue ! "
f"d3d11convert ! mix. "
f"uridecodebin uri=file:///{self.video_b} ! queue ! "
f"d3d11convert ! mix."
)
@dataclass(frozen=True)
class DevGLPipeline:
"""Entwicklungspfad ohne D3D11 (explizit gekennzeichnet, kein Normalpfad).
Nur für CI/Dev ohne Windows-GPU; die Backend-Schnittstelle bleibt
identisch (§12.6)."""
video_a: str
video_b: str
width: int = 960
height: int = 540
def launch_string(self) -> str:
return (
f"glvideomixer name=mix ! glimagesink "
f"uridecodebin uri=file:///{self.video_a} ! queue ! glupload ! mix. "
f"uridecodebin uri=file:///{self.video_b} ! queue ! glupload ! mix."
)
def build_single_video_pipeline(video: str, d3d11: bool = True) -> str:
"""Minimale Einzelquellen-Pipeline (§36 Nr. 4: Testvideo → Vollbild)."""
if d3d11:
return f"uridecodebin uri=file:///{video} ! d3d11convert ! d3d11videosink fullscreen=true"
return f"uridecodebin uri=file:///{video} ! glupload ! glimagesink"
def build_compositor_pipeline(video_a: str, video_b: str, d3d11: bool = True) -> str:
"""Zwei-Quellen-Compositing (§36 Nr. 5: GPU-Mischen ohne CPU-Readback)."""
if d3d11:
return D3D11Pipeline(video_a=video_a, video_b=video_b).launch_string()
return DevGLPipeline(video_a=video_a, video_b=video_b).launch_string()
def gst_available() -> bool:
"""True, wenn ein GStreamer-CLI auf dem System liegt (Diagnose, kein Fake)."""
return shutil.which("gst-launch-1.0") is not None
View File
View File
View File
View File
+46
View File
@@ -0,0 +1,46 @@
# GStreamer-Bündelung Windows (Pin)
ADR-0002. Ziel: portables `runtime/gstreamer` ohne Systeminstallation (PLAN.md §3.1, §30.2).
## Pin
- **Version:** 1.28.6 (aktuelle stabile 1.28-Serie, Stand 2026-09-10, Quelle: <https://gstreamer.freedesktop.org/download/>)
- **Distribution:** offizielle MSVC-Binaries, x86_64 (`gstreamer-1.0-msvc-x86_64-1.28.6.msi`)
- **SHA-256:** nach dem ersten Bündelungs-Build manifestieren (§30.1)
## Minimal gebündelte Plugin-Untermenge (Phase 0)
Ziel: H.264-MP4-Testclip → D3D11-Decode → Compositing → Vollbildausgabe.
| Komponente | Paket | Enthält | Zweck |
| --- | --- | --- | --- |
| libgstreamer-1.0-0.dll | gstreamer core | | Kern |
| coreelements | gst-plugins-core | `filesrc`, `queue`, `decodebin3`-Basen | Container/Datei |
| typefindfunctions | gst-plugins-core | Typenerkennung | MP4-Erkennung |
| isomp4 | gst-plugins-good | `qtdemux` | MP4-Demux |
| videoparsersbad | gst-plugins-bad | `h264parse` | H.264-Parsing vor Decoder |
| playback | gst-plugins-base | `uridecodebin` | bequeme Quelle (Spike) |
| d3d11 | gst-plugins-bad | `d3d11h264dec`, `d3d11h265dec`, `d3d11convert`, `d3d11compositor`, `d3d11videosink` | GPU-Pfad (§12.6) |
| video/x-raw Basen | gst-plugins-base | `videoconvert` (nur Fallback, nicht Normalpfad) | nur Diagnose |
Nicht gebündelt (V1-Spike): Netzwerk-Quellen, NDI, Capture, Software-Codecs außer für expliziten Fallback.
## Umgebungsvariablen beim Start (Launcher, §9.2)
```text
GST_PLUGIN_PATH_1_0=<app>/runtime/gstreamer/lib/gstreamer-1.0
PATH=<app>/runtime/gstreamer/bin;%PATH%
GST_PLUGIN_SYSTEM_PATH_1_0= (leer setzen, um System-Plugins zu unterdrücken)
```
## Validierung
1. Saubere Windows-VM ohne GStreamer (§29.6)
2. `gst-inspect-1.0 d3d11h264dec` muss die Elemente aus dem Bündel listen
3. Testpipeline läuft; GPU-Treiber ist die einzige Systemabhängigkeit
4. MSI-Binärdateien und DLL-Set als SHA-256-Manifest festhalten
## Offene Punkte
- [ ] Bündelungsskript (MSI still entpacken, Untermenge kopieren) Phase-0-Aufgabe mit Packaging-ADR-0005
- [ ] VC-Runtime-Redistributable-Handling → `runtime/vc-runtime` (§9)
View File
+27
View File
@@ -0,0 +1,27 @@
# ADR-0001: Python-Version pinnen
- **Status:** Angenommen
- **Datum:** 2026-09-10
- **Phase:** 0
- **Bauplan:** §7 („Python, unterstützte Version exakt pinnen“)
## Entscheidung
Der Control Core und alle Python-Pakete pinnen **Python 3.13** (`requires-python = "==3.13.*"` in `pyproject.toml`).
## Kontext
Der Bauplan verlangt eine exakt gepinnte Python-Version. 3.13 ist die aktuelle stabile Version mit ausgereiftem `asyncio`, breitem Wheel-Support für FastAPI/Pydantic und PyGObject-Kompatibilität für die GStreamer-Bindings.
## Alternativen
- 3.12: kein messbarer Vorteil, kürzeres Supportfenster als 3.13.
- 3.14: bei Projektstart zu neu für stabile Binärwheels aller Abhängigkeiten.
## Folgen
- uv-Lockfile und CI pinnen 3.13; Versionssprünge erfolgen bewusst per ADR-Änderung.
## Freigabe
- Auftragsvorgabe „exakt pinnen“ aus PLAN.md §7; Umsetzung ohne Abweichung.
+28
View File
@@ -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.
+23
View File
@@ -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.
+17
View File
@@ -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)
+30
View File
@@ -0,0 +1,30 @@
# ADR-NNNN: <Titel>
- **Status:** Vorgeschlagen | Angenommen | Ersetzt (durch ADR-xxxx) | Verworfen
- **Datum:** YYYY-MM-DD
- **Phase:** (z. B. Phase 0)
- **Betroffene Bauplan-Abschnitte:** (z. B. §7.1, §31 Phase 0)
## Kontext
Welches Problem, welche Optionen, welche Messungen/Zahlen liegen vor?
## Entscheidung
Die gewählte Option, klar und eindeutig formuliert.
## Alternativen
Die geprüften Alternativen und warum sie verworfen wurden.
## Folgen
Positiv, negativ, Risiken, Migrationspfad, Testfolgen.
## Messwerte / Nachweise
Verweis auf TEST_REPORT.md oder Rohdaten, die die Entscheidung stützen.
## Freigabe
Auftraggeber: (Freigabe erforderlich gemäß PLAN.md §1)
View File
View File
+41
View File
@@ -0,0 +1,41 @@
# Architekturüberblick (Phase 0)
Verbindliche Referenz: `PLAN.md`. Diese Seite fasst Prozess- und Besitzgrenzen zusammen.
## Prozesse (§6.1)
| Prozess | Technologie | Aufgabe |
| --- | --- | --- |
| Launcher/Supervisor | Python (Phase 1) | Start, Portwahl, portable Pfade, Heartbeat, kontrolliertes Beenden |
| Control Core | Python, asyncio, FastAPI, Pydantic, SQLite/WAL (ab Phase 1) | autoritativer Zustand, REST/WS, Art-Net, Persistenz |
| Render Worker | Python-Orchestrator + GStreamer + nativer Renderkern (ADR-0004 offen) | Decode, GPU-Compositing, Ausgabe, Telemetrie |
| Web-Frontend | TypeScript, Vite (Phase 5, ADR-0006 offen) | Bedienoberfläche; niemals Videoausgang (§3.2) |
## Besitzgrenzen (§8)
- Jedes Paket hat eindeutige `hms_*`-Namen; kein Quereinbau zwischen `packages/*`.
- Control Core und Renderer verbinden ausschließlich über das versionierte IPC (ADR-0003).
- Der Renderer erhält pro Frame einen unveränderlichen Parametersnapshot (§11.4).
## Kernregeln (§33, Auszug)
- Python steuert, decodiert nicht und rendert keine Pixel.
- Windows: D3D11Memory durchgängig; kein CPU-Rundweg im Normalpfad.
- Plugins: versioniertes Manifest, kein Datei-/DB-Zugriff, Quarantäne bei Fehler.
- Alle Steuerquellen (Browser, Art-Net, später Timeline/Audio/KI) laufen über die zentrale Parameter-Engine.
## Steuerfluss Phase 0
```text
Lichtpult/Emulator → ArtDMX (UDP 6454) → hms_artnet.receiver
→ Control Core (hms_parameter.engine: Priorität CONSOLE)
→ IPC-Snapshot → Renderer (GStreamer-D3D11) → Opacity/Parameter an Frame-Grenze
Browser → REST/WebSocket → derselbe Command-/Parameter-Pfad (Priorität WEB)
```
Beide Quellen adressieren denselben Parametersatz; es gibt genau eine autoritative Instanz (Control Core).
## Phase-0-Spike
`hms_renderer` definiert die Pipelines als Launch-Strings; Ausführung und Messung erfolgen auf Referenz-Windows-Hardware (Gate 0). Messprotokoll: `TEST_REPORT.md`.
View File
View File
View File
View File
View File
+65
View File
@@ -0,0 +1,65 @@
channel,parameter,resolution_behavior
1,Layer Enable,Schalter
2,Opacity (MSB),16 Bit
3,Opacity (LSB),16 Bit
4,Source Type,Enum: Media/Generator/Live/Solid
5,Media Bank,8 Bit
6,Media Folder,8 Bit
7,Media/Plugin Index (MSB),16 Bit
8,Media/Plugin Index (LSB),16 Bit
9,Load/Commit Selection,steigende Flanke
10,Transport,Enum: Stop/Play/Pause/Retrigger
11,Loop Mode,Enum
12,Playback Direction/Mode,Enum
13,Playback Speed (MSB),"16 Bit, signed Mapping"
14,Playback Speed (LSB),"16 Bit, signed Mapping"
15,Playback Position (MSB),"16 Bit, normalisiert"
16,Playback Position (LSB),"16 Bit, normalisiert"
17,In Point (MSB),"16 Bit, normalisiert"
18,In Point (LSB),"16 Bit, normalisiert"
19,Out Point (MSB),"16 Bit, normalisiert"
20,Out Point (LSB),"16 Bit, normalisiert"
21,Blend Mode,Enum
22,Transform Mode/Anchor,Enum
23,Position X (MSB),"16 Bit, signed"
24,Position X (LSB),"16 Bit, signed"
25,Position Y (MSB),"16 Bit, signed"
26,Position Y (LSB),"16 Bit, signed"
27,Scale X (MSB),16 Bit
28,Scale X (LSB),16 Bit
29,Scale Y (MSB),16 Bit
30,Scale Y (LSB),16 Bit
31,Rotation (MSB),16 Bit
32,Rotation (LSB),16 Bit
33,Crop Left,8 Bit
34,Crop Right,8 Bit
35,Crop Top,8 Bit
36,Crop Bottom,8 Bit
37,Hue,8 Bit
38,Saturation,8 Bit
39,Brightness,8 Bit
40,Contrast,8 Bit
41,FX1 Enable,Schalter
42,FX1 Plugin Select,"8 Bit, Show-Registry"
43,FX1 Mix,8 Bit
44,FX1 Parameter P1,8 Bit oder manifestgebundene Paare
45,FX1 Parameter P2,8 Bit oder manifestgebundene Paare
46,FX1 Parameter P3,8 Bit oder manifestgebundene Paare
47,FX1 Parameter P4,8 Bit oder manifestgebundene Paare
48,FX1 Parameter P5,8 Bit oder manifestgebundene Paare
49,FX1 Parameter P6,8 Bit oder manifestgebundene Paare
50,FX1 Parameter P7,8 Bit oder manifestgebundene Paare
51,FX1 Parameter P8,8 Bit oder manifestgebundene Paare
52,FX2 Enable,Schalter
53,FX2 Plugin Select,"8 Bit, Show-Registry"
54,FX2 Mix,8 Bit
55,FX2 Parameter P1,8 Bit oder manifestgebundene Paare
56,FX2 Parameter P2,8 Bit oder manifestgebundene Paare
57,FX2 Parameter P3,8 Bit oder manifestgebundene Paare
58,FX2 Parameter P4,8 Bit oder manifestgebundene Paare
59,FX2 Parameter P5,8 Bit oder manifestgebundene Paare
60,FX2 Parameter P6,8 Bit oder manifestgebundene Paare
61,FX2 Parameter P7,8 Bit oder manifestgebundene Paare
62,FX2 Parameter P8,8 Bit oder manifestgebundene Paare
63,Layer Retrigger/Reset,steigende Flanke
64,reserviert,muss neutral ignoriert werden
1 channel parameter resolution_behavior
2 1 Layer Enable Schalter
3 2 Opacity (MSB) 16 Bit
4 3 Opacity (LSB) 16 Bit
5 4 Source Type Enum: Media/Generator/Live/Solid
6 5 Media Bank 8 Bit
7 6 Media Folder 8 Bit
8 7 Media/Plugin Index (MSB) 16 Bit
9 8 Media/Plugin Index (LSB) 16 Bit
10 9 Load/Commit Selection steigende Flanke
11 10 Transport Enum: Stop/Play/Pause/Retrigger
12 11 Loop Mode Enum
13 12 Playback Direction/Mode Enum
14 13 Playback Speed (MSB) 16 Bit, signed Mapping
15 14 Playback Speed (LSB) 16 Bit, signed Mapping
16 15 Playback Position (MSB) 16 Bit, normalisiert
17 16 Playback Position (LSB) 16 Bit, normalisiert
18 17 In Point (MSB) 16 Bit, normalisiert
19 18 In Point (LSB) 16 Bit, normalisiert
20 19 Out Point (MSB) 16 Bit, normalisiert
21 20 Out Point (LSB) 16 Bit, normalisiert
22 21 Blend Mode Enum
23 22 Transform Mode/Anchor Enum
24 23 Position X (MSB) 16 Bit, signed
25 24 Position X (LSB) 16 Bit, signed
26 25 Position Y (MSB) 16 Bit, signed
27 26 Position Y (LSB) 16 Bit, signed
28 27 Scale X (MSB) 16 Bit
29 28 Scale X (LSB) 16 Bit
30 29 Scale Y (MSB) 16 Bit
31 30 Scale Y (LSB) 16 Bit
32 31 Rotation (MSB) 16 Bit
33 32 Rotation (LSB) 16 Bit
34 33 Crop Left 8 Bit
35 34 Crop Right 8 Bit
36 35 Crop Top 8 Bit
37 36 Crop Bottom 8 Bit
38 37 Hue 8 Bit
39 38 Saturation 8 Bit
40 39 Brightness 8 Bit
41 40 Contrast 8 Bit
42 41 FX1 Enable Schalter
43 42 FX1 Plugin Select 8 Bit, Show-Registry
44 43 FX1 Mix 8 Bit
45 44 FX1 Parameter P1 8 Bit oder manifestgebundene Paare
46 45 FX1 Parameter P2 8 Bit oder manifestgebundene Paare
47 46 FX1 Parameter P3 8 Bit oder manifestgebundene Paare
48 47 FX1 Parameter P4 8 Bit oder manifestgebundene Paare
49 48 FX1 Parameter P5 8 Bit oder manifestgebundene Paare
50 49 FX1 Parameter P6 8 Bit oder manifestgebundene Paare
51 50 FX1 Parameter P7 8 Bit oder manifestgebundene Paare
52 51 FX1 Parameter P8 8 Bit oder manifestgebundene Paare
53 52 FX2 Enable Schalter
54 53 FX2 Plugin Select 8 Bit, Show-Registry
55 54 FX2 Mix 8 Bit
56 55 FX2 Parameter P1 8 Bit oder manifestgebundene Paare
57 56 FX2 Parameter P2 8 Bit oder manifestgebundene Paare
58 57 FX2 Parameter P3 8 Bit oder manifestgebundene Paare
59 58 FX2 Parameter P4 8 Bit oder manifestgebundene Paare
60 59 FX2 Parameter P5 8 Bit oder manifestgebundene Paare
61 60 FX2 Parameter P6 8 Bit oder manifestgebundene Paare
62 61 FX2 Parameter P7 8 Bit oder manifestgebundene Paare
63 62 FX2 Parameter P8 8 Bit oder manifestgebundene Paare
64 63 Layer Retrigger/Reset steigende Flanke
65 64 reserviert muss neutral ignoriert werden
View File
+33
View File
@@ -0,0 +1,33 @@
channel,parameter,resolution_behavior
1,Master Intensity (MSB),16 Bit (mit Kanal 2)
2,Master Intensity (LSB),16 Bit
3,Blackout,"Trigger/Schalter, höchste Priorität"
4,Freeze Output,Schalter
5,Preset Bank,8 Bit
6,Preset Index (MSB),16 Bit (mit Kanal 7)
7,Preset Index (LSB),16 Bit
8,Preset Recall,"steigende Flanke, direkter Abruf ohne Cue-GO-Logik"
9,Transition Type,Enum
10,Transition Duration (MSB),"16 Bit, konfigurierter Maximalwert"
11,Transition Duration (LSB),16 Bit
12,Global Speed (MSB),16 Bit
13,Global Speed (LSB),16 Bit
14,BPM (MSB),16 Bit
15,BPM (LSB),16 Bit
16,Tap Tempo,steigende Flanke
17,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
18,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
19,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
20,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
21,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
22,Audio Reactive Enable,Schalter
23,Audio Master Gain,8 Bit
24,Automation/AI Enable,"nur Freigabe, keine Sicherheitsumgehung"
25,Output Test Pattern,Enum
26,Preview Enable,Schalter
27,Global Hue,8 Bit
28,Global Saturation,8 Bit
29,Fallback Preset,8 Bit
30,Release Manual Overrides,Trigger mit Schutzlogik
31,reserviert,muss neutral ignoriert werden
32,reserviert,muss neutral ignoriert werden
1 channel parameter resolution_behavior
2 1 Master Intensity (MSB) 16 Bit (mit Kanal 2)
3 2 Master Intensity (LSB) 16 Bit
4 3 Blackout Trigger/Schalter, höchste Priorität
5 4 Freeze Output Schalter
6 5 Preset Bank 8 Bit
7 6 Preset Index (MSB) 16 Bit (mit Kanal 7)
8 7 Preset Index (LSB) 16 Bit
9 8 Preset Recall steigende Flanke, direkter Abruf ohne Cue-GO-Logik
10 9 Transition Type Enum
11 10 Transition Duration (MSB) 16 Bit, konfigurierter Maximalwert
12 11 Transition Duration (LSB) 16 Bit
13 12 Global Speed (MSB) 16 Bit
14 13 Global Speed (LSB) 16 Bit
15 14 BPM (MSB) 16 Bit
16 15 BPM (LSB) 16 Bit
17 16 Tap Tempo steigende Flanke
18 17 reserviert (Cue/Timeline-Erweiterung) im MVP neutral ignorieren
19 18 reserviert (Cue/Timeline-Erweiterung) im MVP neutral ignorieren
20 19 reserviert (Cue/Timeline-Erweiterung) im MVP neutral ignorieren
21 20 reserviert (Cue/Timeline-Erweiterung) im MVP neutral ignorieren
22 21 reserviert (Cue/Timeline-Erweiterung) im MVP neutral ignorieren
23 22 Audio Reactive Enable Schalter
24 23 Audio Master Gain 8 Bit
25 24 Automation/AI Enable nur Freigabe, keine Sicherheitsumgehung
26 25 Output Test Pattern Enum
27 26 Preview Enable Schalter
28 27 Global Hue 8 Bit
29 28 Global Saturation 8 Bit
30 29 Fallback Preset 8 Bit
31 30 Release Manual Overrides Trigger mit Schutzlogik
32 31 reserviert muss neutral ignoriert werden
33 32 reserviert muss neutral ignoriert werden
View File
View File
@@ -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
View File
+36
View File
@@ -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",
]
+111
View File
@@ -0,0 +1,111 @@
"""DMX→Parameter-Mapping (PLAN.md §16.416.6, §36 Nr. 8).
Bildet DMX-Kanäle eines Universes auf stabile Parameterpfade ab:
- 8-Bit: Byte / 255
- 16-Bit: (MSB << 8 | LSB) / 65535, MSB zuerst (DMX-Konvention)
- Flankenerkennung für Trigger (steigende Flanke, §16.3/§16.5)
- Signalverlust je konfigurierter Policy; HOLD ist V1-Standard (§11.3)
Alle Werte fließen ausschließlich über die Parameter-Engine in den Control
Core (§11); der Renderer wird nie direkt berührt.
"""
from __future__ import annotations
from dataclasses import dataclass
from hms_parameter.engine import ControlSource, ParameterEngine
from hms_parameter.paths import layer_opacity_path
from hms_artnet.receiver import DmxUpdate, LossBehavior
class RisingEdge:
"""Erkennt steigende Flanken über einer Schwelle (§16.5).
Ein Trigger ist ein Ereignis, kein Dauerzustand: derselbe gehaltene
Faderwert löst genau einmal aus; erst nach Rückkehr unter die Schwelle
kann erneut getriggert werden.
"""
def __init__(self, threshold: int = 64) -> None:
if not 0 <= threshold <= 255:
raise ValueError("threshold must be 0..255")
self.threshold = threshold
self._was_active = False
def feed(self, value: int) -> bool:
active = value >= self.threshold
triggered = active and not self._was_active
self._was_active = active
return triggered
@dataclass(frozen=True)
class LayerDmxMapping:
"""Layer-Fixture-Belegung (Auszug Layer64, §16.4).
base_address: 1-basierte DMX-Startadresse des Layer-Fixtures
Kanäle relativ: 1 = Enable, 23 = Opacity (16 Bit, MSB zuerst)
"""
universe: int
base_address: int
composition_id: str
layer_id: str
loss_behavior: LossBehavior = LossBehavior.HOLD
def __post_init__(self) -> None:
if not 0 <= self.universe < 0x8000:
raise ValueError("universe must be 0..0x7FFF")
if not 1 <= self.base_address <= 512 - 2:
raise ValueError("base_address must leave room for channels 1..3")
@property
def enable_path(self) -> str:
return f"composition/{self.composition_id}/layer/{self.layer_id}/enabled"
@property
def opacity_path(self) -> str:
return layer_opacity_path(self.composition_id, self.layer_id)
class DmxLayerMapper:
"""Wandelt DmxUpdates eines Universes in Parameter-Engine-Werte.
Phase-0-Umfang (§36 Nr. 8): DMX-Kanal auf Layer-Opacity mappen.
Media-Auswahl mit Load/Commit-Semantik (§16.5) folgt in Phase 2;
RisingEdge ist bereits getestet verfügbar.
"""
def __init__(
self,
mapping: LayerDmxMapping,
engine: ParameterEngine,
source: ControlSource = ControlSource.CONSOLE,
) -> None:
self._mapping = mapping
self._engine = engine
self._source = source
def _channel(self, data: bytes, relative: int) -> int:
"""Liest Kanal relativ zur Base-Adresse (1-basiert); 0 wenn zu kurz."""
idx = self._mapping.base_address - 1 + (relative - 1)
if 0 <= idx < len(data):
return data[idx]
return 0
def handle(self, update: DmxUpdate) -> None:
if update.universe != self._mapping.universe:
return
if update.sequence == -1 and not update.data:
# Signalverlust (§11.3, §16.1): Policy anwenden, niemals still
if self._mapping.loss_behavior is LossBehavior.FADE_TO_BLACK:
self._engine.release(self._mapping.opacity_path, self._source)
self._engine.release(self._mapping.enable_path, self._source)
# HOLD: letzten Zustand behalten keine Aktion
return
enable = 1.0 if self._channel(update.data, 1) >= 128 else 0.0
opacity = ((self._channel(update.data, 2) << 8) | self._channel(update.data, 3)) / 65535.0
self._engine.set_value(self._mapping.enable_path, enable, self._source)
self._engine.set_value(self._mapping.opacity_path, opacity, self._source)
+225
View File
@@ -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,
)
+169
View File
@@ -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)
View File
View File
@@ -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,
}
View File
View File
View File
+5
View File
@@ -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"]
+53
View File
@@ -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
View File
@@ -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"
View File
View File
@@ -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.214.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)
View File
@@ -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}"
)
+61
View File
@@ -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)
View File
View File
View File
View File
View File
@@ -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;
}
+47
View File
@@ -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"]
View File
View File
@@ -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"}
}
}
View File
+18
View File
@@ -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
}
View File
@@ -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